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