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'; // Row 136 (#1630): an approval binds to the decision, the business and the role instance, plus // the thread when the raising run held one, not to the run that raised it. coder and coder2 are // two instances of one definition; assistant is a thread instance. const businesses = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'pm', delivery: 'pm' }, roles: { coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } }, coder2: { definition: 'coder', authority: { withinRole: [], crossRole: [] } }, pm: { definition: 'pm', authority: { withinRole: [], crossRole: [] } }, assistant: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } }, }, definitions: { coder: {}, pm: {}, assistant: {} }, }, }; function setup(t) { const root = mkdtempSync(join(tmpdir(), 'bus-binding-')); let store, b; const caps = {}; const ctx = { get b() { return b; }, get store() { return store; }, caps, call: (who, verb, args = {}) => b.request(caps[who], { verb, args }), open(bs = businesses) { store = new Store(root); b = new Broker({ store, businesses: bs }); caps.human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); }, // A run of `role` that holds its claim; `thread` for the assistant. run(name, role, thread) { caps[name] = b.bindLaunch({ business: 'demo', role, run: name, harness: 'pi', ...(thread ? { thread } : {}) }); ctx.call(name, 'role.claim'); return caps[name]; }, end: (name) => b.endLaunch({ business: 'demo', run: name, reason: 'exited' }), // A gated decision, so it routes to the human. raise: (who, extra = {}) => ctx.call(who, 'decision.raise', { action: 'deploy', target: 'release1', question: 'Ship release1?', options: [ { key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }, ], recommendation: 'yes', blocking: false, ...extra, }), approve: (d) => ctx.call('human', 'decision.resolve', { id: d.id, choice: 'yes' }), use: (who, d) => b.authorize(caps[who], 'deploy', { decision: d.id, target: 'release1' }), used: (d) => store.get( "SELECT count(*) AS n FROM events WHERE kind='action.allowed' AND json_extract(body,'$.decision')=? AND json_extract(body,'$.operation') IS NOT 'decision.raise'", d.id, ).n, ops: (d) => store.all('SELECT op FROM decision_events WHERE decision=? ORDER BY seq', d.id).map((r) => r.op), }; ctx.open(); t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); }); return ctx; } // Two assistant threads in one project. function threads(f) { const project = f.call('human', 'project.create', { name: 'stack work' }).id; const A = f.call('human', 'thread.create', { project, name: 'alpha' }).id; const B = f.call('human', 'thread.create', { project, name: 'beta' }).id; return { A, B }; } test('REQ-DEC an approval outlives its run and a broker restart; a new run of the instance consumes it once', (t) => { const f = setup(t); f.run('run1', 'coder'); const d = f.raise('run1'); f.approve(d); f.end('run1'); f.store.close(); f.open(); f.run('run2', 'coder'); assert.deepEqual(f.use('run2', d), { class: 'gated', decision: d.id }); assert.equal(f.used(d), 1); // Once: neither the consuming run nor a later one uses it again. assert.throws(() => f.use('run2', d), { code: 'decision-consumed' }); f.end('run2'); f.run('run3', 'coder'); assert.throws(() => f.use('run3', d), { code: 'decision-consumed' }); assert.equal(f.used(d), 1); }); test('REQ-DEC another instance of the same definition cannot consume an approval, and its refusal consumes nothing', (t) => { const f = setup(t); f.run('run1', 'coder'); const d = f.raise('run1'); f.approve(d); f.run('other', 'coder2'); assert.throws(() => f.use('other', d), { code: 'decision-mismatch' }); assert.equal(f.used(d), 0); f.use('run1', d); assert.equal(f.used(d), 1); }); test('REQ-DEC REQ-THREAD-1 a thread approval is consumed only by a run holding the same thread', (t) => { const f = setup(t); const { A, B } = threads(f); f.run('a1', 'assistant', A); f.run('b1', 'assistant', B); const d = f.raise('a1'); f.approve(d); // The same instance in another thread. assert.throws(() => f.use('b1', d), { code: 'decision-mismatch' }); assert.equal(f.used(d), 0); f.end('a1'); f.run('a2', 'assistant', A); f.use('a2', d); assert.equal(f.used(d), 1); assert.throws(() => f.use('a2', d), { code: 'decision-consumed' }); }); test('REQ-DEC a later run of the same instance supersedes an open decision; another instance cannot', (t) => { const f = setup(t); f.run('run1', 'coder'); const old = f.raise('run1'); f.end('run1'); f.run('run2', 'coder'); const replacement = f.raise('run2', { supersedes: old.id, question: 'Ship release1 tonight?' }); assert.equal(replacement.supersedes, old.id); assert.deepEqual(f.ops(old), ['withdrawn']); // The old decision is closed now, so superseding it again refuses. assert.throws(() => f.raise('run2', { supersedes: old.id }), { code: 'supersede-refused' }); f.run('other', 'coder2'); assert.throws(() => f.raise('other', { supersedes: replacement.id }), { code: 'supersede-refused' }); assert.deepEqual(f.ops(replacement), []); assert.equal(f.store.get('SELECT count(*) AS n FROM decisions').n, 2); }); test('REQ-DEC REQ-THREAD-1 a thread decision is superseded only from the same thread', (t) => { const f = setup(t); const { A, B } = threads(f); f.run('a1', 'assistant', A); f.run('b1', 'assistant', B); const old = f.raise('a1'); assert.throws(() => f.raise('b1', { supersedes: old.id }), { code: 'supersede-refused' }); assert.deepEqual(f.ops(old), []); f.end('a1'); f.run('a2', 'assistant', A); f.raise('a2', { supersedes: old.id }); assert.deepEqual(f.ops(old), ['withdrawn']); }); // Before row 121 a thread instance's run held no thread, so a decision it raised has no thread // claim. The broker below reads assistant as a plain role to raise one the way such a run did. test('REQ-DEC REQ-THREAD-1 a thread instance decision raised before row 121 is not consumed or superseded by any thread run', (t) => { const f = setup(t); const { A } = threads(f); f.store.close(); const plain = structuredClone(businesses); plain.demo.roles.assistant.definition = 'assistant-before-121'; plain.demo.definitions['assistant-before-121'] = {}; f.open(plain); f.run('old', 'assistant'); const d = f.raise('old'); f.approve(d); // Left open: a resolved decision refuses supersede as closed, whatever its thread. const open = f.raise('old', { question: 'Ship release2 tonight?' }); f.end('old'); assert.equal(f.store.get('SELECT count(*) AS n FROM thread_claims WHERE holder_run=?', 'old').n, 0); f.store.close(); f.open(); f.run('a1', 'assistant', A); assert.throws(() => f.use('a1', d), { code: 'decision-mismatch' }); assert.equal(f.used(d), 0); assert.deepEqual(f.ops(d), ['resolved']); assert.throws(() => f.raise('a1', { supersedes: open.id }), { code: 'supersede-refused' }); assert.deepEqual(f.ops(open), []); });