import { existsSync } from 'node:fs'; import { mkdir, readFile, rename, rm } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { randomUUID } from 'node:crypto'; import { writeTextFileAtomic } from './atomic-write.js'; import { withCinemaFileLock } from './cinema-file-lock.js'; import { cinemaArtifactsDirFor } from './cinema-store.js'; import { validateCinemaPlanningPackage, type CinemaPlanningPackage, type CinemaPlanningReceiptArtifact, } from './cinema-project-planner.js'; const FILES = { creativeContract: 'creative-contract.json', storyBible: 'story-bible.json', visualSoundBible: 'visual-sound-bible.json', assetEvidence: 'asset-evidence.json', geographyMap: 'geography-map.json', coverage: 'coverage.json', rightsRegistry: 'rights-registry.json', voiceRegistry: 'voice-registry.json', receipt: 'receipt.json', } as const; interface CinemaPlanningPointer { schemaVersion: 1; projectSlug: string; intakeHash: string; relativeDirectory: string; receiptHash: string; } export interface CinemaPlanningWriteResult { created: boolean; packageDirectory: string; pointerPath: string; receipt: CinemaPlanningReceiptArtifact; } function hashSegment(value: string): string { if (!/^sha256:[a-f0-9]{64}$/.test(value)) throw new Error(`Invalid Cinema intake hash: ${value}`); return value.slice('sha256:'.length); } export function cinemaPlanningPointerPathFor(root: string, projectSlug: string): string { return join(cinemaArtifactsDirFor(root, projectSlug), 'planning-current.json'); } export function cinemaPlanningPackageDirFor(root: string, projectSlug: string, intakeHash: string): string { return join(cinemaArtifactsDirFor(root, projectSlug), 'blueprints', hashSegment(intakeHash)); } async function readJson(path: string): Promise { try { return JSON.parse(await readFile(path, 'utf8')) as T; } catch (error) { throw new Error(`Cinema planning artifact is not readable valid JSON at ${path}: ${error instanceof Error ? error.message : String(error)}`); } } async function readPackageDirectory(directory: string): Promise { return { story: { creativeContract: await readJson(join(directory, FILES.creativeContract)), storyBible: await readJson(join(directory, FILES.storyBible)), }, visualSoundBible: await readJson(join(directory, FILES.visualSoundBible)), assetEvidence: await readJson(join(directory, FILES.assetEvidence)), geographyMap: await readJson(join(directory, FILES.geographyMap)), coverage: await readJson(join(directory, FILES.coverage)), rightsRegistry: await readJson(join(directory, FILES.rightsRegistry)), voiceRegistry: await readJson(join(directory, FILES.voiceRegistry)), receipt: await readJson(join(directory, FILES.receipt)), }; } function assertPackage(value: CinemaPlanningPackage): void { const validation = validateCinemaPlanningPackage(value); if (validation.ok) return; const summary = validation.issues.slice(0, 8).map((issue) => `${issue.path}: ${issue.message}`).join('; '); throw new Error(`Cinema planning package failed validation: ${summary}`); } async function writePackageDirectory(directory: string, value: CinemaPlanningPackage): Promise { await mkdir(directory, { recursive: true }); await Promise.all([ writeTextFileAtomic(join(directory, FILES.creativeContract), `${JSON.stringify(value.story.creativeContract, null, 2)}\n`), writeTextFileAtomic(join(directory, FILES.storyBible), `${JSON.stringify(value.story.storyBible, null, 2)}\n`), writeTextFileAtomic(join(directory, FILES.visualSoundBible), `${JSON.stringify(value.visualSoundBible, null, 2)}\n`), writeTextFileAtomic(join(directory, FILES.assetEvidence), `${JSON.stringify(value.assetEvidence, null, 2)}\n`), writeTextFileAtomic(join(directory, FILES.geographyMap), `${JSON.stringify(value.geographyMap, null, 2)}\n`), writeTextFileAtomic(join(directory, FILES.coverage), `${JSON.stringify(value.coverage, null, 2)}\n`), writeTextFileAtomic(join(directory, FILES.rightsRegistry), `${JSON.stringify(value.rightsRegistry, null, 2)}\n`), writeTextFileAtomic(join(directory, FILES.voiceRegistry), `${JSON.stringify(value.voiceRegistry, null, 2)}\n`), ]); await writeTextFileAtomic(join(directory, FILES.receipt), `${JSON.stringify(value.receipt, null, 2)}\n`); } export async function readCinemaPlanningPackage(root: string, projectSlug: string): Promise { const pointerPath = cinemaPlanningPointerPathFor(root, projectSlug); if (!existsSync(pointerPath)) return null; const pointer = await readJson(pointerPath); if (pointer.schemaVersion !== 1 || pointer.projectSlug !== projectSlug || pointer.relativeDirectory !== join('blueprints', hashSegment(pointer.intakeHash))) { throw new Error(`Cinema planning pointer is invalid for project ${projectSlug}`); } const directory = join(cinemaArtifactsDirFor(root, projectSlug), pointer.relativeDirectory); const value = await readPackageDirectory(directory); assertPackage(value); if (value.receipt.intakeHash !== pointer.intakeHash || value.receipt.contentHash !== pointer.receiptHash) { throw new Error(`Cinema planning pointer is stale or corrupt for project ${projectSlug}`); } return value; } export async function writeCinemaPlanningPackage(root: string, value: CinemaPlanningPackage): Promise { assertPackage(value); const { projectSlug, intakeHash } = value.receipt; const pointerPath = cinemaPlanningPointerPathFor(root, projectSlug); await mkdir(dirname(pointerPath), { recursive: true }); return withCinemaFileLock(pointerPath, async () => { const current = await readCinemaPlanningPackage(root, projectSlug); if (current) { if (current.receipt.intakeHash !== intakeHash) { throw new Error(`Cinema project ${projectSlug} already has an immutable planning package for a different intake; use the future cinema-revise workflow instead of overwriting evidence`); } return { created: false, packageDirectory: cinemaPlanningPackageDirFor(root, projectSlug, intakeHash), pointerPath, receipt: current.receipt, }; } const packageDirectory = cinemaPlanningPackageDirFor(root, projectSlug, intakeHash); let committedReceipt = value.receipt; if (!existsSync(packageDirectory)) { const tempDirectory = `${packageDirectory}.tmp-${randomUUID()}`; try { await writePackageDirectory(tempDirectory, value); assertPackage(await readPackageDirectory(tempDirectory)); await mkdir(dirname(packageDirectory), { recursive: true }); await rename(tempDirectory, packageDirectory); } catch (error) { await rm(tempDirectory, { recursive: true, force: true }); throw error; } } else { const existing = await readPackageDirectory(packageDirectory); assertPackage(existing); if (existing.receipt.intakeHash !== intakeHash) throw new Error(`Cinema planning directory already exists for a different intake: ${packageDirectory}`); committedReceipt = existing.receipt; } const pointer: CinemaPlanningPointer = { schemaVersion: 1, projectSlug, intakeHash, relativeDirectory: join('blueprints', hashSegment(intakeHash)), receiptHash: committedReceipt.contentHash, }; await writeTextFileAtomic(pointerPath, `${JSON.stringify(pointer, null, 2)}\n`); return { created: true, packageDirectory, pointerPath, receipt: committedReceipt }; }); }