From c33c1bcbc57346b755046b7a6d5fe2190d129f46 Mon Sep 17 00:00:00 2001 From: profit Date: Wed, 22 Apr 2026 04:02:36 -0500 Subject: [PATCH] Auditor: poller + live end-to-end proof MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit auditor/index.ts (task #9) — the top-level poller. 90s interval, dedupes by head SHA via data/_auditor/state.json, supports --once for CLI testing. Env gates: LH_AUDITOR_RUN_DYNAMIC=1 to include the hybrid fixture (default off; it mutates live state), LH_AUDITOR_SKIP_INFERENCE=1 for fast runs without cloud calls. Single-shot run proof (task #10): cycle 1: 2 open PRs audit PR #2 f0a3ed68 "Fix: UpsertOutcome newtype serde panic" verdict=block, 9 findings (1 block, 5 warn, 3 info) audit PR #1 039ed324 "Auditor: PR-claim hard-block reviewer" verdict=approve, 4 findings (0 block, 0 warn, 4 info) audits_run=2, state persisted Commit statuses and issue comments posted live to Gitea. PR #2 is currently hard-blocked (lakehouse/auditor commit status = failure); PR #1 has a passing status. State survives restart — next cycle skips already-audited SHAs. Both PRs now have the audit comment with per-check breakdown. Operator can read the comment, fix blocking findings (or defend them with a reply), push a new commit; auditor re-audits on new SHA, verdict updates, merge gate responds accordingly. The full loop J asked for is closed: 1. static check caught own Phase 45 placeholder (b933334) 2. hybrid fixture caught UpsertOutcome serde panic (9c893fb) 3. LLM-Team-style codereview caught ternary bug (5bbcaf4) 4. auditor poller now runs on every open PR, block/approve with evidence, re-audits on new SHAs Tasks done: 1-11 (except 12, a scoped follow-up fix for UPDATE branch dropping doc_refs). The auditor is running, catching real bugs in its own build, and gating merges. --- auditor/index.ts | 147 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 auditor/index.ts diff --git a/auditor/index.ts b/auditor/index.ts new file mode 100644 index 0000000..cd64144 --- /dev/null +++ b/auditor/index.ts @@ -0,0 +1,147 @@ +// Auditor poller — the top-level entry. Polls Gitea for open PRs on +// a fixed interval, dedupes by head SHA, runs audit + posts verdict +// for each new (pr, sha) pair. +// +// Run manually: +// bun run auditor/index.ts +// +// Stop: +// touch auditor.paused (skips next cycle) +// pkill -f auditor/index.ts (kills in-flight) +// +// State: +// data/_auditor/state.json — last-audited SHA per PR +// data/_auditor/verdicts/{id}.json — per-run verdict records +// +// This entry runs forever. A systemd unit would wrap it once the +// workflow is trusted (same pattern as mcp-server, observer). + +import { readFile, writeFile, mkdir, access } from "node:fs/promises"; +import { listOpenPrs } from "./gitea.ts"; +import { auditPr } from "./audit.ts"; + +const POLL_INTERVAL_MS = 90_000; // 90s — enough budget for audit runs to complete +const PAUSE_FILE = "/home/profit/lakehouse/auditor.paused"; +const STATE_FILE = "/home/profit/lakehouse/data/_auditor/state.json"; + +interface State { + // Map: PR number → last-audited head SHA. Lets us dedupe audits + // across restarts (poller can crash/restart without re-auditing + // all open PRs from scratch). + last_audited: Record; + started_at: string; + cycles_total: number; + cycles_skipped_paused: number; + audits_run: number; + last_cycle_at?: string; +} + +async function fileExists(path: string): Promise { + try { await access(path); return true; } catch { return false; } +} + +async function loadState(): Promise { + try { + const raw = await readFile(STATE_FILE, "utf8"); + const s = JSON.parse(raw); + return { + last_audited: s.last_audited ?? {}, + started_at: s.started_at ?? new Date().toISOString(), + cycles_total: s.cycles_total ?? 0, + cycles_skipped_paused: s.cycles_skipped_paused ?? 0, + audits_run: s.audits_run ?? 0, + last_cycle_at: s.last_cycle_at, + }; + } catch { + return { + last_audited: {}, + started_at: new Date().toISOString(), + cycles_total: 0, + cycles_skipped_paused: 0, + audits_run: 0, + }; + } +} + +async function saveState(s: State): Promise { + await mkdir("/home/profit/lakehouse/data/_auditor", { recursive: true }); + await writeFile(STATE_FILE, JSON.stringify(s, null, 2)); +} + +async function runCycle(state: State): Promise { + state.cycles_total += 1; + state.last_cycle_at = new Date().toISOString(); + + if (await fileExists(PAUSE_FILE)) { + state.cycles_skipped_paused += 1; + console.log(`[auditor] cycle ${state.cycles_total}: paused (touch ${PAUSE_FILE} exists)`); + return state; + } + + let prs; + try { + prs = await listOpenPrs(); + } catch (e) { + console.error(`[auditor] listOpenPrs failed: ${(e as Error).message}`); + return state; + } + + console.log(`[auditor] cycle ${state.cycles_total}: ${prs.length} open PR(s)`); + + for (const pr of prs) { + const last = state.last_audited[String(pr.number)]; + if (last === pr.head_sha) { + console.log(`[auditor] skip PR #${pr.number} (SHA ${pr.head_sha.slice(0, 8)} already audited)`); + continue; + } + console.log(`[auditor] audit PR #${pr.number} (${pr.head_sha.slice(0, 8)}) — ${pr.title.slice(0, 60)}`); + try { + // Skip dynamic by default: it mutates live playbook state and + // re-runs on every PR update would pollute quickly. Operator + // can run dynamic via `bun run auditor/fixtures/cli.ts` manually + // OR set LH_AUDITOR_RUN_DYNAMIC=1 to opt in. + const run_dynamic = process.env.LH_AUDITOR_RUN_DYNAMIC === "1"; + const verdict = await auditPr(pr, { + skip_dynamic: !run_dynamic, + skip_inference: process.env.LH_AUDITOR_SKIP_INFERENCE === "1", + }); + console.log(`[auditor] verdict=${verdict.overall} findings=${verdict.metrics.findings_total} (block=${verdict.metrics.findings_block} warn=${verdict.metrics.findings_warn})`); + state.last_audited[String(pr.number)] = pr.head_sha; + state.audits_run += 1; + } catch (e) { + console.error(`[auditor] audit failed: ${(e as Error).message}`); + } + } + + return state; +} + +async function main(): Promise { + console.log(`[auditor] starting poller — interval ${POLL_INTERVAL_MS / 1000}s`); + console.log(`[auditor] pause file: ${PAUSE_FILE}`); + console.log(`[auditor] state file: ${STATE_FILE}`); + + let state = await loadState(); + console.log(`[auditor] loaded state: ${Object.keys(state.last_audited).length} PRs previously audited, ${state.cycles_total} cycles so far`); + + // Single-shot mode for CLI testing: `bun run auditor/index.ts --once` + const once = process.argv.includes("--once"); + if (once) { + state = await runCycle(state); + await saveState(state); + console.log(`[auditor] single-shot complete. total audits: ${state.audits_run}`); + return; + } + + // Loop. + while (true) { + state = await runCycle(state); + await saveState(state); + await new Promise(res => setTimeout(res, POLL_INTERVAL_MS)); + } +} + +main().catch(e => { + console.error("[auditor] fatal:", e); + process.exit(1); +});