import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; const ledgerPath = (root) => path.join(root, "tests/golden/WBV2/failure-ledger.json"); const keyFor = (entry) => crypto.createHash("sha256").update(JSON.stringify({ task: entry.task, phase: entry.phase, reason: entry.reason, childExit: entry.childExit ?? null })).digest("hex"); const readLedger = (root) => { try { const value = JSON.parse(fs.readFileSync(ledgerPath(root), "utf8")); return { schemaVersion: 2, entries: Array.isArray(value.entries) ? value.entries : [], hydratedLogs: Array.isArray(value.hydratedLogs) ? value.hydratedLogs : [], }; } catch { return { schemaVersion: 2, entries: [], hydratedLogs: [] }; } }; export function createFailureLedgerSession(root, { logPaths = [] } = {}) { const ledger = readLedger(root); const existing = new Set(ledger.entries.map((entry) => entry.key).filter(Boolean)); const hydrated = new Set(ledger.hydratedLogs); let dirty = false; // Recovery is deliberately narrow: only explicit cycle.completed events can // add entries, and each log is imported at most once. for (const relative of logPaths) { if (hydrated.has(relative)) continue; const file = path.join(root, relative); if (!fs.existsSync(file)) continue; let source; try { source = fs.readFileSync(file, "utf8"); } catch { continue; } if (!/cycle\.completed/u.test(source)) continue; for (const line of source.split("\n")) { if (!/cycle\.completed/u.test(line)) continue; let event; try { const start = line.indexOf("{"); event = JSON.parse(start >= 0 ? line.slice(start) : line); } catch { event = null; } const entry = event?.failure ?? event?.entry; if (!entry || typeof entry !== "object" || !entry.task || !entry.phase || !entry.reason) continue; const key = keyFor(entry); if (existing.has(key)) continue; ledger.entries.push({ ...entry, key, recordedAt: event.recordedAt ?? new Date().toISOString(), hydratedFrom: relative }); existing.add(key); dirty = true; } hydrated.add(relative); dirty = true; } const record = (entry) => { const key = keyFor(entry); if (existing.has(key)) return false; ledger.entries.push({ ...entry, key, recordedAt: new Date().toISOString() }); ledger.entries = ledger.entries.slice(-200); existing.add(key); dirty = true; return true; }; const flush = () => { if (!dirty) return false; const file = ledgerPath(root); fs.mkdirSync(path.dirname(file), { recursive: true }); const temporary = `${file}.tmp-${process.pid}`; fs.writeFileSync(temporary, `${JSON.stringify({ ...ledger, hydratedLogs: [...hydrated] }, null, 2)}\n`); fs.renameSync(temporary, file); dirty = false; return true; }; return { record, flush, ledger }; } export function recordFailure(root, entry) { const session = createFailureLedgerSession(root); const recorded = session.record(entry); session.flush(); return recorded; } /* Legacy callers should migrate to createFailureLedgerSession so a cycle only writes once. */ export function _legacyRecordFailure(root, entry) { const file = ledgerPath(root); let ledger = { schemaVersion: 1, entries: [] }; try { ledger = JSON.parse(fs.readFileSync(file, "utf8")); } catch { /* First failure or an interrupted write. */ } const key = keyFor(entry); if (ledger.entries.some((candidate) => candidate.key === key)) return false; ledger.entries = [...ledger.entries, { ...entry, key, recordedAt: new Date().toISOString() }].slice(-200); fs.mkdirSync(path.dirname(file), { recursive: true }); const temporary = `${file}.tmp-${process.pid}`; fs.writeFileSync(temporary, `${JSON.stringify(ledger, null, 2)}\n`); fs.renameSync(temporary, file); return true; }