/** * run-state.ts — persistent hygiene-run state. * * Tracks per-check phase progress so `/pygienium-status`, `/pygienium-resume`, * and `/pygienium-export` work, and so `/pygienium-all` is resumable. State is * a single JSON file at `/.pygienium/run-state.json` so it is trivial to * inspect and is naturally session-scoped to the target directory. * * @module pygienium/run-state */ import { appendFile, mkdir, readFile, stat, writeFile } from "node:fs/promises"; import { join } from "node:path"; export const RUN_STATE_DIRNAME = ".pygienium"; export const RUN_STATE_FILENAME = "run-state.json"; export const RECON_FILENAME = "recon.json"; /** Resolve the pygienium state directory for a given cwd. */ export function stateDir(cwd: string): string { return join(cwd, RUN_STATE_DIRNAME); } /** Resolve the run-state file path for a given cwd. */ export function runStatePath(cwd: string): string { return join(stateDir(cwd), RUN_STATE_FILENAME); } export type PhaseStatus = | "pending" | "in_progress" | "complete" | "failed" | "skipped"; export type CheckStatus = PhaseStatus; export type RunStatus = "in_progress" | "complete" | "failed" | "partial"; export interface PhaseEntry { id: string; status: PhaseStatus; startedAt?: number; finishedAt?: number; error?: string; } export interface CheckRun { name: string; label: string; status: CheckStatus; fix: boolean; phases: PhaseEntry[]; findings?: string; changes?: string; startedAt?: number; finishedAt?: number; error?: string; } export interface ReconState { complete: boolean; path: string; finishedAt?: number; } export interface RunState { version: 1; cwd: string; startedAt: number; updatedAt: number; status: RunStatus; recon: ReconState; checks: Record; } /** Phase ids shared by every check run, in execution order. */ export const PHASE_RECON = "recon"; export const PHASE_ANALYSIS = "analysis"; export const PHASE_FIX = "fix"; export const PHASE_VERIFY = "verify"; export const PHASE_CLEANUP = "cleanup"; function freshPhase(id: string): PhaseEntry { return { id, status: "pending" }; } /** Create the phase skeleton for a single check (analysis always runs; fix only when requested). */ export function phasesForCheck(fix: boolean): PhaseEntry[] { const phases = [freshPhase(PHASE_RECON), freshPhase(PHASE_ANALYSIS)]; if (fix) phases.push(freshPhase(PHASE_FIX)); phases.push(freshPhase(PHASE_VERIFY), freshPhase(PHASE_CLEANUP)); return phases; } /** Initialize a fresh run state for `checkNames` (labels default to the name). */ export function initRunState( cwd: string, checks: Array<{ name: string; label: string; fix?: boolean }>, ): RunState { const now = Date.now(); const state: RunState = { version: 1, cwd, startedAt: now, updatedAt: now, status: "in_progress", recon: { complete: false, path: join(stateDir(cwd), RECON_FILENAME) }, checks: {}, }; for (const c of checks) { state.checks[c.name] = { name: c.name, label: c.label, status: "pending", fix: c.fix ?? false, phases: phasesForCheck(c.fix ?? false), startedAt: undefined, }; } return state; } /** Load run state for `cwd`. Returns `undefined` when none exists. */ export async function loadRunState(cwd: string): Promise { const path = runStatePath(cwd); try { const raw = await readFile(path, "utf8"); return JSON.parse(raw) as RunState; } catch (err) { if ((err as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw err; } } /** Persist run state, creating the state directory as needed. */ export async function saveRunState(state: RunState): Promise { const dir = stateDir(state.cwd); await mkdir(dir, { recursive: true }); state.updatedAt = Date.now(); await writeFile( runStatePath(state.cwd), JSON.stringify(state, null, 2) + "\n", "utf8", ); } /** * Memo of cwds whose `.gitignore` was already ensured this process, so the * check runs at most once per target per session. */ const gitIgnoreMemo = new Set(); /** * Make sure `/.gitignore` excludes `.pygienium/` (run-state + artifacts) * so a run never stages its own output into the scanned repo's git index. * Best-effort and idempotent: no-op outside a git work tree or when the entry * already exists. Returns true when it appended the entry (or created the file). */ export async function ensureRunStateIgnored(cwd: string): Promise { if (gitIgnoreMemo.has(cwd)) return false; gitIgnoreMemo.add(cwd); try { // Only act inside a git work tree (works for worktrees too: .git is a file). await stat(join(cwd, ".git")); const ignorePath = join(cwd, ".gitignore"); const marker = ".pygienium/"; let content: string; try { content = await readFile(ignorePath, "utf8"); } catch { await writeFile(ignorePath, `${marker}\n`, "utf8"); return true; } if (content.split(/\r?\n/).some((l) => l.trim() === marker)) return false; const prefix = content.endsWith("\n") ? "" : "\n"; await appendFile( ignorePath, `${prefix}# pygienium run-state and check artifacts\n${marker}\n`, "utf8", ); return true; } catch { return false; // not a git work tree, or a best-effort write failed } } /** Mark a phase's status (and optionally an error message). */ export function applyPhaseStatus( state: RunState, checkName: string, phaseId: string, status: PhaseStatus, error?: string, ): void { const check = state.checks[checkName]; if (!check) return; const phase = check.phases.find((p) => p.id === phaseId); if (!phase) return; phase.status = status; const now = Date.now(); if (status === "in_progress") { phase.startedAt = now; if (check.startedAt == null) check.startedAt = now; check.status = "in_progress"; } else if ( status === "complete" || status === "failed" || status === "skipped" ) { phase.finishedAt = now; // Always set (or clear) the error: a phase that previously failed // and then succeeds on retry must not carry a stale error forward. phase.error = error; } } /** * Reconcile a check's overall status from its phases and, when terminal, * stamp `finishedAt`. Used after the cleanup phase resolves. */ export function markCheckStatus( state: RunState, checkName: string, status: CheckStatus, error?: string, ): void { const check = state.checks[checkName]; if (!check) return; check.status = status; // Always set (or clear) the error: a check that previously failed // and then succeeds on retry must not carry a stale error forward. check.error = error; if (status === "complete" || status === "failed" || status === "skipped") { check.finishedAt = Date.now(); } } /** Record findings/changes text on a check. */ export function recordCheckOutput( state: RunState, checkName: string, out: { findings?: string; changes?: string }, ): void { const check = state.checks[checkName]; if (!check) return; if (out.findings !== undefined) check.findings = out.findings; if (out.changes !== undefined) check.changes = out.changes; } /** Mark the overall run status. */ export function markRunStatus(state: RunState, status: RunStatus): void { state.status = status; state.updatedAt = Date.now(); } /** * Determine whether a check is terminal (shouldn't be re-dispatched unless a * fresh run is forced). `pending`/`in_progress`/`failed` are resumable. */ export function isCheckTerminal(check: CheckRun): boolean { return check.status === "complete" || check.status === "skipped"; } /** * Re-dispatch predicate for `/pygienium-resume`: a check runs on resume when it * is not terminal, OR when `--fresh` forced a re-dispatch of everything. */ export function shouldRunOnResume(check: CheckRun, fresh: boolean): boolean { return fresh || !isCheckTerminal(check); } /** * Reset a single check entry back to `pending` with fresh phases. Used by * `/pygienium-resume --fresh` so that previously-complete checks are * re-dispatched from scratch. Preserves `label`/`fix` from the existing entry * unless overridden. */ export function resetCheckEntry( state: RunState, name: string, fixOverride?: boolean, ): void { const existing = state.checks[name]; const fix = fixOverride ?? existing?.fix ?? false; state.checks[name] = { name, label: existing?.label ?? name, status: "pending", fix, phases: phasesForCheck(fix), startedAt: undefined, finishedAt: undefined, findings: undefined, changes: undefined, error: undefined, }; } /** * Compute the next check to run when resuming: the first `in_progress` check, * else the first pending/failed check. Returns `undefined` when nothing remains. */ /** * Recompute the run-level status from check statuses. A run is `complete` only * when every check is terminal-complete; `failed` when every check failed; * `partial` when some checks failed/skipped but others succeeded. */ export function reconcileRunStatus(state: RunState): RunStatus { const checks = Object.values(state.checks); if (checks.length === 0) return "in_progress"; let anyFailed = false; let anySkipped = false; for (const c of checks) { if (c.status === "pending" || c.status === "in_progress") return "in_progress"; if (c.status === "failed") anyFailed = true; if (c.status === "skipped") anySkipped = true; } if (anyFailed) { // Every check failed (none succeeded or were skipped) → the run failed; // a mix of failures and successes is only partially complete. return checks.every((c) => c.status === "failed") ? "failed" : "partial"; } if (anySkipped) return "partial"; return "complete"; }