Oregami
Repositories/oxedyne/daimond

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.
40import fs from 'node:fs';
41import path from 'node:path';
42import { fileURLToPath } from 'node:url';
43import { open, scratch, shot, chat } from './harness.mjs';
44
45const WWW = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'www');
46
47const BREAK = (() => {
48 const i = process.argv.indexOf('--break');
49 return i > 0 ? String(process.argv[i + 1] || '') : '';
50})();
51
52const 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
80if (BREAK && !BREAKS[BREAK]) {
81 console.error(`unknown break '${BREAK}'; one of: ${Object.keys(BREAKS).join(', ')}`);
82 process.exit(2);
83}
84
85function 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.
101function 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
111const 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
120const ok = [], bad = [];
121const check = (name, pass, detail) => {
122 (pass ? ok : bad).push(name);
123 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
124};
125const sleep = (ms) => new Promise(r => setTimeout(r, ms));
126
127const PROFILE = scratch('pw', 'panelfacts' + (BREAK ? '-' + BREAK : ''));
128fs.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.
133const s = await open({ name: 'panelfacts', profile: PROFILE, signIn: true, connect: true,
134 route: routeBreaks });
135const p = s.page;
136await p.waitForFunction(() => !!window.DaimondAdmin && !!window.DaimondWorkers,
137 null, { timeout: 20000 }).catch(() => {});
138await sleep(1200);
139
140try {
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
313console.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 '));
316process.exit(bad.length ? 1 : 0);