Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_crystalmigrate.mjs

21.1 KiB, 1 run

created by r2519314175:331, 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_crystalmigrate.mjs — `crystal.md` becomes `crystal.json`, and NOTHING
2// IS LOST DOING IT.
3//
4// A Diamond's crystal stops being markdown and becomes data. Every Diamond that
5// exists today has the old shape, so the change reaches all of them at once,
6// through one migration, on one boot — and the crystal is the only place a
7// Diamond's reduced state lives. A migration that drops the last two sections of
8// a file is not a bug the user reports; it is a bug they discover months later
9// when they go looking for something that used to be there.
10//
11// So the property asserted here is NOT the steps of the conversion. Steps can be
12// read off the code and re-asserted in a test that agrees with the code and with
13// nothing else. The property is a ROUND TRIP: rendering the produced data back to
14// markdown reproduces the original file byte for byte. That holds or it does not,
15// whatever the conversion does in the middle, and it is the only statement that
16// covers a shape nobody thought of. Where a shape cannot survive being reshaped,
17// the contract's answer is to carry it verbatim in one section — which the round
18// trip accepts and a step-by-step test would reject.
19//
20// The awkward inputs are chosen because each breaks a different plausible
21// implementation:
22//
23// * a `##` inside a fenced code block breaks a splitter that scans for `^## `,
24// and that is the whole of the naive implementation. A crystal that documents
25// markdown, or holds a snippet of a config file, has one.
26// * a `#` inside a fence BEFORE any real heading gives that splitter the wrong
27// title, and the title is what the rail shows and what `name_from_crystal`
28// reads back when a Diamond loses its metadata.
29// * text before the first heading has nowhere to go in the schema, so it is the
30// first thing a conversion silently drops.
31// * an empty file, a whitespace-only file and a file with no headings at all
32// are what a young Diamond actually holds — `create_diamond` writes an empty
33// crystal, so EVERY Diamond starts as case 9.
34// * CRLF, trailing spaces after a heading, and a missing final newline are the
35// three ways a file that looks identical on screen is not identical in bytes.
36//
37// Then the two properties this migration inherits from the `brief.md` → `crystal.md`
38// rename that came before it, because it runs in the same place on the same
39// trigger: it is IDEMPOTENT, and IT NEVER CLOBBERS. A Diamond holding both files
40// is left alone rather than merged; whichever way a merge went it would be
41// guessing, and the losing side is somebody's work.
42//
43// And the redundancy path, which is the most destructive thing in this whole
44// change to get wrong: a Diamond whose `crystal.json` is missing reads its newest
45// `versions/NNNN.json`. A Diamond whose crystal is not found reads as an EMPTY
46// one, and an agent handed an empty crystal will write a new one over work it
47// never saw.
48//
49// How these go red (this lane could not run a browser, so the lead's batched pass
50// is the first time they are exercised):
51//
52// * split on `^## ` without tracking fences → the four fence cases go red and
53// the ordinary one stays green, which is the shape a fence-blind splitter has;
54// * drop the text before the first heading → case 4 goes red;
55// * `toMarkdown` always ending its output with a newline → cases 9 and 11 go red;
56// * let the migration run over a Diamond that already has both files → the
57// clobber check goes red;
58// * revert `read_crystal_data` to a bare read → the two redundancy checks go red.
59//
60// node dev/verify_crystalmigrate.mjs
61//
62// Needs dev/serve.mjs (DAIMOND_PORT, default 8777). No gateway, no mock LLM: nothing
63// here runs a turn.
64import fs from 'node:fs';
65import { open, scratch } from './harness.mjs';
66
67const PROFILE = scratch('pw', 'crystalmigrate');
68fs.rmSync(PROFILE, { recursive: true, force: true });
69
70let bad = 0;
71const check = (pass, name, detail) => {
72 if (!pass) bad++;
73 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
74};
75
76/// Every awkward shape a crystal can arrive in, and what makes each awkward.
77///
78/// Written with explicit `\n` rather than as template literals: a fence inside a
79/// template literal has to be escaped, and an escaped fence in a test about
80/// fences is one transcription error away from testing nothing.
81const ROUNDTRIP = [
82 { name: 'an ordinary crystal',
83 md: '# Title\n\nWhat this Diamond is for.\n\n## One\n\nThe first thing.\n\n## Two\n\nThe second.\n' },
84 { name: 'no headings at all',
85 md: 'Just a paragraph, and then another.\n\nNothing in it is a heading.\n' },
86 { name: 'a heading with nothing under it',
87 md: '# Only a title\n' },
88 { name: 'text before the first heading',
89 md: 'A line that arrived before anything named it.\n\n# Title\n\n## One\n\nBody.\n' },
90 { name: 'a ## line inside a fenced code block',
91 md: '# Title\n\nSummary.\n\n## Real section\n\n```\n## not a heading\n```\n\nAfter the fence.\n' },
92 { name: 'a ## line inside a ~~~ fence',
93 md: '# Title\n\n~~~\n## not a heading either\n~~~\n' },
94 { name: 'a # line inside a fence, before any real heading',
95 md: '```\n# not the title\n```\n\n# The actual title\n\nBody.\n' },
96 { name: 'a ## line indented into a code block',
97 md: '# Title\n\n ## four spaces in, so it is code\n\nAfter it.\n' },
98 { name: 'an empty file',
99 md: '' },
100 { name: 'a file that is only whitespace',
101 md: '\n \n\t\n\n' },
102 { name: 'a file that does not end in a newline',
103 md: '# Title\n\n## One\n\nIt stops here.' },
104 { name: 'a ### under a ##, which is body and not a section of its own',
105 md: '# Title\n\n## One\n\n### Deeper\n\nUnder the deeper one.\n' },
106 { name: '#Nospace, which is not a heading at all',
107 md: '#Nospace\n\n##Nor is this one.\n' },
108 { name: 'a second # heading later in the file',
109 md: '# First\n\nA.\n\n# Second\n\nB.\n' },
110 { name: 'a heading with trailing spaces after it',
111 md: '# Title \n\n## One \n\nBody.\n' },
112 { name: 'CRLF line endings',
113 md: '# Title\r\n\r\nSummary.\r\n\r\n## One\r\n\r\nBody.\r\n' },
114 { name: 'blank lines before anything else',
115 md: '\n\n# Title\n\nBody.\n' },
116 { name: 'a fence nobody closed',
117 md: '# Title\n\n```\n## inside a fence with no end\n' },
118];
119
120const s = await open({ name: 'crystalmigrate', profile: PROFILE, connect: false });
121const { page } = s;
122
123try {
124 await page.waitForTimeout(1500);
125
126 // ── The instrument, installed once ───────────────────────────
127 await page.evaluate(async () => {
128 const mod = await import('../pkg/oxedyne_daimond.js');
129 window.__d = {
130 mod,
131 app: new mod.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 256, '', true),
132 root: await navigator.storage.getDirectory(),
133 };
134 // Seeded through the directory handle, not through the store: the state
135 // under test is a PRE-MIGRATION Diamond, which the store's own writers can
136 // no longer produce.
137 window.__put = async (path, body) => {
138 let cur = __d.root;
139 const parts = path.split('/');
140 for (let i = 0; i < parts.length - 1; i++) {
141 cur = await cur.getDirectoryHandle(parts[i], { create: true });
142 }
143 const fh = await cur.getFileHandle(parts[parts.length - 1], { create: true });
144 const w = await fh.createWritable();
145 await w.write(body);
146 await w.close();
147 };
148 window.__meta = (name) => JSON.stringify(
149 { name: name, crystal_version: 0, updated: 1, touched: 1 });
150 // The bytes on disk, or null. `store_read` pins the OPFS root, where a
151 // Diamond lives whatever folder the user has open; `file_read` is a
152 // model-facing rendering and would number every line.
153 window.__bytes = async (path) => {
154 try { return await __d.mod.store_read(path); } catch (e) { return null; }
155 };
156 // Key order is not meaning. Two migrations that agree on the data can
157 // disagree on the order they emit it in, and a raw string comparison would
158 // call that a difference.
159 window.__canon = (v) => {
160 if (Array.isArray(v)) return '[' + v.map(__canon).join(',') + ']';
161 if (v && typeof v === 'object') {
162 return '{' + Object.keys(v).sort().map((k) =>
163 JSON.stringify(k) + ':' + __canon(v[k])).join(',') + '}';
164 }
165 return JSON.stringify(v === undefined ? null : v);
166 };
167 // Where two strings first part company, in a form that survives the console.
168 window.__where = (a, b) => {
169 if (a === b) return '';
170 const n = Math.min(a.length, b.length);
171 let i = 0;
172 while (i < n && a[i] === b[i]) i++;
173 const win = (x) => JSON.stringify(x.slice(Math.max(0, i - 12), i + 24));
174 return 'at ' + i + ' of ' + a.length + '/' + b.length
175 + ' want ' + win(a) + ' got ' + win(b);
176 };
177 });
178
179 const hasModule = await page.evaluate(() => !!(window.DaimondCrystal
180 && typeof DaimondCrystal.fromMarkdown === 'function'
181 && typeof DaimondCrystal.toMarkdown === 'function'
182 && typeof DaimondCrystal.parse === 'function'));
183 check(hasModule, 'the crystal module is loaded, with the migration and its inverse',
184 hasModule ? '' : 'window.DaimondCrystal is not there, or is missing a half');
185
186 // ── The round trip, over shapes that break different splitters ──
187 const trips = await page.evaluate((cases) => cases.map((c) => {
188 let data, out, err = '';
189 try { data = DaimondCrystal.fromMarkdown(c.md); }
190 catch (e) { err = 'fromMarkdown threw: ' + (e && e.message || e); }
191 if (!err) {
192 try { out = DaimondCrystal.toMarkdown(data); }
193 catch (e) { err = 'toMarkdown threw: ' + (e && e.message || e); }
194 }
195 return {
196 name: c.name,
197 ok: !err && out === c.md,
198 why: err || __where(c.md, String(out)),
199 };
200 }), ROUNDTRIP);
201
202 for (const t of trips) {
203 check(t.ok, 'it round-trips ' + t.name, t.why);
204 }
205
206 // ── ...and the conversion is a conversion ────────────────────
207 //
208 // The round trip alone is satisfied by a migration that does nothing: put the
209 // whole file in one nameless section and hand it back, and all eighteen cases
210 // above pass while no Diamond has ever gained a title, a summary or a section.
211 // That is the vacuous pass this file would otherwise ship with, so the ordinary
212 // case is also asked what it FOUND. Read by meaning rather than by position:
213 // the set of headings, not `sections[1].heading`.
214 const shape = await page.evaluate((md) => {
215 const d = DaimondCrystal.fromMarkdown(md) || {};
216 return {
217 title: d.title || '',
218 summary: String(d.summary || ''),
219 headings: (d.sections || []).map((x) => String(x && x.heading || '')),
220 bodies: (d.sections || []).map((x) => String(x && x.body || '')),
221 };
222 }, ROUNDTRIP[0].md);
223 check(shape.title === 'Title', 'the first # heading becomes the title', JSON.stringify(shape.title));
224 check(/What this Diamond is for\./.test(shape.summary),
225 'the text under it becomes the summary', JSON.stringify(shape.summary));
226 check(shape.headings.includes('One') && shape.headings.includes('Two'),
227 'and every ## becomes a section of its own', JSON.stringify(shape.headings));
228 check(shape.bodies.some((b) => /The second\./.test(b)),
229 'carrying the text that was under it', JSON.stringify(shape.bodies));
230
231 // A file with no headings becomes one section with an empty heading, which is
232 // what the contract says and what keeps `sections` the place everything ends up
233 // when nothing else fits.
234 const bare = await page.evaluate((md) => {
235 const d = DaimondCrystal.fromMarkdown(md) || {};
236 return {
237 headings: (d.sections || []).map((x) => String(x && x.heading || '')),
238 kept: JSON.stringify(d).indexOf('Nothing in it is a heading') >= 0,
239 };
240 }, ROUNDTRIP[1].md);
241 check(bare.kept, 'a file with no headings keeps its text somewhere', JSON.stringify(bare));
242 check(bare.headings.length > 0 && bare.headings.every((h) => h === ''),
243 'under a section with no heading, rather than under an invented one',
244 JSON.stringify(bare.headings));
245
246 // `parse` is the door every one of these arrives through, and it must never
247 // throw: the ✎ editor and the fallback view both call it on text a person or a
248 // model just typed.
249 const parsed = await page.evaluate(() => {
250 const bad1 = DaimondCrystal.parse('{"title": "unterminated');
251 const good = DaimondCrystal.parse('{"title":"fine"}');
252 return {
253 badOk: !!(bad1 && bad1.ok === false && bad1.error),
254 goodOk: !!(good && good.ok === true && good.data && good.data.title === 'fine'),
255 };
256 });
257 check(parsed.badOk, 'unparseable text is refused by parse() with a reason, not by a throw');
258 check(parsed.goodOk, 'and text that parses comes back as data', JSON.stringify(parsed));
259
260 // ── The real migration, through the store ────────────────────
261 //
262 // Everything above is the JS half. This is the one that runs on every Diamond
263 // the user owns, in Rust, on the trigger `migrate_crystal_file` already uses.
264 const LEG = 'a11decade001';
265 const LEGMD = '# Kept from before\n\nWhy this Diamond exists.\n\n## Notes\n\n```\n## not a heading\n```\n\n## Next\n\nWhat is left to do.\n';
266 const migrated = await page.evaluate(async (arg) => {
267 const dir = 'diamonds/' + arg.id;
268 await __put(dir + '/.daimond/meta.json', __meta('Kept from before'));
269 await __put(dir + '/crystal.md', arg.md);
270 // The trigger, exactly where it runs today.
271 await __d.app.list_diamonds();
272
273 const json = await __bytes(dir + '/crystal.json');
274 const stale = await __bytes(dir + '/crystal.md');
275 let back = null, viaApp = null, err = '';
276 try { viaApp = await __d.app.read_crystal_data(arg.id); }
277 catch (e) { err = String(e && e.message || e); }
278 const p = DaimondCrystal.parse(json || viaApp || '');
279 if (p && p.ok) { try { back = DaimondCrystal.toMarkdown(p.data); } catch (e) { err = String(e); } }
280 return {
281 wrote: json !== null,
282 stale: stale !== null,
283 viaApp: viaApp,
284 same: json !== null && viaApp !== null && json === viaApp,
285 back: back,
286 trip: back === arg.md,
287 why: err || __where(arg.md, String(back)),
288 agree: (p && p.ok)
289 ? __canon(p.data) === __canon(DaimondCrystal.fromMarkdown(arg.md))
290 : false,
291 rust: (p && p.ok) ? __canon(p.data) : 'unparseable: ' + (p && p.error),
292 js: __canon(DaimondCrystal.fromMarkdown(arg.md)),
293 };
294 }, { id: LEG, md: LEGMD });
295
296 check(migrated.wrote, 'a legacy Diamond gains a crystal.json where it had a crystal.md');
297 check(migrated.trip, 'and rendering it back reproduces the original file byte for byte',
298 migrated.why);
299 check(migrated.same, 'the app reads back the same bytes that are on disk, so the '
300 + 'migration WROTE rather than converting afresh on every read');
301 // Two migrations, one in Rust and one in JS, and the JS one is what every test
302 // above measures. If they disagree, those eighteen cases prove nothing about
303 // the code that will actually touch the user's Diamonds.
304 check(migrated.agree, 'the migration in Rust and the migration in JS produce the same data',
305 'rust ' + String(migrated.rust).slice(0, 120) + ' | js ' + String(migrated.js).slice(0, 120));
306 // NOT a rename, unlike the brief.md migration, and the reason is worth keeping.
307 // The self-check proves the BYTES round-trip and structurally cannot prove the
308 // STRUCTURE is right: a `##` inside a fence rejoins to identical bytes whether or
309 // not the fence was honoured. So the one failure the check cannot see is exactly
310 // the one that would justify still having the markdown. And `import_diamond`
311 // deletes a Diamond's directory before rewriting it, so a bad conversion
312 // propagates back over a good copy on the next sync -- the shape this project
313 // has already lost data to once.
314 //
315 // The cost is a few kilobytes riding in the parcel. The user's real backup was
316 // thirteen Diamonds and 15,786 bytes of workspace in total, so it is a few
317 // kilobytes of a very small number, and a later release can drop the file once
318 // the conversion has run against real workspaces without complaint.
319 check(migrated.stale, 'the legacy markdown is KEPT, because a lossless conversion '
320 + 'is not the same as a provably correct one');
321
322 // ── Idempotent ───────────────────────────────────────────────
323 const twice = await page.evaluate(async (id) => {
324 const path = 'diamonds/' + id + '/crystal.json';
325 const before = await __bytes(path);
326 await __d.app.list_diamonds();
327 await __d.app.list_diamonds();
328 const after = await __bytes(path);
329 return { ok: before !== null && before === after, why: __where(String(before), String(after)) };
330 }, LEG);
331 check(twice.ok, 'running the migration again changes nothing', twice.why);
332
333 // ── It never clobbers ────────────────────────────────────────
334 //
335 // A Diamond holding both files has already been migrated on another device and
336 // synced back, or was migrated here and then had a legacy file restored from a
337 // backup. Either way a merge would be guessing, and the losing side is work.
338 const BOTH = 'b0thf11e5001';
339 const KEPT = '{"title":"The data that was already here","summary":"Written after the migration."}';
340 const both = await page.evaluate(async (arg) => {
341 const dir = 'diamonds/' + arg.id;
342 await __put(dir + '/.daimond/meta.json', __meta('Holds both'));
343 await __put(dir + '/crystal.md', '# An older markdown crystal\n\nWhich must not win.\n');
344 await __put(dir + '/crystal.json', arg.kept);
345 await __d.app.list_diamonds();
346 const json = await __bytes(dir + '/crystal.json');
347 let viaApp = null;
348 try { viaApp = await __d.app.read_crystal_data(arg.id); } catch (e) { viaApp = 'ERR ' + e; }
349 return { json, viaApp };
350 }, { id: BOTH, kept: KEPT });
351
352 check(both.json === KEPT, 'a Diamond holding both files keeps the data it already had',
353 String(both.json).slice(0, 120));
354 check(!/older markdown crystal/.test(String(both.json) + String(both.viaApp)),
355 'and nothing from the markdown is merged into it, whichever way a merge would have gone');
356
357 // ── The redundancy path ──────────────────────────────────────
358 //
359 // The most destructive failure in this change: a Diamond whose crystal is not
360 // found reads as an empty one, and an agent handed an empty crystal will write
361 // a new one over work it never saw. The versions are the store's own backup and
362 // nothing but this ever reads them.
363 const VER = 'c0deca11ab1e';
364 const ver = await page.evaluate(async (id) => {
365 const dir = 'diamonds/' + id;
366 await __put(dir + '/.daimond/meta.json', __meta('Crystal lost, versions kept'));
367 await __put(dir + '/versions/0001.json', '{"title":"the first crystal"}');
368 await __put(dir + '/versions/0009.json', '{"title":"as it was last left"}');
369 await __put(dir + '/versions/0002.json', '{"title":"a middle one"}');
370 let text = null, err = '';
371 try { text = await __d.app.read_crystal_data(id); }
372 catch (e) { err = String(e && e.message || e); }
373 const p = DaimondCrystal.parse(text || '');
374 return { err, title: (p && p.ok && p.data) ? p.data.title : null, text };
375 }, VER);
376 check(!ver.err, 'a Diamond whose crystal.json is gone still opens', ver.err);
377 check(ver.title === 'as it was last left',
378 'reading its NEWEST version snapshot rather than opening empty',
379 JSON.stringify(ver.title) + ' from ' + JSON.stringify(String(ver.text).slice(0, 80)));
380
381 // A Diamond that holds only the legacy markdown must read as its own content
382 // too, whether it gets there by having been migrated or by a migrated read.
383 // The failure being guarded is the same one either way: opening empty.
384 const OLD = 'd0c0mdon1y001';
385 const old = await page.evaluate(async (id) => {
386 const dir = 'diamonds/' + id;
387 await __put(dir + '/.daimond/meta.json', __meta('Only markdown'));
388 await __put(dir + '/crystal.md', '# Still here\n\nAnd it must not read as empty.\n');
389 let text = null, err = '';
390 try { text = await __d.app.read_crystal_data(id); }
391 catch (e) { err = String(e && e.message || e); }
392 return { err, text };
393 }, OLD);
394 check(!old.err, 'a Diamond holding only a legacy markdown crystal opens', old.err);
395 check(/Still here/.test(String(old.text)),
396 'with what it says, rather than as an empty crystal an agent would write over',
397 JSON.stringify(String(old.text).slice(0, 120)));
398
399 // And the control, without which "never opens empty" could be met by opening
400 // something. A Diamond that genuinely has nothing must still open, and must not
401 // acquire content from anywhere.
402 const NIL = 'e3117n0th1ng1';
403 const nil = await page.evaluate(async (id) => {
404 await __put('diamonds/' + id + '/.daimond/meta.json', __meta('Nothing at all'));
405 try { return { text: await __d.app.read_crystal_data(id) }; }
406 catch (e) { return { err: String(e && e.message || e) }; }
407 }, NIL);
408 check(nil.err === undefined, 'a Diamond that truly holds nothing opens rather than throwing',
409 nil.err);
410
411 // A resource the browser could not load is the dev stack, not the page: no
412 // gateway runs here, so its probes answer 401 or 502 and neither is a throw.
413 const noise = s.errs.filter((e) =>
414 !/favicon|ERR_ABORTED|net::ERR|Failed to load resource|i18n: no string/i.test(e));
415 check(noise.length === 0, 'the page threw nothing along the way', noise.slice(0, 3).join(' | '));
416
417} catch (e) {
418 check(false, 'the run finished', String(e && e.message || e));
419} finally {
420 await s.close();
421}
422
423console.log(bad === 0 ? '\nall checks passed' : `\n${bad} check(s) FAILED`);
424process.exit(bad === 0 ? 0 : 1);