Files
stack/packages/bus/tests/launcher-released.test.mjs

152 lines
8.5 KiB
JavaScript

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';
// Row 302 (#1809, Vikunja 588): threadIdle and landRelease release a run's claim and the launcher
// signals it after. The run's own calls in between answer launcher-released, so its runner exits
// stopped. Every other way a run loses its claim still answers not-holder, and a released run
// doesn't claim again (Dewey's D1 in dewey-0875).
const shipped = (name) =>
JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), 'utf8'));
function setup(t, roles, definitions) {
const root = mkdtempSync(join(tmpdir(), 'bus-launcher-released-'));
const store = new Store(root);
t.after(() => {
store.close();
rmSync(root, { recursive: true, force: true });
});
const businesses = {
demo: {
id: 'demo',
human: 'jason',
arbiters: { technical: 'pm', delivery: 'pm' },
roles,
...(definitions ? { definitions } : {}),
projects: { stack: { root: '/nonexistent' } },
},
};
const b = new Broker({ store, businesses });
const caps = { human: b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }) };
const call = (who, verb, args = {}) => b.request(caps[who], { verb, args });
const launch = (name, role, extra = {}) => {
caps[name] = b.bindLaunch({ business: 'demo', role, run: `${name}-run`, harness: 'pi', ...extra });
};
const refused = (run) =>
store.all("SELECT body FROM events WHERE kind='action.refused' AND actor_run=? ORDER BY seq", run).map((e) => JSON.parse(e.body).code);
return { b, store, caps, call, launch, refused };
}
// Two threads of the assistant, each with its own run holding it.
function threads(t) {
const w = setup(
t,
{
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
assistant: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } },
pm: { definition: 'pm', authority: { withinRole: ['message.send'], crossRole: [] } },
},
{ 'chief-of-staff': {}, assistant: { messaging: shipped('assistant').messaging }, pm: {} },
);
const project = w.call('human', 'project.create', { name: 'stack work', repo: 'stack' }).id;
const A = w.call('human', 'thread.create', { project, name: 'alpha' }).id;
const B = w.call('human', 'thread.create', { project, name: 'beta' }).id;
for (const r of ['chief', 'pm']) {
w.launch(r, r);
w.call(r, 'role.claim');
}
w.launch('a', 'assistant', { thread: A });
w.launch('b', 'assistant', { thread: B });
w.call('a', 'role.claim');
w.call('b', 'role.claim');
const idle = (run, thread = A) => w.b.threadIdle({ business: 'demo', thread, run });
return { ...w, A, B, idle };
}
test('row 302 a run threadIdle released answers launcher-released on receive, state, read and send, logs each, and claims nothing again', (t) => {
const { b, store, call, refused, idle, A } = threads(t);
const sent = call('human', 'thread.send', { thread: A, body: 'for A' });
assert.deepEqual(call('a', 'message.receive').map((m) => m.id), [sent.id]);
call('a', 'message.read', { id: sent.id });
assert.deepEqual(idle('a-run'), { released: true });
const later = call('human', 'thread.send', { thread: A, body: 'later for A' });
assert.throws(() => call('a', 'message.receive'), { code: 'launcher-released' });
assert.throws(() => call('a', 'state'), { code: 'launcher-released' });
assert.throws(() => call('a', 'message.read', { id: later.id }), { code: 'launcher-released' });
// message.send needs the claim too, through #consumeAuthority (dewey-0878 C1).
assert.throws(() => call('a', 'message.send', { to: 'chief', body: 'from a released run' }), { code: 'launcher-released' });
// D1: no new claim for the released run, so a later not-holder can't be misreported.
const claims = store.get('SELECT count(*) AS n FROM thread_claims').n;
assert.throws(() => call('a', 'role.claim'), { code: 'launcher-released' });
assert.equal(store.get('SELECT count(*) AS n FROM thread_claims').n, claims);
assert.deepEqual(refused('a-run'), Array(5).fill('launcher-released'));
// #route writes one routed row per message; with no holder its holder_run is null (broker #recipient).
assert.equal(store.get('SELECT count(*) AS n FROM deliveries WHERE message=? AND holder_run=?', later.id, 'a-run').n, 0, 'nothing delivered to the released run');
assert.deepEqual(store.all('SELECT op, holder_run FROM deliveries WHERE message=?', later.id).map((r) => ({ ...r })), [{ op: 'routed', holder_run: null }]);
// threadIdle's own refusal is unchanged.
assert.throws(() => idle('a-run'), { code: 'not-holder' });
// After the end the run's caps are gone.
b.endLaunch({ business: 'demo', run: 'a-run', reason: 'idle' });
assert.throws(() => call('a', 'message.receive'), { code: 'unauthenticated' });
});
test('row 302 with a run launcher-released, a run that lost its claim another way still answers not-holder', (t) => {
const { call, launch, idle, A, B } = threads(t);
assert.deepEqual(idle('a-run'), { released: true });
// The human's stop revokes the other thread's holder.
assert.deepEqual(call('human', 'assistant.stop', { instance: 'assistant' }).stopped, [{ thread: B, run: 'b-run' }]);
assert.throws(() => call('b', 'message.receive'), { code: 'not-holder' });
assert.throws(() => call('b', 'state'), { code: 'not-holder' });
assert.throws(() => call('b', 'role.claim'), { code: 'run-revoked' });
// A role run that released its own claim.
call('pm', 'role.release');
assert.throws(() => call('pm', 'message.receive'), { code: 'not-holder' });
// A new run holds the released thread; the released run's answer stays its own.
launch('a2', 'assistant', { thread: A });
call('a2', 'role.claim');
assert.throws(() => call('a', 'message.receive'), { code: 'launcher-released' });
assert.deepEqual(call('a2', 'message.receive'), []);
});
const MERGE = 'git.merge.protected';
const TASK = 'vikunja:1/2';
test('row 302 a run landRelease released answers launcher-released, and claims nothing again', (t) => {
const w = setup(t, {
pm: { authority: { withinRole: ['message.send', 'decision.resolve.technical'], crossRole: [] } },
coder: { authority: { withinRole: ['git.push.working', 'review.request', 'message.send'], crossRole: [] } },
reviewer: { authority: { withinRole: ['review.verdict', 'message.send'], crossRole: [] } },
'merge-gate': { authority: { withinRole: [], crossRole: [MERGE] } },
});
for (const role of ['pm', 'coder', 'reviewer', 'merge-gate']) {
w.launch(role, role);
w.call(role, 'role.claim');
}
const commit = 'a'.repeat(40);
w.b.checkPush(w.caps.coder, TASK);
w.b.recordPush(w.caps.coder, { task_ref: TASK, project: 'stack', branch: 'feat', ref: 'work/vikunja-1-2', commit, previous: null });
const r = w.call('coder', 'review.request', { task_ref: TASK, commit, to: 'reviewer' });
w.call('reviewer', 'review.verdict', { task_ref: TASK, request: r.request, verdict: 'approve', body: 'good' });
const options = [{ key: 'yes', text: 'Land it' }, { key: 'no', text: 'Not yet' }];
const d = w.call('merge-gate', 'decision.raise', { action: MERGE, target: TASK, question: 'Land?', options, recommendation: 'yes', blocking: false });
w.call('pm', 'decision.resolve', { id: d.id, choice: 'yes' });
const release = () => w.b.landRelease({ business: 'demo', run: 'merge-gate-run', decision: d.id });
for (const m of w.call('merge-gate', 'message.receive')) w.call('merge-gate', 'message.read', { id: m.id });
assert.deepEqual(release(), { released: true });
assert.throws(() => w.call('merge-gate', 'message.receive'), { code: 'launcher-released' });
assert.throws(() => w.call('merge-gate', 'state'), { code: 'launcher-released' });
const claims = w.store.get('SELECT count(*) AS n FROM role_claims').n;
assert.throws(() => w.call('merge-gate', 'role.claim'), { code: 'launcher-released' });
assert.equal(w.store.get('SELECT count(*) AS n FROM role_claims').n, claims);
// landRelease's own refusal is unchanged, and a role run that released itself still answers not-holder.
assert.throws(() => release(), { code: 'not-holder' });
w.call('coder', 'role.release');
assert.throws(() => w.call('coder', 'message.receive'), { code: 'not-holder' });
});