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:
@@ -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)) {
|
||||
|
||||
@@ -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}`,
|
||||
'',
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user