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:
+26
-8
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 · .+$/);
|
||||
});
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user