Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_droots.mjs

21.8 KiB, 1 run

created by r2519314175:385, 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_droots.mjs — a Diamond is the same Diamond in either workspace mode.
2//
3// The Workspace panel switches the agent's root between the browser sandbox
4// (OPFS, private, syncs) and a real folder on the machine (does not sync). That
5// switch is meant to move the WORK. It was moving Daimond's own state with it:
6// a Diamond lives at `diamonds/<id>`, the store pinned that to the sandbox, and
7// every other reader of the same path — the Workspace panel, a dispatched
8// worker, the "view this delta" button — resolved it against whichever root was
9// active. So with a folder open the Diamond's own directory could not be listed
10// or read at all, and a worker's files went into the user's repository, outside
11// the sync parcel, where nothing would ever see them again.
12//
13// The rule now is one line: `diamonds/` is app state and does not follow the
14// root (`is_store_path`, src/tools.rs; `resolve_root`, src/wasm/opfs.rs). The
15// user's work still does, which is half of what is checked here — a fix that
16// pinned everything to the sandbox would break real-folder mode outright and
17// several checks below would still pass.
18//
19// What is pinned:
20// * a Diamond's directory lists and reads the same in both modes;
21// * a write into it lands in the store and travels in the export;
22// * the user's own files still follow the folder, and are out of view in the
23// sandbox — the switch still does its job;
24// * `diamonds-old/` and `src/diamonds/` are the user's, not the store;
25// * Diamonds made in either mode survive a reload and appear in the parcel;
26// * Diamond files an earlier build left in a folder are adopted into the
27// store on activation, both copies are kept where they differ, a folder
28// that is not a Diamond is left alone, nothing is deleted, and running it
29// again adopts nothing.
30//
31// A real folder needs `showDirectoryPicker()`, a native dialog no harness can
32// answer, so an OPFS subdirectory stands in for one — the same stand-in
33// dev/verify_fsa.mjs uses, and for the same reason: what the picker returns is
34// a FileSystemDirectoryHandle, and OPFS hands out that very type.
35//
36// Run with dev/serve.mjs (DAIMOND_PORT, default 8777) up. No gateway, no hand, no mock
37// model.
38//
39// node dev/verify_droots.mjs
40//
41// Every check here has been proved against BROKEN code: see
42// dev/breakproof_droots.sh, which reverts the fix four different ways and
43// records which checks go red for each.
44import { open, signInAs } from './harness.mjs';
45
46const ok = [], bad = [];
47const check = (name, pass, detail) => {
48 (pass ? ok : bad).push(name);
49 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
50};
51
52const FOLDER = 'standin-folder'; // an OPFS subdirectory standing in for a picked folder
53
54/// A crystal as `crystal.json` now holds one. Nothing here is about the crystal's
55/// shape -- these fixtures need a Diamond to be carrying something they can tell
56/// apart -- but it has to be the real format, because the store parses it.
57const crystalOf = (title) => JSON.stringify({ title: title });
58
59const s = await open({ name: 'droots', connect: false });
60const p = s.page;
61await p.waitForTimeout(1500);
62
63// Helpers installed in the page, so every step below drives the real engine.
64// Re-installed after the reload, which takes the page's globals with it.
65const install = () => p.evaluate(async (folder) => {
66 const mod = await import('../pkg/oxedyne_daimond.js');
67 window.__d = {
68 mod,
69 app: new mod.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 256, '', true),
70 root: await navigator.storage.getDirectory(),
71 folder: null,
72 };
73 __d.folder = await __d.root.getDirectoryHandle(folder, { create: true });
74 // Read a file straight out of a directory handle, bypassing every tool: the
75 // question "where did the bytes land" must not be answered by the thing under test.
76 window.__at = async (which, path) => {
77 let cur = which === 'folder' ? __d.folder : __d.root;
78 const parts = path.split('/');
79 try {
80 for (let i = 0; i < parts.length - 1; i++) cur = await cur.getDirectoryHandle(parts[i]);
81 const fh = await cur.getFileHandle(parts[parts.length - 1]);
82 return await (await fh.getFile()).text();
83 } catch (e) { return null; }
84 };
85 window.__put = async (which, path, body) => {
86 let cur = which === 'folder' ? __d.folder : __d.root;
87 const parts = path.split('/');
88 for (let i = 0; i < parts.length - 1; i++) {
89 cur = await cur.getDirectoryHandle(parts[i], { create: true });
90 }
91 const fh = await cur.getFileHandle(parts[parts.length - 1], { create: true });
92 const w = await fh.createWritable();
93 await w.write(body);
94 await w.close();
95 };
96 window.__tool = (name, args) => __d.app.run_tool(name, JSON.stringify(args));
97 window.__err = (v) => typeof v !== 'string' || /^\s*Error\b/i.test(v);
98}, FOLDER);
99
100await install();
101
102const tool = (name, args) => p.evaluate(([n, a]) => __tool(n, a), [name, args]);
103const at = (which, path) => p.evaluate(([w, x]) => __at(w, x), [which, path]);
104const put = (which, path, body) => p.evaluate(([w, x, b]) => __put(w, x, b), [which, path, body]);
105const toFolder = () => p.evaluate(() => __d.mod.set_workspace_dir(__d.folder));
106const toBrowser = () => p.evaluate(() => __d.mod.use_opfs_workspace());
107
108// ── A Diamond made in the sandbox ───────────────────────────────────────
109
110const A = await p.evaluate(async (crystal) => {
111 const id = await __d.app.create_diamond('Made in the browser');
112 await __d.app.write_crystal_data(id, crystal);
113 await __tool('file_write', { path: 'diamonds/' + id + '/note.md', content: 'note of A' });
114 return id;
115}, crystalOf('crystal of A'));
116check('a Diamond can be made in the browser sandbox', !!A, A);
117
118const readsIn = async (id, what) => ({
119 list: await tool('file_list', { path: 'diamonds/' + id }),
120 crystal: await tool('file_read', { path: 'diamonds/' + id + '/crystal.json' }),
121 note: await tool('file_read', { path: 'diamonds/' + id + '/note.md' }),
122 what,
123});
124
125const browserA = await readsIn(A, 'browser');
126check('its directory lists in the browser sandbox',
127 /crystal\.json/.test(browserA.list) && /note\.md/.test(browserA.list), browserA.list);
128check('and its crystal reads there', /crystal of A/.test(browserA.crystal), browserA.crystal);
129
130// ── The switch to the machine folder ────────────────────────────────────
131
132await toFolder();
133const machineA = await readsIn(A, 'machine');
134check('the SAME directory still lists once a folder is open',
135 /crystal\.json/.test(machineA.list) && /note\.md/.test(machineA.list), machineA.list);
136check('and the crystal reads back byte for byte',
137 /crystal of A/.test(machineA.crystal), machineA.crystal);
138check('and so does a file written into it before the switch',
139 /note of A/.test(machineA.note), machineA.note);
140
141// A write made WHILE the folder is open belongs to the Diamond, not to the repo.
142await tool('file_write', { path: 'diamonds/' + A + '/worker.md', content: 'a worker wrote this' });
143check('a write into the Diamond lands in the store, not the folder',
144 (await at('opfs', 'diamonds/' + A + '/worker.md')) === 'a worker wrote this'
145 && (await at('folder', 'diamonds/' + A + '/worker.md')) === null,
146 'store: ' + JSON.stringify(await at('opfs', 'diamonds/' + A + '/worker.md'))
147 + ' folder: ' + JSON.stringify(await at('folder', 'diamonds/' + A + '/worker.md')));
148
149const exportA = await p.evaluate((id) => __d.app.export_diamond(id), A);
150check('and it travels with the Diamond, so it reaches the other device',
151 exportA.includes('worker.md') && exportA.includes('a worker wrote this'),
152 exportA.slice(0, 160));
153
154// ── The user's work still follows the folder ────────────────────────────
155//
156// Without this the fix could be "pin everything to the sandbox", which passes
157// every check above and destroys the feature.
158
159await tool('file_write', { path: 'src/main.rs', content: 'fn main() {}' });
160check("the user's own file goes into the folder, as real-folder mode promises",
161 (await at('folder', 'src/main.rs')) === 'fn main() {}'
162 && (await at('opfs', 'src/main.rs')) === null,
163 'folder: ' + JSON.stringify(await at('folder', 'src/main.rs')));
164
165// Paths that merely look like the store are the user's.
166await tool('file_write', { path: 'diamonds-old/keep.md', content: 'theirs' });
167await tool('file_write', { path: 'src/diamonds/keep.md', content: 'also theirs' });
168check('a path that only resembles the store is still the user\'s work',
169 (await at('folder', 'diamonds-old/keep.md')) === 'theirs'
170 && (await at('folder', 'src/diamonds/keep.md')) === 'also theirs'
171 && (await at('opfs', 'diamonds-old/keep.md')) === null
172 && (await at('opfs', 'src/diamonds/keep.md')) === null,
173 'diamonds-old in folder: ' + JSON.stringify(await at('folder', 'diamonds-old/keep.md'))
174 + ', in store: ' + JSON.stringify(await at('opfs', 'diamonds-old/keep.md')));
175
176// ── A Diamond made while the folder is open ─────────────────────────────
177
178const B = await p.evaluate(async (crystal) => {
179 const id = await __d.app.create_diamond('Made on the machine');
180 await __d.app.write_crystal_data(id, crystal);
181 await __tool('file_write', { path: 'diamonds/' + id + '/note.md', content: 'note of B' });
182 return id;
183}, crystalOf('crystal of B'));
184check('a Diamond can be made while a folder is open', !!B, B);
185check('and none of it lands in the folder',
186 (await at('folder', 'diamonds/' + B + '/crystal.json')) === null, 'folder holds nothing for B');
187
188await toBrowser();
189const browserB = await readsIn(B, 'browser');
190check('switching back to the browser, it is still there',
191 /crystal\.json/.test(browserB.list) && /note\.md/.test(browserB.list), browserB.list);
192check('with its crystal and its files intact',
193 /crystal of B/.test(browserB.crystal) && /note of B/.test(browserB.note),
194 browserB.crystal + ' | ' + browserB.note);
195check("and the folder's own files are out of view again",
196 (await tool('file_read', { path: 'src/main.rs' })).startsWith('Error'),
197 (await tool('file_read', { path: 'src/main.rs' })).slice(0, 40));
198
199// ── Both are in the sync parcel ─────────────────────────────────────────
200
201const inParcel = async () => p.evaluate(async () => {
202 const parcel = await window.DaimondCore.collectSync();
203 return (parcel.diamonds || []).map((d) => ({ id: d.id, data: d.data || '' }));
204});
205const parcelBrowser = await inParcel();
206const carries = (rows, id, body) => {
207 const row = rows.find((r) => r.id === id);
208 return !!row && row.data.includes(body);
209};
210check('both Diamonds are in the parcel from the browser sandbox',
211 carries(parcelBrowser, A, 'crystal of A') && carries(parcelBrowser, B, 'crystal of B'),
212 parcelBrowser.map((r) => r.id).join(' '));
213
214await toFolder();
215const parcelMachine = await inParcel();
216check('and both are still in the parcel with a folder open',
217 carries(parcelMachine, A, 'crystal of A') && carries(parcelMachine, B, 'crystal of B'),
218 parcelMachine.map((r) => r.id).join(' '));
219check('including the file the worker wrote while the folder was open',
220 carries(parcelMachine, A, 'a worker wrote this'));
221
222// ── Across a reload ─────────────────────────────────────────────────────
223
224await p.reload({ waitUntil: 'domcontentloaded' });
225await signInAs(s, 'droots');
226await p.waitForTimeout(2500);
227await install();
228
229const afterReload = await p.evaluate(async ({ a, b }) => {
230 const listed = JSON.parse(await __d.app.list_diamonds());
231 const browser = {
232 a: await __tool('file_read', { path: 'diamonds/' + a + '/crystal.json' }),
233 b: await __tool('file_read', { path: 'diamonds/' + b + '/crystal.json' }),
234 };
235 __d.mod.set_workspace_dir(__d.folder);
236 const machine = {
237 a: await __tool('file_read', { path: 'diamonds/' + a + '/crystal.json' }),
238 b: await __tool('file_read', { path: 'diamonds/' + b + '/crystal.json' }),
239 };
240 __d.mod.use_opfs_workspace();
241 return { ids: listed.map((d) => d.id), browser, machine };
242}, { a: A, b: B });
243
244check('after a reload the rail still holds both Diamonds',
245 afterReload.ids.includes(A) && afterReload.ids.includes(B), afterReload.ids.join(' '));
246check('and both crystals read in the browser sandbox',
247 /crystal of A/.test(afterReload.browser.a) && /crystal of B/.test(afterReload.browser.b),
248 afterReload.browser.a + ' | ' + afterReload.browser.b);
249check('and both read again with the folder open',
250 /crystal of A/.test(afterReload.machine.a) && /crystal of B/.test(afterReload.machine.b),
251 afterReload.machine.a + ' | ' + afterReload.machine.b);
252
253// ── Bringing home what an earlier build left in a folder ────────────────
254//
255// The state a user of yesterday's build is actually in: Diamond files sitting
256// in their project, invisible to the app and outside the parcel.
257
258const C = 'c0ffee5eaded'; // a whole Diamond, in the folder and nowhere else
259await p.evaluate(async ({ a, c, crystalC }) => {
260 // (1) A worker's file for a Diamond the store already holds.
261 await __put('folder', 'diamonds/' + a + '/stranded.md', 'left in the folder');
262 // (2) A file that CLASHES with one the store holds, with different bytes.
263 await __put('folder', 'diamonds/' + a + '/note.md', 'the folder version of the note');
264 // (3) A whole Diamond that only exists in the folder.
265 await __put('folder', 'diamonds/' + c + '/crystal.json', crystalC);
266 await __put('folder', 'diamonds/' + c + '/.daimond/meta.json',
267 JSON.stringify({ name: 'Found in the folder', crystal_version: 0, updated: 1, touched: 1 }));
268 // (4) A directory of the user's that happens to live under diamonds/.
269 await __put('folder', 'diamonds/notes/todo.md', 'not a Diamond at all');
270}, { a: A, c: C, crystalC: crystalOf('crystal of C') });
271
272// Adoption runs where the app runs it: on folder activation.
273const report = await p.evaluate(async () => {
274 __d.mod.set_workspace_dir(__d.folder);
275 return JSON.parse(await __d.mod.adopt_folder_diamonds());
276});
277
278check('adoption reports the Diamonds it brought home',
279 report.adopted.length === 2
280 && report.adopted.some((d) => d.id === A)
281 && report.adopted.some((d) => d.id === C),
282 JSON.stringify(report.adopted));
283check('a stranded worker file is now in the store',
284 (await at('opfs', 'diamonds/' + A + '/stranded.md')) === 'left in the folder',
285 JSON.stringify(await at('opfs', 'diamonds/' + A + '/stranded.md')));
286check('a clash keeps what the store already had',
287 (await at('opfs', 'diamonds/' + A + '/note.md')) === 'note of A',
288 JSON.stringify(await at('opfs', 'diamonds/' + A + '/note.md')));
289check('and keeps the folder\'s copy beside it rather than dropping it',
290 (await at('opfs', 'diamonds/' + A + '/note.from-machine.md')) === 'the folder version of the note',
291 JSON.stringify(await at('opfs', 'diamonds/' + A + '/note.from-machine.md')));
292check('and says so, naming the file it kept',
293 (report.adopted.find((d) => d.id === A) || {}).kept
294 && report.adopted.find((d) => d.id === A).kept
295 .includes('diamonds/' + A + '/note.from-machine.md'),
296 JSON.stringify((report.adopted.find((d) => d.id === A) || {}).kept));
297
298const adoptedC = await p.evaluate(async (c) => {
299 const listed = JSON.parse(await __d.app.list_diamonds());
300 const row = listed.find((d) => d.id === c) || null;
301 return { row, crystal: await __d.app.read_crystal_data(c).catch(() => null) };
302}, C);
303check('a whole Diamond found only in the folder joins the rail',
304 !!adoptedC.row && adoptedC.row.name === 'Found in the folder', JSON.stringify(adoptedC.row));
305check('with its crystal', adoptedC.crystal === crystalOf('crystal of C'),
306 JSON.stringify(adoptedC.crystal));
307
308check('a folder of the user\'s under diamonds/ is left alone',
309 report.left.includes('diamonds/notes')
310 && (await at('opfs', 'diamonds/notes/todo.md')) === null,
311 JSON.stringify(report.left));
312check('and nothing at all was deleted from the folder',
313 (await at('folder', 'diamonds/' + A + '/stranded.md')) === 'left in the folder'
314 && (await at('folder', 'diamonds/' + C + '/crystal.json')) === crystalOf('crystal of C')
315 && (await at('folder', 'diamonds/notes/todo.md')) === 'not a Diamond at all');
316
317const second = await p.evaluate(() => __d.mod.adopt_folder_diamonds().then(JSON.parse));
318check('running adoption again brings nothing home, and makes no second copy',
319 second.adopted.length === 0
320 && (await at('opfs', 'diamonds/' + A + '/note.from-machine.from-machine.md')) === null,
321 JSON.stringify(second.adopted));
322
323// The adopted Diamond has to sync like any other, which is the point of moving it.
324await toBrowser();
325const parcelAfter = await inParcel();
326check('the adopted Diamond is in the sync parcel',
327 carries(parcelAfter, C, 'crystal of C') && carries(parcelAfter, A, 'left in the folder'),
328 parcelAfter.map((r) => r.id).join(' '));
329
330// With no folder open there is nothing to adopt, and saying so is not an error.
331const none = await p.evaluate(() => __d.mod.adopt_folder_diamonds().then(JSON.parse));
332check('with no folder open, adoption looks at nothing and says so',
333 none.folder === false && none.adopted.length === 0, JSON.stringify(none));
334
335// ── And through the app's own hands, not the engine's ───────────────────
336//
337// Everything above called the adoption edge directly. What a user of yesterday's
338// build actually does is open the app with a folder already granted, so the run
339// that matters is the silent reconnect at boot — `Files.tryReconnect` →
340// `activateFolder` → adoption — and the user has to be TOLD, because files moved.
341
342const D = 'dead1eaf0000';
343await p.evaluate(async ({ folder, d, crystalD }) => {
344 // A whole Diamond, in the folder and nowhere else.
345 await __put('folder', 'diamonds/' + d + '/crystal.json', crystalD);
346 await __put('folder', 'diamonds/' + d + '/.daimond/meta.json',
347 JSON.stringify({ name: 'Stranded in the project', crystal_version: 0, updated: 2, touched: 2 }));
348 // Where the panel looks for a folder it may reconnect without a picker.
349 const db = await new Promise((res, rej) => {
350 const q = indexedDB.open('daimond-fsa', 1);
351 q.onupgradeneeded = () => q.result.createObjectStore('handles');
352 q.onsuccess = () => res(q.result);
353 q.onerror = () => rej(q.error);
354 });
355 await new Promise((res, rej) => {
356 const tx = db.transaction('handles', 'readwrite');
357 tx.objectStore('handles').put(__d.folder, 'workspace');
358 tx.oncomplete = res;
359 tx.onerror = () => rej(tx.error);
360 });
361 void folder;
362}, { folder: FOLDER, d: D, crystalD: crystalOf('crystal of D') });
363
364await p.reload({ waitUntil: 'domcontentloaded' });
365await signInAs(s, 'droots');
366await p.waitForTimeout(4000);
367await install();
368
369const booted = await p.evaluate(async (d) => {
370 const mod = await import('../pkg/oxedyne_daimond.js');
371 const app = new mod.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 256, '', true);
372 const card = document.querySelector('.modal.dlg .dlg-card');
373 return {
374 mode: mod.workspace_mode(),
375 ids: JSON.parse(await app.list_diamonds()).map((x) => x.id),
376 crystal: await app.read_crystal_data(d).catch(() => null),
377 title: card ? (card.querySelector('h2') || {}).textContent : null,
378 body: card ? (card.querySelector('.dlg-msg, .dlg-pre') || {}).textContent : null,
379 };
380}, D);
381
382check('the app reconnected the folder on its own at boot', booted.mode === 'folder', booted.mode);
383check('and brought the stranded Diamond into the store on the way',
384 booted.ids.includes(D) && booted.crystal === crystalOf('crystal of D'),
385 booted.ids.join(' ') + ' | ' + JSON.stringify(booted.crystal));
386check('and told the user, naming the Diamond it moved',
387 !!booted.title && /brought back/i.test(booted.title)
388 && !!booted.body && booted.body.includes('Stranded in the project'),
389 JSON.stringify(booted.title) + ' | ' + JSON.stringify(booted.body));
390// THE PROPERTY. Adoption COPIES; the user's own files stay in their folder,
391// and the dialog invites them to delete those copies once satisfied. So the
392// message must say the folder copies are still there and where to find them,
393// and must never claim Daimond moved or removed anything -- and the file must
394// really still be on disk. A user who reads "moved" does not go looking, and a
395// user who reads "copied" about files that were not copied deletes the only
396// remaining set.
397//
398// This was `/left exactly where they are/`, which the rewrite broke by dropping
399// one adverb. The wording is not the property; the file still being there is.
400const stillInTheFolder = (b) => {
401 const bits = b.split(/(?<=[.!?;])\s+/);
402 const neg = /\b(not|no|never|nothing|none|nor)\b|n[’']t\b/i;
403 const gone = /\b(moved|removed|deleted|erased|taken away)\b/i;
404 return {
405 // It says the user's copies stayed put.
406 kept: bits.some(x => /\b(left|still|remains?|remained|untouched|unchanged|kept|in place|where they (are|were))\b/i.test(x)
407 && /\b(cop(y|ies|ied)|files?|originals?|them|these)\b/i.test(x)),
408 // It says where they are.
409 where: /diamonds\/|\bfolder\b/i.test(b),
410 // It never claims Daimond took them away.
411 notMoved: !bits.some(x => gone.test(x) && !neg.test(x)),
412 };
413};
414const told = stillInTheFolder(booted.body || '');
415const onDisk = (await at('folder', 'diamonds/' + D + '/crystal.json')) === crystalOf('crystal of D');
416check('and said the copies in their folder are still there, untaken — and they are',
417 !!booted.body && told.kept && told.where && told.notMoved && onDisk,
418 Object.entries({ ...told, onDisk }).filter(([, v]) => !v).map(([k]) => 'no ' + k).join(', ')
419 || JSON.stringify(booted.body));
420
421// A resource the browser could not load is the dev stack, not the page: no
422// gateway runs here, so its probes answer 401 or 502 and neither is a throw.
423const noise = s.errs.filter((e) =>
424 !/favicon|ERR_ABORTED|net::ERR|Failed to load resource/i.test(e));
425check('the page threw nothing along the way', noise.length === 0, noise.slice(0, 3).join(' | '));
426
427await s.close();
428console.log('\n' + ok.length + ' ok, ' + bad.length + ' failed');
429process.exit(bad.length ? 1 : 0);