import { createHash } from 'node:crypto'; import { existsSync } from 'node:fs'; import { mkdir, readFile, readdir } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { writeArtifact } from './artifact-store.js'; import { writeTextFileAtomic } from './atomic-write.js'; import { readCinemaCandidateBridgeReceipt } from './cinema-candidate-bridge.js'; import { withCinemaFileLock } from './cinema-file-lock.js'; import { cinemaArtifactContentHash } from './cinema-provider-contracts.js'; import { readCinemaProductionQueue } from './cinema-production-queue.js'; import { appendCinemaPromotion, cinemaArtifactsDirFor, readCinemaGenerationLedger } from './cinema-store.js'; import type { CinemaPromotionEvent, CinemaSha256 } from './cinema-types.js'; import { appendProjectEvent } from './events.js'; import { preserveInputKeyframes } from './execution-status.js'; import { readSceneCandidatesArtifact, withSceneArtifactsLock } from './scene-candidate-store.js'; import { deriveAssetManifestFromSelection } from './scene-candidates.js'; import { rejectCandidate, selectCandidate } from './scene-selection.js'; import { readSceneSelectionArtifact, writeSceneSelectionArtifact } from './scene-selection-store.js'; import { ensureProjectWorkspace } from './workspace.js'; export type CinemaCandidateReviewLane = 'technical' | 'identity-continuity' | 'performance' | 'editorial'; export type CinemaCandidateReviewDecision = 'pass' | 'reject' | 'redesign'; export interface CinemaCandidateReviewArtifact { schemaVersion: 1 | 2; reviewId: string; projectSlug: string; queueTaskId: string; attemptId: string; outcomeId: string; shotId: string; candidateId: string; bridgeReceiptHash: CinemaSha256; decision: CinemaCandidateReviewDecision; changedVariable: string | null; reviewerId: string; reviewedAt: string; storyPurposeVerified: boolean; authorityEvidence?: Array<{ path: string; contentHash: CinemaSha256; byteLength: number }>; lanes: Array<{ lane: CinemaCandidateReviewLane; verdict: 'pass' | 'fail'; reason: string; evidence: Array<{ path: string; contentHash: CinemaSha256; byteLength: number }>; }>; providerCalls: 0; generationCalls: 0; spendAuthorized: false; contentHash: CinemaSha256; } export interface CinemaCandidateReviewInput { reviewId: string; taskId: string; decision: CinemaCandidateReviewDecision; changedVariable: string | null; reviewerId: string; reviewedAt: string; storyPurposeVerified: boolean; authorityEvidencePaths: string[]; lanes: Array<{ lane: CinemaCandidateReviewLane; verdict: 'pass' | 'fail'; reason: string; evidencePaths: string[] }>; } const LANES: CinemaCandidateReviewLane[] = ['technical', 'identity-continuity', 'performance', 'editorial']; function safe(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`); return value; } function reviewPath(root: string, projectSlug: string, reviewId: string): string { return join(cinemaArtifactsDirFor(root, projectSlug), 'candidate-reviews', `${safe(reviewId, 'reviewId')}.json`); } function reviewDir(root: string, projectSlug: string): string { return join(cinemaArtifactsDirFor(root, projectSlug), 'candidate-reviews'); } function hashBytes(bytes: Uint8Array): CinemaSha256 { return `sha256:${createHash('sha256').update(bytes).digest('hex')}`; } async function evidence(path: string) { const bytes = await readFile(path); if (!bytes.byteLength) throw new Error(`Cinema review evidence is empty: ${path}`); return { path, contentHash: hashBytes(bytes), byteLength: bytes.byteLength }; } function validateInput(input: CinemaCandidateReviewInput): void { if (!input.reviewId.trim() || !input.reviewerId.trim()) throw new Error('Cinema candidate review requires review and reviewer identity'); if (input.authorityEvidencePaths.length === 0) throw new Error('Cinema candidate review requires separate authority evidence'); if (Number.isNaN(Date.parse(input.reviewedAt))) throw new Error('Cinema candidate review time must be an ISO timestamp'); if (input.decision === 'redesign' && !input.changedVariable?.trim()) throw new Error('Cinema redesign must name exactly one changed variable'); if (input.decision !== 'redesign' && input.changedVariable !== null) throw new Error('Only Cinema redesign can name a changed variable'); if (input.lanes.length !== LANES.length || new Set(input.lanes.map((lane) => lane.lane)).size !== LANES.length || LANES.some((lane) => !input.lanes.some((entry) => entry.lane === lane))) throw new Error('Cinema candidate review requires exactly the four canonical lanes'); for (const lane of input.lanes) { if (!lane.reason.trim() || lane.evidencePaths.length === 0) throw new Error(`Cinema ${lane.lane} review requires a reason and evidence`); } const failures = input.lanes.filter((lane) => lane.verdict === 'fail').length; if (input.decision === 'pass' && (failures > 0 || !input.storyPurposeVerified)) throw new Error('Cinema pass requires all four lanes and story purpose to pass'); if (input.decision !== 'pass' && failures === 0) throw new Error(`Cinema ${input.decision} requires at least one failed lane`); } function validateArtifact(artifact: CinemaCandidateReviewArtifact): void { const laneNames = artifact.lanes.map((lane) => lane.lane); if (artifact.lanes.length !== LANES.length || new Set(laneNames).size !== LANES.length || LANES.some((lane) => !laneNames.includes(lane))) throw new Error('Cinema candidate review does not contain exactly the four canonical lanes'); if (artifact.lanes.some((lane) => !lane.reason.trim() || lane.evidence.length === 0)) throw new Error('Cinema candidate review has an unevidenced lane'); if (artifact.schemaVersion === 2 && (!artifact.authorityEvidence || artifact.authorityEvidence.length === 0)) throw new Error('Cinema candidate review has no authority evidence'); const failures = artifact.lanes.filter((lane) => lane.verdict === 'fail').length; if (artifact.decision === 'pass' && (failures > 0 || !artifact.storyPurposeVerified)) throw new Error('Cinema candidate pass does not satisfy all review gates'); if (artifact.decision !== 'pass' && failures === 0) throw new Error(`Cinema candidate ${artifact.decision} lacks a failed lane`); if (artifact.decision === 'redesign' && !artifact.changedVariable?.trim()) throw new Error('Cinema redesign lacks its one controlled variable'); if (artifact.decision !== 'redesign' && artifact.changedVariable !== null) throw new Error('Non-redesign Cinema review names a changed variable'); } async function verifyEvidenceBytes(entry: { path: string; contentHash: CinemaSha256; byteLength: number }): Promise { const bytes = await readFile(entry.path); if (bytes.byteLength !== entry.byteLength || hashBytes(bytes) !== entry.contentHash) throw new Error(`Cinema review evidence bytes changed: ${entry.path}`); } export async function readCinemaCandidateReview(root: string, projectSlug: string, reviewId: string): Promise { const path = reviewPath(root, projectSlug, reviewId); if (!existsSync(path)) return null; const artifact = JSON.parse(await readFile(path, 'utf8')) as CinemaCandidateReviewArtifact; if (artifact.projectSlug !== projectSlug || artifact.reviewId !== reviewId || artifact.contentHash !== cinemaArtifactContentHash(artifact)) throw new Error(`Cinema candidate review ${reviewId} failed identity or content-hash validation`); validateArtifact(artifact); return artifact; } export async function recordCinemaCandidateReview( root: string, projectSlug: string, input: CinemaCandidateReviewInput, ): Promise<{ appended: boolean; artifact: CinemaCandidateReviewArtifact; path: string }> { validateInput(input); const bridge = await readCinemaCandidateBridgeReceipt(root, projectSlug, input.taskId); if (!bridge) throw new Error(`Cinema task ${input.taskId} has not entered candidate review`); const queue = await readCinemaProductionQueue(root, projectSlug); const task = queue?.tasks.find((entry) => entry.taskId === input.taskId); const ledger = await readCinemaGenerationLedger(root, projectSlug); const outcome = ledger?.outcomes?.find((entry) => entry.outcomeId === bridge.outcomeId); if (!task || task.status !== 'succeeded' || !ledger || !outcome || outcome.contentHash !== bridge.outcomeHash) throw new Error('Cinema review target lacks a matching succeeded queue task and terminal outcome'); const supersededPromotions = new Set(ledger.promotions.map((promotion) => promotion.supersedesPromotionId).filter((id): id is string => id !== null)); const activePromotion = ledger.promotions.find((promotion) => promotion.attemptId === bridge.attemptId && !supersededPromotions.has(promotion.promotionId)); if (activePromotion && input.decision !== 'pass') throw new Error(`Cinema candidate is already the active promotion ${activePromotion.promotionId}; rejection requires an explicit superseding selection`); const candidates = await readSceneCandidatesArtifact(root, projectSlug); const scene = candidates.scenes.find((entry) => entry.sceneIndex === bridge.sceneIndex); const candidate = scene?.candidates.find((entry) => entry.id === bridge.candidateId); if (!candidate || candidate.status !== 'completed') throw new Error(`Cinema review candidate ${bridge.candidateId} is missing or incomplete`); const lanes = await Promise.all(input.lanes.map(async (lane) => ({ lane: lane.lane, verdict: lane.verdict, reason: lane.reason, evidence: await Promise.all(lane.evidencePaths.map(evidence)), }))); const authorityEvidence = await Promise.all(input.authorityEvidencePaths.map(evidence)); const body: Omit = { schemaVersion: 2, reviewId: input.reviewId, projectSlug, queueTaskId: input.taskId, attemptId: bridge.attemptId, outcomeId: bridge.outcomeId, shotId: bridge.shotId, candidateId: bridge.candidateId, bridgeReceiptHash: bridge.contentHash, decision: input.decision, changedVariable: input.changedVariable, reviewerId: input.reviewerId, reviewedAt: input.reviewedAt, storyPurposeVerified: input.storyPurposeVerified, authorityEvidence, lanes, providerCalls: 0, generationCalls: 0, spendAuthorized: false, }; const artifact = { ...body, contentHash: cinemaArtifactContentHash({ ...body, contentHash: hashBytes(new Uint8Array()) }) }; const path = reviewPath(root, projectSlug, input.reviewId); return withCinemaFileLock(path, async () => { const existing = await readCinemaCandidateReview(root, projectSlug, input.reviewId); if (existing) { if (existing.contentHash !== artifact.contentHash) throw new Error(`Cinema candidate review ${input.reviewId} is immutable`); await applyNegativeReviewIfLatest(root, projectSlug, existing); return { appended: false, artifact: existing, path }; } await mkdir(dirname(path), { recursive: true }); await writeTextFileAtomic(path, `${JSON.stringify(artifact, null, 2)}\n`); await applyNegativeReviewIfLatest(root, projectSlug, artifact); const workspace = await ensureProjectWorkspace(projectSlug, root); await appendProjectEvent(workspace, { type: 'cinema.candidate.reviewed', recordedAt: input.reviewedAt, payload: { reviewId: input.reviewId, taskId: input.taskId, candidateId: bridge.candidateId, decision: input.decision, contentHash: artifact.contentHash } }); return { appended: true, artifact, path }; }); } async function reviewsForTask(root: string, projectSlug: string, taskId: string): Promise { let names: string[]; try { names = await readdir(reviewDir(root, projectSlug)); } catch (error) { if (error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'ENOENT') return []; throw error; } const reviews = await Promise.all(names.filter((name) => name.endsWith('.json')) .map((name) => readCinemaCandidateReview(root, projectSlug, name.slice(0, -5)))); return reviews.filter((review): review is CinemaCandidateReviewArtifact => review?.queueTaskId === taskId) .sort((left, right) => left.reviewedAt.localeCompare(right.reviewedAt) || left.reviewId.localeCompare(right.reviewId)); } export async function listCinemaCandidateReviews(root: string, projectSlug: string): Promise { let names: string[]; try { names = await readdir(reviewDir(root, projectSlug)); } catch (error) { if (error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'ENOENT') return []; throw error; } const reviews = await Promise.all(names.filter((name) => name.endsWith('.json')) .map((name) => readCinemaCandidateReview(root, projectSlug, name.slice(0, -5)))); return reviews.filter((review): review is CinemaCandidateReviewArtifact => review !== null) .sort((left, right) => left.reviewedAt.localeCompare(right.reviewedAt) || left.reviewId.localeCompare(right.reviewId)); } async function applyNegativeReviewIfLatest(root: string, projectSlug: string, review: CinemaCandidateReviewArtifact): Promise { if (review.decision === 'pass') return; const reviews = await reviewsForTask(root, projectSlug, review.queueTaskId); if (reviews.at(-1)?.reviewId !== review.reviewId) return; const bridge = await readCinemaCandidateBridgeReceipt(root, projectSlug, review.queueTaskId); if (!bridge || bridge.candidateId !== review.candidateId) throw new Error('Cinema negative review no longer matches its candidate bridge'); await withSceneArtifactsLock(root, projectSlug, async () => { const selection = await readSceneSelectionArtifact(root, projectSlug); await writeSceneSelectionArtifact(root, projectSlug, rejectCandidate(selection, bridge.sceneIndex, bridge.candidateId)); }); } export async function promoteCinemaCandidate(root: string, projectSlug: string, input: { reviewId: string; promotionId: string; reviewerId: string; editorialSlot: string; supersedesPromotionId: string | null; decidedAt: string; evidencePaths: string[]; }): Promise<{ appended: boolean; promotion: CinemaPromotionEvent; candidateId: string; sceneIndex: number; assetManifestPath: string }> { const review = await readCinemaCandidateReview(root, projectSlug, input.reviewId); if (!review) throw new Error(`Cinema candidate review ${input.reviewId} does not exist`); if (review.decision !== 'pass' || !review.storyPurposeVerified || review.lanes.some((lane) => lane.verdict !== 'pass')) throw new Error('Cinema promotion requires an all-lane passed review with verified story purpose'); const taskReviews = await reviewsForTask(root, projectSlug, review.queueTaskId); if (taskReviews.at(-1)?.reviewId !== review.reviewId) throw new Error(`Cinema review ${review.reviewId} is stale; promotion requires latest review ${taskReviews.at(-1)?.reviewId}`); if (!input.promotionId.trim() || !input.reviewerId.trim() || !input.editorialSlot.trim() || Number.isNaN(Date.parse(input.decidedAt))) throw new Error('Cinema promotion requires promotion, reviewer, editorial-slot and decision-time identity'); if (input.evidencePaths.length === 0) throw new Error('Cinema promotion requires separate authority evidence'); const ledger = await readCinemaGenerationLedger(root, projectSlug); const outcome = ledger?.outcomes?.find((entry) => entry.outcomeId === review.outcomeId); if (!ledger || !outcome) throw new Error('Cinema promotion target has no terminal outcome'); const bridge = await readCinemaCandidateBridgeReceipt(root, projectSlug, review.queueTaskId); if (!bridge || bridge.contentHash !== review.bridgeReceiptHash || bridge.outcomeHash !== outcome.contentHash) throw new Error('Cinema promotion review no longer matches its media bridge and outcome'); const mediaBytes = await readFile(bridge.media.path); if (mediaBytes.byteLength !== bridge.media.byteLength || hashBytes(mediaBytes) !== bridge.media.contentHash) throw new Error('Cinema promotion media bytes changed after review'); await Promise.all(review.lanes.flatMap((lane) => lane.evidence.map(verifyEvidenceBytes))); await Promise.all((review.authorityEvidence ?? []).map(verifyEvidenceBytes)); const superseded = new Set(ledger.promotions.map((promotion) => promotion.supersedesPromotionId).filter((id): id is string => id !== null)); const active = ledger.promotions.find((promotion) => promotion.shotId === review.shotId && !superseded.has(promotion.promotionId)); const existingPromotion = ledger.promotions.find((promotion) => promotion.promotionId === input.promotionId); if (!existingPromotion && (active?.promotionId ?? null) !== input.supersedesPromotionId) throw new Error(`Cinema promotion must explicitly supersede ${active?.promotionId ?? 'nothing'}`); const authorityEvidence = await Promise.all(input.evidencePaths.map(evidence)); const laneEvidence = review.lanes.flatMap((lane) => lane.evidence.map((entry) => entry.contentHash)); const promotion: CinemaPromotionEvent = { promotionId: input.promotionId, shotId: review.shotId, attemptId: review.attemptId, reviewerId: input.reviewerId, editorialSlot: input.editorialSlot, decidedAt: input.decidedAt, supersedesPromotionId: input.supersedesPromotionId, evidenceIds: [...new Set([review.contentHash, review.bridgeReceiptHash, outcome.contentHash, ...laneEvidence, ...(review.authorityEvidence ?? []).map((entry) => entry.contentHash), ...authorityEvidence.map((entry) => entry.contentHash)])], storyPurposeVerified: true, }; const stored = await appendCinemaPromotion(root, projectSlug, promotion, input.decidedAt); let sceneIndex = -1; let candidates!: Awaited>; let selection!: Awaited>; await withSceneArtifactsLock(root, projectSlug, async () => { candidates = await readSceneCandidatesArtifact(root, projectSlug); const scene = candidates.scenes.find((entry) => entry.candidates.some((candidate) => candidate.id === review.candidateId)); if (!scene) throw new Error(`Cinema promotion candidate ${review.candidateId} is missing`); sceneIndex = scene.sceneIndex; selection = selectCandidate(await readSceneSelectionArtifact(root, projectSlug), sceneIndex, review.candidateId); await writeSceneSelectionArtifact(root, projectSlug, selection); }); const workspace = await ensureProjectWorkspace(projectSlug, root); const derived = deriveAssetManifestFromSelection(projectSlug, candidates, selection); const assetManifestPath = await writeArtifact(workspace, 'asset-manifest', await preserveInputKeyframes(workspace, derived)); if (stored.appended) await appendProjectEvent(workspace, { type: 'cinema.candidate.promoted', recordedAt: input.decidedAt, payload: { promotionId: input.promotionId, reviewId: input.reviewId, candidateId: review.candidateId, sceneIndex, editorialSlot: input.editorialSlot } }); return { appended: stored.appended, promotion: stored.event, candidateId: review.candidateId, sceneIndex, assetManifestPath }; }