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 { stableCinemaJson } from './cinema-shot-compiler.js'; import type { CinemaCharacterSheetArtifact, CinemaContractValidation, CinemaGenerationAttempt, CinemaGenerationLedgerArtifact, CinemaAttemptOutcome, CinemaPromotionEvent, CinemaReferencePackArtifact, CinemaShotManifestArtifact, } from './cinema-types.js'; import { validateCinemaCharacterSheet, validateCinemaGenerationLedger, validateCinemaReferencePack, validateCinemaShotManifest, } from './cinema-validation.js'; import { resolveProjectWorkspace } from './workspace.js'; import { withCinemaFileLock } from './cinema-file-lock.js'; const CINEMA_DIRECTORY = 'cinema'; export interface CinemaAppendResult { appended: boolean; artifact: CinemaGenerationLedgerArtifact; event: T; } function safeSegment(value: string, label: string): string { if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value)) { throw new Error(`${label} must be a path-safe identifier, got ${JSON.stringify(value)}`); } return value; } export function cinemaArtifactsDirFor(root: string, projectSlug: string): string { return join(resolveProjectWorkspace(projectSlug, root).artifactsDir, CINEMA_DIRECTORY); } export function cinemaCharacterSheetPathFor(root: string, projectSlug: string, characterVersionId: string): string { return join(cinemaArtifactsDirFor(root, projectSlug), 'character-sheets', `${safeSegment(characterVersionId, 'characterVersionId')}.json`); } export function cinemaReferencePackPathFor(root: string, projectSlug: string, packId: string): string { return join(cinemaArtifactsDirFor(root, projectSlug), 'reference-packs', `${safeSegment(packId, 'packId')}.json`); } export function cinemaShotManifestPathFor(root: string, projectSlug: string, shotId: string): string { return join(cinemaArtifactsDirFor(root, projectSlug), 'shot-manifests', `${safeSegment(shotId, 'shotId')}.json`); } export function cinemaGenerationLedgerPathFor(root: string, projectSlug: string): string { return join(cinemaArtifactsDirFor(root, projectSlug), 'generation-ledger.json'); } function assertValid(label: string, validation: CinemaContractValidation): void { if (validation.ok) return; const details = validation.issues.slice(0, 8).map((issue) => `${issue.path}: ${issue.message}`).join('; '); throw new Error(`${label} failed Cinema contract validation: ${details}`); } async function readJson(path: string, label: string): Promise { if (!existsSync(path)) return null; try { return JSON.parse(await readFile(path, 'utf8')) as T; } catch (error) { throw new Error(`${label} is not readable valid JSON at ${path}: ${error instanceof Error ? error.message : String(error)}`); } } async function writeImmutable( path: string, artifact: T, label: string, validate: (value: T) => CinemaContractValidation, ): Promise { assertValid(label, validate(artifact)); await mkdir(dirname(path), { recursive: true }); const existing = await readJson(path, label); if (existing) { if (stableCinemaJson(existing) === stableCinemaJson(artifact)) return path; throw new Error(`${label} is immutable and already exists with different content: ${path}`); } await writeTextFileAtomic(path, `${JSON.stringify(artifact, null, 2)}\n`); return path; } function assertProject(expected: string, actual: string, label: string): void { if (expected !== actual) throw new Error(`${label} belongs to project ${actual}, expected ${expected}`); } export async function writeCinemaCharacterSheet( root: string, sheet: CinemaCharacterSheetArtifact, ): Promise { return writeImmutable( cinemaCharacterSheetPathFor(root, sheet.projectSlug, sheet.characterVersionId), sheet, `Cinema character sheet ${sheet.characterVersionId}`, validateCinemaCharacterSheet, ); } export async function readCinemaCharacterSheet( root: string, projectSlug: string, characterVersionId: string, ): Promise { const artifact = await readJson( cinemaCharacterSheetPathFor(root, projectSlug, characterVersionId), `Cinema character sheet ${characterVersionId}`, ); if (!artifact) return null; assertProject(projectSlug, artifact.projectSlug, `Cinema character sheet ${characterVersionId}`); assertValid(`Cinema character sheet ${characterVersionId}`, validateCinemaCharacterSheet(artifact)); return artifact; } export async function writeCinemaReferencePack( root: string, pack: CinemaReferencePackArtifact, ): Promise { return writeImmutable( cinemaReferencePackPathFor(root, pack.projectSlug, pack.packId), pack, `Cinema reference pack ${pack.packId}`, validateCinemaReferencePack, ); } export async function readCinemaReferencePack( root: string, projectSlug: string, packId: string, ): Promise { const artifact = await readJson( cinemaReferencePackPathFor(root, projectSlug, packId), `Cinema reference pack ${packId}`, ); if (!artifact) return null; assertProject(projectSlug, artifact.projectSlug, `Cinema reference pack ${packId}`); assertValid(`Cinema reference pack ${packId}`, validateCinemaReferencePack(artifact)); return artifact; } export async function writeCinemaShotManifest( root: string, manifest: CinemaShotManifestArtifact, ): Promise { return writeImmutable( cinemaShotManifestPathFor(root, manifest.projectSlug, manifest.shotId), manifest, `Cinema shot manifest ${manifest.shotId}`, validateCinemaShotManifest, ); } export async function readCinemaShotManifest( root: string, projectSlug: string, shotId: string, ): Promise { const artifact = await readJson( cinemaShotManifestPathFor(root, projectSlug, shotId), `Cinema shot manifest ${shotId}`, ); if (!artifact) return null; assertProject(projectSlug, artifact.projectSlug, `Cinema shot manifest ${shotId}`); assertValid(`Cinema shot manifest ${shotId}`, validateCinemaShotManifest(artifact)); return artifact; } function emptyLedger(projectSlug: string, generatedAt: string): CinemaGenerationLedgerArtifact { return { schemaVersion: 1, projectSlug, generatedAt, attempts: [], promotions: [] }; } export async function readCinemaGenerationLedger( root: string, projectSlug: string, ): Promise { const artifact = await readJson( cinemaGenerationLedgerPathFor(root, projectSlug), 'Cinema generation ledger', ); if (!artifact) return null; assertProject(projectSlug, artifact.projectSlug, 'Cinema generation ledger'); assertValid('Cinema generation ledger', validateCinemaGenerationLedger(artifact)); return artifact; } async function updateLedger( root: string, projectSlug: string, recordedAt: string, update: (current: CinemaGenerationLedgerArtifact) => CinemaAppendResult, ): Promise> { const path = cinemaGenerationLedgerPathFor(root, projectSlug); return withCinemaFileLock(path, async () => { const current = await readCinemaGenerationLedger(root, projectSlug) ?? emptyLedger(projectSlug, recordedAt); const result = update(current); assertValid('Cinema generation ledger', validateCinemaGenerationLedger(result.artifact)); if (result.appended) await writeTextFileAtomic(path, `${JSON.stringify(result.artifact, null, 2)}\n`); return result; }); } export async function appendCinemaGenerationAttempt( root: string, projectSlug: string, attempt: CinemaGenerationAttempt, recordedAt = new Date().toISOString(), ): Promise> { return updateLedger(root, projectSlug, recordedAt, (current) => { const existing = current.attempts.find((entry) => entry.attemptId === attempt.attemptId); if (existing) { if (stableCinemaJson(existing) !== stableCinemaJson(attempt)) { throw new Error(`Cinema attempt ${attempt.attemptId} is immutable and already exists with different content`); } return { appended: false, artifact: current, event: existing }; } const artifact = { ...current, generatedAt: recordedAt, attempts: [...current.attempts, structuredClone(attempt)], }; return { appended: true, artifact, event: attempt }; }); } export async function appendCinemaAttemptOutcome( root: string, projectSlug: string, outcome: CinemaAttemptOutcome, recordedAt = new Date().toISOString(), ): Promise> { return updateLedger(root, projectSlug, recordedAt, (current) => { const existing = (current.outcomes ?? []).find((entry) => entry.outcomeId === outcome.outcomeId); if (existing) { if (stableCinemaJson(existing) !== stableCinemaJson(outcome)) throw new Error(`Cinema outcome ${outcome.outcomeId} is immutable and already exists with different content`); return { appended: false, artifact: current, event: existing }; } const artifact = { ...current, generatedAt: recordedAt, outcomes: [...(current.outcomes ?? []), structuredClone(outcome)] }; return { appended: true, artifact, event: outcome }; }); } export async function appendCinemaPromotion( root: string, projectSlug: string, promotion: CinemaPromotionEvent, recordedAt = new Date().toISOString(), ): Promise> { return updateLedger(root, projectSlug, recordedAt, (current) => { const existing = current.promotions.find((entry) => entry.promotionId === promotion.promotionId); if (existing) { if (stableCinemaJson(existing) !== stableCinemaJson(promotion)) { throw new Error(`Cinema promotion ${promotion.promotionId} is immutable and already exists with different content`); } return { appended: false, artifact: current, event: existing }; } const artifact = { ...current, generatedAt: recordedAt, promotions: [...current.promotions, structuredClone(promotion)], }; return { appended: true, artifact, event: promotion }; }); }