The ledger prints a queue section above the weekly table. It checks four things: - open issues named by done rows; - owner registrations for active rows; - closed issues for done rows; - the age of required rows. The result is fail, incomplete or reduced pass. It uses its own Gitea budget of the open list plus at most 10 lookups. A full open page counts only while an issue in some row's closes has no known state (lead decision 40). T3 seats are exempt per run with --unsupported-runtime. The weekly routine is in packages/ledger/README.md. Built by Darkwing (build.patch ab1f12ca, manifest 0b20bbca). Filbert reviewed it: round 1 81f26f2e asked for changes (C1, ISO requiredSince never aged); round 2 ce8ce150 approved. Also carries Filbert's plan amendment for decision 40 (68a25ffe). Co-Authored-By: Claude Opus 5.5 <[email protected]>
450 lines
31 KiB
JavaScript
450 lines
31 KiB
JavaScript
// The ledger's queue section (Piece E, #1508; plan 8.10). The checks run
|
|
// in-process on fixture rows with an injected clock and pid probe. The CLI
|
|
// runs in a scratch repository whose queue.json the real queue writer made,
|
|
// against a fake gitea-api.sh. No test reads a real credential, registration
|
|
// or config: every run sets HOME and MOSAIC_CONFIG to temporary paths.
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { chmodSync, cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { queueChecks, formatQueue, issueStates, readQueue, readOpenIssues, lookupIssue, classifySeat, pidAlive, protectedChanges, LOOKUP_BUDGET } from '../src/queue-checks.mjs';
|
|
import { writeRegistration } from '../../seat/src/seat.mjs';
|
|
import { scratchRepo, genesisCommitted, mapText, MAP_ROWS, cli as queueCli } from '../../queue/tests/helpers.mjs';
|
|
|
|
const PACKAGES = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
|
const DAY = 86400000;
|
|
const NOW = Date.parse('2026-09-28T09:00:00Z');
|
|
const ROOT = '/srv/checkout';
|
|
|
|
const row = (id, over = {}) => ({ id, piece: `Row ${id}`, owner: 'alice', issues: [], closes: [], state: 'briefed', required: false, requiredSince: null, ...over });
|
|
const queue = (rows, genesisAt = '2026-09-27T01:00:00Z') => ({ revision: 7, canonicalRoot: ROOT, genesisAt, rows });
|
|
const states = (map, { lookups = 0, openListFull = false } = {}) => ({
|
|
states: new Map(Object.entries(map).map(([n, state]) => [Number(n), { state, detail: state === 'unknown' ? 'lookup failed' : 'lookup' }])),
|
|
lookups, openListFull,
|
|
});
|
|
// Seats directory with one registration per entry: [seat, pid, root].
|
|
function seatsWith(t, entries) {
|
|
const dir = mkdtempSync(path.join(os.tmpdir(), 'ledger-seats-'));
|
|
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
|
for (const [seat, pid, root = ROOT] of entries) {
|
|
writeRegistration(dir, { version: 1, seat, project: 'checkout', task: '', workspace: root, tmux: null, harness: 'pi', startedAt: '2026-09-27T00:00:00.000Z',
|
|
pid, sessionsDir: path.join(root, '.pi/state', seat, 'sessions'), seatDir: path.join(root, 'agents', seat), launchScript: path.join(root, 'agents', seat, 'launch.sh'), layout: 'repo', updatedAt: null });
|
|
}
|
|
return dir;
|
|
}
|
|
const run = (rows, { issues = states({}), seats = null, exempt = [], genesisAt, isPidAlive = () => true } = {}) =>
|
|
queueChecks(queue(rows, genesisAt), { issues, seats, exempt: new Set(exempt), now: NOW, isPidAlive });
|
|
const ids = list => list.map(f => [f.check, f.row, f.issue]);
|
|
|
|
test('a done row whose closing issue is open is a violation; a row that is not done is not', () => {
|
|
const rows = [row(1, { state: 'done', issues: [101], closes: [101] }), row(2, { state: 'briefed', issues: [102], closes: [102] })];
|
|
const open = run(rows, { issues: states({ 101: 'open', 102: 'open' }) });
|
|
assert.deepEqual(ids(open.violations), [['issue-open', 1, 101]]);
|
|
assert.equal(open.result, 'fail');
|
|
const closed = run(rows, { issues: states({ 101: 'closed', 102: 'open' }) });
|
|
assert.deepEqual([closed.violations, closed.undecided, closed.dispositions], [[], [], []]);
|
|
assert.equal(closed.result, 'reduced pass');
|
|
});
|
|
|
|
test('an issue several rows close is expected closed only once all of them are done', () => {
|
|
const rows = [row(1, { state: 'done', issues: [200], closes: [200] }), row(2, { state: 'in-progress', owner: 'bob', issues: [200], closes: [200] })];
|
|
const exempt = ['bob'];
|
|
assert.deepEqual(ids(run(rows, { issues: states({ 200: 'open' }), exempt }).violations), []);
|
|
const early = run(rows, { issues: states({ 200: 'closed' }), exempt });
|
|
assert.deepEqual(ids(early.dispositions), [['issue-closed-early', 2, 200]]);
|
|
assert.match(early.dispositions[0].message, /^#200 is closed, but row 2 that closes it is not done$/);
|
|
assert.equal(early.result, 'reduced pass', 'a disposition is printed, not counted');
|
|
const both = [rows[0], { ...rows[1], state: 'done' }];
|
|
assert.deepEqual(ids(run(both, { issues: states({ 200: 'open' }) }).violations), [['issue-open', 1, 200], ['issue-open', 2, 200]]);
|
|
// Rows 9 to 12 name #1508 without closing it: done while it is open is fine.
|
|
const named = [row(9, { state: 'done', issues: [1508], closes: [] }), row(13, { state: 'briefed', issues: [1508], closes: [1508] })];
|
|
const r = run(named, { issues: states({ 1508: 'open' }) });
|
|
assert.deepEqual([r.violations, r.result], [[], 'reduced pass']);
|
|
});
|
|
|
|
test('closure needs positive evidence: unknown is undecided, and so is a skipped or short issue check', () => {
|
|
const rows = [row(1, { state: 'done', issues: [101], closes: [101] })];
|
|
const unknown = run(rows, { issues: states({ 101: 'unknown' }) });
|
|
assert.deepEqual([ids(unknown.undecided), unknown.result], [[['issue-unknown', 1, 101]], 'incomplete']);
|
|
assert.match(unknown.undecided[0].message, /whether #101 is closed is unknown \(lookup failed\)/);
|
|
const skipped = run(rows, { issues: null });
|
|
assert.deepEqual([ids(skipped.undecided), skipped.result, skipped.issueChecks], [[['issues-not-run', null, null]], 'incomplete', { run: false }]);
|
|
// A full page is undecided only while some issue a row closes is unknown.
|
|
const full = run(rows, { issues: states({ 101: 'closed' }, { openListFull: true }) });
|
|
assert.deepEqual([full.undecided, full.result], [[], 'reduced pass']);
|
|
const short = run([...rows, row(2, { issues: [102], closes: [102] })], { issues: states({ 101: 'closed', 102: 'unknown' }, { openListFull: true }) });
|
|
assert.deepEqual([ids(short.undecided), short.result], [[['open-list-full', null, null]], 'incomplete']);
|
|
assert.match(short.undecided[0].message, /full page of 50, and #102 has no known state$/);
|
|
});
|
|
|
|
test('each owner of an in-progress or in-review row gets one liveness class', t => {
|
|
const other = '/srv/another-clone';
|
|
const seats = seatsWith(t, [['present', 101], ['gone', 102], ['nopid', null], ['elsewhere', 103, other], ['exempted', 104]]);
|
|
mkdirSync(path.join(seats, 'repo', 'broken'), { recursive: true });
|
|
writeFileSync(path.join(seats, 'repo', 'broken', 'registration.json'), '{not json');
|
|
const owners = ['present', 'gone', 'nopid', 'elsewhere', 'exempted', 'broken', 'absent'];
|
|
const rows = [...owners.map((owner, i) => row(i + 1, { owner, state: i % 2 ? 'in-review' : 'in-progress' })),
|
|
row(20, { owner: 'idle', state: 'briefed' }), row(21, { owner: 'finished', state: 'done' }), row(22, { owner: 'present', state: 'in-progress' })];
|
|
const r = run(rows, { seats, exempt: ['exempted'], isPidAlive: pid => pid !== 102 });
|
|
assert.deepEqual(r.liveness.seats.map(s => [s.seat, s.class, s.rows]), [
|
|
['absent', 'missing', [7]], ['broken', 'invalid', [6]], ['elsewhere', 'missing', [4]], ['exempted', 'exempt', [5]],
|
|
['gone', 'pid-gone', [2]], ['nopid', 'pid-unknown', [3]], ['present', 'pid-present', [1, 22]],
|
|
]);
|
|
assert.deepEqual(r.liveness.counts, { 'pid-present': 1, exempt: 1, 'pid-unknown': 1, missing: 2, invalid: 1, 'pid-gone': 1 });
|
|
assert.deepEqual(ids(r.violations).sort(), [['owner-invalid', 6, null], ['owner-missing', 4, null], ['owner-missing', 7, null], ['owner-pid-gone', 2, null]].sort());
|
|
assert.deepEqual(ids(r.undecided), [['owner-pid-unknown', 3, null]]);
|
|
assert.match(r.violations.find(f => f.row === 4).message, /another checkout/);
|
|
// Without a readable config there is no seats directory: invalid, never missing.
|
|
assert.deepEqual(classifySeat('present', { seats: null, canonicalRoot: ROOT, exempt: new Set() }).class, 'invalid');
|
|
// Nothing active, nothing checked: exempt seats with no rows add no count.
|
|
const idle = run([row(1)], { seats, exempt: ['present'] });
|
|
assert.deepEqual([idle.liveness.seats, idle.liveness.exempt, idle.result], [[], ['present'], 'reduced pass']);
|
|
});
|
|
|
|
test('a required row not done after 14 days is a violation; a legacy row uses genesis as its lower bound', () => {
|
|
const since = days => new Date(NOW - days * DAY).toISOString().slice(0, 10);
|
|
const rows = [
|
|
row(1, { required: true, requiredSince: since(15) }), row(2, { required: true, requiredSince: since(14) }),
|
|
row(3, { required: true, requiredSince: since(40), state: 'done' }), row(4, { required: false, state: 'waiting-on-jason' }),
|
|
];
|
|
const r = run(rows);
|
|
assert.deepEqual(ids(r.violations), [['age', 1, null]]);
|
|
assert.match(r.violations[0].message, /^row 1 "Row 1" is required and not done, 15 days since \d{4}-\d{2}-\d{2}$/);
|
|
const legacy = [row(5, { required: true, requiredSince: 'unknown', state: 'waiting-on-jason' })];
|
|
const old = run(legacy, { genesisAt: new Date(NOW - 20 * DAY).toISOString() });
|
|
assert.deepEqual(ids(old.violations), [['age', 5, null]]);
|
|
assert.match(old.violations[0].message, /age ≥ 20 days \(legacy lower bound\)$/);
|
|
const edge = run(legacy, { genesisAt: new Date(NOW - 14 * DAY).toISOString() });
|
|
assert.deepEqual([ids(edge.violations), ids(edge.undecided)], [[], [['age-unknown', 5, null]]], 'exactly 14 days is not over 14');
|
|
const young = run(legacy, { genesisAt: new Date(NOW - 3 * DAY).toISOString() });
|
|
assert.deepEqual([ids(young.undecided), young.result], [[['age-unknown', 5, null]], 'incomplete']);
|
|
});
|
|
|
|
test('an ISO requiredSince, as `set required` writes it, ages from its UTC day; one that does not parse is a violation', () => {
|
|
const at = ms => new Date(ms).toISOString();
|
|
const rows = [
|
|
row(1, { required: true, requiredSince: at(NOW - 15 * DAY) }), row(2, { required: true, requiredSince: at(NOW - 14 * DAY) }),
|
|
// 23:59Z fifteen days back is still that day: 15 whole days, not 14.
|
|
row(3, { required: true, requiredSince: '2026-09-13T23:59:59.999Z' }),
|
|
];
|
|
const r = run(rows);
|
|
assert.deepEqual(ids(r.violations), [['age', 1, null], ['age', 3, null]]);
|
|
assert.match(r.violations[0].message, /^row 1 "Row 1" is required and not done, 15 days since 2026-09-13T09:00:00\.000Z$/);
|
|
// The queue validator checks the shape, not the calendar.
|
|
const bad = run([row(4, { required: true, requiredSince: '2026-13-01T00:00:00.000Z' })]);
|
|
assert.deepEqual([ids(bad.violations), bad.result], [[['age-invalid', 4, null]], 'fail']);
|
|
assert.match(bad.violations[0].message, /^row 4 is required and not done, and its requiredSince "2026-13-01T00:00:00\.000Z" is not a date$/);
|
|
});
|
|
|
|
test('the text section always ends in a count and a result, and never prints a full pass', () => {
|
|
const clean = formatQueue(run([row(1)]));
|
|
assert.deepEqual(clean.slice(-4), ['queue issue checks: open list, 0 lookups', 'protected changes in range: 0 (not checks; confirm the actors)', 'queue: 0 violations; result reduced pass', '']);
|
|
assert.ok(clean.includes('liveness: 0 pid-present (unverified), 0 exempt, 0 pid-unknown, 0 missing, 0 invalid, 0 pid-gone'));
|
|
const bad = formatQueue(run([row(1, { state: 'done', issues: [101], closes: [101] })], { issues: states({ 101: 'open' }, { lookups: 1 }) }));
|
|
assert.ok(bad.includes('violation issue-open row 1 #101: row 1 is done and every row that closes #101 is done, but #101 is open'));
|
|
assert.deepEqual(bad.slice(-4), ['queue issue checks: open list, 1 lookup', 'protected changes in range: 0 (not checks; confirm the actors)', 'queue: 1 violation; result fail', '']);
|
|
assert.deepEqual(formatQueue({ checked: false }), ['Queue: not checked (--no-queue)']);
|
|
assert.ok(formatQueue(run([row(1)], { issues: null })).includes('queue issue checks: not run'));
|
|
});
|
|
|
|
test('pidAlive: a running pid is present, an exited one is gone, and EPERM still means present', () => {
|
|
assert.equal(pidAlive(process.pid), true);
|
|
const child = spawnSync(process.execPath, ['-e', 'process.stdout.write(String(process.pid))'], { encoding: 'utf8' });
|
|
assert.equal(pidAlive(Number(child.stdout)), false);
|
|
// pid 1 belongs to root: signal 0 from another user fails with EPERM.
|
|
if (process.getuid() !== 0) assert.equal(pidAlive(1), true);
|
|
});
|
|
|
|
// A fake helper that answers the open list from OPEN, an issue from
|
|
// ISSUE_DIR/N.json (exit 1 when absent, like a 404), and logs every call.
|
|
function fakeTool(t) {
|
|
const dir = mkdtempSync(path.join(os.tmpdir(), 'ledger-fake-'));
|
|
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
|
const tool = path.join(dir, 'gitea-api.sh');
|
|
writeFileSync(tool, `#!/usr/bin/env node
|
|
const fs = require('fs'), p = process.argv[3];
|
|
fs.appendFileSync(process.env.CALLS, JSON.stringify(process.argv.slice(2)) + '\\n');
|
|
if (process.env.API_FAIL) { console.error('secret must not escape'); process.exit(Number(process.env.API_FAIL)); }
|
|
if (p.includes('state=open')) { if (process.env.OPEN_FAIL) process.exit(Number(process.env.OPEN_FAIL)); process.stdout.write(fs.readFileSync(process.env.OPEN, 'utf8')); }
|
|
else if (/issues\\/\\d+$/.test(p)) { const f = process.env.ISSUE_DIR + '/' + p.split('/').pop() + '.json'; if (!fs.existsSync(f)) process.exit(1); process.stdout.write(fs.readFileSync(f, 'utf8')); }
|
|
else process.stdout.write(fs.readFileSync(process.env.ISSUES, 'utf8'));
|
|
`);
|
|
chmodSync(tool, 0o755);
|
|
mkdirSync(path.join(dir, 'issues'));
|
|
const env = { CALLS: path.join(dir, 'calls.jsonl'), OPEN: path.join(dir, 'open.json'), ISSUE_DIR: path.join(dir, 'issues'), ISSUES: path.join(dir, 'metric.json') };
|
|
writeFileSync(env.CALLS, ''); writeFileSync(env.OPEN, '[]'); writeFileSync(env.ISSUES, '[]');
|
|
const issue = (n, over = {}) => writeFileSync(path.join(env.ISSUE_DIR, `${n}.json`), JSON.stringify({ number: n, title: `Issue ${n}`, state: 'closed', ...over }));
|
|
const calls = () => readFileSync(env.CALLS, 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l)[1]);
|
|
return { dir, tool, env, issue, calls };
|
|
}
|
|
const openIssue = n => ({ number: n, title: `Issue ${n}`, state: 'open' });
|
|
|
|
test('issue states: open list first, then the metric page, then at most 10 lookups', t => {
|
|
const f = fakeTool(t);
|
|
Object.assign(process.env, f.env);
|
|
t.after(() => { for (const k of Object.keys(f.env)) delete process.env[k]; });
|
|
writeFileSync(f.env.OPEN, JSON.stringify([openIssue(1), { ...openIssue(2), pull_request: {} }]));
|
|
const closes = [1, 2, 3, ...Array.from({ length: 12 }, (_, i) => 10 + i)];
|
|
const rows = closes.map(n => row(n, { issues: [n], closes: [n] }));
|
|
f.issue(10); f.issue(11, { state: 'open' }); f.issue(12, { pull_request: { merged: true } }); f.issue(13, { number: 99 });
|
|
// #10 is on the metric page but not closed there, so it still needs a lookup.
|
|
// #11 was reopened: a closed_at without state closed is no evidence either,
|
|
// and neither is state closed without a closed_at (#12).
|
|
const metric = [{ number: 3, state: 'closed', closed_at: '2026-09-20T00:00:00Z' }, { number: 10, state: 'open', closed_at: null },
|
|
{ number: 11, state: 'open', closed_at: '2026-09-21T00:00:00Z' }, { number: 12, state: 'closed', closed_at: null }];
|
|
const r = issueStates('/', rows, metric, f.tool);
|
|
const state = n => r.states.get(n);
|
|
assert.deepEqual([state(1).state, state(3), state(10), state(11).state], ['open', { state: 'closed', detail: 'metric page' }, { state: 'closed', detail: 'lookup' }, 'open']);
|
|
assert.deepEqual(state(2), { state: 'unknown', detail: 'lookup failed' });
|
|
assert.deepEqual([state(12).detail, state(13).detail, state(14).detail], ['the number is a pull request', 'lookup returned no issue record', 'lookup failed']);
|
|
assert.equal(r.lookups, LOOKUP_BUDGET);
|
|
assert.deepEqual([state(20), state(21)], [{ state: 'unknown', detail: 'over the lookup budget' }, { state: 'unknown', detail: 'over the lookup budget' }]);
|
|
// The open list is one call; a pull request in it is not an issue, so #2 is looked up.
|
|
const calls = f.calls();
|
|
assert.equal(calls.length, 1 + LOOKUP_BUDGET);
|
|
assert.match(calls[0], /^repos\/mosaicstack\/stack\/issues\?state=open&type=issues&limit=50&page=1$/);
|
|
assert.deepEqual(calls.slice(1), [2, 10, 11, 12, 13, 14, 15, 16, 17, 18].map(n => `repos/mosaicstack/stack/issues/${n}`));
|
|
assert.equal(r.openListFull, false);
|
|
writeFileSync(f.env.OPEN, JSON.stringify(Array.from({ length: 50 }, (_, i) => openIssue(500 + i))));
|
|
assert.equal(issueStates('/', [row(1, { issues: [500], closes: [500] })], null, f.tool).openListFull, true);
|
|
});
|
|
|
|
test('a full open list: lookups settle what it leaves out, and only an unsettled issue keeps it undecided', t => {
|
|
const f = fakeTool(t);
|
|
Object.assign(process.env, f.env);
|
|
t.after(() => { for (const k of Object.keys(f.env)) delete process.env[k]; });
|
|
writeFileSync(f.env.OPEN, JSON.stringify(Array.from({ length: 50 }, (_, i) => openIssue(500 + i))));
|
|
const check = rows => queueChecks(queue(rows), { issues: issueStates('/', rows, null, f.tool), seats: null, now: NOW });
|
|
// Every wanted issue resolved: #600 closed by lookup, #501 open on the page for a pending row.
|
|
f.issue(600);
|
|
const resolved = check([row(1, { state: 'done', issues: [600], closes: [600] }), row(2, { issues: [501], closes: [501] })]);
|
|
assert.deepEqual([resolved.issueChecks, resolved.undecided, resolved.violations, resolved.result], [{ run: true, lookups: 1, openListFull: true }, [], [], 'reduced pass']);
|
|
assert.ok(formatQueue(resolved).includes('queue issue checks: open list (full page), 1 lookup'));
|
|
// An open issue off the page is found by lookup, and its done row fails.
|
|
f.issue(601, { state: 'open' });
|
|
const offPage = check([row(1, { state: 'done', issues: [601], closes: [601] })]);
|
|
assert.deepEqual([ids(offPage.violations), offPage.undecided, offPage.result], [[['issue-open', 1, 601]], [], 'fail']);
|
|
// Past the budget an issue stays unknown, and the full page stays undecided.
|
|
const many = Array.from({ length: LOOKUP_BUDGET + 1 }, (_, i) => 700 + i);
|
|
for (const n of many) f.issue(n);
|
|
const budget = check(many.map(n => row(n, { state: 'done', issues: [n], closes: [n] })));
|
|
assert.deepEqual(ids(budget.undecided), [['issue-unknown', 710, 710], ['open-list-full', null, null]]);
|
|
assert.match(budget.undecided[0].message, /is unknown \(over the lookup budget\)$/);
|
|
assert.equal(budget.result, 'incomplete');
|
|
});
|
|
|
|
test('the open list refuses on a failed call or a bad record, and never echoes the helper', t => {
|
|
const f = fakeTool(t);
|
|
Object.assign(process.env, f.env);
|
|
t.after(() => { for (const k of [...Object.keys(f.env), 'OPEN_FAIL', 'API_FAIL']) delete process.env[k]; });
|
|
const rows = [row(1, { issues: [1], closes: [1] })];
|
|
const refuses = (pattern) => assert.throws(() => issueStates('/', rows, null, f.tool), e => e.exitCode === 2 && pattern.test(e.message) && !/secret/.test(e.message));
|
|
process.env.API_FAIL = '3'; refuses(/credentials missing, unreadable, or invalid/); delete process.env.API_FAIL;
|
|
process.env.OPEN_FAIL = '1'; refuses(/credential or Gitea request failure/); delete process.env.OPEN_FAIL;
|
|
for (const bad of ['nope', '{}', '[{"number":0,"state":"open"}]', '[{"number":1,"state":"merged"}]', '[{"number":1,"state":"closed"}]']) {
|
|
writeFileSync(f.env.OPEN, bad);
|
|
refuses(/Open issues unavailable/);
|
|
}
|
|
});
|
|
|
|
test('a helper call past the deadline is killed with its child, and the call reports it', t => {
|
|
const dir = mkdtempSync(path.join(os.tmpdir(), 'ledger-slow-'));
|
|
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
|
// The helper starts a child, as gitea-api.sh starts curl, and both hang.
|
|
const tool = path.join(dir, 'gitea-api.sh');
|
|
writeFileSync(tool, `#!/usr/bin/env node
|
|
const { spawn } = require('child_process');
|
|
const child = spawn('sleep', ['30'], { stdio: 'ignore' });
|
|
require('fs').appendFileSync('${dir}/pids', child.pid + '\\n');
|
|
setTimeout(() => {}, 30000);
|
|
`);
|
|
chmodSync(tool, 0o755);
|
|
const started = Date.now();
|
|
assert.throws(() => readOpenIssues('/', tool, 1), e => e.exitCode === 2 && /^Open issues unavailable: no answer within 1 s; use --no-issues/.test(e.message));
|
|
assert.deepEqual(lookupIssue('/', 5, tool, 1), { state: 'unknown', detail: 'lookup failed' });
|
|
assert.ok(Date.now() - started < 10000, 'each call returns at its deadline');
|
|
const pids = readFileSync(path.join(dir, 'pids'), 'utf8').split('\n').filter(Boolean).map(Number);
|
|
assert.equal(pids.length, 2);
|
|
// Give init a moment to reap the killed children.
|
|
const until = Date.now() + 3000;
|
|
while (pids.some(pidAlive) && Date.now() < until) spawnSync('sleep', ['0.1']);
|
|
assert.deepEqual(pids.filter(pidAlive), [], 'no child outlives the call');
|
|
assert.throws(() => readOpenIssues('/', path.join(dir, 'absent'), 1), /Open issues unavailable: gitea-api\.sh unavailable;/);
|
|
});
|
|
|
|
test('readQueue loads queue.json through the queue validator and refuses anything else', t => {
|
|
const repo = scratchRepo(t);
|
|
genesisCommitted(repo);
|
|
const q = readQueue(repo.root);
|
|
assert.deepEqual([q.revision, q.canonicalRoot, q.rows.map(r => r.id)], [0, repo.root, [1, 6, 8, 9, 11]]);
|
|
assert.match(q.genesisAt, /^\d{4}-\d{2}-\d{2}T/);
|
|
const text = readFileSync(repo.queuePath, 'utf8');
|
|
writeFileSync(repo.queuePath, text.replace('"state": "briefed"', '"state": "done"'));
|
|
assert.throws(() => readQueue(repo.root), /Queue unavailable: .*hand edit/);
|
|
rmSync(repo.queuePath);
|
|
assert.throws(() => readQueue(repo.root), /missing or not a regular file; use --no-queue/);
|
|
writeFileSync(path.join(repo.base, 'elsewhere.json'), text);
|
|
symlinkSync(path.join(repo.base, 'elsewhere.json'), repo.queuePath);
|
|
assert.throws(() => readQueue(repo.root), /missing or not a regular file/);
|
|
});
|
|
|
|
test('protected changes list every in-range entry that changes a required or parked row', t => {
|
|
const repo = scratchRepo(t);
|
|
genesisCommitted(repo);
|
|
// Row 9 is required, row 6 is neither required nor parked.
|
|
for (const [id, op] of [[9, 'note-row9-0001'], [6, 'note-row6-0001']]) {
|
|
const r = queueCli(repo, ['note', String(id), 'text', '--op', op], { by: 'darkwing' });
|
|
assert.equal(r.code, 0, r.err);
|
|
}
|
|
// Unparking row 8 leaves it neither parked nor required: the row before
|
|
// the entry makes it protected.
|
|
const unpark = queueCli(repo, ['move', '8', 'queued', '--op', 'move-row8-0001'], { by: 'jason' });
|
|
assert.equal(unpark.code, 0, unpark.err);
|
|
const { log } = readQueue(repo.root);
|
|
const all = protectedChanges(log, { start: 0, end: Infinity });
|
|
assert.deepEqual(all.map(c => [c.rev, c.verb, c.by, c.rows]), [[0, 'genesis', 'sage', [8, 9]], [1, 'note', 'darkwing', [9]], [3, 'move', 'jason', [8]]]);
|
|
assert.equal(all[1].op, 'note-row9-0001');
|
|
// The range includes its start and excludes its end. A range after
|
|
// genesis replays up to its first entry and starts there.
|
|
assert.ok(Date.parse(log[0].at) < Date.parse(log[1].at) && Date.parse(log[1].at) < Date.parse(log[2].at));
|
|
assert.deepEqual(protectedChanges(log, { start: Date.parse(log[1].at), end: Infinity }).map(c => c.rev), [1, 3]);
|
|
assert.deepEqual(protectedChanges(log, { start: 0, end: Date.parse(log[1].at) }).map(c => c.rev), [0]);
|
|
assert.deepEqual(protectedChanges(log, { start: Date.parse(log[1].at), end: Date.parse(log[2].at) }).map(c => c.rev), [1]);
|
|
assert.deepEqual(protectedChanges(log, { start: 0, end: Date.parse(log[0].at) }), []);
|
|
const q = queueChecks(readQueue(repo.root), { issues: null, seats: null, exempt: new Set(['darkwing']), now: NOW, range: { start: 0, end: Infinity } });
|
|
const text = formatQueue(q);
|
|
assert.ok(text.includes('protected change rev 1 note by darkwing at ' + log[1].at + ': row 9'));
|
|
assert.ok(text.includes('protected changes in range: 3 (not checks; confirm the actors)'));
|
|
assert.equal(q.result, 'incomplete', 'the list is not a finding');
|
|
});
|
|
|
|
// A scratch repository with a committed queue, the ledger and the seat code,
|
|
// a config whose dataRoot holds the registrations, and a fake helper.
|
|
const MAP = [
|
|
{ id: 1, piece: 'Closed and done', owner: 'darkwing', issues: [101], closes: [101], state: 'done', required: false, requiredSince: null, brief: null },
|
|
{ id: 2, piece: 'Done but open', owner: 'darkwing', issues: [102], closes: [102], state: 'done', required: false, requiredSince: null, brief: null },
|
|
{ id: 3, piece: 'Active and old', owner: 'dewey', issues: [103], closes: [103], state: 'in-progress', required: true, requiredSince: '2020-01-01', brief: { path: 'docs/plans/brief-a.md', anchor: 'Row six' } },
|
|
{ id: 4, piece: 'Active and registered', owner: 'rocko', issues: [103], closes: [], state: 'in-progress', required: false, requiredSince: null, brief: { path: 'docs/plans/brief-a.md', anchor: 'Row six' } },
|
|
].map(r => {
|
|
const full = { previousState: null, gate: 'g', gateOwner: 'jason', after: [], reviewers: [], note: null, blockedReason: null, createdAt: '2026-09-13', ...r };
|
|
// The map wants its keys in the queue's order.
|
|
return Object.fromEntries(Object.keys(MAP_ROWS[0]).map(k => [k, full[k]]));
|
|
});
|
|
function cliFixture(t, rows = MAP) {
|
|
const repo = scratchRepo(t, { map: mapText(rows, { retired: [], highWater: Math.max(...rows.map(r => r.id)) }) });
|
|
genesisCommitted(repo);
|
|
for (const pkg of ['ledger', 'seat']) cpSync(path.join(PACKAGES, pkg, 'src'), path.join(repo.root, 'packages', pkg, 'src'), { recursive: true });
|
|
const dataRoot = path.join(repo.base, 'data');
|
|
const config = path.join(repo.base, 'config.json');
|
|
writeFileSync(config, JSON.stringify({ dataRoot }));
|
|
const f = fakeTool(t);
|
|
f.issue(101);
|
|
writeFileSync(f.env.OPEN, JSON.stringify([openIssue(102), openIssue(103)]));
|
|
writeFileSync(f.env.ISSUES, '[]');
|
|
const seats = path.join(dataRoot, 'seats');
|
|
mkdirSync(seats, { recursive: true });
|
|
writeRegistration(seats, { version: 1, seat: 'rocko', project: 'repo', task: '', workspace: repo.root, tmux: null, harness: 'pi', startedAt: '2026-09-27T00:00:00.000Z',
|
|
pid: process.pid, sessionsDir: path.join(repo.root, '.pi/state/rocko/sessions'), seatDir: path.join(repo.root, 'agents/rocko'), launchScript: path.join(repo.root, 'agents/rocko/launch.sh'), layout: 'repo', updatedAt: null });
|
|
const run = (args = [], env = {}, { until = '2026-09-26' } = {}) => {
|
|
writeFileSync(f.env.CALLS, '');
|
|
const r = spawnSync(process.execPath, [path.join(repo.root, 'packages/ledger/src/cli.mjs'), '--since', '2026-09-20', '--until', until, '--no-t3', ...args],
|
|
{ cwd: repo.root, encoding: 'utf8', env: { ...repo.env, PATH: `${f.dir}:${process.env.PATH}`, MOSAIC_CONFIG: config, ...f.env, ...env } });
|
|
return { ...r, calls: f.calls() };
|
|
};
|
|
return { repo, f, run, config };
|
|
}
|
|
|
|
test('the CLI prints the queue section above the weekly table and under a queue key in --json', t => {
|
|
const { run } = cliFixture(t);
|
|
const text = run(['--unsupported-runtime', 'dewey']);
|
|
assert.equal(text.status, 0, text.stderr);
|
|
const lines = text.stdout.split('\n');
|
|
assert.match(lines[0], /^Queue checks: queue\.json revision 0, as of \d{4}-/);
|
|
assert.ok(lines.indexOf('queue: 2 violations; result fail') < lines.findIndex(l => l.startsWith('Ledger 2026-09-20')));
|
|
assert.ok(lines.includes('violation issue-open row 2 #102: row 2 is done and every row that closes #102 is done, but #102 is open'));
|
|
assert.ok(lines.some(l => /^violation age row 3: row 3 "Active and old" is required and not done, \d+ days since 2020-01-01$/.test(l)));
|
|
assert.ok(lines.includes('owner dewey (row 3): exempt, declared with --unsupported-runtime'));
|
|
assert.ok(lines.includes('owner rocko (row 4): pid-present, pid present (identity not verified)'));
|
|
assert.ok(lines.includes('liveness: 1 pid-present (unverified), 1 exempt, 0 pid-unknown, 0 missing, 0 invalid, 0 pid-gone'));
|
|
assert.ok(lines.includes('queue issue checks: open list, 1 lookup'));
|
|
// Metric page, open list, one lookup for #101.
|
|
assert.deepEqual(text.calls.map(c => c.replace(/\?.*/, '?')), ['repos/mosaicstack/stack/issues?', 'repos/mosaicstack/stack/issues?', 'repos/mosaicstack/stack/issues/101']);
|
|
const json = JSON.parse(run(['--json', '--unsupported-runtime', 'dewey']).stdout);
|
|
assert.deepEqual([json.queue.result, json.queue.violations.map(v => [v.check, v.row, v.issue])], ['fail', [['issue-open', 2, 102], ['age', 3, null]]]);
|
|
// Genesis is dated today, outside the requested week.
|
|
assert.deepEqual(json.queue.protectedChanges, []);
|
|
const week = JSON.parse(run(['--json', '--no-issues'], {}, { until: new Date().toISOString().slice(0, 10) }).stdout).queue;
|
|
assert.deepEqual(week.protectedChanges.map(c => [c.rev, c.verb, c.rows]), [[0, 'genesis', [3]]]);
|
|
// Without the declaration, dewey has no registration.
|
|
const missing = JSON.parse(run(['--json']).stdout).queue;
|
|
assert.deepEqual(missing.violations.map(v => v.check), ['issue-open', 'owner-missing', 'age']);
|
|
});
|
|
|
|
test('a queue with nothing wrong prints 0 violations and a reduced pass, never a full pass', t => {
|
|
const { run } = cliFixture(t, [MAP[0], MAP[3]]);
|
|
const r = run();
|
|
assert.equal(r.status, 0, r.stderr);
|
|
const lines = r.stdout.split('\n');
|
|
assert.ok(lines.includes('queue: 0 violations; result reduced pass'));
|
|
assert.ok(lines.includes('liveness: 1 pid-present (unverified), 0 exempt, 0 pid-unknown, 0 missing, 0 invalid, 0 pid-gone'));
|
|
assert.ok(!lines.some(l => /^(violation|undecided|disposition) /.test(l)));
|
|
assert.equal(r.calls.length, 3, 'metric page, open list, one lookup');
|
|
});
|
|
|
|
test('--no-issues makes no call and leaves the issue checks undecided; --no-queue skips the section', t => {
|
|
const { run } = cliFixture(t);
|
|
const none = run(['--no-issues', '--json']);
|
|
assert.equal(none.status, 0, none.stderr);
|
|
assert.deepEqual(none.calls, []);
|
|
assert.deepEqual(JSON.parse(none.stdout).queue.issueChecks, { run: false });
|
|
const skipped = run(['--no-queue']);
|
|
assert.equal(skipped.status, 0, skipped.stderr);
|
|
assert.match(skipped.stdout, /^Queue: not checked \(--no-queue\)\nLedger 2026-09-20/);
|
|
assert.equal(skipped.calls.length, 1, 'the metric call only');
|
|
assert.deepEqual(JSON.parse(run(['--no-queue', '--json']).stdout).queue, { checked: false });
|
|
});
|
|
|
|
test('the CLI refuses a bad queue before any call, and a failed open list with exit 2', t => {
|
|
const { repo, run } = cliFixture(t);
|
|
const refusedOpen = run([], { OPEN_FAIL: '1' });
|
|
assert.deepEqual([refusedOpen.status, refusedOpen.stdout], [2, '']);
|
|
assert.match(refusedOpen.stderr, /^Open issues unavailable: credential or Gitea request failure; use --no-issues/);
|
|
const text = readFileSync(repo.queuePath, 'utf8');
|
|
writeFileSync(repo.queuePath, text.replace('"Done but open"', '"Done, hand edited"'));
|
|
const edited = run();
|
|
assert.deepEqual([edited.status, edited.stdout, edited.calls], [1, '', []]);
|
|
assert.match(edited.stderr, /^Queue unavailable: /);
|
|
rmSync(repo.queuePath);
|
|
const gone = run();
|
|
assert.deepEqual([gone.status, gone.calls], [1, []]);
|
|
assert.match(gone.stderr, /use --no-queue to skip the queue section/);
|
|
});
|
|
|
|
test('--unsupported-runtime repeats once per seat and takes a seat name', t => {
|
|
const { run } = cliFixture(t);
|
|
const two = JSON.parse(run(['--json', '--no-issues', '--unsupported-runtime', 'dewey', '--unsupported-runtime', 'rocko']).stdout).queue;
|
|
assert.deepEqual([two.liveness.exempt, two.liveness.counts.exempt], [['dewey', 'rocko'], 2]);
|
|
for (const [args, message] of [
|
|
[['--unsupported-runtime', 'dewey', '--unsupported-runtime', 'dewey'], /^Duplicate --unsupported-runtime dewey\n$/],
|
|
[['--unsupported-runtime'], /needs a seat name/],
|
|
[['--unsupported-runtime', '--json'], /needs a seat name/],
|
|
[['--unsupported-runtime', 'Dewey'], /needs a seat name/],
|
|
[['--no-queue', '--unsupported-runtime', 'dewey'], /cannot be combined/],
|
|
]) {
|
|
const r = run(args);
|
|
assert.equal(r.status, 1, args.join(' '));
|
|
assert.match(r.stderr, message);
|
|
assert.deepEqual([r.stdout, r.calls], ['', []]);
|
|
}
|
|
});
|
|
|
|
test('an unreadable config makes every owner invalid instead of passing them', t => {
|
|
const { run } = cliFixture(t);
|
|
const r = JSON.parse(run(['--json', '--no-issues'], { MOSAIC_CONFIG: '/nonexistent/config.json' }).stdout).queue;
|
|
assert.deepEqual(r.liveness.seats.map(s => [s.seat, s.class]), [['dewey', 'invalid'], ['rocko', 'invalid']]);
|
|
assert.equal(r.result, 'fail');
|
|
});
|