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. |
| 9 | import { open, signInAs, errors, scratch } from './harness.mjs'; |
| 10 | |
| 11 | const ok = [], bad = []; |
| 12 | const check = (name, pass, detail) => { |
| 13 | (pass ? ok : bad).push(name + (detail ? ' — ' + detail : '')); |
| 14 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 15 | }; |
| 16 | |
| 17 | const s = await open({ name: 'journal', connect: false, signIn: false, profile: scratch('journal-profile') }); |
| 18 | const p = s.page; |
| 19 | await p.waitForTimeout(1500); |
| 20 | await signInAs(s, 'Jo'); |
| 21 | await 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:[...] } |
| 27 | const 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 | |
| 46 | check('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 })); |
| 49 | check('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); |
| 51 | check('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])); |
| 54 | check('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])); |
| 57 | check('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 | |
| 62 | const 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 | }); |
| 68 | check('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 | |
| 73 | const 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 | }); |
| 84 | check('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 | |
| 90 | const 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 | }); |
| 97 | check('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 | |
| 102 | const 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 | }); |
| 113 | check('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)); |
| 116 | check('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 | |
| 120 | const 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 | }); |
| 133 | check('the primary account holds its own interrupted turn', r6.primaryHas === true); |
| 134 | check('a second account gets its OWN namespace', r6.before === '' && r6.after !== '', r6.before + ' -> ' + r6.after); |
| 135 | check('a second account cannot see the primary account\'s journal', r6.secondSees === false); |
| 136 | check('and switching back finds the primary account\'s turn intact', r6.primaryStillHas === true); |
| 137 | |
| 138 | const errs = errors(s).filter(e => !/favicon|404|401|502|Bad Gateway|net::ERR/.test(e)); |
| 139 | console.log('\nconsole errors:', errs.slice(0, 4)); |
| 140 | check('nothing throws', errs.length === 0, errs[0] || ''); |
| 141 | |
| 142 | await s.close(); |
| 143 | console.log(`\n${ok.length} passed, ${bad.length} failed`); |
| 144 | if (bad.length) console.log('FAILED:\n ' + bad.join('\n ')); |
| 145 | process.exit(bad.length ? 1 : 0); |