import { existsSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; import { resolveProjectWorkspace } from '../workspace.js'; import { readSceneCandidatesArtifact } from '../scene-candidate-store.js'; import { readProjectEvents } from '../events.js'; import { candidateState, deriveHeadline, rollupCounts } from './normalize.js'; import type { GenerationState, ProjectRunState } from './types.js'; async function readJsonSafe(path: string): Promise { try { return JSON.parse(await readFile(path, 'utf-8')) as T; } catch { return null; } } export async function readProjectRunState( root: string, slug: string, options: { reviewState?: 'missing' | 'current' | 'stale' } = {}, ): Promise { const workspace = resolveProjectWorkspace(slug, root); const artifactsDir = workspace.artifactsDir; const candidatesArtifact = await readSceneCandidatesArtifact(root, slug).catch(() => ({ schemaVersion: 1 as const, scenes: [] })); const storyboard = await readJsonSafe<{ scenes?: Array<{ sceneIndex?: number }> }>(join(artifactsDir, 'storyboard.json')); const report = await readJsonSafe<{ routeId?: string | null; blockers?: string[] }>(join(artifactsDir, 'execution-report.json')); // A `pending` candidate older than STALE_PENDING_MS is an abandoned run, not a // live render — treat it as 'none' so the project drops out of "Now rendering" // (→ idle) instead of showing a stale empty hero tile forever. const STALE_PENDING_MS = 6 * 60 * 60 * 1000; const now = Date.now(); const scenes: GenerationState[] = (candidatesArtifact.scenes ?? []).map((entry) => { const last = entry.candidates?.[entry.candidates.length - 1]; const hasVideo = !!(last?.outputs ?? []).find((o) => o.kind === 'video'); const submittedMs = last?.submittedAt ? new Date(last.submittedAt).getTime() : 0; const stalePending = last?.status === 'pending' && submittedMs > 0 && now - submittedMs > STALE_PENDING_MS; return { sceneIndex: entry.sceneIndex, state: !last || stalePending ? 'none' : candidateState(last.status, hasVideo), externalJobId: last?.source?.externalJobId ?? null, hasVideo, }; }); const total = Math.max(storyboard?.scenes?.length ?? 0, scenes.length); const counts = rollupCounts(scenes, total); const blockers = report?.blockers ?? []; const hasFinal = existsSync(join(workspace.projectDir, 'final')); const events = await readProjectEvents(workspace).catch(() => []); const lastActivity = events.length ? events[events.length - 1].recordedAt ?? null : null; return { slug, workspaceRoot: root, routeId: report?.routeId ?? null, headline: deriveHeadline({ counts, blockers, reviewState: options.reviewState, hasFinal }), scenes, counts, blockers, reviewState: options.reviewState, hasFinal, lastActivity, }; }