Files
stack/packages/bus/tests/decision-route.test.mjs

156 lines
7.8 KiB
JavaScript

// Row 133 (#1627, REQ-ASSIST-2): whoever closes a decision messages the raising role, so
// decision.raise refuses a route that can't message the raiser before it stores anything. The route
// is the sender and the raiser the target, under row 120's messaging rules. A within-role decision,
// routed to the raiser's own role, is exempt.
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Store } from '../src/store.mjs';
import { Broker } from '../src/broker.mjs';
// The assistant's lists come from the shipped role file, as in messaging.test.mjs.
const assistantLists = JSON.parse(
readFileSync(fileURLToPath(new URL('../../../roles/assistant.json', import.meta.url)), 'utf8'),
).messaging;
const options = [
{ key: 'yes', text: 'Allow' },
{ key: 'no', text: 'Decline' },
];
const pusher = { authority: { withinRole: ['message.send'], crossRole: ['git.push.working'] } };
const roles = {
pm: { definition: 'pm', authority: { withinRole: ['message.send', 'decision.resolve.technical'], crossRole: [] } },
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send', 'decision.resolve.delivery'], crossRole: [] } },
assistant: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } },
// Hears from pm, the technical arbiter, but not from the chief of staff, the delivery arbiter.
coder: { definition: 'coder', ...pusher },
// Hears from neither arbiter nor the human, but may send to the human: a reversed check passes it.
warden: { definition: 'warden', ...pusher },
// Hears from the human and both arbiters but sends only to pm: a reversed check refuses it.
scribe: { definition: 'scribe', ...pusher },
};
const definitions = {
pm: {},
'chief-of-staff': {},
assistant: { messaging: assistantLists },
coder: { messaging: { sendTo: ['human', 'pm', 'chief-of-staff'], receiveFrom: ['human', 'pm'] } },
warden: { messaging: { sendTo: ['human', 'pm', 'chief-of-staff'], receiveFrom: ['coder'] } },
scribe: { messaging: { sendTo: ['pm'], receiveFrom: ['human', 'pm', 'chief-of-staff'] } },
};
const businesses = {
demo: {
id: 'demo',
human: 'jason',
arbiters: { technical: 'pm', delivery: 'chief' },
roles,
definitions,
projects: { stack: { root: '/nonexistent' } },
},
};
const ROUTE = 'decision-route-refused';
function setup(t) {
const root = mkdtempSync(join(tmpdir(), 'bus-decision-route-'));
const store = new Store(root);
t.after(() => {
store.close();
rmSync(root, { recursive: true, force: true });
});
const b = new Broker({ store, businesses });
const caps = {};
caps.human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
// Row 121 (#1615): the assistant runs for one thread.
const project = b.request(caps.human, { verb: 'project.create', args: { name: 'demo' } }).id;
const thread = b.request(caps.human, { verb: 'thread.create', args: { project, name: 'main' } }).id;
for (const role of Object.keys(roles)) {
caps[role] = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi', ...(role === 'assistant' ? { thread } : {}) });
b.request(caps[role], { verb: 'role.claim' });
}
const call = (who, verb, args = {}) => b.request(caps[who], { verb, args });
const raise = (who, action, extra = {}) =>
call(who, 'decision.raise', { action, question: `${action}?`, options, recommendation: 'no', blocking: false, ...extra });
const count = (sql) => store.get(sql).n;
const stored = () => ({
decisions: count('SELECT count(*) n FROM decisions'),
decisionEvents: count('SELECT count(*) n FROM decision_events'),
messages: count('SELECT count(*) n FROM messages'),
allowed: count("SELECT count(*) n FROM events WHERE kind='action.allowed'"),
});
const refusals = () =>
store
.all("SELECT actor_role, body FROM events WHERE kind='action.refused' ORDER BY seq")
.map((e) => [e.actor_role, JSON.parse(e.body).code]);
return { store, call, raise, stored, refusals };
}
test('a cross-role raise whose arbiter the raiser does not hear from is refused and stores nothing', (t) => {
const { raise, stored, refusals } = setup(t);
const before = stored();
// Delivery goes to chief (chief-of-staff), which the coder's receiveFrom doesn't list.
assert.throws(() => raise('coder', 'git.push.working', { domain: 'delivery' }), { code: ROUTE });
assert.throws(() => raise('warden', 'git.push.working', { domain: 'technical' }), { code: ROUTE });
assert.deepEqual(refusals(), [['coder', ROUTE], ['warden', ROUTE]]);
assert.deepEqual(stored(), before, 'no decision, decision event, message or allowed event');
});
test('a gated raise by a role that does not hear from the human is refused and stores nothing', (t) => {
const { raise, stored, refusals } = setup(t);
const before = stored();
// deploy is gated, so the route is the human; warden's receiveFrom lacks human.
assert.throws(() => raise('warden', 'deploy', { target: 'release-1' }), { code: ROUTE });
// A cross-role action outside the raiser's crossRole is gated too.
assert.throws(() => raise('warden', 'task.create'), { code: ROUTE });
assert.deepEqual(refusals(), [['warden', ROUTE], ['warden', ROUTE]]);
assert.deepEqual(stored(), before);
});
test('a route the raiser hears from is allowed, checked from the route to the raiser', (t) => {
const { raise, call, store } = setup(t);
const technical = raise('coder', 'git.push.working', { domain: 'technical' });
const gated = raise('coder', 'deploy', { target: 'release-1' });
assert.deepEqual([technical.route_to, gated.route_to], ['pm', 'human']);
// scribe sends only to pm, but every route here may message scribe.
const routes = [
raise('scribe', 'git.push.working', { domain: 'delivery' }),
raise('scribe', 'git.push.working', { domain: 'technical' }),
raise('scribe', 'deploy', { target: 'release-1' }),
].map((d) => d.route_to);
assert.deepEqual(routes, ['chief', 'pm', 'human']);
// The close message the check stands for reaches the raiser.
call('pm', 'decision.resolve', { id: technical.id, choice: 'yes' });
const close = store.get("SELECT from_role, to_role FROM messages WHERE class='DECISION' AND decision=?", technical.id);
assert.deepEqual({ ...close }, { from_role: 'pm', to_role: 'coder' });
});
test('a refused supersede withdraws nothing', (t) => {
const { raise, store } = setup(t);
const old = raise('coder', 'git.push.working', { domain: 'technical' });
assert.throws(
() => raise('coder', 'git.push.working', { domain: 'delivery', supersedes: old.id }),
{ code: ROUTE },
);
assert.equal(store.get('SELECT count(*) n FROM decision_events WHERE decision=?', old.id).n, 0);
assert.equal(store.get('SELECT count(*) n FROM decisions').n, 1);
});
test("the assistant's within-role and gated raises still work", (t) => {
const { raise, call, store, refusals } = setup(t);
// message.send is within role: routed to the assistant itself, which its own lists don't name.
const within = raise('assistant', 'message.send', { choice: 'yes' });
assert.equal(within.route_to, 'assistant');
assert.equal(
store.get("SELECT op FROM decision_events WHERE decision=?", within.id).op,
'resolved',
);
// Anything else is gated: routed to the human, whom the assistant hears from.
const gated = raise('assistant', 'task.create');
assert.equal(gated.route_to, 'human');
call('human', 'decision.resolve', { id: gated.id, choice: 'no' });
const close = store.get("SELECT from_role, to_role FROM messages WHERE class='DECISION' AND decision=?", gated.id);
assert.deepEqual({ ...close }, { from_role: 'human', to_role: 'assistant' });
assert.deepEqual(refusals(), []);
});