import { loadModelArkWorkspaceEnv } from './native-modelark.js'; import { createHash } from 'node:crypto'; import { VclawError } from './errors.js'; import { resolveActiveTransport } from './execution-adapter.js'; import type { RunContractArtifact } from './run-status.js'; import type { ProviderRouteId } from './provider-platform/types.js'; /** * Working-discipline rule 5 asks a person to dry-run the payload and confirm it * still matches what was approved immediately before submitting, because the * submission has diverged silently before (an `@tag` hijacked the references). * Nothing in the tool held that. `produce --dry-run` prints one approval hash * for the whole run; `produce --require-contract ` recomputes it from the * payload it is about to submit and refuses when it differs. * * The hash covers the route, the execution profile and every scene's * `contractHash` (prompt, resolved references, slot plan, provider settings and * the BYTES of each reference file). It leaves out what differs between a dry * run and a live run by design: timestamps, candidate ids and the provider job id. */ /** * The profile is hashed WHOLE, keys sorted, never re-listed field by field: a * hand-kept list already dropped `flowResolution` once in the contract builder, * and here that would let a run reviewed at 360p submit at 720p for twice the * credits. Whatever `buildRunContract` freezes on the profile is covered. */ function canonicalProfile(profile: RunContractArtifact['executionProfile']): Array<[string, unknown]> { return Object.entries(profile as Record) .filter(([, value]) => value !== undefined) .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)); } /** * The environment the fingerprint must see. For seedance-modelark that is the * route's own merge (`.env.local` under the process env): the transport reads * its model, host and chain mode from there, so a value set only in * `.env.local` would otherwise change the bill without changing the hash. */ export async function submitEnvironmentFor(routeId: string | null | undefined, workspaceRoot: string, env: NodeJS.ProcessEnv): Promise { return routeId === 'seedance-modelark' ? loadModelArkWorkspaceEnv(workspaceRoot, env) : env; } /** * What decides the spend but lives in the environment, not the payload: WHICH * transport submits (on `seedance-direct` the free engine and the paid API share * one route id and one payload, and `VCLAW_SEEDANCE_DIRECT_NATIVE=1` is the whole * difference) and Dreamina's route-local model and resolution overrides. Read * from the same environment the adapter is resolved from. */ export function submitEnvironmentFingerprint( routeId: ProviderRouteId, env: NodeJS.ProcessEnv, /** Injectable free-engine probe, passed straight to `resolveActiveTransport` (tests). */ probeFreeSeedanceEngine?: (env: NodeJS.ProcessEnv) => boolean, ): Record { const pick = (name: string) => (env[name] ?? '').trim().toLowerCase(); return { activeTransport: resolveActiveTransport(routeId, env, ...(probeFreeSeedanceEngine ? [probeFreeSeedanceEngine] as const : [])), ...(routeId === 'dreamina-useapi' ? { dreaminaModel: pick('VCLAW_DREAMINA_MODEL'), dreaminaResolution: pick('VCLAW_DREAMINA_RESOLUTION') } : {}), // The credential path decides WHO is billed and the resolution override // decides how much; both change the spend, so both bind the approval. ...(routeId === 'reapi-seedance' ? { reapiVia: pick('VCLAW_REAPI_SEEDANCE_VIA'), reapiResolution: pick('VCLAW_REAPI_SEEDANCE_RESOLUTION') } : {}), // The model sets the price and the reference limits; the base URL decides // which BytePlus account is billed. Both change the spend, so both bind. ...(routeId === 'seedance-modelark' ? { modelarkModel: pick('VCLAW_MODELARK_MODEL'), modelarkBaseUrl: pick('VCLAW_MODELARK_BASE_URL'), modelarkChainMode: pick('VCLAW_MODELARK_CHAIN_MODE') } : {}), }; } export function runContractApprovalHash( contract: Pick, submitEnvironment: Record = {}, ): string { const canonical = JSON.stringify({ routeId: contract.routeId, submitEnvironment: Object.entries(submitEnvironment).sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)), executionProfile: canonicalProfile(contract.executionProfile), scenes: [...contract.scenes] .sort((left, right) => left.sceneIndex - right.sceneIndex) .map((scene) => ({ sceneIndex: scene.sceneIndex, contractHash: scene.contractHash })), }); return createHash('sha256').update(canonical).digest('hex'); } const APPROVAL_HASH_RE = /^[0-9a-f]{64}$/; export function parseRequiredContractHash(raw: string | undefined): string { const value = (raw ?? '').trim().toLowerCase(); if (!APPROVAL_HASH_RE.test(value)) { throw new VclawError( 'invalid_flag_value', '--require-contract takes the 64-character contractApprovalHash a `video produce --dry-run` printed.', { flag: '--require-contract', value: raw ?? null }, ); } return value; } /** * Refuse a submit whose contract is not the approved one. `approved` is the * contract the last run left on disk: when it is the dry run that produced * `requiredHash`, the refusal can name the scenes that changed; when it is not * (another run overwrote it, or it never existed) the refusal says so instead. */ export function assertApprovedRunContract(input: { requiredHash: string; pending: Pick; approved: RunContractArtifact | null; /** From `submitEnvironmentFingerprint`, for the run about to be submitted. */ submitEnvironment?: Record; }): void { const submitEnvironment = input.submitEnvironment ?? {}; const pendingHash = runContractApprovalHash(input.pending, submitEnvironment); if (pendingHash === input.requiredHash) return; const approvedIsTheReviewedOne = input.approved !== null && runContractApprovalHash(input.approved, submitEnvironment) === input.requiredHash; const details: Record = { code: 'run-contract-not-approved', requiredContractHash: input.requiredHash, pendingContractHash: pendingHash, }; let explanation: string; if (approvedIsTheReviewedOne && input.approved) { const approvedByScene = new Map(input.approved.scenes.map((scene) => [scene.sceneIndex, scene.contractHash])); const pendingByScene = new Map(input.pending.scenes.map((scene) => [scene.sceneIndex, scene.contractHash])); const changedScenes = [...pendingByScene].filter(([index, hash]) => approvedByScene.has(index) && approvedByScene.get(index) !== hash).map(([index]) => index); const addedScenes = [...pendingByScene.keys()].filter((index) => !approvedByScene.has(index)); const droppedScenes = [...approvedByScene.keys()].filter((index) => !pendingByScene.has(index)); const settingsChanged = input.approved.routeId !== input.pending.routeId || JSON.stringify(canonicalProfile(input.approved.executionProfile)) !== JSON.stringify(canonicalProfile(input.pending.executionProfile)); Object.assign(details, { changedScenes, addedScenes, droppedScenes, settingsChanged }); const parts = [ changedScenes.length ? `scene ${changedScenes.join(', ')} changed (prompt, references, settings or the bytes of a reference file)` : '', addedScenes.length ? `scene ${addedScenes.join(', ')} was not in the reviewed run` : '', droppedScenes.length ? `scene ${droppedScenes.join(', ')} was reviewed but is not in this run` : '', settingsChanged ? 'the route or execution profile changed (aspect, quality, resolution, audio, output count or model)' : '', ].filter(Boolean); explanation = parts.join('; ') || 'the contracts differ'; } else { // Also the answer when only the environment moved: the contract on disk does // not record it, so it cannot be told apart from an overwritten contract. explanation = `the reviewed dry run is no longer the contract on disk, or the submit environment changed since (now: ${JSON.stringify(submitEnvironment)}), so the difference cannot be itemised`; details.submitEnvironment = submitEnvironment; } throw new VclawError( 'execution_blocked_by_readiness', `Nothing was submitted: this run is not the one that was reviewed (${explanation}). Run \`vclaw video produce --dry-run\` again, review artifacts/run-contract.json, and pass the new contractApprovalHash to --require-contract.`, details, ); } /** The commands that hold `--require-contract`. Every other command refuses it. */ const REQUIRE_CONTRACT_COMMANDS = new Set(['produce', 'execute']); /** * Handlers read flags with `args.includes(...)`, so a flag a command does not * know is dropped without a word. For a spend gate that is the worst outcome: * `render-scenes --require-contract --confirm-spend` would walk the whole * paid ladder looking gated. A command that cannot hold the gate says so. */ export function assertRequireContractIsHonoured(subcommand: string, args: string[]): void { if (!args.includes('--require-contract') || REQUIRE_CONTRACT_COMMANDS.has(subcommand)) return; throw new VclawError( 'invalid_flag_value', `video ${subcommand} does not hold --require-contract, so nothing would check this run against the reviewed contract. Only \`video produce\` (alias \`execute\`) does; review and submit the scenes through it, or drop the flag.`, { flag: '--require-contract', subcommand, honouredBy: [...REQUIRE_CONTRACT_COMMANDS] }, ); }