feat(bus): decisions routed to roles and recent decisions (#1546, row 57)
The broker gains the reader verb `decisions` with `{list: 'roles' |
'recent'}`: the open decisions routed to each role, and the last 50
decisions (RECENT). views.mjs exposes it in the Q1 client module, and
webui serves `GET /api/bus/decisions?list=roles|recent`. Any other list
is 400 and never reaches the bus. No Inbox tab uses the route yet; the
tabs are the design rows' work. The bus and webui READMEs list the verb,
the route and who may call it.
Filbert built it (agents/filbert/work/queue-57). Round 1 approve from
Darkwing (27290) and Dewey (27300) on manifest af3b03cf, 8 paths. Their
non-blocking notes (no seen-then-resolve test, no webui test of ?list=
before the no-bus 503, test 4 compares fewer fields, roles has no LIMIT,
3 of 14 mutants survived) go to a follow-up row.
Sage's gate on 3c79809c, sequential and teed: node suites bus 80 and
webui 40, 0 failed; test-auth 15, test-conductor 17, test-config 24,
test-discord 66, test-extension-package 18, test-foundation 44,
test-queue 27, test-release 14, test-task 98, all 0 failed.
958668a8..3c79809c touches none of the 8 paths.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -193,17 +193,22 @@ 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.
|
||||
GET `/api/bus/inbox`, `/api/bus/tasks`, `/api/bus/agents`,
|
||||
`/api/bus/trail?subject=<id>` and `/api/bus/decisions?list=roles|recent` are
|
||||
the bus reads (`src/bus.mjs`). Any other method is 405. The subject must match
|
||||
the broker's identifier rule, and the list must be `roles` or `recent`, else
|
||||
400. `decisions` (row 57, #1546) is for the Inbox tabs "Routed to roles" and
|
||||
"Recent"; "Human and reader paths" in `packages/bus/README.md` has what each
|
||||
list holds.
|
||||
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
|
||||
the inbox, tasks, agents, trails and the two decision lists. 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
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// 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
|
||||
// Only the four verbs of the Q1 module (lead decision 56) and row 57's
|
||||
// `decisions` read 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';
|
||||
|
||||
@@ -46,16 +46,22 @@ async function body(req) {
|
||||
return bytes;
|
||||
}
|
||||
// Slice 1 S5 (#1522): GET only, the four Q1 verbs, nothing else (lead decision 56, Q4).
|
||||
const BUS_VERBS = ['inbox', 'tasks', 'agents', 'trail'];
|
||||
// Row 57 (#1546) adds `decisions`, also a read, for the Inbox tabs.
|
||||
const BUS_VERBS = ['inbox', 'tasks', 'agents', 'trail', 'decisions'];
|
||||
const DECISION_LISTS = ['roles', 'recent'];
|
||||
async function busRead(res, bus, verb, search) {
|
||||
let subject;
|
||||
let arg;
|
||||
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' });
|
||||
arg = new URLSearchParams(search).get('subject');
|
||||
if (!subjectOk(arg)) return json(res, 400, { error: 'invalid-request', message: 'subject must be a decision id, message id or task ref' });
|
||||
}
|
||||
if (verb === 'decisions') {
|
||||
arg = new URLSearchParams(search).get('list');
|
||||
if (!DECISION_LISTS.includes(arg)) return json(res, 400, { error: 'invalid-request', message: 'list must be roles or recent' });
|
||||
}
|
||||
if (!bus) return json(res, 503, { error: 'not-configured', message: 'the Console has no bus configured' });
|
||||
try {
|
||||
const rows = await bus[verb](subject);
|
||||
const rows = await bus[verb](arg);
|
||||
if (!Array.isArray(rows)) throw new BusReadError('invalid-response');
|
||||
return json(res, 200, { rows, at: new Date().toISOString() });
|
||||
} catch (err) {
|
||||
|
||||
@@ -87,13 +87,52 @@ test('bus routes serve the four reads from the reader view, unescaped JSON for t
|
||||
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 => {
|
||||
// Row 57 (#1546): the Inbox's "Routed to roles" and "Recent" lists.
|
||||
test('decisions route serves the roles and recent lists from the reader view', async t => {
|
||||
const store = new Store(tmp(t, 'webui-bus-decisions-'));
|
||||
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 raise = args => b.request(coder, { verb: 'decision.raise', args: { options: [{ key: 'yes', text: 'Allow' }, { key: 'no', text: 'Decline' }], recommendation: 'no', blocking: false, ...args } });
|
||||
const routed = raise({ action: 'task.scope.change', domain: 'technical', target: 'vikunja:32/8', question: `Split it? ${HOSTILE}` });
|
||||
const human = raise({ action: 'deploy', target: 'release-1', question: 'Deploy?' });
|
||||
const closed = raise({ action: 'message.send', question: 'Send?', choice: 'yes' });
|
||||
const reader = b.bindReader({ business: 'demo' });
|
||||
const calls = [];
|
||||
const bus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail'].map(v => [v, async () => { calls.push(v); return []; }]));
|
||||
const bus = views({ call: async (verb, args = {}) => { calls.push([verb, args]); return structuredClone(b.request(reader, { verb, args })); } });
|
||||
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);
|
||||
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 roles = await read('/api/bus/decisions?list=roles');
|
||||
assert.equal(roles.status, 200); assert.equal(roles.cache, 'no-store');
|
||||
assert.match(roles.body.at, /^\d{4}-\d\d-\d\dT/);
|
||||
assert.deepEqual(roles.body.rows.map(r => [r.id, r.route_to]), [[routed.id, 'cto']]);
|
||||
assert.ok(roles.body.rows[0].question.endsWith(HOSTILE));
|
||||
const recent = await read('/api/bus/decisions?list=recent');
|
||||
assert.deepEqual(recent.body.rows.map(r => [r.id, r.closed.op, r.closed.choice]), [[closed.id, 'resolved', 'yes']]);
|
||||
assert.deepEqual((await read('/api/bus/inbox')).body.rows.map(r => r.id), [human.id]);
|
||||
assert.deepEqual(calls.filter(([v]) => v === 'decisions'), [['decisions', { list: 'roles' }], ['decisions', { list: 'recent' }]]);
|
||||
|
||||
// A missing or unknown list never reaches the bus.
|
||||
const before = calls.length;
|
||||
for (const q of ['', '?list=', '?list=all', '?list=Roles', '?list=roles%00', '?subject=roles'])
|
||||
assert.deepEqual([q, (await read('/api/bus/decisions' + q)).body.error], [q, 'invalid-request']);
|
||||
assert.equal(calls.length, before);
|
||||
});
|
||||
|
||||
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', 'decisions'].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);
|
||||
assert.equal((await fetch(base + '/api/bus/decisions?list=roles', { 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);
|
||||
@@ -176,6 +215,7 @@ test('busReader takes --business, else the live bus host, else refuses', async t
|
||||
const dataRoot = join(root, 'data');
|
||||
const pinned = busReader({ dataRoot, socket: 'sock', business: 'pinned', cli });
|
||||
assert.equal((await pinned.inbox()).request.business, 'pinned');
|
||||
assert.deepEqual((await pinned.decisions('recent')).request, { business: 'pinned', verb: 'decisions', args: { list: 'recent' } });
|
||||
|
||||
const reader = busReader({ dataRoot, socket: 'sock', cli });
|
||||
const code = () => reader.agents().then(() => 'resolved', e => e.code);
|
||||
|
||||
Reference in New Issue
Block a user