import { existsSync } from 'node:fs'; import { resolveWorkspaceRootFromEnv } from './workspace-root.js'; import { readFile } from 'node:fs/promises'; import { artifactPathFor, writeArtifact } from './artifact-store.js'; import { writeStageCheckpoint } from './checkpoints.js'; import { appendProjectEvent } from './events.js'; import { appendGenerationTelemetry, buildGenerationTelemetryFromPoll, } from './generation-telemetry.js'; import { pollExecutionPayload } from './execution-runtime.js'; import { deriveAssetManifestFromSelection } from './scene-candidates.js'; import { readSceneCandidatesArtifact, sceneCandidatesPathFor, withSceneArtifactsLock, writeSceneCandidatesArtifact, } from './scene-candidate-store.js'; import { markPending } from './scene-selection.js'; import { readSceneSelectionArtifact, writeSceneSelectionArtifact, } from './scene-selection-store.js'; import { regenerateRunSurface } from './preview-portal/index.js'; import { buildProjectStatusReport } from './status.js'; import { ensureProjectWorkspace, readProjectManifest, resolveProjectWorkspace, updateProjectManifestState } from './workspace.js'; import type { AssetManifestArtifact } from './artifacts.js'; import type { ProviderRouteId } from './provider-platform/types.js'; import type { SceneCandidate, SceneCandidateOutput, SceneCandidatesArtifact, VideoExecutionPollResult, VideoExecutionReport, VideoProductionMode, } from './types.js'; function mergeAssets( existing: AssetManifestArtifact['assets'], incoming: AssetManifestArtifact['assets'], ): AssetManifestArtifact['assets'] { const byId = new Map(existing.map((asset) => [asset.id, asset])); for (const asset of incoming) { byId.set(asset.id, asset); } return [...byId.values()].sort((left, right) => left.id.localeCompare(right.id)); } /** * Ids of candidate-derived OUTPUT assets are `${candidate.id}-output-${n}` * (see `deriveAssetManifestFromSelection`). Operator/keyframe INPUT assets use * `${kind}-${path}` ids (see `parseAssetSpec` in `asset-spec.ts`), so this * pattern never matches an input keyframe. */ const DERIVED_OUTPUT_ID_RE = /-output-\d+$/; /** * Candidate-mode `execute-status` re-derives the asset-manifest from the * SELECTED candidate outputs. Before any winner is selected the derived * manifest is empty, so a naive overwrite DELETES the per-scene INPUT image * keyframes that `buildExecutionPayload` needs to keep pending scenes on the * image-to-video path (no keyframe → text-to-video → fresh character every * scene). This preserves the existing manifest's INPUT assets (operator * image keyframe / audio / video reference assets whose ids are NOT candidate * outputs) and merges them under the freshly-derived outputs — the same * merge-preserve shape the * non-candidate branch already uses via `mergeAssets`. Derived outputs win on * id collision; the two id namespaces never collide so both survive. */ async function preserveInputKeyframes( workspace: Awaited>, derived: AssetManifestArtifact, ): Promise { const manifestPath = artifactPathFor(workspace, 'asset-manifest'); if (!existsSync(manifestPath)) return derived; let existing: AssetManifestArtifact['assets'] = []; try { const parsed = JSON.parse(await readFile(manifestPath, 'utf-8')) as AssetManifestArtifact; existing = Array.isArray(parsed.assets) ? parsed.assets : []; } catch { return derived; } // Preserve EVERY operator INPUT asset (image keyframe, audio, or video/V2V // reference) — i.e. any asset whose id is NOT a candidate-derived output. // Narrowing to image/audio would re-introduce the same wipe bug for input // `video:` references on the first pre-selection poll; the `-output-` id // guard alone cleanly separates inputs from derived outputs. const inputs = existing.filter((asset) => !DERIVED_OUTPUT_ID_RE.test(asset.id)); if (inputs.length === 0) return derived; return { projectSlug: derived.projectSlug, assets: mergeAssets(inputs, derived.assets) }; } /** Immutably replace one candidate within the candidates artifact. */ function updateCandidate( artifact: SceneCandidatesArtifact, sceneIndex: number, candidateId: string, fn: (prev: SceneCandidate) => SceneCandidate, ): SceneCandidatesArtifact { return { ...artifact, scenes: artifact.scenes.map((scene) => scene.sceneIndex !== sceneIndex ? scene : { ...scene, candidates: scene.candidates.map((c) => (c.id === candidateId ? fn(c) : c)) }, ), }; } /** * Per-scene candidate poll — the auto-chain / per-scene-submit safety net. * * The single `execution-report` records only the LAST submission's job, but in * candidate mode each scene carries its OWN adapter job id on * `candidate.source.externalJobId`. When the latest report is blocked / job-less * (e.g. a later scene failed to submit and overwrote the report), the legacy * single-job poll bails with `execution-already-blocked` and strands earlier * scenes' in-flight jobs. This polls every still-`pending` candidate that has its * own job id, promotes each independently, and re-derives the asset-manifest — so * one blocked scene never hides the rest of the chain's progress. * * Returns the full refresh result, or `null` when there is no candidate artifact * or no pending candidate carries a job id (the caller then keeps the legacy * blocked-report behavior — preserving today's output for non-candidate runs). */ async function pollPendingSceneCandidates(args: { workspace: Awaited>; root: string; projectSlug: string; baseReport: VideoExecutionReport; env?: NodeJS.ProcessEnv; }): Promise<{ reportPath: string; report: VideoExecutionReport; poll: VideoExecutionPollResult; assetManifestPath?: string; } | null> { const { workspace, root, projectSlug, baseReport } = args; if (!existsSync(sceneCandidatesPathFor(root, projectSlug))) return null; const candidates = await readSceneCandidatesArtifact(root, projectSlug); const pending: Array<{ sceneIndex: number; candidate: SceneCandidate }> = []; for (const scene of candidates.scenes) { for (const candidate of scene.candidates) { if (candidate.status === 'pending' && candidate.source.externalJobId) { pending.push({ sceneIndex: scene.sceneIndex, candidate }); } } } if (pending.length === 0) return null; const lastCheckedAt = new Date().toISOString(); const outputDir = `${workspace.projectDir}/outputs`; const allOutputs: VideoExecutionPollResult['outputs'] = []; const issues: string[] = []; let completed = 0; let failed = 0; let stillPending = 0; let lastJobId: string | null = null; // Collect per-scene deltas during the SLOW poll loop (kept OUTSIDE the lock). // Each delta is applied to a FRESH re-read of the on-disk artifacts under the // lock below, so a concurrent writer's update is never clobbered. const candidateDeltas: Array<{ sceneIndex: number; candidateId: string; apply: (prev: SceneCandidate) => SceneCandidate; }> = []; const selectionMarks: Array<{ markPendingScene: number; candidateId: string }> = []; for (const { sceneIndex, candidate } of pending) { const jobId = candidate.source.externalJobId as string; lastJobId = jobId; let poll: VideoExecutionPollResult; try { poll = await pollExecutionPayload({ projectSlug, routeId: candidate.route as ProviderRouteId, externalJobId: jobId, outputDir, workspaceRoot: workspace.root, }, { env: args.env }); } catch (error) { issues.push(`scene ${sceneIndex} (${candidate.id}): poll failed — ${(error as Error).message}`); stillPending += 1; continue; } allOutputs.push(...poll.outputs); if (poll.issues.length) issues.push(...poll.issues.map((issue) => `scene ${sceneIndex}: ${issue}`)); const completedWithoutOutputs = poll.status === 'completed' && poll.outputs.length === 0; if (poll.status === 'completed' && !completedWithoutOutputs) { const sceneOutputs: SceneCandidateOutput[] = []; for (const out of poll.outputs) { if (out.kind !== 'image' && out.kind !== 'video' && out.kind !== 'audio') continue; // Honor a per-output scene tag; untagged outputs attach to this job's scene. if (typeof out.sceneIndex === 'number' && out.sceneIndex !== sceneIndex) continue; sceneOutputs.push({ kind: out.kind, path: out.path }); } candidateDeltas.push({ sceneIndex, candidateId: candidate.id, apply: (prev) => ({ ...prev, status: 'completed', completedAt: lastCheckedAt, outputs: sceneOutputs.length > 0 ? sceneOutputs : prev.outputs, }), }); selectionMarks.push({ markPendingScene: sceneIndex, candidateId: candidate.id }); completed += 1; } else if (poll.status === 'failed' || completedWithoutOutputs) { if (completedWithoutOutputs) { issues.push(`scene ${sceneIndex}: completed but provider returned no outputs to ingest.`); } candidateDeltas.push({ sceneIndex, candidateId: candidate.id, apply: (prev) => ({ ...prev, status: 'failed', completedAt: lastCheckedAt, }), }); failed += 1; } else { stillPending += 1; } } // Apply the collected deltas to a FRESH re-read under the single artifact lock, // then derive the asset-manifest from the LOCKED result. const locked = await withSceneArtifactsLock(root, projectSlug, async () => { let cur = await readSceneCandidatesArtifact(root, projectSlug); let sel = await readSceneSelectionArtifact(root, projectSlug); for (const delta of candidateDeltas) { cur = updateCandidate(cur, delta.sceneIndex, delta.candidateId, delta.apply); } for (const mark of selectionMarks) { sel = markPending(sel, mark.markPendingScene, [mark.candidateId]); } await writeSceneCandidatesArtifact(root, projectSlug, cur); await writeSceneSelectionArtifact(root, projectSlug, sel); return { cur, sel }; }); const derived = deriveAssetManifestFromSelection(projectSlug, locked.cur, locked.sel); const merged = await preserveInputKeyframes(workspace, derived); const assetManifestPath = await writeArtifact(workspace, 'asset-manifest', merged); // Aggregate: still-pending dominates (keep polling); else completed if any // scene finished; else everything we polled failed. const status: VideoExecutionPollResult['status'] = stillPending > 0 ? 'pending' : completed > 0 ? 'completed' : 'failed'; const rawResult = { reason: 'per-scene-candidate-poll', polled: pending.length, completed, failed, stillPending, }; const poll: VideoExecutionPollResult = { status, externalJobId: lastJobId, outputs: allOutputs, issues, rawResult, }; const reportPath = artifactPathFor(workspace, 'execution-report'); const updatedReport: VideoExecutionReport = { ...baseReport, poll: { lastCheckedAt, status, issues, ...(status === 'completed' ? { outputsIngested: allOutputs.length } : {}), rawResult, }, }; if (status === 'completed') { await writeStageCheckpoint(workspace, { stage: 'assets', status: 'completed', generatedAt: lastCheckedAt, artifacts: { 'asset-manifest': assetManifestPath, 'execution-report': reportPath }, summary: 'Pending per-scene candidate jobs completed and outputs were ingested.', issues: [], nextAction: 'Run review on the generated outputs.', }); await updateProjectManifestState(workspace, { updatedAt: lastCheckedAt, currentStage: 'review', lastCompletedStage: 'assets', lastCheckpointStatus: 'completed', }); } else if (status === 'failed') { await writeStageCheckpoint(workspace, { stage: 'assets', status: 'failed', generatedAt: lastCheckedAt, artifacts: { 'execution-report': reportPath }, summary: 'Pending per-scene candidate jobs failed.', issues, nextAction: 'Resolve provider issues and resubmit the affected scenes.', }); await updateProjectManifestState(workspace, { updatedAt: lastCheckedAt, currentStage: 'assets', lastCompletedStage: 'storyboard', lastCheckpointStatus: 'failed', }); } else { await writeStageCheckpoint(workspace, { stage: 'assets', status: 'pending', generatedAt: lastCheckedAt, artifacts: { 'execution-report': reportPath }, summary: 'Per-scene candidate jobs are still pending.', issues, nextAction: 'Poll execution status again later.', }); await updateProjectManifestState(workspace, { updatedAt: lastCheckedAt, currentStage: 'assets', lastCompletedStage: 'storyboard', lastCheckpointStatus: 'pending', }); } await writeArtifact(workspace, 'execution-report', updatedReport); await appendProjectEvent(workspace, { type: 'execution.status.refreshed', recordedAt: lastCheckedAt, payload: { reportPath, status, externalJobId: lastJobId, outputsIngested: allOutputs.length, }, }); await appendGenerationTelemetry(workspace, buildGenerationTelemetryFromPoll({ report: updatedReport, poll, recordedAt: lastCheckedAt, })); // Repaint the live run dashboard (status badges + newly-ingested clips) so it // stays current each poll without a manual portal command. Best-effort. await regenerateRunSurface(root, projectSlug, args.env); return { reportPath, report: updatedReport, poll, assetManifestPath }; } export async function refreshExecutionStatus( projectSlug: string, options: { root?: string; productionMode?: VideoProductionMode; env?: NodeJS.ProcessEnv; } = {}, ): Promise<{ reportPath: string; report: VideoExecutionReport; poll: VideoExecutionPollResult; assetManifestPath?: string; }> { const root = options.root ?? resolveWorkspaceRootFromEnv(); const resolvedWorkspace = resolveProjectWorkspace(projectSlug, root); const projectManifest = await readProjectManifest(resolvedWorkspace); if (!projectManifest) { const now = new Date().toISOString(); const reportPath = artifactPathFor(resolvedWorkspace, 'execution-report'); const issues = [ `Execution status unavailable for ${projectSlug}: project manifest is missing. Run \`vclaw video init ${projectSlug}\` first.`, ]; const poll: VideoExecutionPollResult = { status: 'failed', externalJobId: null, outputs: [], issues, rawResult: { reason: 'missing-project-manifest', }, }; const report: VideoExecutionReport = { projectSlug, productionMode: options.productionMode ?? 'storyboard', operationKind: 'text-to-video', routeId: null, status: 'blocked', dryRun: false, generatedAt: now, blockers: issues, executedSteps: ['execution-status-requested'], taskCount: 0, poll: { lastCheckedAt: now, status: 'failed', issues, rawResult: poll.rawResult, }, }; return { reportPath, report, poll, }; } const workspace = await ensureProjectWorkspace(projectSlug, root); const status = await buildProjectStatusReport(projectSlug, root, options.productionMode ?? 'storyboard'); if (status.productionMode === 'director' && status.storyboardReviewStale) { throw new Error( `Execution status unavailable for ${projectSlug}: storyboard review is stale. Refresh ${status.storyboardReviewPath ?? 'storyboard.md'} before continuing.`, ); } const reportPath = artifactPathFor(workspace, 'execution-report'); if (!existsSync(reportPath)) { const lastCheckedAt = new Date().toISOString(); const issues = ['Execution status unavailable: execution-report artifact is missing. Run execution first.']; const poll: VideoExecutionPollResult = { status: 'failed', externalJobId: null, outputs: [], issues, rawResult: { reason: 'missing-execution-report', }, }; const updatedReport: VideoExecutionReport = { projectSlug, productionMode: status.productionMode, operationKind: 'text-to-video', routeId: null, status: 'blocked', dryRun: false, generatedAt: lastCheckedAt, blockers: issues, executedSteps: ['execution-status-requested'], taskCount: 0, poll: { lastCheckedAt, status: 'failed', issues, rawResult: poll.rawResult, }, }; await writeStageCheckpoint(workspace, { stage: 'assets', status: 'failed', generatedAt: lastCheckedAt, artifacts: { 'execution-report': reportPath, }, summary: 'Execution status refresh failed.', issues, nextAction: 'Run execution before polling execution status.', }); await updateProjectManifestState(workspace, { updatedAt: lastCheckedAt, currentStage: 'assets', lastCompletedStage: 'storyboard', lastCheckpointStatus: 'failed', }); await writeArtifact(workspace, 'execution-report', updatedReport); await appendProjectEvent(workspace, { type: 'execution.status.refreshed', recordedAt: lastCheckedAt, payload: { reportPath, status: 'failed', externalJobId: null, outputsIngested: 0, }, }); return { reportPath, report: updatedReport, poll, }; } const report = JSON.parse(await readFile(reportPath, 'utf-8')) as VideoExecutionReport; if (!report.routeId || !report.submission?.externalJobId) { // The latest report has no pollable job of its own. Before bailing, poll any // still-pending per-scene candidate jobs (auto-chain / per-scene submits each // carry their own job id) so one blocked scene doesn't strand the rest of the // chain. No candidate artifact / no pending job → null → legacy bail below. const candidateResult = await pollPendingSceneCandidates({ workspace, root, projectSlug, baseReport: report, env: options.env, }); if (candidateResult) return candidateResult; const lastCheckedAt = new Date().toISOString(); const reportHasBlockers = Array.isArray(report.blockers) && report.blockers.length > 0; const issues = reportHasBlockers ? [...report.blockers] : !report.routeId ? ['Execution status unavailable: last execution report has no provider route id.'] : ['Execution status unavailable: last execution report has no live adapter job id.']; const reason = reportHasBlockers ? 'execution-already-blocked' : !report.routeId ? 'missing-provider-route-id' : 'missing-live-adapter-job-id'; const nextAction = reportHasBlockers ? 'Resolve execution blockers and rerun execution.' : 'Rerun execution to create a live adapter job id before polling status.'; const poll: VideoExecutionPollResult = { status: 'failed', externalJobId: null, outputs: [], issues, rawResult: { reason, }, }; const updatedReport: VideoExecutionReport = { ...report, poll: { lastCheckedAt, status: 'failed', issues, rawResult: poll.rawResult, }, }; await writeStageCheckpoint(workspace, { stage: 'assets', status: 'failed', generatedAt: lastCheckedAt, artifacts: { 'execution-report': reportPath, }, summary: 'Execution status refresh failed.', issues, nextAction, }); await updateProjectManifestState(workspace, { updatedAt: lastCheckedAt, currentStage: 'assets', lastCompletedStage: 'storyboard', lastCheckpointStatus: 'failed', }); await writeArtifact(workspace, 'execution-report', updatedReport); await appendProjectEvent(workspace, { type: 'execution.status.refreshed', recordedAt: lastCheckedAt, payload: { reportPath, status: 'failed', externalJobId: null, outputsIngested: 0, }, }); return { reportPath, report: updatedReport, poll, }; } // Concurrent submits (the render pool, or any parallel `execute --scene`) put // MORE THAN ONE job in flight, but the single execution-report tracks only the // LATEST job (`report.submission`). Polling only that job orphans earlier // still-pending candidates — their finished scenes never download. When a // pending candidate carries a job id OTHER than the latest, poll EVERY pending // candidate's own job instead (this still covers the latest job too). The // single-job fast path below is unchanged when the only pending candidates // belong to the latest job — so the common one-job case is byte-identical. if (existsSync(sceneCandidatesPathFor(root, projectSlug))) { const pendingJobs = new Set(); for (const scene of (await readSceneCandidatesArtifact(root, projectSlug)).scenes) { for (const candidate of scene.candidates) { if (candidate.status === 'pending' && candidate.source.externalJobId) { pendingJobs.add(candidate.source.externalJobId); } } } const latestJobId = report.submission.externalJobId; const hasOrphanedJob = [...pendingJobs].some((jobId) => jobId !== latestJobId); if (hasOrphanedJob) { const multiJobResult = await pollPendingSceneCandidates({ workspace, root, projectSlug, baseReport: report, env: options.env, }); if (multiJobResult) return multiJobResult; } } const poll = await pollExecutionPayload({ projectSlug, routeId: report.routeId, externalJobId: report.submission.externalJobId, outputDir: `${workspace.projectDir}/outputs`, workspaceRoot: workspace.root, }, { env: options.env, }); const completedWithoutOutputs = poll.status === 'completed' && poll.outputs.length === 0; const normalizedPollStatus: VideoExecutionPollResult['status'] = completedWithoutOutputs ? 'failed' : poll.status; const normalizedIssues = completedWithoutOutputs ? [...poll.issues, 'Execution completed but provider returned no outputs to ingest.'] : poll.issues; const pollMetadata = { lastCheckedAt: new Date().toISOString(), status: normalizedPollStatus, issues: normalizedIssues, ...(normalizedPollStatus === 'completed' ? { outputsIngested: poll.outputs.length } : {}), rawResult: poll.rawResult, }; const updatedReport: VideoExecutionReport = { ...report, poll: pollMetadata, }; const lastCheckedAt = pollMetadata.lastCheckedAt; // Candidate-mode detection mirrors executeProject: if a candidate artifact // exists, or the last report carries `candidatesByScene`, treat this poll as // a candidate-mode poll and route output ingestion through the candidate // store. Otherwise we keep the legacy direct-asset-manifest behavior. const candidateMode = existsSync(sceneCandidatesPathFor(root, projectSlug)) || Array.isArray(report.candidatesByScene); let assetManifestPath: string | undefined; if (normalizedPollStatus === 'completed') { if (candidateMode) { // Candidate path — update per-scene candidates, append to // pendingCandidateIds, then re-derive asset-manifest from selection. // Group poll outputs by sceneIndex (pure, on already-fetched poll data — // safe OUTSIDE the lock). Outputs without a sceneIndex are attached to // every candidate we created for this run (preserves the legacy fallback // when adapters don't tag outputs). const outputsByScene = new Map(); const untaggedOutputs: SceneCandidateOutput[] = []; for (const out of poll.outputs) { const kind = out.kind === 'image' || out.kind === 'video' || out.kind === 'audio' ? out.kind : null; if (!kind) continue; const candidateOutput: SceneCandidateOutput = { kind, path: out.path }; if (typeof out.sceneIndex === 'number') { const existing = outputsByScene.get(out.sceneIndex) ?? []; existing.push(candidateOutput); outputsByScene.set(out.sceneIndex, existing); } else { untaggedOutputs.push(candidateOutput); } } const candidateIdsByScene = new Map(); for (const entry of report.candidatesByScene ?? []) { candidateIdsByScene.set(entry.sceneIndex, entry.candidateId); } // Apply the completion updates to a FRESH re-read under the single artifact // lock, so a concurrent pool scene's candidate/selection write is not lost. const locked = await withSceneArtifactsLock(root, projectSlug, async () => { let updatedCandidates = await readSceneCandidatesArtifact(root, projectSlug); let updatedSelection = await readSceneSelectionArtifact(root, projectSlug); for (const [sceneIndex, candidateId] of candidateIdsByScene) { const sceneOutputs = [ ...(outputsByScene.get(sceneIndex) ?? []), ...untaggedOutputs, ]; updatedCandidates = updateCandidate(updatedCandidates, sceneIndex, candidateId, (prev) => ({ ...prev, status: 'completed', completedAt: lastCheckedAt, outputs: sceneOutputs.length > 0 ? sceneOutputs : prev.outputs, })); updatedSelection = markPending(updatedSelection, sceneIndex, [candidateId]); } await writeSceneCandidatesArtifact(root, projectSlug, updatedCandidates); await writeSceneSelectionArtifact(root, projectSlug, updatedSelection); return { updatedCandidates, updatedSelection }; }); // Derive asset-manifest from selection so the legacy review/publish // readers keep seeing a coherent manifest. Before any operator has // selected a winner the derived `assets` is empty, so preserve the // existing manifest's INPUT image/audio keyframes (merge-preserve) — // otherwise the first post-`produce` poll would wipe the per-scene // keyframes that keep pending scenes on the image-to-video path. const derived = deriveAssetManifestFromSelection(projectSlug, locked.updatedCandidates, locked.updatedSelection); const merged = await preserveInputKeyframes(workspace, derived); assetManifestPath = await writeArtifact(workspace, 'asset-manifest', merged); } else { const existingAssetManifest = existsSync(artifactPathFor(workspace, 'asset-manifest')) ? JSON.parse(await readFile(artifactPathFor(workspace, 'asset-manifest'), 'utf-8')) as AssetManifestArtifact : { projectSlug, assets: [] }; const nextAssetManifest: AssetManifestArtifact = { projectSlug, assets: mergeAssets(existingAssetManifest.assets ?? [], poll.outputs), }; assetManifestPath = await writeArtifact(workspace, 'asset-manifest', nextAssetManifest); } await writeStageCheckpoint(workspace, { stage: 'assets', status: 'completed', generatedAt: lastCheckedAt, artifacts: { 'asset-manifest': assetManifestPath, 'execution-report': reportPath, }, summary: 'Live execution completed and outputs were ingested.', issues: [], nextAction: 'Run review on the generated outputs.', }); await updateProjectManifestState(workspace, { updatedAt: lastCheckedAt, currentStage: 'review', lastCompletedStage: 'assets', lastCheckpointStatus: 'completed', }); } else if (normalizedPollStatus === 'failed') { await writeStageCheckpoint(workspace, { stage: 'assets', status: 'failed', generatedAt: lastCheckedAt, artifacts: { 'execution-report': reportPath, }, summary: 'Live execution failed.', issues: normalizedIssues, nextAction: 'Resolve provider issues and resubmit execution.', }); await updateProjectManifestState(workspace, { updatedAt: lastCheckedAt, currentStage: 'assets', lastCompletedStage: 'storyboard', lastCheckpointStatus: 'failed', }); } else { await writeStageCheckpoint(workspace, { stage: 'assets', status: 'pending', generatedAt: lastCheckedAt, artifacts: { 'execution-report': reportPath, }, summary: 'Live execution is still pending.', issues: normalizedIssues, nextAction: 'Poll execution status again later.', }); await updateProjectManifestState(workspace, { updatedAt: lastCheckedAt, currentStage: 'assets', lastCompletedStage: 'storyboard', lastCheckpointStatus: 'pending', }); } await writeArtifact(workspace, 'execution-report', updatedReport); await appendProjectEvent(workspace, { type: 'execution.status.refreshed', recordedAt: lastCheckedAt, payload: { reportPath, status: normalizedPollStatus, externalJobId: poll.externalJobId, outputsIngested: poll.outputs.length, }, }); await appendGenerationTelemetry(workspace, buildGenerationTelemetryFromPoll({ report: updatedReport, poll: { ...poll, status: normalizedPollStatus, issues: normalizedIssues, }, recordedAt: lastCheckedAt, })); // Repaint the live run dashboard (status badges + newly-ingested clips) so it // stays current each poll without a manual portal command. Best-effort. await regenerateRunSurface(root, projectSlug, options.env); return { reportPath, report: updatedReport, poll, ...(assetManifestPath ? { assetManifestPath } : {}), }; }