import path from "node:path"; import { mkdir, writeFile } from "node:fs/promises"; import { NextResponse } from "next/server"; import { getRegistry, newJobId, type JobRecord, } from "yt-dlp-transcript-common/jobs/registry"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { testRouteDenied } from "../_guard"; export const dynamic = "force-dynamic"; // E2E test harness only. Fabricates the registry/scheduler DRIFT that /jobs // surfaces (the live payload's reconciliation): a job holding a running // scheduler slot that has gone quiet (no in-flight tasks) far longer than // STUCK_AGE_MS — the soft "possibly-stalled" case. That case is deliberately // NOT auto-healed, so it persists across polls and the test can prove // FORCE-RELEASE (not auto-heal) clears it. We reproduce it directly // (backdating startedAt) since a genuinely wedged child would be racy. // GUARDED BY `E2E_TEST_ROUTES`, like every other /api/test route. It was // mounted unconditionally on the reasoning that the editor is a localhost admin // tool — but the operator's browser is inside the loopback, so an // unauthenticated GET that writes into the registry is CSRF-able. See _guard.ts. // Run `fn` after `ms` FROM OUTSIDE ANY REQUEST (see `releaseAfterMs` below for // why); resolves whether that worked. async function armDetached(fn: () => void, ms: number): Promise { let detached = false; try { // A Next internal (typed, but not public API) — test harness only. const { workAsyncStorage } = await import("next/dist/server/app-render/work-async-storage.external"); workAsyncStorage.exit(() => { detached = workAsyncStorage.getStore() === undefined; setTimeout(fn, ms); }); } catch { setTimeout(fn, ms); } return detached; } export async function GET(request: Request) { const denied = testRouteDenied(); if (denied) return denied; const url = new URL(request.url); // `release=`: finish a holder this route made NOW, the same detached way // `releaseAfterMs` does — for a spec that knows when it is done holding, // rather than one sizing a timer for the slowest machine it might run on // (e2e speed S3: publish-lane.spec's 45 s, channel-storage.spec's 25 s). const releaseId = url.searchParams.get("release"); if (releaseId) { const registry = getRegistry(); const known = registry.get(releaseId) !== undefined; const detached = known ? await armDetached(() => registry.finalize(releaseId, "done"), 0) : false; return NextResponse.json({ id: releaseId, released: known, detached }); } const queueKey = url.searchParams.get("queue") || "stuck-queue"; // `slug` (+ `task`): the fake job runs ON A CHANNEL, transcribing one video — // a writer the media move must refuse over and name (release 16 slice RM's // relocate e2e). Without them the record is the slug-less holder it always // was. const slug = url.searchParams.get("slug") || undefined; const task = url.searchParams.get("task") || undefined; const registry = getRegistry(); const paths = getPaths(); const id = newJobId(); const logPath = path.join(paths.jobsDir, `${id}.log`); const record: JobRecord = { id, kind: "whisper-all", queueKey, ...(slug ? { channelSlug: slug } : {}), status: "queued", queuedAt: Date.now(), logPath, }; registry.register(record); // Submit through the normal enqueue path so the scheduler grants it a running // slot (start flips it to "running"/startedAt). Then backdate startedAt ~20m // and clear tasks so it reads as an idle, long-held slot → "possibly-stalled". registry.enqueue(record, { start: () => {}, onCancel: () => {} }); record.startedAt = Date.now() - 20 * 60 * 1000; record.tasks = task ? [{ id: task, label: task, kind: "transcribe", startedAt: Date.now() }] : []; // A log line so the page's lastLogLine tail has something to show. try { await mkdir(paths.jobsDir, { recursive: true }); await writeFile(logPath, "[fake] wedged job holding its slot\n", "utf8"); } catch { /* best-effort */ } // `releaseAfterMs`: finish the fake job after that long FROM OUTSIDE ANY // REQUEST, the way a job the auto-runner or the sync heartbeat started // finishes (release 9, B1). The scheduler starts the next queued job // synchronously inside `complete()`, so that job inherits the async context // the release runs in — and a job started with no Next work store is exactly // the one whose revalidatePath threw "static generation store missing". A // plain setTimeout here would inherit THIS request's store, so the timer is // armed inside `workAsyncStorage.exit`. `detached` reports whether that // worked, so a spec can refuse to pass for the wrong reason. const releaseAfterMs = Number(url.searchParams.get("releaseAfterMs") ?? ""); let detached = false; if (Number.isFinite(releaseAfterMs) && releaseAfterMs > 0) { detached = await armDetached(() => registry.finalize(id, "done"), releaseAfterMs); } return NextResponse.json({ id, queueKey, detached }); }