fix(system): keep a refused or handed-back restore review usable

A Restore refused as busy now opens the same review again with Discard /
Restore, and a restore job whose leases did not drain hands its review back
(scope.review_kept) to the page that started it, which reopens it; every
other path that abandons a token after Restore sends its DELETE, so held
reviews can no longer lock restore out. The kept-review line says where the
review is, and a full review store says what to do (I1). applyPlan resolves
with its outcome and its job tracking now has failing-before proof (N5).

Also: the safety-backup toggle says what the policy does (D104, M2); the
restore picker has no type filter so safety backups can be chosen (M3);
"Export all prompts JSON" names what /export holds (N1); small snapshots
show KB (N3); the duplicated holding and save helpers are shared (N4); a
dismissal test covers Cancel, x, Escape, a replacing dialog and leaving (M1).

Refs #326
This commit is contained in:
Vito Sansevero
2026-10-25 12:38:28 -07:00
parent dde8b99b75
commit af2d8e3792
11 changed files with 396 additions and 79 deletions
+26 -8
View File
@@ -2630,14 +2630,17 @@ Recorded deviations (plan D60–D75, D79–D82 renumbered D92–D107 and D111–
`<db>.backup_*`, else "No backup recorded."; "Checking backups…" while read and "The
backup status could not be read." when it failed (never "no backup"). Portable
export's "Export JSON" (the concept's "Export sample JSON") downloads the Library
document (`/library/export`); a second `.row-actions.spaced` adds "Export components
JSON" (`/export`, the legacy prompt list) and "Export saved pairs JSON"
document (`/library/export`); a second `.row-actions.spaced` adds "Export all prompts
JSON" (`/export`, the legacy admin page's Export of every prompt) and "Export saved pairs JSON"
(`/pairs/export`), plain buttons as the concept's; a failed export toasts "The export
could not be downloaded. Nothing was saved to this device." "Import / inspect file"
opens the shared "Import & inspect" (D15, D55), as Images does.
- **D104** (Backup & export, S9, Q4): Recovery policy keeps the concept's "Proposed" chip.
"Create a safety backup before maintenance" is the server's `safety_backup`
(`maintenance.safety_backup`) policy, drafted behind the Save row and saved with
(`maintenance.safety_backup`) policy. Its line says what it does ("Keep a verified copy
of the database on disk before maintenance, cleanups and LoRA imports change records. A
restore always keeps one."); the concept's "Preview a verified backup before applying
operations that change records." describes nothing the server does. It is drafted behind the Save row and saved with
`expected_revision` (a configuration changed elsewhere is the conflict status); it is
disabled with "Server settings could not be read." when the read failed. "Scheduled
database backups" offers Manual only; Daily and Weekly stay listed but disabled
@@ -2645,13 +2648,14 @@ Recorded deviations (plan D60–D75, D79–D82 renumbered D92–D107 and D111–
back up with Back up whenever you choose." (the concept's proposal line is
prototype copy) and nothing is drafted for it. The warning note reads "Restores are
staged, migrated and validated before replacing live data."
- **D105** (Backup & export, S9): "Review restore…" picks a `.db` file and stages it
- **D105** (Backup & export, S9): "Review restore…" picks a file (no type filter: the
safety backups `<db>.backup_<time>` have no `.db` extension; the server validates) and stages it
(`POST /prompt_manager/restore/preview`, multipart through `fetchUrl`; the button reads
"Reviewing…" meanwhile). "Restore preview" is the shared review: no sample note; the
concept's `dl.meta-grid` Snapshot (file name · size · date), Integrity ("Verified" plus
the stage's document repairs), Migration ("Current schema" / "Upgraded to the current
schema"), Safety backup ("Created before replacing (<label>)": the restore always
keeps one), then "In the backup" and "In the library now" (prompts, image links, saved
keeps one; the Snapshot size is whole KB below 1 MB), then "In the backup" and "In the library now" (prompts, image links, saved
pairs, runs); a `p.small` "Restore replaces the whole library with this backup and
runs alone in Activity. Nothing changes until you restore. This review holds for N
minutes."; the D55 backup box ("Downloads a copy of the prompts database before the
@@ -2660,7 +2664,18 @@ Recorded deviations (plan D60–D75, D79–D82 renumbered D92–D107 and D111–
leaving) discards the stage (`DELETE …/preview/<token>`). Restore starts the "Database
restore" Activity job (exclusive, not cancellable, indeterminate; its descriptor ends
with the server's line) and opens its view; a review the server asks to redo (expired,
library changed) stages the same file again. When a restore the page saw running
library changed) stages the same file again. A Restore refused as `busy` (another job
runs; the server keeps the token) opens the same review again with "Discard" / "Restore":
Restore retries, Discard (or leaving) releases it. A job whose leases did not drain
hands its review back (`scope.review_kept`, "…The review was reopened in the page that
started the restore: restore it again there, or discard it."): the page that started it
opens the review again over the page shown now, with Discard / Restore. Every other path
that abandons a token after Restore (a used, expired or replaced review, a lost answer)
sends its DELETE, which changes nothing for a token the server dropped or a job took. A
third review while two are held elsewhere toasts "Two restores are already being
reviewed in another window. Discard one there, or try again when it expires (15 minutes
after it was reviewed)." An upload whose answer is lost because the section was left
mid-response is not discarded by the page: the server's 15-minute expiry removes it. When a restore the page saw running
finishes, the cached Images and Library visits are dropped, the sidebar counts and
saved views are read again and the open route is shown again (the Activity job view
stays). A refused file toasts "This file can't be restored: <validator reason>.". A
@@ -3397,7 +3412,8 @@ the executor. `POST /prompt_manager/restore/apply {token}` rechecks the live fin
`plan-changed`, stage dropped) and starts "Database restore": kind `restore`, admission
`exclusive` (it runs alone, like Move library and Optimize), not cancellable,
indeterminate. Its runner waits for request leases (when they outlast the wait the job
fails `busy` and the review is kept under its token), rechecks the fingerprint,
fails `busy` and the review is kept under its token, flagged `scope.review_kept` for the
page that started it), rechecks the fingerprint,
publishes, swaps `self.db`, prunes the pool and logs the safety backup by name, any
document repair and the restored counts; a failed page copy keeps the safety backup and
says so. Once the copy replaced the library the restore succeeded: pruning, reopening
@@ -3437,7 +3453,9 @@ swaps tokens. `tests/browser/workspace-system-backups.spec.mjs` covers Back up a
revoked object URL, the status line against the server (none, a safety backup, a failed
read), Overview's row, the restore review against the preview (and that it writes
nothing), Cancel and Escape discarding the stage, a restore through Activity that
reloads the counts and the open Library, an invalid file, the three exports and a
reloads the counts and the open Library, a busy refusal reopened and retried, two
refusals then a third review, ×, a section and a route leave discarding, a safety-backup
file name, an invalid file, the three exports and a
failed one, Import / inspect (Rescan while running, Scan for metadata, Inspect), the
safety-backup policy with its revision and a conflict, the schedule, a failed settings
read and the legacy routes. The fixture (`workspace_system_fixture.py`) writes a safety
+6 -1
View File
@@ -90,9 +90,13 @@ REQUESTS_BUSY = (
"Other requests are still reading the library; nothing was changed. "
"Try again in a moment."
)
# S9 I1: the review goes back to the page that started the restore
# (``scope.review_kept``), which opens it again with Restore and Discard; no
# other place can start a kept review, so the line says where it is.
REVIEW_KEPT = (
"Other requests are still reading the library; nothing was changed. "
"The review is kept: start the restore again in a moment."
"The review was reopened in the page that started the restore: restore it "
"again there, or discard it."
)
VERIFY_HEADER = "X-PromptManager-Verify"
SQLITE_HEADER = b"SQLite format 3\x00"
@@ -873,6 +877,7 @@ class BackupRestoreRoutesMixin:
if token is not None and self._restore_plans.give_back(token, held):
keep_stage = True
self._schedule_restore_expiry()
job.scope["review_kept"] = True
return _fail_job(job, "busy", REVIEW_KEPT)
return _fail_job(job, "busy", REQUESTS_BUSY)
job.update(status_line="Checking the library")
+14 -8
View File
@@ -87,18 +87,20 @@ function measureBackups(mainSelector) {
}
const SCHEDULE_REASON = 'Daily and Weekly are not available yet: back up with Back up whenever you choose.';
const SAFETY_DESCRIPTION = 'Keep a verified copy of the database on disk before maintenance, cleanups and LoRA imports change ' +
'records. A restore always keeps one.';
/**
* D103: "Back up" (the concept's "Simulate backup"), the status line from /backup/status, "Export JSON" (its "Export
* sample JSON") and the two secondary exports in a second `.row-actions.spaced` with the concept's plain buttons.
* D104: the schedule's reason replaces the prototype's proposal line, Daily and Weekly are disabled, and the warning
* note is truthful. The fixture has no backup yet.
* D104: the toggle's line says what the policy does, the schedule's reason replaces the prototype's proposal line,
* Daily and Weekly are disabled, and the warning note is truthful. The fixture has no backup yet.
*/
function mapBackupsConcept(reference) {
const [database, portable] = reference.surfaces;
const [backup, restore] = database.actions[0].buttons;
const [exportButton, importButton] = portable.actions[0].buttons;
const secondary = ['Export components JSON', 'Export saved pairs JSON'].map(label => ({ ...exportButton, text: label }));
const secondary = ['Export all prompts JSON', 'Export saved pairs JSON'].map(label => ({ ...exportButton, text: label }));
return {
...reference,
surfaces: [
@@ -109,9 +111,12 @@ function mapBackupsConcept(reference) {
],
status: { ...reference.status, text: 'No backup recorded.' },
policy: { ...reference.policy,
rows: reference.policy.rows.map(item => (item.control.kind !== 'select' ? item : { ...item,
text: item.text.map(([node, value, target]) => [node, node === 'p' ? SCHEDULE_REASON : value, target]),
control: { ...item.control, options: item.control.options.map(([label]) => [label, label !== 'Manual only']) } })),
rows: reference.policy.rows.map(item => (item.control.kind !== 'select'
// D104 (M2): what the safety-backup policy really does.
? { ...item, text: item.text.map(([node, value, target]) => [node, node === 'p' ? SAFETY_DESCRIPTION : value, target]) }
: { ...item,
text: item.text.map(([node, value, target]) => [node, node === 'p' ? SCHEDULE_REASON : value, target]),
control: { ...item.control, options: item.control.options.map(([label]) => [label, label !== 'Manual only']) } })),
warning: { ...reference.policy.warning, text: 'Restores are staged, migrated and validated before replacing live data.' } },
};
}
@@ -255,7 +260,7 @@ for (const theme of THEMES) for (const [width, height] of SIZES) {
const { preview, production } = await productionRestore(page, request, theme);
await page.screenshot({ path: testInfo.outputPath(`production-restore-${theme}-${width}.png`) });
const snapshot = production.grid.rows[0][1];
expect(snapshot).toMatch(/^prompts_backup_20261025_093000\.db · \d+\.\d MB · .+$/);
expect(snapshot).toMatch(/^prompts_backup_20261025_093000.db · (\d+ KB|\d+\.\d MB) · .+$/);
const { text: _intro, ...small } = conceptImport.note;
expect(diffInventory(mapRestoreConcept(conceptRestore, small, restoreRows(preview, snapshot)), production)).toEqual([]);
await page.keyboard.press('Escape');
@@ -281,7 +286,8 @@ test('backup & export comparison detects mutations', async ({ page, request }) =
const added = 'surfaces.1.actions.1.';
expect(unmapped.some(path => path.startsWith(added))).toBe(true);
expect(unmapped.filter(path => !path.startsWith(added))).toEqual([
'policy.rows.1.control.options.1.1', 'policy.rows.1.control.options.2.1', 'policy.rows.1.text.1.1', 'policy.warning.text',
'policy.rows.0.text.1.1', 'policy.rows.1.control.options.1.1', 'policy.rows.1.control.options.2.1', 'policy.rows.1.text.1.1',
'policy.warning.text',
'status.text', 'surfaces.0.actions.0.buttons.0.text', 'surfaces.1.actions.0.buttons.0.text', 'surfaces.1.actions.length',
'surfaces.1.tree.3', 'surfaces.1.tree.length']);
// A renamed label, a removed row, a lost danger mark and a swapped token are noticed.
+106 -5
View File
@@ -10,6 +10,8 @@ const STATUS_URL = '/prompt_manager/backup/status';
const PREVIEW_URL = '/prompt_manager/restore/preview';
const APPLY_URL = '/prompt_manager/restore/apply';
const WHEN = { dateStyle: 'medium', timeStyle: 'short' };
const SAFETY_DESCRIPTION = 'Keep a verified copy of the database on disk before maintenance, cleanups and LoRA imports change ' +
'records. A restore always keeps one.';
test.use({ locale: 'en-US', timezoneId: 'UTC' });
@@ -154,7 +156,7 @@ test('Review restore stages the chosen file and shows the real summary with the
`${summary.pairs} ${summary.pairs === 1 ? 'saved pair' : 'saved pairs'} · ${summary.runs} ${summary.runs === 1 ? 'run' : 'runs'}`;
const rows = await gridOf(page);
expect(rows.map(([term]) => term)).toEqual(['Snapshot', 'Integrity', 'Migration', 'Safety backup', 'In the backup', 'In the library now']);
expect(rows[0][1]).toMatch(/^prompts_backup_20261025_093000\.db · \d+\.\d MB · .+$/);
expect(rows[0][1]).toMatch(/^prompts_backup_20261025_093000.db · (\d+ KB|\d+\.\d MB) · .+$/);
expect(rows.slice(1)).toEqual([['Integrity', 'Verified'], ['Migration', 'Current schema'],
['Safety backup', `Created before replacing (${preview.safety_backup.destination_label})`],
['In the backup', counts(preview.summary)], ['In the library now', counts(preview.live)]]);
@@ -264,14 +266,14 @@ test('invalid file shows the validator message; nothing is staged', async ({ pag
expect(await serverJson(request, '/__test__/system/digest')).toEqual(before);
});
test('Export JSON downloads /library/export; secondary buttons download /export and /pairs/export (D103)', async ({ page }) => {
test('Export JSON downloads /library/export; secondary buttons download all prompts (/export) and /pairs/export (D103)', async ({ page }) => {
const layout = await openBackups(page);
const portable = layout.locator('section.surface').filter({ has: page.locator('h2', { hasText: 'Portable export' }) });
expect(await portable.locator('.row-actions > button').allTextContents())
.toEqual(['Export JSON', 'Import / inspect file', 'Export components JSON', 'Export saved pairs JSON']);
.toEqual(['Export JSON', 'Import / inspect file', 'Export all prompts JSON', 'Export saved pairs JSON']);
for (const [label, url, name, kind] of [
['Export JSON', '/prompt_manager/library/export', /^prompt-manager-library-v1\.json$/, 'prompt-manager-library'],
['Export components JSON', '/prompt_manager/export', /^prompt_manager_\d{8}_\d{6}\.json$/, null],
['Export all prompts JSON', '/prompt_manager/export', /^prompt_manager_\d{8}_\d{6}\.json$/, null],
['Export saved pairs JSON', '/prompt_manager/pairs/export', /^prompt-pairs\.json$/, 'prompt-manager-pairs']]) {
const download = page.waitForEvent('download');
const answer = page.waitForResponse(response => new URL(response.url()).pathname === url);
@@ -329,7 +331,10 @@ test('safety backup toggle saves through Save preferences with revision (D104)',
const before = await serverJson(request, '/prompt_manager/settings');
expect(before.settings.safety_backup).toBe(true);
await openBackups(page);
const toggle = settingOf(page, 'Create a safety backup before maintenance').locator('input');
const row = settingOf(page, 'Create a safety backup before maintenance');
// D104: what the policy really does (the concept's "Preview a verified backup…" line does not say it).
await expect(row.locator('p').first()).toHaveText(SAFETY_DESCRIPTION);
const toggle = row.locator('input');
await expect(toggle).toBeChecked();
await toggle.uncheck();
await expect(settingsStatus(page)).toHaveText('Unsaved changes.');
@@ -386,3 +391,99 @@ test('the legacy admin Backup and Restore keep working', async ({ page, request
await page.goto('/prompt_manager/admin');
await expect(page.locator('#restoreFileInput')).toHaveCount(1);
});
/** Start a fake running job (a metadata scan another tab started), which an exclusive restore must wait for. */
async function runningScan(request) {
const response = await request.post('/__test__/jobs/start', { data: { kind: 'scan', title: 'Metadata scan', total: 4 } });
await expect(response).toBeOK();
return (await response.json()).job;
}
const stagesOf = async request => (await serverJson(request, '/__test__/system/backups')).stages;
const deleted = page => page.waitForResponse(response => response.url().includes(`${PREVIEW_URL}/`) &&
response.request().method() === 'DELETE');
test('a refused restore reopens its review: Restore retries it, Discard releases it (I1)', async ({ page, request }) => {
const bytes = await backupBytes(request);
await openBackups(page);
await chooseBackup(page, bytes);
await expect(dialog(page).locator('#modal-title')).toHaveText('Restore preview');
const scan = await runningScan(request);
const refused = page.waitForResponse(response => response.url().endsWith(APPLY_URL));
await dialog(page).getByRole('button', { name: 'Restore', exact: true }).click();
expect((await refused).status()).toBe(409);
await expect(toast(page)).toHaveText('Another job is running: Metadata scan.');
// The same review is open again, its stage still held: Restore retries, Discard releases it.
await expect(dialog(page).locator('#modal-title')).toHaveText('Restore preview');
await expect(dialog(page).locator('.dialog-actions > button')).toHaveText(['Discard', 'Restore']);
expect(await stagesOf(request)).toBe(1);
await expect(await request.post(`/__test__/jobs/${scan.id}/finish`, { data: { state: 'finished' } })).toBeOK();
const applied = page.waitForResponse(response => response.url().endsWith(APPLY_URL));
await dialog(page).getByRole('button', { name: 'Restore', exact: true }).click();
const { job } = await (await applied).json();
await expect(page).toHaveURL(`${workspace}#/activity/${job.id}`);
await expect(toast(page)).toHaveText(/^Database restore finished · /);
expect(await stagesOf(request)).toBe(0);
});
test('two refused restores in a row, then a third Review restore… still opens the preview (I1)', async ({ page, request }) => {
const bytes = await backupBytes(request);
await openBackups(page);
await runningScan(request);
for (const leave of ['discard', 'escape']) {
await chooseBackup(page, bytes);
await expect(dialog(page).locator('#modal-title')).toHaveText('Restore preview');
const refused = page.waitForResponse(response => response.url().endsWith(APPLY_URL));
await dialog(page).getByRole('button', { name: 'Restore', exact: true }).click();
expect((await refused).status()).toBe(409);
await expect(dialog(page).locator('.dialog-actions > button')).toHaveText(['Discard', 'Restore']);
const released = deleted(page);
if (leave === 'discard') await dialog(page).getByRole('button', { name: 'Discard', exact: true }).click();
else await page.keyboard.press('Escape');
expect((await released).status()).toBe(200);
await expect(dialog(page)).toHaveCount(0);
expect(await stagesOf(request)).toBe(0);
}
const answer = await chooseBackup(page, bytes);
expect((await answer).status()).toBe(200);
await expect(dialog(page).locator('#modal-title')).toHaveText('Restore preview');
expect(await stagesOf(request)).toBe(1);
});
test('× and leaving the section or the page discard the stage (M1)', async ({ page, request }) => {
const bytes = await backupBytes(request);
await openBackups(page);
const leaves = [
['×', () => page.locator('#modal-close').click()],
// The modal covers the page, so the section changes as a link or the Back button changes it: by its hash.
['section', () => page.evaluate(() => { window.location.hash = '#/system/maintenance'; })],
['route', () => page.evaluate(() => { window.location.hash = '#/images'; })],
];
for (const [name, leave] of leaves) {
if (!page.url().endsWith('#/system/backups')) {
await page.goto(`${workspace}#/system/backups`);
await expect(statusLine(page)).not.toHaveText('Checking backups…');
}
await chooseBackup(page, bytes);
await expect(dialog(page).locator('#modal-title')).toHaveText('Restore preview');
expect(await stagesOf(request), name).toBe(1);
// A keepalive DELETE (it may outlive the page) is asserted by its request and the server's stage count.
const released = page.waitForRequest(item => item.url().includes(`${PREVIEW_URL}/`) && item.method() === 'DELETE');
await leave();
await released;
await expect(dialog(page)).toHaveCount(0);
await expect.poll(() => stagesOf(request), { message: name }).toBe(0);
}
});
test('a safety backup file (no .db extension) can be picked and reviewed (M3)', async ({ page, request }) => {
await expect(await request.post('/__test__/system/safety-backup')).toBeOK();
const bytes = await backupBytes(request);
await openBackups(page);
// The picker lists every file; the server decides what is a database.
await expect(page.locator('#restore-file')).not.toHaveAttribute('accept', /./);
const answer = await chooseBackup(page, bytes, 'prompts.db.backup_20261025_093000_000000');
expect((await answer).status()).toBe(200);
await expect(dialog(page).locator('#modal-title')).toHaveText('Restore preview');
expect((await gridOf(page))[0][1]).toMatch(/^prompts\.db\.backup_20261025_093000_000000 · \d+ KB · .+$/);
});
+74 -5
View File
@@ -26,14 +26,15 @@ test('the section keeps the concept copy: surfaces, descriptions and the two kep
const body = conceptSection();
for (const text of ['Database backup', 'A complete recovery snapshot, including prompt pairs, metadata and image links. Media files are separate.',
'Review restore…', 'Portable export', 'Export reusable recipes and tags as versioned JSON. This is distinct from a complete database backup.',
'Import / inspect file', 'Recovery policy', 'Proposed', 'Create a safety backup before maintenance',
'Preview a verified backup before applying operations that change records.', 'Scheduled database backups',
'Import / inspect file', 'Recovery policy', 'Proposed', 'Create a safety backup before maintenance', 'Scheduled database backups',
'Manual only', 'Daily', 'Weekly']) {
assert.ok(body.includes(text), `concept lacks ${text}`);
assert.ok(SOURCE.includes(text), `production lacks ${text}`);
}
// D103/D104: prototype copy is never shipped.
for (const sample of ['Simulate backup', 'Sample backup', 'Export sample JSON', 'Production feature proposal', 'A restore would be']) {
// D104 (M2): the toggle's concept line ("Preview a verified backup…") misstates the policy and is replaced too.
for (const sample of ['Simulate backup', 'Sample backup', 'Export sample JSON', 'Production feature proposal', 'A restore would be',
'Preview a verified backup before applying operations that change records.']) {
assert.ok(body.includes(sample));
assert.ok(!SOURCE.includes(sample), `production ships ${sample}`);
}
@@ -117,7 +118,8 @@ test('invalid file shows the validator message; a refusal names its job; a lost
const full = await uploadRestore(file, { ApiError, fetchUrl: async () => response(429,
{ success: false, code: 'too-large', error: 'Two restores are already being reviewed. Discard one first.' }) })
.catch(error => error);
assert.equal(reviewRefusal(full), 'Two restores are already being reviewed. Discard one first.');
// I1: the server's "Discard one first" names nothing this page can do; the toast says what the user can.
assert.equal(reviewRefusal(full), (await backups).REVIEWS_FULL);
const proxy = await uploadRestore(file, { ApiError, fetchUrl: async () => response(502, undefined) }).catch(error => error);
assert.equal(reviewRefusal(proxy), REVIEW_FAILED);
const odd = await uploadRestore(file, { ApiError, fetchUrl: async () => response(200, { success: true }) }).catch(error => error);
@@ -138,7 +140,7 @@ test('Export JSON downloads /library/export; secondary buttons download /export
const { EXPORTS } = await backups;
assert.deepEqual(EXPORTS.map(([label, url]) => [label, url]), [
['Export JSON', '/prompt_manager/library/export'],
['Export components JSON', '/prompt_manager/export'],
['Export all prompts JSON', '/prompt_manager/export'],
['Export saved pairs JSON', '/prompt_manager/pairs/export'],
]);
});
@@ -215,3 +217,70 @@ test('the Import dialog shares the rescan copy with Images (D15)', async () => {
assert.equal(rescanFinished({ total: 1200, roots: 1 }), 'Rescan finished. 1,200 files in 1 output folder.');
assert.equal(rescanFinished(null), 'Rescan finished.');
});
test('applyPlan follows a started job even after its section left, and reports the outcome (S9 N5, I1)', async () => {
const { applyPlan } = await import('../../web/js/workspace/maintenance-review.mjs');
const job = { id: 'a'.repeat(32), kind: 'restore', title: 'Database restore', state: 'running' };
const tracked = [];
const store = { track: raw => { tracked.push(raw.id); return raw; }, load() {} };
const left = new AbortController();
left.abort();
const toasts = [], routes = [];
const outcome = await applyPlan({ api: { post: async () => ({ success: true, job }) }, store, scope: { signal: left.signal },
token: 't', notify: text => toasts.push(text), navigate: hash => routes.push(hash) });
// The page's store follows it (a finished restore must reload the views); the left section neither toasts nor opens it.
assert.deepEqual(tracked, [job.id]);
assert.deepEqual([toasts, routes], [[], []]);
assert.deepEqual(outcome, { job });
const busy = new ApiError(409, 'Busy', 'busy', null, { success: false, code: 'busy', running: { id: 'j1', title: 'Scan' } });
const live = { signal: new AbortController().signal };
const refused = await applyPlan({ api: { post: async () => { throw busy; } }, store, scope: live, token: 't',
notify: text => toasts.push(text), navigate: hash => routes.push(hash) });
assert.deepEqual(refused, { refused: busy });
assert.deepEqual(toasts, [['Another job is running: ', { text: 'Scan', href: '#/activity/j1' }, '.']]);
});
test('after an apply: follow a started job, reopen a busy refusal, release the token otherwise (I1)', async () => {
const { afterApply } = await backups;
const refusal = code => ({ refused: new ApiError(409, 'x', code, null, { success: false, code }) });
assert.equal(afterApply({ job: { id: 'j' } }), 'follow');
assert.equal(afterApply(refusal('busy')), 'reopen');
for (const outcome of [refusal('plan-used'), refusal('plan-expired'), refusal('plan-changed'), refusal('storage-changed'),
{ refused: new TypeError('offline') }, { lost: true }, undefined]) {
assert.equal(afterApply(outcome), 'release');
}
});
test('a restore job that hands its review back reopens it once in the page that started it; any other end forgets it (I1)', async () => {
const { rememberRestore, onRestoreHandedBack } = await backups;
let list = [];
const listeners = new Set();
const activity = { jobs: () => list, subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); } };
const emit = next => { list = next; for (const listener of listeners) listener(); };
const reopened = [];
const stop = onRestoreHandedBack(activity, review => reopened.push(review.token));
rememberRestore('kept', { token: 'k' });
rememberRestore('done', { token: 'd' });
rememberRestore('gone', { token: 'g' });
emit([{ id: 'kept', kind: 'restore', state: 'running', scope: {} }]);
emit([{ id: 'kept', kind: 'restore', state: 'failed', scope: { failure: 'busy', review_kept: true } },
{ id: 'done', kind: 'restore', state: 'finished', scope: {} },
{ id: 'gone', kind: 'restore', state: 'failed', scope: { failure: 'busy' } }]);
emit(list);
assert.deepEqual(reopened, ['k']);
stop();
assert.equal(listeners.size, 0);
});
test('small files show KB; a full review store says what to do (N3, I1)', async () => {
const { snapshotText, reviewRefusal, EXPORTS } = await backups;
assert.equal(snapshotText({ name: 'a.db', size: 40 * 1024 }), 'a.db · 40 KB');
assert.equal(snapshotText({ name: 'a.db', size: 10 }), 'a.db · 1 KB');
assert.equal(snapshotText({ name: 'a.db', size: 3 * 2 ** 20 }), 'a.db · 3.0 MB');
const full = new ApiError(429, 'Two restores are already being reviewed. Discard one first.', 'too-large', null,
{ success: false, code: 'too-large' });
assert.equal(reviewRefusal(full), 'Two restores are already being reviewed in another window. Discard one there, ' +
'or try again when it expires (15 minutes after it was reviewed).');
// N1: /export is the legacy list of every prompt.
assert.deepEqual(EXPORTS[1].slice(0, 2), ['Export all prompts JSON', '/prompt_manager/export']);
});
+34
View File
@@ -933,3 +933,37 @@ test('the scan review is a thin openReviewDialog caller with its scope switch',
dom.restore();
}
});
test('a review dialog reports one dismissal for Cancel, ×, Escape, a replacing dialog and the page leaving, never after its primary (S9 M1)', async () => {
const { openReviewDialog, openContentDialog, closeWorkspaceDialog } = await dialogsModule;
const { createScope } = await coreModule;
const dom = fakeDOM();
const scope = createScope();
const dismissed = [];
const open = (name, primary = 'Restore') => openReviewDialog({ scope, title: 'Restore preview', grid: [['Integrity', 'Verified']],
cancelLabel: 'Cancel', primary: { label: primary, danger: true }, onStart() {}, onDismiss: () => dismissed.push(name) });
try {
open('cancel');
button(byId(dom, 'modal-body'), 'Cancel').click();
open('close');
byId(dom, 'modal-close').click();
open('escape');
modal(dom).dispatchEvent(new Event('cancel', { cancelable: true }));
open('replaced');
openContentDialog({ scope, title: 'Other', nodes: [] });
closeWorkspaceDialog();
open('started');
button(byId(dom, 'modal-body'), 'Restore').click();
await tick();
open('left');
scope.dispose();
await tick();
assert.deepEqual(dismissed, ['cancel', 'close', 'escape', 'replaced', 'left']);
assert.throws(() => openReviewDialog({ scope: createScope(), title: 'x', grid: [['a', 'b']], cancelLabel: 'Cancel',
primary: { label: 'Go' }, onStart() {}, onDismiss: 'no' }), TypeError);
} finally {
scope.dispose();
closeWorkspaceDialog();
dom.restore();
}
});
+27 -1
View File
@@ -205,13 +205,39 @@ class TestPreviewLease(RestoreTestCase):
await lease.__aexit__(None, None, None)
self.assertEqual(job.state, "failed")
self.assertEqual(job.last_event["code"], "busy")
self.assertIn("review is kept", job.status_line)
# S9 I1: the page that started it reopens the kept review (Restore again
# or Discard); the flag tells it so, and the line says where the review
# is, never "start the restore again" from a place that cannot.
self.assertIs(job.scope.get("review_kept"), True)
self.assertEqual(job.status_line, backup_restore.REVIEW_KEPT)
self.assertIn("reopened in the page that started", job.status_line)
self.assertEqual(len(self.stages()), 1)
# The same review applies once the requests are done.
job = await self.finish((await self.apply(body["token"]))["job"]["id"])
self.assertEqual(job.state, "finished", job.log)
self.assertEqual(_texts(self.db_path), ["restored"])
async def test_a_review_that_cannot_be_kept_is_not_flagged_kept(self):
self.save("live")
body = await self.preview(self.upload(["restored"]))
lease = self.api._jobs.lease("Stuck preview")
await lease.__aenter__()
try:
with (
patch.object(jobs, "LEASE_DRAIN_SECONDS", 0),
patch.object(
backup_restore.RestorePlanStore, "give_back", lambda *_: False
),
):
started = await self.apply(body["token"])
job = await self.finish(started["job"]["id"])
finally:
await lease.__aexit__(None, None, None)
self.assertEqual(job.state, "failed")
self.assertNotIn("review_kept", job.scope)
self.assertEqual(job.status_line, backup_restore.REQUESTS_BUSY)
self.assertEqual(len(self.stages()), 0)
class TestStoreOnTheLoop(RestoreTestCase):
"""M3: entries leave the store on the loop; only file removal is threaded."""
+2 -2
View File
@@ -32,8 +32,8 @@ export function backupFilename(disposition, fallback = FALLBACK_NAME) {
return match ? match[1] : fallback;
}
/** Save a fetched response through a temporary link, named by `filename`. */
function saveResponse(blob, filename) {
/** Save a fetched response through a temporary link, named by `filename` (also System's exports, S9). */
export function saveResponse(blob, filename) {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
+6 -1
View File
@@ -18,7 +18,7 @@ import { mountInspect } from './inspect.mjs';
import { mountReview } from './review.mjs';
import { createPromptSender } from './prompt-sender.mjs';
import { mountSystem } from './system.mjs';
import { onRestoreFinished } from './system-backups.mjs';
import { onRestoreFinished, onRestoreHandedBack, showRestoreReview } from './system-backups.mjs';
import { mountLibraryBanner } from './library-location.mjs';
import { detectJxlSupport } from './thumbnail-support.mjs';
@@ -169,6 +169,11 @@ function mountWorkspace() {
savedViews.load();
if (currentKey !== 'activity') navigateTo(window.location.hash);
}));
// S9 I1: a restore this page started that could not run yet (other requests kept reading) hands its review back:
// it opens again over the page shown now, with Restore (again) and Discard; leaving it any other way discards it.
scope.cleanup(onRestoreHandedBack(activity, review => {
if (viewScope && !viewScope.signal.aborted) showRestoreReview({ api, store: activity, scope: viewScope, ...review, again: true });
}));
let viewScope;
let firstRender = true;
// D62: a page whose teardown must wait (Inspect, while a video is still starting) holds the next route until it
+14 -10
View File
@@ -155,7 +155,7 @@ export function reviewRefusal(error) {
}
/** Hold `trigger` aria-disabled with `busyText` while `work()` runs; a second press is ignored. */
async function holding(trigger, busyText, work) {
export async function holding(trigger, busyText, work) {
if (trigger?.getAttribute('aria-disabled') === 'true') return;
const text = trigger?.textContent;
trigger?.setAttribute('aria-disabled', 'true');
@@ -169,9 +169,11 @@ async function holding(trigger, busyText, work) {
}
/**
* Apply a reviewed plan's `token` (at `url`: maintenance's apply, or the LoRA import's, S10); the job opens in Activity.
* `reviewAgain()` reviews the operation once more when the server says the review expired, the database was replaced
* or (the LoRA import) its files changed.
* Apply a reviewed plan's `token` (at `url`: maintenance's apply, the LoRA import's (S10) or the restore's (S9)); the
* job opens in Activity. `reviewAgain()` reviews the operation once more when the server says the review expired, the
* database was replaced or (the LoRA import) its files changed. Resolves with the outcome, which maintenance and the
* LoRA import ignore and the restore acts on (S9 I1): `{job}` once started, `{refused: error}` when the server or the
* network refused it, `{lost: true}` when the answer could not be read (the job may be running).
*/
export async function applyPlan({ api, store, scope, token, reviewAgain, notify = showWorkspaceToast, navigate = navigateTo,
url = APPLY_URL }) {
@@ -179,12 +181,12 @@ export async function applyPlan({ api, store, scope, token, reviewAgain, notify
try {
result = await api.post(url, { token });
} catch (error) {
if (scope.signal.aborted) return;
if (scope.signal.aborted) return { refused: error };
const refusal = applyRefusal(error);
notify(refusal.toast);
if (refusal.lost) store.load();
if (refusal.review) reviewAgain?.();
return;
return refusal.lost ? { lost: true } : { refused: error };
}
let job;
try {
@@ -194,11 +196,13 @@ export async function applyPlan({ api, store, scope, token, reviewAgain, notify
} catch {
if (!scope.signal.aborted) notify(NOT_CONFIRMED);
store.load();
return;
return { lost: true };
}
if (scope.signal.aborted) return;
notify(`${job.title} started.`);
navigate(`#/activity/${job.id}`);
if (!scope.signal.aborted) {
notify(`${job.title} started.`);
navigate(`#/activity/${job.id}`);
}
return { job };
}
/** Show the reviewed `plan` for `operation`; Apply applies exactly this token. */
+87 -38
View File
@@ -13,12 +13,16 @@
* hold line, the D55 backup box and Cancel / "Restore" (`danger`). Leaving it any way but Restore discards the stage
* (`DELETE /prompt_manager/restore/preview/<token>`). Restore starts the "Database restore" Activity job (exclusive,
* not cancellable) and opens its view; a finished restore reloads the open views (`onRestoreFinished`, index.mjs).
* A file the server refuses toasts its validator's reason; nothing is staged.
* I1: a Restore the server refuses as busy reopens the same review (Restore retries, Discard releases); a job that
* hands its review back (the leases did not drain) reopens it in this page (`onRestoreHandedBack`, index.mjs);
* every other path that abandons the token discards it. The picker has no type filter (M3: the safety backups have
* no `.db` extension); a file the server refuses toasts its validator's reason, and nothing is staged.
* - "Portable export": D103 "Export JSON" downloads the Library document (`/library/export`); the secondary row
* downloads the legacy components list (`/export`) and the saved pairs (`/pairs/export`). "Import / inspect file"
* downloads every prompt (`/export`, the legacy admin Export) and the saved pairs (`/pairs/export`). "Import / inspect file"
* opens the shared "Import & inspect" (D15, D55): Inspect a local image, Rescan and Scan for metadata.
* - D104 "Recovery policy" (the concept's "Proposed" chip kept, Q4): "Create a safety backup before maintenance" is
* the server's `safety_backup` policy, drafted behind the Save row and saved with the revision; "Scheduled database
* the server's `safety_backup` policy (its line says what it does), drafted behind the Save row and saved with the
* revision; "Scheduled database
* backups" offers Manual only, Daily and Weekly disabled with the reason; the warning note is truthful.
*
* Every request goes through the api client (or `fetchUrl` for a download or an upload); every listener sits on the
@@ -28,8 +32,8 @@ import { createButton } from '../shared/toolkit/core.js';
import { navigateTo } from './state.mjs';
import { showWorkspaceToast } from './toast.mjs';
import { settingRow } from './system-controls.mjs';
import { busyToast, downloadDatabaseBackup, openMetadataScan } from './activity-scan.mjs';
import { applyPlan } from './maintenance-review.mjs';
import { busyToast, downloadDatabaseBackup, openMetadataScan, saveResponse } from './activity-scan.mjs';
import { applyPlan, holding } from './maintenance-review.mjs';
import { RESCAN_FAILED, RESCAN_STARTED, openImportDialog, openReviewDialog, rescanFinished } from './workspace-dialogs.mjs';
import { noun } from './plural.mjs';
@@ -51,13 +55,20 @@ export const SOURCES_FAILED = 'The output folders could not be read. Try again.'
export const HOST_UNAVAILABLE = 'Server settings could not be read.';
export const SCHEDULE_REASON = 'Daily and Weekly are not available yet: back up with Back up whenever you choose.';
export const WARNING_NOTE = 'Restores are staged, migrated and validated before replacing live data.';
// D104 (M2): what the safety-backup policy does (the concept's "Preview a verified backup…" line misstates it).
export const SAFETY_DESCRIPTION = 'Keep a verified copy of the database on disk before maintenance, cleanups and LoRA ' +
'imports change records. A restore always keeps one.';
// I1: a third review while two are held (another window's): what the user can do about it.
export const REVIEWS_FULL = 'Two restores are already being reviewed in another window. Discard one there, or try again ' +
'when it expires (15 minutes after it was reviewed).';
export const RESTORE_BACKUP_COPY = Object.freeze({ heading: 'Back up the database first (recommended)',
text: 'Downloads a copy of the prompts database before the restore replaces it.' });
/** D103: the concept's Export plus the two secondary exports: `[label, url, fallback file name]`. */
export const EXPORTS = Object.freeze([
['Export JSON', '/prompt_manager/library/export', 'prompt-manager-library-v1.json'],
['Export components JSON', '/prompt_manager/export', 'prompt-manager-components.json'],
// N1: the legacy admin page's Export: every prompt, as `{export_date, total_prompts, prompts}`.
['Export all prompts JSON', '/prompt_manager/export', 'prompt_manager_prompts.json'],
['Export saved pairs JSON', '/prompt_manager/pairs/export', 'prompt-pairs.json'],
]);
@@ -118,10 +129,15 @@ export function integrityText(preview) {
return parts.join(' · ');
}
/** A file size: whole KB below 1 MB (never "0.0 MB"), else MB with one decimal. */
function sizeText(bytes) {
if (bytes < 2 ** 20) return `${number(Math.max(1, Math.round(bytes / 1024)))} KB`;
return `${(bytes / 2 ** 20).toLocaleString('en-US', { minimumFractionDigits: 1, maximumFractionDigits: 1 })} MB`;
}
/** The Snapshot value: the chosen file's name, size and date. */
export function snapshotText(file) {
const size = Number.isFinite(file?.size) ? ` · ${(file.size / 2 ** 20).toLocaleString('en-US', { minimumFractionDigits: 1,
maximumFractionDigits: 1 })} MB` : '';
const size = Number.isFinite(file?.size) ? ` · ${sizeText(file.size)}` : '';
const when = Number.isFinite(file?.lastModified) ? whenText(new Date(file.lastModified).toISOString()) : null;
return `${file?.name ?? 'Backup file'}${size}${when ? ` · ${when}` : ''}`;
}
@@ -155,6 +171,7 @@ export function restoreNote(preview, { now = Date.now() } = {}) {
/** The toast for a review the server refused (its reason, a running job) or that could not be read. */
export function reviewRefusal(error) {
if (error?.code === 'busy') return busyToast(error.running);
if (error?.status === 429) return REVIEWS_FULL;
if (error?.refused === true && typeof error.message === 'string' && error.message) {
if (error.code === 'invalid-backup') return `This file can't be restored: ${error.message.replace(/\.?$/, '.')}`;
return error.message;
@@ -188,18 +205,63 @@ export function discardRestore(token, { fetchUrl = window.ApiClient.fetchUrl } =
.then(() => {}, () => {});
}
/** Hold `trigger` aria-disabled with `busyText` while `work()` runs; a second press is ignored. */
async function holding(trigger, busyText, work) {
if (trigger?.getAttribute('aria-disabled') === 'true') return;
const text = trigger?.textContent;
trigger?.setAttribute('aria-disabled', 'true');
if (trigger) trigger.textContent = busyText;
try {
await work();
} finally {
trigger?.removeAttribute('aria-disabled');
if (trigger) trigger.textContent = text;
}
/**
* I1: what to do with a reviewed token once Restore was pressed: `follow` the started job (the page keeps the review
* until it ends, in case the job hands it back), `reopen` the review after a busy refusal (the server keeps the token:
* Restore retries, Discard releases), and `release` it on every other path (a refusal that dropped it, a used one, a
* lost answer): a DELETE of a token the server no longer holds, or a job took, changes nothing.
*/
export function afterApply(outcome) {
if (outcome?.job) return 'follow';
if (outcome?.refused?.code === 'busy') return 'reopen';
return 'release';
}
// I1: the reviews whose restore job is running, by job id, so a job that hands its review back reopens it here.
const startedReviews = new Map();
/** Keep `review` (`{token, preview, file}`) until the restore job `jobId` ends. */
export function rememberRestore(jobId, review) {
startedReviews.set(jobId, review);
}
/**
* Call `onKept(review)` when a restore job this page started fails and the server kept its review
* (`scope.review_kept`, the leases did not drain); any other end forgets the review (the server discarded or used its
* stage). Returns the unsubscribe.
*/
export function onRestoreHandedBack(activity, onKept) {
return activity.subscribe(() => {
for (const job of activity.jobs()) {
if (job.kind !== RESTORE_KIND || job.state === 'running' || !startedReviews.has(job.id)) continue;
const review = startedReviews.get(job.id);
startedReviews.delete(job.id);
if (job.state === 'failed' && job.scope?.review_kept === true) onKept(review);
}
});
}
/**
* D105: "Restore preview" for a staged `preview` of `file`. Leaving it any way but Restore discards the stage; after a
* refusal or a handed-back job (`again`), Cancel reads "Discard". Restore applies exactly this token and acts on the
* outcome (`afterApply`).
*/
export function showRestoreReview({ api, store, scope, preview, file, trigger = null, onBackup = () => downloadDatabaseBackup(),
again = false, reviewAgain = null, notify = showWorkspaceToast, navigate = navigateTo, fetchUrl = window.ApiClient.fetchUrl }) {
const token = preview.token;
const show = (retry, shownScope = scope) => showRestoreReview({ api, store, scope: shownScope, preview, file, trigger,
onBackup, again: retry, reviewAgain, notify, navigate, fetchUrl });
openReviewDialog({ scope, trigger, title: RESTORE_TITLE, grid: restoreGrid(preview, file),
sentence: restoreNote(preview), backup: { copy: RESTORE_BACKUP_COPY, onBackup },
cancelLabel: again ? 'Discard' : 'Cancel', primary: { label: RESTORE_LABEL, danger: true },
onDismiss: () => discardRestore(token, { fetchUrl }),
onStart: async () => {
const outcome = await applyPlan({ api, store, scope, token, reviewAgain, notify, navigate, url: APPLY_URL });
const next = afterApply(outcome);
if (next === 'follow') rememberRestore(outcome.job.id, { token, preview, file });
else if (next === 'reopen' && !scope.signal.aborted) show(true);
else discardRestore(token, { fetchUrl });
} });
}
/**
@@ -220,13 +282,8 @@ export async function reviewRestore({ api, store, scope, file, trigger = null, h
discardRestore(preview.token, { fetchUrl });
return;
}
const again = () => reviewRestore({ api, store, scope, file, trigger, onBackup, notify, navigate, fetchUrl });
openReviewDialog({ scope, trigger, title: RESTORE_TITLE, grid: restoreGrid(preview, file),
sentence: restoreNote(preview), backup: { copy: RESTORE_BACKUP_COPY, onBackup },
cancelLabel: 'Cancel', primary: { label: RESTORE_LABEL, danger: true },
onDismiss: () => discardRestore(preview.token, { fetchUrl }),
onStart: () => applyPlan({ api, store, scope, token: preview.token, reviewAgain: again, notify, navigate,
url: APPLY_URL }) });
const reviewAgain = () => reviewRestore({ api, store, scope, file, trigger, onBackup, notify, navigate, fetchUrl });
showRestoreReview({ api, store, scope, preview, file, trigger, onBackup, reviewAgain, notify, navigate, fetchUrl });
});
}
@@ -244,15 +301,7 @@ export async function downloadExport([, url, fallback], { fetchUrl = window.ApiC
if (!response.ok) throw new Error(`Export failed with ${response.status}`);
const blob = await response.blob();
if (signal?.aborted) return false;
const href = URL.createObjectURL(blob);
const link = el('a');
link.href = href;
link.download = filenameOf(response, fallback);
link.hidden = true;
document.body.append(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(href), 1000);
saveResponse(blob, filenameOf(response, fallback));
return true;
} catch {
if (!signal?.aborted) notify(EXPORT_FAILED);
@@ -311,7 +360,7 @@ export function mountBackups(container, { api, activity, scope, drafts, notify =
const restore = createButton('Review restore…', 'danger');
const picker = el('input');
picker.type = 'file';
picker.accept = '.db';
// M3: no type filter: the safety backups (`prompts.db.backup_<time>`) have no `.db` extension; the server validates.
picker.hidden = true;
picker.id = 'restore-file';
picker.setAttribute('aria-label', 'Backup file to restore');
@@ -386,7 +435,7 @@ export function mountBackups(container, { api, activity, scope, drafts, notify =
const heading = el('h2', 'Recovery policy ');
heading.append(el('span', 'Proposed', 'chip blue'));
const safety = settingRow({ id: 'backup-before', label: 'Create a safety backup before maintenance',
description: 'Preview a verified backup before applying operations that change records.',
description: SAFETY_DESCRIPTION,
control: { type: 'toggle' }, scope, onChange: value => drafts.set('host', 'safety_backup', value) });
const safetyInput = safety.querySelector('#backup-before');
safetyInput.dataset.key = 'safety_backup';