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' }); });