Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_backup.mjs

6.2 KiB, 1 run

created by r2519314175:251, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1// A backup must contain the workspace, and must restore it. Session A puts a file
2// in the workspace, exports a backup, and we read the download. Session B (a fresh
3// profile) imports it and we confirm the file is back.
4//
5// TWO REPAIRS, 2026-08-14, and the second matters more than the first.
6//
7// THE SEED IS OUT OF BAND. It used to be `@tool file_write {"path":"keep/important.txt"}`
8// typed into a chat. Since the chat fence landed on 2026-08-12 a chat is confined to
9// `chats/<id>/work` (`scopeChatTo`, www/js/daimond.js), and `Tool::guard`
10// (src/tools.rs:5490) refuses a workspace-ROOT path before anything is written. The
11// refusal came back as an ordinary tool result: nothing was written, nothing threw,
12// and the export below packed an empty workspace. The seed now goes through the
13// engine's own door instead, which is where a fixture that is not ABOUT a turn belongs.
14//
15// AND THE RESTORE IS NO LONGER READ OUT OF A TRANSCRIPT, which is the older and worse
16// defect: this file has never once proved a restore. The check was
17// `/DO NOT LOSE THIS/.test(session B's visible transcript)` — and that phrase was the
18// USER'S OWN TYPED TEXT, the first message of session A's chat, restored with the
19// CONVERSATION. So it passed with an export carrying zero workspace files and an
20// import restoring zero, which is exactly the state it was in. The marker below is
21// therefore never typed by anybody, the restore is read from OPFS in session B, and a
22// check further down asserts the marker is absent from the backup's chats — so if a
23// later hand moves the seed back into a turn, that check goes red and says why.
24import fs from 'node:fs';
25import { open, errors, signInAs, scratch } from './harness.mjs';
26
27const ok = [], bad = [];
28const check = (name, pass, detail) => {
29 (pass ? ok : bad).push(name + (detail ? ' — ' + detail : ''));
30 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
31};
32
33const WS_PATH = 'keep/important.txt';
34// Never typed into a chat, never in a prompt, never in a name: the ONLY way this
35// string can be found anywhere is if the FILE was carried and restored.
36const MARKER = 'DO-NOT-LOSE-THIS-9f3a1c';
37
38/// A workspace file read straight out of OPFS, outside the app entirely.
39const opfsRead = (s, p) => s.page.evaluate(async (p) => {
40 const parts = p.split('/');
41 let d = await navigator.storage.getDirectory();
42 for (const seg of parts.slice(0, -1)) d = await d.getDirectoryHandle(seg);
43 const fh = await d.getFileHandle(parts[parts.length - 1]);
44 return await (await fh.getFile()).text();
45}, p).catch((e) => '(' + String(e).split('\n')[0] + ')');
46
47// ── Session A: put a file in the workspace, export ───────────────────────
48const a = await open({ name: 'backupA' });
49if (errors(a).length) console.log('A load errors:', errors(a));
50await a.page.evaluate(async ([p, body]) => {
51 const mod = await import('/pkg/oxedyne_daimond.js');
52 await mod.write_file(p, body);
53}, [WS_PATH, MARKER]);
54check('session A really has the file in its workspace',
55 await opfsRead(a, WS_PATH) === MARKER, JSON.stringify(await opfsRead(a, WS_PATH)));
56
57// Open the account menu and click Export, capturing the download.
58await a.page.click('#user-row');
59await a.page.waitForTimeout(400);
60const dl = a.page.waitForEvent('download', { timeout: 15000 });
61await a.page.click('button.admin-item:has-text("Export a backup")');
62const download = await dl;
63const path = scratch('backup-test.json');
64await download.saveAs(path);
65const backup = JSON.parse(fs.readFileSync(path, 'utf8'));
66
67const ws = backup.workspace || [];
68const found = ws.find(f => f.path === WS_PATH);
69console.log('backup format:', backup.format, 'workspace files:', ws.length);
70check('EXPORT CONTAINS WORKSPACE — the file is an entry in the backup',
71 !!found, `${ws.length} workspace file(s): ${ws.map(f => f.path).slice(0, 5).join(', ')}`);
72const packed = found ? Buffer.from(found.b64, 'base64').toString('utf8') : '';
73check('and its bytes round-trip through the backup, not just its name',
74 packed === MARKER, JSON.stringify(packed));
75// The guard that keeps this file honest. If the marker is in the conversation, the
76// restore check below could be satisfied by the CHAT coming back and would prove
77// nothing about the workspace — which is how this test passed for months.
78check('the marker is nowhere in the backup\'s chats, so only the FILE can carry it',
79 !JSON.stringify(backup.chats || []).includes(MARKER),
80 'chats bytes: ' + JSON.stringify(backup.chats || []).length);
81await a.close();
82
83// ── Session B: fresh profile, import, confirm the file is back ───────────
84const b = await open({ name: 'backupB' });
85check('session B starts without the file', /^\(/.test(await opfsRead(b, WS_PATH)),
86 JSON.stringify(await opfsRead(b, WS_PATH)));
87await b.page.click('#user-row');
88await b.page.waitForTimeout(400);
89// The file input is created on click; set its files via the chooser.
90const chooser = b.page.waitForEvent('filechooser', { timeout: 15000 });
91await b.page.click('button.admin-item:has-text("Import a backup")');
92const fc = await chooser;
93await fc.setFiles(path);
94
95// A restore rewrites the workspace out from under the running engine, so the app
96// confirms and then reloads to bring every restored surface back consistent.
97// Acknowledge the notice, let it reload, and unlock the fresh session.
98await b.page.waitForSelector('.dlg-ok', { timeout: 15000 });
99await b.page.click('.dlg-ok');
100await b.page.waitForSelector('#id-primary', { timeout: 15000 });
101await signInAs(b, 'backupB'); // unlock the reloaded session
102
103// READ FROM OPFS, not from the thread: the store is what a restore has to reach.
104const restored = await opfsRead(b, WS_PATH);
105console.log('\nSession B, after import, the file on disk:', JSON.stringify(restored));
106check('IMPORT RESTORES WORKSPACE — the file is on disk in session B, with its bytes',
107 restored === MARKER, JSON.stringify(restored));
108
109const errs = errors(b).filter(e => !/502|Bad Gateway/.test(e));
110check('nothing threw in session B', errs.length === 0, errs.slice(0, 2).join(' | '));
111await b.close();
112
113console.log(`\n${ok.length} passed, ${bad.length} failed`);
114if (bad.length) { bad.forEach(x => console.log(' FAILED: ' + x)); process.exit(1); }