/** * Studio execution — a dry-by-default step runner for `vclaw studio --execute`. * * Studio's planner stays the source of truth: it emits a `StudioPlan` whose * steps are fully-resolved `vclaw video ...` command strings. This module does * NOT re-derive any orchestration decisions (readiness, route, approval) — it * just runs the emitted plan step-by-step by delegating each command to an * injectable {@link StudioStepRunner} (the CLI supplies a `spawnSync` wrapper; * tests inject a stub, so the whole runner is offline/deterministic). * * Credit safety is structural, not behavioural: * 1. Default `--execute` is dry: {@link classifyStep} REFUSES any spend/network * subcommand that lacks `--dry-run` (blocked-spend), and dry steps keep their * `--dry-run`. Real spend requires the explicit `--confirm-spend` (which strips * `--dry-run` from spend-dry steps); spend-live steps run only with it. * 2. `--auto-approve-storyboard` (only with `--confirm-spend`) is the ONLY way the * runner sets `VIDEOCLAW_APPROVE_STORYBOARD`; otherwise it strips it from the * child env, so the director storyboard-approval gate keeps blocking a real * render — the gate is inherited verbatim, never reimplemented here. * 3. FREE_SUBCOMMANDS is the ALLOW-LIST of verified-offline subcommands (local fs * reads/writes + pure logic only — no provider/Gemini/R2/credit call). The * classifier is FAIL-CLOSED: anything NOT on this allow-list is treated as a * spend step (and refused on a default dry `--execute` unless it carries * `--dry-run`). A new, unknown, or typo'd subcommand is therefore spend by * default — it can never silently run. To make a new subcommand free, verify * it is offline and add it here explicitly. */ import type { StudioGoal, StudioPlan, StudioPlanStep } from './types.js'; /** * Allow-list of subcommands verified to be fully offline: they only read/write * local project files and run pure logic — no provider credits, remote storage * pushes, or live external/paid API (Gemini/R2/network) calls. A step is only * classified `free` when its subcommand is on this list; everything else is a * spend step (fail-closed). Keep this list conservative — when in doubt, leave a * subcommand OFF so it is treated as spend rather than silently run. */ const FREE_SUBCOMMANDS = new Set([ // Read-only status / reporting 'status', 'readiness', 'next-actions', 'list', 'index', 'metrics', 'report', 'doctor-project', 'doctor-portfolio', 'candidates-list', 'candidates-show', // Local HTML portal generation (the publish-* variants push to R2 and are NOT free) 'portal', 'portal-index', 'review-ui', // Pure prompt/cinematography composers + deterministic local renders 'filmmaking-prompts', 'multi-shot', 'prompt-lint', 'cinema-profile', 'storyboard-grid', // Template / clone-plan reads (local template store + artifacts) 'template-list', 'template-show', 'storyboard-template-list', 'storyboard-template-show', 'clone-plan', // Character / reference-sheet local artifact reads & writes 'character-list', 'character-show', 'reference-sheet-add', 'reference-sheet-list', 'reference-sheet-show', 'reference-sheet-bind', 'reference-sheet-validate', // Scene-candidate selection / chaining (local artifact + event writes) 'select-candidate', 'reject-candidate', 'reroll-scene', 'chain-from', 'unchain', // Local exports (write to disk / local Obsidian vault, no network) 'export-csv', 'export-obsidian', 'sync-obsidian', // Local batch-queue state rollup (batch-submit/monitor poll+download and are NOT free) 'batch-status', ]); /** Env var that unblocks the director storyboard-approval gate. The runner must * never set it, so the gate keeps blocking inside any spawned execute. */ export const APPROVAL_ENV_VAR = 'VIDEOCLAW_APPROVE_STORYBOARD'; export type StudioStepClassification = 'free' | 'spend-dry' | 'spend-live'; export class StudioExecuteError extends Error { constructor( public readonly code: string, message: string, ) { super(message); this.name = 'StudioExecuteError'; } } /** * Tokenizes a Studio command template into argv, honoring single/double quotes * (recipe commands quote multi-word values, e.g. `--client "Acme Inc"`). */ export function parseStudioCommandArgv(command: string): string[] { const argv: string[] = []; let current = ''; let quote: '"' | "'" | null = null; let started = false; for (const ch of command) { if (quote) { if (ch === quote) quote = null; else current += ch; started = true; } else if (ch === '"' || ch === "'") { quote = ch; started = true; } else if (ch === ' ' || ch === '\t' || ch === '\n') { if (started) { argv.push(current); current = ''; started = false; } } else { current += ch; started = true; } } if (started) argv.push(current); return argv; } /** * Returns the argv with a leading `vclaw` token removed (recipe commands are * written `vclaw video ...`; the spawned child is the vclaw binary itself). */ export function studioStepArgv(step: StudioPlanStep): string[] { const argv = parseStudioCommandArgv(step.command); return argv[0] === 'vclaw' ? argv.slice(1) : argv; } /** The subcommand of a `vclaw video ...` (or `vclaw ...`) command. */ function subcommandOf(argv: string[]): string | undefined { const rest = argv[0] === 'vclaw' ? argv.slice(1) : argv; return rest[0] === 'video' ? rest[1] : rest[0]; } /** * Classifies a step by credit risk, FAIL-CLOSED. A step is only `free` when its * subcommand is on the {@link FREE_SUBCOMMANDS} allow-list; everything else — * including an unknown, typo'd, or missing subcommand — is a spend step: * `spend-dry` when it carries `--dry-run`, else `spend-live` (refused by the * runner on a default dry `--execute`). An empty/undefined subcommand is the * safest classification, `spend-live`. */ export function classifyStep(step: StudioPlanStep): StudioStepClassification { const argv = parseStudioCommandArgv(step.command); const sub = subcommandOf(argv); if (sub && FREE_SUBCOMMANDS.has(sub)) { return 'free'; } return argv.includes('--dry-run') ? 'spend-dry' : 'spend-live'; } export interface StudioStepRunResult { exitCode: number; stdout: string; stderr: string; /** Parsed child stdout JSON, when stdout was valid JSON. */ json?: unknown; } /** Injectable step runner. Receives child argv (no leading `vclaw`) + child env. */ export type StudioStepRunner = (argv: string[], env: NodeJS.ProcessEnv) => StudioStepRunResult; export interface StudioExecuteOptions { runStep: StudioStepRunner; /** Base env the child env is derived from. */ baseEnv?: NodeJS.ProcessEnv; /** * Permit credit-spending steps to RUN for real. When set, spend-dry steps are * promoted to live (their `--dry-run` is stripped) and spend-live steps run. * Default false → fully dry (spend-live is refused; spend-dry runs as dry). */ confirmSpend?: boolean; /** * Only meaningful with `confirmSpend`: set `VIDEOCLAW_APPROVE_STORYBOARD` in the * child env so a real render proceeds unattended past the director gate. Without * it the approval var is stripped, so the human storyboard-approval gate still * blocks a real render unless it was approved out-of-band. */ autoApproveStoryboard?: boolean; /** Resume from this step id (earlier steps are recorded as skipped). */ fromStepId?: string; } export type StudioStepStatus = | 'ran' | 'skipped-before-resume' | 'skipped-approval' | 'blocked-spend' | 'blocked' | 'failed'; export interface StudioStepResult { id: string; command: string; classification: StudioStepClassification; status: StudioStepStatus; exitCode: number | null; /** `status` field parsed from the child's JSON output, when present. */ childStatus?: string; /** A storyboard markdown path surfaced by a director awaiting-approval child. */ markdownPath?: string; } export type StudioStopReason = | 'approval' | 'spend-guard' | 'child-blocked' | 'child-failed' | 'missing-inputs'; export type StudioExecutionMode = 'dry' | 'confirm-spend' | 'auto-render'; export interface StudioExecutionReport { schemaVersion: 1; goal: StudioGoal; /** dry = no credits; confirm-spend = real spend, human-gated; auto-render = real spend, gate auto-approved. */ mode: StudioExecutionMode; executed: boolean; ranCount: number; stoppedAt?: string; stopReason?: StudioStopReason; /** Set in dry mode when the plan contains spend steps: how to render for real. */ hint?: string; results: StudioStepResult[]; warnings: string[]; } /** * Builds the child env. By default the storyboard-approval var is STRIPPED so the * director gate keeps blocking. Only when `autoApprove` is set (auto-render mode) * is it set to '1' so a real render proceeds unattended. */ export function studioChildEnv( base: NodeJS.ProcessEnv = {}, opts: { autoApprove?: boolean } = {}, ): NodeJS.ProcessEnv { const env = { ...base }; if (opts.autoApprove) { env[APPROVAL_ENV_VAR] = '1'; } else { delete env[APPROVAL_ENV_VAR]; } return env; } function interpretChild(result: StudioStepRunResult): { stop: boolean; status: StudioStepStatus; reason?: StudioStopReason; childStatus?: string; markdownPath?: string; } { if (result.exitCode !== 0) { return { stop: true, status: 'failed', reason: 'child-failed' }; } const json = result.json && typeof result.json === 'object' && !Array.isArray(result.json) ? (result.json as Record) : undefined; const childStatus = json && typeof json.status === 'string' ? json.status : undefined; const blocked = childStatus === 'blocked' || childStatus === 'awaiting-approval' || json?.awaitingApproval === true || json?.state === 'awaiting-approval'; if (blocked) { const markdownPath = typeof json?.markdownPath === 'string' ? json.markdownPath : typeof json?.storyboardMarkdownPath === 'string' ? (json.storyboardMarkdownPath as string) : undefined; return { stop: true, status: 'blocked', reason: 'child-blocked', childStatus, markdownPath }; } return { stop: false, status: 'ran', childStatus }; } /** Returns argv with any `--dry-run` flag removed — used to promote a spend-dry * step to a real run under --confirm-spend. * * NOTE: the paid audio commands (`narrate`/`dialogue`/`sfx`/`soundtrack`) have * their OWN fail-closed CLI gate (`requireSpendConfirmation` in vclaw.ts) that * refuses a real run unless the argv carries `--dry-run` OR `--confirm-spend`. * No STUDIO_RECIPE emits those subcommands today, so stripping `--dry-run` here * is sufficient. If a recipe ever DOES emit one, this promotion must also append * `--confirm-spend` (otherwise the promoted child hits the CLI gate and fails). */ export function stripDryRunArgv(argv: string[]): string[] { return argv.filter((arg) => arg !== '--dry-run'); } /** * Runs a Studio plan's steps in order, stopping (fail-fast, no partial spend) at * the first spend-guard refusal, child block, or child failure. Pure control flow * — all I/O is delegated to `opts.runStep`. Never mutates the input plan. * * Modes (by flags): default = dry (free + spend-dry-as-dry; spend-live refused); * confirmSpend = real spend (spend-dry promoted by stripping --dry-run), still * human-gated; confirmSpend + autoApproveStoryboard = real spend with the director * storyboard gate auto-approved (unattended). */ export function runStudioPlan(plan: StudioPlan, opts: StudioExecuteOptions): StudioExecutionReport { const confirmSpend = opts.confirmSpend === true; const autoApprove = opts.autoApproveStoryboard === true; if (autoApprove && !confirmSpend) { throw new StudioExecuteError( 'studio_execute_auto_approve_requires_confirm_spend', '--auto-approve-storyboard only applies together with --confirm-spend.', ); } const mode: StudioExecutionMode = !confirmSpend ? 'dry' : autoApprove ? 'auto-render' : 'confirm-spend'; const childEnv = studioChildEnv(opts.baseEnv, { autoApprove: confirmSpend && autoApprove }); const results: StudioStepResult[] = []; const report: StudioExecutionReport = { schemaVersion: 1, goal: plan.goal, mode, executed: false, ranCount: 0, results, warnings: plan.warnings, }; if (plan.missingInputs.length > 0) { report.stopReason = 'missing-inputs'; return report; } if (mode === 'dry' && plan.steps.some((step) => classifyStep(step) !== 'free')) { report.hint = 'Dry run: spend steps used --dry-run. To render for real add --confirm-spend (storyboard approval still required), or --confirm-spend --auto-approve-storyboard for an unattended render.'; } let resuming = opts.fromStepId !== undefined; for (const step of plan.steps) { const classification = classifyStep(step); if (resuming) { if (step.id === opts.fromStepId) { resuming = false; } else { results.push({ id: step.id, command: step.command, classification, status: 'skipped-before-resume', exitCode: null }); continue; } } // Spend guard: a spend-live step (spend subcommand without --dry-run) runs // only under --confirm-spend; otherwise it is refused and the run stops. if (classification === 'spend-live' && !confirmSpend) { results.push({ id: step.id, command: step.command, classification, status: 'blocked-spend', exitCode: null }); report.stoppedAt = step.id; report.stopReason = 'spend-guard'; return report; } // Free + spend-dry steps run (the real human checkpoint comes from the child's // own director-approval gate, surfaced as a 'blocked' result — not a coarse // pre-stop). Under --confirm-spend a spend-dry step is promoted to a real run // by stripping its --dry-run. const baseArgv = studioStepArgv(step); const argv = confirmSpend && classification === 'spend-dry' ? stripDryRunArgv(baseArgv) : baseArgv; const runResult = opts.runStep(argv, childEnv); const verdict = interpretChild(runResult); results.push({ id: step.id, command: step.command, classification, status: verdict.status, exitCode: runResult.exitCode, ...(verdict.childStatus ? { childStatus: verdict.childStatus } : {}), ...(verdict.markdownPath ? { markdownPath: verdict.markdownPath } : {}), }); if (verdict.status === 'ran') { report.ranCount += 1; report.executed = true; } if (verdict.stop) { report.stoppedAt = step.id; report.stopReason = verdict.reason; return report; } } return report; }