573 lines
31 KiB
JavaScript
573 lines
31 KiB
JavaScript
// Row 137 (#1631): the broker's side of an agent's landing. The git socket asks
|
|
// checkLand before it touches the working repository, then ends the landing
|
|
// with recordLand, which spends the merge decision, or landRefused. landHeld is
|
|
// the launcher's question whether to offer land_work at all.
|
|
//
|
|
// The lander is merge-gate, granted git.merge.protected cross-role (PRD 1.5
|
|
// REQ-LAND-1), and its raise goes to the arbiter pm (REQ-DEC-2). The coder
|
|
// pushes; its own merge raise is gated, and gated never lands.
|
|
|
|
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 { fork } from 'node:child_process';
|
|
import { once } from 'node:events';
|
|
import { Store } from '../src/store.mjs';
|
|
import { Broker } from '../src/broker.mjs';
|
|
import { readLanding } from '../src/landing.mjs';
|
|
import { fixture } from './spec-fixtures.mjs';
|
|
import { createUsage, turnRecords } from '../src/usage.mjs';
|
|
|
|
const TASK = 'vikunja:1/2';
|
|
const MERGE = 'git.merge.protected';
|
|
const A = 'a'.repeat(40);
|
|
const B = 'b'.repeat(40);
|
|
const C = 'c'.repeat(40);
|
|
const roles = {
|
|
pm: { authority: { withinRole: ['message.send'], 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] } },
|
|
};
|
|
const demo = (arbiters = { technical: 'pm', delivery: 'pm' }, r = roles) => ({
|
|
id: 'demo',
|
|
human: 'jason',
|
|
arbiters,
|
|
projects: { stack: { root: '/nonexistent' } },
|
|
roles: r,
|
|
});
|
|
const options = [
|
|
{ key: 'yes', text: 'Land the parser fix\nIt is reviewed.' },
|
|
{ key: 'no', text: 'Not yet' },
|
|
];
|
|
const merge = { action: MERGE, target: TASK, question: 'Squash-merge work/vikunja-1-2 into main?', options, recommendation: 'yes', blocking: false };
|
|
|
|
// `extra(store, root, businesses)` adds Broker options, as the usage collector and limit checks.
|
|
function setup(t, { arbiters, roles: r, extra = () => ({}) } = {}) {
|
|
const root = mkdtempSync(join(tmpdir(), 'bus-land-'));
|
|
const store = new Store(root);
|
|
const businesses = { demo: demo(arbiters, r) };
|
|
const b = new Broker({ store, businesses, ...extra(store, root, businesses) });
|
|
t.after(() => {
|
|
store.close();
|
|
rmSync(root, { recursive: true, force: true });
|
|
});
|
|
const caps = { human: b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }) };
|
|
for (const role of Object.keys(businesses.demo.roles)) {
|
|
caps[role] = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi' });
|
|
b.request(caps[role], { verb: 'role.claim', args: {} });
|
|
}
|
|
const call = (who, verb, args = {}) => b.request(caps[who], { verb, args });
|
|
const push = (commit, previous = null) => {
|
|
b.checkPush(caps.coder, TASK);
|
|
return b.recordPush(caps.coder, { task_ref: TASK, project: 'stack', branch: 'feat', ref: 'work/vikunja-1-2', commit, previous });
|
|
};
|
|
const approve = (commit) => {
|
|
const r = call('coder', 'review.request', { task_ref: TASK, commit, to: 'reviewer' });
|
|
return call('reviewer', 'review.verdict', { task_ref: TASK, request: r.request, verdict: 'approve', body: 'good' });
|
|
};
|
|
const raise = (who = 'merge-gate', extra = {}) => call(who, 'decision.raise', { ...merge, ...extra });
|
|
// The arbiter resolves by default; a decision routed to the human needs who = 'human'.
|
|
const resolve = (d, choice = 'yes', who = 'pm') => call(who, 'decision.resolve', { id: d.id, choice });
|
|
// A pushed, approved task with a merge decision its arbiter approved.
|
|
const ready = () => {
|
|
push(A);
|
|
const v = approve(A);
|
|
const d = raise();
|
|
resolve(d);
|
|
return { d, v };
|
|
};
|
|
const land = { commit: B, previous: C, reviewed: A, base: C, patch_id: 'd'.repeat(40) };
|
|
const events = (kind) => store.all('SELECT * FROM events WHERE kind=? ORDER BY seq', kind).map((e) => ({ ...e, body: JSON.parse(e.body) }));
|
|
const merges = () => events('action.allowed').filter((e) => e.body.action === MERGE && e.body.operation !== 'decision.raise');
|
|
// The landing's RESULTs; a #notice is a RESULT too.
|
|
const results = () =>
|
|
store.all("SELECT * FROM messages WHERE class='RESULT' AND (body LIKE 'Landed %' OR body LIKE 'Landing of %') ORDER BY at, id").map((m) => ({ ...m }));
|
|
const held = (role = 'merge-gate', thread = null) => b.landHeld({ business: 'demo', role, thread });
|
|
const check = (args) => b.checkLand(caps['merge-gate'], args);
|
|
const routed = (d) => ({ ...store.get('SELECT class, route_to FROM decisions WHERE id=?', d.id) });
|
|
return { b, store, root, businesses, caps, call, push, approve, raise, resolve, ready, land, events, merges, results, held, check, routed };
|
|
}
|
|
|
|
test('REQ-DEC-2: a merge-gate raise its arbiter resolved lands through the host op: checkLand answers the facts and a ticket, records nothing, and recordLand spends the decision once', (t) => {
|
|
const w = setup(t);
|
|
const { d, v } = w.ready();
|
|
assert.deepEqual(w.routed(d), { class: 'cross-role', route_to: 'pm' });
|
|
assert.deepEqual({ ...w.store.get('SELECT "by", via FROM decision_events WHERE decision=? AND op=\'resolved\'', d.id) }, { by: 'pm-run', via: 'broker' });
|
|
// land-work's copy of the check, without `arbiter`, still wants the human.
|
|
assert.throws(() => readLanding(w.store, w.businesses.demo, d.id, { into: 'main' }), { reason: 'not-human' });
|
|
const r = w.check({ task_ref: TASK, decision: d.id });
|
|
const { ticket, resolved_at, ...facts } = r;
|
|
assert.match(ticket, /^[0-9a-f-]{36}$/);
|
|
assert.match(resolved_at, /^\d{4}-/);
|
|
assert.deepEqual(facts, {
|
|
task_ref: TASK,
|
|
work_ref: 'work/vikunja-1-2',
|
|
decision: d.id,
|
|
project: 'stack',
|
|
ref: 'work/vikunja-1-2',
|
|
commit: A,
|
|
verdict: v.verdict,
|
|
subject: 'Land the parser fix',
|
|
choice: 'yes',
|
|
by: 'pm-run',
|
|
via: 'broker',
|
|
});
|
|
assert.deepEqual(w.merges(), []);
|
|
assert.deepEqual(w.events('action.refused'), []);
|
|
|
|
assert.deepEqual(w.b.recordLand(ticket, w.land), { task_ref: TASK, commit: B });
|
|
const [e] = w.merges();
|
|
assert.equal(e.subject, TASK);
|
|
assert.equal(e.actor_role, 'merge-gate');
|
|
assert.deepEqual(e.body, {
|
|
project: 'stack',
|
|
ref: 'refs/heads/main',
|
|
commit: B,
|
|
previous: C,
|
|
reviewed: A,
|
|
base: C,
|
|
patch_id: 'd'.repeat(40),
|
|
verdict: v.verdict,
|
|
action: MERGE,
|
|
class: 'cross-role',
|
|
decision: d.id,
|
|
target: TASK,
|
|
});
|
|
const [m] = w.results();
|
|
assert.equal(m.to_role, 'merge-gate');
|
|
assert.equal(m.decision, d.id);
|
|
assert.match(m.body, new RegExp(`^Landed ${TASK} on stack main under decision ${d.id}: main moved from ${C} to ${B}`));
|
|
// Spent: a second landing, and the old ticket, refuse.
|
|
assert.throws(() => w.check({ task_ref: TASK, decision: d.id }), /decision-consumed/);
|
|
assert.throws(() => w.b.recordLand(ticket, w.land), /land-unknown/);
|
|
assert.equal(w.held(), false);
|
|
});
|
|
|
|
test('checkLand refuses on the decision and the review', (t) => {
|
|
const w = setup(t);
|
|
const { check } = w;
|
|
assert.throws(() => check({ task_ref: TASK }), /invalid-request/);
|
|
assert.throws(() => check({ task_ref: 'gitea:1', decision: 'x' }), /invalid-request/);
|
|
assert.throws(() => check({ task_ref: TASK, decision: 'x', extra: 1 }), /invalid-request/);
|
|
// No decision yet, then one that isn't resolved.
|
|
assert.throws(() => check({ task_ref: TASK, decision: 'no-such' }), /decision-not-found/);
|
|
w.push(A);
|
|
const open = w.raise();
|
|
assert.throws(() => check({ task_ref: TASK, decision: open.id }), /decision-not-approved/);
|
|
w.resolve(open, 'no');
|
|
assert.throws(() => check({ task_ref: TASK, decision: open.id }), /decision-not-approved/);
|
|
// Approved, but by another role's raise, or for another target. pm's raise is gated and
|
|
// uncited, so it goes to the human.
|
|
const pmRaise = w.raise('pm');
|
|
w.resolve(pmRaise, 'yes', 'human');
|
|
assert.throws(() => check({ task_ref: TASK, decision: pmRaise.id }), /decision-mismatch/);
|
|
const other = w.raise('merge-gate', { target: 'vikunja:1/3' });
|
|
w.resolve(other);
|
|
assert.throws(() => check({ task_ref: TASK, decision: other.id }), /decision-mismatch/);
|
|
// Approved for this task, but nothing reviewed it yet.
|
|
const d = w.raise();
|
|
w.resolve(d);
|
|
assert.throws(() => check({ task_ref: TASK, decision: d.id }), /no-verdict/);
|
|
// The reviewer approved A, then the coder pushed B.
|
|
w.approve(A);
|
|
w.push(B, A);
|
|
assert.throws(() => check({ task_ref: TASK, decision: d.id }), /ref-moved/);
|
|
assert.deepEqual(w.merges(), []);
|
|
});
|
|
|
|
// PRD 1.5 REQ-LAND-1. The coder has no cross-role grant, so its merge raise is gated: cited, it
|
|
// goes to the arbiter (row 139); uncited, to the human. Approved either way, it never lands.
|
|
test('REQ-LAND-1: a gated merge decision refuses not-authorized, cited and approved by the arbiter or uncited and approved by the human', async (t) => {
|
|
const f = await fixture(t, { roles, arbiters: { technical: 'pm', delivery: 'pm' } });
|
|
const pm = f.agent('pm');
|
|
f.broker.checkPush(f.coder, TASK);
|
|
f.broker.recordPush(f.coder, { task_ref: TASK, project: 'stack', branch: 'feat', ref: 'work/vikunja-1-2', commit: A, previous: null });
|
|
const r = f.call(f.coder, 'review.request', { task_ref: TASK, commit: A, to: 'reviewer' });
|
|
f.call(f.reviewer, 'review.verdict', { task_ref: TASK, request: r.request, verdict: 'approve', body: 'good' });
|
|
const d = await f.call(f.coder, 'decision.raise', { ...merge, project: 'stack', spec_path: 'spec/north-star.md', spec_commit: f.commit });
|
|
assert.deepEqual({ ...f.store.get('SELECT class, route_to FROM decisions WHERE id=?', d.id) }, { class: 'gated', route_to: 'pm' });
|
|
f.call(pm, 'decision.resolve', { id: d.id, choice: 'yes' });
|
|
assert.throws(() => f.broker.checkLand(f.coder, { task_ref: TASK, decision: d.id }), /not-authorized/);
|
|
// The shared check refuses it too: its arbiter path takes only a cross-role decision.
|
|
assert.throws(() => readLanding(f.store, f.business, d.id, { into: 'main', arbiter: true }), { reason: 'not-human' });
|
|
assert.equal(f.broker.landHeld({ business: 'demo', role: 'coder', thread: null }), false);
|
|
|
|
const w = setup(t);
|
|
w.push(A);
|
|
w.approve(A);
|
|
const human = w.raise('coder');
|
|
assert.deepEqual(w.routed(human), { class: 'gated', route_to: 'human' });
|
|
w.resolve(human, 'yes', 'human');
|
|
assert.throws(() => w.b.checkLand(w.caps.coder, { task_ref: TASK, decision: human.id }), /not-authorized/);
|
|
assert.deepEqual([...w.merges(), ...w.events('action.refused')], []);
|
|
});
|
|
|
|
// Round 4 (sage-0585): a role granted git.merge.protected within-role resolves its own merge
|
|
// raise. That isn't the cross-role grant REQ-LAND-1 asks for, so it refuses at the land too.
|
|
test('REQ-LAND-1: a within-role merge grant refuses not-authorized at the land', (t) => {
|
|
const w = setup(t, {
|
|
roles: { ...roles, 'merge-gate': { authority: { withinRole: [MERGE], crossRole: [] } } },
|
|
});
|
|
w.push(A);
|
|
w.approve(A);
|
|
const d = w.raise('merge-gate', { choice: 'yes' });
|
|
assert.deepEqual(w.routed(d), { class: 'within-role', route_to: 'merge-gate' });
|
|
assert.deepEqual({ ...w.store.get('SELECT op, choice, via FROM decision_events WHERE decision=?', d.id) }, { op: 'resolved', choice: 'yes', via: 'broker' });
|
|
assert.throws(() => w.check({ task_ref: TASK, decision: d.id }), /not-authorized/);
|
|
assert.equal(w.held(), false);
|
|
assert.deepEqual([...w.merges(), ...w.events('action.refused')], []);
|
|
});
|
|
|
|
test('a cross-role merge that routes to the human, because merge-gate is its own arbiter, lands on the human\'s CLI answer', (t) => {
|
|
const w = setup(t, { arbiters: { technical: 'merge-gate', delivery: 'merge-gate' } });
|
|
w.push(A);
|
|
w.approve(A);
|
|
const d = w.raise();
|
|
assert.deepEqual(w.routed(d), { class: 'cross-role', route_to: 'human' });
|
|
assert.throws(() => w.resolve(d), /human-required/);
|
|
w.resolve(d, 'yes', 'human');
|
|
const { ticket, by, via } = w.check({ task_ref: TASK, decision: d.id });
|
|
assert.deepEqual([by, via], ['jason', 'cli']);
|
|
w.b.recordLand(ticket, w.land);
|
|
assert.equal(w.merges()[0].body.class, 'cross-role');
|
|
});
|
|
|
|
test('a merge option with no first line refuses no-subject, as land-work does', (t) => {
|
|
const w = setup(t);
|
|
w.push(A);
|
|
w.approve(A);
|
|
const d = w.raise('merge-gate', {
|
|
options: [
|
|
{ key: 'yes', text: '\nLand it' },
|
|
{ key: 'no', text: 'Not yet' },
|
|
],
|
|
});
|
|
w.resolve(d);
|
|
assert.throws(() => w.check({ task_ref: TASK, decision: d.id }), /no-subject/);
|
|
});
|
|
|
|
// The codes readLanding gives once #checkAuthority has passed, on the 'main'
|
|
// side. scripts/test-land-work.sh runs every code on the 'refactor' side.
|
|
// checkLand never gives not-human: #landFacts refuses every class but
|
|
// cross-role with not-authorized first, and the broker resolves a cross-role
|
|
// decision only the two ways readLanding takes with `arbiter`, the human's CLI
|
|
// answer or its arbiter's decision.resolve. The REQ-LAND-1 test calls
|
|
// readLanding for not-human.
|
|
test("checkLand refuses with land-work's codes past the authority check, and a decision raised for refactor never lands", (t) => {
|
|
const w = setup(t);
|
|
const check = (d) => () => w.check({ task_ref: TASK, decision: d.id });
|
|
w.push(A);
|
|
w.approve(A);
|
|
const approved = (extra) => {
|
|
const d = w.raise('merge-gate', extra);
|
|
w.resolve(d);
|
|
return d;
|
|
};
|
|
assert.throws(check(approved({ question: 'Squash-merge work/vikunja-1-2 and work/vikunja-1-3 into main?' })), /work-ref/);
|
|
assert.throws(check(approved({ question: 'Squash-merge work/vikunja-1-2 into main?', task_ref: 'vikunja:1/3' })), /work-ref/);
|
|
// The raise targets this task, but the question names another.
|
|
assert.throws(check(approved({ question: 'Squash-merge work/vikunja-1-3 into main?' })), /target-branch/);
|
|
// land-work's kind of decision: the authority check wants the task as the target.
|
|
assert.throws(check(approved({ target: 'refactor' })), /decision-mismatch/);
|
|
// A later merge decision on the same work ref supersedes an approved one.
|
|
const first = approved();
|
|
w.raise();
|
|
assert.throws(check(first), /superseded/);
|
|
|
|
// A later verdict asks for changes on the same commit.
|
|
const v = setup(t);
|
|
v.push(A);
|
|
v.approve(A);
|
|
const d = v.raise();
|
|
v.resolve(d);
|
|
const r = v.call('coder', 'review.request', { task_ref: TASK, commit: A, to: 'reviewer' });
|
|
v.call('reviewer', 'review.verdict', { task_ref: TASK, request: r.request, verdict: 'changes', body: 'one more thing' });
|
|
assert.throws(() => v.check({ task_ref: TASK, decision: d.id }), /not-approved/);
|
|
assert.deepEqual([...w.merges(), ...v.merges()], []);
|
|
});
|
|
|
|
test('a ticket outlives the run that asked, and recordLand checks the task again', (t) => {
|
|
const w = setup(t);
|
|
const { d } = w.ready();
|
|
const { ticket } = w.check({ task_ref: TASK, decision: d.id });
|
|
// A bad record refuses and keeps the ticket.
|
|
assert.throws(() => w.b.recordLand(ticket, { ...w.land, commit: 'x' }), /invalid-request/);
|
|
const { patch_id, ...short } = w.land;
|
|
assert.throws(() => w.b.recordLand(ticket, short), /invalid-request/);
|
|
assert.throws(() => w.b.recordLand(ticket, { ...w.land, reviewed: C }), /ref-moved/);
|
|
// The run ends while the gate runs; the landing is still recorded as merge-gate's.
|
|
w.b.endLaunch({ business: 'demo', run: 'merge-gate-run', reason: 'exited' });
|
|
assert.deepEqual(w.b.recordLand(ticket, w.land), { task_ref: TASK, commit: B });
|
|
assert.equal(w.merges()[0].actor_role, 'merge-gate');
|
|
assert.throws(() => w.b.recordLand('no-such-ticket', w.land), /land-unknown/);
|
|
assert.throws(() => w.b.landRefused(ticket, { code: 'gate-failed' }), /land-unknown/);
|
|
});
|
|
|
|
// Row 241 (#1747): one decision lands once. Two tickets on one decision can't both reach
|
|
// recordLand through the git socket (land-busy, git-socket.mjs), so this pins the broker's own
|
|
// guard: recordLand runs #landFacts again, and a decision spent by the first refuses the second.
|
|
test('two tickets on one decision: the first recordLand lands, the second refuses decision-consumed and records nothing', (t) => {
|
|
const w = setup(t);
|
|
const { d } = w.ready();
|
|
const first = w.check({ task_ref: TASK, decision: d.id }).ticket;
|
|
const second = w.check({ task_ref: TASK, decision: d.id }).ticket;
|
|
assert.notEqual(first, second);
|
|
assert.deepEqual(w.b.recordLand(first, w.land), { task_ref: TASK, commit: B });
|
|
assert.throws(() => w.b.recordLand(second, { ...w.land, commit: C, previous: B }), /decision-consumed/);
|
|
assert.equal(w.merges().length, 1);
|
|
assert.equal(w.merges()[0].body.commit, B);
|
|
assert.equal(w.results().length, 1);
|
|
assert.deepEqual(w.events('action.refused'), []);
|
|
// The git socket then ends the second ticket with the broker's code, as it does any record refusal.
|
|
assert.equal(w.b.landRefused(second, { code: 'decision-consumed', notify: true }), 'decision-consumed');
|
|
assert.deepEqual(w.events('action.refused').map((e) => e.body), [{ code: 'decision-consumed' }]);
|
|
assert.equal(w.merges().length, 1);
|
|
assert.throws(() => w.b.recordLand(second, w.land), /land-unknown/);
|
|
});
|
|
|
|
test('landRefused files the code on the task, tells the role only with notify, and ends the ticket', (t) => {
|
|
const w = setup(t);
|
|
const { d } = w.ready();
|
|
const first = w.check({ task_ref: TASK, decision: d.id });
|
|
assert.throws(() => w.b.landRefused(first.ticket, { code: 'Not A Code' }), /invalid-request/);
|
|
assert.throws(() => w.b.landRefused(first.ticket, { code: 'x', detail: '' }), /invalid-request/);
|
|
assert.throws(() => w.b.landRefused(first.ticket, { code: 'x', notify: 'yes' }), /invalid-request/);
|
|
assert.equal(w.b.landRefused(first.ticket, { code: 'unknown-project' }), 'unknown-project');
|
|
assert.deepEqual(w.results(), []);
|
|
const second = w.check({ task_ref: TASK, decision: d.id });
|
|
w.b.landRefused(second.ticket, { code: 'gate-failed', detail: 'pkg-bus failed, see x.log', notify: true });
|
|
const [m] = w.results();
|
|
assert.equal(m.to_role, 'merge-gate');
|
|
assert.equal(m.body, `Landing of ${TASK} under decision ${d.id} refused: gate-failed.\npkg-bus failed, see x.log`);
|
|
assert.deepEqual(
|
|
w.events('action.refused').map((e) => [e.subject, e.actor_role, e.body.code]),
|
|
[
|
|
[TASK, 'merge-gate', 'unknown-project'],
|
|
[TASK, 'merge-gate', 'gate-failed'],
|
|
],
|
|
);
|
|
// A refusal spends nothing: the decision still lands.
|
|
assert.equal(w.held(), true);
|
|
const third = w.check({ task_ref: TASK, decision: d.id });
|
|
w.b.recordLand(third.ticket, w.land);
|
|
assert.throws(() => w.b.landRefused(second.ticket, { code: 'x' }), /land-unknown/);
|
|
});
|
|
|
|
test('landHeld is true only for an approved, unspent cross-role merge decision this role raised for a task in this thread', (t) => {
|
|
const w = setup(t);
|
|
assert.equal(w.held(), false);
|
|
w.push(A);
|
|
const d = w.raise();
|
|
assert.equal(w.held(), false, 'open');
|
|
w.resolve(d, 'no');
|
|
assert.equal(w.held(), false, 'declined');
|
|
const notTask = w.raise('merge-gate', { target: 'main' });
|
|
w.resolve(notTask);
|
|
assert.equal(w.held(), false, 'a target that is not a task ref');
|
|
const gated = w.raise('coder');
|
|
w.resolve(gated, 'yes', 'human');
|
|
assert.equal(w.held('coder'), false, 'gated');
|
|
const ok = w.raise();
|
|
w.resolve(ok);
|
|
assert.equal(w.held(), true);
|
|
assert.equal(w.held('pm'), false, 'another role');
|
|
assert.equal(w.held('merge-gate', 'thread-1'), false, 'another thread');
|
|
assert.throws(() => w.b.landHeld({ business: 'demo', role: 'merge-gate' }), /invalid-request/);
|
|
assert.throws(() => w.b.landHeld({ business: 'nope', role: 'merge-gate', thread: null }));
|
|
});
|
|
|
|
// Row 156 (#1651): landHolders is landHeld's list for the launcher's landing pass. At each step
|
|
// of the landHeld test above, the two agree.
|
|
test('landHolders lists a merge decision exactly while landHeld is true for its non-thread instance, in decision order', (t) => {
|
|
const w = setup(t);
|
|
const holders = () => w.b.landHolders('demo');
|
|
const agree = (msg) => assert.equal(holders().some((h) => h.instance === 'merge-gate'), w.held(), msg);
|
|
assert.deepEqual(holders(), []);
|
|
w.push(A);
|
|
w.approve(A);
|
|
const d = w.raise();
|
|
agree('open');
|
|
w.resolve(d, 'no');
|
|
agree('declined');
|
|
const notTask = w.raise('merge-gate', { target: 'main' });
|
|
w.resolve(notTask);
|
|
agree('a target that is not a task ref');
|
|
const gated = w.raise('coder');
|
|
w.resolve(gated, 'yes', 'human');
|
|
assert.deepEqual(holders(), [], 'gated');
|
|
const first = w.raise();
|
|
w.resolve(first);
|
|
const second = w.raise();
|
|
w.resolve(second);
|
|
agree('approved');
|
|
const at = (x) => w.store.get("SELECT at FROM decision_events WHERE decision=? AND op='resolved'", x.id).at;
|
|
const entry = (x) => ({ instance: 'merge-gate', decision: x.id, task_ref: TASK, resolved_at: at(x) });
|
|
assert.deepEqual(holders(), [entry(first), entry(second)]);
|
|
// The second supersedes the first, so only the second lands. The first is never spent, and
|
|
// landHeld still counts it, so it stays listed (BUILD.md follow-up 5).
|
|
assert.throws(() => w.check({ task_ref: TASK, decision: first.id }), /superseded/);
|
|
w.b.recordLand(w.check({ task_ref: TASK, decision: second.id }).ticket, w.land);
|
|
assert.deepEqual(holders(), [entry(first)], 'spent');
|
|
agree('spent');
|
|
assert.throws(() => w.b.landHolders('nope'), /unknown-business/);
|
|
assert.throws(() => w.b.landHolders('no such'), /invalid-request/);
|
|
});
|
|
|
|
test("landHolders leaves out a thread instance's decision, even one raised before its role became one", (t) => {
|
|
const w = setup(t);
|
|
w.ready();
|
|
assert.equal(w.b.landHolders('demo').length, 1);
|
|
// A restart with merge-gate now a thread instance: landHeld still answers for the role and
|
|
// thread asked, but the landing pass starts no non-thread run for it.
|
|
const roles2 = { ...roles, 'merge-gate': { ...roles['merge-gate'], definition: 'assistant' } };
|
|
const b2 = new Broker({ store: w.store, businesses: { demo: demo(undefined, roles2) } });
|
|
assert.equal(b2.landHeld({ business: 'demo', role: 'merge-gate', thread: null }), true);
|
|
assert.deepEqual(b2.landHolders('demo'), []);
|
|
});
|
|
|
|
// Row 156: the landing pass stops a role run started before its decision was resolved. Unread
|
|
// delivered messages keep it; waiting ones stay undelivered for the next holder. Round 2 (F1 in
|
|
// 29033): so does a run that hasn't asked message.receive yet, since it may be in a landing turn.
|
|
test('landRelease releases the holding run by land-relaunch once it has asked message.receive and nothing delivered to it is unread, and waiting messages go to the next holder', (t) => {
|
|
const w = setup(t);
|
|
const { d } = w.ready();
|
|
const release = (extra = {}) => w.b.landRelease({ business: 'demo', run: 'merge-gate-run', decision: d.id, ...extra });
|
|
assert.deepEqual(release(), { released: false, landing: true }, 'no receive yet');
|
|
assert.deepEqual(release(), { released: false, landing: true }, 'asking again changes nothing');
|
|
// The resolve in ready() sent merge-gate its DECISION message, so this receive delivers two.
|
|
const sent = w.call('human', 'message.send', { to: 'merge-gate', body: 'one' });
|
|
const got = w.call('merge-gate', 'message.receive');
|
|
assert.equal(got.length, 2);
|
|
assert.equal(got.filter((m) => m.id === sent.id).length, 1);
|
|
assert.deepEqual(release(), { released: false, unread: 2 });
|
|
assert.equal(w.store.get("SELECT op FROM role_claims WHERE role='merge-gate' ORDER BY seq DESC LIMIT 1").op, 'claim', 'kept');
|
|
w.call('merge-gate', 'message.read', { id: sent.id });
|
|
assert.deepEqual(release(), { released: false, unread: 1 });
|
|
for (const m of got) if (m.id !== sent.id) w.call('merge-gate', 'message.read', { id: m.id });
|
|
const waiting = w.call('human', 'message.send', { to: 'merge-gate', body: 'two' });
|
|
assert.throws(() => release({ decision: 'nope' }), /decision-not-held/);
|
|
assert.throws(() => release({ extra: 1 }), /invalid-request/);
|
|
assert.throws(() => w.b.landRelease({ business: 'demo', run: 'no-run', decision: d.id }), /unknown-run/);
|
|
assert.throws(() => w.b.landRelease({ business: 'demo', run: 'coder-run', decision: d.id }), /decision-not-held/);
|
|
// A run of the same instance that doesn't hold the claim releases nothing.
|
|
w.caps.other = w.b.bindLaunch({ business: 'demo', role: 'merge-gate', run: 'merge-gate-0', harness: 'pi' });
|
|
assert.throws(() => w.b.landRelease({ business: 'demo', run: 'merge-gate-0', decision: d.id }), /not-holder/);
|
|
assert.deepEqual(release(), { released: true });
|
|
assert.deepEqual(
|
|
{ ...w.store.get("SELECT op, holder_run, \"by\", decision, reason FROM role_claims WHERE role='merge-gate' ORDER BY seq DESC LIMIT 1") },
|
|
{ op: 'release', holder_run: 'merge-gate-run', by: 'land-relaunch', decision: null, reason: null },
|
|
);
|
|
assert.throws(() => release(), /not-holder/, 'released once');
|
|
assert.throws(() => w.call('merge-gate', 'message.receive'), /launcher-released/);
|
|
w.b.endLaunch({ business: 'demo', run: 'merge-gate-run', reason: 'exited' });
|
|
assert.throws(() => release(), /run-ended/);
|
|
assert.equal(w.store.get("SELECT count(*) AS n FROM deliveries WHERE message=? AND op='delivered'", waiting.id).n, 0, 'still waiting');
|
|
// The decision is still held, and the next run gets the waiting message.
|
|
assert.equal(w.held(), true);
|
|
w.caps.next = w.b.bindLaunch({ business: 'demo', role: 'merge-gate', run: 'merge-gate-2', harness: 'pi' });
|
|
w.call('next', 'role.claim');
|
|
assert.deepEqual(w.call('next', 'message.receive').map((m) => m.id), [waiting.id]);
|
|
assert.equal(w.store.get("SELECT holder_run FROM deliveries WHERE message=? AND op='delivered'", waiting.id).holder_run, 'merge-gate-2');
|
|
});
|
|
|
|
// Row 156 round 2 (Sage's F3 ruling on 29033): the run the landing pass starts after the
|
|
// resolution lands in its landing turn, before it has asked message.receive. Its land_work reaches
|
|
// checkLand through the git socket with the run's capability; here the call is made as that run.
|
|
test('a landing run started after the resolution passes checkLand for its held decision before any message.receive, and lands it', (t) => {
|
|
const w = setup(t);
|
|
const { d, v } = w.ready();
|
|
w.b.endLaunch({ business: 'demo', run: 'merge-gate-run', reason: 'exited' });
|
|
assert.deepEqual(w.b.landHolders('demo').map(({ instance, decision, task_ref }) => ({ instance, decision, task_ref })), [{ instance: 'merge-gate', decision: d.id, task_ref: TASK }]);
|
|
const cap = w.b.bindLaunch({ business: 'demo', role: 'merge-gate', run: 'merge-gate-land', harness: 'pi' });
|
|
w.b.request(cap, { verb: 'role.claim', args: {} });
|
|
assert.deepEqual(w.b.landRelease({ business: 'demo', run: 'merge-gate-land', decision: d.id }), { released: false, landing: true });
|
|
const { ticket, resolved_at, ...facts } = w.b.checkLand(cap, { task_ref: TASK, decision: d.id });
|
|
assert.match(ticket, /^[0-9a-f-]{36}$/);
|
|
assert.deepEqual([facts.decision, facts.commit, facts.verdict, facts.choice], [d.id, A, v.verdict, 'yes']);
|
|
assert.deepEqual(w.b.recordLand(ticket, w.land), { task_ref: TASK, commit: B });
|
|
const [e] = w.merges();
|
|
assert.deepEqual([e.actor_run, e.body.decision, e.body.commit], ['merge-gate-land', d.id, B]);
|
|
// Spent: no longer listed, and nothing was delivered to the landing run. The claim routed the
|
|
// waiting messages to it as their holder; none was read.
|
|
assert.deepEqual(w.b.landHolders('demo'), []);
|
|
assert.equal(w.store.get("SELECT count(*) AS n FROM deliveries WHERE holder_run='merge-gate-land' AND op='delivered'").n, 0);
|
|
});
|
|
|
|
// Row 156 (sage-0933): the bus process answers both launch ops from its own launchOp line. A
|
|
// LAUNCH_OPS name without one refuses launch-op-refused; before row 156 it answered as
|
|
// credentialStatus, whose [] for landHolders read as "no holders" (mutant P02).
|
|
test('the bus process answers landHolders and landRelease from their own lines, not as credentialStatus', async (t) => {
|
|
const root = mkdtempSync(join(tmpdir(), 'bus-land-ops-'));
|
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
const child = fork(new URL('../src/process.mjs', import.meta.url), [], { stdio: ['ignore', 'ignore', 'ignore', 'ipc'] });
|
|
t.after(() => {
|
|
if (child.exitCode === null) child.kill('SIGKILL');
|
|
});
|
|
const ask = async (m) => {
|
|
const reply = once(child, 'message');
|
|
child.send(m);
|
|
return (await reply)[0];
|
|
};
|
|
const businesses = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'pm', delivery: 'pm' }, roles } };
|
|
assert.equal((await ask({ op: 'boot', config: { dataRoot: root, businesses, launches: [], readers: [] } })).ok, true);
|
|
// Without its launchOp line each would get launch-op-refused, not the broker's own answer.
|
|
assert.deepEqual(await ask({ op: 'landHolders', business: 'demo', id: 1 }), { ok: true, result: [], id: 1 }, 'landHolders, not launch-op-refused');
|
|
assert.deepEqual(
|
|
await ask({ op: 'landRelease', record: { business: 'demo', run: 'no-run', decision: 'd1' }, id: 2 }),
|
|
{ ok: false, error: 'unknown-run', id: 2 },
|
|
'landRelease, not launch-op-refused',
|
|
);
|
|
assert.deepEqual(await ask({ op: 'credentialStatus', business: 'demo', role: 'pm', id: 3 }), { ok: true, result: [], id: 3 });
|
|
const exit = once(child, 'exit');
|
|
child.send({ op: 'close' });
|
|
assert.equal((await exit)[0], 0);
|
|
});
|
|
|
|
// Row 156 (sage-0808): a role run's landing turn brackets its usage with the decision id. The
|
|
// begin needs the decision held for the run's instance; the end needs that begin.
|
|
test('usage.begin with a decision id needs it held for the run\'s instance, usage.end needs the begin, and the limit checks see the landing turn', async (t) => {
|
|
const asked = [];
|
|
const w = setup(t, {
|
|
extra: (store, root, businesses) => {
|
|
const usage = createUsage({ store, dataRoot: root, businesses, log: () => {} });
|
|
return {
|
|
usage: { collect: (...a) => usage.then((u) => u.collect(...a)), view: () => ({}) },
|
|
limits: { mustStop: async (business, subject) => { asked.push({ subject, turns: turnRecords(store, business) }); return { stop: false }; } },
|
|
};
|
|
},
|
|
});
|
|
const usage = (who, phase, turn) => w.call(who, `usage.${phase}`, { turn });
|
|
const open = w.raise('merge-gate');
|
|
await assert.rejects(usage('merge-gate', 'begin', open.id), { code: 'not-recipient' }, 'open');
|
|
w.push(A);
|
|
w.approve(A);
|
|
w.resolve(open);
|
|
await assert.rejects(usage('pm', 'begin', open.id), { code: 'not-recipient' }, "another instance's");
|
|
await assert.rejects(usage('merge-gate', 'end', open.id), { code: 'not-recipient' }, 'no begin');
|
|
assert.deepEqual(await usage('merge-gate', 'begin', open.id), { recorded: true });
|
|
// The landing spends the decision inside the turn; the end still records.
|
|
w.b.recordLand(w.check({ task_ref: TASK, decision: open.id }).ticket, w.land);
|
|
await assert.rejects(usage('pm', 'end', open.id), { code: 'not-recipient' }, "another run's begin");
|
|
assert.deepEqual(await usage('merge-gate', 'end', open.id), { recorded: true });
|
|
assert.deepEqual(
|
|
w.store.all('SELECT run, instance, turn, phase FROM usage_records ORDER BY seq').map((r) => ({ ...r })),
|
|
['begin', 'end'].map((phase) => ({ run: 'merge-gate-run', instance: 'merge-gate', turn: open.id, phase })),
|
|
);
|
|
assert.equal(asked.length, 1);
|
|
assert.deepEqual(asked[0].subject, { instance: 'merge-gate', provider: null, model: null, account: null });
|
|
assert.deepEqual(asked[0].turns.map((r) => [r.run, r.turn, r.instance]), [['merge-gate-run', open.id, 'merge-gate']]);
|
|
// Spent, a second landing turn's begin refuses.
|
|
await assert.rejects(usage('merge-gate', 'begin', open.id), { code: 'not-recipient' }, 'spent');
|
|
});
|
|
|
|
test('no run may write the action.allowed for git.merge.protected itself', (t) => {
|
|
const w = setup(t);
|
|
assert.throws(
|
|
() => w.b.recordEvent(w.caps.coder, { kind: 'action.allowed', body: { action: MERGE, target: TASK, commit: B }, subject: TASK }),
|
|
/reserved-event/,
|
|
);
|
|
});
|