102 lines
3.8 KiB
JavaScript
102 lines
3.8 KiB
JavaScript
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;
|
|
}
|