import path from "node:path"; import { recentJobs as recentChainJobs, runningJob, type Job } from "./jobs"; import { recentJobs as recentRenders, type RenderJob } from "./mix"; import { labelFor } from "./paths"; // --------------------------------------------------------------------------- // One activity list over the two job registries. // // Chain jobs (lib/jobs.ts: report builds, clip fetches, the song recipes) and // mix renders (lib/mix.ts) are kept in SEPARATE registries on purpose -- their // one-at-a-time rules differ: a chain is process-wide exclusive because two // builds would interleave in one out/, while a render is a single ffmpeg call // that does not care what else runs. This file does not merge the registries; // it merges their VIEWS, so the dashboard has one "now" and one "recent". // // SERVER ONLY, like everything that reaches the registries. // --------------------------------------------------------------------------- export type ActivityProgress = { /** 1-based step, of how many. */ step: number; of: number; /** Timeline entries done / total, from the build's NDJSON events, when known. */ clipsDone?: number; clipsOf?: number; /** Which entries the events have touched, and what state each is in. */ entries?: Record; }; export type ActivityItem = { id: string; source: "chain" | "render"; label: string; state: "running" | "done" | "failed"; startedAt: number; endedAt: number | null; error: string | null; progress?: ActivityProgress; /** Where to go to see it: the project page for a chain, /mix for a render. */ href: string; }; /** * Per-entry state from a build's events, the same vocabulary * ReportBuildChain.tsx paints its clip boxes from. Kept in one place so the * dashboard and the project page cannot disagree about what "done" means. */ export function entryStates(events: Record[]): ActivityProgress["entries"] { const out: NonNullable = {}; for (const e of events) { const id = typeof e.id === "string" ? e.id : null; if (!id) continue; const ev = e.ev; if (ev === "fetch") out[id] = e.cached ? "cutting" : "fetching"; else if (ev === "clip" || ev === "card" || ev === "snap") out[id] = "cutting"; else if (ev === "segment") out[id] = "done"; else if (ev === "entry-failed") out[id] = "failed"; } return out; } function fromChain(j: Job): ActivityItem { const entries = entryStates(j.events); const start = j.events.find((e) => e.ev === "start") as { entries?: number } | undefined; const clipsOf = typeof start?.entries === "number" ? start.entries : undefined; const clipsDone = Object.values(entries ?? {}).filter((s) => s === "done").length; return { id: j.id, source: "chain", label: j.kind, state: j.state, startedAt: j.startedAt, endedAt: j.endedAt, error: j.error, progress: { step: Math.min(j.stepIndex + 1, j.steps.length), of: j.steps.length, ...(clipsOf !== undefined ? { clipsOf, clipsDone } : {}), entries, }, href: j.project ? `/browse/${j.project}` : "/browse", }; } function fromRender(r: RenderJob): ActivityItem { return { id: r.id, source: "render", label: `render ${r.label || labelFor(r.out) || path.basename(r.out)}`, state: r.state, startedAt: r.startedAt, endedAt: r.endedAt, error: r.error, href: "/mix", }; } /** The newest n across both registries. */ export function listActivity(n = 8): ActivityItem[] { const all = [...recentChainJobs(n).map(fromChain), ...recentRenders(n).map(fromRender)]; return all.sort((a, b) => b.startedAt - a.startedAt).slice(0, n); } /** Whichever registry is busy, or null. A chain wins the tie: it is the long one. */ export function runningActivity(): ActivityItem | null { const chain = runningJob(); if (chain) return fromChain(chain); const render = recentRenders(4).find((r) => r.state === "running"); return render ? fromRender(render) : null; }