/** * Run-contract persistence: a per-scene snapshot of the EXACT resolved submit * payload at produce/execute time (prompt + referencePaths + referenceRole + * inputKind + duration + characters), plus the run-level execution profile and * route. This is the FROZEN baseline the live run dashboard diffs the CURRENT * contract against — when an `@tag` silently hijacks the references between * submit and a later re-render, the dashboard paints the card red. * * Distinct from scene-candidates (status + job id) and the execution-report * (run outcome): this artifact records "what was actually sent to the model". * It is purely additive — never read by the execution path, only by the run * dashboard — so an absent file just degrades the dashboard's diff alarm * gracefully (like every other optional portal artifact). * * Written via `writeTextFileAtomic` (not the typed `writeArtifact` helper) so it * stays decoupled from the `VideoStageArtifactName` union; allowlisted in * `scripts/check-artifact-schema-coverage.mjs` against * `schemas/video/artifacts/run-contract.schema.json`. */ import { createHash } from 'node:crypto'; 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 { resolveProjectWorkspace } from './workspace.js'; import type { ProviderRouteId } from './provider-platform/types.js'; import type { VideoExecutionPayload, VideoExecutionTask } from './types.js'; export interface RunContractScene { sceneIndex: number; /** The candidate created for THIS submission (candidate mode), else null. */ candidateId: string | null; externalJobId: string | null; submittedAt: string; /** The SUBMITTED contract snapshot (frozen; the diff baseline). */ submittedPrompt: string; /** Resolved Asset://, @tag, chain-seed, voice — the exact array sent. */ submittedReferencePaths: string[]; submittedReferenceRole?: 'character' | 'keyframe'; submittedInputKind: 'text' | 'image' | 'video'; submittedDurationSeconds?: number; submittedCharacters: string[]; submittedChainedFromCandidateId?: string; /** sha256 of the stable-stringified contract fields — the diff fast-path. */ contractHash: string; } export interface RunContractArtifact { schemaVersion: 1; projectSlug: string; routeId: ProviderRouteId; /** The submit timestamp identifying this run (= report.generatedAt). */ runId: string; recordedAt: string; executionProfile: { aspectRatio: '16:9' | '9:16' | '1:1'; quality: 'fast' | 'quality'; resolution: '720p' | '1080p'; generateAudio: boolean; outputCount: number; veoModel?: 'fast' | 'quality' | 'lite' | 'free' | 'omni-flash'; }; scenes: RunContractScene[]; } export function runContractPathFor(root: string, slug: string): string { return join(resolveProjectWorkspace(slug, root).artifactsDir, 'run-contract.json'); } /** * Stable canonical hash of the per-scene contract fields used for the diff * fast-path. The field order is fixed and characters are sorted (set semantics); * referencePaths keep ORDER (a reorder is a meaningful divergence). Excludes * `submittedAt`/`candidateId`/`externalJobId` (run-instance metadata, not the * contract). */ export function hashRunContractScene(fields: { prompt: string; referencePaths: string[]; referenceRole?: 'character' | 'keyframe'; inputKind: 'text' | 'image' | 'video'; durationSeconds?: number; characters: string[]; chainedFromCandidateId?: string; }): string { const canonical = JSON.stringify({ prompt: fields.prompt, referencePaths: fields.referencePaths, referenceRole: fields.referenceRole ?? null, inputKind: fields.inputKind, durationSeconds: fields.durationSeconds ?? null, characters: [...fields.characters].sort(), chainedFromCandidateId: fields.chainedFromCandidateId ?? null, }); return createHash('sha256').update(canonical).digest('hex'); } /** Build one frozen scene snapshot from a submitted execution task. */ function sceneFromTask( task: VideoExecutionTask, submittedAt: string, candidateId: string | null, externalJobId: string | null, ): RunContractScene { return { sceneIndex: task.sceneIndex, candidateId, externalJobId, submittedAt, submittedPrompt: task.prompt, submittedReferencePaths: [...task.referencePaths], ...(task.referenceRole ? { submittedReferenceRole: task.referenceRole } : {}), submittedInputKind: task.inputKind, ...(typeof task.durationSeconds === 'number' ? { submittedDurationSeconds: task.durationSeconds } : {}), submittedCharacters: [...task.characters], ...(task.chainedFromCandidateId ? { submittedChainedFromCandidateId: task.chainedFromCandidateId } : {}), contractHash: hashRunContractScene({ prompt: task.prompt, referencePaths: task.referencePaths, ...(task.referenceRole ? { referenceRole: task.referenceRole } : {}), inputKind: task.inputKind, ...(typeof task.durationSeconds === 'number' ? { durationSeconds: task.durationSeconds } : {}), characters: task.characters, ...(task.chainedFromCandidateId ? { chainedFromCandidateId: task.chainedFromCandidateId } : {}), }), }; } export interface BuildRunContractInput { payload: VideoExecutionPayload; submittedAt: string; /** sceneIndex → candidateId for this submission (candidate mode). */ candidatesByScene?: Array<{ sceneIndex: number; candidateId: string }>; externalJobId?: string | null; } /** Build the run-contract artifact from a just-submitted payload. PURE. */ export function buildRunContract(input: BuildRunContractInput): RunContractArtifact { const candidateBySceneIndex = new Map(); for (const entry of input.candidatesByScene ?? []) { candidateBySceneIndex.set(entry.sceneIndex, entry.candidateId); } const externalJobId = input.externalJobId ?? null; const scenes = [...input.payload.tasks] .sort((a, b) => a.sceneIndex - b.sceneIndex) .map((task) => sceneFromTask( task, input.submittedAt, candidateBySceneIndex.get(task.sceneIndex) ?? null, externalJobId, ), ); return { schemaVersion: 1, projectSlug: input.payload.projectSlug, routeId: input.payload.routeId, runId: input.submittedAt, recordedAt: input.submittedAt, executionProfile: { aspectRatio: input.payload.executionProfile.aspectRatio, quality: input.payload.executionProfile.quality, resolution: input.payload.executionProfile.resolution, generateAudio: input.payload.executionProfile.generateAudio, outputCount: input.payload.executionProfile.outputCount, ...(input.payload.executionProfile.veoModel ? { veoModel: input.payload.executionProfile.veoModel } : {}), }, scenes, }; } export async function writeRunContract( root: string, slug: string, artifact: RunContractArtifact, ): Promise { const path = runContractPathFor(root, slug); await mkdir(dirname(path), { recursive: true }); await writeTextFileAtomic(path, `${JSON.stringify(artifact, null, 2)}\n`); return path; } /** Read `artifacts/run-contract.json` (null when absent or malformed). */ export async function readRunContract(root: string, slug: string): Promise { const path = runContractPathFor(root, slug); if (!existsSync(path)) return null; try { return JSON.parse(await readFile(path, 'utf-8')) as RunContractArtifact; } catch { return null; } }