import { createHash } from 'node:crypto'; import { accessSync, constants, existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { spawn } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { VclawError } from './errors.js'; import { ROUTE_PREREQUISITES } from './provider-platform/route-prerequisites.js'; import type { ProviderRouteId } from './provider-platform/types.js'; import type { ActiveTransport, VideoExecutionCancelResult, VideoExecutionPayload, VideoExecutionPollResult, } from './types.js'; function adapterEnvVarForRoute(routeId: ProviderRouteId): string { switch (routeId) { case 'veo-useapi': return 'VCLAW_VEO_USEAPI_ADAPTER'; case 'seedance-direct': return 'VCLAW_SEEDANCE_DIRECT_ADAPTER'; case 'runway-useapi': return 'VCLAW_RUNWAY_USEAPI_ADAPTER'; case 'dreamina-useapi': return 'VCLAW_DREAMINA_USEAPI_ADAPTER'; case 'magnific-rest': return 'VCLAW_MAGNIFIC_REST_ADAPTER'; case 'seedance-modelark': return 'VCLAW_SEEDANCE_MODELARK_ADAPTER'; case 'reapi-seedance': return 'VCLAW_REAPI_SEEDANCE_ADAPTER'; } } function builtinAdapterCommandForRoute(routeId: ProviderRouteId): string | null { if (!['seedance-direct', 'veo-useapi', 'runway-useapi', 'dreamina-useapi', 'magnific-rest', 'seedance-modelark', 'reapi-seedance'].includes(routeId)) { return null; } const scriptPath = fileURLToPath(new URL('../cli/provider-adapter.js', import.meta.url)); return `${JSON.stringify(process.execPath)} ${JSON.stringify(scriptPath)} --route ${routeId}`; } /** * Filename of the readiness marker `bootstrap/import_cookies.py` writes INSIDE * the Higgsfield browser session directory after a successful, signed-in cookie * import. Only the cookie import can write it, because only the cookie import * proves a sign-in. */ export const FREE_SEEDANCE_ENGINE_MARKER = '.vclaw-engine-ready.json'; /** * Whether the operator has explicitly asked for the PAID Seedance API * (`api.xskill.ai`, `SUTUI_API_KEY`) on `seedance-direct`. * * One helper, read by the readiness verdict, the transport precedence and * `vclaw video providers`, because the whole point of the flag is that those * three must agree on when money is authorised. The accepted spellings match * `isTruthyEnv` in provider-platform/route-capabilities.ts, which reads the * same variable for the paid transport's 15s duration cap: before this, an * unset-by-`=0` shell counted as a paid pin here and as no pin there. */ export function explicitPaidSeedanceTransport(env: NodeJS.ProcessEnv): boolean { const value = (env.VCLAW_SEEDANCE_DIRECT_NATIVE ?? '').trim().toLowerCase(); return value !== '' && value !== '0' && value !== 'false' && value !== 'no' && value !== 'off'; } /** Why the vendored free Seedance engine is (or is not) usable on this machine. */ export interface FreeSeedanceEngineDescription { ready: boolean; /** One line naming the failing check and the command that fixes it. Absent when ready. */ reason?: string; venvPath: string; profilePath: string; markerPath: string; } export interface FreeSeedanceEngineProbeOptions { /** * Engine directory to probe. Tests point this at a temp layout: the real * `engines/seedance-direct/` is set up on real machines and its profile * directory holds a live, secret Higgsfield browser session, so a test that * read it would pass or fail on who ran it. */ engineDir?: string; } /** * The readiness of the vendored free Seedance engine, as a verdict PLUS the * reason — one function so a probe and an operator-facing hint cannot disagree. * * Presence alone is not readiness. This used to check only that * `.venv/bin/python` and the profile directory EXIST, which an empty directory * and an empty file satisfy: the CI setup action creates exactly those stubs, * so any machine that mimicked it (or any half-finished bootstrap) reported * `seedance-direct: available` on `in-tree-engine` with nothing configured, and * a `--confirm-spend` render would have launched a broken engine instead of * refusing. The marker is the one artifact that cannot be faked by `mkdir`. */ export function describeFreeInTreeSeedanceEngine( env: NodeJS.ProcessEnv, options: FreeSeedanceEngineProbeOptions = {}, ): FreeSeedanceEngineDescription { const engineDir = options.engineDir ?? fileURLToPath(new URL('../../engines/seedance-direct/', import.meta.url)); const venvPath = join(engineDir, '.venv', 'bin', 'python'); const profilePath = env.HIGGS_VCLAW_PROFILE ?? join(engineDir, '.cloak-profile'); const markerPath = join(profilePath, FREE_SEEDANCE_ENGINE_MARKER); // Reasons carry no trailing period: callers embed them mid-sentence. const verdict = (reason?: string): FreeSeedanceEngineDescription => ({ ready: !reason, reason, venvPath, profilePath, markerPath }); if (explicitPaidSeedanceTransport(env)) { return verdict('VCLAW_SEEDANCE_DIRECT_NATIVE is set, which selects the paid API'); } if (!existsSync(venvPath)) { return verdict(`the engine is not installed (no Python environment at ${venvPath})`); } try { accessSync(venvPath, constants.X_OK); } catch { return verdict(`the engine's Python at ${venvPath} is not executable, so the install is incomplete`); } if (!existsSync(profilePath)) { return verdict(`the Higgsfield browser session has not been imported (nothing at ${profilePath})`); } let raw: string; try { raw = readFileSync(markerPath, 'utf-8'); } catch { return verdict( `the Higgsfield browser session has not been imported (${profilePath} has no ${FREE_SEEDANCE_ENGINE_MARKER};` + ' a session already signed in can have that file written by hand)', ); } let parsed: unknown; try { parsed = JSON.parse(raw); } catch { return verdict(`the Higgsfield browser session record at ${markerPath} is not valid JSON`); } const loggedIn = (parsed as { loggedIn?: unknown } | null)?.loggedIn; if (loggedIn !== true) { return verdict( `the last Higgsfield sign-in check failed (${markerPath} records loggedIn=${JSON.stringify(loggedIn)}),` + ' so the imported session is not signed in', ); } return verdict(); } /** Whether the vendored free Seedance engine is fully bootstrapped and enabled. */ export function hasFreeInTreeSeedanceEngine( env: NodeJS.ProcessEnv, options: FreeSeedanceEngineProbeOptions = {}, ): boolean { return describeFreeInTreeSeedanceEngine(env, options).ready; } /** * Which transport actually owns the next submission on a route. Reported by * `vclaw video providers` as `activeTransport`, and derived from the SAME * precedence `resolveAdapterCommand` (and, for the `command-shim` step, the * built-in adapter binary) walks — so the report cannot drift from the code * that runs: * 1. `..._ADAPTER` -> custom-adapter (an operator-supplied binary) * 2. free in-tree engine -> in-tree-engine (seedance-direct only, $0, browser) * 3. `..._SUBMIT_CMD` -> command-shim (built-in adapter runs the shim) * 4. unusable free engine, no paid pin -> blocked (seedance-direct only) * 5. otherwise -> the route's native in-process transport * A route reading `native-*` is the one whose declared `requiredEnvVars` must * actually be present; the other three own their own credentials. * * Step 4 is ADR 0007 in code. `seedance-direct` is one route id over two * materially different providers — the free bundled Higgsfield engine and the * paid Ark/xskill API — so an engine that stops being usable (an expired * session, a half-finished bootstrap, a fresh clone) used to move every render * onto the paid API without anyone choosing that. Paid execution is now a * decision the operator makes with `VCLAW_SEEDANCE_DIRECT_NATIVE=1`. */ const NATIVE_TRANSPORTS: Record = { 'veo-useapi': 'native-veo', 'seedance-direct': 'native-seedance', 'runway-useapi': 'native-runway', 'dreamina-useapi': 'native-dreamina', 'magnific-rest': 'native-magnific', 'seedance-modelark': 'native-modelark', 'reapi-seedance': 'native-reapi', }; /** The two credential paths `reapi-seedance` can bill through; chosen explicitly. */ export const REAPI_SEEDANCE_PATHS = ['treg', 'direct'] as const; export type ReapiSeedancePath = (typeof REAPI_SEEDANCE_PATHS)[number]; /** * Which credential path `VCLAW_REAPI_SEEDANCE_VIA` names, or `null` when it * names neither. Read by the transport precedence (a `null` is `blocked`), by * `vclaw video providers`, and by the native transport itself, so the refusal * and the report cannot disagree. The path is never inferred from which key * happens to be set: `treg` bills a treg balance, `direct` a reAPI account. */ export function explicitReapiSeedancePath(env: NodeJS.ProcessEnv): ReapiSeedancePath | null { const value = (env.VCLAW_REAPI_SEEDANCE_VIA ?? '').trim().toLowerCase(); return (REAPI_SEEDANCE_PATHS as readonly string[]).includes(value) ? (value as ReapiSeedancePath) : null; } export function resolveActiveTransport( routeId: ProviderRouteId, env: NodeJS.ProcessEnv, /** * Injectable engine probe. The real one stats the vendored venv + browser * profile, so a caller that has already probed passes its own answer in and * the two cannot disagree; tests pass a fixed one. Default = the real probe. */ probeFreeSeedanceEngine: (env: NodeJS.ProcessEnv) => boolean = hasFreeInTreeSeedanceEngine, ): ActiveTransport { if (env[adapterEnvVarForRoute(routeId)]?.trim()) return 'custom-adapter'; if (routeId === 'seedance-direct' && probeFreeSeedanceEngine(env)) return 'in-tree-engine'; // The shim env var names are DECLARED once in route-prerequisites.ts (#463); // a second table here is exactly the drift that declaration removed. if (ROUTE_PREREQUISITES[routeId].commandEnvVars.some((name) => env[name]?.trim())) return 'command-shim'; if (routeId === 'seedance-direct' && !explicitPaidSeedanceTransport(env)) return 'blocked'; if (routeId === 'reapi-seedance' && explicitReapiSeedancePath(env) === null) return 'blocked'; return NATIVE_TRANSPORTS[routeId]; } /** The `blocked` refusal for `reapi-seedance`: the selector names no path. */ function reapiBlockedRefusal(routeId: ProviderRouteId, env: NodeJS.ProcessEnv, action: string): VclawError { const given = (env.VCLAW_REAPI_SEEDANCE_VIA ?? '').trim(); return new VclawError( 'execution_blocked_by_readiness', 'Seedance 2.5 via reAPI cannot run: VCLAW_REAPI_SEEDANCE_VIA ' + (given ? `is '${given}', which names neither credential path. ` : 'is not set. ') + 'This route can bill two different accounts, so the path is chosen, never inferred: ' + 'VCLAW_REAPI_SEEDANCE_VIA=treg renders on the treg token (TREG_TOKEN, billed to the treg team balance) and ' + 'VCLAW_REAPI_SEEDANCE_VIA=direct on your own reAPI key (REAPI_API_KEY, references hosted with GO_BANANAS_API_KEY).' + ' (This refusal is the one carrying details.activeTransport === "blocked".)', { routeId, envVar: 'VCLAW_REAPI_SEEDANCE_VIA', activeTransport: 'blocked', action, reason: given ? `unknown path '${given}'` : 'VCLAW_REAPI_SEEDANCE_VIA is not set', choices: [...REAPI_SEEDANCE_PATHS], }, ); } /** * The shell command that runs a route's adapter — the executable half of * `resolveActiveTransport`, so the two walk ONE precedence for the decision * that can spend. A verdict of `blocked` is the only one with no command. * * `action` exists because only `submit` spends. `resolveActiveTransport` * answers "who owns the next SUBMISSION", which is the right question for * `vclaw video providers` and the wrong one for reading a job that already * exists: a poll, a cancel or a lookup reaches a provider someone already * chose, so refusing them does not prevent a charge — it strands the charge. * That is what this carve-out fixes. `produce` under a one-shot * `VCLAW_SEEDANCE_DIRECT_NATIVE=1` left a live paid job that `execute-status` * from an ordinary shell could then never poll or cancel: the gate threw, * `execution-status.ts` folded the throw into `issues`, and the scene stayed * `pending` for good while the job ran on and billed. */ function resolveAdapterCommand( routeId: ProviderRouteId, env: NodeJS.ProcessEnv, action: 'submit' | 'poll' | 'cancel' | 'lookup' = 'submit', ): string { // Probed lazily: an explicit adapter override outranks the engine, and on // that path this would otherwise stat a venv and a browser profile whose // answer nothing reads. let engineVerdict: FreeSeedanceEngineDescription | null | undefined; const engine = (): FreeSeedanceEngineDescription | null => { if (engineVerdict === undefined) { engineVerdict = routeId === 'seedance-direct' ? describeFreeInTreeSeedanceEngine(env) : null; } return engineVerdict; }; const transport = resolveActiveTransport(routeId, env, () => engine()?.ready ?? false); if (transport === 'custom-adapter') return env[adapterEnvVarForRoute(routeId)] as string; if (transport === 'in-tree-engine') { return JSON.stringify(fileURLToPath(new URL('../../engines/seedance-direct/run.sh', import.meta.url))); } if (transport === 'blocked' && action === 'submit' && routeId === 'reapi-seedance') { throw reapiBlockedRefusal(routeId, env, action); } if (transport === 'blocked' && action === 'submit') { throw new VclawError( 'execution_blocked_by_readiness', 'Seedance 2.0 cannot run free through your Higgsfield account right now: ' + `${engine()?.reason ?? 'the bundled engine is not set up on this machine'}. ` + 'Refusing to bill the paid Seedance API instead, because nobody asked for it. ' + 'Either finish the free setup (run engines/seedance-direct/bootstrap.sh, then ' + 'bootstrap/import_cookies.py on a machine whose Chrome is signed in to higgsfield.ai) ' + 'or choose the paid API on purpose with VCLAW_SEEDANCE_DIRECT_NATIVE=1 and SUTUI_API_KEY.' + ' (This refusal is the one carrying details.activeTransport === "blocked".)', { routeId, envVar: 'VCLAW_SEEDANCE_DIRECT_NATIVE', activeTransport: transport, action, reason: engine()?.reason ?? null, }, ); } const builtin = builtinAdapterCommandForRoute(routeId); if (builtin) return builtin; throw new VclawError( 'env_var_missing', `Live execution for ${routeId} requires ${adapterEnvVarForRoute(routeId)} to point at an adapter command.`, { routeId, envVar: adapterEnvVarForRoute(routeId) }, ); } /** * The submit contract's adapter-command hash, bound into a Cinema quote and * checked again at authorization. It asks the SUBMIT question deliberately, so * a blocked route refuses to be quoted rather than being quoted for a transport * that will not run. */ export function executionAdapterCommandHash(routeId: ProviderRouteId, env: NodeJS.ProcessEnv = process.env): string { return `sha256:${createHash('sha256').update(resolveAdapterCommand(routeId, env, 'submit')).digest('hex')}`; } async function runAdapter( routeId: ProviderRouteId, input: unknown, env: NodeJS.ProcessEnv, action: 'submit' | 'poll' | 'cancel' | 'lookup', ): Promise<{ adapterCommand: string; stdout: string; rawResult: unknown }> { const adapterCommand = resolveAdapterCommand(routeId, env, action); const result = await new Promise<{ stdout: string; stderr: string; code: number | null }>((resolve, reject) => { const child = spawn('sh', ['-lc', adapterCommand], { env, stdio: ['pipe', 'pipe', 'pipe'] }); let stdout = ''; let stderr = ''; child.stdout.on('data', (chunk) => { stdout += String(chunk); }); child.stderr.on('data', (chunk) => { stderr += String(chunk); }); child.on('error', reject); child.on('close', (code) => resolve({ stdout, stderr, code })); child.stdin.write(JSON.stringify(input)); child.stdin.end(); }); if (result.code !== 0) { throw new VclawError( 'adapter_command_failed', `Adapter ${action} failed for ${routeId}: ${result.stderr.trim() || `exit ${result.code}`}`, { routeId, action, exitCode: result.code }, ); } const stdout = result.stdout.trim(); let rawResult: unknown = stdout; if (stdout) { try { rawResult = JSON.parse(stdout) as unknown; } catch { rawResult = stdout; } } return { adapterCommand, stdout, rawResult }; } function objectResult(value: unknown): Record | null { return value && typeof value === 'object' ? value as Record : null; } function externalJobIdFrom(value: unknown, fallback: string | null = null): string | null { const candidate = objectResult(value)?.externalJobId; return candidate === undefined ? fallback : String(candidate ?? '') || fallback; } function executionOutputsFrom(value: unknown): VideoExecutionPollResult['outputs'] { const record = objectResult(value); const rawOutputs = Array.isArray(record?.outputs) ? record.outputs : []; return rawOutputs .filter((asset): asset is Record => !!asset && typeof asset === 'object' && typeof (asset as Record).id === 'string' && typeof (asset as Record).kind === 'string' && typeof (asset as Record).path === 'string') .map((asset) => ({ id: String(asset.id), kind: ['image', 'video', 'audio', 'subtitle', 'other'].includes(String(asset.kind)) ? String(asset.kind) as 'image' | 'video' | 'audio' | 'subtitle' | 'other' : 'other' as const, path: String(asset.path), ...(Number.isInteger(asset.sceneIndex) ? { sceneIndex: asset.sceneIndex as number } : {}), ...(typeof asset.backend === 'string' && asset.backend.trim() ? { backend: asset.backend } : {}), })); } export async function submitExecutionPayload( payload: VideoExecutionPayload, options: { env?: NodeJS.ProcessEnv } = {}, ): Promise<{ adapterCommand: string; externalJobId: string | null; rawResult: unknown }> { const result = await runAdapter(payload.routeId, payload, options.env ?? process.env, 'submit'); const externalJobId = externalJobIdFrom(result.rawResult); if (!externalJobId) { throw new VclawError( 'adapter_command_failed', `Adapter submit for ${payload.routeId} returned no externalJobId ` + `(stdout: ${result.stdout ? result.stdout.slice(0, 200) : ''}). ` + 'Adapters must print JSON {"externalJobId":"..."} on submit.', { routeId: payload.routeId, action: 'submit' }, ); } return { adapterCommand: result.adapterCommand, externalJobId, rawResult: result.rawResult }; } export async function pollExecutionPayload( input: { projectSlug: string; routeId: ProviderRouteId; externalJobId: string; outputDir: string; workspaceRoot: string; }, options: { env?: NodeJS.ProcessEnv } = {}, ): Promise { const result = await runAdapter(input.routeId, { action: 'poll', ...input }, options.env ?? process.env, 'poll'); const record = objectResult(result.rawResult); const status = String(record?.status ?? ''); if (!(status === 'pending' || status === 'completed' || status === 'failed')) { throw new VclawError('adapter_command_failed', `Adapter poll for ${input.routeId} returned invalid status: ${status || 'missing'}`, { routeId: input.routeId, action: 'poll', status: status || null, }); } const outputs = executionOutputsFrom(result.rawResult); const issues = Array.isArray(record?.issues) ? record.issues.map(String) : []; const actualCost = actualCostFrom(record?.actualCost); return { status, externalJobId: externalJobIdFrom(result.rawResult, input.externalJobId) ?? input.externalJobId, outputs, issues, rawResult: result.rawResult, ...(actualCost ? { actualCost } : {}), }; } /** A shell adapter's `actualCost` survives only as `{ currency, amount }` with a finite amount; anything else is dropped, never guessed. */ function actualCostFrom(value: unknown): { currency: string; amount: number } | undefined { if (!value || typeof value !== 'object') return undefined; const { currency, amount } = value as { currency?: unknown; amount?: unknown }; if (typeof currency !== 'string' || !currency.trim() || typeof amount !== 'number' || !Number.isFinite(amount) || amount < 0) return undefined; return { currency, amount }; } export async function lookupExecutionSubmission( input: { payload: VideoExecutionPayload; submissionKey: string; }, options: { env?: NodeJS.ProcessEnv } = {}, ): Promise<{ state: 'not-found' | 'accepted' | 'processing' | 'completed' | 'failed' | 'unknown'; authoritative: boolean; externalJobId: string | null; providerStatus: string; outputs: VideoExecutionPollResult['outputs']; issues: string[]; rawResult: unknown; }> { if (!input.submissionKey.trim()) throw new Error('Adapter submission lookup requires a submission key'); const payload = input.payload; const result = await runAdapter(payload.routeId, { action: 'lookup', submissionKey: input.submissionKey, projectSlug: payload.projectSlug, routeId: payload.routeId, outputDir: payload.outputDir, workspaceRoot: payload.workspaceRoot, }, options.env ?? process.env, 'lookup'); const record = objectResult(result.rawResult); const state = String(record?.state ?? ''); if (!['not-found', 'accepted', 'processing', 'completed', 'failed', 'unknown'].includes(state)) { throw new VclawError('adapter_command_failed', `Adapter lookup for ${payload.routeId} returned invalid state: ${state || 'missing'}`, { routeId: payload.routeId, action: 'lookup', state: state || null, }); } const authoritative = record?.authoritative === true; const externalJobId = externalJobIdFrom(result.rawResult); if (['accepted', 'processing', 'completed', 'failed'].includes(state) && !externalJobId) { throw new VclawError('adapter_command_failed', `Adapter lookup for ${payload.routeId} returned ${state} without externalJobId`, { routeId: payload.routeId, action: 'lookup', state, }); } return { state: state as 'not-found' | 'accepted' | 'processing' | 'completed' | 'failed' | 'unknown', authoritative, externalJobId, providerStatus: String(record?.providerStatus ?? state), outputs: executionOutputsFrom(result.rawResult), issues: Array.isArray(record?.issues) ? record.issues.map(String) : [], rawResult: result.rawResult, }; } export async function cancelExecutionPayload( input: { projectSlug: string; routeId: ProviderRouteId; externalJobId: string; outputDir: string; workspaceRoot: string; }, options: { env?: NodeJS.ProcessEnv } = {}, ): Promise { const result = await runAdapter(input.routeId, { action: 'cancel', ...input }, options.env ?? process.env, 'cancel'); const record = objectResult(result.rawResult); const status = String(record?.status ?? ''); if (!(status === 'cancelled' || status === 'unsupported')) { throw new VclawError('adapter_command_failed', `Adapter cancel for ${input.routeId} returned invalid status: ${status || 'missing'}`, { routeId: input.routeId, action: 'cancel', status: status || null, }); } return { status, externalJobId: externalJobIdFrom(result.rawResult, input.externalJobId) ?? input.externalJobId, issues: Array.isArray(record?.issues) ? record.issues.map(String) : [], rawResult: result.rawResult, }; }