feat(webui,conversation): S5 WebUI views and CHAT-03 follow-ups (row 40, #1522)
Dewey's round 3 candidate, manifest
agents/dewey/work/queue-40/candidate-manifest-r3.sha256 (d0aa0ded,
27 files, checked OK in the canonical tree).
- WebUI inbox, tasks, agents and trail views, read-only over /api/bus.
The README says the bus proof ends at the Console process.
- CHAT-03 seal: the engine command is fixed, the engine environment is
explicit, SEAL_FLAGS has --no-approve, escalating is cleared on throw.
- Terminal input typed after Ctrl-T or Ctrl-O is held. Only the run whose
own parse set held drains it (T1), and #run catches errors per action.
- DEFERRED keeps N2 and moves F2 to done, citing T1.
Reviews: Filbert approve (comment 27011, rev 260), Darkwing approve
(27013, rev 264). Landing gate on 8cad7722 plus the candidate: webui 22,
conversation 161, control-board 124, every scripts/test-*.sh green,
test-task 98/0. Mutant Mr survives; its flows test is the first
follow-up row.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -18,7 +18,7 @@ test('served Console browser: real board fixtures, keyboard, drafts, receipts, t
|
||||
assert.equal(await b.evaluate('document.querySelector("#session-count").textContent'), '3 of 4');
|
||||
await b.evaluate('document.querySelector("#hide-offline").click()');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("table.sessions tbody tr").length'), 4);
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("script").length'), 2);
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("script").length'), 3);
|
||||
assert.equal(await b.evaluate('!!window.injected'), false);
|
||||
// Project filtering does not hide another project's waiting requests.
|
||||
await b.evaluate('document.querySelector("[data-project=proj]").click()');
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
// Slice 1 S5 (#1522): the inbox, tasks, agents and trail pages in a browser,
|
||||
// against an in-process broker read through its reader session, so every row
|
||||
// the page renders has the broker's own shape. Nothing here starts the human
|
||||
// CLI or reaches a real broker; the seeding writes go straight to the broker.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Store } from '../../bus/src/store.mjs';
|
||||
import { Broker } from '../../bus/src/broker.mjs';
|
||||
import { views } from '../../bus/src/views.mjs';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { BusReadError } from '../src/bus.mjs';
|
||||
import { browser } from './browser.mjs';
|
||||
import { fixture, close } from './fixture.mjs';
|
||||
|
||||
const HOSTILE = '<script>window.injected=1</script><img src=x onerror="window.injected=2"> \u001b[31mred \u202eevil';
|
||||
const businesses = {
|
||||
demo: {
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' },
|
||||
roles: {
|
||||
pm: { authority: { withinRole: ['message.send', 'task.create'], crossRole: [] } },
|
||||
cto: { authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
coder: { authority: { withinRole: ['message.send'], crossRole: ['task.scope.change'] } },
|
||||
},
|
||||
},
|
||||
};
|
||||
const digest = c => c.repeat(64);
|
||||
const iso = () => new Date().toISOString();
|
||||
// A poll read counts only when it was sent after the stack's own write (task_current), and the
|
||||
// broker's clock can run a few milliseconds ahead of Date.now() when writes come fast.
|
||||
const later = () => new Date(Date.now() + 1000).toISOString();
|
||||
|
||||
function seed(root) {
|
||||
const store = new Store(root), b = new Broker({ store, businesses });
|
||||
const launch = (role, harness, address) => { const cap = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness, address }); b.request(cap, { verb: 'role.claim' }); return cap; };
|
||||
const coder = launch('coder', 'pi', 'coder-run'), pm = launch('pm', 'pi', 'pm-run');
|
||||
launch('cto', 'claude-code', 'cto-thread');
|
||||
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
// The human's request, the task PM created for it, a later poll that moved it, and a refused write.
|
||||
const asked = b.request(human, { verb: 'message.send', args: { to: 'pm', body: `Build the inbox ${HOSTILE}` } });
|
||||
const fields = { project_id: 32, title: `Inbox ${HOSTILE}`, description: `Line one ${HOSTILE}\nline two`, done: false, due_date: '2026-10-20T00:00:00Z', priority: 3, percent_done: 40, bucket: 2, labels: [], assignees: [5] };
|
||||
b.recordTask({ cap: pm, business: 'demo', snapshots: [{ task_ref: 'vikunja:32/7', updated: iso(), digest: digest('a'), fields }], events: [{ kind: 'task.created', subject: 'vikunja:32/7', body: { request: asked.request, requirement: 'REQ-SLICE-1' } }] });
|
||||
b.recordTask({ business: 'demo', snapshots: [{ task_ref: 'vikunja:32/7', updated: iso(), digest: digest('b'), fields: { ...fields, bucket: 3 }, via: 'board', read_at: later() }], events: [{ kind: 'task.changed.external', subject: 'vikunja:32/7', body: { via: 'board', digest: digest('b'), previous: digest('a'), changed: ['bucket'], comments: 2 } }] });
|
||||
b.recordTask({ cap: pm, business: 'demo', events: [{ kind: 'task.conflict', subject: 'vikunja:32/7', body: { verb: 'task.update', expected: digest('a'), actual: digest('b') } }] });
|
||||
// A task the stack never wrote, a done task and one no longer readable.
|
||||
b.recordTask({ business: 'demo', snapshots: [{ task_ref: 'vikunja:32/8', updated: iso(), digest: digest('c'), fields: { ...fields, title: 'Foreign task', bucket: 2, assignees: [] }, via: 'cursor', read_at: iso() }], events: [{ kind: 'task.changed.external', subject: 'vikunja:32/8', body: { via: 'cursor', digest: digest('c'), previous: null, changed: [] } }] });
|
||||
b.recordTask({ cap: pm, business: 'demo', snapshots: [{ task_ref: 'vikunja:32/9', updated: iso(), digest: digest('d'), fields: { ...fields, title: 'Done task', done: true, percent_done: 100 } }] });
|
||||
b.recordTask({ cap: pm, business: 'demo', snapshots: [{ task_ref: 'vikunja:32/10', updated: iso(), digest: digest('e'), fields: { ...fields, title: 'Moved task' } }] });
|
||||
b.recordTask({ business: 'demo', snapshots: [{ task_ref: 'vikunja:32/10', updated: iso(), digest: digest('f'), fields: { gone: 'moved' }, via: 'task', read_at: later() }], events: [{ kind: 'task.missing', subject: 'vikunja:32/10', body: { reason: 'moved', project: 40 } }] });
|
||||
// An open blocking decision that replaced an earlier one, seen by the human; one routed to PM; one resolved.
|
||||
const raise = args => b.request(coder, { verb: 'decision.raise', args });
|
||||
const push = { action: 'git.push.protected', target: 'refactor', task_ref: 'vikunja:32/7', options: [{ key: 'yes', text: `Allow ${HOSTILE}` }, { key: 'no', text: 'Decline' }], recommendation: 'no', blocking: true };
|
||||
const old = raise({ ...push, question: 'Push the first candidate?' });
|
||||
const open = raise({ ...push, question: `Push the release? ${HOSTILE}\nSecond line`, supersedes: old.id });
|
||||
b.request(human, { verb: 'decision.seen', args: { id: open.id } });
|
||||
const routed = raise({ action: 'task.scope.change', question: 'Widen the scope?', options: [{ key: 'widen', text: 'Widen' }, { key: 'keep', text: 'Keep' }], recommendation: 'keep', blocking: false, approvalChoice: 'widen' });
|
||||
const resolved = raise({ ...push, question: 'Push the hotfix?', blocking: false, task_ref: undefined, target: 'hotfix' });
|
||||
b.request(human, { verb: 'decision.resolve', args: { id: resolved.id, choice: 'yes' } });
|
||||
const reader = b.bindReader({ business: 'demo' });
|
||||
const calls = [];
|
||||
let fail = null;
|
||||
const bus = views({ call: async (verb, args = {}) => { calls.push(verb); if (fail) throw new BusReadError(fail); return structuredClone(b.request(reader, { verb, args })); } });
|
||||
return { store, bus, calls, fail: code => { fail = code; }, ids: { old: old.id, open: open.id, routed: routed.id, resolved: resolved.id } };
|
||||
}
|
||||
|
||||
test('S5 pages in a browser: real broker rows, inert text, Copy, routes, failures, layout and no writes', { timeout: 180000 }, async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'webui-bus-browser-'));
|
||||
const s = seed(root), f = await fixture(), b = await browser();
|
||||
const web = await startServer({ port: 0, board: f.boardURL, bus: s.bus }), base = `http://127.0.0.1:${web.address().port}/`;
|
||||
const out = process.env.WEBUI_EVIDENCE;
|
||||
if (out) mkdirSync(out, { recursive: true });
|
||||
const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,50))}throw new Error('Condition timed out: '+${JSON.stringify(expr)})})()`);
|
||||
const text = sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.textContent ?? null`);
|
||||
const go = async (hash, h1) => { await b.evaluate(`location.hash=${JSON.stringify(hash)}`); await wait(`document.querySelector("#bus-view h1")?.textContent.startsWith(${JSON.stringify(h1)}) && !document.querySelector("#bus-view [aria-busy]")`); };
|
||||
const inertCheck = async where => {
|
||||
assert.equal(await b.evaluate('!!window.injected'), false, where);
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#bus-view script, #bus-view img, #bus-view iframe, #bus-view object, #bus-view embed, #bus-view svg, #bus-view style, #bus-view link").length'), 0, where);
|
||||
const shown = await text('#bus-view');
|
||||
assert.doesNotMatch(shown, /[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/, where);
|
||||
};
|
||||
const noOverflow = async where => {
|
||||
for (const width of [320, 1440]) {
|
||||
await b.viewport(width, 1000);
|
||||
assert.equal(await b.evaluate('document.documentElement.scrollWidth<=document.documentElement.clientWidth'), true, `${where} ${width} overflow`);
|
||||
if (out) await b.screenshot(join(out, `s5-${where}-${width}.png`));
|
||||
}
|
||||
await b.viewport(1440, 1000);
|
||||
};
|
||||
try {
|
||||
// Every request the page makes is logged with its method; the bus pages must make only GETs.
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: 'window.errors=[];addEventListener("error",e=>errors.push(e.message));addEventListener("unhandledrejection",e=>errors.push(String(e.reason)));window.requests=[];const f=window.fetch;window.fetch=(u,o={})=>{requests.push([(o.method||"GET").toUpperCase(),String(u)]);return f(u,o)};' });
|
||||
await b.viewport(1440, 1000);
|
||||
|
||||
// The board is untouched at the root, with the Console sections above its projects.
|
||||
await b.navigate(base); await wait('document.querySelectorAll("table.sessions tbody tr").length===3');
|
||||
assert.equal(await b.evaluate('document.querySelector("#bus-view").hidden'), true);
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#projects [data-project]").length'), 3);
|
||||
await wait('document.querySelector("#sections a[href=\\"#/inbox\\"] .count")?.textContent==="1"');
|
||||
assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page]").getAttribute("href")'), '#/');
|
||||
|
||||
// Inbox: the one open human decision, its approval context, and the gap labels.
|
||||
await go('#/inbox', 'Inbox');
|
||||
assert.equal(await b.evaluate('document.activeElement.tagName'), 'H1');
|
||||
assert.equal(await b.evaluate('document.body.classList.contains("on-bus")'), true);
|
||||
assert.equal(await b.evaluate('getComputedStyle(document.querySelector("#board-view")).display'), 'none');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#bus-view .s1-dec").length'), 1);
|
||||
const card = await text('#bus-view .s1-dec');
|
||||
assert.match(card, /Push the release\? <script>window\.injected=1<\/script>/);
|
||||
assert.match(card, /␛\[31mred \[U\+202E\]evil/);
|
||||
assert.doesNotMatch(card, /Second line/);
|
||||
assert.match(card, /Choosing “yes” approves git\.push\.protected on refactor\./);
|
||||
assert.match(card, /blocking/);
|
||||
for (const label of ['Decisions routed to roles: not in the Q1 module', 'Closed decisions: not in the Q1 module', 'Seen and DM state per row: not in the Q1 module'])
|
||||
assert.ok((await text('#bus-view')).includes(label), label);
|
||||
assert.equal(await b.evaluate('document.title'), 'Inbox · Mosaic Console');
|
||||
await inertCheck('inbox'); await noOverflow('inbox');
|
||||
|
||||
// The open decision: options with Copy, history, the earlier version, no write.
|
||||
await b.evaluate('document.querySelector("#bus-view .s1-dec-open").click()');
|
||||
await wait(`location.hash===${JSON.stringify(`#/inbox/${s.ids.open}`)}`);
|
||||
await wait('document.querySelectorAll("#bus-view [data-copy]").length===2');
|
||||
assert.equal(await b.evaluate('document.activeElement.tagName'), 'H1');
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#bus-view [data-copy]")].map(e=>e.dataset.copy)'), [`mosaic decide ${s.ids.open} yes`, `mosaic decide ${s.ids.open} no`]);
|
||||
const detail = await text('#bus-view');
|
||||
assert.match(detail, /approves git\.push\.protected/); assert.match(detail, /recommended/);
|
||||
assert.match(detail, /Seen by jason via cli/);
|
||||
assert.match(detail, /Second line/);
|
||||
assert.ok(detail.includes(`${s.ids.old.slice(0, 8)} withdrawn Push the first candidate?`), detail);
|
||||
assert.ok(detail.includes('A later version that replaces this one: not in the Q1 module'));
|
||||
assert.match(detail, /DMnot sent/);
|
||||
await inertCheck('decision');
|
||||
// Copy writes the command to the clipboard and nothing else; with no clipboard the command is selected.
|
||||
await b.evaluate('window.copied=[];navigator.clipboard.writeText=async t=>{copied.push(t)}');
|
||||
await b.evaluate('document.querySelector("#bus-view [data-copy]").click()');
|
||||
await wait('document.querySelector("#copy-status").textContent.startsWith("Copied")');
|
||||
assert.deepEqual(await b.evaluate('window.copied'), [`mosaic decide ${s.ids.open} yes`]);
|
||||
await b.evaluate('navigator.clipboard.writeText=async()=>{throw new Error("denied")}');
|
||||
await b.evaluate('document.querySelectorAll("#bus-view [data-copy]")[1].click()');
|
||||
await wait('document.querySelector("#copy-status").textContent.startsWith("The clipboard is unavailable")');
|
||||
assert.equal(await b.evaluate('getSelection().toString()'), `mosaic decide ${s.ids.open} no`);
|
||||
// A refresh keeps the focused button and the copy status.
|
||||
await b.evaluate('document.querySelectorAll("#bus-view [data-copy]")[1].focus()');
|
||||
await b.evaluate('document.querySelector("#refresh").click()');
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
assert.equal(await b.evaluate('document.activeElement.dataset.copy'), `mosaic decide ${s.ids.open} no`);
|
||||
assert.match(await text('#copy-status'), /clipboard is unavailable/);
|
||||
await noOverflow('decision');
|
||||
|
||||
// A decision routed to a role and a resolved one show no command.
|
||||
await go(`#/inbox/${s.ids.routed}`, 'Decision');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#bus-view [data-copy]").length'), 0);
|
||||
assert.match(await text('#bus-view'), /routed to pm, not to you/);
|
||||
assert.match(await text('#bus-view'), /Choosing “widen” approves task\.scope\.change\./);
|
||||
await go(`#/inbox/${s.ids.resolved}`, 'Decision');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#bus-view [data-copy]").length'), 0);
|
||||
assert.match(await text('#bus-status'), /Resolved: jason chose “yes” via cli/);
|
||||
assert.match(await text('#bus-view'), /chosen/);
|
||||
|
||||
// Tasks: buckets, markers, done, missing, the freshness line.
|
||||
await go('#/tasks', 'Tasks');
|
||||
assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page]").getAttribute("href")'), '#/tasks');
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#bus-view h2[id^=b-bucket]")].map(h=>h.textContent)'), ['Bucket 2 1', 'Bucket 3 1']);
|
||||
const tasks = await text('#bus-view');
|
||||
assert.match(tasks, /Inbox <script>window\.injected=1<\/script>/);
|
||||
// The list reads no trail: it says only that the current state came from a Vikunja read.
|
||||
assert.equal(await b.evaluate('[...document.querySelectorAll("#bus-view .badge")].filter(e=>/^last read from Vikunja \\((board|cursor)\\)$/.test(e.textContent)).length'), 2);
|
||||
assert.doesNotMatch(tasks, /refused write|not created by the stack/);
|
||||
assert.match(tasks, /#10 \(no title in the current read\)/);
|
||||
assert.match(tasks, /user 5/); assert.match(tasks, /2026-10-20/); assert.match(tasks, /high/);
|
||||
assert.match(tasks, /Done 1/); assert.match(tasks, /No longer readable 1/); assert.match(tasks, /missing: moved/);
|
||||
assert.match(tasks, /Bucket names, bot names and the Vikunja address: not in the Q1 module/);
|
||||
assert.match(tasks, /Poller status: from the S3 status read, not built yet/);
|
||||
await inertCheck('tasks'); await noOverflow('tasks');
|
||||
|
||||
// Task detail: request, requirement, external change, refused write, decisions, snapshots and trail.
|
||||
await go('#/tasks/vikunja%3A32%2F7', '#7');
|
||||
const t7 = await text('#bus-view');
|
||||
assert.match(t7, /RequirementREQ-SLICE-1/);
|
||||
assert.match(t7, /Changed in Vikunja after the stack wrote it: bucket, 2 new comments/);
|
||||
assert.match(t7, /task\.update: Vikunja changed after the stack read it \(expected aaaaaaaa, found bbbbbbbb\)/);
|
||||
assert.ok(t7.includes(s.ids.open.slice(0, 8)) && t7.includes(s.ids.old.slice(0, 8)));
|
||||
assert.match(t7, /Request from the human, recorded from the CLI/);
|
||||
assert.match(t7, /the request task\.created names/);
|
||||
assert.match(t7, /The human to pm: Build the inbox/);
|
||||
assert.match(t7, /Created for REQ-SLICE-1/);
|
||||
assert.match(t7, /Changed in Vikunja: bucket, and 2 new comments/);
|
||||
assert.match(t7, /Session started: pm as pm-run \(pi, pm-run\)/);
|
||||
assert.match(t7, /Snapshots 2/);
|
||||
await inertCheck('task'); await noOverflow('task');
|
||||
await go('#/tasks/vikunja%3A32%2F8', '#8');
|
||||
assert.match(await text('#bus-view'), /Not created by the stack; first read/);
|
||||
assert.match(await text('#bus-view'), /Read from Vikunja for the first time/);
|
||||
await go('#/tasks/vikunja%3A32%2F10', '#10');
|
||||
assert.match(await text('#bus-status'), /No longer readable in Vikunja \(moved, now in project 40\)/);
|
||||
|
||||
// The trail with filters.
|
||||
await go('#/trail/task/vikunja%3A32%2F7?kind=task', 'Trail for task #7');
|
||||
assert.equal(await b.evaluate('document.querySelector(".chips [aria-current=true]").textContent.startsWith("Task changes")'), true);
|
||||
assert.equal(await b.evaluate('[...document.querySelectorAll("#bus-view .s1-trow")].every(r=>r.classList.contains("s1-g-task"))'), true);
|
||||
await go('#/trail/task/vikunja%3A32%2F7?kind=request', 'Trail for task #7');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#bus-view .s1-trow").length'), 3); // the message, its delivery, the human.input
|
||||
await go(`#/trail/decision/${s.ids.open}`, 'Trail for decision');
|
||||
const dt = await text('#bus-view');
|
||||
assert.match(dt, /Raised with this context: choosing “yes” approves git\.push\.protected on refactor/);
|
||||
assert.match(dt, /Withdrawn by coder/); assert.match(dt, /jason saw it via cli/);
|
||||
assert.match(dt, /Push the first candidate\?/);
|
||||
await inertCheck('trail'); await noOverflow('trail');
|
||||
|
||||
// Agents: one row per held role, with its harness and address; the gaps are labelled.
|
||||
await go('#/agents', 'Agents');
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#bus-view table.s1-agents tbody th")].map(e=>e.textContent)').then(r => r.sort()), ['coder', 'cto', 'pm']);
|
||||
assert.match(await b.evaluate('[...document.querySelectorAll("#bus-view table.s1-agents tbody tr")].find(r=>r.querySelector("th").textContent==="cto").textContent'), /cto-runclaude-codecto-thread/);
|
||||
for (const label of ['Role instances, launch limits and model families', 'Launch state', 'Live and ended sessions', 'Credential notices'])
|
||||
assert.ok((await text('#bus-view')).includes(`${label}: not in the Q1 module`), label);
|
||||
await noOverflow('agents');
|
||||
|
||||
// Addresses that are not pages.
|
||||
await go('#/nope', 'Not found');
|
||||
await go('#/inbox/bad%20id', 'Not found');
|
||||
assert.match(await text('#bus-view'), /bad id is not a decision id/);
|
||||
await go('#/tasks/vikunja%3A32%2F99', 'Not found');
|
||||
|
||||
// A failed read shows what was read before, labelled; a page never read shows the refusal.
|
||||
await go('#/inbox', 'Inbox');
|
||||
s.fail('outcome-unknown');
|
||||
await b.evaluate('document.querySelector("#refresh").click()');
|
||||
await wait('!!document.querySelector("#bus-view .s1-stale")');
|
||||
assert.match(await text('#bus-view .s1-stale'), /The last read failed\. The bus did not answer in time\. \(outcome-unknown\)/);
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#bus-view .s1-dec").length'), 1);
|
||||
await go('#/trail/task/vikunja%3A32%2F9', 'The read failed');
|
||||
s.fail('human-required');
|
||||
await b.evaluate('document.querySelector("#bus-view [data-retry]").click()');
|
||||
await wait('document.querySelector("#bus-view h1").textContent==="Bus refused the read"');
|
||||
assert.match(await text('#bus-view'), /started it from their own shell.*\(human-required\).*Nothing was written\./s);
|
||||
s.fail(null);
|
||||
await b.evaluate('document.querySelector("#bus-view [data-retry]").click()');
|
||||
await wait('document.querySelector("#bus-view h1").textContent.startsWith("Trail for task #9")');
|
||||
await noOverflow('refused');
|
||||
|
||||
// Back to the board: it still works.
|
||||
await b.evaluate('document.querySelector("#sections a[href=\\"#/\\"]").click()');
|
||||
await wait('document.querySelector("#bus-view").hidden && getComputedStyle(document.querySelector("#board-view")).display!=="none"');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("table.sessions tbody tr").length'), 3);
|
||||
assert.equal(await b.evaluate('document.title'), 'Mosaic Console');
|
||||
|
||||
// The skip link focuses main and does not route.
|
||||
await b.evaluate('location.hash="#/agents"'); await wait('document.querySelector("#bus-view h1")?.textContent==="Agents"');
|
||||
await b.evaluate('document.querySelector(".skip").click()');
|
||||
assert.equal(await b.evaluate('location.hash'), '#/agents');
|
||||
assert.equal(await b.evaluate('document.activeElement.id'), 'main');
|
||||
|
||||
assert.deepEqual(await b.evaluate('window.errors'), []);
|
||||
const requests = await b.evaluate('window.requests');
|
||||
assert.deepEqual(requests.filter(([m]) => m !== 'GET'), []);
|
||||
assert.ok(requests.some(([, u]) => u.startsWith('/api/bus/trail?subject=')));
|
||||
assert.deepEqual([...new Set(s.calls)].sort(), ['agents', 'inbox', 'tasks', 'trail']);
|
||||
|
||||
// A Console with no bus configured says so, and its board still works.
|
||||
await b.navigate(`${f.base}/#/inbox`); await wait('document.querySelector("#bus-view h1")?.textContent==="No bus to read"');
|
||||
assert.match(await text('#bus-view'), /no bus configured.*The control board still works\. \(not-configured\)/s);
|
||||
await b.evaluate('location.hash="#/"'); await wait('document.querySelectorAll("table.sessions tbody tr").length===3');
|
||||
assert.deepEqual(await b.evaluate('window.errors'), []);
|
||||
} finally { await b.close(); await close(web); await f.close(); s.store.close(); rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
// Slice 1 S5 (#1522): the /api/bus/* routes and the Console's bus transport.
|
||||
// The routes run against an in-process broker through its reader session;
|
||||
// the transport runs a fake human CLI. Nothing here starts the real human
|
||||
// CLI or reaches a real broker.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Store } from '../../bus/src/store.mjs';
|
||||
import { Broker } from '../../bus/src/broker.mjs';
|
||||
import { views } from '../../bus/src/views.mjs';
|
||||
import { hostFile, startTimeOf } from '../../cli/src/host.mjs';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { BusReadError, busReader, humanCall } from '../src/bus.mjs';
|
||||
import { close } from './fixture.mjs';
|
||||
|
||||
const HOSTILE = '<img src=x onerror="window.injected=true"> evil';
|
||||
const at = '2026-10-08T12:00:00.000Z';
|
||||
const businesses = {
|
||||
demo: {
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' },
|
||||
roles: {
|
||||
pm: { authority: { withinRole: ['message.send', 'task.create'], crossRole: [] } },
|
||||
cto: { authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
coder: { authority: { withinRole: ['message.send'], crossRole: ['task.scope.change'] } },
|
||||
},
|
||||
},
|
||||
};
|
||||
const tmp = (t, prefix) => { const root = mkdtempSync(join(tmpdir(), prefix)); t.after(() => rmSync(root, { recursive: true, force: true })); return root; };
|
||||
|
||||
// A broker with one open decision, one claimed role and one externally changed task.
|
||||
function seeded(t) {
|
||||
const store = new Store(tmp(t, 'webui-bus-'));
|
||||
t.after(() => store.close());
|
||||
const b = new Broker({ store, businesses });
|
||||
const coder = b.bindLaunch({ business: 'demo', role: 'coder', run: 'coder-run', harness: 'pi', address: 'coder-run' });
|
||||
b.request(coder, { verb: 'role.claim' });
|
||||
const decision = b.request(coder, { verb: 'decision.raise', args: { action: 'git.push.protected', target: 'refactor', task_ref: 'vikunja:32/7', question: `Push the release? ${HOSTILE}`, options: [{ key: 'yes', text: 'Allow' }, { key: 'no', text: 'Decline' }], recommendation: 'no', blocking: true } });
|
||||
b.recordTask({ business: 'demo', snapshots: [{ task_ref: 'vikunja:32/7', updated: at, digest: 'b'.repeat(64), fields: { title: HOSTILE, bucket: 11, done: false }, via: 'board', read_at: at }], events: [{ kind: 'task.changed.external', subject: 'vikunja:32/7', body: { changed: ['bucket'] } }] });
|
||||
const reader = b.bindReader({ business: 'demo' });
|
||||
const calls = [];
|
||||
const bus = views({ call: async (verb, args = {}) => { calls.push(verb); return structuredClone(b.request(reader, { verb, args })); } });
|
||||
return { decision, bus, calls };
|
||||
}
|
||||
|
||||
test('bus routes serve the four reads from the reader view, unescaped JSON for the page to escape', async t => {
|
||||
const { decision, bus, calls } = seeded(t);
|
||||
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus });
|
||||
t.after(() => close(web));
|
||||
const base = `http://127.0.0.1:${web.address().port}`;
|
||||
const read = async path => { const r = await fetch(base + path); return { status: r.status, cache: r.headers.get('cache-control'), body: await r.json() }; };
|
||||
|
||||
const inbox = await read('/api/bus/inbox');
|
||||
assert.equal(inbox.status, 200); assert.equal(inbox.cache, 'no-store');
|
||||
assert.match(inbox.body.at, /^\d{4}-\d\d-\d\dT/);
|
||||
assert.equal(inbox.body.rows.length, 1);
|
||||
assert.equal(inbox.body.rows[0].id, decision.id);
|
||||
assert.deepEqual(inbox.body.rows[0].authorization, { action: 'git.push.protected', target: 'refactor', approvalChoice: 'yes' });
|
||||
assert.ok(inbox.body.rows[0].question.endsWith(HOSTILE));
|
||||
assert.equal(inbox.body.rows[0].blocking, true);
|
||||
assert.deepEqual(inbox.body.rows[0].options.map(o => o.key), ['yes', 'no']);
|
||||
|
||||
const tasks = await read('/api/bus/tasks');
|
||||
assert.equal(tasks.body.rows.length, 1);
|
||||
assert.equal(tasks.body.rows[0].source, 'poll');
|
||||
assert.equal(tasks.body.rows[0].fields.title, HOSTILE);
|
||||
|
||||
const agents = await read('/api/bus/agents');
|
||||
assert.deepEqual(agents.body.rows.map(r => [r.role, r.op]), [['coder', 'claim']]);
|
||||
|
||||
const id = inbox.body.rows[0].id;
|
||||
const trail = await read('/api/bus/trail?subject=' + encodeURIComponent(id));
|
||||
assert.equal(trail.status, 200);
|
||||
assert.ok(trail.body.rows.some(r => r.table === 'decisions' && r.id === id));
|
||||
const taskTrail = await read('/api/bus/trail?subject=vikunja:32/7');
|
||||
assert.ok(taskTrail.body.rows.some(r => r.table === 'task_snapshots'));
|
||||
assert.ok(taskTrail.body.rows.some(r => r.table === 'events' && r.kind === 'task.changed.external'));
|
||||
|
||||
// Bad subjects never reach the bus.
|
||||
const before = calls.length;
|
||||
for (const q of ['', '?subject=', '?subject=' + 'a'.repeat(161), '?subject=-x', '?subject=a%20b', '?subject=a%00b'])
|
||||
assert.deepEqual([q, (await read('/api/bus/trail' + q)).body.error], [q, 'invalid-request']);
|
||||
assert.equal(calls.length, before);
|
||||
assert.deepEqual([...new Set(calls)].sort(), ['agents', 'inbox', 'tasks', 'trail']);
|
||||
});
|
||||
|
||||
test('bus routes are GET-only, have no write verb, and keep the same-origin checks', async t => {
|
||||
const calls = [];
|
||||
const bus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail'].map(v => [v, async () => { calls.push(v); return []; }]));
|
||||
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus });
|
||||
t.after(() => close(web));
|
||||
const base = `http://127.0.0.1:${web.address().port}`;
|
||||
for (const method of ['POST', 'PUT', 'DELETE', 'HEAD', 'OPTIONS']) assert.equal((await fetch(base + '/api/bus/inbox', { method })).status, 405, method);
|
||||
for (const path of ['/api/bus/decide', '/api/bus/seen', '/api/bus/', '/api/bus/inbox/x', '/api/bus/request', '/api/bus/raise']) {
|
||||
assert.equal((await fetch(base + path)).status, 404, path);
|
||||
assert.equal((await fetch(base + path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })).status, 404, path);
|
||||
}
|
||||
assert.equal((await fetch(base + '/api/bus/inbox', { headers: { origin: 'https://evil.example' } })).status, 403);
|
||||
assert.deepEqual(calls, []);
|
||||
for (const path of ['/bus.js', '/bus.css']) assert.equal((await fetch(base + path)).status, 200, path);
|
||||
});
|
||||
|
||||
test('bus refusals map to statuses and never carry a path or a stack', async t => {
|
||||
let fail;
|
||||
const bus = { inbox: async () => { throw fail; }, tasks: async () => ({ not: 'rows' }), agents: async () => [], trail: async () => [] };
|
||||
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus });
|
||||
const none = await startServer({ port: 0, board: 'http://127.0.0.1:9' });
|
||||
t.after(() => Promise.all([close(web), close(none)]));
|
||||
const base = `http://127.0.0.1:${web.address().port}`;
|
||||
const inbox = async () => { const r = await fetch(base + '/api/bus/inbox'); return [r.status, (await r.json())]; };
|
||||
for (const [code, status] of [['human-required', 403], ['unauthenticated', 403], ['unknown-business', 403], ['read-only', 403], ['outcome-unknown', 503], ['no-bus-host', 503], ['invalid-response', 502], ['response-too-large', 502], ['something-new', 502]]) {
|
||||
fail = new BusReadError(code);
|
||||
const [s, body] = await inbox();
|
||||
assert.deepEqual([code, s, body.error], [code, status, code]);
|
||||
}
|
||||
fail = Object.assign(new Error('ENOENT: /home/someone/.mosaic-dev/bus/broker.sock'), { stack: 'at secret (/x.mjs:1)' });
|
||||
const [s, body] = await inbox();
|
||||
assert.equal(s, 502); assert.equal(body.error, 'invalid-response');
|
||||
assert.doesNotMatch(JSON.stringify(body), /home|mosaic-dev|secret|\.mjs/);
|
||||
const shape = await fetch(base + '/api/bus/tasks');
|
||||
assert.deepEqual([shape.status, (await shape.json()).error], [502, 'invalid-response']);
|
||||
const unset = await fetch(`http://127.0.0.1:${none.address().port}/api/bus/agents`);
|
||||
assert.deepEqual([unset.status, (await unset.json()).error], [503, 'not-configured']);
|
||||
});
|
||||
|
||||
// A fake human CLI: it reads the request, then acts on args.mode.
|
||||
const FAKE = `
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
const request = JSON.parse(readFileSync(0, 'utf8'));
|
||||
const mode = request.args.mode;
|
||||
if (mode === 'refuse') { process.stderr.write('Some warning: Text\\nhuman-required\\n'); process.exit(2); }
|
||||
if (mode === 'garbage') { process.stdout.write('not json'); process.exit(0); }
|
||||
if (mode === 'silent-fail') process.exit(2);
|
||||
if (mode === 'big') { process.stdout.write('"' + 'x'.repeat(4096) + '"'); process.exit(0); }
|
||||
if (mode === 'slow') { setTimeout(() => process.stdout.write('[]'), 60000); }
|
||||
else if (mode === 'pid') { writeFileSync(request.args.pidFile, String(process.pid)); setInterval(() => {}, 60000); }
|
||||
else process.stdout.write(JSON.stringify({ request, socket: process.argv[2] }));
|
||||
`;
|
||||
function fake(t) {
|
||||
const root = tmp(t, 'webui-bus-cli-');
|
||||
const cli = join(root, 'fake-human-cli.mjs');
|
||||
writeFileSync(cli, FAKE);
|
||||
return { root, cli };
|
||||
}
|
||||
|
||||
test('humanCall speaks the human transport protocol without blocking the server', async t => {
|
||||
const { cli } = fake(t);
|
||||
const call = humanCall({ socket: '/run/fake.sock', business: 'demo', cli, timeoutMs: 1500, maxBytes: 1024 });
|
||||
assert.deepEqual(await call('trail', { subject: 'd-1' }), { request: { business: 'demo', verb: 'trail', args: { subject: 'd-1' } }, socket: '/run/fake.sock' });
|
||||
const code = async args => { try { await call('inbox', args); return 'resolved'; } catch (e) { assert.ok(e instanceof BusReadError); return e.code; } };
|
||||
assert.equal(await code({ mode: 'refuse' }), 'human-required');
|
||||
assert.equal(await code({ mode: 'garbage' }), 'invalid-response');
|
||||
assert.equal(await code({ mode: 'silent-fail' }), 'invalid-response');
|
||||
assert.equal(await code({ mode: 'big' }), 'response-too-large');
|
||||
assert.equal(await humanCall({ socket: 's', business: 'demo', cli: join(tmpdir(), 'no-such-dir-x', 'cli.mjs') })('inbox').catch(e => e.code), 'invalid-response');
|
||||
|
||||
// A slow read stays slow on its own: the server keeps answering meanwhile.
|
||||
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus: views({ call: (verb) => call(verb, { mode: 'slow' }) }) });
|
||||
t.after(() => close(web));
|
||||
const base = `http://127.0.0.1:${web.address().port}`;
|
||||
const started = Date.now();
|
||||
const slow = fetch(base + '/api/bus/inbox');
|
||||
const health = await fetch(base + '/healthz');
|
||||
assert.equal(health.status, 200);
|
||||
assert.ok(Date.now() - started < 1000, 'healthz waited for the bus read');
|
||||
const r = await slow;
|
||||
assert.deepEqual([r.status, (await r.json()).error], [503, 'outcome-unknown']);
|
||||
assert.ok(Date.now() - started >= 1400);
|
||||
});
|
||||
|
||||
test('busReader takes --business, else the live bus host, else refuses', async t => {
|
||||
const { root, cli } = fake(t);
|
||||
const dataRoot = join(root, 'data');
|
||||
const pinned = busReader({ dataRoot, socket: 'sock', business: 'pinned', cli });
|
||||
assert.equal((await pinned.inbox()).request.business, 'pinned');
|
||||
|
||||
const reader = busReader({ dataRoot, socket: 'sock', cli });
|
||||
const code = () => reader.agents().then(() => 'resolved', e => e.code);
|
||||
assert.equal(await code(), 'no-bus-host');
|
||||
mkdirSync(join(dataRoot, 'bus-host'), { recursive: true });
|
||||
writeFileSync(hostFile(dataRoot), '{not json');
|
||||
assert.equal(await code(), 'no-bus-host');
|
||||
writeFileSync(hostFile(dataRoot), JSON.stringify({ pid: process.pid, startTime: 'not-my-start', business: 'stale' }));
|
||||
assert.equal(await code(), 'no-bus-host');
|
||||
writeFileSync(hostFile(dataRoot), JSON.stringify({ pid: process.pid, startTime: startTimeOf(process.pid), business: 'hosted' }));
|
||||
const reply = await reader.agents();
|
||||
assert.deepEqual([reply.request.business, reply.request.verb, reply.socket], ['hosted', 'agents', 'sock']);
|
||||
// The host is read on every request.
|
||||
writeFileSync(hostFile(dataRoot), JSON.stringify({ pid: process.pid, startTime: startTimeOf(process.pid), business: 'moved' }));
|
||||
assert.equal((await reader.tasks()).request.business, 'moved');
|
||||
});
|
||||
|
||||
test('humanCall kills a transport that runs past its timeout', async t => {
|
||||
const { root, cli } = fake(t);
|
||||
const pidFile = join(root, 'pid');
|
||||
const call = humanCall({ socket: 's', business: 'demo', cli, timeoutMs: 500 });
|
||||
assert.equal(await call('inbox', { mode: 'pid', pidFile }).catch(e => e.code), 'outcome-unknown');
|
||||
const pid = Number(readFileSync(pidFile, 'utf8'));
|
||||
const gone = () => { try { process.kill(pid, 0); return false; } catch { return true; } };
|
||||
// If it survived, stop it so the failure is this assertion, not a hang.
|
||||
t.after(() => { if (!gone()) process.kill(pid, 'SIGKILL'); });
|
||||
// SIGKILL lands asynchronously; give the kernel a moment to reap it.
|
||||
for (let i = 0; i < 40 && !gone(); i++) await new Promise(r => setTimeout(r, 25));
|
||||
assert.ok(gone(), `transport ${pid} still runs after the timeout`);
|
||||
});
|
||||
|
||||
test('serve refuses a --business value that is not a business id', () => {
|
||||
const cli = fileURLToPath(new URL('../src/cli.mjs', import.meta.url));
|
||||
for (const business of ['../acme', '-x', 'acme corp']) {
|
||||
const result = spawnSync(process.execPath, [cli, 'serve', '--business', business, '--port', '0'], { encoding: 'utf8', timeout: 10000 });
|
||||
assert.equal(result.status, 2, business);
|
||||
assert.match(result.stderr, /^refused: business must be a business id/, business);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user