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:
2026-10-09 22:05:43 -05:00
co-authored by Claude Opus 5.5
parent 8cad772282
commit 08b428ecf1
27 changed files with 1935 additions and 80 deletions
+62
View File
@@ -13,6 +13,8 @@ node packages/webui/src/cli.mjs serve
Open http://127.0.0.1:7330/. Optional `--port N` and
`--board http://127.0.0.1:7331` select another port or loopback board origin.
`--business ID` picks the business the bus views read; without it they read
the running bus host's business (`mosaic bus start <business>`).
Port 0 picks a free port. Ctrl-C stops each foreground server. No daemon,
installation, account, authentication or deployment is added.
@@ -58,6 +60,40 @@ A pending send stays disabled across refresh; new text typed during a send is
not cleared by the earlier send's success. If the proxy loses the response,
delivery may be unknown: inspect the seat before sending again.
## Inbox, tasks, agents and trails (slice 1 S5, #1522)
The Console sidebar adds Inbox, Tasks and Agents next to the control board,
which stays at `#/`. Routes: `#/inbox`, `#/inbox/<decision>`, `#/tasks`,
`#/tasks/<vikunja:project/task>`, `#/agents`, `#/trail/task/<ref>` and
`#/trail/decision/<id>`, with `?kind=` filters on a trail. The design
note is `agents/dewey/work/wui/SLICE1-VIEWS.md`.
- Every view reads the four verbs of the Q1 module (`packages/bus/src/views.mjs`,
lead decision 56): `inbox`, `tasks`, `agents` and `trail`. The CLI reads the
same functions, so both show the same broker data.
- Nothing writes. A decision shows `mosaic decide <id> <key>` for each choice,
with Copy, which only puts the command on the clipboard. It is answered in a
terminal (REQ-DEC-3, Q4). Seen is not set from the browser either. The
command has no `--business`, so `mosaic decide` uses the running bus host's
business, as the Console does by default. From a Console started with
`--business` for another business the copied command refuses (no bus host,
or no such decision) rather than answering elsewhere.
- What no Q1 verb returns is labelled where it would show, for example
"Bot names: not in the Q1 module" (lead decision 63). Nothing is guessed.
- A task's list row can only say its current state came from a Vikunja read,
since the list has no trail. The task page reads the trail and tells a task
the stack never created from one changed in Vikunja after the stack wrote it.
A stale poll read is shown in the snapshots and never as current.
- All bus- and agent-authored text is rendered inert: controls show as control
pictures or `[U+XXXX]`, and Markdown stays as source.
- Views refresh on the board's ten-second interval and stop on Pause. Focus,
open sections and the copy status survive a refresh.
- A failed read keeps the last good answer for that read and says it is old,
with the time it was read. A page never read shows the failure instead,
titled "Bus refused the read" (403: the bus refused the Console's read, for
example `human-required` from a Console started inside an agent run), "No bus to read" (no system
config, or no bus host and no `--business`) or "The read failed".
## Data and boundaries
GET `/api/board`, `/api/conversations` and `/api/conversation`, and POST
@@ -68,6 +104,24 @@ configured board URL for the page's error message. No scanner, registration,
session reader or transport is implemented here. The session reader is
`packages/conversation`, served by the board.
GET `/api/bus/inbox`, `/api/bus/tasks`, `/api/bus/agents` and
`/api/bus/trail?subject=<id>` are the bus reads (`src/bus.mjs`). Any other
method is 405. The subject must match the broker's identifier rule, else 400.
Each read runs the S4 human transport (`packages/bus/src/human-cli.mjs`) as
a child process, so a slow read doesn't stall the server. The bus checks
that child's ancestry, which ends at the Console process; it never sees the
browser or any other client of the port. So while the Console runs,
anything that can connect to its port (7330 by default) reads what a reader
capability reads ("Human and reader paths" in `packages/bus/README.md`):
the inbox, tasks, agents and trails. That includes a T3 seat using `curl`
and a managed S6 session, since S6 doesn't confine the network. No route
writes (Q4), so the exposure is reads only. Slice 1 doesn't change this
(Filbert F1 on #1522). Answers are `{ rows, at }`; failures are `{ error, message }`
with 403 for a refusal, 503 for no bus or no answer, and 502 for an answer
that can't be used. The bus needs the system config
(`~/.config/mosaic-dev/config.json`); without it the board still serves and
the bus routes answer `not-configured`.
Console's shared CSS, Console CSS, brand.js and local Manrope fonts were copied
unchanged from `agents/dewey/work/wui/`. Font license and source URLs accompany
the files under `src/public/assets/fonts/`. `live.css` contains the live-page
@@ -83,6 +137,7 @@ node --test packages/webui/tests/
node --test packages/control-board/tests/ packages/seat/tests/ packages/ledger/tests/ packages/mosaic/tests/
WEBUI_EVIDENCE=/tmp/webui-evidence node --test packages/webui/tests/browser.test.mjs
WEBUI_EVIDENCE=/tmp/webui-evidence node --test packages/webui/tests/conversation.test.mjs
WEBUI_EVIDENCE=/tmp/webui-evidence node --test packages/webui/tests/bus-browser.test.mjs
```
Node's test runner and installed `/usr/bin/chromium` are required. Set `CHROMIUM`
@@ -106,6 +161,13 @@ the return flow: a send from the view, a tool call and a delayed result while a
draft is typed, a peer message, a 4.5-million-character answer split into
continuation parts, and a relaunch mid-turn.
Bus tests (`bus.test.mjs`, `bus-browser.test.mjs`) run the routes and the
views against an in-process broker read through its reader session, so every
row has the broker's own shape. `humanCall` runs against a stub CLI script.
They cover each view, the gap labels, inert hostile text, Copy, the stale
fallback and each failure title, and that no route writes. They never start
the human CLI against a live bus.
No root CI workflow is configured for this package. These local tests are not a
claim of CI, deployment, live-seat delivery or user acceptance.
+78
View File
@@ -0,0 +1,78 @@
// Slice 1 S5 (#1522): the Console's read of the bus. It runs the S4 human
// transport (packages/bus/src/human-cli.mjs, lead decision 70) as a child
// with spawn, not spawnSync, so one slow read never stalls the HTTP server.
// Only the four verbs of the Q1 module (lead decision 56) are reachable:
// the WebUI writes nothing, not even seen (Q4). The bus does the proof; a
// server started inside an agent run gets `human-required` from the broker.
import { spawn } from 'node:child_process';
import { HUMAN_CLI } from '../../cli/src/transport.mjs';
import { readHostState } from '../../cli/src/host.mjs';
import { views } from '../../bus/src/views.mjs';
export class BusReadError extends Error {
constructor(code, message = code) { super(message); this.code = code; }
}
// The broker's identifier rule (packages/bus/src/broker.mjs `id`), checked
// here so a bad subject is a 400 and never reaches the bus.
export const subjectOk = s => typeof s === 'string' && s.length > 0 && s.length <= 160 && /^[a-zA-Z0-9][a-zA-Z0-9_.:/-]*$/.test(s);
// Same protocol as humanTransport in packages/cli/src/transport.mjs: the
// request on stdin, JSON on stdout, or a bus error code as the last
// `^[a-z-]{1,64}$` line of stderr.
export function humanCall({ socket, business, env = process.env, cli = HUMAN_CLI, timeoutMs = 30000, maxBytes = 8 * 1024 * 1024 }) {
return (verb, args = {}) => new Promise((resolve, reject) => {
let child;
try {
child = spawn(process.execPath, [cli, socket], { env, stdio: ['pipe', 'pipe', 'pipe'] });
} catch { return reject(new BusReadError('outcome-unknown', 'the bus transport did not start')); }
const out = [], err = [];
let size = 0, settled = false;
const done = (fn, value) => { if (!settled) { settled = true; clearTimeout(timer); fn(value); } };
const timer = setTimeout(() => { child.kill('SIGKILL'); done(reject, new BusReadError('outcome-unknown', 'the bus transport did not finish in time')); }, timeoutMs);
child.stdout.on('data', chunk => {
size += chunk.length;
if (size > maxBytes) { child.kill('SIGKILL'); return done(reject, new BusReadError('response-too-large')); }
out.push(chunk);
});
child.stderr.on('data', chunk => { if (err.length < 64) err.push(chunk); });
child.on('error', () => done(reject, new BusReadError('outcome-unknown', 'the bus transport did not start')));
child.on('close', status => {
if (status === null) return done(reject, new BusReadError('outcome-unknown', 'the bus transport did not finish'));
if (status !== 0) {
const code = Buffer.concat(err).toString('utf8').trim().split('\n').reverse().find(l => /^[a-z-]{1,64}$/.test(l)) ?? 'invalid-response';
return done(reject, new BusReadError(code));
}
try { done(resolve, JSON.parse(Buffer.concat(out).toString('utf8'))); }
catch { done(reject, new BusReadError('invalid-response')); }
});
child.stdin.on('error', () => {}); // A child that exits early reports through 'close'.
child.stdin.end(`${JSON.stringify({ business, verb, args })}\n`);
});
}
// The business is the --business flag, or else the live bus host's, read
// on every request so a host started after the Console is picked up.
export function busReader({ dataRoot, socket, business = null, env = process.env, cli = HUMAN_CLI, timeoutMs = 30000 }) {
const call = async (verb, args) => {
let id = business;
if (!id) {
let state;
try { state = readHostState(dataRoot); } catch { throw new BusReadError('no-bus-host', 'the bus host state file is unreadable'); }
if (!state?.live) throw new BusReadError('no-bus-host', 'no bus host is running and no --business was given');
id = state.business;
}
return humanCall({ socket, business: id, env, cli, timeoutMs })(verb, args);
};
return views({ call });
}
// HTTP status by bus error code. 403: the bus refused this caller; 503: no
// bus to ask, or no answer; 502: an answer that can't be used.
const STATUS = {
'human-required': 403, unauthenticated: 403, 'unknown-business': 403, 'read-only': 403,
'outcome-unknown': 503, 'no-bus-host': 503, 'not-configured': 503,
'invalid-request': 400,
};
export const busStatus = code => STATUS[code] ?? 502;
+18 -3
View File
@@ -1,7 +1,10 @@
#!/usr/bin/env node
import { startServer, DEFAULT_BOARD } from './serve.mjs';
import { busReader } from './bus.mjs';
import { loadSystem, socketPath } from '../../cli/src/config.mjs';
import { ID_PATTERN } from '../../business/src/vocabulary.mjs';
const args = process.argv.slice(2);
const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--board http://127.0.0.1:7331]';
const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--board http://127.0.0.1:7331] [--business ID]';
try {
if (args.length === 1 && ['--help', '-h'].includes(args[0])) { console.log(usage); process.exit(0); }
if (args.shift() !== 'serve') throw new Error(usage);
@@ -13,10 +16,22 @@ try {
if (!/^\d+$/.test(value) || Number(value) > 65535) throw new Error('port must be 0..65535');
options.port = Number(value);
} else if (flag === '--board') options.board = value;
else if (flag === '--business') {
if (!ID_PATTERN.test(value)) throw new Error('business must be a business id');
options.business = value;
}
else throw new Error(`unknown option: ${flag}`);
}
const server = await startServer(options);
console.log(`Mosaic Console: http://127.0.0.1:${server.address().port}/\nBoard: ${options.board}\nCtrl-C stops this server.`);
// The bus views (slice 1 S5) need the system config. Without it the
// board still serves and the bus routes answer not-configured.
let bus = null, busLine;
try {
const { dataRoot } = loadSystem();
bus = busReader({ dataRoot, socket: socketPath(dataRoot), business: options.business ?? null });
busLine = `Bus: ${options.business ?? 'the running bus host\'s business'}`;
} catch (err) { busLine = `Bus: not configured (${err.message})`; }
const server = await startServer({ board: options.board, port: options.port, bus });
console.log(`Mosaic Console: http://127.0.0.1:${server.address().port}/\nBoard: ${options.board}\n${busLine}\nCtrl-C stops this server.`);
for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => server.close());
} catch (err) {
console.error(`refused: ${err.message}`);
+41
View File
@@ -0,0 +1,41 @@
/* Slice 1 S5 (#1522): inbox, tasks, agents and trail (bus.js), on top of the Console and
live styles. Nothing here changes those files. On a bus route (body.on-bus) the board's
own parts are hidden and the inspector column is dropped; the board itself is untouched. */
#board-tree{display:contents}
body.on-bus #board-tree,body.on-bus #board-head,body.on-bus #error,body.on-bus #conversation,body.on-bus #board-view,body.on-bus #inspector{display:none}
body.on-bus .frame,body.on-bus.has-inspector .frame{grid-template-columns:240px minmax(0,1fr)}
@media (min-width:1900px){body.on-bus .frame,body.on-bus.has-inspector .frame{grid-template-columns:280px minmax(0,1fr)}}
@media (min-width:2560px){body.on-bus .frame,body.on-bus.has-inspector .frame{grid-template-columns:320px minmax(0,1fr)}}
@media (max-width:959px){body.on-bus .frame,body.on-bus.has-inspector .frame{grid-template-columns:minmax(0,1fr)}}
.s1-sections .count{margin-left:auto}.s1-auth{overflow-wrap:anywhere}
.s1-sections{display:grid;gap:4px;list-style:none;margin:0;padding:0}.s1-section{display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding:7px 8px;border-radius:6px;color:var(--text);text-decoration:none;font-weight:600;border:1px solid transparent;min-height:38px}
.s1-section:hover{background:var(--raised)}.s1-section[aria-current=page]{background:var(--raised);border-color:var(--border);color:var(--action)}
/* The h1 takes focus after navigation so screen readers land on the new page; it is not a control, so no ring. */
.content h1{overflow-wrap:anywhere}.content h1:focus{outline:none}
.page-head>div{min-width:0}#bus-status{overflow-wrap:anywhere}
/* Inbox */
.s1-decs{grid-template-columns:repeat(auto-fit,minmax(min(100%,340px),1fr));margin-bottom:16px}
.s1-dec:not(.is-attn){border-left-color:var(--border);background:var(--surface)}
.s1-dec-open{font-weight:600;color:var(--action);overflow-wrap:anywhere;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}
.s1-badges{display:flex;gap:6px;flex-wrap:wrap;margin:8px 0 4px}.s1-dec .small{margin:4px 0 0;overflow-wrap:anywhere}
.s1-q{white-space:pre-wrap;overflow-wrap:anywhere;margin:0 0 12px;max-width:80ch}
.s1-opts{display:grid;gap:10px;padding-left:0;list-style:none;margin:0 0 8px}.s1-opt{border:1px solid var(--line);border-radius:var(--r);padding:10px 12px;background:var(--surface)}
.s1-opt p{margin:0;overflow-wrap:anywhere}.s1-key{font-family:var(--mono);margin-right:6px}
.s1-cmd{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:8px}.s1-cmd code{flex:1 1 260px;min-width:0;overflow-wrap:anywhere;font-size:.82rem;padding:6px 8px;border:1px solid var(--line);border-radius:6px;background:var(--canvas);user-select:all}
.s1-plain{list-style:none;padding:0;margin:0 0 10px;display:grid;gap:6px}.s1-plain li{overflow-wrap:anywhere}
.chips .chip{gap:6px}.panel{margin-bottom:16px}.panel h3{font-size:1rem;margin:14px 0 6px}
/* Tables */
table.s1-table td,table.s1-table th{overflow-wrap:anywhere}table.s1-tasks{min-width:900px}table.s1-tasks .s1-title{min-width:220px;max-width:340px}table.s1-agents{min-width:820px}
table.s1-table tbody th{font-weight:600;text-transform:none;letter-spacing:0;color:var(--text);font-size:.9rem;white-space:normal}
.s1-done summary{list-style:none}.s1-done summary h2{display:inline}.s1-done summary::before{content:'▸ ';color:var(--muted)}.s1-done[open] summary::before{content:'▾ '}.s1-done{margin-bottom:20px}
.s1-marker{text-decoration:none}.s1-launch{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
.s1-stale{margin-bottom:16px}.s1-stale p{margin:0}
/* Trail */
.s1-trail{list-style:none;margin:0 0 12px;padding:0;border-left:2px solid var(--line)}
.s1-trow{display:grid;grid-template-columns:4.2em minmax(9em,13em) minmax(0,1fr);gap:4px 12px;padding:8px 0 8px 12px;border-bottom:1px solid var(--line);position:relative}
.s1-trow::before{content:'';position:absolute;left:-6px;top:14px;width:10px;height:10px;border-radius:50%;background:var(--muted)}
.s1-g-request::before{background:var(--action)}.s1-g-decision::before{background:var(--warning)}.s1-g-launch::before{background:var(--accent)}.s1-g-review::before{background:var(--success)}
.s1-twhen{color:var(--muted);font-variant-numeric:tabular-nums}.s1-tkind{font-family:var(--mono);font-size:.8rem;overflow-wrap:anywhere}.s1-ttext{min-width:0}.s1-ttext p{margin:0;overflow-wrap:anywhere;white-space:pre-wrap}
@media(max-width:699px){.s1-trow{grid-template-columns:3.6em minmax(0,1fr)}.s1-ttext{grid-column:1/-1}}
@media(max-width:959px){.s1-sections{display:flex;flex-wrap:wrap;flex-basis:100%;gap:6px}.s1-section{border-color:var(--border);border-radius:99px;padding:5px 12px}}
@media (forced-colors:active){.s1-section[aria-current=page]{outline:2px solid Highlight}.s1-trow::before{background:CanvasText}}
+456
View File
@@ -0,0 +1,456 @@
// Slice 1 S5 (#1522): inbox, tasks, agents and trail, read through /api/bus (src/bus.mjs),
// which reaches only the four reads of the Q1 module (lead decision 56). Nothing on these
// pages writes: decisions are answered with `mosaic decide` in a terminal, and Copy only
// puts that command on the clipboard (Q4). What no Q1 read returns is labelled where it
// would show (lead decision 63). The control board (app.js) is untouched and stays at #/.
(() => {
'use strict';
const $ = id => document.getElementById(id);
const esc = v => String(v ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
// The CHAT-02 rules: C0 controls show as control pictures, C1 and bidi controls as
// [U+XXXX]. Newlines and tabs stay. Markdown stays as its source.
const CONTROL = /[\u0000-\u0008\u000b-\u001f\u007f]/g, UNSEEN = /[\u0080-\u009f؜‎‏‪-‮⁦-⁩]/g;
const inert = v => String(v ?? '').replace(CONTROL, c => c === '\u007f' ? '␡' : String.fromCharCode(0x2400 + c.charCodeAt(0))).replace(UNSEEN, c => `[U+${c.charCodeAt(0).toString(16).toUpperCase().padStart(4, '0')}]`);
const txt = v => esc(inert(v)); // every agent-, tracker- or bus-authored string goes through this
const announce = text => { $('announce').textContent = text; };
const enc = encodeURIComponent;
const SAFE = /^[a-zA-Z0-9][a-zA-Z0-9_.:/-]{0,159}$/; // the broker's identifier rule
const TASK = /^vikunja:(\d+)\/(\d+)$/;
const CLOSE = ['resolved', 'withdrawn', 'expired'];
// ---------------------------------------------------------------- reads
// One GET per Q1 read. The last good answer for each read is kept, so a failed read
// can show what was read before, labelled as such.
const last = new Map();
class ReadError extends Error { constructor(code, message, status) { super(message); this.code = code; this.status = status; } }
async function fetchRead(verb, subject) {
let res, body;
try {
res = await fetch(`/api/bus/${verb}${subject === undefined ? '' : `?subject=${enc(subject)}`}`, { cache: 'no-store' });
body = await res.json();
} catch { throw new ReadError('unreachable', 'The Console server did not answer.', 0); }
if (!res.ok || !body || !Array.isArray(body.rows)) throw new ReadError(typeof body?.error === 'string' ? body.error : 'invalid-response', typeof body?.message === 'string' ? body.message : `HTTP ${res.status}`, res.status);
return { rows: body.rows, at: body.at };
}
// A reader for one render. `live` asks the server; otherwise only kept answers are used.
function reader(live) {
const used = [];
const get = async (verb, subject) => {
const k = subject === undefined ? verb : `${verb}:${subject}`;
let r;
if (live) { r = await fetchRead(verb, subject); last.set(k, r); }
else if (!(r = last.get(k))) throw new ReadError('not-read', 'not read before');
used.push(r.at);
return r.rows;
};
get.oldest = () => used.filter(Boolean).sort()[0] || null;
return get;
}
const of = (rows, table) => rows.filter(r => r.table === table);
// One decision from its trail. An open human decision's inbox row carries the
// authorization context; otherwise it comes from the trail's decision.raise event,
// the rule the broker's own decision view uses.
async function decision(get, id, row = null) {
const tr = await get('trail', id), d = of(tr, 'decisions').find(x => x.id === id);
if (!d) return null;
const ev = of(tr, 'decision_events').filter(x => x.decision === id), closed = ev.filter(x => CLOSE.includes(x.op)).at(-1) || null;
const raise = of(tr, 'events').find(e => e.kind === 'action.allowed' && e.body?.operation === 'decision.raise' && e.body?.decision === id);
const mids = new Set(of(tr, 'messages').filter(m => m.decision === id).map(m => m.id));
const dm = of(tr, 'deliveries').find(x => mids.has(x.message) && x.transport === 'discord-dm' && x.op === 'delivered');
return {
id, at: d.at, class: d.class, action: d.action, routeTo: d.route_to, question: d.question,
options: Array.isArray(d.options) ? d.options : [], recommendation: d.recommendation, taskRef: d.task_ref, requirementRef: d.requirement_ref,
blocking: !!d.blocking, raisedBy: { role: d.raised_by_role, run: d.raised_by_run },
authorization: row ? row.authorization : raise ? { action: d.action, target: raise.body.target, approvalChoice: raise.body.approvalChoice } : null,
seen: ev.filter(x => x.op === 'seen'), closed, dm: dm ? { at: dm.at } : null, supersedes: d.supersedes, trail: tr,
};
}
// Earlier versions through `supersedes`, newest first, one trail read each, at most ten.
async function chain(get, d) {
const out = [];
for (let x = d, n = 0; x?.supersedes && n < 10; n++) { x = await decision(get, x.supersedes); if (x) out.push(x); }
return out;
}
// A task from its task_current row; the trail adds what only events carry.
function task(row, tr = null) {
const ref = row.task_ref, f = row.fields || {}, [, project, id] = ref.match(TASK) || [, null, null];
const evs = tr ? of(tr, 'events').filter(e => e.subject === ref) : [], snaps = tr ? of(tr, 'task_snapshots') : [];
const shape = f.gone ? snaps.filter(s => !s.fields?.gone).at(-1)?.fields || {} : f;
const created = evs.find(e => e.kind === 'task.created');
const ext = evs.filter(e => e.kind === 'task.changed.external').at(-1);
// task_current already skips stale reads, so a current row from a poll is a change the
// stack didn't make: to a task the stack wrote ('changed') or to one it never wrote ('foreign').
const external = !f.gone && row.source === 'poll' ? { kind: tr ? (snaps.some(s => s.source === 'self') ? 'changed' : 'foreign') : null, via: row.via, readAt: row.read_at, changed: Array.isArray(ext?.body?.changed) ? ext.body.changed : [], comments: ext?.body?.comments || 0 } : null;
const missingEv = evs.filter(e => e.kind === 'task.missing').at(-1);
return {
ref, project: project && +project, id: id && +id, title: shape.title ?? null, description: shape.description ?? '',
bucket: f.gone ? null : f.bucket, done: !!f.done, assignees: Array.isArray(shape.assignees) ? shape.assignees : [],
requirement: created?.body?.requirement ?? null, request: created?.body?.request ?? null,
due: shape.due_date ?? null, priority: shape.priority ?? 0, percent: shape.percent_done ?? 0,
changedAt: row.at, seq: row.seq, external,
conflicts: evs.filter(e => e.kind === 'task.conflict').map(e => ({ at: e.at, ...e.body })),
missing: f.gone ? { reason: f.gone, at: row.at, project: missingEv?.body?.project ?? null } : null,
decisions: tr ? of(tr, 'decisions').filter(d => d.task_ref === ref) : [],
// Rows after the current one are reads task_current skipped (lead decision 59).
snapshots: snaps.map(s => ({ at: s.at, source: s.source, via: s.via, readAt: s.read_at, role: s.role, stale: s.seq > row.seq, fields: s.fields || {} })),
};
}
// ---------------------------------------------------------------- trail lines
const group = k => k === 'human.input' ? 'request' : k.startsWith('session.') || k.startsWith('launch.') || k.startsWith('claim.') ? 'launch' : k.startsWith('task.') || k.startsWith('snapshot.') ? 'task' : k.startsWith('review.') ? 'review' : k.startsWith('action.') || k.startsWith('decision.') ? 'decision' : 'other';
const bucketText = f => f?.gone ? `gone (${f.gone})` : `bucket ${f?.bucket}`;
const short = id => String(id ?? '').slice(0, 8);
const plural = (n, one) => `${n} ${one}${n === 1 ? '' : 's'}`;
function line(r, ctx) {
const out = { at: r.at, actor: null, by: 'broker', decision: null, note: null };
if (r.table === 'events') {
const b = r.body && typeof r.body === 'object' ? r.body : {};
out.kind = r.kind; out.decision = typeof b.decision === 'string' ? b.decision : null;
if (r.actor_role) out.actor = { role: r.actor_role, run: r.actor_run }; else if (r.kind === 'human.input' || r.kind.startsWith('launch.')) out.by = 'human';
const map = {
'session.launched': () => `Session started: ${r.actor_role} as ${r.actor_run} (${b.harness}${b.address ? `, ${b.address}` : ''})`,
'session.ended': () => `Session ended: ${r.actor_role} ${r.actor_run}${b.reason ? ` (${b.reason}${b.exit !== undefined ? `, exit ${b.exit}` : ''})` : ''}`,
'human.input': () => b.kind === 'answer' ? (b.op === 'resolved' ? `The human chose “${b.choice}” from the CLI` : `The human ${b.op === 'seen' ? 'marked it seen' : b.op} from the CLI`)
: b.kind === 'instruction' ? 'Request from the human, recorded from the CLI' : b.kind === 'admin' ? 'Admin command from the human, CLI' : 'Input from the human',
'task.created': () => `Created for ${b.requirement}`,
'task.assigned': () => `Assigned to ${b.role}`,
'task.state': () => `Moved ${b.previous ? `from ${b.previous} ` : ''}to ${b.state}${b.percent_done !== undefined ? `, ${b.percent_done}% done` : ''}${b.comment ? ', with a comment in Vikunja' : ''}`,
'task.closed': () => `Closed with verdict ${b.verdict}`,
'task.changed.external': () => {
const changed = Array.isArray(b.changed) ? b.changed : [], comments = b.comments ? plural(b.comments, 'new comment') : '';
if (b.previous === null) return 'Read from Vikunja for the first time: the stack did not create it';
return changed.length ? `Changed in Vikunja: ${changed.join(', ')}${comments ? `, and ${comments}` : ''}` : comments ? `${comments[0].toUpperCase()}${comments.slice(1)} in Vikunja` : 'Read from Vikunja';
},
'task.conflict': () => `Write refused (${b.verb}): Vikunja changed after the stack read it (expected ${short(b.expected)}, found ${short(b.actual)})`,
'task.missing': () => `No longer readable in Vikunja (${b.reason}${b.project ? `, now in project ${b.project}` : ''})`,
'review.requested': () => 'Review requested',
'review.verdict': () => `Review verdict: ${b.verdict ?? 'not recorded'}`,
'action.refused': () => `Refused: ${b.code}`,
'action.allowed': () => b.operation === 'decision.raise' ? `Raised with this context: choosing “${b.approvalChoice}” approves ${b.action}${b.target ? ` on ${b.target}` : ''}` : `Allowed ${b.action}${b.target ? ` on ${b.target}` : ''}`,
'launch.revoked': () => 'Launching revoked from the CLI',
'launch.restored': () => 'Launching restored from the CLI',
'credential.expiring': () => `${b.service} credential for ${b.instance} expires ${b.expires ?? 'soon'}`,
'credential.expired': () => `${b.service} credential for ${b.instance} expired`,
'credential.changed': () => `${b.service} credential for ${b.instance} changed`,
};
out.text = (map[r.kind] || (() => r.kind))();
if (r.kind === 'human.input' && ctx.asked.has(r.id)) out.note = 'the request task.created names (lead decision 56, Q3)';
if (r.kind === 'human.input' && b.kind === 'answer') out.groupAs = 'decision'; // an answer, not a request
} else if (r.table === 'messages') {
out.kind = `message.${String(r.class).toLowerCase()}`; out.decision = r.decision;
if (r.from_role === 'human') out.by = 'human'; else out.actor = { role: r.from_role, run: r.from_run };
out.text = `${r.from_role === 'human' ? 'The human' : r.from_role} to ${r.to_role}: ${r.body}`;
out.groupAs = r.from_role === 'human' ? 'request' : r.decision ? 'decision' : 'other';
} else if (r.table === 'deliveries') {
const m = ctx.messages[r.message];
out.kind = `delivery.${r.op}`; out.decision = m?.decision || null;
out.text = `Message ${r.op}${r.holder_run ? ` to ${r.holder_run}` : ''}${r.transport ? ` by ${r.transport}` : ''}${r.transport === 'discord-dm' ? ' (DM to the human)' : ''}`;
out.groupAs = m?.from_role === 'human' ? 'request' : m?.decision ? 'decision' : 'other';
} else if (r.table === 'decisions') {
out.kind = 'decision.raised'; out.decision = r.id; out.actor = { role: r.raised_by_role, run: r.raised_by_run };
out.text = `Raised a ${r.class} decision for ${r.route_to}: ${String(r.question).split('\n')[0]}`;
} else if (r.table === 'decision_events') {
// The human answers from the CLI (via cli); every other row is the broker's.
out.kind = `decision.${r.op}`; out.decision = r.decision; out.by = r.via === 'cli' ? 'human' : 'broker';
out.text = r.op === 'resolved' ? `${r.by} chose “${r.choice}” via ${r.via}` : r.op === 'seen' ? `${r.by} saw it${r.via ? ` via ${r.via}` : ''}` : `${r.op[0].toUpperCase()}${r.op.slice(1)} by ${r.by}${r.note ? `: ${r.note}` : ''}`;
} else if (r.table === 'task_snapshots') {
out.kind = `snapshot.${r.source}`;
if (r.source === 'self') out.actor = { role: r.role, run: r.run };
out.text = r.source === 'self' ? `Written: ${bucketText(r.fields)}` : `Read from Vikunja (${r.via} read sent ${String(r.read_at).slice(11, 19)}): ${bucketText(r.fields)}`;
if (ctx.current != null && r.seq > ctx.current) out.note = 'stale read, not current (task_current skips it)';
} else if (r.table === 'role_claims') {
out.kind = `claim.${r.op}`; out.actor = { role: r.role, run: r.holder_run };
out.text = r.op === 'claim' ? `${r.role} claimed by ${r.holder_run} (${r.harness}${r.address ? `, ${r.address}` : ''})` : `${r.role} ${r.op === 'release' ? 'released' : 'revoked'} by ${r.by}${r.reason ? `: ${r.reason}` : ''}`;
} else { out.kind = String(r.table); out.text = 'A row this page does not know how to show'; }
out.group = out.groupAs || group(out.kind);
return out;
}
function lines(tr, current = null) {
const ctx = { current, asked: new Set(of(tr, 'events').filter(e => e.kind === 'task.created').map(e => e.body?.request)), messages: Object.fromEntries(of(tr, 'messages').map(m => [m.id, m])) };
return tr.map(r => line(r, ctx));
}
// ---------------------------------------------------------------- render helpers
function ago(iso) {
const t = Date.parse(iso);
if (!Number.isFinite(t)) return String(iso ?? 'unknown');
const s = Math.max(0, Math.round((Date.now() - t) / 1000)), m = Math.floor(s / 60), h = Math.floor(m / 60);
return s < 60 ? `${s} s ago` : m < 60 ? `${m} min ago` : h < 24 ? `${h} h ${m % 60} min ago` : `${Math.floor(h / 24)} d ago`;
}
const when = iso => iso ? `<time datetime="${esc(iso)}" title="${esc(iso)}">${esc(ago(iso))}</time>` : 'never';
const clock = iso => iso ? `<time datetime="${esc(iso)}" title="${esc(iso)}">${esc(String(iso).slice(11, 16))}</time>` : '';
const badge = (text, kind = 'muted') => `<span class="badge badge-${kind}">${txt(text)}</span>`;
const taskLink = ref => ref ? `<a href="#/tasks/${enc(ref)}">#${esc(String(ref).split('/')[1])}</a>` : '';
// Rows without an actor role come from the human (CLI, outside any run) or from the broker itself.
const actor = (a, by) => `<span class="source">${a ? `${txt(a.role)} · ${txt(a.run)}` : by === 'human' ? 'human, CLI outside any run' : 'broker'}</span>`;
const PRIORITY = ['unset', 'low', 'medium', 'high', 'urgent', 'do now'];
const head = (title, sub) => `<div class="page-head"><div><h1 tabindex="-1">${title}</h1>${sub ? `<p class="small muted" id="bus-status">${sub}</p>` : ''}</div></div>`;
let readAt = null;
const readNote = () => `Read from the bus ${when(readAt)}.`;
// Lead decision 63: what no Q1 read returns carries this label where it would show.
const gap = what => `<span class="feat">${esc(what)}: not in the Q1 module</span>`;
// Which choice authorizes which action on which target, as the broker recorded it at
// decision.raise. Never inferred from the recommendation or an option's position.
const approves = a => a ? `Choosing “${txt(a.approvalChoice)}” approves ${txt(a.action)}${a.target ? ` on ${txt(a.target)}` : ''}.` : 'No approval context was recorded for this decision, so no option is marked as approving.';
const first = q => String(q ?? '').split('\n')[0];
const due = d => d ? `<time datetime="${esc(d)}" title="${esc(d)}">${esc(String(d).slice(0, 10))}</time>` : '<span class="muted">none</span>';
const user = u => `user ${esc(u)}`;
// ---------------------------------------------------------------- views
let inboxCount = null;
function sections(route) {
const items = [['/', 'Control board', ''], ['/inbox', 'Inbox', inboxCount ? `<span class="count" aria-label="${inboxCount.open} open for you">${inboxCount.open}</span>${inboxCount.blocking ? ` ${badge(`${inboxCount.blocking} blocking`, 'attn')}` : ''}` : ''], ['/tasks', 'Tasks', ''], ['/agents', 'Agents', '']];
$('sections').innerHTML = items.map(([h, l, extra]) => {
const cur = h === '/' ? route === '/' : route.startsWith(h) || (h === '/tasks' && route.startsWith('/trail/task')) || (h === '/inbox' && route.startsWith('/trail/decision'));
return `<li><a href="#${h}" class="s1-section"${cur ? ' aria-current="page"' : ''}>${l}${extra ? ` ${extra}` : ''}</a></li>`;
}).join('');
}
function countInbox(rows) { inboxCount = { open: rows.length, blocking: rows.filter(d => d.blocking).length }; }
// Inbox
function decisionCard(d) {
return `<li class="wait-item s1-dec${d.blocking ? ' is-attn' : ''}"><div class="wait-head"><a href="#/inbox/${enc(d.id)}" class="s1-dec-open">${txt(first(d.question))}</a></div>
<p class="s1-badges">${d.blocking ? badge('blocking', 'attn') : ''}${badge(`${d.class} · ${d.action}`)}</p>
<p class="small s1-auth">${approves(d.authorization)}</p>
<p class="small muted">Raised by ${txt(d.raised_by_role)} ${when(d.at)}${d.task_ref ? ` · task ${taskLink(d.task_ref)}` : ''}${d.requirement_ref ? ` · ${txt(d.requirement_ref)}` : ''}</p></li>`;
}
async function inboxView(get) {
// The broker orders gated first, then oldest; lift blocking ones to the top.
const rows = (await get('inbox')).slice().sort((a, b) => (b.blocking - a.blocking) || String(a.at).localeCompare(String(b.at)));
countInbox(rows);
const body = rows.length ? `<ul class="waiting s1-decs">${rows.map(decisionCard).join('')}</ul>` : '<p class="state">Nothing is waiting on you.</p>';
return `${head('Inbox', `${readNote()} Decisions are answered in a terminal with <code>mosaic decide</code>; Console only shows them.`)}
<h2 id="b-for-you">For you <span class="count">${rows.length}</span></h2>${body}
<p class="small muted">Not shown here yet: ${gap('Decisions routed to roles')} ${gap('Closed decisions')} ${gap('Seen and DM state per row')} Open a decision for its seen, DM and closing history.</p>`;
}
async function decisionView(get, id) {
const open = (await get('inbox')).find(r => r.id === id) || null, d = await decision(get, id, open);
if (!d) return notFound(`No decision with id ${txt(id)}.`);
const c = d.closed;
const status = c ? (c.op === 'resolved' ? `Resolved: ${txt(c.by)} chose “${txt(c.choice)}”${c.via ? ` via ${txt(c.via)}` : ''} ${when(c.at)}.` : `${esc(c.op[0].toUpperCase() + c.op.slice(1))} by ${txt(c.by)} ${when(c.at)}${c.note ? `: ${txt(c.note)}` : ''}.`) : `Open since ${when(d.at)}.`;
// A command is offered only for an open decision routed to the human, and only when
// the id and key pass the broker's identifier rule, so the copied text is safe to paste.
const answerable = !c && d.routeTo === 'human';
const options = `<ol class="s1-opts">${d.options.map(o => {
const cmd = `mosaic decide ${d.id} ${o.key}`, chosen = c?.choice === o.key, approving = d.authorization?.approvalChoice === o.key;
const copy = !answerable ? '' : SAFE.test(String(d.id)) && SAFE.test(String(o.key))
? `<div class="s1-cmd"><code>${txt(cmd)}</code><button type="button" class="btn" data-copy="${esc(cmd)}" aria-label="Copy the command for option ${txt(o.key)}">Copy</button></div>`
: '<p class="small muted">This option has no command here: its key is not a plain identifier.</p>';
return `<li class="s1-opt"><p><b class="s1-key">${txt(o.key)}</b> ${txt(o.text)} ${approving ? badge(`approves ${d.authorization.action}`, 'attn') : ''}${o.key === d.recommendation ? badge('recommended', 'ok') : ''}${chosen ? badge('chosen', 'ok') : ''}</p>${copy}</li>`;
}).join('')}</ol>`;
const earlier = await chain(get, d);
const history = [...d.seen.map(s => `<li>Seen by ${txt(s.by)}${s.via ? ` via ${txt(s.via)}` : ''} ${when(s.at)}</li>`), ...(c ? [`<li>${esc(c.op)} by ${txt(c.by)}${c.via ? ` via ${txt(c.via)}` : ''} ${when(c.at)}</li>`] : [])];
const help = answerable ? '<p class="small muted">To answer, run one of these in a terminal. Copy only puts the command on your clipboard. Console sends nothing.</p>'
: !c ? `<p class="small muted">This decision is routed to ${txt(d.routeTo)}, not to you, so Console offers no command for it.</p>` : '';
return `${head(`Decision ${txt(short(d.id))}`, status)}
<section aria-labelledby="b-q" class="panel"><h2 id="b-q">Question</h2><p class="s1-q">${txt(d.question)}</p>
<dl class="kv"><dt>Class</dt><dd>${txt(d.class)} · ${txt(d.action)}${d.blocking ? ` ${badge('blocking', 'attn')}` : ''}</dd><dt>Approval</dt><dd class="s1-auth">${approves(d.authorization)}</dd><dt>Routed to</dt><dd>${txt(d.routeTo)}</dd><dt>Raised by</dt><dd>${txt(d.raisedBy.role)} <span class="source">${txt(d.raisedBy.run)}</span></dd><dt>Raised</dt><dd>${when(d.at)}</dd>${d.taskRef ? `<dt>Task</dt><dd>${taskLink(d.taskRef)} <span class="source">${txt(d.taskRef)}</span></dd>` : ''}${d.requirementRef ? `<dt>Requirement</dt><dd>${txt(d.requirementRef)}</dd>` : ''}<dt>DM</dt><dd>${d.dm ? `sent ${when(d.dm.at)}` : d.blocking ? 'not sent' : 'not needed (not blocking)'}</dd><dt>Id</dt><dd><code>${txt(d.id)}</code></dd></dl></section>
<section aria-labelledby="b-o"><h2 id="b-o">Options</h2>${help}${options}<p class="small muted" id="copy-status" role="status"></p></section>
<section aria-labelledby="b-c"><h2 id="b-c">Earlier versions</h2>${earlier.length ? `<ul class="s1-plain">${earlier.map(x => `<li><a href="#/inbox/${enc(x.id)}">${txt(short(x.id))}</a> ${badge(x.closed ? x.closed.op : 'open')} ${txt(first(x.question))}${x.closed?.note ? `<span class="source">${txt(x.closed.note)}</span>` : ''}</li>`).join('')}</ul>` : '<p class="muted">None.</p>'}<p class="small muted">${gap('A later version that replaces this one')}</p></section>
<section aria-labelledby="b-h"><h2 id="b-h">History</h2>${history.length ? `<ul class="s1-plain">${history.join('')}</ul>` : '<p class="muted">Not seen yet.</p>'}<p><a href="#/trail/decision/${enc(d.id)}">Full trail for this decision</a></p></section>`;
}
// Tasks
function markers(t) {
const out = [];
// The list has no trail, so it can only say the current state came from a Vikunja read;
// the task page tells a change to a task the stack wrote from one it never wrote.
if (t.external) out.push(t.external.kind === null ? badge(`last read from Vikunja (${t.external.via})`, 'attn') : t.external.kind === 'foreign' ? badge('not created by the stack', 'attn') : badge(`changed in Vikunja${t.external.changed.length ? `: ${t.external.changed.join(', ')}` : ''}`, 'attn'));
if (t.conflicts.length) out.push(badge(plural(t.conflicts.length, 'refused write'), 'danger'));
if (t.missing) out.push(badge(`missing: ${t.missing.reason}`, 'danger'));
return out.join(' ');
}
const title = t => `#${esc(t.id)} ${t.title === null ? '<span class="muted">(no title in the current read)</span>' : txt(t.title)}`;
function taskRow(t) {
return `<tr><td class="s1-title"><a href="#/tasks/${enc(t.ref)}">${title(t)}</a></td><td>${t.assignees.map(user).join(', ') || '<span class="muted">none</span>'}</td><td>${due(t.due)}</td><td>${esc(PRIORITY[t.priority] || t.priority)}</td><td>${esc(t.percent)}%</td><td>${when(t.changedAt)}</td><td>${markers(t) || '<span class="muted">none</span>'}</td></tr>`;
}
const taskTable = (rows, label) => `<div class="table-wrap" tabindex="0" role="region" aria-label="${esc(label)}, scroll for all columns"><table class="s1-table s1-tasks"><thead><tr>${['Task', 'Assignees', 'Due', 'Priority', 'Done', 'Changed', 'Markers'].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${rows.map(taskRow).join('')}</tbody></table></div>`;
function freshness(rows) {
const reads = rows.filter(r => r.source === 'poll' && r.read_at).map(r => r.read_at).sort();
return `Newest Vikunja read among these tasks: ${reads.length ? when(reads.at(-1)) : 'none'}. <span class="feat">Poller status: from the S3 status read, not built yet</span>`;
}
const byChanged = (x, y) => String(y.changedAt).localeCompare(String(x.changedAt));
async function tasksView(get) {
const rows = await get('tasks'), ts = rows.map(r => task(r));
if (!ts.length) return `${head('Tasks', freshness(rows))}<p class="state">No tasks in this business yet. Tasks appear after the PM creates them or the poller reads them from Vikunja.</p>`;
const open = ts.filter(t => !t.missing && !t.done), done = ts.filter(t => !t.missing && t.done).sort(byChanged), missing = ts.filter(t => t.missing);
const buckets = [...new Set(open.map(t => t.bucket))].sort((a, b) => a - b);
const groups = buckets.map(b => {
const g = open.filter(t => t.bucket === b).sort(byChanged), id = `b-bucket-${esc(b)}`;
return `<section aria-labelledby="${id}"><h2 id="${id}">Bucket ${esc(b)} <span class="count">${g.length}</span></h2>${taskTable(g, `Bucket ${b} tasks`)}</section>`;
}).join('');
const doneHtml = done.length ? `<details class="s1-done" id="b-done"><summary><h2 id="b-done-title">Done <span class="count">${done.length}</span></h2></summary>${taskTable(done.slice(0, 50), 'Done tasks')}${done.length > 50 ? `<p class="small muted">The 50 most recently changed of ${done.length}.</p>` : ''}</details>` : '';
const missingHtml = missing.length ? `<section aria-labelledby="b-missing"><h2 id="b-missing">No longer readable <span class="count">${missing.length}</span></h2><ul class="s1-plain">${missing.map(t => `<li><a href="#/tasks/${enc(t.ref)}">${title(t)}</a> ${badge(`missing: ${t.missing.reason}`, 'danger')} <span class="source">since ${clock(t.missing.at)}</span></li>`).join('')}</ul></section>` : '';
return `${head('Tasks', freshness(rows))}<p class="small muted">Edit tasks in Vikunja; Console only reads them. ${gap('Bucket names, bot names and the Vikunja address')}</p>${groups || '<p class="state">No open tasks.</p>'}${doneHtml}${missingHtml}`;
}
async function taskView(get, ref) {
const row = (await get('tasks')).find(r => r.task_ref === ref);
if (!row) return notFound(`No task ${txt(ref)} in this business.`);
const tr = await get('trail', ref), t = task(row, tr);
const ext = t.external ? `<dt>Vikunja</dt><dd>${t.external.kind === 'foreign' ? 'Not created by the stack; first read' : 'Changed in Vikunja after the stack wrote it'}${t.external.changed.length ? `: ${txt(t.external.changed.join(', '))}` : ''}${t.external.comments ? `, ${esc(plural(t.external.comments, 'new comment'))}` : ''} <span class="source">${txt(t.external.via)} read sent ${clock(t.external.readAt)}</span></dd>` : '';
const conflicts = t.conflicts.length ? `<dt>Refused writes</dt><dd>${t.conflicts.map(c => `${txt(c.verb)}: Vikunja changed after the stack read it (expected ${txt(short(c.expected))}, found ${txt(short(c.actual))}) ${when(c.at)}`).join('<br>')}</dd>` : '';
const decisions = t.decisions.length ? `<dt>Decisions</dt><dd>${t.decisions.map(d => `<a href="#/inbox/${enc(d.id)}">${txt(short(d.id))}</a> ${txt(first(d.question))}`).join('<br>')}</dd>` : '';
const sub = t.missing ? `No longer readable in Vikunja (${txt(t.missing.reason)}${t.missing.project ? `, now in project ${txt(t.missing.project)}` : ''}) since ${when(t.missing.at)}.` : `${t.done ? 'Done' : `In bucket ${esc(t.bucket)}`}, last changed ${when(t.changedAt)}. ${readNote()}`;
return `${head(title(t), `${sub} ${markers(t)}`)}
<section class="panel" aria-labelledby="b-t"><h2 id="b-t">Task</h2><dl class="kv"><dt>Ref</dt><dd><code>${txt(t.ref)}</code> ${gap('The Vikunja address')}</dd><dt>Assignees</dt><dd>${t.assignees.map(user).join(', ') || 'none'} ${gap('Bot names')}</dd><dt>Requirement</dt><dd>${txt(t.requirement || 'none')}</dd><dt>Due</dt><dd>${due(t.due)}</dd><dt>Priority</dt><dd>${esc(PRIORITY[t.priority] || t.priority)}</dd><dt>Done</dt><dd>${esc(t.percent)}%</dd>${ext}${conflicts}${decisions}</dl>
${t.description ? `<h3>Description</h3><p class="s1-q">${txt(t.description)}</p>` : ''}
<details class="s1-snaps" id="b-snaps"><summary>Snapshots <span class="count">${t.snapshots.length}</span></summary><ol class="s1-plain">${t.snapshots.map(s => `<li>${clock(s.at)} ${esc(bucketText(s.fields))} <span class="source">${s.source === 'self' ? `written by ${txt(s.role)}` : `${txt(s.via)} read sent ${esc(String(s.readAt).slice(11, 19))}`}${s.stale ? '; stale read, not shown' : ''}</span></li>`).join('')}</ol></details></section>
<section aria-labelledby="b-trail"><h2 id="b-trail">Trail</h2>${trailList(lines(tr, row.seq), 'all')}<p><a href="#/trail/task/${enc(ref)}">Trail with filters</a></p></section>`;
}
// Agents
async function agentsView(get) {
const claims = await get('agents');
const rows = claims.map(c => `<tr><th scope="row">${txt(c.role)}</th><td>${txt(c.holder_run)}</td><td>${txt(c.harness)}${c.address ? `<span class="source">${txt(c.address)}</span>` : ''}</td><td>${when(c.at)}</td></tr>`).join('');
return `${head('Agents', readNote())}
<section aria-labelledby="b-claims"><h2 id="b-claims">Held roles <span class="count">${claims.length}</span></h2><p class="small muted">The current holder of each claimed role. A role nobody holds does not appear.</p>
${claims.length ? `<div class="table-wrap" tabindex="0" role="region" aria-label="Held roles, scroll for all columns"><table class="s1-table s1-agents"><thead><tr>${['Role', 'Holder', 'Harness', 'Held since'].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${rows}</tbody></table></div>` : '<p class="state">No role is held right now.</p>'}</section>
<p class="small muted">Not shown here yet: ${gap('Role instances, launch limits and model families')} ${gap('Launch state')} ${gap('Live and ended sessions')} ${gap('Credential notices')}</p>
<p class="small muted">Board seats stay on the <a href="#/">control board</a>. This view is about roles.</p>`;
}
// Trail
const GROUPS = [['all', 'All'], ['request', 'Requests'], ['decision', 'Decisions'], ['launch', 'Launches'], ['task', 'Task changes'], ['review', 'Review']];
function trailList(rows, filter) {
const shown = filter === 'all' ? rows : rows.filter(r => r.group === filter);
if (!shown.length) return '<p class="muted">Nothing of this kind in the trail.</p>';
return `<ol class="s1-trail">${shown.map(r => `<li class="s1-trow s1-g-${esc(r.group)}"><span class="s1-twhen">${clock(r.at)}</span><span class="s1-tkind">${txt(r.kind)}</span><div class="s1-ttext"><p>${txt(r.text)}${r.decision && !r.kind.startsWith('decision.') && !r.kind.startsWith('delivery.') && SAFE.test(String(r.decision)) ? ` <a href="#/inbox/${enc(r.decision)}">decision ${txt(short(r.decision))}</a>` : ''}</p>${actor(r.actor, r.by)}${r.note ? `<span class="source">${esc(r.note)}</span>` : ''}</div></li>`).join('')}</ol>`;
}
async function trailView(get, kind, ref, q) {
let rows;
if (kind === 'task') {
const row = (await get('tasks')).find(r => r.task_ref === ref);
if (!row) return notFound(`No task ${txt(ref)} in this business.`);
rows = lines(await get('trail', ref), row.seq);
} else {
const d = await decision(get, ref);
if (!d) return notFound(`No decision with id ${txt(ref)}.`);
// A decision's trail with its earlier versions, merged in the broker's order.
const all = new Map();
for (const x of [d, ...await chain(get, d)]) for (const r of x.trail) all.set(`${r.table}:${r.seq}`, r);
rows = lines([...all.values()].sort((a, b) => String(a.at).localeCompare(String(b.at)) || String(a.table).localeCompare(String(b.table)) || a.seq - b.seq));
}
const filter = GROUPS.some(([g]) => g === q.get('kind')) ? q.get('kind') : 'all';
const base = `#/trail/${kind}/${enc(ref)}`;
const name = kind === 'task' ? `Trail for task #${esc(String(ref).split('/')[1])}` : `Trail for decision ${txt(short(ref))}`;
const back = kind === 'task' ? `<a href="#/tasks/${enc(ref)}">Back to the task</a>` : `<a href="#/inbox/${enc(ref)}">Back to the decision</a>`;
return `${head(name, `${readNote()} Oldest first. ${back}`)}
<nav aria-label="Filter the trail" class="chips">${GROUPS.map(([g, l]) => `<a class="chip" href="${base}${g === 'all' ? '' : `?kind=${g}`}"${filter === g ? ' aria-current="true"' : ''}>${l} <span class="count">${g === 'all' ? rows.length : rows.filter(r => r.group === g).length}</span></a>`).join('')}</nav>
${trailList(rows, filter)}`;
}
// Not found and states
function notFound(why) { return `${head('Not found')}<p class="state">${why || 'No page at this address.'} <a href="#/inbox">Go to the inbox</a></p>`; }
const loadingView = () => `${head('Reading…')}<p class="state" aria-busy="true"><span class="spinner" aria-hidden="true"></span>Reading from the bus.</p>`;
const retry = '<div class="actions"><button type="button" class="btn" data-retry>Read again</button></div>';
// Why a read failed, in words, with the bus code as given. Never a path or a guess.
function why(err) {
const code = `<code>${txt(err.code)}</code>`;
const say = {
'human-required': 'The bus answers the Console only when the human started it from their own shell. This Console was started inside an agent session, so the bus refused.',
unauthenticated: 'The bus did not accept this Console as the human.',
'unknown-business': 'The bus does not know this business.',
'read-only': 'The bus refused the read.',
'not-configured': 'This Console has no bus configured: the system config could not be read when it started. The control board still works.',
'no-bus-host': 'No bus host is running, and the Console was started without <code>--business</code>. Start one with <code>mosaic bus start &lt;business&gt;</code>.',
'outcome-unknown': 'The bus did not answer in time.',
'invalid-request': 'The bus refused this address as a request.',
unreachable: 'The Console server did not answer.',
}[err.code] || 'The bus answered with something Console cannot use.';
return `${say} (${code})`;
}
const failTitle = err => err.status === 403 ? 'Bus refused the read' : ['not-configured', 'no-bus-host'].includes(err.code) ? 'No bus to read' : 'The read failed';
const failedView = err => `${head(failTitle(err))}<div class="state state-error" role="alert"><p><b>${why(err)}</b> Console shows nothing rather than a guess. Nothing was written.</p>${retry}</div>`;
const staleBanner = err => `<div class="state state-error s1-stale" role="alert"><p><b>The last read failed.</b> ${why(err)} This is what was read before, ${when(readAt)}. Nothing was changed.</p>${retry}</div>`;
// ---------------------------------------------------------------- router
// #/ and anything that is not a #/ route is the control board; the rest is this file's.
const routeOf = () => location.hash.startsWith('#/') ? location.hash.slice(1) : '/';
function view(get, path, q) {
const parts = path.split('/').filter(Boolean).map(p => { try { return decodeURIComponent(p); } catch { return null; } });
if (parts.includes(null)) return notFound();
const [a, b, c] = parts, n = parts.length;
if (a === 'inbox' && n === 1) return inboxView(get);
if (a === 'inbox' && n === 2) return SAFE.test(b) ? decisionView(get, b) : notFound(`${txt(b)} is not a decision id.`);
if (a === 'tasks' && n === 1) return tasksView(get);
if (a === 'tasks' && n === 2) return TASK.test(b) ? taskView(get, b) : notFound(`${txt(b)} is not a task reference.`);
if (a === 'agents' && n === 1) return agentsView(get);
if (a === 'trail' && n === 3 && b === 'task') return TASK.test(c) ? trailView(get, b, c, q) : notFound(`${txt(c)} is not a task reference.`);
if (a === 'trail' && n === 3 && b === 'decision') return SAFE.test(c) ? trailView(get, b, c, q) : notFound(`${txt(c)} is not a decision id.`);
return notFound();
}
let gen = 0, shown = null, timer;
const paused = () => $('pause')?.getAttribute('aria-pressed') === 'true';
function schedule() { clearTimeout(timer); if (!paused()) timer = setTimeout(() => render(false), 10000); }
// Keep focus, open <details> and the copy status across a refresh of the same page.
function keep() {
const a = document.activeElement, view = $('bus-view');
return {
href: view.contains(a) ? a.getAttribute('href') : null, copy: view.contains(a) ? a.dataset?.copy : null, retry: view.contains(a) && a.hasAttribute?.('data-retry'),
open: [...view.querySelectorAll('details[id]')].filter(d => d.open).map(d => d.id), status: $('copy-status')?.textContent || '',
};
}
function restore(k) {
for (const id of k.open) { const d = $(id); if (d) d.open = true; }
if ($('copy-status')) $('copy-status').textContent = k.status;
const el = [...$('bus-view').querySelectorAll('a[href],[data-copy],[data-retry]')].find(e => k.href ? e.getAttribute('href') === k.href : k.copy ? e.dataset.copy === k.copy : k.retry && e.hasAttribute('data-retry'));
if (el && (k.href || k.copy || k.retry)) el.focus({ preventScroll: true });
}
async function render(navigated) {
const route = routeOf(), [path, query = ''] = route.split('?'), board = path === '/' || path === '';
const g = ++gen;
document.body.classList.toggle('on-bus', !board);
$('bus-view').hidden = board;
sections(path);
if (board) {
// The board page keeps its own title; the Inbox count still follows the bus.
if (shown !== null) { shown = null; $('bus-view').innerHTML = ''; document.title = 'Mosaic Console'; if (navigated) $('main').focus(); }
try { const r = await fetchRead('inbox'); last.set('inbox', r); if (g === gen) { countInbox(r.rows); sections(path); } } catch {}
if (g === gen) schedule();
return;
}
if (navigated || shown !== route) { $('bus-view').innerHTML = loadingView(); }
let html;
const live = reader(true);
try { html = await view(live, path, new URLSearchParams(query)); readAt = live.oldest(); }
catch (err) {
const kept = reader(false);
try { html = await view(kept, path, new URLSearchParams(query)); readAt = kept.oldest(); html = staleBanner(err) + html; }
catch { html = failedView(err); }
}
if (g !== gen) return;
const k = !navigated && shown === route ? keep() : null;
$('bus-view').innerHTML = html; shown = route;
sections(path);
const h1 = $('bus-view').querySelector('h1');
document.title = `${h1 ? h1.textContent : 'Console'} · Mosaic Console`;
if (k) restore(k);
else if (navigated && h1) { h1.focus(); announce(h1.textContent); }
schedule();
}
// ---------------------------------------------------------------- events
// Copy puts the command on the clipboard and does nothing else. If the clipboard is
// unavailable, the command text is selected so the person can copy it themselves.
document.addEventListener('click', async e => {
// The skip link moves focus only. Its #main would otherwise reach the hash router.
if (e.target.closest('.skip')) { e.preventDefault(); $('main').focus(); return; }
const b = e.target.closest('#bus-view [data-copy]');
if (b) {
const text = b.dataset.copy, out = $('copy-status');
try { await navigator.clipboard.writeText(text); out.textContent = `Copied: ${text}`; }
catch {
const r = document.createRange(); r.selectNodeContents(b.previousElementSibling);
const s = getSelection(); s.removeAllRanges(); s.addRange(r);
out.textContent = 'The clipboard is unavailable. The command is selected; press Ctrl+C to copy it.';
}
return;
}
if (e.target.closest('#bus-view [data-retry]')) render(false);
});
window.addEventListener('hashchange', () => render(true));
$('refresh').addEventListener('click', () => render(false));
$('pause').addEventListener('click', () => setTimeout(schedule)); // after app.js flips aria-pressed
render(false).then(() => { if (!$('bus-view').hidden) { const h1 = $('bus-view').querySelector('h1'); if (h1 && location.hash.startsWith('#/')) h1.focus(); } });
})();
+5 -4
View File
@@ -1,15 +1,15 @@
<!doctype html>
<html lang="en" data-design="console" data-palette="harbor" data-mode="light">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Mosaic Console</title>
<link rel="stylesheet" href="/shared/app.css"><link rel="stylesheet" href="/console.css"><link rel="stylesheet" href="/live.css">
<script src="/brand.js" defer></script><script src="/app.js" defer></script></head>
<link rel="stylesheet" href="/shared/app.css"><link rel="stylesheet" href="/console.css"><link rel="stylesheet" href="/live.css"><link rel="stylesheet" href="/bus.css">
<script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/bus.js" defer></script></head>
<body>
<a class="skip" href="#main">Skip to content</a>
<header class="cmdbar"><a href="/" class="wordmark"><i>M</i>Mosaic<small>Console</small></a>
<div class="cmd-right"><label>Palette<select id="palette"></select></label><label>Appearance<select id="mode"><option>light</option><option>dim</option><option>dark</option></select></label><button class="btn" id="refresh" type="button">Refresh</button><button class="btn" id="pause" type="button" aria-pressed="false">Pause</button></div></header>
<div class="frame">
<nav class="tree" aria-label="Projects"><h2 class="tree-title">Projects</h2><div id="projects"><p class="muted">Loading projects…</p></div><p class="small muted">Select a project to filter sessions. Waiting on you always shows all projects.</p></nav>
<main id="main" class="content" tabindex="-1"><div class="page-head"><div><h1>Control board</h1><p id="status" class="small muted" role="status">Loading board…</p></div></div>
<nav class="tree" aria-label="Console"><h2 class="tree-title">Console</h2><ul class="s1-sections" id="sections"></ul><div id="board-tree"><h2 class="tree-title">Projects</h2><div id="projects"><p class="muted">Loading projects…</p></div><p class="small muted">Select a project to filter sessions. Waiting on you always shows all projects.</p></div></nav>
<main id="main" class="content" tabindex="-1"><div class="page-head" id="board-head"><div><h1>Control board</h1><p id="status" class="small muted" role="status">Loading board…</p></div></div>
<div id="error" class="state state-error" role="alert" hidden></div>
<section id="conversation" class="conversation" aria-labelledby="conv-title" hidden><div class="page-head"><div><h2 id="conv-title" tabindex="-1">Conversation</h2><p id="conv-meta" class="small muted"></p></div><div class="conv-actions"><label>Session<select id="conv-pick"></select></label><button class="btn" id="conv-back" type="button">Back to board</button></div></div>
<div id="conv-markers"></div><p id="conv-status" class="small muted" role="status"></p><ol id="conv-log" class="turns conv-log" aria-label="Conversation history"></ol>
@@ -17,6 +17,7 @@
<div id="board-view"><section class="console-waiting" aria-labelledby="waiting-title"><h2 id="waiting-title">Waiting on you <span id="waiting-count" class="count">0</span></h2><div id="waiting"><p class="muted">Loading sessions…</p></div></section>
<details id="seen-section"><summary>Seen <span id="seen-count" class="count">0</span></summary><div id="seen"></div></details>
<section aria-labelledby="sessions-title"><div class="page-head"><h2 id="sessions-title">All sessions <span id="session-count" class="count">0</span></h2><div class="filters"><label><input id="hide-offline" type="checkbox" checked>Hide offline</label><label><input id="hide-seen" type="checkbox" checked>Hide seen</label></div></div><p class="small muted">Select an agent to inspect. Arrow keys move between agents; Enter opens the inspector. Scroll the table for all columns.</p><div id="sessions"></div></section></div>
<div id="bus-view" hidden></div>
</main>
<aside class="inspector" id="inspector" aria-labelledby="inspector-title" hidden><div class="inspector-head"><h2 id="inspector-title" tabindex="-1">Inspector</h2><button type="button" class="btn" id="close">Close</button></div><div id="inspection"></div></aside>
</div><footer class="foot"><span id="footer">Mosaic Stack</span><span id="board-url"></span></footer><div id="announce" class="sr-only" aria-live="polite"></div>
+28 -3
View File
@@ -2,6 +2,7 @@ import { createServer } from 'node:http';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { isIP } from 'node:net';
import { BusReadError, busStatus, subjectOk } from './bus.mjs';
export const DEFAULT_BOARD = 'http://127.0.0.1:7331';
export function isLoopback(host) {
@@ -19,8 +20,8 @@ export function boardURL(value) {
const root = resolve(import.meta.dirname, 'public');
const files = new Map([
['/', ['index.html', 'text/html; charset=utf-8']],
...['app.js', 'brand.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
...['shared/app.css', 'console.css', 'live.css'].map(f => ['/' + f, [f, 'text/css; charset=utf-8']]),
...['app.js', 'brand.js', 'bus.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
...['shared/app.css', 'console.css', 'live.css', 'bus.css'].map(f => ['/' + f, [f, 'text/css; charset=utf-8']]),
...[400, 500, 600, 700].map(w => [`/assets/fonts/manrope-${w}.woff2`, [`assets/fonts/manrope-${w}.woff2`, 'font/woff2']]),
]);
function json(res, status, body) {
@@ -41,7 +42,25 @@ async function body(req) {
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('body must be a JSON object');
return bytes;
}
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000 } = {}) {
// Slice 1 S5 (#1522): GET only, the four Q1 verbs, nothing else (lead decision 56, Q4).
const BUS_VERBS = ['inbox', 'tasks', 'agents', 'trail'];
async function busRead(res, bus, verb, search) {
let subject;
if (verb === 'trail') {
subject = new URLSearchParams(search).get('subject');
if (!subjectOk(subject)) return json(res, 400, { error: 'invalid-request', message: 'subject must be a decision id, message id or task ref' });
}
if (!bus) return json(res, 503, { error: 'not-configured', message: 'the Console has no bus configured' });
try {
const rows = await bus[verb](subject);
if (!Array.isArray(rows)) throw new BusReadError('invalid-response');
return json(res, 200, { rows, at: new Date().toISOString() });
} catch (err) {
const code = err instanceof BusReadError ? err.code : 'invalid-response';
return json(res, busStatus(code), { error: code, message: err instanceof BusReadError ? err.message : code });
}
}
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null } = {}) {
if (!isLoopback(host)) throw new Error('refusing to bind to non-loopback host');
if (host === 'localhost') host = '127.0.0.1';
const upstream = boardURL(board);
@@ -79,6 +98,12 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
return json(res, 502, { error: `Board unreachable or invalid response at ${upstream}. Check the board server. No automatic action retry.`, board: upstream });
}
}
if (path.startsWith('/api/bus/')) {
const verb = path.slice('/api/bus/'.length);
if (!BUS_VERBS.includes(verb)) return json(res, 404, { error: 'not found' });
if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' });
return busRead(res, bus, verb, search);
}
if (req.method !== 'GET' && req.method !== 'HEAD') return json(res, 405, { error: 'method not allowed' });
if (path === '/api/config') return json(res, 200, { board: upstream });
if (path === '/healthz') return json(res, 200, { ok: true });
+1 -1
View File
@@ -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()');
+266
View File
@@ -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 }); }
});
+217
View File
@@ -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);
}
});