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]>
256 lines
16 KiB
JavaScript
256 lines
16 KiB
JavaScript
// 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}`,
|
|
'',
|
|
];
|
|
}
|