Files
stack/packages/bus/tests/hard-waits.test.mjs

227 lines
16 KiB
JavaScript

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, ACTIONS, HARD_WAITS } from '../src/broker.mjs';
import { ACTIONS as VOCABULARY, GATED_ONLY } from '../../organization/src/vocabulary.mjs';
import { fixture } from './spec-fixtures.mjs';
import { hostGrantRecord, hostRecord } from './host-fixtures.mjs';
// PRD 1.5 REQ-WAIT-1, REQ-WAIT-2 and REQ-DEC-2 (row 139, #1633).
const WAITS = ['spend', 'data.delete', 'network.expose', 'account.create', 'credential.mint', 'message.external',
'git.push.protected', 'git.push.remote', 'host.sudo', 'deploy', 'project.bootstrap'];
const LEFT = ['git.merge.protected', 'policy.change', 'prd.approve', 'role.revoke'];
const cite = f => ({ project: 'stack', spec_path: 'spec/north-star.md', spec_commit: f.commit });
const routed = (f, d) => ({ ...f.store.get('SELECT class, route_to FROM decisions WHERE id=?', d.id) });
test('REQ-WAIT-1: the broker hard waits are exactly the list, and match the business vocabulary', () => {
assert.deepEqual([...HARD_WAITS].sort(), [...WAITS].sort());
assert.deepEqual([...HARD_WAITS], [...GATED_ONLY]);
assert.deepEqual([...ACTIONS], [...VOCABULARY]);
for (const action of LEFT) assert.ok(ACTIONS.includes(action) && !HARD_WAITS.includes(action), action);
});
test('REQ-WAIT-2: a business granting a hard wait at either class is refused; the actions that left are grantable', t => {
const root = mkdtempSync(join(tmpdir(), 'hard-waits-'));
const store = new Store(root);
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
const business = authority => ({ demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' },
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition: 'coder', authority } } } });
for (const action of WAITS) {
assert.throws(() => new Broker({ store, businesses: business({ withinRole: [action], crossRole: [] }) }), /invalid-authority/, action);
assert.throws(() => new Broker({ store, businesses: business({ withinRole: [], crossRole: [action] }) }), /invalid-authority/, action);
}
assert.ok(new Broker({ store, businesses: business({ withinRole: [], crossRole: LEFT }) }));
});
test('REQ-DEC-2: every hard wait goes to the human, cited or not, and the arbiter cannot resolve it', async t => {
const f = await fixture(t, { roles: { coder: { definition: 'coder',
authority: { withinRole: ['message.send', 'record.write', 'git.push.working'], crossRole: [] } } } });
// Row 210: a host.sudo raise names a registered host the raiser is granted and an argv its grant allows.
// Row 234 (#1740): both are store records.
hostRecord(f.store, { host: 'web1', entry: { address: '10.0.0.5', port: 22, user: 'admin', hostKeys: ['ssh-ed25519 AAAA'] } });
hostGrantRecord(f.store, { agent: 'coder', host: 'web1', commands: [['systemctl', 'status']] });
// Row 199: a git.push.remote raise binds the task's latest push, so the task has one.
f.broker.recordPush(f.coder, { task_ref: 'vikunja:1/2', project: 'stack', branch: 'feat', ref: 'work/vikunja-stack-2',
commit: 'a'.repeat(40), previous: null });
for (const action of WAITS) {
for (const args of [{}, cite(f), { ...cite(f), domain: 'technical' }]) {
const d = await f.raise({ action, ...(action === 'git.push.remote' ? { target: 'vikunja:1/2' } : {}),
...(action === 'host.sudo' ? { target: 'web1', argv: ['systemctl', 'status'] } : {}), ...args });
assert.deepEqual(routed(f, d), { class: 'gated', route_to: 'human' }, `${action} ${JSON.stringify(args)}`);
}
}
const d = await f.raise({ action: 'data.delete', ...cite(f) });
assert.throws(() => f.call(f.chief, 'decision.resolve', { id: d.id, choice: 'yes' }), /human-required/);
f.call(f.human, 'decision.resolve', { id: d.id, choice: 'yes' });
});
test('REQ-DEC-2: a gated raise citing the current spec goes to the arbiter for its domain; uncited, to the human', async t => {
const f = await fixture(t, { arbiters: { technical: 'cto', delivery: 'chief' },
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, cto: { definition: 'cto', authority: { withinRole: ['message.send', 'decision.resolve.technical'], crossRole: [] } } } });
f.agent('cto');
for (const action of LEFT) {
const raise = { action, ...(action === 'role.revoke' ? { target: 'reviewer' } : {}) };
assert.deepEqual(routed(f, f.raise(raise)), { class: 'gated', route_to: 'human' }, action);
assert.deepEqual(routed(f, await f.raise({ ...raise, ...cite(f), domain: 'technical' })), { class: 'gated', route_to: action === 'policy.change' ? 'chief' : 'cto' }, action);
assert.deepEqual(routed(f, await f.raise({ ...raise, ...cite(f) })), { class: 'gated', route_to: ['git.merge.protected','prd.approve'].includes(action) ? 'cto' : 'chief' }, action);
}
});
test('REQ-DEC-2: an arbiter-approved cited gated decision authorizes the raiser, and the human cannot resolve it', async t => {
const f = await fixture(t, { roles: { pm: { definition: 'pm', authority: { withinRole: ['message.send'], crossRole: [] } } } });
f.agent('pm');
const d = await f.raise({ action: 'role.revoke', target: 'pm', ...cite(f) });
assert.deepEqual(routed(f, d), { class: 'gated', route_to: 'chief' });
assert.throws(() => f.call(f.human, 'decision.resolve', { id: d.id, choice: 'yes' }), /agent-required/);
f.call(f.chief, 'decision.resolve', { id: d.id, choice: 'yes' });
assert.deepEqual(f.call(f.coder, 'role.revoke', { role: 'pm', decision: d.id }), { revoked: true });
});
test('REQ-DEC-2: a cited merge raised by merge-gate goes to the technical arbiter; a merge raised without the grant routes by citation', async t => {
const f = await fixture(t, { arbiters: { technical: 'cto', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } },
cto: { definition: 'cto', authority: { withinRole: ['message.send', 'decision.resolve.technical'], crossRole: [] } },
'merge-gate': { definition: 'merge-gate', authority: { withinRole: [], crossRole: ['git.merge.protected'] } },
} });
const cto = f.agent('cto'), gate = f.agent('merge-gate');
const merge = { action: 'git.merge.protected', question: 'Merge?', options: [{ key: 'yes', text: 'Merge' }, { key: 'no', text: 'Hold' }],
recommendation: 'yes', blocking: false, domain: 'technical' };
const d = await f.call(gate, 'decision.raise', { ...merge, ...cite(f) });
assert.deepEqual(routed(f, d), { class: 'cross-role', route_to: 'cto' });
f.call(cto, 'decision.resolve', { id: d.id, choice: 'yes' });
assert.equal(f.store.get('SELECT via FROM decision_events WHERE decision=?', d.id).via, 'broker');
assert.deepEqual(routed(f, f.call(gate, 'decision.raise', merge)), { class: 'cross-role', route_to: 'cto' });
assert.deepEqual(routed(f, await f.raise({ ...merge, ...cite(f) })), { class: 'gated', route_to: 'cto' });
assert.deepEqual(routed(f, f.raise(merge)), { class: 'gated', route_to: 'human' });
});
test('REQ-DEC-2: a stale or unconfirmed citation refuses without a decision; an arbiter raising its own goes to the human', async t => {
const f = await fixture(t);
const old = cite(f);
f.save('# Newer\n');
await assert.rejects(f.raise({ action: 'policy.change', ...old }), { code: 'stale-spec' });
const edit = f.save('# Agent edit\n', 'coder.coder');
await assert.rejects(f.raise({ action: 'policy.change', ...old, spec_commit: edit }), { code: 'spec-unconfirmed' });
assert.equal(f.store.get('SELECT count(*) n FROM decisions').n, 0);
const current = f.save('# Current\n');
const own = await f.call(f.chief, 'decision.raise', { action: 'policy.change', question: 'Change?',
options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }], recommendation: 'yes', blocking: false,
project: 'stack', spec_path: 'spec/north-star.md', spec_commit: current });
assert.deepEqual(routed(f, own), { class: 'gated', route_to: 'human' });
});
// Jason's ruling Q169 (PRD 1.6): only the chief of staff, or a decision the human resolved through
// the CLI, gets past END_PROTECTED. A raise to revoke the assistant goes to the human even when
// cited; any other cited revoke routes as REQ-DEC-2 says. The assistant's runs hold thread claims,
// and row 155 (#1649) makes both paths revoke every held one.
const revoke = (f, cap, role, extra = {}) => f.call(cap, 'decision.raise', { action: 'role.revoke', target: role,
question: 'Revoke?', options: [{ key: 'yes', text: 'Revoke' }, { key: 'no', text: 'Keep' }], recommendation: 'no',
blocking: false, ...extra });
const grants = withinRole => ({ authority: { withinRole: ['message.send', ...withinRole], crossRole: [] } });
const seats = { assistant: { definition: 'assistant', ...grants([]) }, pm: { definition: 'pm', ...grants([]) } };
// Row 155: an assistant run holding its own thread, so an end has a claim to stop.
const held = (f, run) => {
const project = f.call(f.human, 'project.create', { name: `p-${run}` }).id;
const id = f.call(f.human, 'thread.create', { project, name: run }).id;
const cap = f.broker.bindLaunch({ business: 'demo', role: 'assistant', run, harness: 'pi', thread: id });
f.call(cap, 'role.claim');
return { id, cap };
};
const lastThreadClaim = (f, thread) =>
({ ...f.store.get('SELECT op, holder_run, by, decision, reason FROM thread_claims WHERE thread=? ORDER BY seq DESC LIMIT 1', thread) });
const allowedCount = f => f.store.get("SELECT count(*) n FROM events WHERE kind='action.allowed'").n;
test('Q169: the chief of staff\'s end of the assistant stops every thread run; its own run and another ender\'s are refused', async t => {
const f = await fixture(t, { roles: { ...seats,
chief: { definition: 'chief-of-staff', ...grants(['role.end']) }, lead: { definition: 'lead', ...grants(['role.end']) } } });
const chief = f.chief, lead = f.agent('lead');
// No thread is held yet, so there is nothing to end.
assert.throws(() => f.call(chief, 'role.revoke', { role: 'assistant', reason: 'Q169' }), /not-held/);
const a = held(f, 'a-run'), b = held(f, 'b-run');
assert.throws(() => f.call(chief, 'role.revoke', { role: 'chief', reason: 'own run' }), /role-end-refused/);
assert.throws(() => f.call(lead, 'role.revoke', { role: 'assistant', reason: 'not the chief' }), /role-end-refused/);
assert.equal(allowedCount(f), 0);
assert.deepEqual(f.call(chief, 'role.revoke', { role: 'assistant', reason: 'Q169' }), { revoked: true, threads: 2 });
for (const [x, run] of [[a, 'a-run'], [b, 'b-run']])
assert.deepEqual(lastThreadClaim(f, x.id), { op: 'revoke', holder_run: run, by: 'chief-run', decision: null, reason: 'Q169' });
// The run's next bus call is refused, which the runner reads as the session gone, and it can't claim
// its thread again. A new run for the thread can.
assert.throws(() => f.call(a.cap, 'inbox'), /not-holder/);
assert.throws(() => f.call(a.cap, 'role.claim'), /run-revoked/);
f.call(f.broker.bindLaunch({ business: 'demo', role: 'assistant', run: 'a2-run', harness: 'pi', thread: a.id }), 'role.claim');
assert.deepEqual(f.call(chief, 'role.revoke', { role: 'assistant', reason: 'again' }), { revoked: true, threads: 1 });
assert.throws(() => f.call(chief, 'role.revoke', { role: 'assistant', reason: 'none left' }), /not-held/);
assert.equal(allowedCount(f), 2);
});
test('Q169: a revoke of the assistant the chief of staff raises goes to the human, cited or not, and no arbiter resolves it', async t => {
const f = await fixture(t, { roles: { ...seats, chief: { definition: 'chief-of-staff', ...grants(['role.end']) } } });
const chief = f.chief;
for (const args of [{}, cite(f), { ...cite(f), domain: 'technical' }, { ...cite(f), domain: 'delivery' }]) {
const d = await revoke(f, chief, 'assistant', args);
assert.deepEqual(routed(f, d), { class: 'gated', route_to: 'human' }, JSON.stringify(args));
assert.throws(() => f.call(f.chief, 'decision.resolve', { id: d.id, choice: 'yes' }), /human-required/);
}
// Resolved by the human, the chief's revoke citing it stops the thread run, once.
const a = held(f, 'a-run');
const d = await revoke(f, chief, 'assistant', cite(f));
f.call(f.human, 'decision.resolve', { id: d.id, choice: 'yes' });
assert.deepEqual(f.call(chief, 'role.revoke', { role: 'assistant', decision: d.id }), { revoked: true, threads: 1 });
assert.deepEqual(lastThreadClaim(f, a.id), { op: 'revoke', holder_run: 'a-run', by: 'chief-run', decision: d.id, reason: null });
held(f, 'b-run');
assert.throws(() => f.call(chief, 'role.revoke', { role: 'assistant', decision: d.id }), /decision-consumed/);
});
test('Q169: another role\'s cited revoke of the assistant, resolved by an arbiter, is refused', async t => {
const f = await fixture(t, { roles: seats });
// A raise for it goes to the human, so an arbiter can't resolve one raised now.
assert.deepEqual(routed(f, await revoke(f, f.coder, 'assistant', cite(f))), { class: 'gated', route_to: 'human' });
// One raised while the instance's definition wasn't protected went to the arbiter. The same
// store under a business that now makes it the assistant still refuses it.
const d = await revoke(f, f.coder, 'pm', cite(f));
assert.deepEqual(routed(f, d), { class: 'gated', route_to: 'chief' });
f.call(f.chief, 'decision.resolve', { id: d.id, choice: 'yes' });
const now = { ...f.business, roles: { ...f.business.roles, pm: { definition: 'assistant', ...grants([]) } } };
const later = new Broker({ store: f.store, businesses: { demo: now } });
const coder = later.bindLaunch({ business: 'demo', role: 'coder', run: 'coder-run', harness: 'pi', address: 'coder' });
assert.throws(() => later.request(coder, { verb: 'role.revoke', args: { role: 'pm', decision: d.id } }), /role-end-refused/);
});
test('Q169: a revoke of the assistant citing a decision the human resolved stops its thread run', async t => {
const f = await fixture(t, { roles: seats });
const d = await revoke(f, f.coder, 'assistant', cite(f));
assert.throws(() => f.call(f.chief, 'decision.resolve', { id: d.id, choice: 'yes' }), /human-required/);
f.call(f.human, 'decision.resolve', { id: d.id, choice: 'yes' });
// Row 155: the recorded holder run (null) and the role_claims one (none) compare equal, so the
// revoke reaches the thread claims and stops the held one.
const a = held(f, 'a-run');
assert.deepEqual(f.call(f.coder, 'role.revoke', { role: 'assistant', decision: d.id }), { revoked: true, threads: 1 });
assert.deepEqual(lastThreadClaim(f, a.id), { op: 'revoke', holder_run: 'a-run', by: 'coder-run', decision: d.id, reason: null });
});
test('Q169: a cited revoke of a role not in END_PROTECTED still goes to the arbiter, and executes', async t => {
const f = await fixture(t, { roles: seats });
f.agent('pm');
const d = await revoke(f, f.coder, 'pm', cite(f));
assert.deepEqual(routed(f, d), { class: 'gated', route_to: 'chief' });
f.call(f.chief, 'decision.resolve', { id: d.id, choice: 'yes' });
assert.deepEqual(f.call(f.coder, 'role.revoke', { role: 'pm', decision: d.id }), { revoked: true });
});
test('Q169: a role granted role.revoke at either class still sends a revoke of the assistant to the human', async t => {
const f = await fixture(t, { roles: { ...seats,
lead: { definition: 'lead', authority: { withinRole: [], crossRole: ['role.revoke'] } },
boss: { definition: 'boss', ...grants(['role.revoke']) } } });
const lead = f.agent('lead'), boss = f.agent('boss');
assert.deepEqual(routed(f, await revoke(f, lead, 'assistant', cite(f))), { class: 'gated', route_to: 'human' });
assert.deepEqual(routed(f, await revoke(f, lead, 'pm', cite(f))), { class: 'cross-role', route_to: 'chief' });
const own = await revoke(f, boss, 'assistant', { choice: 'yes' });
assert.deepEqual(routed(f, own), { class: 'gated', route_to: 'human' });
assert.equal(f.store.get('SELECT count(*) n FROM decision_events WHERE decision=?', own.id).n, 0);
// The revoke's authority check classifies the same way: the decision is open, not mismatched.
assert.throws(() => f.call(boss, 'role.revoke', { role: 'assistant', decision: own.id }), /decision-not-approved/);
assert.deepEqual(routed(f, await revoke(f, boss, 'pm', { choice: 'yes' })), { class: 'within-role', route_to: 'boss' });
});