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. |
| 24 | import fs from 'node:fs'; |
| 25 | import { open, errors, signInAs, scratch } from './harness.mjs'; |
| 26 | |
| 27 | const ok = [], bad = []; |
| 28 | const check = (name, pass, detail) => { |
| 29 | (pass ? ok : bad).push(name + (detail ? ' — ' + detail : '')); |
| 30 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 31 | }; |
| 32 | |
| 33 | const 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. |
| 36 | const MARKER = 'DO-NOT-LOSE-THIS-9f3a1c'; |
| 37 | |
| 38 | /// A workspace file read straight out of OPFS, outside the app entirely. |
| 39 | const 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 ─────────────────────── |
| 48 | const a = await open({ name: 'backupA' }); |
| 49 | if (errors(a).length) console.log('A load errors:', errors(a)); |
| 50 | await 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]); |
| 54 | check('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. |
| 58 | await a.page.click('#user-row'); |
| 59 | await a.page.waitForTimeout(400); |
| 60 | const dl = a.page.waitForEvent('download', { timeout: 15000 }); |
| 61 | await a.page.click('button.admin-item:has-text("Export a backup")'); |
| 62 | const download = await dl; |
| 63 | const path = scratch('backup-test.json'); |
| 64 | await download.saveAs(path); |
| 65 | const backup = JSON.parse(fs.readFileSync(path, 'utf8')); |
| 66 | |
| 67 | const ws = backup.workspace || []; |
| 68 | const found = ws.find(f => f.path === WS_PATH); |
| 69 | console.log('backup format:', backup.format, 'workspace files:', ws.length); |
| 70 | check('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(', ')}`); |
| 72 | const packed = found ? Buffer.from(found.b64, 'base64').toString('utf8') : ''; |
| 73 | check('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. |
| 78 | check('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); |
| 81 | await a.close(); |
| 82 | |
| 83 | // ── Session B: fresh profile, import, confirm the file is back ─────────── |
| 84 | const b = await open({ name: 'backupB' }); |
| 85 | check('session B starts without the file', /^\(/.test(await opfsRead(b, WS_PATH)), |
| 86 | JSON.stringify(await opfsRead(b, WS_PATH))); |
| 87 | await b.page.click('#user-row'); |
| 88 | await b.page.waitForTimeout(400); |
| 89 | // The file input is created on click; set its files via the chooser. |
| 90 | const chooser = b.page.waitForEvent('filechooser', { timeout: 15000 }); |
| 91 | await b.page.click('button.admin-item:has-text("Import a backup")'); |
| 92 | const fc = await chooser; |
| 93 | await 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. |
| 98 | await b.page.waitForSelector('.dlg-ok', { timeout: 15000 }); |
| 99 | await b.page.click('.dlg-ok'); |
| 100 | await b.page.waitForSelector('#id-primary', { timeout: 15000 }); |
| 101 | await 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. |
| 104 | const restored = await opfsRead(b, WS_PATH); |
| 105 | console.log('\nSession B, after import, the file on disk:', JSON.stringify(restored)); |
| 106 | check('IMPORT RESTORES WORKSPACE — the file is on disk in session B, with its bytes', |
| 107 | restored === MARKER, JSON.stringify(restored)); |
| 108 | |
| 109 | const errs = errors(b).filter(e => !/502|Bad Gateway/.test(e)); |
| 110 | check('nothing threw in session B', errs.length === 0, errs.slice(0, 2).join(' | ')); |
| 111 | await b.close(); |
| 112 | |
| 113 | console.log(`\n${ok.length} passed, ${bad.length} failed`); |
| 114 | if (bad.length) { bad.forEach(x => console.log(' FAILED: ' + x)); process.exit(1); } |