/** * Manifest/tasks load paths with corruption recovery and quarantine * (ST-4, ST-9, STATE-3). Extracted verbatim from state-store.ts (Phase 2.5 * god-file decomposition — pure code motion, no behavior change). * * Import direction is one-way: state-store.ts imports from this module; * this module does NOT import values from state-store.ts. */ import * as fs from "node:fs"; import { logInternalError } from "../../utils/internal-error.ts"; import { atomicWriteJson, atomicWriteJsonAsync } from "../atomic-write.ts"; import { isTeamTaskStatus } from "../contracts.ts"; import { reconstructTasksFromEvents } from "../event-log/event-reconstructor.ts"; import type { TeamRunManifest, TeamTaskState } from "../types.ts"; import { CURRENT_TASKS_SCHEMA_VERSION } from "../types.ts"; /** * ST-4: Rename a corrupt file to a quarantine path (`.corrupt-`) so it is * preserved for debugging but no longer read as the primary source of truth. */ /** @internal — Phase 2.5 split: also used by state-store.ts (loadRunManifestById loaders). */ export function quarantineCorruptFile(filePath: string): void { try { fs.renameSync(filePath, `${filePath}.corrupt-${Date.now()}`); } catch { // Best-effort — if rename fails (file already gone, permission, etc.), // we still proceed with reconstruction from the event log. } } /** * R39-M2 (fix-wave 6, EP3): per-run report of a tasks.json corruption recovery. * Corruption previously DROPPED tasks silently (events.jsonl only holds tasks * that already emitted events) and the run then reported "completed 100%" with * no trace of the loss. This record makes the loss observable. */ export interface TasksCorruptionReport { runId: string; /** Path the corrupt file was moved to (best-effort — may be empty on rename failure). */ quarantinedPath: string; /** Number of tasks recovered from events.jsonl. */ reconstructedCount: number; /** Number of task ids salvageable from the corrupt payload (best-effort). */ salvagedCount: number; /** Task ids present in the corrupt payload that could NOT be recovered. */ droppedIds: string[]; at: number; } const tasksCorruptionReports = new Map(); /** R39-M2: the last tasks.json corruption report for a run, if any (this process). */ export function getTasksCorruptionReport(runId: string): TasksCorruptionReport | undefined { return tasksCorruptionReports.get(runId); } /** R39-M2: all tasks.json corruption reports recorded in this process. */ export function listTasksCorruptionReports(): TasksCorruptionReport[] { return [...tasksCorruptionReports.values()]; } /** @internal test hook */ export function __test__clearTasksCorruptionReports(): void { tasksCorruptionReports.clear(); } /** * R39-M2: best-effort salvage of task ids from a corrupt tasks.json payload. * * Scans for top-level (brace-depth 0) `{...}` objects — i.e. elements of the * bare-array write format — and keeps the `id` of every candidate that parses * as JSON. Nested objects (depth >= 1) are ignored, so an unrelated nested * `id` (routing/usage/etc.) cannot inflate the count. Returns [] for a payload * with no salvageable task objects (the total-loss case). */ export function salvageTaskIds(raw: string): string[] { const ids: string[] = []; let depth = 0; let start = -1; let inString = false; let escaped = false; for (let i = 0; i < raw.length; i++) { const ch = raw[i]; if (inString) { if (escaped) escaped = false; else if (ch === "\\") escaped = true; else if (ch === '"') inString = false; continue; } if (ch === '"') { inString = true; continue; } if (ch === "{") { if (depth === 0) start = i; depth++; } else if (ch === "}" && depth > 0) { depth--; if (depth === 0 && start >= 0) { const candidate = raw.slice(start, i + 1); start = -1; try { const obj = JSON.parse(candidate) as { id?: unknown }; if (obj && typeof obj.id === "string") ids.push(obj.id); } catch { /* partial/truncated object — not salvageable */ } } } } return ids; } /** * R39-M2: shared corruption-recovery step for both sync and async load paths. * Quarantines, reconstructs from events, DETECTS + RECORDS the dropped task * count, and logs loudly so a silent task-drop can no longer masquerade as a * clean "completed 100%" run. Returns the reconstructed tasks (persisted by * the caller). */ function recoverCorruptTasks(tasksPath: string, eventsPath: string, runId: string, raw: string): TeamTaskState[] { const quarantinedPath = `${tasksPath}.corrupt-${Date.now()}`; quarantineCorruptFile(tasksPath); const reconstructed = reconstructTasksFromEventLog(eventsPath, runId); const reconstructedIds = new Set(reconstructed.map((task) => task.id)); const salvagedIds = salvageTaskIds(raw); const droppedIds = salvagedIds.filter((id) => !reconstructedIds.has(id)); tasksCorruptionReports.set(runId, { runId, quarantinedPath, reconstructedCount: reconstructed.length, salvagedCount: salvagedIds.length, droppedIds, at: Date.now(), }); const dropDetail = droppedIds.length > 0 ? `; ${droppedIds.length} task(s) present in the corrupt file were NOT recoverable (dropped: ${droppedIds.join(",")}) — run state is DEGRADED` : " (no dropped task ids detected)"; logInternalError( "state-store", new Error( `R39-M2: tasks.json for run ${runId} is corrupt — quarantined; reconstructed ${reconstructed.length} task(s) from events.jsonl${dropDetail}. Preserve the .corrupt-* file for diagnosis.`, ), undefined, droppedIds.length > 0 ? "error" : "warn", ); return reconstructed; } /** * ST-4: Convert event-reconstructor output into TeamTaskState[]. * Reconstructed tasks carry lifecycle data (id, status, timing) from the * event log; auxiliary fields (role, agent, title) are filled with defaults * since they are not present in lifecycle events. */ function reconstructTasksFromEventLog(eventsPath: string, runId: string): TeamTaskState[] { try { const result = reconstructTasksFromEvents(eventsPath); const tasks: TeamTaskState[] = []; for (const [, rt] of result.tasks) { tasks.push({ id: rt.id, runId, role: "unknown", agent: "unknown", title: "reconstructed from events", status: isTeamTaskStatus(rt.status) ? rt.status : "queued", dependsOn: [], cwd: "", startedAt: rt.startedAt, finishedAt: rt.finishedAt, error: rt.error, segment: rt.segment, diagnostics: rt.diagnostics, metrics: rt.metrics, }); } return tasks; } catch { return []; } } /** * ST-4: Load tasks.json with corruption recovery (sync path). * * Distinguishes: * - ENOENT / ENOTDIR → legitimate empty → [] (NOT quarantined). * - SyntaxError (parse failure) → corrupt → quarantine `.corrupt-` AND * reconstruct from events.jsonl. If reconstruction yields tasks, persist * them so subsequent loads see a valid file. * - Non-array JSON (e.g. `{}`) → corrupt → same as SyntaxError. * - Valid array → return as-is. */ /** * ST-9: Extract the task array from a tasks.json payload. * * Accepts both the v0 legacy bare-array format and the v1+ envelope * `{ schemaVersion, tasks }`. Returns [] for unrecognized shapes. */ /** @internal — Phase 2.5 split: also used by state-store.ts (shouldPersistTasks). */ export function extractTaskArray(raw: unknown): TeamTaskState[] { if (Array.isArray(raw)) return raw as TeamTaskState[]; if (raw !== null && typeof raw === "object" && "tasks" in raw) { const envelope = raw as { tasks?: unknown }; if (Array.isArray(envelope.tasks)) return envelope.tasks as TeamTaskState[]; } return []; } /** * ST-9: Whether `parsed` is a recognizable tasks.json shape (v0 bare array * or v1+ envelope). Used to distinguish legitimate formats from corruption. */ function isRecognizableTasksPayload(parsed: unknown): boolean { if (Array.isArray(parsed)) return true; if (parsed !== null && typeof parsed === "object" && "tasks" in parsed) { return Array.isArray((parsed as { tasks?: unknown }).tasks); } return false; } /** * ST-9: Version-check + migration hook for tasks.json. * * tasks.json has two on-disk shapes: * - v0 (current): bare JSON array `TeamTaskState[]` — what saveRunTasks* * write today (backward-compatible; no schemaVersion envelope). * - v1+ (future): envelope `{ schemaVersion: number, tasks: TeamTaskState[] }` * — read-supported defensively for a future write-side switch. * * This detects the shape and returns the task array. v0 needs NO migration * (it IS the current write format). For v1+ envelopes, a schemaVersion * mismatch warns (mirroring the manifest check). Future breaking changes * add real migration logic here. */ function migrateTasksFile(parsed: unknown, runId: string): TeamTaskState[] { // v0 current: bare array (no schemaVersion envelope) — what writers produce. if (Array.isArray(parsed)) { // v0 bare array is the CURRENT write format (saveRunTasks* write the // array directly, by design — backward compat). Nothing to migrate: // return as-is. (v1+ envelope read-support below is defensive, for a // future write-side switch.) Do NOT warn here — it would fire for 100% // of runs on every load and flood the UI on startup. return parsed as TeamTaskState[]; } // v1+ envelope: { schemaVersion, tasks }. if (parsed !== null && typeof parsed === "object" && "tasks" in parsed) { const envelope = parsed as { schemaVersion?: unknown; tasks?: unknown }; const detected = typeof envelope.schemaVersion === "number" ? envelope.schemaVersion : 0; if (detected !== CURRENT_TASKS_SCHEMA_VERSION) { logInternalError( "state-store", new Error( `tasks.json schemaVersion mismatch: expected ${CURRENT_TASKS_SCHEMA_VERSION}, got ${detected}. Run ${runId} may be incompatible.`, ), undefined, "warn", ); } } return extractTaskArray(parsed); } export function loadTasksWithRecovery(tasksPath: string, eventsPath: string, runId: string): TeamTaskState[] { let content: string; try { content = fs.readFileSync(tasksPath, "utf-8"); } catch { // ENOENT / ENOTDIR / other read errors → empty (retry loop handles // transient instability; ENOENT is a legitimate empty run). return []; } let parsed: unknown; try { parsed = JSON.parse(content); } catch { // SyntaxError — corrupt file. const reconstructed = recoverCorruptTasks(tasksPath, eventsPath, runId, content); if (reconstructed.length > 0) atomicWriteJson(tasksPath, reconstructed, { compact: true }); return reconstructed; } if (!isRecognizableTasksPayload(parsed)) { // Neither v0 bare array nor v1+ envelope (e.g. `{}`) — corrupt. const reconstructed = recoverCorruptTasks(tasksPath, eventsPath, runId, content); if (reconstructed.length > 0) atomicWriteJson(tasksPath, reconstructed, { compact: true }); return reconstructed; } return migrateTasksFile(parsed, runId); } /** * STATE-3: Load manifest.json with corruption quarantine (sync). Distinguishes: * - ENOENT / read error → undefined (legitimate missing run — NOT quarantined). * - SyntaxError (unparseable) → CORRUPT → quarantine `.corrupt-` + log + undefined. * Manifest CANNOT be reconstructed from events.jsonl (run.created only carries * {team, workflow}), so quarantine + visible log is the recovery — do NOT attempt * reconstruction (unlike loadTasksWithRecovery). This prevents a corrupt manifest * from silently making a run invisible (STATE-3). */ export function loadManifestWithRecovery(manifestPath: string, runId: string): TeamRunManifest | undefined { let content: string; try { content = fs.readFileSync(manifestPath, "utf-8"); } catch { // ENOENT / ENOTDIR / other read error → legitimate missing run. return undefined; } try { return JSON.parse(content) as TeamRunManifest; } catch { // SyntaxError → corrupt manifest. Quarantine (preserve for diagnosis) + log, // then treat as missing. Do NOT reconstruct (infeasible from events). quarantineCorruptFile(manifestPath); logInternalError( "state-store", new Error( `STATE-3: manifest.json for run ${runId} is corrupt (unparseable) — quarantined to ${manifestPath}.corrupt-*. Run is now treated as missing. Preserve the .corrupt-* file for diagnosis.`, ), undefined, "error", ); return undefined; } } /** * ST-4: async twin of {@link loadTasksWithRecovery}. */ /** @internal — Phase 2.5 split: async twin used by state-store.ts (loadRunManifestByIdAsync). */ export async function loadTasksWithRecoveryAsync(tasksPath: string, eventsPath: string, runId: string): Promise { let content: string; try { content = await fs.promises.readFile(tasksPath, "utf-8"); } catch { return []; } let parsed: unknown; try { parsed = JSON.parse(content); } catch { const reconstructed = recoverCorruptTasks(tasksPath, eventsPath, runId, content); if (reconstructed.length > 0) await atomicWriteJsonAsync(tasksPath, reconstructed, { compact: true }); return reconstructed; } if (!isRecognizableTasksPayload(parsed)) { const reconstructed = recoverCorruptTasks(tasksPath, eventsPath, runId, content); if (reconstructed.length > 0) await atomicWriteJsonAsync(tasksPath, reconstructed, { compact: true }); return reconstructed; } return migrateTasksFile(parsed, runId); }