import { existsSync } from 'node:fs'; import { mkdir, readFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { writeTextFileAtomic } from './atomic-write.js'; import { cinemaEvidenceLedgerHash, validateCinemaEvidenceLedger } from './cinema-evidence.js'; import type { CinemaEvidenceDecision, CinemaEvidenceLedgerArtifact } from './cinema-evidence-types.js'; import { withCinemaFileLock } from './cinema-file-lock.js'; import { readCinemaPlanningPackage } from './cinema-planning-store.js'; import { cinemaArtifactsDirFor } from './cinema-store.js'; import { stableCinemaJson } from './cinema-shot-compiler.js'; export function cinemaEvidenceLedgerPathFor(root: string, projectSlug: string): string { return join(cinemaArtifactsDirFor(root, projectSlug), 'evidence-ledger.json'); } function emptyLedger(projectSlug: string, planningReceiptHash: CinemaEvidenceLedgerArtifact['planningReceiptHash'], generatedAt: string): CinemaEvidenceLedgerArtifact { const input = { schemaVersion: 1 as const, projectSlug, planningReceiptHash, generatedAt, decisions: [] }; return { ...input, contentHash: cinemaEvidenceLedgerHash(input) }; } export async function readCinemaEvidenceLedger(root: string, projectSlug: string): Promise { const path = cinemaEvidenceLedgerPathFor(root, projectSlug); if (!existsSync(path)) return null; let ledger: CinemaEvidenceLedgerArtifact; try { ledger = JSON.parse(await readFile(path, 'utf8')) as CinemaEvidenceLedgerArtifact; } catch (error) { throw new Error(`Cinema evidence ledger is not readable valid JSON at ${path}: ${error instanceof Error ? error.message : String(error)}`); } const planning = await readCinemaPlanningPackage(root, projectSlug); if (!planning) throw new Error(`Cinema evidence ledger exists without a planning package for ${projectSlug}`); const validation = validateCinemaEvidenceLedger(ledger, planning); if (!validation.ok) throw new Error(`Cinema evidence ledger failed validation: ${validation.issues.map((issue) => `${issue.path}: ${issue.message}`).join('; ')}`); return ledger; } export interface CinemaEvidenceAppendResult { appended: boolean; decision: CinemaEvidenceDecision; ledger: CinemaEvidenceLedgerArtifact; } export async function appendCinemaEvidenceDecision( root: string, projectSlug: string, decision: CinemaEvidenceDecision, ): Promise { const planning = await readCinemaPlanningPackage(root, projectSlug); if (!planning) throw new Error(`Cannot append Cinema evidence before planning exists for ${projectSlug}`); const path = cinemaEvidenceLedgerPathFor(root, projectSlug); await mkdir(dirname(path), { recursive: true }); return withCinemaFileLock(path, async () => { const current = await readCinemaEvidenceLedger(root, projectSlug) ?? emptyLedger(projectSlug, planning.receipt.contentHash, decision.decidedAt); const existing = current.decisions.find((item) => item.decisionId === decision.decisionId); if (existing) { if (stableCinemaJson(existing) !== stableCinemaJson(decision)) throw new Error(`Cinema evidence decision ${decision.decisionId} is immutable and already exists with different content`); return { appended: false, decision: existing, ledger: current }; } const { contentHash: _currentHash, ...currentInput } = current; const input: Omit = { ...currentInput, generatedAt: decision.decidedAt, decisions: [...current.decisions, structuredClone(decision)], }; const ledger: CinemaEvidenceLedgerArtifact = { ...input, contentHash: cinemaEvidenceLedgerHash(input) }; const validation = validateCinemaEvidenceLedger(ledger, planning); if (!validation.ok) throw new Error(`Cinema evidence decision failed validation: ${validation.issues.map((issue) => `${issue.path}: ${issue.message}`).join('; ')}`); await writeTextFileAtomic(path, `${JSON.stringify(ledger, null, 2)}\n`); return { appended: true, decision, ledger }; }); }