Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_journal.mjs

7.7 KiB, 1 run

created by r2519314175:495, 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_journal.mjs — the write-ahead log reconstructs exactly what was in flight.
2//
3// verify_durability.mjs drives a real turn to a real crash; this drives the JOURNAL directly, so
4// the reconstruction can be checked deterministically in cases the mock cannot time reliably: a
5// tool that finished vs one caught in the act, several turns at once, event ordering, coalescing
6// across a tool boundary, per-turn pruning, and account isolation. Events are keyed per TURN (not
7// per chat), so successive turns of one chat never conflate and one turn's prune never wipes
8// another's.
9import { open, signInAs, errors, scratch } from './harness.mjs';
10
11const ok = [], bad = [];
12const check = (name, pass, detail) => {
13 (pass ? ok : bad).push(name + (detail ? ' — ' + detail : ''));
14 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
15};
16
17const s = await open({ name: 'journal', connect: false, signIn: false, profile: scratch('journal-profile') });
18const p = s.page;
19await p.waitForTimeout(1500);
20await signInAs(s, 'Jo');
21await p.waitForTimeout(600);
22
23// Everything below runs in the page against the real DaimondJournal. Signatures:
24// turnOpen(turnId, chatId, text, meta) · delta(turnId, chatId, text)
25// toolOpen(turnId, chatId, callId, name, args) · toolDone(turnId, chatId, callId, result, failed)
26// turnClose(turnId, chatId, pTok, cTok) · recover() -> { turns:[...], agents:[...] }
27const r1 = await p.evaluate(async () => {
28 const J = window.DaimondJournal;
29 await J.init();
30 // Turn tA in chat cA: text, a completed tool, and a tool still open when the "tab died".
31 await J.turnOpen('tA', 'cA', 'do the thing', { model: 'm' });
32 await J.delta('tA', 'cA', 'Wor');
33 await J.toolOpen('tA', 'cA', 'tc1', 'file_write', '{"path":"x"}');
34 await J.toolDone('tA', 'cA', 'tc1', 'wrote x', false);
35 await J.delta('tA', 'cA', 'king');
36 await J.toolOpen('tA', 'cA', 'tc2', 'file_read', '{"path":"y"}'); // never returns
37 await J.delta('tA', 'cA', ' on it');
38 // A second, independent turn in a different chat.
39 await J.turnOpen('tB', 'cB', 'other prompt', { model: 'm' });
40 await J.delta('tB', 'cB', 'second');
41 const rec = await J.recover();
42 const by = {}; rec.turns.forEach(t => by[t.turnId] = t);
43 return { tA: by.tA, tB: by.tB, count: rec.turns.length };
44});
45
46check('a turn is reconstructed with its prompt, turn id and chat',
47 r1.tA && r1.tA.userText === 'do the thing' && r1.tA.turnId === 'tA' && r1.tA.chatId === 'cA',
48 JSON.stringify(r1.tA && { u: r1.tA.userText, t: r1.tA.turnId, c: r1.tA.chatId }));
49check('the streamed text is whole and in order, across the tool boundary',
50 r1.tA && r1.tA.text === 'Working on it', r1.tA && r1.tA.text);
51check('a completed tool carries its result and is marked done',
52 r1.tA && r1.tA.tools[0] && r1.tA.tools[0].name === 'file_write' && r1.tA.tools[0].done === true && r1.tA.tools[0].result === 'wrote x',
53 JSON.stringify(r1.tA && r1.tA.tools[0]));
54check('a tool still open when the tab died is marked NOT done',
55 r1.tA && r1.tA.tools[1] && r1.tA.tools[1].name === 'file_read' && r1.tA.tools[1].done === false,
56 JSON.stringify(r1.tA && r1.tA.tools[1]));
57check('two in-flight turns are recovered independently',
58 r1.count === 2 && r1.tB && r1.tB.text === 'second', String(r1.count));
59
60// ── Per-turn pruning: closing one turn removes only its events ──────────
61
62const r2 = await p.evaluate(async () => {
63 const J = window.DaimondJournal;
64 await J.turnClose('tA', 'cA', 100, 50); // tA completes
65 const rec = await J.recover();
66 return { ids: rec.turns.map(t => t.turnId) };
67});
68check('closing a turn prunes only it', r2.ids.indexOf('tA') === -1 && r2.ids.indexOf('tB') !== -1, r2.ids.join(','));
69
70// ── Two turns of the SAME chat do not conflate; the first's close does not
71// mask the second's interruption (the chatId-multiplexing bug) ────────
72
73const r3 = await p.evaluate(async () => {
74 const J = window.DaimondJournal;
75 await J.turnOpen('t1', 'cSame', 'first turn', { model: 'm' });
76 await J.delta('t1', 'cSame', 'first answer');
77 await J.turnClose('t1', 'cSame', 5, 5); // first turn completes cleanly
78 await J.turnOpen('t2', 'cSame', 'second turn', { model: 'm' });
79 await J.delta('t2', 'cSame', 'second answer'); // ...and the tab dies here
80 const rec = await J.recover();
81 const same = rec.turns.filter(t => t.chatId === 'cSame');
82 return { n: same.length, turnId: same[0] && same[0].turnId, text: same[0] && same[0].text, userText: same[0] && same[0].userText };
83});
84check('a completed turn does not mask a later interrupted turn of the same chat',
85 r3.n === 1 && r3.turnId === 't2' && r3.text === 'second answer' && r3.userText === 'second turn',
86 JSON.stringify(r3));
87
88// ── A buffered event does not survive its turn being closed and pruned ──
89
90const r4 = await p.evaluate(async () => {
91 const J = window.DaimondJournal;
92 J.delta('tB', 'cB', ' thoughts'); // deliberately not awaited: still buffered
93 await J.turnClose('tB', 'cB', 10, 5);
94 const rec = await J.recover();
95 return { tBGone: !rec.turns.some(t => t.turnId === 'tB'), ids: rec.turns.map(t => t.turnId) };
96});
97check('a buffered event does not survive its turn being closed and pruned',
98 r4.tBGone === true, r4.ids.join(',') || '(none left)');
99
100// ── Agents ──────────────────────────────────────────────────────────────
101
102const r5 = await p.evaluate(async () => {
103 const J = window.DaimondJournal;
104 await J.agentOpen('w1', { name: 'agent-1', task: 'research X' });
105 await J.agentDelta('w1', 'found ');
106 await J.agentDelta('w1', 'something');
107 await J.agentOpen('w2', { name: 'agent-2', task: 'research Y' });
108 await J.agentDelta('w2', 'other');
109 await J.agentClose('w2', 'done', 20, 10); // w2 finished
110 const rec = await J.recover();
111 return { agents: rec.agents.map(a => ({ id: a.runId, task: a.rec && a.rec.task, text: a.text })) };
112});
113check('an interrupted agent keeps its task and partial output',
114 r5.agents.length === 1 && r5.agents[0].id === 'w1' && r5.agents[0].text === 'found something',
115 JSON.stringify(r5.agents));
116check('a completed agent is pruned, not recovered', !r5.agents.some(a => a.id === 'w2'));
117
118// ── Account isolation: a second account's journal is a different store ───
119
120const r6 = await p.evaluate(async () => {
121 const J = window.DaimondJournal;
122 await J.turnOpen('tKeep', 'cKeep', 'primary secret', { model: 'm' });
123 await J.delta('tKeep', 'cKeep', 'primary only');
124 const primaryHas = (await J.recover()).turns.some(t => t.turnId === 'tKeep');
125 const before = window.DaimondAccounts.opfsNs();
126 window.DaimondAccounts.add('Second'); // becomes current
127 const after = window.DaimondAccounts.opfsNs();
128 const secondSees = (await J.recover()).turns.some(t => t.turnId === 'tKeep');
129 window.DaimondAccounts.setCurrent(window.DaimondAccounts.list().filter(x => x.primary)[0].id);
130 const primaryStillHas = (await J.recover()).turns.some(t => t.turnId === 'tKeep');
131 return { primaryHas, before, after, secondSees, primaryStillHas };
132});
133check('the primary account holds its own interrupted turn', r6.primaryHas === true);
134check('a second account gets its OWN namespace', r6.before === '' && r6.after !== '', r6.before + ' -> ' + r6.after);
135check('a second account cannot see the primary account\'s journal', r6.secondSees === false);
136check('and switching back finds the primary account\'s turn intact', r6.primaryStillHas === true);
137
138const errs = errors(s).filter(e => !/favicon|404|401|502|Bad Gateway|net::ERR/.test(e));
139console.log('\nconsole errors:', errs.slice(0, 4));
140check('nothing throws', errs.length === 0, errs[0] || '');
141
142await s.close();
143console.log(`\n${ok.length} passed, ${bad.length} failed`);
144if (bad.length) console.log('FAILED:\n ' + bad.join('\n '));
145process.exit(bad.length ? 1 : 0);