import { resolveInRoots, labelFor } from "@/lib/paths"; import { TRIM_SETS, hookRecipe, ENV_DENY } from "@/lib/trim"; import { getJob, jobView, recentJobs, runningJob, startJob } from "@/lib/jobs"; export const dynamic = "force-dynamic"; // Start a rebuild, or poll one. // // The client sends a RECIPE NAME and typed parameters. It never sends a // command, a path to execute, or an env map -- the recipe is a pure function // from those parameters to the steps, and it is the only thing that may set // HOOKDIR or TAKE_DIR (see lib/trim.ts for why that matters). // // POLLING, NOT STREAMING. Every client component in this app is useState + // fetch(no-store), and MixBench already polls a render job exactly this way. // SSE would be the only long-lived connection in the codebase, would need its // own reconnect handling, and buys nothing for a chain whose slowest step is a // couple of minutes. const RECIPES = ["mk-hooks-remix"] as const; type Recipe = (typeof RECIPES)[number]; const isRecipe = (v: string): v is Recipe => (RECIPES as readonly string[]).includes(v); /** `20260817-1408`, the same stamp shape promote uses for a demoted cut. */ function stamp(d = new Date()): string { const p = (n: number) => String(n).padStart(2, "0"); return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}`; } export async function GET(request: Request) { const url = new URL(request.url); const id = url.searchParams.get("job"); const since = Number(url.searchParams.get("since") ?? 0) || 0; const headers = { "cache-control": "no-store" }; if (!id) { return Response.json( { running: runningJob()?.id ?? null, jobs: recentJobs().map((j) => jobView(j, j.log.length)) }, { headers }, ); } const job = getJob(id); if (!job) return Response.json({ error: "no such job" }, { status: 404 }); return Response.json(jobView(job, since), { headers }); } export async function POST(request: Request) { const url = new URL(request.url); const dry = url.searchParams.get("dry") === "1"; let body: { recipe?: string; set?: string; body?: string; overlay?: number }; try { body = await request.json(); } catch { return Response.json({ error: "bad json" }, { status: 400 }); } const recipe = body.recipe ?? ""; if (!isRecipe(recipe)) { return Response.json({ error: `recipe must be one of ${RECIPES.join(", ")}` }, { status: 400 }); } const set = TRIM_SETS[body.set ?? ""]; if (!set) return Response.json({ error: "no such trim set" }, { status: 400 }); // The body render is a media INPUT the user picks, because nothing on disk // records which render belongs to which cut. Read-only, and still resolved // inside the roots. const bodyAbs = resolveInRoots(body.body); if (!bodyAbs) { return Response.json( { error: "the body render must be a file inside a known root" }, { status: 400 }, ); } const steps = hookRecipe(set, stamp(), bodyAbs, Math.max(0, Number(body.overlay) || 0)); // The invariant that costs a take selection if it is ever broken. Asserted // here, before anything spawns, rather than trusted to the recipe staying // correct through a future edit. if (Object.keys(steps[2].env).some((k) => ENV_DENY.includes(k))) { return Response.json( { error: "refusing to run: the re-mix step must not set HOOKDIR or TAKE_DIR" }, { status: 500 }, ); } const view = { recipe, set: set.id, body: labelFor(bodyAbs), steps: steps.map((s) => ({ label: s.label, argv: s.argv, cwd: s.cwd, env: s.env })), }; // Same contract as /api/mix/render?dry=1: the exact argv, before it runs. if (dry) return Response.json({ dry: true, ...view }, { headers: { "cache-control": "no-store" } }); const running = runningJob(); if (running) { return Response.json( { error: `a job is already running (${running.id})`, running: running.id }, { status: 409 }, ); } try { const job = startJob(recipe, steps); return Response.json({ ok: true, ...view, job: jobView(job) }, { headers: { "cache-control": "no-store" } }); } catch (e) { return Response.json({ error: e instanceof Error ? e.message : String(e) }, { status: 409 }); } }