* 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.
202 lines
8.9 KiB
JavaScript
202 lines
8.9 KiB
JavaScript
/**
|
|
* The System draft store (#249 System S2 Task 5): every System section edits drafts behind the one Save row (the
|
|
* concept's `.save-row`, app.js renderSystem 705), in three domains:
|
|
* - `browser`: preferences kept in this browser (Appearance);
|
|
* - `host`: the server configuration, saved with the `revision` its read returned (`expected_revision`, #220);
|
|
* - `runtime`: values applied to the running server only (the log level, Q10b).
|
|
*
|
|
* `save()` writes browser, then host, then runtime, and only the domains with changes. A domain that fails keeps its
|
|
* drafts dirty and its keys `failed()`, so the section highlights them; the others are saved. A host save refused
|
|
* because the configuration changed elsewhere (`settings-changed`) is a `conflict` and keeps the draft. A host save before
|
|
* `load('host')` returned a revision fails without a request: a blind write would merge over a configuration changed
|
|
* elsewhere (S1 reads a null `expected_revision` as "merge into what is on disk"). `reloadHost()` reads the host values
|
|
* again (System calls it on mount); a host conflict reloads them itself, so the next save carries the current revision.
|
|
* Drafts live for
|
|
* the page session (Q10a): a section left and opened again shows them, and a reload of its values keeps them.
|
|
*
|
|
* Adapters: `readBrowser()` and `readRuntime()` return the values; `readHost()` returns `{values, revision}`.
|
|
* `writeBrowser(changes)` returns `{persisted}` (false: kept for this tab only), `writeHost(changes,
|
|
* {expected_revision})` the settings response (`{success, persisted, revision}`), `writeRuntime(changes)` anything;
|
|
* a write that throws failed.
|
|
*/
|
|
export { SYSTEM_SECTIONS, isSystemSection } from './state.mjs';
|
|
|
|
/** D92: the Save row's status copy; never the concept's prototype text. */
|
|
export const SETTINGS_STATUS = Object.freeze({
|
|
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.',
|
|
});
|
|
|
|
export const DRAFT_DOMAINS = Object.freeze(['browser', 'host', 'runtime']);
|
|
const CONFLICT_CODE = 'settings-changed';
|
|
const ADAPTERS = ['readBrowser', 'readHost', 'readRuntime', 'writeBrowser', 'writeHost', 'writeRuntime'];
|
|
|
|
/** The status copy of one `save()` outcome (`{browser, host, runtime}`, each clean/saved/tab-only/applied/failed/conflict). */
|
|
export function statusText(outcome) {
|
|
const results = DRAFT_DOMAINS.map(domain => outcome?.[domain]);
|
|
if (results.includes('conflict')) return SETTINGS_STATUS.conflict;
|
|
if (results.includes('failed')) return SETTINGS_STATUS.partial;
|
|
const saved = [];
|
|
if (outcome?.browser === 'saved') saved.push(SETTINGS_STATUS.browser);
|
|
if (outcome?.browser === 'tab-only') saved.push(SETTINGS_STATUS.tabOnly);
|
|
if (outcome?.host === 'saved') saved.push(SETTINGS_STATUS.host);
|
|
if (outcome?.runtime === 'applied') saved.push(SETTINGS_STATUS.runtime);
|
|
return saved.join(' ') || SETTINGS_STATUS.clean;
|
|
}
|
|
|
|
function requireDomain(domain) {
|
|
if (!DRAFT_DOMAINS.includes(domain)) throw new TypeError(`Unknown settings domain: ${domain}`);
|
|
}
|
|
|
|
/** Create the store; see the module comment for the adapters. */
|
|
export function createSystemDrafts(adapters) {
|
|
for (const name of ADAPTERS) {
|
|
if (typeof adapters?.[name] !== 'function') throw new TypeError(`System drafts need ${name}`);
|
|
}
|
|
const baseline = { browser: {}, host: {}, runtime: {} };
|
|
const drafts = { browser: new Map(), host: new Map(), runtime: new Map() };
|
|
const failedKeys = { browser: new Set(), host: new Set(), runtime: new Set() };
|
|
const listeners = new Set();
|
|
let revision = null;
|
|
let outcome = null;
|
|
let saving = null;
|
|
|
|
const notify = () => { for (const listener of [...listeners]) listener(); };
|
|
const changes = domain => Object.fromEntries(drafts[domain]);
|
|
|
|
/** Read a domain's saved values; drafts are kept, and a draft equal to the new value is clean. */
|
|
async function load(domain) {
|
|
requireDomain(domain);
|
|
let values;
|
|
if (domain === 'host') {
|
|
const read = await adapters.readHost();
|
|
values = read?.values ?? {};
|
|
revision = read?.revision ?? null;
|
|
} else {
|
|
values = await (domain === 'browser' ? adapters.readBrowser() : adapters.readRuntime());
|
|
}
|
|
baseline[domain] = { ...(values ?? {}) };
|
|
for (const [key, value] of drafts[domain]) if (Object.is(baseline[domain][key], value)) drafts[domain].delete(key);
|
|
notify();
|
|
return { ...baseline[domain] };
|
|
}
|
|
|
|
function get(domain, key) {
|
|
requireDomain(domain);
|
|
return drafts[domain].has(key) ? drafts[domain].get(key) : baseline[domain][key];
|
|
}
|
|
|
|
function set(domain, key, value) {
|
|
requireDomain(domain);
|
|
if (Object.is(baseline[domain][key], value)) drafts[domain].delete(key);
|
|
else drafts[domain].set(key, value);
|
|
failedKeys[domain].delete(key);
|
|
outcome = null;
|
|
notify();
|
|
}
|
|
|
|
function dirty(domain) {
|
|
if (domain !== undefined) {
|
|
requireDomain(domain);
|
|
return drafts[domain].size > 0;
|
|
}
|
|
return DRAFT_DOMAINS.some(name => drafts[name].size > 0);
|
|
}
|
|
|
|
function discard(domain) {
|
|
for (const name of domain === undefined ? DRAFT_DOMAINS : [domain]) {
|
|
requireDomain(name);
|
|
drafts[name].clear();
|
|
failedKeys[name].clear();
|
|
}
|
|
outcome = null;
|
|
notify();
|
|
}
|
|
|
|
/** The values of one domain as written: they become its saved values, and drafts still equal to them are clean. */
|
|
function commit(domain, written) {
|
|
for (const [key, value] of written) {
|
|
baseline[domain][key] = value;
|
|
if (drafts[domain].has(key) && Object.is(drafts[domain].get(key), value)) drafts[domain].delete(key);
|
|
}
|
|
}
|
|
|
|
function fail(domain, written) {
|
|
for (const [key] of written) failedKeys[domain].add(key);
|
|
}
|
|
|
|
async function saveDomain(domain) {
|
|
const written = new Map(drafts[domain]);
|
|
if (written.size === 0) return 'clean';
|
|
const values = Object.fromEntries(written);
|
|
try {
|
|
if (domain === 'browser') {
|
|
const result = await adapters.writeBrowser(values);
|
|
commit(domain, written);
|
|
return result?.persisted === false ? 'tab-only' : 'saved';
|
|
}
|
|
if (domain === 'host') {
|
|
if (typeof revision !== 'string') { fail(domain, written); return 'failed'; }
|
|
const result = await adapters.writeHost(values, { expected_revision: revision });
|
|
if (result?.code === CONFLICT_CODE) { fail(domain, written); return 'conflict'; }
|
|
if (!result?.success || result.persisted !== true) { fail(domain, written); return 'failed'; }
|
|
if (typeof result.revision === 'string') revision = result.revision;
|
|
commit(domain, written);
|
|
return 'saved';
|
|
}
|
|
await adapters.writeRuntime(values);
|
|
commit(domain, written);
|
|
return 'applied';
|
|
} catch (error) {
|
|
fail(domain, written);
|
|
return error?.code === CONFLICT_CODE ? 'conflict' : 'failed';
|
|
}
|
|
}
|
|
|
|
/** Read the host values again; a failed read leaves host saves refused until one succeeds. */
|
|
const reloadHost = () => load('host').then(() => true, () => false);
|
|
|
|
/** Save the dirty domains in order; a Save pressed while one runs joins it. */
|
|
function save() {
|
|
saving ??= (async () => {
|
|
for (const domain of DRAFT_DOMAINS) failedKeys[domain].clear();
|
|
const result = {};
|
|
for (const domain of DRAFT_DOMAINS) result[domain] = await saveDomain(domain);
|
|
// Settings changed elsewhere: read them (and their revision), keeping the drafts (Q10).
|
|
if (result.host === 'conflict') await reloadHost();
|
|
return result;
|
|
})().then(result => {
|
|
saving = null;
|
|
outcome = result;
|
|
notify();
|
|
return result;
|
|
});
|
|
return saving;
|
|
}
|
|
|
|
return {
|
|
load,
|
|
reloadHost,
|
|
get,
|
|
set,
|
|
dirty,
|
|
discard,
|
|
save,
|
|
changes,
|
|
saving: () => saving !== null,
|
|
failed: domain => { requireDomain(domain); return new Set(failedKeys[domain]); },
|
|
/** The Save row's text: the last save's outcome until the next edit, else unsaved or clean. */
|
|
statusText: () => (outcome ? statusText(outcome) : dirty() ? SETTINGS_STATUS.dirty : SETTINGS_STATUS.clean),
|
|
subscribe(listener) {
|
|
listeners.add(listener);
|
|
return () => listeners.delete(listener);
|
|
},
|
|
};
|
|
}
|