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 { withCinemaFileLock } from './cinema-file-lock.js'; import { stableCinemaJson } from './cinema-shot-compiler.js'; import { cinemaArtifactsDirFor, readCinemaGenerationLedger } from './cinema-store.js'; import type { CinemaReviewDecision, CinemaReviewLedgerArtifact } from './cinema-review-types.js'; import { validateCinemaReviewLedger } from './cinema-review-validation.js'; export interface CinemaReviewAppendResult { appended: boolean; artifact: CinemaReviewLedgerArtifact; decision: CinemaReviewDecision; } export function cinemaReviewLedgerPathFor(root: string, projectSlug: string): string { return join(cinemaArtifactsDirFor(root, projectSlug), 'review-ledger.json'); } function emptyLedger(projectSlug: string, generatedAt: string): CinemaReviewLedgerArtifact { return { schemaVersion: 1, projectSlug, generatedAt, decisions: [] }; } async function readRaw(path: string): Promise { if (!existsSync(path)) return null; try { return JSON.parse(await readFile(path, 'utf8')) as CinemaReviewLedgerArtifact; } catch (error) { throw new Error(`Cinema review ledger is not readable valid JSON at ${path}: ${error instanceof Error ? error.message : String(error)}`); } } export async function readCinemaReviewLedger( root: string, projectSlug: string, ): Promise { const artifact = await readRaw(cinemaReviewLedgerPathFor(root, projectSlug)); if (!artifact) return null; if (artifact.projectSlug !== projectSlug) throw new Error(`Cinema review ledger belongs to ${artifact.projectSlug}, expected ${projectSlug}`); const generationLedger = await readCinemaGenerationLedger(root, projectSlug); if (!generationLedger) throw new Error('Cinema review ledger requires an existing generation ledger'); const validation = validateCinemaReviewLedger(artifact, generationLedger); if (!validation.ok) throw new Error(`Cinema review ledger failed validation: ${validation.issues.map((issue) => `${issue.path}: ${issue.message}`).join('; ')}`); return artifact; } export async function appendCinemaReviewDecision( root: string, projectSlug: string, decision: CinemaReviewDecision, recordedAt = new Date().toISOString(), ): Promise { const path = cinemaReviewLedgerPathFor(root, projectSlug); return withCinemaFileLock(path, async () => { const generationLedger = await readCinemaGenerationLedger(root, projectSlug); if (!generationLedger) throw new Error('Cannot append a review decision before the generation ledger exists'); const current = await readRaw(path) ?? emptyLedger(projectSlug, recordedAt); const existing = current.decisions.find((entry) => entry.decisionId === decision.decisionId); if (existing) { if (stableCinemaJson(existing) !== stableCinemaJson(decision)) throw new Error(`Cinema review decision ${decision.decisionId} is immutable and already exists with different content`); return { appended: false, artifact: current, decision: existing }; } const artifact: CinemaReviewLedgerArtifact = { ...current, generatedAt: recordedAt, decisions: [...current.decisions, structuredClone(decision)], }; const validation = validateCinemaReviewLedger(artifact, generationLedger); if (!validation.ok) throw new Error(`Cinema review decision failed validation: ${validation.issues.map((issue) => `${issue.path}: ${issue.message}`).join('; ')}`); await mkdir(dirname(path), { recursive: true }); await writeTextFileAtomic(path, `${JSON.stringify(artifact, null, 2)}\n`); return { appended: true, artifact, decision }; }); }