Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_triage.mjs

43.3 KiB, 60 runs

created by r2519314175:759, 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_triage.mjs — one verb whose object is the whole list of notes.
2//
3// `www/js/triage.js` reads every kept note and the public proposal list, asks a
4// model for a PLAN, and draws each draft in a box that is sent by its own press.
5// `dev/IMPROVE_CONTRACT.md` §11 is the contract; the fixture is the owner's own
6// eighteen notes, which is why the clustering assertions below name real ones.
7//
8// ── WHAT IS AT EACH END ──────────────────────────────────────────────
9//
10// `dev/mock_forge.mjs` on :8443, through a stand-in for the gateway that builds
11// the upstream path the way `gateway/src/handlers/improve.rs` does — the same
12// arrangement `dev/verify_improve.mjs` uses, and for the same reason: a verifier
13// that answered the panel out of its own idea of the forge would agree with
14// itself. TWO THINGS ARE REWRITTEN AT THE HOP, both deliberately:
15//
16// * the per-asker "you may amend" flag, `mine_to_amend`. The forge answers it
17// now and the mock answers it too, but only for a proposal the asking voice
18// actually wrote — so the hop forces all three phases instead: absent in
19// one, `false` in another, `true` in a third, because ABSENT and FALSE are
20// different facts and the panel must draw them differently. THE SPELLING IS
21// READ OUT OF `js/improve.js`, never written here: a fixture holding its own
22// copy of the name goes on answering the old key after a rename and keeps a
23// broken client green, which is what happened to `amend` and `may_amend`;
24// * the repository name, so a refusal stays reachable.
25//
26// `dev/mockllm.mjs` on a port of this run's own, with `MOCK_TRIAGE_PLAN` naming
27// `dev/fixtures/triage_plan_18.json` — a plan a REAL model produced over the
28// real eighteen notes against the real brief. Replayed rather than re-earned:
29// what this file proves is the pipeline around the plan, and a fixed answer
30// invented here would prove the clustering was whatever it was written to be.
31// What IS proved about the brief is that all eighteen notes and the whole
32// proposal list actually reached the model, which a plausible plan cannot show.
33//
34// 1. NOTHING RUNS ON ITS OWN. Opening the panel with eighteen kept notes in
35// the store reaches no model at all. Counted at the network, so a request
36// that was never made and one that was merely hidden look different.
37//
38// 2. THE COST IS SAID BEFORE THE PRESS, not after. It names the model, it
39// carries a figure, and it is ABOVE the button — a price a person reads
40// once the money is gone is not consent.
41//
42// 3. ONE PRESS DRAFTS, AND EVERYTHING IT WAS GIVEN ARRIVED. All eighteen note
43// ids are in the request the model was actually sent, and the proposal
44// listing was read first. A brief that quietly dropped the oldest half
45// would still produce a plausible plan.
46//
47// 4. THE PROPOSALS ARE READ WITH NO VOICE, which is what lets this work before
48// anybody is enrolled.
49//
50// 5. DRAFTING SENDS NOTHING TO THE FORGE, AND HANDS THE DRAFTS TO THE QUEUE.
51// The whole plan lands in the approve-list (js/approvelist.js) and not one
52// proposal has been opened. Triage generates; it keeps no per-draft box or
53// Send of its own.
54//
55// 6. (MOVED) ONE PRESS PER DRAFT, WHAT LEAVES IS THE BOX, and the title+body
56// field set are now dev/verify_approvelist.mjs's -- the queue sends, so what
57// leaves is proved where it leaves.
58//
59// 7. (MOVED) A FOLDED NOTE IS NOT A SENT ONE is also the queue's now: it folds
60// the notes a sent draft was written from. §8 below still holds the cap
61// itself, driven through `DaimondImprove.fold` directly.
62//
63// 8. AND THE CAP HONOURS THAT. Driven past two hundred with a mix of sent and
64// folded notes: the sent ones go and the folded ones stay.
65//
66// 9. (MOVED) THE REVISION DRAFT'S DARK/LIT GATE is the queue's, since a revision
67// draft is drawn there now. `cleanProp`'s absent-vs-false (§10) and the
68// panel's own Revise control (§10c) stay here -- both improve.js's.
69//
70// 10. ABSENT IS NOT FALSE. `cleanProp` does not coerce the flag: a proposal
71// nobody asked about is `askedAmend: false`, and one answered `false` is
72// `askedAmend: true`. Both draw nothing, and they are still different
73// facts — the second can change when a voice is set and the first cannot.
74//
75// 10b. THE PANEL'S KEY IS THE FORGE'S KEY. Everything above is driven through
76// a hop that INJECTS the flag, so every one of those checks passes just as
77// well against a name nobody answers. That is how two lanes shipped two
78// different wrong spellings, each with a green run behind it. This one
79// asks the forge itself which word arrives.
80//
81// 10c. `revisions` IS A LIST AND NOT A COUNT, and the Revise button under a
82// proposal goes through the same door the triage draft does. It did not:
83// both halves declared `async function amend` in one closure, the later
84// declaration won outright, and the button threw on its first line while
85// every triage check went on passing.
86//
87// 11. A REFUSAL IS SAID AND NOTHING IS RETRIED. One request, one sentence
88// beside the draft, and the notes untouched.
89//
90// 12. AN UNREADABLE ANSWER COSTS NOTHING ELSE. `parse` takes junk without
91// drafting from it, the panel says so, and no note is marked.
92//
93// 13. EVERY NOTE IS ACCOUNTED FOR — in a draft's `from` or in `left` with a
94// reason. A plan quietly one note short is a report quietly lost.
95//
96// 14. THE RUN IS BOOKED AGAINST THE ACCOUNT'S OWN LEDGER. Money spent that
97// nothing records is money the spend panel cannot show.
98//
99// Run it in a world of its own; the mock provider is this file's own, on its
100// own port, with its own log:
101//
102// eval "$(bash dev/world.sh 7 --env)"
103// node dev/verify_triage.mjs
104// node dev/verify_triage.mjs --break foldsent
105
106import fs from 'node:fs';
107import path from 'node:path';
108import { spawn } from 'node:child_process';
109import { fileURLToPath } from 'node:url';
110import { open, shot, scratch, errors, connectMock } from './harness.mjs';
111
112const HERE = path.dirname(fileURLToPath(import.meta.url));
113const WWW = path.join(HERE, '..', 'www');
114
115const BREAK = (() => {
116 const i = process.argv.indexOf('--break');
117 return i > 0 ? String(process.argv[i + 1] || '') : '';
118})();
119
120const PROFILE = scratch('pw', 'triage' + (BREAK ? '-' + BREAK : ''));
121fs.rmSync(PROFILE, { recursive: true, force: true });
122
123const ok = [], bad = [];
124const check = (name, pass, detail) => {
125 (pass ? ok : bad).push(name);
126 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
127};
128
129// ── The fixture: the owner's own eighteen notes ──────────────────────
130
131const NOTES = JSON.parse(fs.readFileSync(path.join(HERE, 'fixtures', 'triage_notes_18.json'), 'utf8')).notes;
132const PLAN = JSON.parse(fs.readFileSync(path.join(HERE, 'fixtures', 'triage_plan_18.json'), 'utf8'));
133const PLAN_FILE = path.join(HERE, 'fixtures', 'triage_plan_18.json');
134
135// ── The forge and the model, both this run's own ─────────────────────
136
137const FORGE_PORT = 8443;
138const MOCK_PORT = Number(process.env.TRIAGE_MOCK_PORT || 9143);
139const MOCK_URL = `http://127.0.0.1:${MOCK_PORT}/v1/chat/completions`;
140const MOCK_LOG = scratch('triage', 'mockllm-triage.log');
141
142const started = [];
143
144async function reachable(port, p) {
145 try { const r = await fetch(`http://127.0.0.1:${port}${p}`); await r.text(); return true; }
146 catch (e) { return false; }
147}
148
149async function startBoth() {
150 if (await reachable(FORGE_PORT, '/a/b/proposals?format=json&limit=1')) {
151 console.error(`:${FORGE_PORT} is already held by something. Free it, or the run below `
152 + 'would be driven against somebody else\'s forge.');
153 process.exit(2);
154 }
155 if (await reachable(MOCK_PORT, '/__world')) {
156 console.error(`:${MOCK_PORT} is already held by something, so the plan this run replays `
157 + 'would not be the plan it asserts on.');
158 process.exit(2);
159 }
160 started.push(spawn('node', [path.join(HERE, 'mock_forge.mjs'), '--port', String(FORGE_PORT),
161 '--count', '6'], { stdio: ['ignore', 'ignore', 'inherit'] }));
162 fs.writeFileSync(MOCK_LOG, '');
163 started.push(spawn('node', [path.join(HERE, 'mockllm.mjs'), String(MOCK_PORT)], {
164 stdio: ['ignore', 'ignore', 'inherit'],
165 env: { ...process.env, DAIMOND_MOCK_LOG: MOCK_LOG, MOCK_TRIAGE_PLAN: PLAN_FILE },
166 }));
167 for (let i = 0; i < 120; i++) {
168 if (await reachable(FORGE_PORT, '/a/b/proposals?format=json&limit=1')
169 && await reachable(MOCK_PORT, '/__world')) return;
170 await new Promise(r => setTimeout(r, 100));
171 }
172 console.error('the forge or the mock provider never bound; nothing below would prove anything.');
173 process.exit(2);
174}
175
176function stopBoth() {
177 for (const p of started) { try { p.kill('SIGTERM'); } catch (e) { /* already gone */ } }
178 started.length = 0;
179}
180process.on('exit', stopBoth);
181for (const sig of ['SIGINT', 'SIGTERM']) {
182 process.on(sig, () => { stopBoth(); process.exit(130); });
183}
184
185/// Everything the model was actually sent, since the log was cleared.
186const modelLog = () => {
187 if (!fs.existsSync(MOCK_LOG)) return [];
188 return fs.readFileSync(MOCK_LOG, 'utf8').split('\n').filter(Boolean)
189 .map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
190};
191
192// ── The seams ────────────────────────────────────────────────────────
193
194const SEAM = [
195 { file: 'index.html', want: '<script src="js/triage.js"></script>',
196 why: 'the drafting module is not loaded' },
197 { file: 'js/improve.js', want: 'DaimondTriage.polish', // the per-note polish path the compose box drives
198 why: 'the compose box has no polish path to the drafting machinery' },
199 { file: 'js/improve.js', want: 'window.DaimondTriage) DaimondTriage.draw()',
200 why: 'the panel never draws the row, so a batch surface could not be restored' },
201];
202
203function requireSeams() {
204 const missing = [];
205 for (const s of SEAM) {
206 const src = fs.readFileSync(path.join(WWW, s.file), 'utf8');
207 if (!src.includes(s.want)) missing.push(` ${s.file}: ${s.why}`);
208 }
209 if (missing.length) {
210 console.error('the drafting half is not wired up, so this run would prove nothing:');
211 for (const b of missing) console.error(b);
212 process.exit(2);
213 }
214}
215
216// ── The breaks ───────────────────────────────────────────────────────
217// Each is a real edit to a real file, served in place of it. `find` must appear
218// exactly once, or nothing was changed and the run proves the opposite of what
219// it claims.
220
221const BREAKS = {
222 // NOTE: the triage-side send breaks -- `foldsent` (a folded note marked sent),
223 // `rebuild` (send reads the record not the box) and `amendbright` (a revision
224 // drawn sendable whatever the forge said) -- MOVED to dev/verify_approvelist.mjs
225 // as `failvanishes`, `staleedit` and `amendbright`, because the send and the
226 // revision gate they broke now live in the queue. Triage no longer sends.
227
228 // The cap treats a folded note as delivered, which is the same data loss
229 // arriving from the other side: `fold` is right and eviction is wrong.
230 evictfolded: [{
231 file: 'js/improve.js',
232 find: '\t\treturn !!(rec && rec.sent && !(rec.into && rec.into.length));',
233 with: '\t\treturn !!(rec && (rec.sent || (rec.into && rec.into.length)));',
234 }],
235 // The flag is coerced like every other field, so "nobody asked" and
236 // "answered no" become the same fact and the control can never light up
237 // for a reason anybody can see.
238 coerce: [{
239 file: 'js/improve.js',
240 find: '\t\tif (Object.prototype.hasOwnProperty.call(p, AMEND_FLAG)) {\n'
241 + '\t\t\trec.askedAmend = true;\n'
242 + '\t\t\trec.amendable = (p[AMEND_FLAG] === true);\n\t\t}',
243 with: '\t\trec.askedAmend = true;\n\t\trec.amendable = !!p[AMEND_FLAG];',
244 }],
245 // (`autorun` and `noconsent` retired: they broke the batch drafting CONTROL,
246 // whose UI was removed when note-capture merged into the compose box. The
247 // compose box's own model path -- "Polish & post" -- runs only on a press and
248 // only for one note, and that is proved in dev/verify_composemerge.mjs.)
249
250 // The panel goes back to a spelling nobody answers. THE BREAK THIS LANE EXISTS
251 // FOR: two lanes built this half against a forge that had not published the
252 // name, guessed `amend` and `may_amend`, and each had a green run behind it --
253 // because every check either of them wrote was driven through a fixture that
254 // injected whatever the panel was looking for. Under this break the hop does
255 // the same, so every one of those checks stays green and only the one that
256 // asks the FORGE what word it sends can tell.
257 oldflag: [{
258 file: 'js/improve.js',
259 find: "\tvar AMEND_FLAG = 'mine_to_amend';",
260 with: "\tvar AMEND_FLAG = 'amend';",
261 }],
262 // `revisions` read the way `comments` beside it is read. The panel would then
263 // hold a number where a list belongs, and the count it draws would be NaN.
264 revcount: [{
265 file: 'js/improve.js',
266 find: '\t\tif (typeof p.body === \'string\') rec.detail = true;',
267 with: '\t\tif (p.revisions !== undefined) rec.revisions = whole(p.revisions);\n'
268 + '\t\tif (typeof p.body === \'string\') rec.detail = true;',
269 }],
270 // THE COLLISION, PUT BACK. Both halves of this seam declared `async function
271 // amend` in one closure; the later declaration wins outright, so the panel's
272 // button called the wire door with no arguments and threw, while every triage
273 // check went on passing. The export moves with it, or the module would throw
274 // on load and redden the whole run instead of the one check that can see it.
275 twoamends: [
276 {
277 file: 'js/improve.js',
278 find: '\tasync function revise(n, parts) {',
279 with: '\tasync function amend(n, parts) {',
280 },
281 {
282 file: 'js/improve.js',
283 find: '\t\t\tamend: revise,',
284 with: '\t\t\tamend: amend,',
285 },
286 ],
287};
288
289if (BREAK && !BREAKS[BREAK]) {
290 console.error(`unknown break '${BREAK}'; one of: ${Object.keys(BREAKS).join(', ')}`);
291 process.exit(2);
292}
293
294function edit(src, spec, what) {
295 const n = src.split(spec.find).length - 1;
296 if (n !== 1) {
297 console.error(`${what}: the anchor appears ${n} times in ${spec.file}, `
298 + 'so nothing was changed and the run below would prove nothing.');
299 process.exit(2);
300 }
301 return src.replace(spec.find, spec.with);
302}
303
304const FILES = new Map();
305function build() {
306 requireSeams();
307 if (!BREAK) return;
308 for (const spec of BREAKS[BREAK]) {
309 const p = spec.file;
310 const src = FILES.get(p) ?? fs.readFileSync(path.join(WWW, p), 'utf8');
311 FILES.set(p, edit(src, spec, `break '${BREAK}'`));
312 }
313}
314build();
315
316// ── The gateway, stood in for ────────────────────────────────────────
317
318const NAME = /^[A-Za-z0-9_-]+$/;
319const HDR = 'x-daimond-voice';
320// allowlist secret
321const SECRET = 'mock-voice-ada-0000000000000';
322const MOCK_SECRET = 'mock-voice-ada';
323
324/// What the hop does to the forge's answer about the amend flag.
325///
326/// 'absent' the forge as it stands today: the key is not there at all
327/// 'false' the forge answering, and saying no
328/// 'true' the forge answering, and saying yes
329let amendMode = 'absent';
330
331/// The panel's own name for the flag, taken from the panel.
332///
333/// NOT A COPY. Two lanes each guessed this name against a forge that did not
334/// answer it yet, and a fixture that had held either guess would have gone on
335/// feeding the panel a key it recognised while the real forge sent another.
336/// Read out of the source, and the run stops if it cannot be found — a fixture
337/// that quietly fell back to a literal would be the same fault again.
338const AMEND_FLAG = (() => {
339 // THE SOURCE AS SERVED, break and all. Taking it off disk instead would make
340 // the `oldflag` break invisible here and visible in five checks at once: the
341 // hop would inject the right name into a panel looking for the wrong one, and
342 // the whole revision section would go red for a reason that is not its own.
343 // Reading what is actually served keeps every hop-driven check green under
344 // that break and leaves exactly one — the forge's own word — to find it.
345 const src = FILES.get('js/improve.js')
346 ?? fs.readFileSync(path.join(WWW, 'js', 'improve.js'), 'utf8');
347 const m = /\bvar AMEND_FLAG = '([A-Za-z0-9_]+)';/.exec(src);
348 if (!m) {
349 console.error('js/improve.js no longer holds `var AMEND_FLAG = \'...\';`, so this file '
350 + 'cannot know which key the panel reads and nothing below would prove anything.');
351 process.exit(2);
352 }
353 return m[1];
354})();
355
356/// The path `upstream_path()` builds, including the revision route this lane
357/// added to the gateway. Written here from that function rather than guessed.
358function upstreamPath(u) {
359 const q = u.searchParams;
360 const n = q.get('n');
361 const voting = q.get('vote') === '1';
362 const amending = q.get('amend') === '1';
363 let p = `/${q.get('account')}/${q.get('repo')}/proposals`;
364 if (n !== null) p += '/' + n + (voting ? '/vote' : (amending ? '/amend' : ''));
365 p += '?format=json';
366 if (n === null) {
367 for (const k of ['state', 'from', 'limit']) {
368 const v = q.get(k);
369 if (v !== null) p += `&${k}=${v}`;
370 }
371 }
372 return p;
373}
374
375/// Put the flag on, take it off, or set it false — on a listing and on a detail
376/// alike, since the panel reads both.
377function withFlag(text) {
378 let j;
379 try { j = JSON.parse(text); } catch (e) { return text; }
380 const FLAG = AMEND_FLAG;
381 const mark = (o) => {
382 if (!o || typeof o !== 'object') return o;
383 if (amendMode === 'absent') delete o[FLAG];
384 else o[FLAG] = (amendMode === 'true');
385 return o;
386 };
387 if (Array.isArray(j.proposals)) j.proposals.forEach(mark);
388 if (typeof j.number === 'number') mark(j);
389 return JSON.stringify(j);
390}
391
392const wire = []; // every request the page made, whatever its address
393const asked = []; // every request that reached /api/improve
394
395async function improveRoute(r) {
396 const req = r.request();
397 const u = new URL(req.url());
398 const q = u.searchParams;
399 const method = req.method();
400 const body = req.postData() || '';
401 const headers = req.headers();
402 asked.push({ url: req.url(), method, body, query: Object.fromEntries(q), headers });
403
404 const refuse = (status, sentence) => r.fulfill({
405 status, contentType: 'application/json',
406 body: JSON.stringify({ ok: false, error: sentence }),
407 });
408 if (!NAME.test(q.get('account') || '')) return refuse(400, 'An account is letters, digits, \'-\' and \'_\'.');
409 if (!NAME.test(q.get('repo') || '')) return refuse(400, 'A repository is letters, digits, \'-\' and \'_\'.');
410 if (q.get('vote') === '1' && q.get('amend') === '1') {
411 return refuse(400, 'A request is a vote or a revision, not both.');
412 }
413 const voice = headers[HDR];
414 if (method === 'POST' && !voice) {
415 return refuse(401, 'Writing on the forge needs your voice, which Daimond sends with the '
416 + 'request and never keeps here.');
417 }
418
419 // The revision route, answered here rather than forwarded, because what is
420 // proved on this side is that the request went to the revision PATH — the
421 // forge's own half of it is `dev/mock_forge.mjs`'s S31. The answer is the
422 // shape the deployed forge gives: the record it changed, with `revisions` a
423 // LIST and `comments` beside it a COUNT.
424 if (q.get('amend') === '1') {
425 const f = new URLSearchParams(body);
426 return r.fulfill({ status: 200, contentType: 'application/json',
427 body: JSON.stringify({ number: Number(q.get('n')),
428 title: f.get('title') || '', body: f.get('body') || '',
429 state: 'open', author: 'ada', comments: 0, opened: 1, changed: 2,
430 discussion: [], votes: { for: 0, against: 0 }, mark: null, build: null,
431 revisions: [{ title: 'what it used to say', body: 'and how', when: 1 }] }) });
432 }
433
434 const out = { accept: 'application/json' };
435 if (voice) out['x-ore-voice'] = (voice === SECRET ? MOCK_SECRET : voice);
436 if (method === 'POST') out['content-type'] = headers['content-type'] || 'application/x-www-form-urlencoded';
437
438 let res, text;
439 try {
440 res = await fetch(`http://127.0.0.1:${FORGE_PORT}` + upstreamPath(u), {
441 method, headers: out, body: method === 'POST' ? body : undefined,
442 });
443 text = await res.text();
444 } catch (e) {
445 return r.fulfill({ status: 502, contentType: 'application/json',
446 body: JSON.stringify({ ok: false, error: 'The forge could not be reached just now.' }) });
447 }
448 return r.fulfill({ status: res.status,
449 contentType: res.headers.get('content-type') || 'application/json',
450 body: withFlag(text) });
451}
452
453const json = (body, status = 200) => ({ status, contentType: 'application/json', body: JSON.stringify(body) });
454
455async function stub(page) {
456 for (const [p, body] of FILES) {
457 const type = p.endsWith('.html') ? 'text/html' : 'application/javascript';
458 await page.route('**/' + p, r => r.fulfill({ status: 200, contentType: type, body }));
459 }
460 if (FILES.has('index.html')) {
461 await page.route(u => u.pathname === '/' || u.pathname === '/index.html',
462 r => r.fulfill({ status: 200, contentType: 'text/html', body: FILES.get('index.html') }));
463 }
464 page.on('request', req => {
465 let body = '';
466 try { body = req.postData() || ''; } catch (e) { body = ''; }
467 wire.push({ url: req.url(), method: req.method(), body });
468 });
469 await page.route(u => u.pathname === '/api/improve', improveRoute);
470 await page.route('**/api/telemetry', r => r.fulfill(json({ ok: true })));
471 await page.route('**/api/account', r => r.fulfill(json({ ok: true })));
472 await page.route('**/api/auth/challenge', r => r.fulfill(json({ ok: true, challenge: 'chal-trg', challenge_id: 'cid-1' })));
473 await page.route('**/api/auth/verify', r => r.fulfill(json({ ok: true })));
474 await page.route('**/api/balance', r => r.fulfill(json({ ok: true, credits_minor: 0, currency: 'usd', entries: [] })));
475 await page.route('**/api/licence', r => r.fulfill(json({ ok: true, licence: false, currency: 'usd' })));
476}
477
478// ── Driving ──────────────────────────────────────────────────────────
479
480/// Requests the page made to the model, told from everything else by address.
481const turns = () => wire.filter(w => w.url.indexOf('/v1/chat/completions') !== -1);
482/// Proposals opened, comments said, revisions made — told apart the way the
483/// gateway tells them apart: by the query, never by the body.
484const opens = () => asked.filter(a => a.method === 'POST' && a.query.n === undefined);
485const says = () => asked.filter(a => a.method === 'POST' && a.query.n !== undefined
486 && a.query.vote === undefined && a.query.amend === undefined);
487const revisions = () => asked.filter(a => a.method === 'POST' && a.query.amend === '1');
488
489const fields = (raw) => {
490 const out = {};
491 for (const [k, v] of new URLSearchParams(raw)) out[k] = v;
492 return out;
493};
494
495await startBoth();
496
497const s = await open({ name: 'triage', profile: PROFILE, signIn: false, connect: false, route: stub });
498const { page } = s;
499
500const { signInAs } = await import('./harness.mjs');
501await signInAs(s, 'triage');
502await page.waitForTimeout(1500);
503await connectMock(s, { baseUrl: MOCK_URL, model: 'mock/fast' });
504await page.waitForTimeout(600);
505
506try {
507 // The owner's own eighteen notes, written into the store the panel reads.
508 await page.evaluate((notes) => {
509 const key = Object.keys(localStorage).find(k => k.indexOf('daimond-improve') !== -1)
510 || 'daimond-improve';
511 localStorage.setItem(key, JSON.stringify({ v: 3, notes }));
512 if (window.DaimondImprove) window.DaimondImprove.reset();
513 }, NOTES);
514
515 // NO VOICE YET, deliberately. The whole first half below runs unenrolled,
516 // because that is the state a new tester is in and because the proposal list
517 // is read with no credential at all -- which is what makes drafting possible
518 // before anybody has been given anything.
519 await page.evaluate(() => { window.DaimondPanels.show('social'); });
520 await page.waitForTimeout(400);
521 await page.evaluate(() => { if (window.DaimondImprove) window.DaimondImprove.onOpen(); });
522 await page.waitForTimeout(800);
523 check('no voice is held yet, so the half below is the unenrolled case',
524 await page.evaluate(() => !window.DaimondVoice.has()));
525
526 const stored = await page.evaluate(() => window.DaimondImprove.notes().length);
527 check('the eighteen notes are in the store the panel reads', stored === 18, `${stored} found`);
528
529 // ── 1. Nothing runs on its own ───────────────────────────────
530 //
531 // THE BATCH DRAFTING NO LONGER HAS A UI. Note-capture merged into the compose
532 // box in the Proposals view, which drafts ONE note at a time -- "Polish & post",
533 // proved end to end in dev/verify_composemerge.mjs. What is guarded HERE is the
534 // MACHINERY that survived and that the polish path shares: the brief the model is
535 // handed, the parse that defends its answer, and the cost said before a run --
536 // driven headlessly, so nothing here pays for a turn.
537 check('opening the panel reaches no model at all', turns().length === 0,
538 `${turns().length} request(s) left the page`);
539
540 // ── 2. The brief carries everything, and drops nothing ───────
541 const brief = await page.evaluate(() =>
542 window.DaimondTriage.brief(window.DaimondImprove.notes(), []));
543 const missing = NOTES.filter(n => brief.user.indexOf(n.id) === -1);
544 check('every one of the eighteen note ids is in the brief', missing.length === 0,
545 missing.map(n => n.id).join(', '));
546 const missingWords = NOTES.filter(n => brief.user.indexOf(n.text.split('\n')[0].slice(0, 40)) === -1);
547 check('and the first words of every one of them', missingWords.length === 0,
548 missingWords.map(n => n.id).join(', '));
549 check('the brief tells the model not to merge two faults that share a panel',
550 /DO NOT MERGE TWO FAULTS BECAUSE THEY TOUCH THE SAME PANEL/.test(brief.system));
551 check('and that a note holding two faults becomes two proposals',
552 /one note holds two faults: TWO proposals/.test(brief.system));
553
554 // ── 3. The cost is honest, measured off that brief ───────────
555 const est = await page.evaluate(() =>
556 window.DaimondTriage.estimate(window.DaimondImprove.notes(), []));
557 check('the estimate is measured off the real brief, not a round number',
558 est.inTok === Math.ceil((brief.system.length + brief.user.length) / 3.5) && est.inTok > 1200,
559 JSON.stringify({ inTok: est.inTok }));
560 check('it names the model a run would use', !!est.model && /mock\/fast/.test(est.model),
561 est.model || '(none)');
562 check('and says honestly whether anything prices that model',
563 est.known === false && est.usd === 0, JSON.stringify(est));
564
565 // ── 4. The parse defends a junk answer, reads a good one ─────
566 const parsed = await page.evaluate(() => ({
567 fence: window.DaimondTriage.parse('```json\n{"drafts":[]}\n```'),
568 prose: window.DaimondTriage.parse('Sure: {"drafts":[{"kind":"new","title":"x","body":"y","from":[]}]}'),
569 garbage: window.DaimondTriage.parse('not json at all'),
570 nokind: window.DaimondTriage.parse('{"drafts":[{"title":"no kind"}]}'),
571 }));
572 check('a fenced empty plan parses to no drafts, not a throw',
573 parsed.fence && parsed.fence.err === '' && parsed.fence.drafts.length === 0, JSON.stringify(parsed.fence));
574 check('a plan wrapped in prose is still read', parsed.prose && parsed.prose.drafts.length === 1,
575 JSON.stringify(parsed.prose));
576 check('junk parses to an empty plan with a shape error, and costs nothing else',
577 parsed.garbage && parsed.garbage.err === 'shape' && parsed.garbage.drafts.length === 0,
578 JSON.stringify(parsed.garbage));
579 check('a draft with no kind this build knows is dropped, and counted',
580 parsed.nokind && parsed.nokind.drafts.length === 0 && parsed.nokind.dropped === 1,
581 JSON.stringify(parsed.nokind));
582
583 // ── 5. The fixture plan, HELD without paying: the hand-off ───
584 // `hold()` is the same hand-off a real run makes -- parse, then to the queue --
585 // so the plan-to-queue coverage stands without a model turn.
586 const plan = await page.evaluate((p) => window.DaimondTriage.hold(p), PLAN);
587
588 const PLAN_LEN = PLAN.drafts.length;
589 check('the whole fixture plan is read, none dropped', plan && plan.drafts.length === PLAN_LEN,
590 plan ? `${plan.drafts.length} of ${PLAN_LEN}` : 'none');
591
592 // ── 6. The listing is read with no voice ─────────────────────
593 const reads = asked.filter(a => a.method === 'GET');
594 check('the proposal listing is read with no voice at all, so this works unenrolled',
595 reads.length > 0 && reads.every(a => !a.headers[HDR]),
596 `${reads.length} read(s), ${reads.filter(a => a.headers[HDR]).length} voiced`);
597
598 // ── 7. Drafting sends nothing to the forge ───────────────────
599 check('drafting reaches no forge write: nothing has been posted',
600 opens().length === 0 && says().length === 0 && revisions().length === 0,
601 `${opens().length} opens, ${says().length} comments, ${revisions().length} revisions`);
602
603 // The hand-off: every draft went to the approve-list queue, which is where a
604 // draft is reviewed and sent -- dev/verify_approvelist.mjs owns the sending.
605 const queued = await page.evaluate(() =>
606 window.DaimondApproveList ? window.DaimondApproveList.queue() : []);
607 check('every draft the plan named landed in the approve-list queue',
608 queued.length === PLAN_LEN, `${queued.length} of ${PLAN_LEN} queued`);
609 check('and each is tagged with what sending it would do',
610 queued.length === PLAN_LEN && queued.every(d => ['new', 'comment', 'revision'].indexOf(d.kind) !== -1),
611 queued.map(d => d.kind).join(','));
612
613 // ── 8. Every note is accounted for ───────────────────────────
614 const accounted = new Set();
615 (plan ? plan.drafts : []).forEach(d => d.from.forEach(id => accounted.add(id)));
616 (plan ? plan.left : []).forEach(l => accounted.add(l.id));
617 const lost = NOTES.filter(n => !accounted.has(n.id));
618 check('every note is in a draft or said to be left out, by name', lost.length === 0,
619 lost.map(n => n.id).join(', '));
620
621 const twice = {};
622 (plan ? plan.drafts : []).forEach(d => d.from.forEach(id => { twice[id] = (twice[id] || 0) + 1; }));
623 const splitTwo = Object.keys(twice).filter(k => twice[k] > 1).sort();
624 check('the two notes holding two faults each became two drafts, and only those two',
625 splitTwo.length === 2 && splitTwo.includes('nmtbemooi4ok8') && splitTwo.includes('nmtbes6h4vei5'),
626 splitTwo.join(', '));
627
628 await shot(s, 'triage-machinery' + (BREAK ? '-' + BREAK : ''));
629
630 // ── A voice is set, from the Settings view where it now lives, for the panel's
631 // own Revise control below.
632 await page.evaluate(() => window.DaimondSocial.show('settings'));
633 await page.waitForTimeout(200);
634 await page.click('[data-act="improve-voice-open"]');
635 await page.waitForTimeout(200);
636 await page.fill('#improve-voice-in', SECRET);
637 await page.click('[data-act="improve-voice-save"]');
638 await page.waitForTimeout(800);
639 check('a voice can be set from the Settings view',
640 await page.evaluate(() => window.DaimondVoice.has()) === true);
641 check('setting a voice runs no model turn', turns().length === 0, `${turns().length} model turn(s)`);
642 await page.evaluate(() => window.DaimondSocial.show('proposals'));
643 await page.waitForTimeout(200);
644
645 // §6 (ONE PRESS PER DRAFT, WHAT LEAVES IS THE BOX) and §7 (A FOLDED NOTE IS NOT
646 // A SENT ONE) MOVED to dev/verify_approvelist.mjs, which now owns the sending
647 // and the fold. Triage no longer sends, so what-leaves-is-the-box, the
648 // title+body field set, and folding-on-send are proved where they now happen.
649
650 // ── 8. And the cap honours it ────────────────────────────────
651 const capped = await page.evaluate(() => {
652 // Two hundred and forty notes: eighty sent, eighty folded, eighty plain.
653 // The cap is two hundred, so forty must go, and every one of them must
654 // come out of the eighty the forge already holds.
655 const key = Object.keys(localStorage).find(k => k.indexOf('daimond-improve') !== -1)
656 || 'daimond-improve';
657 const notes = [];
658 for (let i = 0; i < 240; i++) {
659 const kind = i % 3;
660 notes.push({
661 id: 'cap' + i, at: 1000000 + i, text: 'note ' + i,
662 sent: kind === 0 ? 2000 + i : 0,
663 n: kind === 0 ? 100 + i : 0,
664 into: kind === 1 ? [500 + i] : [],
665 });
666 }
667 localStorage.setItem(key, JSON.stringify({ v: 3, notes }));
668 window.DaimondImprove.reset();
669 // One write, which is what applies the cap.
670 window.DaimondImprove.fold(['cap1'], 999);
671 const left = window.DaimondImprove.notes();
672 return {
673 total: left.length,
674 sent: left.filter(r => r.sent && !r.into.length).length,
675 folded: left.filter(r => r.into.length).length,
676 plain: left.filter(r => !r.sent && !r.into.length).length,
677 };
678 });
679 check('the cap holds at two hundred', capped.total === 200, JSON.stringify(capped));
680 check('and every folded note survived it', capped.folded === 80, JSON.stringify(capped));
681 check('and every note nobody has delivered survived it', capped.plain === 80, JSON.stringify(capped));
682 check('and what went was forty notes the forge already holds in full',
683 capped.sent === 40, JSON.stringify(capped));
684
685 // ── 9 and 10. The revision control, and absent against false ─
686 //
687 // Driven through `cleanProp` itself rather than through a plan, because what
688 // is being asserted is the record and not the drawing -- and the drawing is
689 // asserted underneath it, from the same record.
690 amendMode = 'absent';
691 await page.evaluate(() => { window.DaimondImprove.reset(); });
692 await page.evaluate(() => window.DaimondImprove.load(false));
693 await page.waitForTimeout(700);
694 const absent = await page.evaluate(() => {
695 const p = window.DaimondImprove.listing().shown[0];
696 const r = window.DaimondImprove.proposal(p);
697 return { n: p, askedAmend: r.askedAmend, amendable: r.amendable,
698 may: window.DaimondImprove.forge.mayAmend(p) };
699 });
700 check('with no flag in the answer, nobody was asked and nobody may amend',
701 absent.askedAmend === false && absent.amendable === false && absent.may === false,
702 JSON.stringify(absent));
703
704 amendMode = 'false';
705 await page.evaluate(() => { window.DaimondImprove.reset(); });
706 await page.evaluate(() => window.DaimondImprove.load(false));
707 await page.waitForTimeout(700);
708 const said_no = await page.evaluate(() => {
709 const p = window.DaimondImprove.listing().shown[0];
710 const r = window.DaimondImprove.proposal(p);
711 return { askedAmend: r.askedAmend, amendable: r.amendable,
712 may: window.DaimondImprove.forge.mayAmend(p) };
713 });
714 check('a flag answered FALSE is a different fact from no flag at all',
715 said_no.askedAmend === true && said_no.amendable === false && said_no.may === false,
716 JSON.stringify(said_no));
717 check('and the two are told apart, which is what the panel needs to light up later',
718 absent.askedAmend !== said_no.askedAmend,
719 `absent ${absent.askedAmend} / false ${said_no.askedAmend}`);
720
721 // ── 10b. THE PANEL'S KEY IS THE FORGE'S KEY, AND `revisions` IS A LIST ─
722 //
723 // Everything above is driven through the hop, which INJECTS the flag — so all
724 // of it would pass just as well against a name nobody answers. That is exactly
725 // how two lanes shipped `amend` and `may_amend` against a forge that says
726 // `mine_to_amend`, each with a green run behind it. This reaches the forge
727 // itself, with a voice and without one, and asks whether the word the panel
728 // looks for is the word that arrives.
729 const forgeSaid = await (async () => {
730 const at = `http://127.0.0.1:${FORGE_PORT}/oxedyne/ore/proposals?format=json&limit=1`;
731 const bare = await (await fetch(at)).json();
732 const voiced = await (await fetch(at, { headers: { 'x-ore-voice': MOCK_SECRET } })).json();
733 const has = (o) => Object.prototype.hasOwnProperty.call(o, AMEND_FLAG);
734 return { bare: has(bare.proposals[0]), voiced: has(voiced.proposals[0]),
735 value: voiced.proposals[0][AMEND_FLAG] };
736 })();
737 check(`the forge answers the very key the panel reads, '${AMEND_FLAG}', and only to a voice`,
738 forgeSaid.voiced === true && forgeSaid.bare === false
739 && (forgeSaid.value === true || forgeSaid.value === false),
740 JSON.stringify(forgeSaid));
741
742 // And `revisions` survives `cleanProp` as the LIST it is. `comments` beside it
743 // is a COUNT on both routes, which is the analogy that turns a list into a
744 // type error; and a listing record carries no `revisions` at all, so `null`
745 // there is the honest answer and `[]` would be a claim nobody made.
746 const revs = await page.evaluate(async () => {
747 const n = window.DaimondImprove.listing().shown[0];
748 const listed = window.DaimondImprove.proposal(n);
749 await window.DaimondImprove.one(n);
750 const whole = window.DaimondImprove.proposal(n);
751 return { listed: listed.revisions, whole: whole.revisions, comments: whole.comments };
752 });
753 check('a listing record carries no revisions and says so with null, never with an empty list',
754 revs.listed === null, JSON.stringify(revs.listed));
755 check('and the whole proposal carries the LIST the forge sent, beside comments as a COUNT',
756 Array.isArray(revs.whole) && revs.whole.length === 0
757 && typeof revs.comments === 'number', JSON.stringify(revs));
758
759 // ── 10c. THE PANEL'S OWN REVISE CONTROL, PRESSED ─────────────
760 //
761 // Everything about revising up to here goes through the triage plan, which
762 // reaches the forge by a different function from the one the button under a
763 // proposal reaches it by. THE TWO WERE THE SAME NAME. Two lanes each declared
764 // `async function amend` in one closure a fortnight apart, and a function
765 // declaration does not collide — the later one silently replaced the earlier,
766 // so the triage half worked, its checks passed, and the button threw on its
767 // first line. Nothing either lane wrote could see it, because each half was
768 // right. This presses the button.
769 amendMode = 'true';
770 await page.evaluate(() => { window.DaimondImprove.reset(); });
771 // THE PROPOSALS VIEW, because this half is pressed rather than called: every
772 // check above reads the record and this one reads the screen, and a row drawn
773 // into a hidden view is a row nothing can click.
774 await page.evaluate(() => window.DaimondImprove.show('proposals'));
775 await page.evaluate(() => window.DaimondImprove.load(false));
776 await page.waitForTimeout(700);
777 // An OPEN one: `drawAmendControl` asks the forge who the author is and asks
778 // this side whether the proposal is still taking words.
779 const openN = await page.evaluate(() => {
780 const shown = window.DaimondImprove.listing().shown;
781 for (const n of shown) {
782 const r = window.DaimondImprove.proposal(n);
783 if (r && r.state === 'open') return n;
784 }
785 return 0;
786 });
787 const at = (sel) => `.imp-prop[data-prop="${openN}"] ${sel}`;
788 await page.locator(at('[data-act="improve-open"]')).click();
789 await page.locator(at('[data-act="improve-amend-open"]')).click();
790 await page.fill(at('.imp-amend-title'), 'The words it says now');
791 await page.fill(at('.imp-amend-body'), 'And what they were changed for.');
792 const beforePress = revisions().length;
793 await page.locator(at('[data-act="improve-amend-save"]')).click();
794 await page.waitForTimeout(900);
795 const pressed = await page.evaluate((n) => {
796 const r = window.DaimondImprove.proposal(n);
797 const f = document.querySelector('.imp-prop[data-prop="' + n + '"] .imp-prop-facts');
798 return { title: r && r.title, revisions: r && r.revisions,
799 facts: f ? (f.textContent || '') : '' };
800 }, openN);
801 check('pressing Revise under a proposal sends exactly one revision, to the revision route',
802 openN > 0 && revisions().length === beforePress + 1
803 && revisions()[revisions().length - 1].query.amend === '1',
804 `#${openN}, ${revisions().length - beforePress} request(s)`);
805 check('and the record it answered is taken back, so the row shows the new words at once',
806 pressed.title === 'The words it says now', JSON.stringify(pressed.title));
807 check('with the revisions it carried kept as a LIST and said in words a person reads',
808 Array.isArray(pressed.revisions) && pressed.revisions.length === 1
809 && /revised once/.test(pressed.facts),
810 JSON.stringify(pressed).slice(0, 240));
811 await page.evaluate(() => window.DaimondImprove.show('notes'));
812
813 // THE REVISION DRAFT'S DARK/LIT GATE AND ITS SEND MOVED to
814 // dev/verify_approvelist.mjs: a revision draft is now drawn in the QUEUE, and
815 // whether it offers a tick is gated there on the forge's per-asker
816 // `mine_to_amend` flag (dark when the forge is silent, lit when it grants,
817 // never a path to another author's proposal). What stays HERE is `cleanProp`'s
818 // absent-vs-false above, and the panel's OWN Revise control below §10c -- both
819 // improve.js's, not triage's.
820
821 // ── 11. A refusal is said and nothing is retried ─────────────
822 // ── 12. An unreadable answer costs nothing else ──────────────
823 const junk = await page.evaluate(() => [
824 window.DaimondTriage.parse('I am sorry, I cannot do that.'),
825 window.DaimondTriage.parse('```json\n{"drafts":[{"kind":"nope"}],"left":[]}\n```'),
826 window.DaimondTriage.parse('{"drafts":[{"kind":"comment","body":"x","from":[]}],"left":[]}'),
827 ]);
828 check('prose in place of a plan is read as no plan at all',
829 junk[0].drafts.length === 0 && junk[0].err === 'shape', JSON.stringify(junk[0]));
830 check('a kind this build does not know is dropped and counted, never guessed',
831 junk[1].drafts.length === 0 && junk[1].dropped === 1, JSON.stringify(junk[1]));
832 check('a comment with no proposal number is dropped: it has nowhere to land',
833 junk[2].drafts.length === 0 && junk[2].dropped === 1, JSON.stringify(junk[2]));
834
835 // ── 14. A REAL polish run: it reaches the model, and is booked ─
836 //
837 // The one paid turn in this file. `DaimondTriage.polish` is what the compose
838 // box's "Polish & post" runs; here it is run for real against the mock model,
839 // so meter() and the account ledger are exercised and the note's own words are
840 // shown to have reached the model. (dev/verify_composemerge.mjs stubs the draft
841 // to prove the WIRING that posts it; this proves the run itself.)
842 await page.evaluate(() => window.DaimondTriage.polish('The compose box loses focus after a post.'));
843 await page.waitForTimeout(700);
844 const polishSent = modelLog();
845 const polishMsg = polishSent.length
846 ? (polishSent[polishSent.length - 1].messages || []).filter(m => m.role === 'user')
847 .map(m => String(m.content || '')).join('\n')
848 : '';
849 check('a Polish run reaches the model, carrying the note\'s own words',
850 /compose box loses focus/.test(polishMsg), polishMsg.slice(0, 120));
851 const booked = await page.evaluate(() => {
852 try {
853 if (!window.DaimondLedger) return null;
854 var per = window.DaimondLedger.perModel('month') || [];
855 return per.map(function (r) { return { model: r.model || r.m || '', tokens: r.tokens || 0 }; });
856 } catch (e) { return null; }
857 });
858 check('and it is booked to the account\'s own ledger, under the model that ran it',
859 Array.isArray(booked) && booked.some(r => /mock\/fast/.test(r.model) && r.tokens > 0),
860 JSON.stringify(booked));
861
862 const errs = errors(s).filter(e => !/Failed to load resource/.test(e));
863 check('nothing above was reached by way of an unhandled error', errs.length === 0,
864 errs.slice(0, 3).join(' | '));
865
866 await shot(s, 'triage' + (BREAK ? '-' + BREAK : ''));
867} finally {
868 await s.close();
869 stopBoth();
870}
871
872console.log(`\nmodel turns: ${turns().length} forge requests: ${asked.length}`
873 + ` opens: ${opens().length} revisions: ${revisions().length}`);
874if (BREAK) {
875 console.log(`\nbreak '${BREAK}': ${bad.length} check(s) failed`
876 + (bad.length ? ' — ' + bad.join('; ') : ' — NOTHING FAILED, so the checks above prove nothing'));
877 process.exit(bad.length ? 0 : 1);
878}
879console.log(bad.length === 0 ? `\nall ${ok.length} checks passed` : `\n${bad.length} check(s) FAILED`);
880process.exit(bad.length === 0 ? 0 : 1);