152 lines
8.5 KiB
JavaScript
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' });
|
|
});
|