/** * Rendering for the verb action ledger (#126). * * The store owns the rows; this owns how an operator reads them. Separate from * both because the ledger is the thing an escalation cites, and a record nobody * can read at a glance is a record nobody checks: `omp-conductor ledger` and * the `status` block below print through the same two functions, so the two * surfaces cannot come to disagree about what a refusal was called. */ import type { VerbLedgerEntry } from "../types.ts"; /** How many entries `status` shows before an operator has to ask for the rest. */ export const STATUS_LEDGER_LINES = 5; /** * How many the snapshot carries. * * More than it prints, on purpose: the header says "N recent calls, M refused", * and a count computed from only the five it shows would say "1 refused" about * a fleet that refused eleven things this morning. */ export const STATUS_LEDGER_SCAN = 20; function clock(at: number): string { return new Date(at).toISOString().replace("T", " ").slice(0, 19); } /** * The arguments, flattened to one scannable line. * * Bounded, because a `conductor_pr_create` body is a whole PR description and * the ledger is a list an operator scans, not a document they read. Truncation * only ever loses the tail of one value, and the untruncated payload is still * in the row for anything that needs it. */ export function formatVerbArgs(args: Record, limit = 60): string { const parts = Object.entries(args).map(([key, value]) => { const text = typeof value === "string" ? value : JSON.stringify(value); const flat = (text ?? "undefined").replace(/\s+/g, " ").trim(); return `${key}=${flat.length > limit ? `${flat.slice(0, limit - 1)}…` : flat}`; }); return parts.length === 0 ? "(no arguments)" : parts.join(" "); } /** * One entry, decision first. * * The decision leads the line on purpose: the question an operator brings to * this list is "what got refused", and a column they have to read past the * timestamp and the verb to find is a column they skim. */ export function formatVerbLedgerEntry(entry: VerbLedgerEntry): string[] { const who = entry.runId === undefined ? entry.role : `${entry.role} #${entry.issue ?? "?"}`; const head = ` ${clock(entry.at)} ${entry.decision === "allowed" ? "ALLOW " : "REFUSE"} ` + `${entry.verb.padEnd(26)}${who}` + (entry.refusal === undefined ? "" : ` [${entry.refusal}]`) + (entry.sha === undefined ? "" : ` ${entry.sha.slice(0, 12)}`); return [head, ` ${formatVerbArgs(entry.args)}`, ` ${entry.detail}`]; } /** The `status` block: newest first, bounded, and silent when nothing happened. */ export function formatVerbLedger( entries: readonly VerbLedgerEntry[], limit = STATUS_LEDGER_LINES, ): string[] { if (entries.length === 0) return []; const shown = entries.slice(0, limit); const refused = entries.filter((e) => e.decision === "refused").length; return [ "", `verb ledger ${entries.length} recent call(s), ${refused} refused` + (entries.length > shown.length ? ` (omp-conductor ledger for the rest)` : ""), ...shown.flatMap(formatVerbLedgerEntry), ]; }