feat(ledger): Piece E, queue section in the weekly ledger (row 13, #1508)

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]>
This commit is contained in:
2026-09-27 11:33:44 -05:00
co-authored by Claude Opus 5.5
parent 2333d837e2
commit fd72d26899
9 changed files with 1421 additions and 20 deletions
+114 -10
View File
@@ -2,7 +2,9 @@
Read-only counts from local `refactor` commit subjects, one Gitea issue-list
request through `scripts/gitea-api.sh`, repo seats' Pi session logs, and T3's
thread messages in `~/.t3/userdata/state.sqlite`.
thread messages in `~/.t3/userdata/state.sqlite`. Above the counts, a queue
section checks `docs/plans/queue.json` against Gitea and the seat
registrations (see "Queue section").
No board changes, data-root writes, fleet reads, transcript output, or scheduler.
```sh
@@ -11,6 +13,8 @@ node packages/ledger/src/cli.mjs --since 2026-09-06 --until 2026-09-12 --json
node packages/ledger/src/cli.mjs --since 2026-09-06 --no-issues
node packages/ledger/src/cli.mjs --since 2026-09-06 --no-t3
node packages/ledger/src/cli.mjs --since 2026-09-06 --t3-db /tmp/fixture.sqlite
node packages/ledger/src/cli.mjs --since 2026-09-06 --no-queue
node packages/ledger/src/cli.mjs --since 2026-09-06 --unsupported-runtime dewey
node --test packages/ledger/tests/
```
@@ -125,27 +129,127 @@ directory means no Pi seats ran here; a missing T3 database means the path or
T3 changed, so it refuses instead of counting zero. Error messages name ids
and paths, never message text.
## One Gitea call and missing evidence
## Queue section
The client requests issues updated since the start date, all states, first page,
limit 50. This includes issues closed in range, even if later updated. Gitea caps
The rules come from plan 8.10 in
`agents/filbert/work/queue-as-data-plan-2026-09-26.md` (Piece E, #1508). The
section is on by default and prints above the weekly table; `--json` puts it
under `queue`. `--no-queue` skips it and prints `Queue: not checked
(--no-queue)`. The ledger reads `docs/plans/queue.json` through the queue's own
validator before any Gitea call, so a missing, symlinked or hand-edited file
exits 1 and costs no call. It writes nothing, takes no queue lock and never
changes a row.
Four checks, each finding named by (check, row, issue):
- **Issues.** An issue is expected closed once every row whose `closes`
includes it is done (J6). Then an open issue is `issue-open` on each of
those rows. A row that names an issue in `issues` but not in `closes` is
never checked against it, so rows 9 to 12 can be done while #1508 is open.
A closed issue with a row that closes it still pending is printed as a
`disposition` for the lead. It is not a violation.
- **Owners.** Each owner of an `in-progress` or `in-review` row gets one
liveness class from its `repo` registration in `<dataRoot>/seats`:
- `exempt`: declared on this run with `--unsupported-runtime SEAT`.
- `missing`: no registration, or one written for another checkout.
- `invalid`: the registration fails validation, or the config that names
the data root can't be read. A malformed or absent pid fails validation,
so it lands here, not in `pid-unknown`.
- `pid-unknown`: a valid registration with a null pid.
- `pid-gone`: the recorded pid is not running.
- `pid-present`: the pid is running. A registration holds no process-start
identity and a reused pid looks the same, so this reads "pid present
(identity not verified)".
The coverage line counts them: `liveness: N pid-present (unverified), N
exempt, N pid-unknown, N missing, N invalid, N pid-gone`.
- **Age.** A required row that is not done and whose `requiredSince` is more
than 14 whole days before the run is listed by name. `requiredSince` is a
date at genesis and an ISO time once `set required` or `add --required`
writes it; both count from 00:00Z of their UTC day. A value that doesn't
parse as a date is an `age-invalid` violation. A legacy row with
`requiredSince: "unknown"` was required no later than genesis, so once
genesis is more than 14 days old it is listed as `age ≥ N days (legacy lower
bound)`. Before that its age is undecided. Age runs to the time of the run,
not to `--until`.
- **Protected changes.** Every log entry dated inside the report range that
changes a required or parked row is listed with its revision, verb, claimed
actor and rows. The queue trusts `--by` (its README, "Trust boundary"), so
this list is how a wrong claim gets seen. It is not a check and never
changes the result. Jason confirms the actors weekly.
Every run ends with `queue: N violations; result R`, where R is one of three:
- `fail`: any violation. That is an open issue, a `missing`, `invalid` or
`pid-gone` owner, or an age.
- `incomplete`: no violation, but something undecided. That is an issue
whose state is unknown, a full open-issue page with some issue left
unknown, issue checks not run, a
`pid-unknown` owner, or a legacy age before genesis is 14 days old.
- `reduced pass`: nothing known and nothing undecided. There is no full pass,
because no owner's process identity is ever verified.
The exit code stays 0 whenever a report was computed; the result is in the
text and the JSON.
### Queue issue calls
The metric call can't answer "is this issue closed", because an issue nobody
touched this week isn't in it. So the queue checks have their own budget, per
plan 8.10:
| Calls | Purpose |
|---|---|
| 1 | `state=open`, issues only, limit 50, one page. A full page may be short. Issues missing from it are looked up, so it makes the run `incomplete` only while some issue a row closes has no known state (lead decision 40). |
| up to 10 | `GET issues/N` for each issue in some row's `closes` that is neither on the open list nor closed on the metric page. Beyond 10, the issue's state is unknown, "over the lookup budget". |
With the metric call that is at most 12 calls. `--no-issues` makes none and
prints `queue issue checks: not run`. Closed needs positive evidence: an
entry on the metric page with state `closed` and a `closed_at`, or a lookup
returning a closed issue that isn't a pull request. A failed lookup, a 404, a
pull request or a mismatched number leaves the issue unknown. A failed or
malformed open-list call exits 2, like the metric call. Each queue call runs
under `timeout -s KILL 60`, which kills the helper and its curl together.
### Weekly routine
Run the ledger each Monday for the week that ended on Saturday, Sunday
through Saturday as row 7 counts it, with the queue section on and no
`--no-issues`:
```sh
node packages/ledger/src/cli.mjs --since 2026-09-20 --until 2026-09-26
```
Post the dated run on #1508 with its coverage line and result. Any seat
whose runtime writes no registration (T3 seats today) is passed with
`--unsupported-runtime SEAT`, which the output prints. A violation is
remediated when a second dated run on the same UTC day no longer reports its
(check, row, issue), and both runs are posted on #1508. A row edit alone is
not remediation, and a `note` can't clear an owner finding, because that
check reads registrations. Row 13's gate is one Monday run with zero
violations, or every violation remediated that day.
## Gitea calls and missing evidence
The metric client requests issues updated since the start date, all states,
first page, limit 50. This includes issues closed in range, even if later updated. Gitea caps
responses at 50; a full page fails rather than silently reporting partial totals.
Use a narrower range or `--no-issues`, not hidden pagination. A commit-linked
issue not returned by the updated-since query still has a row, with unknown
metadata. This is the cost of the brief's one-call boundary.
Exit 0 means a report was computed. Exit 1 means bad arguments or unreadable git,
session or T3 evidence. Malformed JSONL, including a partially written last line,
Exit 0 means a report was computed. Exit 1 means bad arguments, a queue.json
the validator refuses, or unreadable git, session or T3 evidence. Malformed JSONL, including a partially written last line,
refuses the report; rerun after the seat finishes writing. Exit 2 means issue
credentials, API, payload, or completeness failure. The CLI never prints API
error bodies or reads authentication files itself. `--no-issues` makes no API
call, keeps commit-derived rows, and shows unknown issue metadata, closed counts,
error bodies or reads authentication files itself. The queue's open-list call
fails with exit 2 the same way. `--no-issues` makes no API call, keeps commit-derived rows, and shows unknown issue metadata, closed counts,
median duration, and human-per-closed ratio. It cannot invent close-only rows.
For fixtures, a fake `gitea-api.sh` can be placed first on PATH. Otherwise the
repository scripts directory is appended to PATH for the issue request.
Tests use only temporary repositories, logs, T3 databases and fake API tools,
with no real credentials or network. Every CLI run in the tests sets `HOME` to
Tests use only temporary repositories, logs, T3 databases, queue files, seat
registrations, configs and fake API tools, with no real credentials or network. Every CLI run in the tests sets `HOME` to
a temporary directory, so no test opens the real `~/.t3`. The helper regression stubs Node before any credential
read and checks successful GET, successful POST, and failed HTTP status.
+23 -5
View File
@@ -3,19 +3,29 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { dateRange, readCommits, readIssues, readSessions, mergeSources, summarize, formatTable, SourceError } from './ledger.mjs';
import { readT3, defaultT3Path } from './t3.mjs';
import { readQueue, readSeatsDir, issueStates, queueChecks, formatQueue, checkSeatName } from './queue-checks.mjs';
const usage = 'Usage: node packages/ledger/src/cli.mjs --since YYYY-MM-DD [--until YYYY-MM-DD] [--json] [--no-issues] [--no-t3 | --t3-db PATH]';
const usage = 'Usage: node packages/ledger/src/cli.mjs --since YYYY-MM-DD [--until YYYY-MM-DD] [--json] [--no-issues] [--no-t3 | --t3-db PATH] [--no-queue | --unsupported-runtime SEAT ...]';
export async function main(args, root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..')) {
let since, until, t3Db, json = false, noIssues = false, noT3 = false;
let since, until, t3Db, json = false, noIssues = false, noT3 = false, noQueue = false;
const exempt = new Set();
const seen = new Set();
for (let i = 0; i < args.length; i++) {
const flag = args[i];
// A runtime is declared unsupported once per seat, so only that flag repeats.
if (flag === '--unsupported-runtime') {
const seat = checkSeatName(args[++i]);
if (exempt.has(seat)) throw new SourceError(`Duplicate --unsupported-runtime ${seat}`);
exempt.add(seat);
continue;
}
if (seen.has(flag)) throw new SourceError(`Duplicate option: ${flag}`);
seen.add(flag);
if (flag === '--help') { console.log(usage); return; }
if (flag === '--json') json = true;
else if (flag === '--no-issues') noIssues = true;
else if (flag === '--no-t3') noT3 = true;
else if (flag === '--no-queue') noQueue = true;
else if (flag === '--t3-db') {
t3Db = args[++i];
if (!t3Db || t3Db.startsWith('--')) throw new SourceError('--t3-db requires a path');
@@ -27,20 +37,28 @@ export async function main(args, root = path.resolve(path.dirname(fileURLToPath(
}
if (!since) throw new SourceError(usage);
if (noT3 && t3Db !== undefined) throw new SourceError('--no-t3 and --t3-db cannot be combined');
if (noQueue && exempt.size) throw new SourceError('--no-queue and --unsupported-runtime cannot be combined');
const range = dateRange(since, until);
// The queue loads before any Gitea call, so a bad queue.json costs none.
const queue = noQueue ? null : readQueue(root);
const commits = readCommits(root, range);
// Fixture tools may be placed first on PATH. The repository client is the
// default without requiring installation or reading auth material here.
const priorPath = process.env.PATH;
process.env.PATH = `${priorPath ?? ''}${path.delimiter}${path.join(root, 'scripts')}`;
let issues;
try { issues = noIssues ? null : readIssues(root, range); }
let issues, states = null;
try {
issues = noIssues ? null : readIssues(root, range);
if (queue && !noIssues) states = issueStates(root, queue.rows, issues);
}
finally { if (priorPath === undefined) delete process.env.PATH; else process.env.PATH = priorPath; }
// T3 is on by default. A missing or unreadable database refuses the report.
const t3 = noT3 ? null : await readT3(root, range, t3Db === undefined ? { dbPath: defaultT3Path(), isDefault: true } : { dbPath: t3Db, isDefault: false });
const sessions = mergeSources(await readSessions(root, range), t3);
const report = summarize(range, commits, issues, sessions);
console.log(json ? JSON.stringify(report, null, 2) : formatTable(report));
report.queue = queue ? queueChecks(queue, { issues: states, seats: readSeatsDir(), exempt, range }) : { checked: false };
// The queue section prints above the weekly table.
console.log(json ? JSON.stringify(report, null, 2) : [...formatQueue(report.queue), formatTable(report)].join('\n'));
return report;
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+255
View File
@@ -0,0 +1,255 @@
// The queue section (Piece E, #1508; plan 8.10). It reads
// docs/plans/queue.json through the queue's own validator, the owners'
// registrations through the seat package, and Gitea through the same helper
// as the metric call. It writes nothing. The best result is `reduced pass`:
// a registration holds a pid, not a process identity, so no owner is ever
// verified live.
import { spawnSync } from 'node:child_process';
import { lstatSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { applyEntry, loadDoc, replay, resolvedFromResult, rowsArray, sameJson } from '../../queue/src/queue.mjs';
import { SEAT_NAME, defaultConfigPath, loadDataRoot, readRegistration, samePath, seatsDir } from '../../seat/src/seat.mjs';
import { SourceError, clean } from './ledger.mjs';
const DAY = 86400000;
export const AGE_LIMIT_DAYS = 14;
export const GITEA_DEADLINE_S = 60;
export const OPEN_LIMIT = 50;
export const LOOKUP_BUDGET = 10;
export const LIVENESS = ['pid-present', 'exempt', 'pid-unknown', 'missing', 'invalid', 'pid-gone'];
const ACTIVE = new Set(['in-progress', 'in-review']);
export function checkSeatName(seat) {
if (typeof seat !== 'string' || !SEAT_NAME.test(seat)) throw new SourceError(`--unsupported-runtime needs a seat name, not ${JSON.stringify(clean(seat ?? ''))}`);
return seat;
}
// Any refusal from the validator refuses the report: a queue that doesn't
// load has no rows to check.
export function readQueue(root) {
const file = path.join(root, 'docs/plans/queue.json');
let bytes;
try {
if (!lstatSync(file).isFile()) throw new Error('not a regular file');
bytes = readFileSync(file);
} catch { throw new SourceError('Queue unavailable: docs/plans/queue.json is missing or not a regular file; use --no-queue to skip the queue section'); }
try {
const { doc } = loadDoc(bytes);
return { revision: doc.revision, canonicalRoot: doc.canonicalRoot, genesisAt: doc.log[0].at, rows: doc.rows, log: doc.log };
} catch (error) { throw new SourceError(`Queue unavailable: ${clean(error.message)}`); }
}
// Signal 0 probe. EPERM still means a process has the pid.
export function pidAlive(pid) {
try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; }
}
// One class per owner seat, from its repo-layout registration for the
// queue's canonical root. A registration for the same seat name written by
// another checkout says nothing about this one, so it counts as missing.
export function classifySeat(seat, { seats, canonicalRoot, exempt, isPidAlive = pidAlive }) {
if (exempt.has(seat)) return { class: 'exempt', detail: 'declared with --unsupported-runtime' };
if (seats === null) return { class: 'invalid', detail: 'no seats directory: the config is unreadable' };
let record;
try { record = readRegistration(seats, seat, 'repo'); } catch { return { class: 'invalid', detail: 'the registration does not validate' }; }
if (record === null) return { class: 'missing', detail: 'no repo registration' };
if (!samePath(record.sessionsDir, path.join(canonicalRoot, '.pi', 'state', seat, 'sessions'))) {
return { class: 'missing', detail: 'the repo registration is for another checkout' };
}
if (record.pid === null) return { class: 'pid-unknown', detail: 'the registration has no pid' };
return isPidAlive(record.pid) ? { class: 'pid-present', detail: 'pid present (identity not verified)' } : { class: 'pid-gone', detail: 'the recorded pid is not running' };
}
// The seats directory from the system config. A config that can't be read
// makes every non-exempt owner invalid rather than refusing the whole report.
export function readSeatsDir(configPath = defaultConfigPath()) {
try { return seatsDir(loadDataRoot(configPath)); } catch { return null; }
}
// One helper call under the deadline. `timeout -s KILL` kills the helper's
// process group, curl included, as the queue's review call does; a Node
// timeout would kill the helper and leave curl running.
function gitea(root, apiPath, tool, deadline) {
const r = spawnSync('timeout', ['-s', 'KILL', String(deadline), tool, 'GET', apiPath], { cwd: root, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] });
if (r.error) throw r.error;
if (r.status !== 0) throw Object.assign(new Error('Gitea call failed'), { status: r.status, killed: r.signal === 'SIGKILL' || r.status === 137 });
return r.stdout;
}
const issueRecord = i => i && Number.isSafeInteger(i.number) && i.number > 0 && (i.state === 'open' || i.state === 'closed');
// The open list: one call, one page. A failure refuses the report the way
// the metric call does. A full page may be short, but an issue missing from
// it is looked up, so the page is undecided only while some issue a row
// closes has no known state (lead decision 40).
export function readOpenIssues(root, tool = 'gitea-api.sh', deadline = GITEA_DEADLINE_S) {
let output;
try { output = gitea(root, `repos/mosaicstack/stack/issues?state=open&type=issues&limit=${OPEN_LIMIT}&page=1`, tool, deadline); }
catch (error) {
const reason = error.status === 3 ? 'credentials missing, unreadable, or invalid' :
error.killed ? `no answer within ${deadline} s` :
error.status === 126 || error.status === 127 ? 'gitea-api.sh unavailable' :
error.code === 'ENOENT' ? 'the timeout command is unavailable' : 'credential or Gitea request failure';
throw new SourceError(`Open issues unavailable: ${reason}; use --no-issues to leave the queue issue checks unrun`, 2);
}
let list;
try { list = JSON.parse(output); } catch { throw new SourceError('Open issues unavailable: invalid Gitea JSON', 2); }
if (!Array.isArray(list) || !list.every(issueRecord)) throw new SourceError('Open issues unavailable: invalid Gitea issue records', 2);
const issues = list.filter(i => !i.pull_request);
if (issues.some(i => i.state !== 'open')) throw new SourceError('Open issues unavailable: the open list holds a closed issue', 2);
return { numbers: new Set(issues.map(i => i.number)), full: list.length >= OPEN_LIMIT };
}
// One issue by number. Anything but an issue with that number and a state is
// no evidence: a 404, a pull request, a failed call.
export function lookupIssue(root, number, tool = 'gitea-api.sh', deadline = GITEA_DEADLINE_S) {
let issue;
try { issue = JSON.parse(gitea(root, `repos/mosaicstack/stack/issues/${number}`, tool, deadline)); }
catch { return { state: 'unknown', detail: 'lookup failed' }; }
if (!issueRecord(issue) || issue.number !== number) return { state: 'unknown', detail: 'lookup returned no issue record' };
if (issue.pull_request) return { state: 'unknown', detail: 'the number is a pull request' };
return { state: issue.state, detail: 'lookup' };
}
// Issue states for every issue a row closes. `metric` is the metric call's
// list (issues only) or null when it wasn't made. Returns the states, the
// number of lookups made, and whether the open list was a full page.
export function issueStates(root, rows, metric, tool = 'gitea-api.sh') {
const wanted = [...new Set(rows.flatMap(r => r.closes))].sort((a, b) => a - b);
const open = readOpenIssues(root, tool);
// Closed evidence needs both fields: a reopened issue off the full open
// page must not count as closed on a stale closed_at.
const closedInMetric = new Set((metric ?? []).filter(i => i.state === 'closed' && i.closed_at).map(i => i.number));
const states = new Map();
let lookups = 0;
for (const n of wanted) {
if (open.numbers.has(n)) states.set(n, { state: 'open', detail: 'open list' });
else if (closedInMetric.has(n)) states.set(n, { state: 'closed', detail: 'metric page' });
else if (lookups < LOOKUP_BUDGET) { lookups++; states.set(n, lookupIssue(root, n, tool)); }
else states.set(n, { state: 'unknown', detail: 'over the lookup budget' });
}
return { states, lookups, openListFull: open.full };
}
// Every log entry in the report range that changes a required or parked row,
// with the actor it claims, for Jason to confirm weekly. The queue trusts
// `--by` (its README, "Trust boundary"), so this list is the detection. A row
// counts when it is required or parked before or after the entry. The log has
// already replayed in loadDoc, so replaying it again here cannot refuse.
export function protectedChanges(log, range) {
const inRange = e => { const ms = Date.parse(e.at); return ms >= range.start && ms < range.end; };
const first = log.findIndex(inRange);
if (first === -1) return [];
const guarded = r => r !== undefined && (r.required || r.state === 'parked');
const changes = [];
let state = first === 0 ? { rows: new Map() } : replay(log.slice(0, first));
for (let i = first; i < log.length; i++) {
const e = log[i];
const next = i === 0 ? replay(log.slice(0, 1)) : applyEntry(state, e, resolvedFromResult(e.verb, e.args, e.result)).state;
if (inRange(e)) {
const rows = rowsArray(next).filter(r => !sameJson(r, state.rows.get(r.id)) && (guarded(r) || guarded(state.rows.get(r.id)))).map(r => r.id);
if (rows.length) changes.push({ rev: e.rev, op: e.op, verb: e.verb, by: e.by, at: e.at, rows });
}
state = next;
}
return changes;
}
const ageDays = (fromMs, now) => Math.floor((now - fromMs) / DAY);
// requiredSince is a date (the genesis form) or an ISO time (`set required`
// and `add --required` write one). Date.parse reads a bare date as 00:00Z,
// and either counts from 00:00Z of its UTC day, so age is whole UTC days
// whatever the hour. The queue validator checks the shape, not the
// calendar, so a value that doesn't parse is NaN.
const requiredDay = v => Math.floor(Date.parse(v) / DAY) * DAY;
// The four checks. `issues` is null when the issue checks did not run.
// Each finding has an identity (check, row, issue): a second run on the same
// UTC day that no longer reports it is the remediation (plan 8.10).
export function queueChecks(queue, { issues, seats, exempt = new Set(), now = Date.now(), isPidAlive = pidAlive, range = null }) {
const violations = [], undecided = [], dispositions = [];
const add = (list, check, row, issue, message) => list.push({ check, row, issue, message });
const rows = queue.rows;
if (issues) {
const closers = new Map();
for (const r of rows) for (const n of r.closes) closers.set(n, [...(closers.get(n) ?? []), r]);
for (const [n, rs] of [...closers].sort((a, b) => a[0] - b[0])) {
const { state, detail } = issues.states.get(n);
const allDone = rs.every(r => r.state === 'done');
for (const r of rs.filter(r => r.state === 'done')) {
if (!allDone) continue;
if (state === 'open') add(violations, 'issue-open', r.id, n, `row ${r.id} is done and every row that closes #${n} is done, but #${n} is open`);
else if (state === 'unknown') add(undecided, 'issue-unknown', r.id, n, `row ${r.id} is done; whether #${n} is closed is unknown (${detail})`);
}
if (state === 'closed' && !allDone) {
const pending = rs.filter(r => r.state !== 'done').map(r => r.id);
add(dispositions, 'issue-closed-early', pending[0], n, `#${n} is closed, but row${pending.length > 1 ? 's' : ''} ${pending.join(', ')} that close${pending.length > 1 ? '' : 's'} it ${pending.length > 1 ? 'are' : 'is'} not done`);
}
}
const unresolved = [...issues.states].filter(([, s]) => s.state === 'unknown').map(([n]) => `#${n}`);
if (issues.openListFull && unresolved.length) {
add(undecided, 'open-list-full', null, null, `the open-issue list was a full page of ${OPEN_LIMIT}, and ${unresolved.join(', ')} ${unresolved.length === 1 ? 'has' : 'have'} no known state`);
}
} else {
add(undecided, 'issues-not-run', null, null, 'the issue checks did not run (--no-issues)');
}
const owners = new Map();
for (const r of rows.filter(r => ACTIVE.has(r.state))) owners.set(r.owner, [...(owners.get(r.owner) ?? []), r.id]);
const liveness = [];
for (const [seat, ids] of [...owners].sort((a, b) => a[0].localeCompare(b[0]))) {
const c = classifySeat(seat, { seats, canonicalRoot: queue.canonicalRoot, exempt, isPidAlive });
liveness.push({ seat, rows: ids, ...c });
for (const id of ids) {
const message = `row ${id} owner ${seat}: ${c.class}, ${c.detail}`;
if (c.class === 'missing' || c.class === 'invalid' || c.class === 'pid-gone') add(violations, `owner-${c.class}`, id, null, message);
else if (c.class === 'pid-unknown') add(undecided, 'owner-pid-unknown', id, null, message);
}
}
const genesisDays = ageDays(Date.parse(queue.genesisAt), now);
for (const r of rows.filter(r => r.required && r.state !== 'done')) {
if (r.requiredSince === 'unknown') {
if (genesisDays > AGE_LIMIT_DAYS) add(violations, 'age', r.id, null, `row ${r.id} "${clean(r.piece)}" is required and not done, age ≥ ${genesisDays} days (legacy lower bound)`);
else add(undecided, 'age-unknown', r.id, null, `row ${r.id} is required and not done; its age is unknown until genesis is over ${AGE_LIMIT_DAYS} days old`);
continue;
}
const from = requiredDay(r.requiredSince);
if (!Number.isFinite(from)) {
add(violations, 'age-invalid', r.id, null, `row ${r.id} is required and not done, and its requiredSince ${JSON.stringify(clean(r.requiredSince))} is not a date`);
continue;
}
const days = ageDays(from, now);
if (days > AGE_LIMIT_DAYS) add(violations, 'age', r.id, null, `row ${r.id} "${clean(r.piece)}" is required and not done, ${days} days since ${r.requiredSince}`);
}
const counts = Object.fromEntries(LIVENESS.map(k => [k, liveness.filter(s => s.class === k).length]));
const result = violations.length ? 'fail' : undecided.length ? 'incomplete' : 'reduced pass';
return {
checked: true, revision: queue.revision, asOf: new Date(now).toISOString(), result,
violations, undecided, dispositions,
liveness: { counts, seats: liveness, exempt: [...exempt].sort() },
issueChecks: issues ? { run: true, lookups: issues.lookups, openListFull: issues.openListFull } : { run: false },
protectedChanges: range && queue.log ? protectedChanges(queue.log, range) : [],
};
}
const where = f => [f.row === null ? null : `row ${f.row}`, f.issue === null ? null : `#${f.issue}`].filter(Boolean).join(' ');
export function formatQueue(q) {
if (!q.checked) return ['Queue: not checked (--no-queue)'];
const c = q.liveness.counts;
return [
`Queue checks: queue.json revision ${q.revision}, as of ${q.asOf}`,
...q.violations.map(f => `violation ${f.check} ${where(f)}: ${f.message}`),
...q.undecided.map(f => `undecided ${f.check}${f.row === null && f.issue === null ? '' : ` ${where(f)}`}: ${f.message}`),
...q.dispositions.map(f => `disposition ${f.check} ${where(f)}: ${f.message}`),
...q.liveness.seats.map(s => `owner ${s.seat} (row${s.rows.length === 1 ? '' : 's'} ${s.rows.join(', ')}): ${s.class}, ${s.detail}`),
`liveness: ${c['pid-present']} pid-present (unverified), ${c.exempt} exempt, ${c['pid-unknown']} pid-unknown, ${c.missing} missing, ${c.invalid} invalid, ${c['pid-gone']} pid-gone`,
...(q.liveness.exempt.length ? [`declared unsupported runtime: ${q.liveness.exempt.join(', ')}`] : []),
q.issueChecks.run ? `queue issue checks: open list${q.issueChecks.openListFull ? ' (full page)' : ''}, ${q.issueChecks.lookups} lookup${q.issueChecks.lookups === 1 ? '' : 's'}` : 'queue issue checks: not run',
...q.protectedChanges.map(c => `protected change rev ${c.rev} ${c.verb} by ${c.by} at ${c.at}: row${c.rows.length === 1 ? '' : 's'} ${c.rows.join(', ')}`),
`protected changes in range: ${q.protectedChanges.length} (not checks; confirm the actors)`,
`queue: ${q.violations.length} violation${q.violations.length === 1 ? '' : 's'}; result ${q.result}`,
'',
];
}
+5 -2
View File
@@ -64,13 +64,16 @@ function fixture(t) {
mkdirSync(path.join(root, 'agents/alice'), { recursive: true });
mkdirSync(path.join(root, 'agents/bob'), { recursive: true });
cpSync(source, path.join(root, 'packages/ledger/src'), { recursive: true });
// The queue section imports the queue validator and the seat registry.
for (const pkg of ['queue', 'seat']) cpSync(path.join(source, '../..', pkg, 'src'), path.join(root, 'packages', pkg, 'src'), { recursive: true });
const api = put('bin/gitea-api.sh', '#!/usr/bin/env node\nconst fs=require("fs"); 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));} process.stdout.write(fs.readFileSync(process.env.ISSUES,"utf8"));\n');
execFileSync('chmod', ['+x', api]);
put('issues.json', JSON.stringify(fixtureIssues)); put('calls.jsonl', '');
const entry = (text, timestamp = '2026-09-08T12:00:00Z') => ({ type: 'message', timestamp, message: { role: 'user', content: [{ type: 'text', text }] } });
const logs = [entry('[host:control-board -> host:alice] do #1'), entry('[host:bob -> host:alice] review #2'), entry('build #2'), entry('old #1', '2026-09-05T23:59:59Z'), { type: 'message', timestamp: '2026-09-08T00:00:00Z', message: { role: 'assistant', content: 'not a user #1' } }];
put('.pi/state/alice/sessions/one.jsonl', logs.map(x => JSON.stringify(x)).join('\n') + '\n');
const run = (args = [], env = {}) => spawnSync(process.execPath, [path.join(root, 'packages/ledger/src/cli.mjs'), '--since', '2026-09-06', '--until', '2026-09-12', ...args], { cwd: root, encoding: 'utf8', env: { ...process.env, HOME: home, PATH: `${path.join(root, 'bin')}:${process.env.PATH}`, ISSUES: path.join(root, 'issues.json'), CALLS: path.join(root, 'calls.jsonl'), ...env } });
// These tests cover the weekly table; queue-checks.test.mjs covers the queue section.
const run = (args = [], env = {}) => spawnSync(process.execPath, [path.join(root, 'packages/ledger/src/cli.mjs'), '--since', '2026-09-06', '--until', '2026-09-12', '--no-queue', ...args], { cwd: root, encoding: 'utf8', env: { ...process.env, HOME: home, PATH: `${path.join(root, 'bin')}:${process.env.PATH}`, ISSUES: path.join(root, 'issues.json'), CALLS: path.join(root, 'calls.jsonl'), ...env } });
return { root, real: realpathSync(root), home, defaultDb, put, commit, run, entry, logs };
}
test('fixture git subjects only, follow-ups and three session kinds', t => {
@@ -103,7 +106,7 @@ test('missing credentials exit 2, no-issues never calls API and shows unknown',
test('empty range gives no rows and zero totals', t => {
const f = fixture(t);
f.put('issues.json', '[]');
const result = spawnSync(process.execPath, [path.join(f.root, 'packages/ledger/src/cli.mjs'), '--since', '2027-01-01', '--until', '2027-01-02', '--json'], { encoding: 'utf8', env: { ...process.env, HOME: f.home, PATH: `${f.root}/bin:${process.env.PATH}`, ISSUES: `${f.root}/issues.json`, CALLS: `${f.root}/calls.jsonl` } });
const result = spawnSync(process.execPath, [path.join(f.root, 'packages/ledger/src/cli.mjs'), '--since', '2027-01-01', '--until', '2027-01-02', '--json', '--no-queue'], { encoding: 'utf8', env: { ...process.env, HOME: f.home, PATH: `${f.root}/bin:${process.env.PATH}`, ISSUES: `${f.root}/issues.json`, CALLS: `${f.root}/calls.jsonl` } });
assert.equal(result.status, 0, result.stderr); const r = JSON.parse(result.stdout);
assert.deepEqual(r.issues, []); assert.deepEqual(r.seats, []); assert.ok(Object.values(r.totals).every(n => n === 0));
});
+449
View File
@@ -0,0 +1,449 @@
// 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');
});