import { readFile } from 'node:fs/promises'; import { resolveWorkspaceRootFromEnv } from './workspace-root.js'; import { existsSync } from 'node:fs'; import { artifactPathFor, writeArtifact } from './artifact-store.js'; import { writeStageCheckpoint } from './checkpoints.js'; import { appendProjectEvent } from './events.js'; import { describeExecutionRuns } from './execution-run-marker.js'; import { cancelExecutionPayload } from './execution-runtime.js'; import { ensureProjectWorkspace, readProjectManifest, resolveProjectWorkspace, updateProjectManifestState } from './workspace.js'; import type { VideoExecutionCancelResult, VideoExecutionReport, VideoProductionMode } from './types.js'; export async function cancelExecution( projectSlug: string, options: { root?: string; productionMode?: VideoProductionMode; env?: NodeJS.ProcessEnv; } = {}, ): Promise<{ reportPath: string; report: VideoExecutionReport; cancellation: VideoExecutionCancelResult; }> { const root = options.root ?? resolveWorkspaceRootFromEnv(); const resolvedWorkspace = resolveProjectWorkspace(projectSlug, root); const projectManifest = await readProjectManifest(resolvedWorkspace); if (!projectManifest) { throw new Error(`Execution cancel unavailable for ${projectSlug}: project manifest is missing.`); } const workspace = await ensureProjectWorkspace(projectSlug, root); const reportPath = artifactPathFor(workspace, 'execution-report'); // #468: a run still inside its submit window has no job id to cancel yet. Name // the process instead of claiming there is nothing to cancel. Cancel never // deletes a marker — only the run itself (or execute-status, once stale) does. // Fail open: an unreadable marker dir must not change the structured error // this command has always produced (the handler keys its JSON fallback on it). const liveRuns = (await describeExecutionRuns(workspace).catch(() => [])).filter((run) => run.description.state === 'live'); const liveRunHint = liveRuns.length > 0 ? ` A \`vclaw video produce\` run (pid ${liveRuns[0].marker.pid}, started ${liveRuns[0].marker.startedAt}) is still submitting` + ` and has no provider job id yet: wait for it to finish, or confirm the process (\`ps -p ${liveRuns[0].marker.pid} -o command\`) and kill it.` : ''; if (!existsSync(reportPath)) { throw new Error(`Execution cancel unavailable for ${projectSlug}: execution-report artifact is missing.${liveRunHint}`); } const report = JSON.parse(await readFile(reportPath, 'utf-8')) as VideoExecutionReport; if (!report.routeId || !report.submission?.externalJobId) { throw new Error(`Execution cancel unavailable for ${projectSlug}: last execution report has no live adapter job id.${liveRunHint}`); } const cancellation = await cancelExecutionPayload({ projectSlug, routeId: report.routeId, externalJobId: report.submission.externalJobId, outputDir: `${workspace.projectDir}/outputs`, workspaceRoot: workspace.root, }, { env: options.env, }); // Fail closed. `unsupported` means the provider was NOT told to stop, so the // job is still running and still billable. Recording it as cancelled (a // `blocked` report, a failed checkpoint, a manifest reset and an // `execution.cancelled` event) told every status surface the run was over and // invited a resubmit — a second paid job beside the first. Nothing is written: // the report stays `live-submitted` with its job id, so `execute-status` keeps // reconciling it, and the answer travels in the returned `cancellation`. if (cancellation.status === 'unsupported') { return { reportPath, report, cancellation }; } const generatedAt = new Date().toISOString(); const issues = ['Execution cancelled by operator.', ...cancellation.issues]; const updatedReport: VideoExecutionReport = { ...report, status: 'blocked', poll: { lastCheckedAt: generatedAt, status: 'failed', issues, rawResult: cancellation.rawResult, }, }; const updatedReportPath = await writeArtifact(workspace, 'execution-report', updatedReport); await writeStageCheckpoint(workspace, { stage: 'assets', status: 'failed', generatedAt, artifacts: { 'execution-report': updatedReportPath, }, summary: 'Execution cancelled by operator.', issues, nextAction: 'Resolve the issue and resubmit execution when ready.', }); await updateProjectManifestState(workspace, { updatedAt: generatedAt, currentStage: 'assets', lastCompletedStage: 'storyboard', lastCheckpointStatus: 'failed', }); await appendProjectEvent(workspace, { type: 'execution.cancelled', recordedAt: generatedAt, payload: { reportPath: updatedReportPath, routeId: report.routeId, externalJobId: cancellation.externalJobId, status: cancellation.status, }, }); return { reportPath: updatedReportPath, report: updatedReport, cancellation, }; }