commit 33f338c4760dfe4776408a717138af0184f13dbb
parent 64549a205378f530760b86df53ef451ed2c193a1
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Wed, 23 Sep 2026 19:21:44 -0400
api: one polling route — /api/view/[name], and the eight old paths are rewrites
Eight route files that each called one builder and serialized it become one
dynamic route plus a handler table:
- `common/views/names.ts` (importless): `VIEW_NAMES`, `ViewName`, and
`VIEW_CONTRACT` — pulse is the one "observe" view, the other seven
"construct". A `/api/test/*` name may never join the tuple.
- `editor/app/api/view/views.ts`: `VIEWS: Record<ViewName, handler>`, total, so
a name without a handler is a tsc error. Each handler builds its own inputs,
exactly as its old route did; there is no shared constructor in the
dispatcher (a hoisted liveInputs() is the ~16-flaky-spec regression).
- `editor/app/api/view/pulseView.ts`: the pulse GET moved whole, the ?rev= idle
fast path included. `views.test.ts` reads it as text and bans the
constructors, and asserts the loader's `CleanableChannelRow` stays
assignable to the view's `CleanableChannel`.
- `editor/app/api/view/[name]/route.ts`: force-dynamic; the name is checked
before any handler runs; unknown ⇒ 404. No auth, no EDITOR_TEST_ROUTES guard —
unchanged from the eight routes it replaces.
The old paths are REWRITES in next.config.ts, not redirects: method, status,
body and the query string pass through, so pinned widgets, the dashboard and
`/api/pulse?rev=` keep working. No existing e2e assertion was edited;
`e2e/view-route.spec.ts` adds old-vs-new body equality, the 404, and ?rev
through the rewrite. `/api/widget/presets` is not a view and keeps its route.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
16 files changed, 487 insertions(+), 168 deletions(-)
diff --git a/common/views/names.test.ts b/common/views/names.test.ts
@@ -0,0 +1,30 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { VIEW_CONTRACT, VIEW_NAMES } from "./names";
+
+test("every name has a contract, and the contract names nothing else", () => {
+ assert.deepEqual(Object.keys(VIEW_CONTRACT).sort(), [...VIEW_NAMES].sort());
+});
+
+test("the names are unique", () => {
+ assert.equal(new Set(VIEW_NAMES).size, VIEW_NAMES.length);
+});
+
+// The one view that may not construct. If this flips, /api/view/pulse has
+// stopped being the cheap change-token the whole UI polls, and e2e/pulse.spec.ts
+// (< 250 ms, no corpus contact) is the next thing to go red.
+test("pulse observes; every other view constructs", () => {
+ assert.equal(VIEW_CONTRACT.pulse, "observe");
+ for (const name of VIEW_NAMES) {
+ if (name === "pulse") continue;
+ assert.equal(VIEW_CONTRACT[name], "construct", `${name} may construct`);
+ }
+});
+
+// A `/api/test/*` route is unauthenticated AND mutating; the dispatcher has no
+// guard, so the only thing standing between the two is this tuple.
+test("no test-harness route is a view", () => {
+ for (const name of VIEW_NAMES) {
+ assert.equal(/test/i.test(name), false, `${name} smells like the harness`);
+ }
+});
diff --git a/common/views/names.ts b/common/views/names.ts
@@ -0,0 +1,55 @@
+// THE POLLED VIEWS, BY NAME — the contract `/api/view/[name]` serves.
+//
+// Eight endpoints used to be eight route files that differed only in which
+// builder they called. They are one dynamic route now, and this tuple is what
+// makes that route total: `editor/app/api/view/views.ts` declares its handler
+// table as `Record<ViewName, …>`, so a name added here without a handler is a
+// tsc error rather than a 500 somebody finds in production. That completeness
+// proof is the entire reason the names live in a shared, importless module
+// instead of in the route beside the table.
+//
+// WHAT MAY BE ADDED. A view is something a client POLLS for state it draws:
+// read-only, unauthenticated, cheap enough to ask for on a timer, and safe for
+// anyone who can reach the editor at all. Nothing that mutates, nothing that
+// takes a credential, and — stated because the temptation is obvious — NO
+// `/api/test/*` NAME MAY EVER JOIN THIS TUPLE. Those routes fabricate jobs,
+// restart lanes and set the worker token; they are gated on
+// `EDITOR_TEST_ROUTES` precisely so they do not exist on a real editor, and
+// putting one behind this dispatcher would hand it the one surface here that
+// has no guard at all.
+export const VIEW_NAMES = [
+ "pulse",
+ "activeJobs",
+ "workers",
+ "autoQueueStatus",
+ "schedulerStatus",
+ "widgetSync",
+ "widgetActionable",
+ "cleanable",
+] as const;
+
+export type ViewName = (typeof VIEW_NAMES)[number];
+
+// OBSERVE OR CONSTRUCT, declared per view.
+//
+// An "observe" view may look at singletons that already exist and at file
+// mtimes, and MUST NOT bring anything into being: no registry, no settings
+// parse, no worker pool, no corpus read. `pulse` is the only one, and the rule
+// is not stylistic — constructing from the pulse poll is what made ~16 specs
+// flaky earlier this month, because a 1-second timer was racing the very
+// singletons the page under test was setting up.
+//
+// Everything else is "construct": it reads what it needs, each handler
+// assembling its OWN inputs. Do not hoist a shared constructor into the
+// dispatcher "for efficiency" — that is the same regression wearing a
+// different hat, and it would drag an observe view into constructing.
+export const VIEW_CONTRACT: Record<ViewName, "observe" | "construct"> = {
+ pulse: "observe",
+ activeJobs: "construct",
+ workers: "construct",
+ autoQueueStatus: "construct",
+ schedulerStatus: "construct",
+ widgetSync: "construct",
+ widgetActionable: "construct",
+ cleanable: "construct",
+};
diff --git a/editor/app/api/auto-queue/status/route.ts b/editor/app/api/auto-queue/status/route.ts
@@ -1,11 +0,0 @@
-import { NextResponse } from "next/server";
-import { buildAutoQueueStatusPayload } from "../../../operations/status";
-
-export const dynamic = "force-dynamic";
-
-// Read-only view for the Auto-Queue panel: per-kind runner status, effective
-// policy, recent picks, and snapshot-derived pending counts. Backs a passive UI
-// poll, like /api/scheduler/status and /api/jobs/active.
-export async function GET() {
- return NextResponse.json(await buildAutoQueueStatusPayload());
-}
diff --git a/editor/app/api/jobs/active/route.ts b/editor/app/api/jobs/active/route.ts
@@ -1,11 +0,0 @@
-import { NextResponse } from "next/server";
-import { buildActiveJobsPayload } from "../../../jobs/active/buildActiveJobs";
-
-export const dynamic = "force-dynamic";
-
-// Backs the ~1s client poll on the /jobs head, the dashboard and the widget,
-// so per-task progress bars advance live without a full RSC refresh.
-export async function GET() {
- const payload = await buildActiveJobsPayload();
- return NextResponse.json(payload);
-}
diff --git a/editor/app/api/pulse/route.ts b/editor/app/api/pulse/route.ts
@@ -1,52 +0,0 @@
-import { NextResponse } from "next/server";
-import { getPaths } from "yt-dlp-transcript-common/lib/paths";
-import {
- computePulse,
- type PulsePayload,
-} from "yt-dlp-transcript-common/views/pulse";
-import { observeInputs } from "../../lib/liveInputs";
-import { cleanableTotalBytes } from "../../cleanup/lib/loadCleanup";
-
-export const dynamic = "force-dynamic";
-
-// The change token the global AutoRefresh polls instead of blindly re-rendering
-// the whole page tree every 5 seconds. The token itself is
-// `common/views/pulse.ts`; `observeInputs()` is the observer's reads.
-//
-// ⚠️ THIS ENDPOINT OBSERVES; IT MUST NEVER CONSTRUCT. That is now a property of
-// the two functions it calls rather than a rule this file remembers — see the
-// header of either one for the ~16 flaky specs that bought it.
-//
-// Everything on the idle path is either in-memory or a stat(). NO readdir, no
-// corpus contact, no large JSON parse — asserted by e2e/pulse.spec.ts, because
-// the entire point of this endpoint is that it is cheap enough to poll forever.
-export async function GET(request: Request) {
- const known = new URL(request.url).searchParams.get("rev");
- const { rev, activeJobs, runningJobs, busy } = computePulse(observeInputs());
-
- // Idle fast path. The client already has this rev, so nothing it displays can
- // have changed — skip the only part of this endpoint that touches disk.
- if (known && known === rev) {
- return NextResponse.json({
- rev,
- changed: false,
- activeJobs,
- runningJobs,
- cleanableBytes: -1, // not recomputed; the client keeps its last value
- busy,
- } satisfies PulsePayload);
- }
-
- // Something moved: it's worth the ~40 ms to refresh the badge value too.
- // (Before the corpus walk was removed this number cost ~4.4 s, which is
- // precisely why it must never be on the idle path.)
- const cleanableBytes = await cleanableTotalBytes(getPaths());
- return NextResponse.json({
- rev,
- changed: true,
- activeJobs,
- runningJobs,
- cleanableBytes,
- busy,
- } satisfies PulsePayload);
-}
diff --git a/editor/app/api/scheduler/status/route.ts b/editor/app/api/scheduler/status/route.ts
@@ -1,11 +0,0 @@
-import { NextResponse } from "next/server";
-import { buildSchedulerStatusPayload } from "../../../scheduler/status";
-
-export const dynamic = "force-dynamic";
-
-// Read-only view for the /operations/sync console: the resolved per-channel
-// schedule (next due / last outcome / backoff) plus the recent tick log and the
-// effective scheduler settings. Backs a passive UI poll, like /api/jobs/active.
-export async function GET() {
- return NextResponse.json(await buildSchedulerStatusPayload());
-}
diff --git a/editor/app/api/view/[name]/route.ts b/editor/app/api/view/[name]/route.ts
@@ -0,0 +1,41 @@
+import { NextResponse } from "next/server";
+import {
+ VIEW_NAMES,
+ type ViewName,
+} from "yt-dlp-transcript-common/views/names";
+import { VIEWS } from "../views";
+
+export const dynamic = "force-dynamic";
+
+// ONE POLLING ROUTE. Eight route files that each called one builder and
+// serialized it are this file plus a table (`../views.ts`); the old paths are
+// REWRITES in next.config.ts, so `/api/pulse?rev=…` and `/api/widget/actionable`
+// still answer, byte for byte, for every pinned widget and open tab out there.
+//
+// NO AUTH AND NO ENV GUARD HERE, and that is today's behaviour preserved rather
+// than a gap: all eight of these were unauthenticated polls, they are read-only,
+// and the editor is a loopback-only admin surface (docker/guard-exposure.sh
+// refuses to start it otherwise). Contrast `api/worker/health`, which takes the
+// worker token, and `api/test/*`, which is gated on EDITOR_TEST_ROUTES because
+// it MUTATES. A `/api/test/*` name may never join `VIEW_NAMES`: this dispatcher
+// would serve it with no guard at all.
+//
+// THE NAME IS CHECKED BEFORE ANY INPUT IS CONSTRUCTED. An unknown name costs a
+// tuple lookup and a 404 — no corpus read, no singleton, nothing to wedge. The
+// handlers in the table are lazy closures for exactly that reason.
+function isViewName(name: string): name is ViewName {
+ return (VIEW_NAMES as readonly string[]).includes(name);
+}
+
+export async function GET(
+ request: Request,
+ ctx: { params: Promise<{ name: string }> },
+) {
+ const { name } = await ctx.params;
+ // 404 and not 500: a name nobody serves is a path that does not exist, which
+ // is what the client's `res.ok` check already knows how to handle.
+ if (!isViewName(name)) {
+ return NextResponse.json({ error: "Not Found" }, { status: 404 });
+ }
+ return VIEWS[name](request);
+}
diff --git a/editor/app/api/view/pulseView.ts b/editor/app/api/view/pulseView.ts
@@ -0,0 +1,57 @@
+import { NextResponse } from "next/server";
+import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import {
+ computePulse,
+ type PulsePayload,
+} from "yt-dlp-transcript-common/views/pulse";
+import { observeInputs } from "../../lib/liveInputs";
+import { cleanableTotalBytes } from "../../cleanup/lib/loadCleanup";
+
+// The change token the global AutoRefresh polls instead of blindly re-rendering
+// the whole page tree every 5 seconds. The token itself is
+// `common/views/pulse.ts`; `observeInputs()` is the observer's reads.
+//
+// ⚠️ THIS VIEW OBSERVES; IT MUST NEVER CONSTRUCT. That is now a property of
+// the two functions it calls rather than a rule this file remembers — see the
+// header of either one for the ~16 flaky specs that bought it, and
+// `VIEW_CONTRACT.pulse` for the same promise stated where the dispatcher can
+// be read against it. `views.test.ts` re-checks it as text.
+//
+// Everything on the idle path is either in-memory or a stat(). NO readdir, no
+// corpus contact, no large JSON parse — asserted by e2e/pulse.spec.ts, because
+// the entire point of this endpoint is that it is cheap enough to poll forever.
+//
+// It is the only view handler that reads the request: `?rev=` is the client's
+// last token, and the fast path below is what makes an idle editor nearly free.
+// The rewrite from `/api/pulse` is server-internal, so that query string
+// arrives here unchanged.
+export async function pulseView(request: Request): Promise<Response> {
+ const known = new URL(request.url).searchParams.get("rev");
+ const { rev, activeJobs, runningJobs, busy } = computePulse(observeInputs());
+
+ // Idle fast path. The client already has this rev, so nothing it displays can
+ // have changed — skip the only part of this endpoint that touches disk.
+ if (known && known === rev) {
+ return NextResponse.json({
+ rev,
+ changed: false,
+ activeJobs,
+ runningJobs,
+ cleanableBytes: -1, // not recomputed; the client keeps its last value
+ busy,
+ } satisfies PulsePayload);
+ }
+
+ // Something moved: it's worth the ~40 ms to refresh the badge value too.
+ // (Before the corpus walk was removed this number cost ~4.4 s, which is
+ // precisely why it must never be on the idle path.)
+ const cleanableBytes = await cleanableTotalBytes(getPaths());
+ return NextResponse.json({
+ rev,
+ changed: true,
+ activeJobs,
+ runningJobs,
+ cleanableBytes,
+ busy,
+ } satisfies PulsePayload);
+}
diff --git a/editor/app/api/view/views.test.ts b/editor/app/api/view/views.test.ts
@@ -0,0 +1,66 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { VIEW_CONTRACT } from "yt-dlp-transcript-common/views/names";
+import type { CleanableChannel } from "yt-dlp-transcript-common/views/cleanable";
+import type { CleanableChannelRow } from "../../cleanup/lib/loadCleanup";
+
+// Run with:
+// pnpm -C editor exec tsx --test "app/**/*.test.ts"
+
+const HERE = path.dirname(fileURLToPath(import.meta.url));
+
+// THE OBSERVE CONTRACT, CHECKED AS TEXT.
+//
+// `VIEW_CONTRACT.pulse === "observe"` is a promise about what the handler does,
+// and only the handler can keep it. The ban below is the same trick the corpus
+// walk guard uses (common/controller/noCorpusWalkInRenderPaths.test.ts): read
+// the file and refuse the names that construct. It is crude and it is the
+// reason ~16 specs stopped flaking — a pulse poll that builds the registry, the
+// settings or the worker pool races the singletons the page under test is
+// setting up, on a 1-second timer, forever.
+//
+// THE MATCH IS CONTEXT-BLIND, deliberately: a comment naming one of these with
+// its parenthesis fails too. Reword the comment; do not loosen the test.
+const CONSTRUCTORS = [
+ "liveInputs(",
+ "getRegistry(",
+ "getSettings(",
+ "getWorkerPool(",
+];
+
+test("the pulse view observes and never constructs", () => {
+ const src = readFileSync(path.join(HERE, "pulseView.ts"), "utf8");
+ assert.equal(VIEW_CONTRACT.pulse, "observe");
+ // Guard the guard: if the file stops naming the observer, the ban below is
+ // checking an empty claim.
+ assert.ok(
+ src.includes("observeInputs("),
+ "pulseView.ts must read its inputs through observeInputs()",
+ );
+ for (const banned of CONSTRUCTORS) {
+ assert.ok(
+ !src.includes(banned),
+ `pulseView.ts names ${banned} — the pulse view may not construct`,
+ );
+ }
+});
+
+// THE CLEANABLE ROW, ASSERTED AT COMPILE TIME.
+//
+// `common/views/cleanable.ts` re-declares the loader's row structurally rather
+// than importing it, because a view may not reach into the app. That only keeps
+// working while the two stay assignable, and tsc fails HERE — naming both
+// types — instead of somewhere inside the handler table.
+//
+// Tuple-wrapped on both sides: a bare `A extends B ?` DISTRIBUTES over a union
+// and a non-assignable member would collapse to `never`, making `true | never`
+// = `true` (see common/views/streamAction.test.ts, which paid for that lesson).
+type AssignableTo<A, B> = [A] extends [B] ? true : never;
+const _rowIsAChannel: AssignableTo<CleanableChannelRow, CleanableChannel> = true;
+
+test("CleanableChannelRow is assignable to the view's CleanableChannel", () => {
+ assert.equal(_rowIsAChannel, true);
+});
diff --git a/editor/app/api/view/views.ts b/editor/app/api/view/views.ts
@@ -0,0 +1,87 @@
+import { NextResponse } from "next/server";
+import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import type { ViewName } from "yt-dlp-transcript-common/views/names";
+import { buildCleanablePayload } from "yt-dlp-transcript-common/views/cleanable";
+import { buildWidgetActionablePayload } from "yt-dlp-transcript-common/views/widgetActionable";
+import { buildWidgetSyncPayload } from "yt-dlp-transcript-common/views/widgetSync";
+import { pulseView } from "./pulseView";
+import { buildActiveJobsPayload } from "../../jobs/active/buildActiveJobs";
+import { buildWorkersPayload } from "../../workers/buildWorkers";
+import { buildAutoQueueStatusPayload } from "../../operations/status";
+import { buildSchedulerStatusPayload } from "../../scheduler/status";
+import { widgetSyncInputs } from "../../widget/lib/syncInputs";
+import { cleanableChannels } from "../../cleanup/lib/loadCleanup";
+import {
+ actionableDigestReachableCount,
+ actionableUndownloadedCount,
+ actionableUntranscribedCount,
+ loadActionableSummary,
+} from "../../lib/actionable/loadActionable";
+
+// THE HANDLER TABLE, AND THE COMPLETENESS PROOF.
+//
+// `Record<ViewName, …>` is total: every name in `VIEW_NAMES` must appear here
+// or the editor does not compile. That is the whole reason the names are a
+// tuple in `common/views/names.ts` rather than a union spelled out beside this
+// table — a union and a map drift; a total Record cannot.
+//
+// EVERY HANDLER ASSEMBLES ITS OWN INPUTS, and this file must never grow a
+// shared constructor hoisted out of them. One `liveInputs()` in the dispatcher
+// would be cheaper by a few milliseconds and would put the pulse view — which
+// may only OBSERVE — on the constructing path, which is the ~16-flaky-spec
+// regression from earlier this month rebuilt from the top. Each of these
+// closures does exactly what its old route file did, no more and in the same
+// order.
+//
+// The handlers are LAZY for the same reason: the dispatcher picks one by name
+// and calls it, so an unknown name reads nothing at all.
+export const VIEWS: Record<ViewName, (req: Request) => Promise<Response>> = {
+ // The change token, and the only handler that reads the request.
+ pulse: pulseView,
+
+ // The ~1 s poll on the /jobs head, the dashboard and the widget. This one
+ // HEALS: `buildActiveJobsPayload` completes scheduler slots whose job record
+ // is already terminal. (`liveJobRows`, which the server components call, does
+ // not — that asymmetry is deliberate and lives in the view.)
+ activeJobs: async () => NextResponse.json(await buildActiveJobsPayload()),
+
+ // Live worker status for the Workers page and the read-only widget.
+ workers: async () => NextResponse.json(buildWorkersPayload()),
+
+ // The Auto-Queue panel: per-kind runner status, effective policy, recent
+ // picks and snapshot-derived pending counts.
+ autoQueueStatus: async () =>
+ NextResponse.json(await buildAutoQueueStatusPayload()),
+
+ // The /operations/sync console: the resolved per-channel schedule, the recent
+ // tick log and the effective scheduler settings.
+ schedulerStatus: async () =>
+ NextResponse.json(await buildSchedulerStatusPayload()),
+
+ // The widget's last-sync and scheduler strips, plus the corpus-wide digest
+ // and backfill scalars.
+ widgetSync: async () =>
+ NextResponse.json(buildWidgetSyncPayload(await widgetSyncInputs())),
+
+ // The widget's "Needs work" strip. The census read and the map through the
+ // four count helpers (which know the availability exclusions) stay here; the
+ // filter and the sort are the view's.
+ widgetActionable: async () => {
+ const summary = await loadActionableSummary(getPaths());
+ return NextResponse.json(
+ buildWidgetActionablePayload(
+ summary.rows.map((row) => ({
+ slug: row.channel.slug,
+ undownloaded: actionableUndownloadedCount(row),
+ untranscribed: actionableUntranscribedCount(row),
+ digestReachable: actionableDigestReachableCount(row),
+ })),
+ ),
+ );
+ },
+
+ // The cleanable-data badge AND the widget's "Needs cleaning" list — one
+ // snapshot read serving both, as before.
+ cleanable: async () =>
+ NextResponse.json(buildCleanablePayload(await cleanableChannels(getPaths()))),
+};
diff --git a/editor/app/api/widget/actionable/route.ts b/editor/app/api/widget/actionable/route.ts
@@ -1,33 +0,0 @@
-import { NextResponse } from "next/server";
-import { getPaths } from "yt-dlp-transcript-common/lib/paths";
-import { buildWidgetActionablePayload } from "yt-dlp-transcript-common/views/widgetActionable";
-import {
- actionableDigestReachableCount,
- actionableUndownloadedCount,
- actionableUntranscribedCount,
- loadActionableSummary,
-} from "../../../lib/actionable/loadActionable";
-
-export const dynamic = "force-dynamic";
-
-// Backs the monitor widget's optional "Needs work" strip. The payload is
-// `common/views/widgetActionable.ts`; this file is the census read and the map
-// through the count helpers, which know the availability exclusions and stay
-// beside the loader.
-//
-// THE PATH KEEPS ITS NAME. /actionable the page is gone; /api/widget/actionable
-// is a wire contract a pinned widget in someone's browser is polling right now,
-// and renaming it would break that for no gain.
-export async function GET() {
- const summary = await loadActionableSummary(getPaths());
- return NextResponse.json(
- buildWidgetActionablePayload(
- summary.rows.map((row) => ({
- slug: row.channel.slug,
- undownloaded: actionableUndownloadedCount(row),
- untranscribed: actionableUntranscribedCount(row),
- digestReachable: actionableDigestReachableCount(row),
- })),
- ),
- );
-}
diff --git a/editor/app/api/widget/cleanable/route.ts b/editor/app/api/widget/cleanable/route.ts
@@ -1,16 +0,0 @@
-import { NextResponse } from "next/server";
-import { getPaths } from "yt-dlp-transcript-common/lib/paths";
-import { buildCleanablePayload } from "yt-dlp-transcript-common/views/cleanable";
-import { cleanableChannels } from "../../../cleanup/lib/loadCleanup";
-
-export const dynamic = "force-dynamic";
-
-// Backs the monitor widget's cleanable-data indicator AND its "Needs cleaning"
-// channel list — the per-channel rows come from the same snapshot read, so
-// serving both costs no extra I/O and no second poll. Covers the primary "clean
-// audio" reclaim across channels not excluded from the cleanup total (honors the
-// per-channel excludeFromCleanup flag); the payload's `bytes` is their sum, and
-// it is `common/views/cleanable.ts` that sums them.
-export async function GET() {
- return NextResponse.json(buildCleanablePayload(await cleanableChannels(getPaths())));
-}
diff --git a/editor/app/api/widget/sync/route.ts b/editor/app/api/widget/sync/route.ts
@@ -1,16 +0,0 @@
-import { NextResponse } from "next/server";
-import { buildWidgetSyncPayload } from "yt-dlp-transcript-common/views/widgetSync";
-import { widgetSyncInputs } from "../../../widget/lib/syncInputs";
-
-export const dynamic = "force-dynamic";
-
-// The payload is `common/views/widgetSync.ts` and its reads are
-// `widget/lib/syncInputs.ts`. This file is the HTTP edge and nothing else — it
-// used to hold the builder AND the type, which is how `app/page.tsx` came to
-// import a page's data out of a route. Its last trace was a type re-export,
-// which the client components named until they were repointed at the view; the
-// only thing this file exports now is the endpoint.
-
-export async function GET() {
- return NextResponse.json(buildWidgetSyncPayload(await widgetSyncInputs()));
-}
diff --git a/editor/app/api/workers/route.ts b/editor/app/api/workers/route.ts
@@ -1,12 +0,0 @@
-import { NextResponse } from "next/server";
-import { buildWorkersPayload } from "../../workers/buildWorkers";
-
-export const dynamic = "force-dynamic";
-
-// Live worker status for the Workers page and the read-only monitor widget.
-// Polled ~1s by WorkersView/MonitorWidget. Joins the pool's per-worker
-// slot/state summary with the registry's in-flight transcribe tasks (which
-// carry workerId) so each card can show what it's currently running.
-export async function GET() {
- return NextResponse.json(buildWorkersPayload());
-}
diff --git a/editor/e2e/view-route.spec.ts b/editor/e2e/view-route.spec.ts
@@ -0,0 +1,110 @@
+import { test, expect } from "@playwright/test";
+import { resetData } from "./helpers";
+
+// ONE POLLING ROUTE, AND THE OLD PATHS THAT STILL ANSWER.
+//
+// Eight route files became `/api/view/[name]` plus a handler table
+// (app/api/view/views.ts). The eight paths the clients used to call are
+// REWRITES in next.config.ts — server-internal, so a pinned widget and the
+// dashboard keep polling exactly what they always polled.
+//
+// The ~30 assertions the rest of the suite makes at the old paths are that
+// remap's real regression test; nothing there was edited. What this spec adds
+// is the part those cannot see: that each old path and its new twin return the
+// SAME BODY, that an unknown view name 404s instead of 500ing, that none of
+// this wants a credential, and that the query string survives the rewrite —
+// which is the whole of `/api/pulse?rev=`.
+
+// The two fields that carry the clock. Everything else in these payloads is
+// read from disk or from in-memory state that does not move in an idle fixture,
+// so it is compared verbatim.
+const VOLATILE: Record<string, string[]> = {
+ // `builtAt` is Date.now() at build time.
+ "/api/jobs/active": ["builtAt"],
+ // `now` is stamped so the console can age its rows client-side.
+ "/api/scheduler/status": ["now"],
+};
+
+const PAIRS: Array<[string, string]> = [
+ ["/api/pulse", "/api/view/pulse"],
+ ["/api/jobs/active", "/api/view/activeJobs"],
+ ["/api/workers", "/api/view/workers"],
+ ["/api/auto-queue/status", "/api/view/autoQueueStatus"],
+ ["/api/scheduler/status", "/api/view/schedulerStatus"],
+ ["/api/widget/sync", "/api/view/widgetSync"],
+ ["/api/widget/actionable", "/api/view/widgetActionable"],
+ ["/api/widget/cleanable", "/api/view/cleanable"],
+];
+
+function strip(body: unknown, keys: string[]): unknown {
+ if (!body || typeof body !== "object") return body;
+ const copy = { ...(body as Record<string, unknown>) };
+ for (const key of keys) delete copy[key];
+ return copy;
+}
+
+test.describe("/api/view/[name]", () => {
+ test.beforeEach(async () => {
+ await resetData("channel-with-counts");
+ });
+
+ for (const [oldPath, viewPath] of PAIRS) {
+ test(`${oldPath} and ${viewPath} are the same endpoint`, async ({
+ request,
+ }) => {
+ const before = await request.get(oldPath);
+ const after = await request.get(viewPath);
+ expect(before.status(), `${oldPath} status`).toBe(200);
+ expect(after.status(), `${viewPath} status`).toBe(200);
+
+ const keys = VOLATILE[oldPath] ?? [];
+ expect(strip(await after.json(), keys)).toEqual(
+ strip(await before.json(), keys),
+ );
+ });
+ }
+
+ test("an unknown view name is 404, not 500", async ({ request }) => {
+ for (const name of ["nope", "Pulse", "activejobs", "presets"]) {
+ const res = await request.get(`/api/view/${name}`);
+ expect(res.status(), `/api/view/${name}`).toBe(404);
+ }
+ });
+
+ // The dispatcher has no guard by design (these are read-only polls), but the
+ // harness routes DO — and they must not be reachable through it.
+ test("a test-harness name is not a view", async ({ request }) => {
+ const res = await request.get("/api/view/invalidate-cache");
+ expect(res.status()).toBe(404);
+ });
+
+ // /api/widget/presets is a menu fetch on open, not a poll: it is not a view,
+ // it keeps its own route, and nothing here shadows it.
+ test("/api/widget/presets is untouched", async ({ request }) => {
+ const res = await request.get("/api/widget/presets");
+ expect(res.status()).toBe(200);
+ const body = await res.json();
+ expect(Array.isArray(body.builtIn)).toBe(true);
+ expect(Array.isArray(body.saved)).toBe(true);
+ });
+
+ test("the rev query survives the rewrite", async ({ request }) => {
+ const seed = await (await request.get("/api/view/pulse")).json();
+ expect(seed.changed).toBe(true);
+ const rev = encodeURIComponent(seed.rev);
+
+ // Directly: the idle fast path answers `changed: false`.
+ const direct = await (await request.get(`/api/view/pulse?rev=${rev}`)).json();
+ expect(direct.rev).toBe(seed.rev);
+ expect(direct.changed).toBe(false);
+
+ // And through the rewrite — which is the assertion that matters, because a
+ // rewrite that dropped the query string would silently turn every idle
+ // poll into a full one (a disk read per client per 5 seconds) while
+ // returning a body that still looks correct.
+ const viaOldPath = await (await request.get(`/api/pulse?rev=${rev}`)).json();
+ expect(viaOldPath.rev).toBe(seed.rev);
+ expect(viaOldPath.changed).toBe(false);
+ expect(viaOldPath.cleanableBytes).toBe(-1);
+ });
+});
diff --git a/editor/next.config.ts b/editor/next.config.ts
@@ -86,9 +86,17 @@ const nextConfig: NextConfig = {
// TEMPORARY, not permanent: a 308 is cached by the browser forever, and this
// is a self-hosted admin surface where a wrong permanent redirect is a
// support call with no remedy but a profile wipe. `permanent: false` is a
- // 307, and query strings pass through to the destination. The API paths under
- // /api/auto-queue/* and /api/widget/actionable are NOT redirected — they
- // never moved.
+ // 307, and query strings pass through to the destination.
+ //
+ // NO API PATH IS EVER REDIRECTED, and since one-core phase 3 slice 2 that is
+ // a rule with teeth: the eight polled endpoints (/api/pulse,
+ // /api/jobs/active, /api/workers, /api/auto-queue/status,
+ // /api/scheduler/status and three of the four /api/widget/*) are served by
+ // ONE route now, /api/view/[name], and they reach it through the REWRITES
+ // below. A redirect would change the URL a poller sees, the status it checks
+ // and — on some clients — the method; a pinned monitor widget in someone's
+ // browser and the external cron tick are clients nobody can update. A rewrite
+ // is server-internal and changes none of that.
async redirects() {
return [
{ source: "/auto-queue", destination: "/operations", permanent: false },
@@ -106,11 +114,38 @@ const nextConfig: NextConfig = {
{ source: "/homepage", destination: "/sites", permanent: false },
];
},
- // Serve the built export artifacts (stats/summaries/transcripts) through a
- // route handler so the charts authoring tab can preview real data, mirroring
- // the static viewer's served paths.
+ // Two unrelated groups.
+ //
+ // 1. THE POLLED VIEWS. Eight endpoints, one route: /api/view/[name], whose
+ // names are `VIEW_NAMES` in common/views/names.ts. The old paths keep
+ // answering because a rewrite is a server-internal remap — method, status,
+ // body and QUERY STRING pass through, which is what /api/pulse?rev=<token>
+ // depends on, and the client never learns the path changed. That matters
+ // for /api/widget/*: a widget is a pinned link in someone's browser (see
+ // the "THE PATH KEEPS ITS NAME" note in common/views/widgetActionable.ts),
+ // and for /api/scheduler/status and /api/jobs/active, which pages and the
+ // dashboard poll. The e2e suite still asserts at the OLD paths on purpose:
+ // ~30 assertions nobody edited are this remap's regression test.
+ //
+ // An array-form rewrite is `afterFiles` — checked after real files and
+ // BEFORE dynamic routes — so /api/jobs/active cannot be swallowed by
+ // /api/jobs/[id]/log (a different depth anyway), and /api/widget/presets,
+ // which is a menu fetch rather than a poll and is NOT a view, keeps its own
+ // route file and is never matched here.
+ //
+ // 2. The built export artifacts (stats/summaries/transcripts), served through
+ // a route handler so the charts authoring tab can preview real data,
+ // mirroring the static viewer's served paths.
async rewrites() {
return [
+ { source: "/api/pulse", destination: "/api/view/pulse" },
+ { source: "/api/jobs/active", destination: "/api/view/activeJobs" },
+ { source: "/api/workers", destination: "/api/view/workers" },
+ { source: "/api/auto-queue/status", destination: "/api/view/autoQueueStatus" },
+ { source: "/api/scheduler/status", destination: "/api/view/schedulerStatus" },
+ { source: "/api/widget/sync", destination: "/api/view/widgetSync" },
+ { source: "/api/widget/actionable", destination: "/api/view/widgetActionable" },
+ { source: "/api/widget/cleanable", destination: "/api/view/cleanable" },
{ source: "/stats/:path*", destination: "/exported/stats/:path*" },
{ source: "/summaries/:path*", destination: "/exported/summaries/:path*" },
{ source: "/transcripts/:path*", destination: "/exported/transcripts/:path*" },