Files
stack/agents/filbert/work/queue-57/build.patch
T

441 lines
23 KiB
Diff

diff --git a/packages/bus/README.md b/packages/bus/README.md
index fff96910..5b57b7fc 100644
--- a/packages/bus/README.md
+++ b/packages/bus/README.md
@@ -196,8 +196,17 @@ must not invoke the human CLI or work around its proof.
Human resolutions and launch toggles append `human.input`. Agent capabilities
and read-only WebUI capabilities cannot reach these operations. Reader
-capabilities expose the human inbox plus tasks/agents/trail for one business;
-they do not confer mutation or resolution authority.
+capabilities expose the human inbox plus tasks/agents/trail/decisions for one
+business; they do not confer mutation or resolution authority.
+
+`decisions` (row 57, #1546) is for human and reader sessions only; a role
+session gets `human-required`, so it can't list another role's open
+decisions. It takes `{list}`, nothing else. `roles` lists the business's open
+decisions routed to a role, never to the human, oldest first. `recent` lists
+its last `RECENT` (50) closed decisions of every route, newest close first,
+each with `closed`, the closing `decision_events` row. Every row is the
+decision as `trail` gives the same session for its id, so the lists add no
+data a reader couldn't already read one id at a time; they add the listing.
## Shared reads and adapter boundary
@@ -207,6 +216,7 @@ import {views} from './src/views.mjs';
const read = views(new Client({path,cap}));
await read.inbox(); await read.tasks(); await read.agents();
await read.trail('vikunja:3/41');
+await read.decisions('roles'); await read.decisions('recent');
```
S4 and S5 must import this client-only module. Neither opens `bus.sqlite`.
diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs
index 0ebc8ff8..84db8799 100644
--- a/packages/bus/src/broker.mjs
+++ b/packages/bus/src/broker.mjs
@@ -38,6 +38,8 @@ const GATED = new Set([
'role.revoke',
]);
const CLOSED = "('resolved','withdrawn','expired')";
+// How many closed decisions `decisions` with list 'recent' returns, newest close first.
+export const RECENT = 50;
export const TASK_VERBS = Object.freeze(ACTIONS.filter((a) => a.startsWith('task.')));
const SELF_KINDS = new Set(['task.created', 'task.assigned', 'task.state', 'task.closed', 'task.conflict']);
const POLL_KINDS = new Set([
@@ -466,7 +468,8 @@ export class Broker {
if (!object(args)) fail('invalid-request');
this.#secretCheck(args);
return this.#store.transaction(() => {
- if (s.reader && !['inbox', 'agents', 'tasks', 'trail'].includes(request.verb)) fail('read-only');
+ if (s.reader && !['inbox', 'agents', 'tasks', 'trail', 'decisions'].includes(request.verb))
+ fail('read-only');
const result = this.#dispatch(s, request.verb, args);
this.#secretCheck(result);
return result;
@@ -833,6 +836,27 @@ export class Broker {
.map((r) => ({ ...r, fields: JSON.parse(r.fields) }));
return this.#trail(s, identifier(a.subject));
}
+ // Row 57 (#1546): the Inbox's "Routed to roles" and "Recent" lists, for human and
+ // reader sessions only. Each row is what trail gives the same session for that id.
+ case 'decisions': {
+ if (!s.human && !s.reader) fail('human-required');
+ keys(a, ['list'], ['list']);
+ if (a.list === 'roles')
+ return this.#store
+ .all(
+ `SELECT d.* FROM decisions d WHERE business=? AND route_to<>'human' AND NOT EXISTS(SELECT 1 FROM decision_events x WHERE x.decision=d.id AND x.op IN ${CLOSED}) ORDER BY seq`,
+ s.business,
+ )
+ .map((d) => this.#decisionView(d));
+ if (a.list === 'recent')
+ return this.#store
+ .all(
+ `SELECT d.* FROM decisions d JOIN decision_events x ON x.decision=d.id AND x.op IN ${CLOSED} WHERE d.business=? ORDER BY x.seq DESC LIMIT ${RECENT}`,
+ s.business,
+ )
+ .map((d) => ({ ...this.#decisionView(d), closed: this.#closed(d.id) }));
+ fail('invalid-request');
+ }
default:
fail('unknown-verb');
}
diff --git a/packages/bus/src/views.mjs b/packages/bus/src/views.mjs
index d161c2cd..2bffda6b 100644
--- a/packages/bus/src/views.mjs
+++ b/packages/bus/src/views.mjs
@@ -5,5 +5,6 @@ export function views(client) {
tasks: () => client.call('tasks'),
agents: () => client.call('agents'),
trail: (subject) => client.call('trail', { subject }),
+ decisions: (list) => client.call('decisions', { list }),
});
}
diff --git a/packages/bus/tests/decisions.test.mjs b/packages/bus/tests/decisions.test.mjs
new file mode 100644
index 00000000..6626460b
--- /dev/null
+++ b/packages/bus/tests/decisions.test.mjs
@@ -0,0 +1,193 @@
+// Row 57 (#1546): `decisions`, the read behind the Inbox tabs "Routed to roles"
+// and "Recent". Human and reader sessions read it; a role session can't.
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { mkdtempSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { Store } from '../src/store.mjs';
+import { Broker, RECENT } from '../src/broker.mjs';
+
+const options = [
+ { key: 'yes', text: 'Allow' },
+ { key: 'no', text: 'Decline' },
+];
+const 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 businesses = {
+ demo: { id: 'demo', human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' }, roles },
+ other: { id: 'other', human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' }, roles },
+};
+function setup(t) {
+ const root = mkdtempSync(join(tmpdir(), 'bus-decisions-'));
+ const store = new Store(root);
+ const b = new Broker({ store, businesses });
+ t.after(() => {
+ store.close();
+ rmSync(root, { recursive: true, force: true });
+ });
+ const agent = (role, business = 'demo', run = `${business}-${role}-run`) => {
+ const cap = b.bindLaunch({ business, role, run, harness: 'pi', address: run });
+ b.request(cap, { verb: 'role.claim' });
+ return cap;
+ };
+ const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
+ const reader = b.bindReader({ business: 'demo' });
+ return { b, store, agent, human, reader };
+}
+const call = (b, cap, verb, args = {}) => b.request(cap, { verb, args });
+const raise = (b, cap, action, extra = {}) =>
+ call(b, cap, 'decision.raise', { action, question: `${action}?`, options, recommendation: 'no', blocking: false, ...extra });
+const list = (b, cap, which) => call(b, cap, 'decisions', { list: which });
+
+// Open: one routed to cto, one to pm, one to the human. Closed: one within-role.
+function seed(t) {
+ const x = setup(t);
+ const { b, agent } = x;
+ const coder = agent('coder');
+ const cto = agent('cto');
+ const pm = agent('pm');
+ const toCto = raise(b, coder, 'task.scope.change', { domain: 'technical', target: 'vikunja:3/1' });
+ const toPm = raise(b, coder, 'task.scope.change', { domain: 'delivery', target: 'vikunja:3/2' });
+ const toHuman = raise(b, coder, 'deploy', { target: 'release-1', task_ref: 'vikunja:3/3', blocking: true });
+ const within = raise(b, coder, 'message.send', { choice: 'yes' });
+ assert.deepEqual([toCto.route_to, toPm.route_to, toHuman.route_to, within.route_to], ['cto', 'pm', 'human', 'coder']);
+ return { ...x, coder, cto, pm, toCto, toPm, toHuman, within };
+}
+
+test('roles lists open decisions routed to a role, and never the human inbox', (t) => {
+ const { b, human, reader, toCto, toPm, toHuman } = seed(t);
+ for (const cap of [human, reader]) {
+ const rows = list(b, cap, 'roles');
+ assert.deepEqual(
+ rows.map((r) => [r.id, r.route_to]),
+ [
+ [toCto.id, 'cto'],
+ [toPm.id, 'pm'],
+ ],
+ );
+ // The same view as inbox: options parsed, blocking a boolean, the authorization context.
+ assert.deepEqual(rows[0].options, options);
+ assert.equal(rows[0].blocking, false);
+ assert.deepEqual(rows[0].authorization, { action: 'task.scope.change', target: 'vikunja:3/1', approvalChoice: 'yes' });
+ assert.deepEqual(
+ call(b, cap, 'inbox').map((r) => r.id),
+ [toHuman.id],
+ );
+ }
+});
+
+test('recent lists closed decisions of every route, newest close first, with how each closed', (t) => {
+ const { b, store, human, reader, coder, cto, toCto, toPm, toHuman, within } = seed(t);
+ call(b, human, 'decision.resolve', { id: toHuman.id, choice: 'no', note: 'not today' });
+ call(b, cto, 'decision.resolve', { id: toCto.id, choice: 'yes' });
+ call(b, coder, 'decision.withdraw', { id: toPm.id });
+ const late = raise(b, coder, 'task.scope.change', { domain: 'technical', target: 'vikunja:3/4' });
+ store.run(
+ "INSERT INTO decision_events(decision,at,op,by) VALUES(?,'2026-10-11T00:00:00.000Z','expired','broker')",
+ late.id,
+ );
+ for (const cap of [human, reader]) {
+ const rows = list(b, cap, 'recent');
+ assert.deepEqual(
+ rows.map((r) => [r.id, r.closed.op, r.closed.by, r.closed.choice, r.closed.via]),
+ [
+ [late.id, 'expired', 'broker', null, null],
+ [toPm.id, 'withdrawn', 'demo-coder-run', null, 'broker'],
+ [toCto.id, 'resolved', 'demo-cto-run', 'yes', 'broker'],
+ [toHuman.id, 'resolved', 'jason', 'no', 'cli'],
+ [within.id, 'resolved', 'demo-coder-run', 'yes', 'broker'],
+ ],
+ );
+ assert.equal(rows[3].closed.note, 'not today');
+ assert.deepEqual(rows[3].options, options);
+ assert.equal(rows[3].blocking, true);
+ assert.deepEqual(list(b, cap, 'roles'), []);
+ }
+});
+
+test('recent stops at RECENT rows', (t) => {
+ const { b, reader, coder } = seed(t);
+ const ids = [];
+ for (let i = 0; i < RECENT; i++) ids.push(raise(b, coder, 'message.send', { choice: 'yes' }).id);
+ const rows = list(b, reader, 'recent');
+ assert.equal(rows.length, RECENT);
+ assert.deepEqual(
+ rows.map((r) => r.id),
+ ids.reverse(),
+ );
+});
+
+test('every row is what trail already gives the same session for that id', (t) => {
+ const { b, human, reader, toHuman } = seed(t);
+ call(b, human, 'decision.resolve', { id: toHuman.id, choice: 'yes' });
+ for (const cap of [human, reader])
+ for (const row of [...list(b, cap, 'roles'), ...list(b, cap, 'recent')]) {
+ const trail = call(b, cap, 'trail', { subject: row.id });
+ const d = trail.find((r) => r.table === 'decisions' && r.id === row.id);
+ assert.ok(d, row.id);
+ for (const k of ['route_to', 'class', 'action', 'question', 'recommendation', 'raised_by_role', 'raised_by_run'])
+ assert.equal(d[k], row[k], k);
+ assert.deepEqual(d.options, row.options);
+ if (row.closed) {
+ const { table, ...closed } = trail.find((r) => r.table === 'decision_events' && r.seq === row.closed.seq);
+ assert.equal(table, 'decision_events');
+ assert.deepEqual(closed, { ...row.closed });
+ }
+ }
+});
+
+test("a role session can't list decisions, so it can't read another role's open decisions", (t) => {
+ const { b, store, agent, coder, cto, pm, toCto, toPm, toHuman } = seed(t);
+ const idle = b.bindLaunch({ business: 'demo', role: 'pm', run: 'pm-unclaimed', harness: 'pi', address: 'x' });
+ const refusals = () => store.get("SELECT count(*) AS n FROM events WHERE kind='action.refused'").n;
+ for (const cap of [cto, pm, coder, idle])
+ for (const which of ['roles', 'recent', 'bogus']) {
+ const before = refusals();
+ assert.throws(() => list(b, cap, which), /human-required/, which);
+ assert.equal(refusals(), before + 1, 'the refusal is recorded');
+ }
+ // Its own inbox stays its own: cto sees cto's, not pm's or the human's.
+ assert.deepEqual(
+ call(b, cto, 'inbox').map((r) => r.id),
+ [toCto.id],
+ );
+ assert.deepEqual(
+ call(b, pm, 'inbox').map((r) => r.id),
+ [toPm.id],
+ );
+ assert.ok(!call(b, coder, 'inbox').some((r) => r.id === toHuman.id));
+ // A role in another business reads none of demo's either.
+ assert.throws(() => list(b, agent('cto', 'other'), 'roles'), /human-required/);
+});
+
+test('decisions is a read: one business, strict arguments, nothing written', (t) => {
+ const { b, store, human, reader, agent } = seed(t);
+ const coder = agent('coder', 'other');
+ raise(b, coder, 'task.scope.change', { domain: 'technical', target: 'vikunja:9/1' });
+ raise(b, coder, 'message.send', { choice: 'yes' });
+ const otherReader = b.bindReader({ business: 'other' });
+ assert.equal(list(b, otherReader, 'roles').length, 1);
+ assert.equal(list(b, otherReader, 'recent').length, 1);
+ assert.ok(list(b, otherReader, 'roles').every((r) => r.business === 'other'));
+ assert.ok(list(b, reader, 'recent').every((r) => r.business === 'demo'));
+
+ const count = () =>
+ ['events', 'decisions', 'decision_events', 'messages', 'deliveries', 'role_claims'].map(
+ (table) => store.get(`SELECT count(*) AS n FROM ${table}`).n,
+ );
+ const before = count();
+ for (const cap of [human, reader]) {
+ list(b, cap, 'roles');
+ list(b, cap, 'recent');
+ }
+ assert.deepEqual(count(), before, 'a successful read writes nothing');
+ for (const cap of [human, reader])
+ for (const args of [{}, { list: 'all' }, { list: 'roles', limit: 5 }, { list: ['roles'] }, { list: 'Recent' }])
+ assert.throws(() => call(b, cap, 'decisions', args), /invalid-request/, JSON.stringify(args));
+ // The reader's other limits stand.
+ assert.throws(() => call(b, reader, 'decision.seen', { id: list(b, reader, 'roles')[0].id }), /read-only/);
+});
diff --git a/packages/webui/README.md b/packages/webui/README.md
index 20a9a884..e43118d5 100644
--- a/packages/webui/README.md
+++ b/packages/webui/README.md
@@ -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
diff --git a/packages/webui/src/bus.mjs b/packages/webui/src/bus.mjs
index 290541ef..8fee21a6 100644
--- a/packages/webui/src/bus.mjs
+++ b/packages/webui/src/bus.mjs
@@ -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';
diff --git a/packages/webui/src/serve.mjs b/packages/webui/src/serve.mjs
index 8e89e3ed..4d51942a 100644
--- a/packages/webui/src/serve.mjs
+++ b/packages/webui/src/serve.mjs
@@ -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) {
diff --git a/packages/webui/tests/bus.test.mjs b/packages/webui/tests/bus.test.mjs
index b141b5cc..2d9ca6ba 100644
--- a/packages/webui/tests/bus.test.mjs
+++ b/packages/webui/tests/bus.test.mjs
@@ -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']);
});
+// 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 = 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}`;
+ 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'].map(v => [v, async () => { calls.push(v); return []; }]));
+ 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);
+ 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);