import { promises as fs } from "node:fs"; import path from "node:path"; import { getAgentDir } from "@earendil-works/pi-coding-agent"; import { LABEL_KINDS, type LabelRun } from "./types.ts"; function runDir(sessionId: string): string { return path.join(getAgentDir(), "pi-tree-labeler", "runs", sessionId.replace(/[^A-Za-z0-9._-]/g, "_")); } function isRun(value: unknown, sessionId: string): value is LabelRun { if (!value || typeof value !== "object") return false; const run = value as Partial; if (run.version !== 1 || run.sessionId !== sessionId || typeof run.id !== "string" || typeof run.createdAt !== "string") return false; if (!["proposed", "applied", "discarded", "failed"].includes(String(run.status)) || !Array.isArray(run.proposals) || !Array.isArray(run.conflicts)) return false; return run.proposals.every((proposal) => Boolean(proposal) && typeof proposal.entryId === "string" && typeof proposal.label === "string" && LABEL_KINDS.includes(proposal.kind)); } export async function saveRun(run: LabelRun): Promise { const dir = runDir(run.sessionId); await fs.mkdir(dir, { recursive: true, mode: 0o700 }); const target = path.join(dir, `${run.id}.json`); const temporary = `${target}.${process.pid}.${Date.now()}.tmp`; await fs.writeFile(temporary, `${JSON.stringify(run, null, 2)}\n`, { mode: 0o600 }); await fs.rename(temporary, target); } export async function loadRuns(sessionId: string, limit = 20): Promise { const dir = runDir(sessionId); let names: string[]; try { names = await fs.readdir(dir); } catch { return []; } const runs: LabelRun[] = []; for (const name of names.filter((item) => item.endsWith(".json"))) { try { const run: unknown = JSON.parse(await fs.readFile(path.join(dir, name), "utf8")); if (isRun(run, sessionId) && name === `${run.id}.json`) runs.push(run); } catch { /* Ignore corrupt individual history records. */ } } return runs.sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, limit); } export async function latestPendingRun(sessionId: string): Promise { return (await loadRuns(sessionId, Number.MAX_SAFE_INTEGER)).find((run) => run.status === "proposed" && run.proposals.length > 0); }