/** * The package tripwire: hash every file the running install is made of, once * per tick, and stop the fleet the moment one of them changes underneath it. * * The boundary is drawn around "what was deployed", not around any pass: the * manifest is a self-portrait of `src/`, and the two callers — the tick that * compares it and `runDaemon` that records the baseline — want the same three * pure functions and nothing else. Keeping them here means the tripwire has no * `Deps` and therefore nothing a pass could accidentally teach it to ignore. * * `markPaged` lives here rather than in `supervision.ts` because both latches it * serves — this one and the stall gate's — are the same "page once, and only * after delivery is confirmed" rule, and one overload set is the only way that * rule stays one rule. */ import { createHash } from "node:crypto"; import { readFileSync, readdirSync } from "node:fs"; import { join, relative } from "node:path"; import { PACKAGE_SRC_DIR, type IntegrityGate, type StallGate } from "./deps.ts"; /** Enough differing paths to tell a deploy from a tamper at a glance; the full * list is on the host, and the answer is always "go look at the host". */ export const INTEGRITY_SAMPLE = 5; export interface IntegrityVerdict { /** Labelled, sorted differences; empty when the package is untouched. */ diff: string[]; /** Any difference at all stops the fleet. */ pause: boolean; /** First divergent tick only — a page every five minutes is a page nobody reads. */ page: boolean; } /** * sha256 of every source file the running package is made of, keyed by path * relative to `root`. * * `PACKAGE_SRC_DIR` is the installed `src/` of the code executing right now, so * this is a self-portrait: what was actually deployed, not what some checkout * on disk happens to contain. `.ts` and `.md` because both are executable in * this package — the briefs under `src/briefs/` are the sessions' instructions, * and rewriting one of those buys more than rewriting the dispatcher does. * (A checkout also carries `*.test.ts`, which the published package excludes, so * a daemon started from one is watching its tests too. That is the honest * answer — its code did change — and it costs nothing on a real install.) * * Walking and hashing the ~30 files of this package measures 0.6 ms warm, once * per five-minute tick, so a tick does it inline. No cache and no mtime * shortcut on purpose: a cache is a second thing that can be wrong, and mtime * is the first field anyone covering their tracks restores. */ export function packageManifest(root: string = PACKAGE_SRC_DIR): Map { const out = new Map(); const walk = (dir: string): void => { for (const e of readdirSync(dir, { withFileTypes: true })) { const full = join(dir, e.name); if (e.isDirectory()) walk(full); else if (e.isFile() && (e.name.endsWith(".ts") || e.name.endsWith(".md"))) out.set(relative(root, full), createHash("sha256").update(readFileSync(full)).digest("hex")); } }; walk(root); return out; } /** * Labelled rather than three arrays because every consumer — the log line, the * page, the test — wants one readable list of what moved. */ export function manifestDiff(before: Map, after: Map): string[] { const out: string[] = []; for (const [path, hash] of before) { const now = after.get(path); if (now === undefined) out.push(`removed ${path}`); else if (now !== hash) out.push(`changed ${path}`); } for (const path of after.keys()) if (!before.has(path)) out.push(`added ${path}`); return out.sort(); } /** * The tick's decision, split from its effects so the once-only page is a thing * a test can hold. * * `pause` stays true on every divergent tick, deliberately: an operator who * resumes without restarting gets re-paused, because the boundary is still * broken. `page` asks whether this tick should *try* — the caller latches the * gate with {@link markPaged} only once a page actually went out, so a Telegram * outage during the one tick that noticed does not buy permanent silence. */ export function checkIntegrity(gate: IntegrityGate, current: Map): IntegrityVerdict { const diff = manifestDiff(gate.baseline, current); if (diff.length === 0) return { diff, pause: false, page: false }; return { diff, pause: true, page: !gate.paged }; } /** Latch a page after delivery is confirmed, and never before. */ export function markPaged(gate: StallGate, delivered: boolean, now: number): void; export function markPaged(gate: IntegrityGate, delivered: boolean): void; export function markPaged( gate: { paged: boolean; lastPagedAt?: number }, delivered: boolean, now?: number, ): void { if (!delivered) return; gate.paged = true; if (now !== undefined) gate.lastPagedAt = now; }