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
+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}`,
'',
];
}