/** CEREBEL — durable file backend. */ import * as fs from "node:fs/promises"; import * as path from "node:path"; import { resolveNervousStateFile } from "@nervous-system/state"; import { CerebelLedger } from "./store.ts"; import { CerebelError, type CerebelFile } from "./schema.ts"; const LOCK_STALE_TTL_MS = 30_000; const LOCK_MAX_ATTEMPTS = 200; const LOCK_DELAY_MS = 25; export interface CerebelLocation { cerebelPath: string; dir: string; } export function resolveCerebelLocation(cwd: string): CerebelLocation { const cerebelPath = resolveNervousStateFile(cwd, "cerebel", "cerebel.json", "CEREBEL_PATH"); return { cerebelPath, dir: path.dirname(cerebelPath) }; } interface LockInfo { pid: number; ts: number } const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); function isPidAlive(pid: number): boolean { try { process.kill(pid, 0); return true; } catch (err) { const code = (err as NodeJS.ErrnoException).code; return code !== "ESRCH" && code !== "EINVAL"; } } async function readLock(lockPath: string): Promise { try { const parsed = JSON.parse(await fs.readFile(lockPath, "utf8")) as { pid?: unknown; ts?: unknown }; return typeof parsed.pid === "number" && typeof parsed.ts === "number" ? { pid: parsed.pid, ts: parsed.ts } : null; } catch { return null; } } async function isLockStale(lockPath: string): Promise { const info = await readLock(lockPath); return !info || !isPidAlive(info.pid) || Date.now() - info.ts > LOCK_STALE_TTL_MS; } export async function withLock(lockPath: string, fn: () => Promise): Promise { let attempts = 0; for (;;) { try { const handle = await fs.open(lockPath, "wx"); await handle.writeFile(JSON.stringify({ pid: process.pid, ts: Date.now() } satisfies LockInfo)); await handle.close(); try { return await fn(); } finally { try { await fs.unlink(lockPath); } catch { /* ignore */ } } } catch (err) { const code = (err as NodeJS.ErrnoException).code; if (code !== "EEXIST") throw err; attempts++; if (attempts % 20 === 0 && await isLockStale(lockPath)) { try { await fs.unlink(lockPath); } catch { /* raced */ } continue; } if (attempts >= LOCK_MAX_ATTEMPTS) throw new Error(`cerebel: timed out acquiring lock ${lockPath}`); await sleep(LOCK_DELAY_MS); } } } export interface LoadResult { ledger: CerebelLedger; warnings: string[]; fresh: boolean } export class FileBackend { readonly location: CerebelLocation; private readonly lockPath: string; private readonly tmpPath: string; private readonly bakPath: string; constructor(location: CerebelLocation) { this.location = location; this.lockPath = `${location.cerebelPath}.lock`; this.tmpPath = `${location.cerebelPath}.tmp`; this.bakPath = `${location.cerebelPath}.bak`; } async load(): Promise { return this.loadUnlocked(); } async save(ledger: CerebelLedger): Promise { await fs.mkdir(this.location.dir, { recursive: true }); await withLock(this.lockPath, async () => this.saveUnlocked(ledger)); } async mutate(fn: (ledger: CerebelLedger) => T): Promise<{ result: T; warnings: string[] }> { await fs.mkdir(this.location.dir, { recursive: true }); return withLock(this.lockPath, async () => { const { ledger, warnings } = await this.loadUnlocked(); const result = fn(ledger); await this.saveUnlocked(ledger); return { result, warnings }; }); } private async loadUnlocked(): Promise { let raw: string; try { raw = await fs.readFile(this.location.cerebelPath, "utf8"); } catch (err) { const code = (err as NodeJS.ErrnoException).code; if (code === "ENOENT") return { ledger: new CerebelLedger(), warnings: [], fresh: true }; throw err; } try { return { ledger: CerebelLedger.fromJSON(JSON.parse(raw) as CerebelFile), warnings: [], fresh: false }; } catch (err) { if (err instanceof CerebelError) { throw new CerebelError(err.code, `cerebel state at ${this.location.cerebelPath} was rejected: ${err.message}; no migration or automatic reset was performed`); } const stamp = Date.now(); try { await fs.copyFile(this.location.cerebelPath, `${this.location.cerebelPath}.corrupt-${stamp}`); } catch { /* best effort */ } return { ledger: new CerebelLedger(), warnings: [`cerebel state at ${this.location.cerebelPath} was corrupt (${err instanceof Error ? err.message : String(err)}); backed up to .corrupt-${stamp} and started fresh.`], fresh: false }; } } private async saveUnlocked(ledger: CerebelLedger): Promise { const data = JSON.stringify(ledger.toJSON(), null, 2); try { await fs.copyFile(this.location.cerebelPath, this.bakPath); } catch (err) { const code = (err as NodeJS.ErrnoException).code; if (code !== "ENOENT") throw err; } await fs.writeFile(this.tmpPath, data, { encoding: "utf8", mode: 0o600 }); await fs.rename(this.tmpPath, this.location.cerebelPath); } } export class CerebelStore { readonly backend: FileBackend; constructor(backend: FileBackend) { this.backend = backend; } static fromCwd(cwd: string): CerebelStore { return new CerebelStore(new FileBackend(resolveCerebelLocation(cwd))); } async query(fn: (ledger: CerebelLedger) => T): Promise<{ result: T; warnings: string[] }> { const { ledger, warnings } = await this.backend.load(); return { result: fn(ledger), warnings }; } async mutate(fn: (ledger: CerebelLedger) => T): Promise<{ result: T; warnings: string[] }> { return this.backend.mutate(fn); } }