Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_migration.mjs

11.3 KiB, 1 run

created by r2519314175:533, 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// verify_migration.mjs — a workspace written before the rename must open with everything in it.
2//
3// The Red → Daimond rename moved two things a user's workspace actually holds: the per-Diamond
4// store (`diamonds/<id>/.red/` → `.daimond/`) and the standing-instructions file (`RED.md` →
5// `DAIMOND.md`). Neither is Daimond's to lose. A Diamond whose store is not found does not fail
6// loudly — `read_meta` errors, `list` skips it, and a pursuit with a year of folds in it simply
7// is not in the list any more. That is the failure this drives.
8//
9// So the test seeds a workspace exactly as the old code would have left one, boots the app on it
10// the way a user does, and then asks the questions a user would: is my Diamond there, is its
11// history there, and can I still read the delta of a fold I made before the rename?
12//
13// The seed below lands AFTER a boot, and that is the harder of the two orders on purpose.
14// `open()` signs in, which draws the app, which runs `Instructions.refresh()` -- and since the
15// starter shipped that refresh puts a `DAIMOND.md` in the store before this file has written a
16// line. So the pre-rename `RED.md` here arrives beside a starter, exactly as one carried in by
17// sync or found in a folder opened later does, and the migration has to beat a file that is
18// already sitting at its destination. The easy order -- `RED.md` alone, no starter anywhere --
19// is the one that never needed a check.
20import { open, signInAs, shot } from './harness.mjs';
21
22const ok = [], bad = [];
23const check = (name, pass, detail) => {
24 (pass ? ok : bad).push(name + (detail ? ' — ' + detail : ''));
25 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
26};
27
28const ID = 'legacy1';
29const RULE = 'HOUSE RULE: every answer ends with ZZ-HOUSE.';
30
31// A fresh profile, so the OPFS sandbox starts empty and what is in it is what we put there.
32const s = await open({ name: 'migrate', connect: false });
33const p = s.page;
34await p.waitForTimeout(1200);
35
36// ── Seed a workspace as the pre-rename code would have left it ──────────
37//
38// Written through the wasm file surface, which with no real folder open resolves against the
39// same OPFS sandbox the Diamond store lives in — so these are the very bytes the old app wrote.
40const seeded = await p.evaluate(async ({ id, rule }) => {
41 const mod = await import('../pkg/oxedyne_daimond.js');
42 const w = (path, content) => mod.write_file(path, content);
43
44 // The crystal and its snapshots sit OUTSIDE the store directory and never moved.
45 // It is markdown, because that is what a workspace of this vintage holds: the
46 // boot below therefore runs the crystal's own conversion as well as the store
47 // move, which is exactly what a real one of these does.
48 await w(`diamonds/${id}/crystal.md`, '# The old pursuit\n\nA crystal written before the rename.\n');
49 await w(`diamonds/${id}/versions/0000.md`, '');
50 await w(`diamonds/${id}/versions/0001.md`, '# The old pursuit\n\nA crystal written before the rename.\n');
51
52 // The store, under its old name.
53 await w(`diamonds/${id}/.red/meta.json`,
54 '{"name":"An old pursuit","crystal_version":1,"updated":1750000000000}');
55 await w(`diamonds/${id}/.red/deltas/0001.md`, 'THE-OLD-DELTA: what the fold consumed.');
56 // Two log records. The fold's `delta_ref` is a PATH, and it points into the old directory —
57 // which is why moving the files alone would not be enough.
58 await w(`diamonds/${id}/.red/log`,
59 '{"id":"r1","ts":1750000000000,"kind":"create","agent":"user","task":"create diamond",'
60 + '"parent_crystal_version":-1,"crystal_version":0,"delta_ref":"","note":"An old pursuit"}\n'
61 + '{"id":"r2","ts":1750000000001,"kind":"fold","agent":"reducer","task":"fold delta",'
62 + '"parent_crystal_version":0,"crystal_version":1,'
63 + `"delta_ref":"diamonds/${id}/.red/deltas/0001.md","note":"folded before the rename"}\n`);
64
65 // And the house rules, at the workspace root, under their old name.
66 await w('RED.md', rule);
67 return true;
68}, { id: ID, rule: RULE });
69check('a pre-rename workspace was seeded', seeded === true);
70
71// ── Boot the app on it, the way a user does ─────────────────────────────
72await p.reload({ waitUntil: 'domcontentloaded' });
73await signInAs(s, 'migrate');
74await p.waitForTimeout(2500); // loadDiamonds() and Instructions.refresh() run on unlock
75
76// ── What the user sees ──────────────────────────────────────────────────
77const listed = await p.$eval('#diamond-list', e => e.textContent);
78check('the Diamond is still in the list', /An old pursuit/.test(listed), listed.trim().slice(0, 60));
79
80const chip = await p.evaluate(() => {
81 const el = document.getElementById('instructions-chip');
82 return el && el.style.display !== 'none' ? el.textContent.trim() : '(hidden)';
83});
84check('the standing instructions are still in force', /DAIMOND\.md/.test(chip), chip);
85
86// ── What is actually on disk ────────────────────────────────────────────
87const disk = await p.evaluate(async ({ id }) => {
88 const mod = await import('../pkg/oxedyne_daimond.js');
89 const read = async (path) => {
90 try { return await mod.read_file(path); }
91 catch (e) { return null; } // absent, which for the old paths is the point
92 };
93 return {
94 newMeta: await read(`diamonds/${id}/.daimond/meta.json`),
95 newLog: await read(`diamonds/${id}/.daimond/log`),
96 newDelta: await read(`diamonds/${id}/.daimond/deltas/0001.md`),
97 oldMeta: await read(`diamonds/${id}/.red/meta.json`),
98 oldLog: await read(`diamonds/${id}/.red/log`),
99 oldDelta: await read(`diamonds/${id}/.red/deltas/0001.md`),
100 crystal: await read(`diamonds/${id}/crystal.json`),
101 oldCrystal: await read(`diamonds/${id}/crystal.md`),
102 version: await read(`diamonds/${id}/versions/0001.md`),
103 newRules: await read('DAIMOND.md'),
104 oldRules: await read('RED.md'),
105 };
106}, { id: ID });
107
108check('the store moved to .daimond/', !!disk.newMeta && /An old pursuit/.test(disk.newMeta));
109check('the log came with it', !!disk.newLog && /folded before the rename/.test(disk.newLog));
110check('the retained delta came with it',
111 disk.newDelta === 'THE-OLD-DELTA: what the fold consumed.');
112check('nothing was left behind in .red/',
113 disk.oldMeta === null && disk.oldLog === null && disk.oldDelta === null);
114// The store move leaves the crystal alone; the crystal's own conversion, which
115// runs in the same pass, turns it into data. Both migrations are asserted by
116// what SURVIVES rather than by what moved: the user's words are in `crystal.json`
117// and the markdown history is exactly where it was.
118check('the crystal came through as data, carrying the same words',
119 /A crystal written before the rename/.test(disk.crystal || '')
120 && (disk.crystal || '').trim().startsWith('{'),
121 (disk.crystal || '(absent)').slice(0, 80));
122// The markdown is KEPT beside it, deliberately. The conversion self-checks by
123// rendering back and comparing bytes, which structurally cannot prove the
124// STRUCTURE is right -- a `##` inside a fence rejoins to identical bytes whether
125// or not the fence was honoured -- so the one failure that would justify still
126// having the markdown is exactly the one the check cannot see. And
127// `import_diamond` deletes a Diamond's directory before rewriting it, so a bad
128// conversion propagates back over a good copy on the next sync.
129check('and the markdown is kept beside it, because lossless is not the same as proven',
130 disk.oldCrystal !== null, disk.oldCrystal === null ? 'DELETED' : 'kept');
131check('the markdown snapshots were untouched, so the conversion is reversible',
132 /A crystal written before the rename/.test(disk.version || ''));
133
134// The one that a directory move alone would fail: the log points at the delta BY PATH.
135check('the log’s delta_ref was rewritten to the new path',
136 !!disk.newLog && disk.newLog.includes(`diamonds/${ID}/.daimond/deltas/0001.md`)
137 && !disk.newLog.includes('.red/'),
138 (disk.newLog || '').split('\n')[1]?.slice(0, 72));
139
140check('RED.md became DAIMOND.md', disk.newRules === RULE && disk.oldRules === null);
141
142// ── The history the user opens, and the delta they click ────────────────
143const history = await p.evaluate(async ({ id }) => {
144 const mod = await import('../pkg/oxedyne_daimond.js');
145 // Read the delta exactly as the History panel does: by the path the log record carries.
146 const app = new mod.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 256, '', true);
147 const recs = JSON.parse(await app.log_read(id) || '[]');
148 const fold = recs.find(r => r.kind === 'fold');
149 if (!fold || !fold.delta_ref) return { recs: recs.length, delta: null };
150 const delta = await app.run_tool('file_read', JSON.stringify({ path: fold.delta_ref }));
151 return { recs: recs.length, ref: fold.delta_ref, delta };
152}, { id: ID });
153check('the history reads back whole', history.recs === 2, history.recs + ' records');
154check('a fold made before the rename can still show its delta',
155 /THE-OLD-DELTA/.test(history.delta || ''),
156 history.ref);
157
158// ── Idempotence: a second boot must not undo the first ──────────────────
159await p.reload({ waitUntil: 'domcontentloaded' });
160await signInAs(s, 'migrate');
161await p.waitForTimeout(2000);
162const again = await p.$eval('#diamond-list', e => e.textContent);
163const stillThere = await p.evaluate(async ({ id }) => {
164 const mod = await import('../pkg/oxedyne_daimond.js');
165 try { return await mod.read_file(`diamonds/${id}/.daimond/meta.json`); } catch { return null; }
166}, { id: ID });
167check('a second boot migrates nothing and breaks nothing',
168 /An old pursuit/.test(again) && !!stillThere);
169
170// ── The other side of it: rules the user WROTE are not overwritten ──────
171//
172// `migrate` carries `RED.md` over a `DAIMOND.md` that is the starter to the byte. This is the
173// check that keeps that from becoming "carries it over anything": edit the file the way a user
174// would, put an old `RED.md` back beside it, and the edit must still be there afterwards. Break
175// the byte comparison and this goes red while the check above stays green, which is the only
176// arrangement in which either of them is worth reading.
177const MINE = 'MY OWN RULES: answer in Latin.';
178await p.evaluate(async ({ mine, rule }) => {
179 const mod = await import('../pkg/oxedyne_daimond.js');
180 await mod.write_file('DAIMOND.md', mine);
181 await mod.write_file('RED.md', rule);
182}, { mine: MINE, rule: RULE });
183await p.reload({ waitUntil: 'domcontentloaded' });
184await signInAs(s, 'migrate');
185await p.waitForTimeout(2500);
186const kept = await p.evaluate(async () => {
187 const mod = await import('../pkg/oxedyne_daimond.js');
188 const read = async (path) => { try { return await mod.read_file(path); } catch (e) { return null; } };
189 return { mine: await read('DAIMOND.md'), old: await read('RED.md') };
190});
191check('rules the user wrote are not replaced by their old ones',
192 kept.mine === MINE, JSON.stringify(kept.mine));
193check('and the old file is left where it was, not silently dropped',
194 kept.old === RULE, JSON.stringify(kept.old));
195
196await shot(s, 'migration');
197const errs = s.errs.filter(e => !/favicon|404|net::ERR/.test(e));
198console.log('\nconsole errors:', errs.slice(0, 4));
199await s.close();
200
201console.log(`\n${ok.length} passed, ${bad.length} failed`);
202if (bad.length) console.log('FAILED:\n ' + bad.join('\n '));
203process.exit(bad.length ? 1 : 0);