// 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'), []); } }); // Row 67 (#1560): seen, then closed, the usual order. `closed` is the closing event, not the first. test('recent gives the closing event as closed when the decision was seen first', (t) => { const { b, store, human, reader, coder, cto, pm, toCto, toPm, toHuman, within } = seed(t); call(b, human, 'decision.seen', { id: toHuman.id }); call(b, human, 'decision.resolve', { id: toHuman.id, choice: 'yes' }); call(b, cto, 'decision.seen', { id: toCto.id }); call(b, cto, 'decision.resolve', { id: toCto.id, choice: 'no' }); call(b, pm, 'decision.seen', { id: toPm.id }); call(b, coder, 'decision.withdraw', { id: toPm.id }); const events = (id) => store.all('SELECT seq, op FROM decision_events WHERE decision=? ORDER BY seq', id); for (const cap of [human, reader]) { const rows = list(b, cap, 'recent'); assert.deepEqual( rows.map((r) => r.id), [toPm.id, toCto.id, toHuman.id, within.id], ); for (const [d, op, by] of [ [toHuman, 'resolved', 'jason'], [toCto, 'resolved', 'demo-cto-run'], [toPm, 'withdrawn', 'demo-coder-run'], ]) { const ev = events(d.id); assert.deepEqual( ev.map((e) => e.op), ['seen', op], 'seen, then closed', ); const { closed } = rows.find((r) => r.id === d.id); assert.deepEqual([closed.op, closed.seq, closed.by], [op, ev[1].seq, by]); } } }); 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, coder, toHuman } = seed(t); call(b, human, 'decision.resolve', { id: toHuman.id, choice: 'yes' }); // Row 67 (#1560): fill the columns the seed leaves empty: project, requirement_ref, supersedes, // and an approval choice other than the default. const first = raise(b, coder, 'task.scope.change', { domain: 'technical', target: 'vikunja:3/5', project: 'demo-web', requirement_ref: 'REQ-DEC-1', }); raise(b, coder, 'task.scope.change', { domain: 'technical', target: 'vikunja:3/5', task_ref: 'vikunja:3/5', blocking: true, supersedes: first.id, approvalChoice: 'no', }); for (const cap of [human, reader]) for (const which of ['roles', 'recent']) for (const row of list(b, cap, which)) { const trail = call(b, cap, 'trail', { subject: row.id }); const found = trail.find((r) => r.table === 'decisions' && r.id === row.id); assert.ok(found, row.id); // The whole row: the view adds authorization (and closed on recent) and makes blocking // a boolean. Every other column is trail's. const { table, ...d } = found; const { authorization, closed, ...rest } = row; assert.deepEqual(rest, { ...d, blocking: !!d.blocking }, row.id); const raised = trail.filter( (r) => r.table === 'events' && r.kind === 'action.allowed' && r.body.operation === 'decision.raise' && r.body.decision === row.id, ); assert.equal(raised.length, 1, row.id); assert.deepEqual(authorization, { action: d.action, target: raised[0].body.target, approvalChoice: raised[0].body.approvalChoice, }); assert.equal(closed !== undefined, which === 'recent', row.id); if (closed) { const { table, ...event } = trail.find((r) => r.table === 'decision_events' && r.seq === closed.seq); assert.equal(table, 'decision_events'); assert.deepEqual(event, { ...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/); });