import { createHash } from 'node:crypto'; import { realpathSync } from 'node:fs'; import { mkdir, readFile } from 'node:fs/promises'; import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; import { writeTextFileAtomic } from './atomic-write.js'; import { promoteCinemaCandidate, recordCinemaCandidateReview, type CinemaCandidateReviewDecision, type CinemaCandidateReviewLane, } from './cinema-candidate-review.js'; import { loadCinemaProjectConsole } from './cinema-project-console-load.js'; import { renderCinemaConsoleHtml } from './cinema-console-render.js'; import { launchReviewUiServer, ReviewUiRequestError, type ReviewUiServerLaunch, } from './review-ui-server.js'; import type { CinemaReviewRole } from './cinema-review-types.js'; import { resolveProjectWorkspace } from './workspace.js'; const LANES: CinemaCandidateReviewLane[] = ['technical', 'identity-continuity', 'performance', 'editorial']; export interface CinemaLiveConsoleOptions { root: string; projectSlug: string; reviewerId: string; roles: CinemaReviewRole[]; authorityEvidencePath: string; host?: string; port?: number; dryRun?: boolean; allowRemote?: boolean; } export interface CinemaLiveConsoleLaunch extends ReviewUiServerLaunch { authority: { reviewerId: string; roles: CinemaReviewRole[]; evidenceHash: `sha256:${string}`; spendAuthorized: false; }; } function record(value: unknown): Record { if (!value || typeof value !== 'object' || Array.isArray(value)) throw new ReviewUiRequestError(400, 'Cinema live action requires a JSON object'); return value as Record; } function text(body: Record, key: string): string { const value = body[key]; if (typeof value !== 'string' || !value.trim()) throw new ReviewUiRequestError(400, `Cinema live action requires ${key}`); return value.trim(); } function optionalText(body: Record, key: string): string | null { const value = body[key]; if (value === null || value === undefined || value === '') return null; if (typeof value !== 'string') throw new ReviewUiRequestError(400, `Cinema live action ${key} must be text`); return value.trim() || null; } function requireRole(roles: CinemaReviewRole[], allowed: CinemaReviewRole[], action: string): void { if (!roles.some((role) => allowed.includes(role))) throw new ReviewUiRequestError(403, `Cinema live authority cannot ${action}`); } function projectEvidencePath(projectDir: string, value: unknown): string { if (typeof value !== 'string' || !value.trim()) throw new ReviewUiRequestError(400, 'Cinema live review evidence path is required'); const localPath = isAbsolute(value) ? resolve(value) : resolve(projectDir, value); let target: string; try { target = realpathSync(localPath); } catch { throw new ReviewUiRequestError(400, `Cinema live evidence does not exist: ${value}`); } const boundary = realpathSync(projectDir); const inside = relative(boundary, target); if (inside === '' || inside.startsWith('..') || isAbsolute(inside)) throw new ReviewUiRequestError(403, 'Cinema live evidence must be stored inside the project'); return target; } function reviewInput(bodyValue: unknown, projectDir: string, reviewerId: string, authorityEvidencePath: string) { const body = record(bodyValue); const decision = text(body, 'decision') as CinemaCandidateReviewDecision; if (!['pass', 'reject', 'redesign'].includes(decision)) throw new ReviewUiRequestError(400, 'Cinema live review decision must be pass, reject or redesign'); if (!Array.isArray(body.lanes)) throw new ReviewUiRequestError(400, 'Cinema live review requires four lane records'); const lanes = body.lanes.map((value) => { const laneBody = record(value); const lane = text(laneBody, 'lane') as CinemaCandidateReviewLane; const verdict = text(laneBody, 'verdict'); if (!LANES.includes(lane) || !['pass', 'fail'].includes(verdict)) throw new ReviewUiRequestError(400, 'Cinema live review contains an invalid lane or verdict'); if (!Array.isArray(laneBody.evidencePaths)) throw new ReviewUiRequestError(400, `Cinema ${lane} lane requires evidencePaths`); return { lane, verdict: verdict as 'pass' | 'fail', reason: text(laneBody, 'reason'), evidencePaths: laneBody.evidencePaths.map((path) => projectEvidencePath(projectDir, path)), }; }); return { reviewId: text(body, 'reviewId'), taskId: text(body, 'taskId'), decision, changedVariable: optionalText(body, 'changedVariable'), reviewerId, authorityEvidencePaths: [authorityEvidencePath], reviewedAt: optionalText(body, 'reviewedAt') ?? new Date().toISOString(), storyPurposeVerified: body.storyPurposeVerified === true, lanes, }; } export async function launchCinemaLiveConsole(options: CinemaLiveConsoleOptions): Promise { if (!options.reviewerId.trim() || options.roles.length === 0) throw new Error('Cinema live console requires reviewer identity and at least one role'); const authorityBytes = await readFile(options.authorityEvidencePath); if (authorityBytes.byteLength === 0) throw new Error('Cinema live authority evidence cannot be empty'); const evidenceHash = `sha256:${createHash('sha256').update(authorityBytes).digest('hex')}` as const; const workspace = resolveProjectWorkspace(options.projectSlug, options.root); const uiPath = join(workspace.projectDir, 'cinema-live.html'); const initialModel = await loadCinemaProjectConsole(options.root, options.projectSlug, 'live'); const refresh = async () => { const model = await loadCinemaProjectConsole(options.root, options.projectSlug, 'live'); await mkdir(dirname(uiPath), { recursive: true }); await writeTextFileAtomic(uiPath, renderCinemaConsoleHtml(model)); return model; }; if (!options.dryRun) { await mkdir(dirname(uiPath), { recursive: true }); await writeTextFileAtomic(uiPath, renderCinemaConsoleHtml(initialModel)); } const unsupported = async (): Promise => { throw new ReviewUiRequestError(403, 'This action is not available in the Cinema console'); }; const launch = await launchReviewUiServer({ root: options.root, projectSlug: options.projectSlug, uiPath, ...(options.host ? { host: options.host } : {}), ...(options.port !== undefined ? { port: options.port } : {}), ...(options.dryRun !== undefined ? { dryRun: options.dryRun } : {}), ...(options.allowRemote !== undefined ? { allowRemote: options.allowRemote } : {}), }, { buildInventory: async () => refresh(), saveDecision: unsupported, recordStillCandidate: unsupported, recordUpscaledCandidate: unsupported, recordStillRequest: unsupported, recordCharacterRequest: unsupported, resolveRemoteMedia: async () => null, customMutations: { '/api/cinema-review': async (_root, _projectSlug, body) => { const parsed = record(body); const decision = text(parsed, 'decision') as CinemaCandidateReviewDecision; requireRole(options.roles, decision === 'redesign' ? ['editor', 'director', 'producer'] : ['reviewer', 'editor', 'director', 'producer'], `record ${decision}`); const result = await recordCinemaCandidateReview(options.root, options.projectSlug, reviewInput(body, workspace.projectDir, options.reviewerId, options.authorityEvidencePath)); await refresh(); return { ...result, authorityEvidenceHash: evidenceHash, providerCalls: 0, generationCalls: 0, spendAuthorized: false }; }, '/api/cinema-promote': async (_root, _projectSlug, bodyValue) => { requireRole(options.roles, ['director', 'producer'], 'promote a candidate'); const body = record(bodyValue); if (!Array.isArray(body.evidencePaths)) throw new ReviewUiRequestError(400, 'Cinema promotion requires evidencePaths'); const result = await promoteCinemaCandidate(options.root, options.projectSlug, { reviewId: text(body, 'reviewId'), promotionId: text(body, 'promotionId'), reviewerId: options.reviewerId, editorialSlot: text(body, 'editorialSlot'), supersedesPromotionId: optionalText(body, 'supersedesPromotionId'), decidedAt: optionalText(body, 'decidedAt') ?? new Date().toISOString(), evidencePaths: body.evidencePaths.map((path) => projectEvidencePath(workspace.projectDir, path)), }); await refresh(); return { ...result, authorityEvidenceHash: evidenceHash, providerCalls: 0, generationCalls: 0, spendAuthorized: false }; }, }, }); return { ...launch, ...(launch.close ? { close: launch.close } : {}), authority: { reviewerId: options.reviewerId, roles: [...options.roles], evidenceHash, spendAuthorized: false }, }; }