// Row 115 (#1609): a decision closed by anyone but the raising run sends the // raising role a DECISION message in the same transaction as the close. 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 } from '../src/broker.mjs'; const options = [ { key: 'yes', text: 'Allow' }, { key: 'no', text: 'Decline' }, ]; const roles = { pm: { authority: { withinRole: ['message.send'], 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 } }; function setup(t) { const root = mkdtempSync(join(tmpdir(), 'bus-decision-close-')); const store = new Store(root); const b = new Broker({ store, businesses }); t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); }); const agent = (role, run = `${role}-run`) => { const cap = b.bindLaunch({ business: 'demo', 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 coder = agent('coder'); const cto = agent('cto'); const pm = agent('pm'); // Routed to cto (technical arbiter), to pm (delivery arbiter), and to the human (gated). 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 }); assert.deepEqual([toCto.route_to, toPm.route_to, toHuman.route_to], ['cto', 'pm', 'human']); return { b, store, agent, human, coder, cto, pm, toCto, toPm, toHuman }; } 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 closes = (store) => store.all("SELECT * FROM messages WHERE class='DECISION' ORDER BY seq"); const deliveries = (store, mid) => store.all('SELECT op,holder_run,transport,address FROM deliveries WHERE message=? ORDER BY seq', mid).map((r) => ({ ...r })); const answer = (store, decision, op = 'resolved') => store.get( "SELECT id FROM events WHERE kind='human.input' AND subject=? AND json_extract(body,'$.kind')='answer' AND json_extract(body,'$.op')=?", decision, op, ).id; test('a human resolve sends the raising role one message from the human, and its request is the answer', (t) => { const { b, store, human, coder, toHuman } = setup(t); call(b, human, 'decision.resolve', { id: toHuman.id, choice: 'no', note: 'not today' }); const [m, ...rest] = closes(store); assert.equal(rest.length, 0); const closed = store.get("SELECT by FROM decision_events WHERE decision=? AND op='resolved'", toHuman.id); assert.equal(closed.by, 'jason'); assert.equal(m.business, 'demo'); assert.equal(m.from_role, 'human'); assert.equal(m.from_run, 'human-cli'); assert.equal(m.to_role, 'coder'); assert.equal(m.class, 'DECISION'); assert.equal(m.in_reply_to, null); assert.equal(m.decision, toHuman.id); assert.equal(m.corrects, null); assert.equal(m.body, `Decision ${toHuman.id} on deploy was resolved by jason: no (Decline).\nNote: not today`); // Routed to the coder's holder, as message.send routes. assert.deepEqual(deliveries(store, m.id), [{ op: 'routed', holder_run: 'coder-run', transport: 'pi', address: 'coder-run' }]); const [got] = call(b, coder, 'message.receive'); assert.equal(got.id, m.id); assert.equal(got.request, answer(store, toHuman.id)); // The message joins the decision's trail. assert.ok(call(b, human, 'trail', { subject: toHuman.id }).some((r) => r.table === 'messages' && r.id === m.id)); }); test('a cross-role decision resolved by its arbiter comes from that role and run, with no request', (t) => { const { b, store, coder, cto, toCto } = setup(t); call(b, cto, 'decision.resolve', { id: toCto.id, choice: 'yes' }); const [m, ...rest] = closes(store); assert.equal(rest.length, 0); assert.equal(m.from_role, 'cto'); assert.equal(m.from_run, 'cto-run'); assert.equal(m.to_role, 'coder'); assert.equal(m.decision, toCto.id); // No note, so no Note line; by is the arbiter's run. assert.equal(m.body, `Decision ${toCto.id} on task.scope.change was resolved by cto-run: yes (Allow).`); const [got] = call(b, coder, 'message.receive'); assert.equal(got.id, m.id); assert.equal(got.request, null); }); test('a human withdraw sends one message with the note', (t) => { const { b, store, human, coder, toPm } = setup(t); call(b, human, 'decision.withdraw', { id: toPm.id, note: 'superseded by the plan\nsee #1609' }); const [m, ...rest] = closes(store); assert.equal(rest.length, 0); assert.equal(m.from_role, 'human'); assert.equal(m.to_role, 'coder'); assert.equal(m.body, `Decision ${toPm.id} on task.scope.change was withdrawn by jason.\nNote: superseded by the plan\nsee #1609`); const [got] = call(b, coder, 'message.receive'); assert.equal(got.request, answer(store, toPm.id, 'withdrawn')); }); test("a human seen before the close leaves the request on the close's answer, not the seen", (t) => { const { b, store, human, coder, toHuman } = setup(t); const other = raise(b, coder, 'deploy', { target: 'release-2', task_ref: 'vikunja:3/5' }); assert.equal(other.route_to, 'human'); // A human seen writes a human.input answer with op 'seen' on the decision too. call(b, human, 'decision.seen', { id: toHuman.id }); call(b, human, 'decision.seen', { id: other.id }); const seen = answer(store, toHuman.id, 'seen'); assert.ok(seen); call(b, human, 'decision.resolve', { id: toHuman.id, choice: 'yes' }); call(b, human, 'decision.withdraw', { id: other.id }); const got = call(b, coder, 'message.receive'); assert.deepEqual( got.map((m) => [m.decision, m.request]), [ [toHuman.id, answer(store, toHuman.id, 'resolved')], [other.id, answer(store, other.id, 'withdrawn')], ], ); assert.notEqual(got[0].request, seen); assert.notEqual(got[1].request, answer(store, other.id, 'seen')); }); test("the raising run's withdraw, a supersede, a within-role raise and seen send nothing", (t) => { const { b, store, human, coder, cto, toCto, toPm, toHuman } = setup(t); call(b, coder, 'decision.withdraw', { id: toPm.id, note: 'not needed' }); raise(b, coder, 'task.scope.change', { domain: 'technical', target: 'vikunja:3/1', supersedes: toCto.id }); raise(b, coder, 'message.send', { choice: 'yes' }); const late = raise(b, coder, 'task.scope.change', { domain: 'technical', target: 'vikunja:3/4' }); call(b, cto, 'decision.seen', { id: late.id, note: 'looking' }); call(b, human, 'decision.seen', { id: toHuman.id }); // Three withdrawals or closes and two seen rows, and no DECISION message. assert.equal(store.get("SELECT count(*) AS n FROM decision_events WHERE op IN ('withdrawn','resolved')").n, 3); assert.equal(store.get("SELECT count(*) AS n FROM decision_events WHERE op='seen'").n, 2); assert.deepEqual(closes(store), []); assert.deepEqual(call(b, coder, 'message.receive'), []); }); test('refused closes send nothing: invalid-choice, decision-closed, not-resolver', (t) => { const { b, store, human, coder, cto, pm, toCto, toPm, toHuman } = setup(t); assert.throws(() => call(b, human, 'decision.resolve', { id: toHuman.id, choice: 'maybe' }), /invalid-choice/); assert.throws(() => call(b, pm, 'decision.resolve', { id: toCto.id, choice: 'yes' }), /not-resolver/); assert.throws(() => call(b, cto, 'decision.withdraw', { id: toCto.id }), /not-resolver/); assert.deepEqual(closes(store), []); call(b, pm, 'decision.resolve', { id: toPm.id, choice: 'no' }); assert.equal(closes(store).length, 1); assert.throws(() => call(b, pm, 'decision.resolve', { id: toPm.id, choice: 'yes' }), /decision-closed/); assert.throws(() => call(b, human, 'decision.withdraw', { id: toPm.id }), /decision-closed/); assert.equal(closes(store).length, 1); assert.equal(call(b, coder, 'message.receive').length, 1); }); test('a raising role nobody holds at the close gets the message on the next claim', (t) => { const { b, store, agent, human, coder, toHuman } = setup(t); call(b, coder, 'role.release'); call(b, human, 'decision.resolve', { id: toHuman.id, choice: 'yes' }); const [m] = closes(store); assert.deepEqual(deliveries(store, m.id), [{ op: 'routed', holder_run: null, transport: null, address: null }]); const next = agent('coder', 'coder-run-2'); assert.deepEqual(deliveries(store, m.id).at(-1), { op: 'routed', holder_run: 'coder-run-2', transport: 'pi', address: 'coder-run-2' }); const [got] = call(b, next, 'message.receive'); assert.equal(got.id, m.id); assert.equal(got.body, `Decision ${toHuman.id} on deploy was resolved by jason: yes (Allow).`); assert.equal(got.request, answer(store, toHuman.id)); assert.equal(deliveries(store, m.id).at(-1).op, 'delivered'); }); test("a human DECISION send keeps its own instruction as the request, not a close's answer", (t) => { const { b, store, human, coder, toHuman } = setup(t); call(b, human, 'decision.resolve', { id: toHuman.id, choice: 'no' }); const sent = call(b, human, 'message.send', { to: 'coder', body: 'about that', class: 'DECISION', decision: toHuman.id }); const got = call(b, coder, 'message.receive'); assert.deepEqual( got.map((m) => [m.from_role, m.request]), [ ['human', answer(store, toHuman.id)], ['human', sent.request], ], ); assert.notEqual(sent.request, answer(store, toHuman.id)); });