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:
+12
-2
@@ -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`.
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
Reference in New Issue
Block a user