/** * 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 type { ProviderWirePreview } from './provider-wire-preview.js'; 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'; import { captureFilmReferenceEvidence } from './film-reference-evidence.js'; /** A reference slot as the contract freezes it: the human slot plan only * (`slot`/`role`/`label`/`path`). Discovery-side slots carry more (`status`, * `characterName`, `assetUri`); both sides go through this so the diff compares * the same shape in the same key order. Slot ORDER is kept — a reorder is real. */ export interface RunContractReferenceSlot { slot: string; role: string; label: string; path?: string; } export function canonicalReferenceSlot(slot: { slot: string; role: string; label: string; path?: string; }): RunContractReferenceSlot { return { slot: slot.slot, role: slot.role, label: slot.label, ...(slot.path ? { path: slot.path } : {}), }; } /** * The BYTES behind one submitted reference. Every other identity here covers a * reference's PATH: replace `keyframes/scene-3.png` with different pixels at the * same path and the prompt, the paths and the slot plan all still match, so the * dashboard said "unchanged" about a submit that no longer matches what was * reviewed. Only a local file has bytes to measure; an `Asset://` URI or a hosted * URL is recorded as `remote-unverified` and a file that was not there as * `missing` — the statuses of film-reference-evidence.ts, reused rather than * reclassified. */ export interface RunContractReferenceHash { path: string; status: 'hashed' | 'missing' | 'remote-unverified'; sha256?: string; } export function canonicalReferenceHash(entry: RunContractReferenceHash): RunContractReferenceHash { return { path: entry.path, status: entry.status, ...(entry.sha256 ? { sha256: entry.sha256 } : {}) }; } /** The files one task submits: its reference array plus its end keyframe, in order, once each. */ export function submittedReferenceFiles(task: Pick): string[] { return [...new Set([...task.referencePaths, ...(task.endKeyframePath ? [task.endKeyframePath] : [])])]; } /** * Hash what each task is about to submit. I/O, so it is NOT part of the pure * builder: `execute.ts` calls this first and hands the result to * `buildRunContract`. Paths resolve against the project directory (absolute * paths resolve to themselves). * * NEVER THROWS. The film-reference hasher rethrows anything but ENOENT — a * directory in the reference list, EACCES, a symlink loop — and that throw is * load-bearing where it gates a render (`assertFilmReferenceEvidence`). Here it * would be fatal in the wrong way: this runs first inside the best-effort block * that writes the run contract, so one unreadable reference silently cost the * WHOLE contract, on a dry run most of all, which is the review surface. A scene * that cannot be measured is simply left out: it freezes no hashes, exactly like * a contract written before hashes existed, and never alarms. */ export async function captureSubmittedReferenceHashes( projectDir: string, tasks: ReadonlyArray>, ): Promise> { const out: Array<{ sceneIndex: number; entries: RunContractReferenceHash[] }> = []; for (const task of tasks) { try { const evidence = await captureFilmReferenceEvidence(projectDir, [{ sceneIndex: task.sceneIndex, references: submittedReferenceFiles(task).map((path) => ({ path })), }]); out.push({ sceneIndex: task.sceneIndex, entries: evidence.map(({ path, status, sha256 }) => canonicalReferenceHash({ path, status, ...(sha256 ? { sha256 } : {}) })) }); } catch { // Unmeasurable scene: no entry → no `submittedReferenceHashes` for it. } } return out; } 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[]; /** The ordered slot plan the packet bound (present when a filmmaking-prompts * packet drove the task). */ submittedReferenceSlots?: RunContractReferenceSlot[]; /** sha256 of each submitted reference FILE, in submit order (see * RunContractReferenceHash). Absent on a contract written before this existed. */ submittedReferenceHashes?: RunContractReferenceHash[]; submittedReferenceRole?: 'character' | 'keyframe'; submittedInputKind: 'text' | 'image' | 'video'; submittedDurationSeconds?: number; submittedCharacters: string[]; submittedChainedFromCandidateId?: string; /** * Provider settings frozen at submit. `submittedResolution` and * `submittedPromptPacketVariant` come from the packet and the dashboard * re-derives them, so they can drift; the rest (end keyframe, voice preset, * V2V media id, first-frame, Flow character refs) are resolved inside * execution-runtime, so they are provenance — what was sent — not a diff. */ submittedResolution?: string; submittedPromptPacketVariant?: string; submittedEndKeyframePath?: string; submittedVoicePreset?: string; submittedReferenceVideoMediaId?: string; submittedFirstFrame?: boolean; submittedCharacterRefs?: string[]; /** * The EXACT wire body the route's transport would send for this scene, from * the transport's own planner (provider-wire-preview.ts) — or the planner's * refusal, verbatim, so a dry run shows the refusal before any spend. Absent * on routes without a planner and on contracts written before this existed. * Provenance, not part of `contractHash`: it is derived from the fields that * hash already covers. */ submittedProviderWire?: ProviderWirePreview; /** 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[]; referenceSlots?: RunContractReferenceSlot[]; referenceRole?: 'character' | 'keyframe'; inputKind: 'text' | 'image' | 'video'; durationSeconds?: number; characters: string[]; chainedFromCandidateId?: string; resolution?: string; promptPacketVariant?: string; endKeyframePath?: string; voicePreset?: string; referenceVideoMediaId?: string; firstFrame?: boolean; characterRefs?: string[]; referenceHashes?: RunContractReferenceHash[]; }): string { // Every field added after the first seven is a CONDITIONAL key: absent input // → absent key → the hash of a legacy contract is byte-identical to what // it was before the field existed (run-status.test.ts pins the literal). 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, ...(fields.referenceSlots ? { referenceSlots: fields.referenceSlots.map(canonicalReferenceSlot) } : {}), ...(fields.resolution ? { resolution: fields.resolution } : {}), ...(fields.promptPacketVariant ? { promptPacketVariant: fields.promptPacketVariant } : {}), ...(fields.endKeyframePath ? { endKeyframePath: fields.endKeyframePath } : {}), ...(fields.voicePreset ? { voicePreset: fields.voicePreset } : {}), ...(fields.referenceVideoMediaId ? { referenceVideoMediaId: fields.referenceVideoMediaId } : {}), ...(fields.firstFrame !== undefined ? { firstFrame: fields.firstFrame } : {}), ...(fields.characterRefs ? { characterRefs: [...fields.characterRefs] } : {}), ...(fields.referenceHashes ? { referenceHashes: fields.referenceHashes.map(canonicalReferenceHash) } : {}), }); 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, referenceHashes?: RunContractReferenceHash[], providerWire?: ProviderWirePreview, ): RunContractScene { return { sceneIndex: task.sceneIndex, candidateId, externalJobId, submittedAt, submittedPrompt: task.prompt, submittedReferencePaths: [...task.referencePaths], ...(task.referenceSlots ? { submittedReferenceSlots: task.referenceSlots.map(canonicalReferenceSlot) } : {}), ...(task.referenceRole ? { submittedReferenceRole: task.referenceRole } : {}), submittedInputKind: task.inputKind, ...(typeof task.durationSeconds === 'number' ? { submittedDurationSeconds: task.durationSeconds } : {}), submittedCharacters: [...task.characters], ...(task.chainedFromCandidateId ? { submittedChainedFromCandidateId: task.chainedFromCandidateId } : {}), ...(task.resolution ? { submittedResolution: task.resolution } : {}), ...(task.promptPacketVariant ? { submittedPromptPacketVariant: task.promptPacketVariant } : {}), ...(task.endKeyframePath ? { submittedEndKeyframePath: task.endKeyframePath } : {}), ...(task.voicePreset ? { submittedVoicePreset: task.voicePreset } : {}), ...(task.referenceVideoMediaId ? { submittedReferenceVideoMediaId: task.referenceVideoMediaId } : {}), ...(task.firstFrame !== undefined ? { submittedFirstFrame: task.firstFrame } : {}), ...(task.characterRefs ? { submittedCharacterRefs: [...task.characterRefs] } : {}), ...(referenceHashes ? { submittedReferenceHashes: referenceHashes.map(canonicalReferenceHash) } : {}), ...(providerWire ? { submittedProviderWire: providerWire } : {}), contractHash: hashRunContractScene({ prompt: task.prompt, referencePaths: task.referencePaths, ...(task.referenceSlots ? { referenceSlots: task.referenceSlots } : {}), ...(task.referenceRole ? { referenceRole: task.referenceRole } : {}), inputKind: task.inputKind, ...(typeof task.durationSeconds === 'number' ? { durationSeconds: task.durationSeconds } : {}), characters: task.characters, ...(task.chainedFromCandidateId ? { chainedFromCandidateId: task.chainedFromCandidateId } : {}), ...(task.resolution ? { resolution: task.resolution } : {}), ...(task.promptPacketVariant ? { promptPacketVariant: task.promptPacketVariant } : {}), ...(task.endKeyframePath ? { endKeyframePath: task.endKeyframePath } : {}), ...(task.voicePreset ? { voicePreset: task.voicePreset } : {}), ...(task.referenceVideoMediaId ? { referenceVideoMediaId: task.referenceVideoMediaId } : {}), ...(task.firstFrame !== undefined ? { firstFrame: task.firstFrame } : {}), ...(task.characterRefs ? { characterRefs: task.characterRefs } : {}), ...(referenceHashes ? { referenceHashes } : {}), }), }; } export interface BuildRunContractInput { payload: VideoExecutionPayload; submittedAt: string; /** sceneIndex → candidateId for this submission (candidate mode). */ candidatesByScene?: Array<{ sceneIndex: number; candidateId: string }>; externalJobId?: string | null; /** From `captureSubmittedReferenceHashes` — measured before this pure builder runs. */ referenceHashes?: Array<{ sceneIndex: number; entries: RunContractReferenceHash[] }>; /** From `previewProviderWire` — computed before this pure builder runs. */ providerWire?: Map; } /** 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 hashesByScene = new Map((input.referenceHashes ?? []).map((entry) => [entry.sceneIndex, entry.entries])); const scenes = [...input.payload.tasks] .sort((a, b) => a.sceneIndex - b.sceneIndex) .map((task) => sceneFromTask( task, input.submittedAt, candidateBySceneIndex.get(task.sceneIndex) ?? null, externalJobId, hashesByScene.get(task.sceneIndex), input.providerWire?.get(task.sceneIndex), ), ); 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 } : {}), // This builder enumerates fields explicitly, so every profile field added // upstream must be taught here too — `flowResolution` was added to the // schema and to the plan and silently never reached this artifact, which // is how a dashboard ends up disagreeing with what actually rendered. ...(input.payload.executionProfile.flowResolution ? { flowResolution: input.payload.executionProfile.flowResolution } : {}), }, 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; } }