Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_pausespend.mjs

19.4 KiB, 1 run

created by r2519314175:579, 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_pausespend.mjs — a pause is refused where the money is committed.
2//
3// The PPTW of NOTES2_PLAN §1.1 is one control with three states, and §1.1 says
4// what makes it real: "Not in the UI. A pause has to be refused at the point
5// money is committed, or it is decoration." So nothing here reads the flag back.
6// Every assertion is counted AT THE NETWORK — `page.route` sits between the page
7// and every host it talks to, so a request that was refused never reaches a
8// counter, and one that was merely hidden does.
9//
10// Each boundary is asked BOTH questions, and the second is the one that matters:
11//
12// * paused → no request left the page;
13// * resumed → the request DOES leave.
14//
15// A check that only ever proves silence passes with the feature entirely broken —
16// with the network unplugged, with the button doing nothing, with the whole
17// module deleted. The resume half is what tells a refusal from an outage.
18//
19// The boundaries, and what drives each:
20//
21// 1. THE WORKER-SLOT MINT. A real conductor turn dispatching three agents,
22// with `root/workers` paused. Every worker key is minted at
23// /api/inference-key with a slot of 1 or more; a paused pump mints none.
24// 2. THE CHAT'S MINT. `remint(gen, node)` for a paused leaf. Called directly:
25// it is the call daimond.js makes at www/js/daimond.js:7781, with the
26// argument it has yet to pass.
27// 3. THE DISPATCH GATE. `assessDispatch(n, node)` comes back refused through
28// the object it already answers with. A decision check, not a network one —
29// the network half of the same boundary is (1).
30// 4. WEB FETCH. The real DaimondWeb.fetch, against a stubbed /api/web/fetch.
31// 5. MAIL. The real DaimondMail.sync, against a stubbed /api/mail/sync.
32// 6. READING IS NOT PAUSED. With every leaf held, a Diamond still opens, its
33// crystal still renders and its files still list.
34//
35// Everything below the stub is the real code: the real wasm, the real models.js,
36// the real gateway.js, the real Workers pool. The gateway itself is not run.
37//
38// eval "$(bash dev/world.sh 3 --up)"
39// node dev/verify_pausespend.mjs
40//
41// Needs dev/serve.mjs and dev/mockllm.mjs (DAIMOND_PORT / DAIMOND_MOCK_PORT).
42import fs from 'node:fs';
43import { open, signInAs, shot, scratch, errors, MOCK } from './harness.mjs';
44import { IMAP_PORT, SMTP_PORT } from './ports.mjs';
45
46const PROFILE = scratch('pw', 'pausespend');
47fs.rmSync(PROFILE, { recursive: true, force: true });
48
49const ok = [], bad = [];
50const check = (name, pass, detail) => {
51 (pass ? ok : bad).push(name);
52 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
53};
54
55const CORS = { 'access-control-allow-origin': '*', 'access-control-allow-headers': '*' };
56const json = (body, status = 200) => ({
57 status, contentType: 'application/json', headers: CORS, body: JSON.stringify(body),
58});
59const OR_BASE = 'https://openrouter.ai/api/v1';
60const OR_URL = `${OR_BASE}/chat/completions`;
61const MAILBOX = 'alice@test.local';
62
63// Everything that left the page, by boundary. A refusal shows as a count that
64// did not move.
65const net = { mint: [], web: [], mailSync: [], mailSend: [], mailFolders: 0, provider: 0 };
66const slotMints = () => net.mint.filter(s => s >= 1).length;
67
68async function stub(page) {
69 await page.route('**/api/account', r => r.fulfill(json({ ok: true })));
70 await page.route('**/api/auth/challenge', r => r.fulfill(json({ ok: true, challenge: 'chal-pause', challenge_id: 'cid-1' })));
71 await page.route('**/api/auth/verify', r => r.fulfill(json({ ok: true })));
72 await page.route('**/api/balance', r => r.fulfill(json({ ok: true, credits_minor: 5000, currency: 'usd', entries: [] })));
73 await page.route('**/api/licence', r => r.fulfill(json({ ok: true, licence: true, held: true, currency: 'usd' })));
74
75 await page.route('**/api/inference-key', r => {
76 let slot = 0;
77 try { slot = (JSON.parse(r.request().postData() || '{}').slot) | 0; } catch (e) { slot = 0; }
78 net.mint.push(slot);
79 return r.fulfill(json({
80 ok: true, key: `sk-or-v1-SLOT${slot}-pausemarker00000000000000000000000000`,
81 url: OR_BASE, limit_minor: 200, credits_minor: 5000, currency: 'usd',
82 }));
83 });
84
85 await page.route(`${OR_BASE}/models`,
86 r => r.fulfill(json({ data: [{ id: 'anthropic/claude-opus-4.5' }] })));
87 await page.route(OR_URL, async (r) => {
88 net.provider++;
89 const sent = r.request().postData() || '';
90 const res = await fetch(MOCK, { method: 'POST', headers: { 'content-type': 'application/json' }, body: sent });
91 const body = await res.text();
92 return r.fulfill({ status: res.status, headers: CORS, body,
93 contentType: res.headers.get('content-type') || 'application/json' });
94 });
95
96 // The three credit-spending routes this file measures.
97 await page.route('**/api/web/fetch', r => {
98 net.web.push(r.request().postData() || '');
99 return r.fulfill(json({ ok: true, url: 'https://example.com/', title: 'Example',
100 text: 'a page', bytes: 6, credits_minor: 4990 }));
101 });
102 await page.route('**/api/web/head', r => r.fulfill(json({ ok: true, framable: false })));
103 await page.route('**/api/mail/sync', r => {
104 net.mailSync.push(r.request().postData() || '');
105 return r.fulfill(json({ ok: true, uid_validity: 7, messages: [], credits_minor: 4980 }));
106 });
107 await page.route('**/api/mail/send', r => {
108 net.mailSend.push(r.request().postData() || '');
109 return r.fulfill(json({ ok: true, credits_minor: 4970 }));
110 });
111 // Listing is READING, and reading is never paused.
112 await page.route('**/api/mail/folders', r => {
113 net.mailFolders++;
114 return r.fulfill(json({ ok: true, folders: [{ name: 'INBOX' }, { name: 'Sent', role: 'sent' }] }));
115 });
116 await page.route('**/api/mail/accounts', r => r.fulfill(json({ ok: true, allowed: 3, used: 0 })));
117}
118
119// ── The tree, driven the way the widget will drive it ────────────────
120// Only leaves are set, so none of this needs the live tree daimond.js has yet
121// to register: `DaimondPause.set` on an id it cannot find writes that id alone,
122// which for a leaf is exactly right.
123const pauseLeaf = (page, id) => page.evaluate(i => window.DaimondPause.set(i, false), id);
124const playLeaf = (page, id) => page.evaluate(i => window.DaimondPause.set(i, true), id);
125const pausedIds = (page) => page.evaluate(() => window.DaimondPause.pausedIds());
126
127const s = await open({ name: 'pausespend', profile: PROFILE, signIn: false, connect: false });
128const { page } = s;
129await stub(page);
130await signInAs(s, 'pausespend');
131await page.waitForTimeout(2500); // unlock → bootstrap → mint slot 0 → catalogue
132
133try {
134 await page.evaluate(() => window.DaimondModels.setDefault('credits', 'anthropic/claude-opus-4.5'));
135 check('the account is on credits and slot 0 was minted at unlock',
136 net.mint.includes(0), 'mints ' + JSON.stringify(net.mint));
137
138 // ── 1. The worker-slot mint ──────────────────────────────────
139 const WORKERS = 'root/workers';
140 await pauseLeaf(page, WORKERS);
141
142 // The Admin drawer opens over the rail on an unconnected profile and swallows
143 // the click; close it the way a person would, then force past the fade that
144 // keeps failing Playwright's stability check.
145 const drawer = page.locator('#admin-close');
146 if (await drawer.isVisible().catch(() => false)) {
147 await drawer.click({ force: true });
148 await page.waitForTimeout(300);
149 }
150 await page.click('#new-diamond-btn', { force: true });
151 await page.waitForSelector('.dlg-input', { timeout: 8000 });
152 await page.fill('.dlg-input', 'Held');
153 await page.click('.dlg-ok', { force: true });
154 await page.waitForSelector('#chat-input', { timeout: 10000 });
155 await page.waitForTimeout(400);
156
157 const steer = '@tools spawn_agent {"name":"a","task":"one"} ;; '
158 + 'spawn_agent {"name":"b","task":"two"} ;; '
159 + 'spawn_agent {"name":"c","task":"three"}';
160 const before = slotMints();
161 await page.fill('#chat-input', steer);
162 await page.click('#chat-send', { force: true });
163 await page.waitForTimeout(7000);
164 check('a paused worker pump dispatches nothing and mints nothing',
165 slotMints() === before, 'slot mints ' + JSON.stringify(net.mint));
166
167 // AND THE MINT ITSELF, asked directly. The check above passes with the mint
168 // gate deleted, because daimond.js's pump reads the same leaf and never
169 // reaches the mint — which is two guards doing their job and one check
170 // proving only the outer one. This is the inner one on its own: the call the
171 // pump makes at www/js/daimond.js:8453, made here with the pump held.
172 const beforeDirect = net.mint.length;
173 const refusedSlot = await page.evaluate(async () => {
174 try { await window.DaimondModels.mintSlot(1); return { threw: false, msg: '' }; }
175 catch (e) { return { threw: true, msg: String(e && e.message || e), node: e && e.pauseNode }; }
176 finally { window.DaimondModels.forgetSlot(1); }
177 });
178 check('and a worker key asked for directly is refused at the mint',
179 net.mint.length === beforeDirect && refusedSlot.threw,
180 refusedSlot.msg || ('mints ' + JSON.stringify(net.mint)));
181 check('and that refusal names the pump and how to resume it',
182 refusedSlot.node === WORKERS && refusedSlot.msg.includes(WORKERS)
183 && /play/i.test(refusedSlot.msg), refusedSlot.msg);
184
185 await playLeaf(page, WORKERS);
186 const afterDirect = await page.evaluate(async () => {
187 try { await window.DaimondModels.mintSlot(1); return 'ok'; }
188 catch (e) { return String(e && e.message || e); }
189 finally { window.DaimondModels.forgetSlot(1); }
190 });
191 check('and a resumed pump mints — so the silence above was the pause',
192 net.mint.length > beforeDirect, afterDirect + ' — mints ' + JSON.stringify(net.mint));
193
194 await page.fill('#chat-input', steer);
195 await page.click('#chat-send', { force: true });
196 await page.waitForTimeout(9000);
197 check('and a resumed pump dispatches again',
198 slotMints() > before, 'slot mints ' + JSON.stringify(net.mint));
199
200 // ── 2. The chat's own mint ───────────────────────────────────
201 // `remint(gen, node)` is the call at www/js/daimond.js:7781, given the
202 // argument the widget's own work will pass it.
203 const CHAT = 'root/chats/pausespend-1';
204 await pauseLeaf(page, CHAT);
205 const n2 = net.mint.length;
206 // The LIVE generation in BOTH halves. `remint` hands an OLD generation the key
207 // somebody else has already minted rather than buying another, so a stale
208 // number makes the paused half silent whatever the guard does — a check that
209 // passes with the guard deleted, which is no check at all.
210 const refusedMint = await page.evaluate(async (node) => {
211 try {
212 await window.DaimondModels.remint(window.DaimondModels.creditsGen(), node);
213 return { threw: false, msg: '' };
214 } catch (e) {
215 return { threw: true, msg: String(e && e.message || e), node: e && e.pauseNode, flag: !!(e && e.paused) };
216 }
217 }, CHAT);
218 check('a paused chat mints no key', net.mint.length === n2 && refusedMint.threw,
219 'mints ' + JSON.stringify(net.mint));
220 check('and the refusal names the node and how to resume it',
221 refusedMint.threw && refusedMint.node === CHAT
222 && /play/i.test(refusedMint.msg) && refusedMint.msg.includes(CHAT),
223 refusedMint.msg);
224 check('and it is marked a pause rather than a fault', refusedMint.flag === true);
225
226 await playLeaf(page, CHAT);
227 // The LIVE generation, or `remint` rightly hands back the key somebody else
228 // has already minted instead of buying another — and the resume half would
229 // pass without a request ever leaving.
230 const mintedAfter = await page.evaluate(async (node) => {
231 try {
232 await window.DaimondModels.remint(window.DaimondModels.creditsGen(), node);
233 return 'ok';
234 } catch (e) { return String(e && e.message || e); }
235 }, CHAT);
236 check('and resuming it mints', net.mint.length > n2,
237 mintedAfter + ' — mints ' + JSON.stringify(net.mint));
238
239 // ── 3. The dispatch gate ─────────────────────────────────────
240 const DIA = 'root/diamonds/d1/self';
241 await pauseLeaf(page, DIA);
242 const gate = await page.evaluate((node) => ({
243 held: window.DaimondGovernor.assessDispatch(3, node),
244 free: window.DaimondGovernor.assessDispatch(3, 'root/diamonds/d2/self'),
245 none: window.DaimondGovernor.assessDispatch(3),
246 }), DIA);
247 check('a paused node comes back refused through the same decision object',
248 gate.held.refused === true && gate.held.pauseNode === DIA,
249 JSON.stringify({ refused: gate.held.refused, node: gate.held.pauseNode }));
250 check('and its refusal names the node and how to resume it',
251 typeof gate.held.refusal === 'string' && gate.held.refusal.includes(DIA)
252 && /play/i.test(gate.held.refusal), gate.held.refusal);
253 check('and it also stops a caller that only reads needsConfirm',
254 gate.held.needsConfirm === true);
255 check('a node that is playing is not refused', gate.free.refused === false);
256 check('and neither is a caller that names no node at all', gate.none.refused === false);
257 await playLeaf(page, DIA);
258
259 // ── 4. Web fetch ─────────────────────────────────────────────
260 const WEB = 'root/web';
261 await pauseLeaf(page, WEB);
262 const n4 = net.web.length;
263 const refusedWeb = await page.evaluate(async () => {
264 try { await window.DaimondWeb.fetch('https://example.com/'); return { threw: false, msg: '' }; }
265 catch (e) { return { threw: true, msg: String(e && e.message || e) }; }
266 });
267 check('a paused Web panel fetches no page', net.web.length === n4,
268 net.web.length + ' fetch(es)');
269 check('and the refusal names the node and how to resume it',
270 refusedWeb.threw && refusedWeb.msg.includes(WEB) && /play/i.test(refusedWeb.msg),
271 refusedWeb.msg);
272
273 await playLeaf(page, WEB);
274 const gotWeb = await page.evaluate(async () => {
275 try { return (await window.DaimondWeb.fetch('https://example.com/')).title || 'no title'; }
276 catch (e) { return 'THREW ' + (e && e.message || e); }
277 });
278 check('and resuming it fetches', net.web.length === n4 + 1,
279 gotWeb + ' — ' + net.web.length + ' fetch(es)');
280
281 // The global control is the root of the same tree, and holds a fetch that
282 // belongs to no leaf of its own.
283 await pauseLeaf(page, 'root');
284 const n4b = net.web.length;
285 await page.evaluate(async () => { try { await window.DaimondWeb.fetch('https://example.com/'); } catch (e) {} });
286 check('the global pause holds a page fetch that names no leaf',
287 net.web.length === n4b, net.web.length + ' fetch(es)');
288 await playLeaf(page, 'root');
289
290 // ── 5. Mail ──────────────────────────────────────────────────
291 await page.evaluate(async ([addr, PORTS]) => {
292 const pass = await window.DaimondIdentity.wrap('test-app-password');
293 localStorage.setItem('daimond-mail', JSON.stringify({
294 accounts: [{
295 address: addr, host: '127.0.0.1', port: PORTS.imap, security: 'plain',
296 smtpHost: '127.0.0.1', smtpPort: PORTS.smtp, smtpSecurity: 'plain',
297 user: addr, pass, folder: 'INBOX', folders: {}, lastSync: 0,
298 }],
299 sel: addr,
300 }));
301 window.DaimondMail.reload();
302 window.DaimondPanels.show('mail');
303 window.DaimondMail.onOpen();
304 }, [MAILBOX, { imap: IMAP_PORT, smtp: SMTP_PORT }]);
305 await page.waitForTimeout(1200);
306
307 const INBOX = `root/mail/${MAILBOX}/INBOX`;
308 const MBOX = `root/mail/${MAILBOX}/self`;
309 await pauseLeaf(page, INBOX);
310 const n5 = net.mailSync.length;
311 await page.evaluate(() => window.DaimondMail.sync());
312 await page.waitForTimeout(1500);
313 check('a paused mail folder contacts no server', net.mailSync.length === n5,
314 net.mailSync.length + ' sync(s)');
315
316 await playLeaf(page, INBOX);
317 await page.evaluate(() => window.DaimondMail.sync());
318 await page.waitForTimeout(1500);
319 check('and resuming it syncs', net.mailSync.length === n5 + 1,
320 net.mailSync.length + ' sync(s)');
321
322 // The mailbox's own leaf holds every folder under it, or a paused mailbox
323 // would go on reaching the server one folder at a time.
324 await pauseLeaf(page, MBOX);
325 const n5b = net.mailSync.length;
326 await page.evaluate(() => window.DaimondMail.sync());
327 await page.waitForTimeout(1500);
328 check('a paused mailbox holds a sync of a folder that is playing',
329 net.mailSync.length === n5b, net.mailSync.length + ' sync(s)');
330
331 const sendHeld = await page.evaluate((addr) => window.DaimondGateway.spendRefusal(
332 '/api/mail/send', { body: JSON.stringify({ address: addr }) }), MAILBOX);
333 check('and it holds a send too', !!sendHeld && sendHeld.node === MBOX,
334 JSON.stringify(sendHeld));
335 await playLeaf(page, MBOX);
336
337 // ── 6. Reading is not paused ─────────────────────────────────
338 // Everything held: the folder list is still asked for, the Diamond still
339 // opens, its crystal still renders and its files still list.
340 await pauseLeaf(page, 'root');
341 await pauseLeaf(page, WORKERS);
342 await pauseLeaf(page, INBOX);
343 await pauseLeaf(page, MBOX);
344
345 const foldersBefore = net.mailFolders;
346 await page.evaluate(() => window.DaimondMail.loadFolders(undefined, true));
347 await page.waitForTimeout(1200);
348 check('the folder list is still asked for — a list is reading, not spending',
349 net.mailFolders > foldersBefore, net.mailFolders + ' listing(s)');
350
351 await page.evaluate(() => { window.DaimondPanels.show('ai'); window.DaimondPanels.show('work'); });
352 await page.click('.diamond-item, #diamond-list > *', { force: true }).catch(() => {});
353 await page.waitForTimeout(2000);
354 const read = await page.evaluate(() => {
355 const body = document.getElementById('crystal-body');
356 const view = document.getElementById('crystal-view');
357 return {
358 // A CRYSTAL WITH ANYTHING IN IT IS NOW A FRAME, and a frame contributes
359 // no text to its container -- so counting characters here would have
360 // answered zero for every Diamond and failed pointing at pause, which is
361 // not where the fault would have been. What this ever meant is "the
362 // crystal face drew something", and the frame reports that itself.
363 crystal: !!(body && (body.textContent.trim().length > 0
364 || body.querySelector('.crystal-frame, .crystal-fallback, .crystal-empty'))),
365 shown: !!(view && view.style.display !== 'none'),
366 files: document.querySelectorAll('#panel-work .files-tree *').length,
367 steer: !!document.getElementById('chat-input'),
368 };
369 });
370 check('a paused Diamond still opens and its crystal still renders',
371 read.crystal && read.shown, JSON.stringify(read));
372 check('and its files still list, and its steer input is still there — '
373 + 'pause is about spending, not access',
374 read.files > 0 && read.steer, JSON.stringify(read));
375
376 // A refusal is a decision, not a fault, so it must not arrive as a thrown
377 // stack in the console: an app that logs an error every time a paused node
378 // is asked to spend has taught the user to ignore its console.
379 // "Failed to load resource" is the dev server answering the routes this
380 // stub does not cover (/api/sync, /api/admin); it is noise, not a refusal.
381 const errs = errors(s).filter(e => !/Paused:/.test(e) && !/Failed to load resource/.test(e));
382 check('nothing was refused by way of an unhandled error', errs.length === 0,
383 errs.slice(0, 3).join(' | '));
384
385 console.log('\npaused when finished: ' + JSON.stringify(await pausedIds(page)));
386 await shot(s, 'pausespend');
387} finally {
388 await s.close();
389}
390
391console.log('\nmints: ' + JSON.stringify(net.mint));
392console.log('web: ' + net.web.length + ' mail sync: ' + net.mailSync.length
393 + ' mail folders: ' + net.mailFolders + ' provider: ' + net.provider);
394console.log(bad.length === 0 ? '\nall checks passed' : `\n${bad.length} check(s) FAILED`);
395process.exit(bad.length === 0 ? 0 : 1);