oxedyne/daimond/dev/verify_panelfacts.mjs
14.8 KiB, 1 run
created by r2519314175:563, 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_panelfacts.mjs — a panel that states a fact must state the true one. |
| 2 | // |
| 3 | // Three defects of one shape, found by the reachability audit of 2026-08-12. In |
| 4 | // each, a panel keeps drawing something that stopped being true, and nothing on |
| 5 | // screen says so: |
| 6 | // |
| 7 | // 1. THE RAIL'S TOOLS COUNT FROZE. `DaimondAdmin.status()` returned early when |
| 8 | // the money rows drew — `if (!locked && moneyRows(st, arow)) return;` — and |
| 9 | // `proRow()`, `tools()` and `storage()` all sit after that return. So any |
| 10 | // account the money rows could draw for, which is every account with a |
| 11 | // balance or a provider key, saw a Tools count, a Pro row and a storage |
| 12 | // figure frozen at whatever they were the first time the panel drew. |
| 13 | // Asserted as: the rail's number FOLLOWS what the panel is asked. Not "the |
| 14 | // number is 6" — a frozen number can be 6. |
| 15 | // |
| 16 | // 2. AGENT HISTORY CAPPED AT TWELVE, SILENTLY. `runs.slice(0, 12)`, with the |
| 17 | // newest first, so the thirteenth-oldest run vanished on every reload with |
| 18 | // no count and no note, in the panel whose whole job is to say what ran. |
| 19 | // Asserted by NAME: the thirteenth-newest run is still there afterwards. |
| 20 | // |
| 21 | // 3. THE TILE'S CONTROLS WERE HARDCODED ENGLISH. `'Pause'`, `'Stop'`, |
| 22 | // `'Resume'`, the run/paused/queued tally and the status word, all written |
| 23 | // raw. `dev/i18ncheck.mjs` cannot see this class at all: it compares key |
| 24 | // SETS between locale files, so a string that was never given a key is |
| 25 | // invisible to it by construction. Asserted by SWITCHING LANGUAGE and |
| 26 | // watching the controls move — a check that read the English would pass |
| 27 | // against the hardcoded build, which is the whole trap. |
| 28 | // |
| 29 | // node dev/verify_panelfacts.mjs --break staleafter # 1 fails: the early return |
| 30 | // node dev/verify_panelfacts.mjs --break cap12 # 2 fails: twelve runs kept |
| 31 | // node dev/verify_panelfacts.mjs --break silentcut # 2's note fails: the cut |
| 32 | // # happens and nothing says so |
| 33 | // node dev/verify_panelfacts.mjs --break hardeng # 3 fails: English controls |
| 34 | // node dev/verify_panelfacts.mjs # and then, clean |
| 35 | // |
| 36 | // eval "$(bash dev/world.sh 5 --up)" |
| 37 | // node dev/verify_panelfacts.mjs |
| 38 | // |
| 39 | // Needs dev/serve.mjs and dev/mockllm.mjs. No gateway, no IMAP. |
| 40 | import fs from 'node:fs'; |
| 41 | import path from 'node:path'; |
| 42 | import { fileURLToPath } from 'node:url'; |
| 43 | import { open, scratch, shot, chat } from './harness.mjs'; |
| 44 | |
| 45 | const WWW = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'www'); |
| 46 | |
| 47 | const BREAK = (() => { |
| 48 | const i = process.argv.indexOf('--break'); |
| 49 | return i > 0 ? String(process.argv[i + 1] || '') : ''; |
| 50 | })(); |
| 51 | |
| 52 | const BREAKS = { |
| 53 | // The early return, verbatim as it stood. |
| 54 | staleafter: [{ |
| 55 | file: 'js/daimond.js', |
| 56 | find: '\t\t\tif (!locked && moneyRows(st, arow)) { proRow(); tools(); storage(); return; }', |
| 57 | with: '\t\t\tif (!locked && moneyRows(st, arow)) return;', |
| 58 | }], |
| 59 | // Twelve runs, as before. |
| 60 | cap12: [{ file: 'js/daimond.js', find: '\t\tKEEP: 200,', with: '\t\tKEEP: 12,' }], |
| 61 | // The cut still happens; the panel simply does not mention it. |
| 62 | silentcut: [{ |
| 63 | file: 'js/daimond.js', |
| 64 | find: '\t\t\tif (this.dropped > 0) {', |
| 65 | with: '\t\t\tif (false) {', |
| 66 | }], |
| 67 | // The tile's controls as they were written: English literals, whatever the |
| 68 | // app is set to. |
| 69 | hardeng: [{ |
| 70 | file: 'js/daimond.js', |
| 71 | find: '\t\t\t\tacts.appendChild(actBtn(\'pause\', t(\'agents.act_pause\'), t(\'agents.act_pause_help\'),\n' |
| 72 | + '\t\t\t\t\tfunction () { self.pause(run); }));\n' |
| 73 | + '\t\t\t\tacts.appendChild(actBtn(\'cross\', t(\'agents.act_stop\'), t(\'agents.act_stop_help\'),\n' |
| 74 | + '\t\t\t\t\tfunction () { self.stop(run); }));', |
| 75 | with: '\t\t\t\tacts.appendChild(actBtn(\'pause\', \'Pause\', \'Hang up this agent, keeping its work so far; resume it later.\', function () { self.pause(run); }));\n' |
| 76 | + '\t\t\t\tacts.appendChild(actBtn(\'cross\', \'Stop\', \'Stop this agent for good. It keeps whatever it managed to do.\', function () { self.stop(run); }));', |
| 77 | }], |
| 78 | }; |
| 79 | |
| 80 | if (BREAK && !BREAKS[BREAK]) { |
| 81 | console.error(`unknown break '${BREAK}'; one of: ${Object.keys(BREAKS).join(', ')}`); |
| 82 | process.exit(2); |
| 83 | } |
| 84 | |
| 85 | function damaged(src, spec) { |
| 86 | const n = src.split(spec.find).length - 1; |
| 87 | if (n !== 1) { |
| 88 | console.error(`break '${BREAK}': the anchor appears ${n} times in ${spec.file}.`); |
| 89 | process.exit(2); |
| 90 | } |
| 91 | return src.replace(spec.find, spec.with); |
| 92 | } |
| 93 | |
| 94 | /// The damaged files, ONE BODY PER FILE. |
| 95 | /// |
| 96 | /// Every edit a break names for a file goes into the SAME body, in order, and |
| 97 | /// that one body is what the route serves. A `page.route` per edit spec does not |
| 98 | /// work and does not say so: Playwright hands a request to the LAST route |
| 99 | /// registered for its URL, so a two-edit break shipped only its second edit -- |
| 100 | /// and still went red, for half the reason it claims, with nothing to notice it. |
| 101 | function damagedFiles() { |
| 102 | const byFile = new Map(); |
| 103 | for (const spec of (BREAKS[BREAK] || [])) { |
| 104 | const src = byFile.has(spec.file) ? byFile.get(spec.file) |
| 105 | : fs.readFileSync(path.join(WWW, spec.file), 'utf8'); |
| 106 | byFile.set(spec.file, damaged(src, spec)); |
| 107 | } |
| 108 | return byFile; |
| 109 | } |
| 110 | |
| 111 | const routeBreaks = async (pg) => { |
| 112 | if (!BREAK) return; |
| 113 | for (const [file, body] of damagedFiles()) { |
| 114 | await pg.route('**/' + file, r => r.fulfill({ |
| 115 | status: 200, contentType: 'application/javascript', body, |
| 116 | })); |
| 117 | } |
| 118 | }; |
| 119 | |
| 120 | const ok = [], bad = []; |
| 121 | const check = (name, pass, detail) => { |
| 122 | (pass ? ok : bad).push(name); |
| 123 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 124 | }; |
| 125 | const sleep = (ms) => new Promise(r => setTimeout(r, ms)); |
| 126 | |
| 127 | const PROFILE = scratch('pw', 'panelfacts' + (BREAK ? '-' + BREAK : '')); |
| 128 | fs.rmSync(PROFILE, { recursive: true, force: true }); |
| 129 | |
| 130 | // `connect: true` puts a provider on the account, which is what makes the money |
| 131 | // rows draw and therefore what arms defect 1. Without it the early return is |
| 132 | // never taken and check 1 would pass against the broken build. |
| 133 | const s = await open({ name: 'panelfacts', profile: PROFILE, signIn: true, connect: true, |
| 134 | route: routeBreaks }); |
| 135 | const p = s.page; |
| 136 | await p.waitForFunction(() => !!window.DaimondAdmin && !!window.DaimondWorkers, |
| 137 | null, { timeout: 20000 }).catch(() => {}); |
| 138 | await sleep(1200); |
| 139 | |
| 140 | try { |
| 141 | // ══ 0. Arm the state the defect needs ═════════════════════════════ |
| 142 | // |
| 143 | // The money rows draw only when there is something TRUE to say about a pot, |
| 144 | // and a fresh account with no gateway and no spend has nothing. So one real |
| 145 | // turn is run through the mock provider: that puts spend against a key in the |
| 146 | // ledger, which is the commonest BYOK case there is and exactly the account |
| 147 | // the audit describes. Without it the early return is never taken and check 1 |
| 148 | // below would pass against the broken build — which it did, measured, before |
| 149 | // this section existed. |
| 150 | await chat(s, 'hello').catch(() => {}); |
| 151 | await sleep(1200); |
| 152 | |
| 153 | // ══ 1. The rail's Tools count follows what it is told ══════════════ |
| 154 | // |
| 155 | // `DaimondTools.counts()` is the source of truth and is replaced here for the |
| 156 | // length of the check. That is the right thing to fake: the property is that |
| 157 | // the rail ASKS AGAIN and repaints, so the oracle has to be something that |
| 158 | // can change. |
| 159 | const rail = await p.evaluate(async () => { |
| 160 | const out = { drew: '', first: '', second: '' }; |
| 161 | const real = window.DaimondTools.counts; |
| 162 | const readRow = () => (document.getElementById('astat-tools') || {}).textContent || ''; |
| 163 | try { |
| 164 | window.DaimondTools.counts = () => ({ have: 3, all: 9 }); |
| 165 | window.DaimondAdmin.status(); |
| 166 | await new Promise((r) => setTimeout(r, 400)); |
| 167 | out.first = readRow(); |
| 168 | window.DaimondTools.counts = () => ({ have: 7, all: 9 }); |
| 169 | window.DaimondAdmin.status(); |
| 170 | await new Promise((r) => setTimeout(r, 400)); |
| 171 | out.second = readRow(); |
| 172 | } finally { window.DaimondTools.counts = real; } |
| 173 | // The precondition, measured rather than assumed: the money rows drew, so |
| 174 | // the early return was actually reached. A row still offering the account |
| 175 | // fallbacks means they did not. |
| 176 | const acct = (document.getElementById('astat-account') || {}).textContent || ''; |
| 177 | out.drew = acct; |
| 178 | // The account row's FALLBACK wordings, which are what it says when the |
| 179 | // money rows drew nothing. Any of them means the early return was never |
| 180 | // reached, and every check below it would be vacuous. |
| 181 | out.moneyDrew = !/no account|offline|unavailable|unreachable|locked|not signed/i.test(acct) |
| 182 | && acct.trim().length > 0; |
| 183 | return out; |
| 184 | }); |
| 185 | check('the money rows draw for this account, so the return that skipped the rest is reached', |
| 186 | rail.moneyDrew, 'account row reads: ' + rail.drew.trim().slice(0, 70)); |
| 187 | check('the rail\'s Tools count follows what the panel is asked, rather than freezing', |
| 188 | /\b3\b/.test(rail.first) && /\b7\b/.test(rail.second), |
| 189 | 'first "' + rail.first.trim() + '", then "' + rail.second.trim() + '"'); |
| 190 | |
| 191 | // ══ 2. Agent history is not silently cut ═══════════════════════════ |
| 192 | // |
| 193 | // Two hundred and sixty runs, named, so what survives can be asked about by |
| 194 | // NAME. `w13` is the thirteenth-newest and is exactly what the old cap threw |
| 195 | // away. |
| 196 | const kept = await p.evaluate(async () => { |
| 197 | const W = window.DaimondWorkers; |
| 198 | W.runs = []; |
| 199 | for (let i = 1; i <= 260; i++) { |
| 200 | W.runs.push({ id: 'w' + i, name: 'agent-' + i, task: 'task ' + i, |
| 201 | diamondId: '', diamondName: '', chatId: '', chatName: '', |
| 202 | model: 'mock/fast', provider: 'mock', status: 'done', text: '', tools: [], |
| 203 | sees: false, promptTokens: 0, completionTokens: 0, cachedTokens: 0, costUsd: 0 }); |
| 204 | } |
| 205 | W.persist(); |
| 206 | let raw = null; |
| 207 | try { raw = JSON.parse(localStorage.getItem('daimond-workers')); } catch (e) { raw = null; } |
| 208 | const list = Array.isArray(raw) ? raw : ((raw && raw.runs) || []); |
| 209 | return { |
| 210 | n: list.length, |
| 211 | has13: list.some((r) => r.id === 'w13'), |
| 212 | has200: list.some((r) => r.id === 'w200'), |
| 213 | dropped: (raw && !Array.isArray(raw)) ? raw.dropped : null, |
| 214 | live: W.dropped, |
| 215 | }; |
| 216 | }); |
| 217 | check('the thirteenth-newest run survives a write, where it used to be thrown away', |
| 218 | kept.has13, kept.has13 ? kept.n + ' runs kept' : 'w13 is gone; ' + kept.n + ' kept'); |
| 219 | check('and the two-hundredth does too', kept.has200, kept.n + ' runs kept'); |
| 220 | check('what did not fit is counted rather than forgotten', |
| 221 | kept.dropped === 60 && kept.live === 60, |
| 222 | 'stored dropped=' + kept.dropped + ', in memory=' + kept.live); |
| 223 | |
| 224 | const note = await p.evaluate(async () => { |
| 225 | window.DaimondPanels.show('agents'); |
| 226 | window.DaimondWorkers.render(); |
| 227 | await new Promise((r) => setTimeout(r, 400)); |
| 228 | const el = document.querySelector('#agents-list .agents-dropped'); |
| 229 | return { text: el ? (el.textContent || '') : '', |
| 230 | says: el ? window.DaimondI18n.tn('agents.dropped', 60) : '' }; |
| 231 | }); |
| 232 | check('and the panel says what it is not showing, in words and with the number', |
| 233 | !!note.text && /60/.test(note.text) && note.text === note.says, |
| 234 | note.text ? note.text : 'no line at the foot of the list'); |
| 235 | |
| 236 | // ══ 3. The tile's controls are in the reader's language ════════════ |
| 237 | // |
| 238 | // Asserted by MOVEMENT. Reading the English and comparing it to the English |
| 239 | // catalogue would pass against a hardcoded build, since the two agree by |
| 240 | // construction; what a hardcoded string cannot do is change when the language |
| 241 | // does. |
| 242 | const readTile = () => p.evaluate(() => { |
| 243 | const btns = [...document.querySelectorAll('#agents-list .acard .aacts .abtn')]; |
| 244 | return { |
| 245 | labels: btns.map((b) => b.textContent.trim()), |
| 246 | titles: btns.map((b) => b.title), |
| 247 | pill: (document.querySelector('#agents-list .acard .pill') || {}).textContent || '', |
| 248 | tally: (document.getElementById('agents-stat') || {}).textContent || '', |
| 249 | live: (document.getElementById('agents-count') || {}).textContent || '', |
| 250 | says: { |
| 251 | stop: window.DaimondI18n.t('agents.act_stop'), |
| 252 | help: window.DaimondI18n.t('agents.act_pause_help'), |
| 253 | run: window.DaimondI18n.t('agents.status_running'), |
| 254 | tally: window.DaimondI18n.t('agents.tally_running', { n: 1 }), |
| 255 | read: window.DaimondI18n.t('agents.read'), |
| 256 | }, |
| 257 | }; |
| 258 | }); |
| 259 | await p.evaluate(async () => { |
| 260 | const W = window.DaimondWorkers; |
| 261 | W.runs = [{ id: 'wx1', name: 'agent-live', task: 'a running task', |
| 262 | diamondId: '', diamondName: '', chatId: '', chatName: '', |
| 263 | model: 'mock/fast', provider: 'mock', status: 'running', text: '', tools: [], |
| 264 | sees: false, promptTokens: 0, completionTokens: 0, cachedTokens: 0, costUsd: 0 }]; |
| 265 | W.dropped = 0; |
| 266 | W.render(); |
| 267 | await new Promise((r) => setTimeout(r, 300)); |
| 268 | }); |
| 269 | const en = await readTile(); |
| 270 | // THREE, and the third is named. Pause and Stop were the whole of a running |
| 271 | // agent's tile until Read was offered for one -- its transcript is a file that |
| 272 | // grows, so there is something to read before it has finished. A bare count |
| 273 | // would pass a build that had dropped Read and grown something else, which is |
| 274 | // the failure this file is written against, so the label is asserted too. |
| 275 | check('a running agent has its three controls on screen', en.labels.length === 3, |
| 276 | JSON.stringify(en.labels)); |
| 277 | check('and one of them is Read, which is what opens its transcript', |
| 278 | en.labels.some((l) => l === en.says.read), |
| 279 | JSON.stringify(en.labels) + ' against ' + JSON.stringify(en.says.read)); |
| 280 | |
| 281 | const loaded = await p.evaluate((c) => window.DaimondI18n.setLocale(c), 'de'); |
| 282 | await p.evaluate(async () => { |
| 283 | window.DaimondWorkers.render(); |
| 284 | await new Promise((r) => setTimeout(r, 300)); |
| 285 | }); |
| 286 | const de = await readTile(); |
| 287 | await p.evaluate((c) => window.DaimondI18n.setLocale(c), 'en'); |
| 288 | check('German loads', loaded === true, String(loaded)); |
| 289 | check('the tile\'s Stop control follows the catalogue when the language changes', |
| 290 | de.labels.some((l) => l.indexOf(de.says.stop) >= 0) && de.says.stop !== en.says.stop |
| 291 | && !de.labels.some((l) => /Stop$/.test(l)), |
| 292 | 'English ' + JSON.stringify(en.labels) + ' → German ' + JSON.stringify(de.labels)); |
| 293 | check('and so does the help a reader hovers for', |
| 294 | de.titles.some((x) => x === de.says.help) && de.says.help !== en.says.help, |
| 295 | JSON.stringify(de.titles).slice(0, 110)); |
| 296 | check('and the state word on the tile, which is the one label everybody reads', |
| 297 | de.pill === de.says.run && de.says.run !== en.says.run, |
| 298 | '"' + en.pill + '" → "' + de.pill + '"'); |
| 299 | check('and the running/paused/queued tally above the list', |
| 300 | de.tally === de.says.tally && de.says.tally !== en.says.tally, |
| 301 | '"' + en.tally + '" → "' + de.tally + '"'); |
| 302 | check('and the live count in the panel\'s heading', |
| 303 | de.live !== en.live && de.live.length > 0, |
| 304 | '"' + en.live + '" → "' + de.live + '"'); |
| 305 | |
| 306 | await shot(s, 'panelfacts'); |
| 307 | } catch (e) { |
| 308 | check('the run finished', false, String(e && e.message || e)); |
| 309 | } finally { |
| 310 | await s.close(); |
| 311 | } |
| 312 | |
| 313 | console.log(bad.length === 0 |
| 314 | ? `\nverify_panelfacts: all ${ok.length} checks pass.` |
| 315 | : `\nverify_panelfacts: ${bad.length} of ${ok.length + bad.length} failed:\n ` + bad.join('\n ')); |
| 316 | process.exit(bad.length ? 1 : 0); |