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 | |
| 106 | import fs from 'node:fs'; |
| 107 | import path from 'node:path'; |
| 108 | import { spawn } from 'node:child_process'; |
| 109 | import { fileURLToPath } from 'node:url'; |
| 110 | import { open, shot, scratch, errors, connectMock } from './harness.mjs'; |
| 111 | |
| 112 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 113 | const WWW = path.join(HERE, '..', 'www'); |
| 114 | |
| 115 | const BREAK = (() => { |
| 116 | const i = process.argv.indexOf('--break'); |
| 117 | return i > 0 ? String(process.argv[i + 1] || '') : ''; |
| 118 | })(); |
| 119 | |
| 120 | const PROFILE = scratch('pw', 'triage' + (BREAK ? '-' + BREAK : '')); |
| 121 | fs.rmSync(PROFILE, { recursive: true, force: true }); |
| 122 | |
| 123 | const ok = [], bad = []; |
| 124 | const 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 | |
| 131 | const NOTES = JSON.parse(fs.readFileSync(path.join(HERE, 'fixtures', 'triage_notes_18.json'), 'utf8')).notes; |
| 132 | const PLAN = JSON.parse(fs.readFileSync(path.join(HERE, 'fixtures', 'triage_plan_18.json'), 'utf8')); |
| 133 | const PLAN_FILE = path.join(HERE, 'fixtures', 'triage_plan_18.json'); |
| 134 | |
| 135 | // ── The forge and the model, both this run's own ───────────────────── |
| 136 | |
| 137 | const FORGE_PORT = 8443; |
| 138 | const MOCK_PORT = Number(process.env.TRIAGE_MOCK_PORT || 9143); |
| 139 | const MOCK_URL = `http://127.0.0.1:${MOCK_PORT}/v1/chat/completions`; |
| 140 | const MOCK_LOG = scratch('triage', 'mockllm-triage.log'); |
| 141 | |
| 142 | const started = []; |
| 143 | |
| 144 | async 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 | |
| 149 | async 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 | |
| 176 | function stopBoth() { |
| 177 | for (const p of started) { try { p.kill('SIGTERM'); } catch (e) { /* already gone */ } } |
| 178 | started.length = 0; |
| 179 | } |
| 180 | process.on('exit', stopBoth); |
| 181 | for (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. |
| 186 | const 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 | |
| 194 | const 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 | |
| 203 | function 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 | |
| 221 | const 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 | |
| 289 | if (BREAK && !BREAKS[BREAK]) { |
| 290 | console.error(`unknown break '${BREAK}'; one of: ${Object.keys(BREAKS).join(', ')}`); |
| 291 | process.exit(2); |
| 292 | } |
| 293 | |
| 294 | function 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 | |
| 304 | const FILES = new Map(); |
| 305 | function 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 | } |
| 314 | build(); |
| 315 | |
| 316 | // ── The gateway, stood in for ──────────────────────────────────────── |
| 317 | |
| 318 | const NAME = /^[A-Za-z0-9_-]+$/; |
| 319 | const HDR = 'x-daimond-voice'; |
| 320 | // allowlist secret |
| 321 | const SECRET = 'mock-voice-ada-0000000000000'; |
| 322 | const 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 |
| 329 | let 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. |
| 338 | const 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. |
| 358 | function 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. |
| 377 | function 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 | |
| 392 | const wire = []; // every request the page made, whatever its address |
| 393 | const asked = []; // every request that reached /api/improve |
| 394 | |
| 395 | async 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 | |
| 453 | const json = (body, status = 200) => ({ status, contentType: 'application/json', body: JSON.stringify(body) }); |
| 454 | |
| 455 | async 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. |
| 481 | const 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. |
| 484 | const opens = () => asked.filter(a => a.method === 'POST' && a.query.n === undefined); |
| 485 | const says = () => asked.filter(a => a.method === 'POST' && a.query.n !== undefined |
| 486 | && a.query.vote === undefined && a.query.amend === undefined); |
| 487 | const revisions = () => asked.filter(a => a.method === 'POST' && a.query.amend === '1'); |
| 488 | |
| 489 | const fields = (raw) => { |
| 490 | const out = {}; |
| 491 | for (const [k, v] of new URLSearchParams(raw)) out[k] = v; |
| 492 | return out; |
| 493 | }; |
| 494 | |
| 495 | await startBoth(); |
| 496 | |
| 497 | const s = await open({ name: 'triage', profile: PROFILE, signIn: false, connect: false, route: stub }); |
| 498 | const { page } = s; |
| 499 | |
| 500 | const { signInAs } = await import('./harness.mjs'); |
| 501 | await signInAs(s, 'triage'); |
| 502 | await page.waitForTimeout(1500); |
| 503 | await connectMock(s, { baseUrl: MOCK_URL, model: 'mock/fast' }); |
| 504 | await page.waitForTimeout(600); |
| 505 | |
| 506 | try { |
| 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 | |
| 872 | console.log(`\nmodel turns: ${turns().length} forge requests: ${asked.length}` |
| 873 | + ` opens: ${opens().length} revisions: ${revisions().length}`); |
| 874 | if (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 | } |
| 879 | console.log(bad.length === 0 ? `\nall ${ok.length} checks passed` : `\n${bad.length} check(s) FAILED`); |
| 880 | process.exit(bad.length === 0 ? 0 : 1); |