import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import { runStudioPlan, type StudioExecutionReport, type StudioStepRunner, } from '../studio/execute.js'; import type { StudioPlan } from '../studio/types.js'; export interface CreatorUiExecuteRequest { planDigest: string; mode: 'dry' | 'confirm-spend'; fromStepId?: string; confirmation?: { accepted: true; displayedEstimateUsd: number | null; }; } export class CreatorUiExecutionError extends Error { constructor( public readonly statusCode: number, public readonly code: string, message: string, ) { super(message); this.name = 'CreatorUiExecutionError'; } } export function parseCreatorUiExecuteRequest(body: unknown): CreatorUiExecuteRequest { if (!body || typeof body !== 'object' || Array.isArray(body)) { throw new CreatorUiExecutionError(400, 'creator_ui_invalid_request', 'Creator UI execute body must be a JSON object.'); } const input = body as Record; const allowedKeys = new Set(['planDigest', 'mode', 'fromStepId', 'confirmation']); const extra = Object.keys(input).filter((key) => !allowedKeys.has(key)); if (extra.length > 0) { throw new CreatorUiExecutionError(400, 'creator_ui_invalid_request', `Unexpected Creator UI execute field(s): ${extra.join(', ')}.`); } if (typeof input.planDigest !== 'string' || !/^sha256:[a-f0-9]{64}$/.test(input.planDigest)) { throw new CreatorUiExecutionError(400, 'creator_ui_invalid_request', 'planDigest must be a sha256 digest from POST /api/plan.'); } if (input.mode !== 'dry' && input.mode !== 'confirm-spend') { throw new CreatorUiExecutionError(400, 'creator_ui_invalid_request', 'mode must be dry or confirm-spend.'); } if (input.fromStepId !== undefined && (typeof input.fromStepId !== 'string' || !input.fromStepId.trim())) { throw new CreatorUiExecutionError(400, 'creator_ui_invalid_request', 'fromStepId must be a non-empty string.'); } let confirmation: CreatorUiExecuteRequest['confirmation']; if (input.mode === 'confirm-spend') { if (!input.confirmation || typeof input.confirmation !== 'object' || Array.isArray(input.confirmation)) { throw new CreatorUiExecutionError(400, 'creator_ui_publish_confirmation_required', 'Confirmed execution requires the displayed spend confirmation.'); } const value = input.confirmation as Record; const confirmationExtra = Object.keys(value).filter((key) => key !== 'accepted' && key !== 'displayedEstimateUsd'); if (confirmationExtra.length > 0 || value.accepted !== true || (value.displayedEstimateUsd !== null && typeof value.displayedEstimateUsd !== 'number')) { throw new CreatorUiExecutionError(400, 'creator_ui_invalid_request', 'confirmation must contain accepted: true and displayedEstimateUsd as a number or null.'); } confirmation = { accepted: true, displayedEstimateUsd: value.displayedEstimateUsd as number | null }; } else if (input.confirmation !== undefined) { throw new CreatorUiExecutionError(400, 'creator_ui_invalid_request', 'Dry execution does not accept a spend confirmation.'); } return { planDigest: input.planDigest, mode: input.mode, ...(input.fromStepId !== undefined ? { fromStepId: input.fromStepId as string } : {}), ...(confirmation ? { confirmation } : {}), }; } export function displayedEstimateUsd(plan: StudioPlan): number | null { const anticipated = (plan.spendRequirements ?? []).filter((item) => item.anticipated && item.requiresConfirmation); if (anticipated.some((item) => item.estimatedUsd === null)) return null; return anticipated.reduce((total, item) => total + (item.estimatedUsd ?? 0), 0); } export function runCreatorUiExecution( plan: StudioPlan, request: CreatorUiExecuteRequest, options: { runStep: StudioStepRunner; baseEnv?: NodeJS.ProcessEnv }, ): StudioExecutionReport { if (request.fromStepId && !plan.steps.some((step) => step.id === request.fromStepId)) { throw new CreatorUiExecutionError(400, 'creator_ui_invalid_request', `Unknown Studio step id: ${request.fromStepId}.`); } if (request.mode === 'confirm-spend' && request.confirmation?.displayedEstimateUsd !== displayedEstimateUsd(plan)) { throw new CreatorUiExecutionError(409, 'creator_ui_plan_stale', 'The displayed spend estimate changed; preview the plan again before confirming.'); } return runStudioPlan(plan, { runStep: options.runStep, ...(options.baseEnv ? { baseEnv: options.baseEnv } : {}), confirmSpend: request.mode === 'confirm-spend', autoApproveStoryboard: false, ...(request.fromStepId ? { fromStepId: request.fromStepId } : {}), }); } export const defaultCreatorUiStudioRunner: StudioStepRunner = (argv, env) => { const cliPath = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'cli', 'vclaw.js'); const result = spawnSync(process.execPath, [cliPath, ...argv], { encoding: 'utf-8', env, maxBuffer: 64 * 1024 * 1024, }); let json: unknown; try { json = result.stdout ? JSON.parse(result.stdout) : undefined; } catch { json = undefined; } return { exitCode: typeof result.status === 'number' ? result.status : 1, stdout: result.stdout ?? '', stderr: result.stderr ?? '', ...(json !== undefined ? { json } : {}), }; };