* feat(system): read-only health check and truthful overview counts POST /prompt_manager/system/health runs the health check off-loop as a read registry job of kind "health" (one at a time, beside a scan, not cancellable, no file outcomes) and keeps the last report; GET returns it or null. The check reads one snapshot on its own mode=ro connection, closed in finally: integrity_check(100), foreign_key_check, stored hashes against utils.hashing, duplicate pair rows, the legacy consistency queries (now shared with check_consistency), and image links whose file is gone, counted only under readable sources. An unavailable source, or the absent Needs-review queue, makes its check unavailable and the report incomplete instead of producing a count. GET /prompt_manager/system/overview returns Library-eligible positive prompts and active negative variants, indexed images, the last health summary or null, backup null, the Needs-review count null, the effective sources with whether they were auto-detected, and the thumbnail plan's missing count. GET /prompt_manager/system/statistics lists runs and average rating per category of Library parents, with the /stats totals. * feat(system): native System route, drafts and shared review dialog - #/system/<section> renders the concept's System shell: section nav, settings layout and Save row with truthful status (D92); each section not delivered yet hands off to its legacy tools (D94). Overview's System card opens #/system/overview (Q12). - A module-level draft store saves browser, then host (expected_revision), then runtime; failed domains stay dirty, a stale revision is a conflict. - System row builders (setting, operation, section nav) on the scope. - openReviewDialog and backupNotice extracted from the scan review, which is now a thin caller with identical markup. - Overview images gain a "rated" count (images whose linked prompt is rated); the missing-files health check reports per source, so partial availability is visible. * feat(system): native Overview with health report and statistics System > Overview renders the concept's metrics and its Library health and Workspace preferences surfaces from GET /system/overview: real counts, "Not checked" until a health check ran, no invented review count, backup or thumbnail status (D93). Explore opens "Usage statistics" with the real runs and ratings per category and the Library totals (D96); the statistics route now also returns rated_images. Maintenance shows the concept's notice with Run health checks above its hand-off: the read-only check runs as a registry read job and opens the "Health report" with a chip per real result, missing files per source (D95). A load failure shows Retry and no zeros; late answers never repaint a newer section. Adds the System concept comparison (shell, Overview, both dialogs, the notice, mutation checks), the System workflow spec and its fixture hooks, and the TESTING.md "Native System" section. * test(system): coverage floors for the native Overview Adds the system-overview.mjs baseline and raises the system.mjs floors to their measured coverage; no other floor changes. * fix(system): keep health reports per database and off the event loop - A restore that replaces the database forgets the stored health report, and a check that started before the swap is not kept (database generation stamped at start). - The Overview no longer walks every source for the missing-thumbnail count; the page reads /images/thumbnails/status on its own. - Image sources for the health check and the Overview resolve in the executor; a failure to start the check answers the coded envelope. - Any exception fails only its own check; missing files reads its rows in the snapshot and stats the files after it ended. - The read-only URI keeps Windows UNC paths (empty authority). - An integrity count at SQLite's limit is flagged as capped. * fix(system): thumbnail row read, tracked health job and guarded host save - The Overview's thumbnail row is its own read of the thumbnail status: "Checking thumbnails…" until it answers, unavailable when it fails. - The health job is tracked in the Activity store (new store.track) and followed through its event stream instead of polling every 150 ms. - A host save before a read returned a revision fails without a request; System reads the host values on mount and after a conflict. - "100+ problems found" when the integrity count is capped. - D-NEW-S2-5 is D123 everywhere; the unused Overview hand-off is gone. - The concept spec builds the health report from the concept's own rows and asserts the full unmapped path list; surface tops pin the 18px offset. - TESTING.md: D96's reason, the read-only (WAL/SHM) standard, the UNC URI and the new behavior. Coverage floors raised for the touched files. * fix(system): make two health tests portable to Windows - Windows forbids "?" in file names, so the URI-character open test uses "#", "%" and a space there. - ntpath.realpath on Python 3.11+ returns a NUL-byte path instead of raising, so the bad-path test raises as POSIX does to reach the missing-files failure path on every platform.
262 lines
14 KiB
JavaScript
262 lines
14 KiB
JavaScript
// System draft store (#249 System S2 Task 5): one Save row for browser, host and runtime fields (concept app.js
|
|
// renderSystem `.save-row` 705, `save-settings` 1320). It saves browser, then host with the GET revision, then runtime;
|
|
// a domain that fails keeps its fields dirty, and the status copy (D92) says what really happened.
|
|
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
|
|
const source = import('../../web/js/workspace/system-state.mjs');
|
|
|
|
/** Adapters that record every call; `host` answers like POST /prompt_manager/settings. */
|
|
function adapters({ browser = {}, host = {}, runtime = {}, revision = 'sha256:one' } = {}) {
|
|
const calls = [];
|
|
const outcome = { browser: null, host: null, runtime: null };
|
|
return {
|
|
calls,
|
|
outcome,
|
|
readBrowser: () => { calls.push(['readBrowser']); return { ...browser }; },
|
|
readHost: async () => { calls.push(['readHost']); return { values: { ...host }, revision }; },
|
|
readRuntime: async () => { calls.push(['readRuntime']); return { ...runtime }; },
|
|
writeBrowser: async changes => {
|
|
calls.push(['writeBrowser', changes]);
|
|
if (outcome.browser) return outcome.browser(changes);
|
|
return { persisted: true };
|
|
},
|
|
writeHost: async (changes, options) => {
|
|
calls.push(['writeHost', changes, options]);
|
|
if (outcome.host) return outcome.host(changes, options);
|
|
return { success: true, persisted: true, revision: 'sha256:two', runtime_applied: true };
|
|
},
|
|
writeRuntime: async changes => {
|
|
calls.push(['writeRuntime', changes]);
|
|
if (outcome.runtime) return outcome.runtime(changes);
|
|
return { success: true };
|
|
},
|
|
};
|
|
}
|
|
|
|
async function loaded(domains) {
|
|
const { createSystemDrafts } = await source;
|
|
const drafts = createSystemDrafts(domains);
|
|
await Promise.all(['browser', 'host', 'runtime'].map(domain => drafts.load(domain)));
|
|
domains.calls.length = 0;
|
|
return drafts;
|
|
}
|
|
|
|
test('sections follow the concept order and the production hashes', async () => {
|
|
const { SYSTEM_SECTIONS, isSystemSection } = await source;
|
|
assert.deepEqual(SYSTEM_SECTIONS.map(([id, label]) => [id, label]), [
|
|
['overview', 'Overview'], ['appearance', 'Appearance'], ['sources', 'Sources & scans'],
|
|
['integrations', 'Integrations'], ['tagging', 'Auto-tagging'], ['maintenance', 'Maintenance'],
|
|
['backups', 'Backup & export'], ['diagnostics', 'Diagnostics & logs'],
|
|
]);
|
|
assert.equal(isSystemSection('backups'), true);
|
|
for (const value of ['general', '', 'constructor', '__proto__', undefined]) assert.equal(isSystemSection(value), false, value);
|
|
});
|
|
|
|
test('drafts read baselines and report dirty only for changed values', async () => {
|
|
const domains = adapters({ browser: { layout: 'grid' }, host: { result_timeout: 30 } });
|
|
const drafts = await loaded(domains);
|
|
assert.equal(drafts.get('browser', 'layout'), 'grid');
|
|
assert.equal(drafts.get('host', 'result_timeout'), 30);
|
|
assert.equal(drafts.dirty(), false);
|
|
drafts.set('host', 'result_timeout', 45);
|
|
assert.equal(drafts.get('host', 'result_timeout'), 45);
|
|
assert.equal(drafts.dirty(), true);
|
|
assert.equal(drafts.dirty('host'), true);
|
|
assert.equal(drafts.dirty('browser'), false);
|
|
// Setting the saved value back is clean again.
|
|
drafts.set('host', 'result_timeout', 30);
|
|
assert.equal(drafts.dirty(), false);
|
|
assert.throws(() => drafts.set('elsewhere', 'x', 1), TypeError);
|
|
assert.throws(() => drafts.get('elsewhere', 'x'), TypeError);
|
|
});
|
|
|
|
test('save commits browser then host then runtime', async () => {
|
|
const domains = adapters({ browser: { layout: 'grid' }, host: { result_timeout: 30 }, runtime: { level: 'INFO' } });
|
|
const drafts = await loaded(domains);
|
|
drafts.set('runtime', 'level', 'DEBUG');
|
|
drafts.set('host', 'result_timeout', 45);
|
|
drafts.set('browser', 'layout', 'list');
|
|
const outcome = await drafts.save();
|
|
assert.deepEqual(outcome, { browser: 'saved', host: 'saved', runtime: 'applied' });
|
|
assert.deepEqual(domains.calls, [
|
|
['writeBrowser', { layout: 'list' }],
|
|
['writeHost', { result_timeout: 45 }, { expected_revision: 'sha256:one' }],
|
|
['writeRuntime', { level: 'DEBUG' }],
|
|
]);
|
|
assert.equal(drafts.dirty(), false);
|
|
// The next host save sends the revision the last one returned.
|
|
drafts.set('host', 'result_timeout', 60);
|
|
await drafts.save();
|
|
assert.deepEqual(domains.calls.at(-1), ['writeHost', { result_timeout: 60 }, { expected_revision: 'sha256:two' }]);
|
|
});
|
|
|
|
test('host failure keeps host fields dirty and browser saved', async () => {
|
|
const domains = adapters({ browser: { layout: 'grid' }, host: { result_timeout: 30 } });
|
|
const drafts = await loaded(domains);
|
|
domains.outcome.host = () => { throw Object.assign(new Error('Settings could not be written to config.json.'), { code: 'config-write-failed' }); };
|
|
drafts.set('browser', 'layout', 'list');
|
|
drafts.set('host', 'result_timeout', 45);
|
|
const outcome = await drafts.save();
|
|
assert.deepEqual(outcome, { browser: 'saved', host: 'failed', runtime: 'clean' });
|
|
assert.equal(drafts.dirty('browser'), false);
|
|
assert.equal(drafts.dirty('host'), true);
|
|
assert.equal(drafts.get('host', 'result_timeout'), 45);
|
|
assert.deepEqual([...drafts.failed('host')], ['result_timeout']);
|
|
assert.deepEqual([...drafts.failed('browser')], []);
|
|
assert.equal(drafts.statusText(), 'Some changes were not saved. Review the highlighted settings.');
|
|
// A save answered `persisted: false` without an error is not saved either.
|
|
domains.outcome.host = () => ({ success: false, persisted: false });
|
|
assert.deepEqual(await drafts.save(), { browser: 'clean', host: 'failed', runtime: 'clean' });
|
|
// Editing the failed field clears its highlight and reads as unsaved again.
|
|
drafts.set('host', 'result_timeout', 50);
|
|
assert.deepEqual([...drafts.failed('host')], []);
|
|
assert.equal(drafts.statusText(), 'Unsaved changes.');
|
|
});
|
|
|
|
test('stale revision reports conflict and keeps the draft', async () => {
|
|
const domains = adapters({ host: { result_timeout: 30 } });
|
|
const drafts = await loaded(domains);
|
|
domains.outcome.host = () => { throw Object.assign(new Error('Settings changed since they were loaded.'), { code: 'settings-changed' }); };
|
|
drafts.set('host', 'result_timeout', 45);
|
|
assert.deepEqual(await drafts.save(), { browser: 'clean', host: 'conflict', runtime: 'clean' });
|
|
assert.equal(drafts.dirty('host'), true);
|
|
assert.equal(drafts.get('host', 'result_timeout'), 45);
|
|
assert.equal(drafts.statusText(), 'Settings changed elsewhere. Reload this section to see them.');
|
|
// The conflict read the host values again (their new revision), keeping the draft.
|
|
assert.deepEqual(domains.calls.slice(-2).map(([name]) => name), ['writeHost', 'readHost']);
|
|
// Reloading the section reads the new revision and keeps the draft (Q10).
|
|
domains.outcome.host = null;
|
|
await drafts.load('host');
|
|
assert.equal(drafts.get('host', 'result_timeout'), 45);
|
|
await drafts.save();
|
|
assert.deepEqual(domains.calls.at(-1)[2], { expected_revision: 'sha256:one' });
|
|
});
|
|
|
|
test('host save without a read revision never posts', async () => {
|
|
const { createSystemDrafts } = await source;
|
|
// Never read: an edit made before the host values (and their revision) arrived must not merge blindly.
|
|
const unread = adapters({ host: { result_timeout: 30 } });
|
|
const drafts = createSystemDrafts(unread);
|
|
drafts.set('host', 'result_timeout', 45);
|
|
assert.deepEqual(await drafts.save(), { browser: 'clean', host: 'failed', runtime: 'clean' });
|
|
assert.deepEqual(unread.calls, []);
|
|
assert.equal(drafts.dirty('host'), true);
|
|
assert.deepEqual([...drafts.failed('host')], ['result_timeout']);
|
|
// A read of an unreadable configuration has no revision either.
|
|
const unreadable = adapters({ host: { result_timeout: 30 }, revision: null });
|
|
const blind = createSystemDrafts(unreadable);
|
|
await blind.load('host');
|
|
blind.set('host', 'result_timeout', 45);
|
|
assert.equal((await blind.save()).host, 'failed');
|
|
assert.equal(unreadable.calls.filter(([name]) => name === 'writeHost').length, 0);
|
|
// Once read, the same draft saves with the revision.
|
|
await drafts.load('host');
|
|
assert.equal((await drafts.save()).host, 'saved');
|
|
assert.deepEqual(unread.calls.at(-1), ['writeHost', { result_timeout: 45 }, { expected_revision: 'sha256:one' }]);
|
|
});
|
|
|
|
test('reloadHost reads the host values and says whether it could', async () => {
|
|
const { createSystemDrafts } = await source;
|
|
const domains = adapters({ host: { result_timeout: 30 } });
|
|
const drafts = createSystemDrafts(domains);
|
|
assert.equal(await drafts.reloadHost(), true);
|
|
assert.equal(drafts.get('host', 'result_timeout'), 30);
|
|
const broken = createSystemDrafts({ ...domains, readHost: async () => { throw new Error('offline'); } });
|
|
assert.equal(await broken.reloadHost(), false);
|
|
broken.set('host', 'result_timeout', 45);
|
|
assert.equal((await broken.save()).host, 'failed');
|
|
});
|
|
|
|
test('no domain called when clean', async () => {
|
|
const domains = adapters({ browser: { layout: 'grid' } });
|
|
const drafts = await loaded(domains);
|
|
assert.deepEqual(await drafts.save(), { browser: 'clean', host: 'clean', runtime: 'clean' });
|
|
assert.deepEqual(domains.calls, []);
|
|
drafts.set('browser', 'layout', 'list');
|
|
drafts.discard();
|
|
assert.equal(drafts.dirty(), false);
|
|
assert.equal(drafts.get('browser', 'layout'), 'grid');
|
|
assert.deepEqual(await drafts.save(), { browser: 'clean', host: 'clean', runtime: 'clean' });
|
|
assert.deepEqual(domains.calls, []);
|
|
});
|
|
|
|
test('status text per outcome', async () => {
|
|
const { statusText, SETTINGS_STATUS } = await source;
|
|
assert.deepEqual(SETTINGS_STATUS, {
|
|
clean: 'No unsaved changes.',
|
|
dirty: 'Unsaved changes.',
|
|
browser: 'Saved in this browser.',
|
|
host: 'Saved to the server configuration.',
|
|
runtime: 'Applied until the server restarts.',
|
|
partial: 'Some changes were not saved. Review the highlighted settings.',
|
|
tabOnly: 'Saved for this tab only; browser storage is unavailable.',
|
|
conflict: 'Settings changed elsewhere. Reload this section to see them.',
|
|
});
|
|
const clean = { browser: 'clean', host: 'clean', runtime: 'clean' };
|
|
assert.equal(statusText(clean), 'No unsaved changes.');
|
|
assert.equal(statusText({ ...clean, browser: 'saved' }), 'Saved in this browser.');
|
|
assert.equal(statusText({ ...clean, host: 'saved' }), 'Saved to the server configuration.');
|
|
assert.equal(statusText({ ...clean, runtime: 'applied' }), 'Applied until the server restarts.');
|
|
assert.equal(statusText({ ...clean, browser: 'tab-only' }), 'Saved for this tab only; browser storage is unavailable.');
|
|
assert.equal(statusText({ browser: 'saved', host: 'saved', runtime: 'applied' }),
|
|
'Saved in this browser. Saved to the server configuration. Applied until the server restarts.');
|
|
assert.equal(statusText({ ...clean, browser: 'saved', runtime: 'failed' }),
|
|
'Some changes were not saved. Review the highlighted settings.');
|
|
assert.equal(statusText({ browser: 'failed', host: 'conflict', runtime: 'clean' }),
|
|
'Settings changed elsewhere. Reload this section to see them.');
|
|
});
|
|
|
|
test('the store says unsaved, then the save outcome, and notifies subscribers', async () => {
|
|
const domains = adapters({ browser: { layout: 'grid' } });
|
|
const drafts = await loaded(domains);
|
|
const seen = [];
|
|
const unsubscribe = drafts.subscribe(() => seen.push(drafts.statusText()));
|
|
assert.equal(drafts.statusText(), 'No unsaved changes.');
|
|
drafts.set('browser', 'layout', 'list');
|
|
domains.outcome.browser = () => ({ persisted: false });
|
|
await drafts.save();
|
|
assert.deepEqual(seen, ['Unsaved changes.', 'Saved for this tab only; browser storage is unavailable.']);
|
|
unsubscribe();
|
|
drafts.set('browser', 'layout', 'masonry');
|
|
assert.equal(seen.length, 2);
|
|
// The tab-only value is this tab's baseline now.
|
|
drafts.set('browser', 'layout', 'list');
|
|
assert.equal(drafts.dirty(), false);
|
|
});
|
|
|
|
test('an edit made while a save runs stays dirty', async () => {
|
|
const domains = adapters({ browser: { layout: 'grid' } });
|
|
const drafts = await loaded(domains);
|
|
let release;
|
|
domains.outcome.browser = () => new Promise(resolve => { release = () => resolve({ persisted: true }); });
|
|
drafts.set('browser', 'layout', 'list');
|
|
const saving = drafts.save();
|
|
// A second Save while one runs joins it instead of writing twice.
|
|
assert.equal(drafts.save(), saving);
|
|
assert.equal(drafts.saving(), true);
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
drafts.set('browser', 'layout', 'masonry');
|
|
release();
|
|
assert.deepEqual(await saving, { browser: 'saved', host: 'clean', runtime: 'clean' });
|
|
assert.equal(drafts.saving(), false);
|
|
assert.equal(drafts.dirty('browser'), true);
|
|
assert.equal(drafts.get('browser', 'layout'), 'masonry');
|
|
assert.equal(domains.calls.filter(([name]) => name === 'writeBrowser').length, 1);
|
|
});
|
|
|
|
test('a failed read keeps the store usable and drafts survive a reload (Q10)', async () => {
|
|
const { createSystemDrafts } = await source;
|
|
const domains = adapters({ browser: { layout: 'grid' } });
|
|
const drafts = createSystemDrafts(domains);
|
|
await drafts.load('browser');
|
|
drafts.set('browser', 'layout', 'list');
|
|
await drafts.load('browser');
|
|
assert.equal(drafts.get('browser', 'layout'), 'list');
|
|
assert.equal(drafts.dirty(), true);
|
|
const failing = createSystemDrafts({ ...domains, readHost: async () => { throw new Error('offline'); } });
|
|
await assert.rejects(failing.load('host'), /offline/);
|
|
assert.equal(failing.dirty(), false);
|
|
assert.throws(() => createSystemDrafts({ ...domains, writeHost: null }), TypeError);
|
|
});
|