Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit 8d0e46617961beba4f1303d90eec7cc021844ad0
parent eabbce526a21fa2b58c696c4f2f7e69bf24ea352
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Wed, 23 Sep 2026 20:03:34 -0400

merge: one-core/phase-3-s2 — one polling route: /api/view/[name], eight old paths as rewrites, one client poller

Phase 3 slice 2. Common gains common/views/{names,cleanable,widgetActionable}.ts;
the editor dispatches every polled view through api/view/[name] (force-dynamic,
unknown name 404, pulse keeps its observe-only contract); the eight old API
paths stay valid as next.config rewrites; usePolledPayload is the one client
poller (serial, with abort + timeout). Gates: tsc, common 1638, editor unit 63,
scripts 156, build ƒ /api/view/[name], e2e 174/174 then 225/225 with the
reviewer's 12 extra specs; view numbers diff empty over the real corpus.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
Mcommon/lib/operations.ts | 2+-
Acommon/views/cleanable.test.ts | 32++++++++++++++++++++++++++++++++
Acommon/views/cleanable.ts | 42++++++++++++++++++++++++++++++++++++++++++
Mcommon/views/inputs.ts | 3++-
Acommon/views/names.test.ts | 30++++++++++++++++++++++++++++++
Acommon/views/names.ts | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/views/widgetActionable.test.ts | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/views/widgetActionable.ts | 47+++++++++++++++++++++++++++++++++++++++++++++++
Meditor/CHANGELOG.md | 1+
Deditor/app/api/auto-queue/status/route.ts | 11-----------
Deditor/app/api/jobs/active/route.ts | 11-----------
Deditor/app/api/pulse/route.ts | 52----------------------------------------------------
Deditor/app/api/scheduler/status/route.ts | 11-----------
Aeditor/app/api/view/[name]/route.ts | 41+++++++++++++++++++++++++++++++++++++++++
Aeditor/app/api/view/pulseView.ts | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/api/view/views.test.ts | 84+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/api/view/views.ts | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Deditor/app/api/widget/actionable/route.ts | 54------------------------------------------------------
Deditor/app/api/widget/cleanable/route.ts | 23-----------------------
Deditor/app/api/widget/sync/route.ts | 16----------------
Deditor/app/api/workers/route.ts | 12------------
Meditor/app/components/dashboard/DashboardCockpit.tsx | 12++++++------
Meditor/app/components/dashboard/NeedsWorkPanel.tsx | 2+-
Meditor/app/components/dashboard/PipelineBand.tsx | 4++--
Meditor/app/components/lanes/LaneDeck.tsx | 2+-
Meditor/app/components/pulse.ts | 4++--
Meditor/app/jobs/components/JobsTable.tsx | 46++++++++++++++++++++++------------------------
Meditor/app/lib/actionable/loadActionable.ts | 19+++++++++++++++++++
Aeditor/app/lib/usePolledPayload.test.ts | 15+++++++++++++++
Aeditor/app/lib/usePolledPayload.ts | 119+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/operations/components/sync/SyncConsole.tsx | 33++++++++++++++++-----------------
Meditor/app/operations/components/useOperationsStatus.ts | 45+++++++++++++++++----------------------------
Meditor/app/page.tsx | 2+-
Meditor/app/widget/components/MonitorWidget.tsx | 16++++++++--------
Meditor/app/widget/components/WidgetControls.tsx | 2+-
Meditor/app/widget/lib/syncInputs.ts | 3++-
Deditor/app/widget/lib/usePolledPayload.ts | 64----------------------------------------------------------------
Meditor/app/workers/components/WorkersView.tsx | 2+-
Meditor/e2e/auto-refresh.spec.ts | 4+++-
Aeditor/e2e/view-route.spec.ts | 120+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/next.config.ts | 47+++++++++++++++++++++++++++++++++++++++++------
Mplans/one-core-phase-3.md | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aplans/tools/phase3-view-numbers.ts | 108+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
43 files changed, 1142 insertions(+), 356 deletions(-)

diff --git a/common/lib/operations.ts b/common/lib/operations.ts @@ -30,7 +30,7 @@ // corpus at the time of writing: 77,106 videos, 836 with media still on disk, 1 // diarized. A single "remaining" number would therefore read 77,105 — and 91x of // that is unreachable without re-downloading. The repo has already been burned by -// exactly this once: editor/app/api/widget/actionable/route.ts deliberately +// exactly this once: common/views/widgetActionable.ts deliberately // refuses to filter on the digest work count (historically the `noDigest` // bucket) because during the backfill that is 99.87% of the corpus and counting // it would put every channel in the list forever. So diff --git a/common/views/cleanable.test.ts b/common/views/cleanable.test.ts @@ -0,0 +1,32 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { buildCleanablePayload } from "./cleanable"; + +// The whole builder is a sum and a wrap, which is why it is here rather than in +// a route: the number in the widget's badge and the numbers in its list are now +// provably the same numbers. + +test("bytes is the sum of the rows, and the rows pass through in order", () => { + const payload = buildCleanablePayload([ + { slug: "big", count: 31, bytes: 3_000 }, + { slug: "small", count: 2, bytes: 500 }, + ]); + assert.equal(payload.bytes, 3_500); + assert.deepEqual( + payload.channels.map((c) => c.slug), + ["big", "small"], + ); + assert.equal(payload.channels[0].count, 31); +}); + +test("no rows is zero bytes and an empty list, not a missing field", () => { + assert.deepEqual(buildCleanablePayload([]), { bytes: 0, channels: [] }); +}); + +test("the input array is not mutated or aliased", () => { + const rows = [{ slug: "a", count: 1, bytes: 10 }]; + const payload = buildCleanablePayload(rows); + assert.notEqual(payload.channels, rows); + payload.channels.push({ slug: "b", count: 1, bytes: 1 }); + assert.equal(rows.length, 1); +}); diff --git a/common/views/cleanable.ts b/common/views/cleanable.ts @@ -0,0 +1,42 @@ +// The monitor widget's cleanable-data indicator AND its "Needs cleaning" +// channel list, as a pure fold. +// +// The per-channel rows come from one snapshot read (the editor's +// `cleanup/lib/loadCleanup.ts`, which already honours the per-channel +// `excludeFromCleanup` flag and drops empty rows), so serving both the total +// and the list costs no extra I/O and no second poll. `bytes` is their sum — +// and the sum lives HERE, beside the type it sums into, rather than in the +// route that used to hold both. +// +// ZERO IMPORTS, deliberately: the input row is re-declared structurally below +// instead of imported, because it is an editor type and a view may not reach +// into the app. The editor asserts the two stay assignable at compile time +// (`editor/app/api/view/views.test.ts`). + +// One channel's reclaimable-audio row: the structural twin of +// `CleanableChannelRow` in the editor's cleanup loader, and the wire type the +// widget's strip renders. +export type CleanableChannel = { + slug: string; + // Videos in the transcribed-with-audio bucket (the sweep's targets). + count: number; + // Reclaim estimate for the primary "clean audio" sweep, net of protection. + bytes: number; +}; + +export type CleanablePayload = { + bytes: number; + channels: CleanableChannel[]; +}; + +// The rows arrive already filtered and sorted by reclaim, descending — that +// ordering is the loader's, and this fold preserves it rather than re-deciding +// it, so the widget's list and the sidebar badge can never disagree about which +// channel is the biggest. +export function buildCleanablePayload( + rows: readonly CleanableChannel[], +): CleanablePayload { + const channels = [...rows]; + const bytes = channels.reduce((sum, c) => sum + c.bytes, 0); + return { bytes, channels }; +} diff --git a/common/views/inputs.ts b/common/views/inputs.ts @@ -7,7 +7,8 @@ // builder that constructs is a builder that cannot be called from a status // poll, cannot be unit-tested without a real pool, and quietly re-seeds state // that `/api/test/invalidate-cache` had just cleared between e2e specs. See -// `editor/app/api/pulse/route.ts` for the incident that rule is written from. +// `editor/app/api/view/pulseView.ts` (served at /api/view/pulse; /api/pulse is a +// rewrite onto it) for the incident that rule is written from. // // Injecting the readers makes the rule structural rather than remembered: a // view here CANNOT construct, because it never imports a getter. The editor 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/common/views/widgetActionable.test.ts b/common/views/widgetActionable.test.ts @@ -0,0 +1,67 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { buildWidgetActionablePayload } from "./widgetActionable"; + +const row = ( + slug: string, + undownloaded: number, + untranscribed: number, + digestReachable = 0, +) => ({ slug, undownloaded, untranscribed, digestReachable }); + +test("a channel with neither backlog is dropped", () => { + const payload = buildWidgetActionablePayload([ + row("idle", 0, 0, 9_000), + row("busy", 1, 0), + ]); + assert.deepEqual( + payload.channels.map((c) => c.slug), + ["busy"], + ); +}); + +test("digestReachable is carried but never makes a channel actionable", () => { + const payload = buildWidgetActionablePayload([row("digest-only", 0, 0, 77_000)]); + assert.deepEqual(payload.channels, []); +}); + +test("the larger combined backlog sorts first", () => { + const payload = buildWidgetActionablePayload([ + row("third", 1, 1), + row("first", 10, 30), + row("second", 20, 5), + ]); + assert.deepEqual( + payload.channels.map((c) => c.slug), + ["first", "second", "third"], + ); +}); + +test("either bucket alone is enough to be listed", () => { + const payload = buildWidgetActionablePayload([ + row("downloads-only", 3, 0), + row("transcripts-only", 0, 4), + ]); + assert.deepEqual( + payload.channels.map((c) => c.slug), + ["transcripts-only", "downloads-only"], + ); +}); + +test("a tie keeps the order the census handed over", () => { + const payload = buildWidgetActionablePayload([ + row("alpha", 2, 2), + row("beta", 3, 1), + ]); + assert.deepEqual( + payload.channels.map((c) => c.slug), + ["alpha", "beta"], + ); +}); + +test("every field survives the fold", () => { + const payload = buildWidgetActionablePayload([row("a", 2, 3, 400)]); + assert.deepEqual(payload.channels, [ + { slug: "a", undownloaded: 2, untranscribed: 3, digestReachable: 400 }, + ]); +}); diff --git a/common/views/widgetActionable.ts b/common/views/widgetActionable.ts @@ -0,0 +1,47 @@ +// The monitor widget's optional "Needs work" strip, as a pure fold. +// +// The same actionable census the operation pages and the dashboard draw, +// reduced to the two buckets a widget acts on — videos to download and videos +// to transcribe — for each channel that has any, sorted by the larger backlog +// first. The counting itself stays in the editor +// (`lib/actionable/loadActionable.ts`, whose four count helpers know the +// availability exclusions); what moved here is the shape and the two decisions +// about it: which channels appear, and in what order. +// +// 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 — which is why slice 2 serves it +// as a REWRITE onto /api/view/widgetActionable rather than a redirect. +// +// ZERO IMPORTS, deliberately. + +export type WidgetActionableChannel = { + slug: string; + undownloaded: number; + untranscribed: number; + // What the digest lane could act on today — the digest band's `reachable`, + // per channel. Reported but STILL NOT used to decide whether a channel + // "needs work", and the reasoning is unchanged: during the backfill this is + // ~99.87% of the corpus, so + // counting it would put every channel in the list forever and drown the two + // buckets a human can actually act on today. The registry's classification + // removes untranscribed and cues-stale videos from the number, which makes it + // smaller and more honest — nowhere near small enough to filter on. + digestReachable: number; +}; + +export type WidgetActionablePayload = { + channels: WidgetActionableChannel[]; +}; + +export function buildWidgetActionablePayload( + rows: readonly WidgetActionableChannel[], +): WidgetActionablePayload { + const channels = rows + .filter((c) => c.undownloaded > 0 || c.untranscribed > 0) + .sort( + (a, b) => + b.undownloaded + b.untranscribed - (a.undownloaded + a.untranscribed), + ); + return { channels }; +} diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] +- **Every live panel now polls one endpoint, `/api/view/<name>`, and the eight old addresses still answer.** The change token, the job head, workers, the operations board, the sync console and the widget's three strips were eight separate API routes that each did the same thing; they are one route serving eight named views (`pulse`, `activeJobs`, `workers`, `autoQueueStatus`, `schedulerStatus`, `widgetSync`, `widgetActionable`, `cleanable`), and the editor's own pages poll it there. `/api/pulse`, `/api/jobs/active`, `/api/workers`, `/api/auto-queue/status`, `/api/scheduler/status` and `/api/widget/{sync,actionable,cleanable}` are kept as **rewrites**, not redirects — same method, status, body and query string (`?rev=` included) — so a monitor widget pinned in a browser, or any script polling the old path, keeps working untouched. `/api/widget/presets` is unchanged. An unknown view name is a 404. **One behaviour change you might notice:** the operations board (every 3 s) and the sync console (every 5 s) now send their next poll only after the previous one answers, and abandon a poll that takes longer than 10–15 s — so a slow editor no longer piles requests up behind itself, and a hung request no longer stops the page updating. - **A lane's pause is one key on the lane, and the four old pause fields are gone from `settings.json`.** Holding a lane has been `autoQueue.<lane>.held` since the runner work landed; until now the file also still carried the four flags that used to mean it — `transcriptionsPaused`, `downloadsPaused`, `digest.digestsPaused` and the backwards `backfill.enabled` (where *enabled* meant *not held*) — which were read only when a lane had no `held` yet, to carry an older file's pause across. Every lane now carries its own key, so those four are **deleted**: nothing reads them, no form writes them, and the next settings save drops them from the file. A settings.json that still spells one of them holds nothing with it, so a hand-edited file (or a very old backup restored over a newer one) can no longer resurrect a pause you had lifted, or lift one you had set. "Run the backfill lane" on the diarization page and the Hold/Pause buttons write the one key, as they already did. **UPGRADING: boot once on the release that writes `held` before taking this one.** That release is the one that moved the gate onto the lane and carried the old fields across on read; a single boot of it (any settings save, or just starting the editor and pausing/resuming anything) puts `autoQueue.<lane>.held` in your settings.json, after which **nothing you can see changes here** — the same buttons, the same labels, the same pauses. An install that jumps straight from an older release to this one has no `held` keys at all and **loses its pauses**: transcription, downloads and digests come up running, and the backfill lane comes up held. Re-set them from the dashboard, or add the keys by hand before starting. - **A relocate job says how far it has got.** `rsync` has been printing its progress the whole time (`--info=progress2`) and every frame of it went into the job log as a carriage-return redraw of one line — so a 131 GB move and a 3 MB one looked identical from `/jobs`: a spinner. Now each frame is parsed into the **task bar** every other long job on that page already draws, reading `12.3 GB of 45.6 GB · 27 % · 110.50MB/s · ETA 5:32`, and the log gets **one line per 10 %** instead of several thousand frames of one. The percentage is against the tree the job already measured for its space check, not rsync's own — under incremental recursion that one is a percentage of what it has enumerated so far and walks backwards. - **`/channels` is where storage is managed now.** Two new columns: **Location** (which volume this channel's media is on — *Internal* when it has not moved) and **Size** (every byte under its `data/`, from its last report, sortable biggest-first). Free space is *not* a column, because it is a fact about a disk and not about a channel: there is one read-out per **volume** in a new bar above the rack, and each chip is also a **filter** — `?location=platter` lists exactly the channels on that drive, and `/storage` links straight here with the biggest first. Beside them, **Free up N GB**: type a number, press *Select largest*, and the largest channels still on the internal disk are ticked until the target is met, ready for the Move button that was already there. Channels already on another volume are never picked (moving one frees nothing on the disk you are emptying) and channels whose report carries no size are **skipped and counted** rather than ranked as empty — which would have put the biggest thing on the disk at the bottom of the list. 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,84 @@ +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 same ban covers the DISPATCHER (`[name]/route.ts`). The tempting +// optimisation is one shared constructor hoisted above the table "so every +// view gets its inputs for free" — which puts pulse on the constructing path +// again. Each handler in views.ts builds its own inputs; the dispatcher builds +// none, and this makes that a failing test rather than a comment. +// +// 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`, + ); + } +}); + +test("the dispatcher constructs nothing before it picks a view", () => { + const src = readFileSync(path.join(HERE, "[name]", "route.ts"), "utf8"); + // Guard the guard: this is the file that dispatches. + assert.ok(src.includes("VIEWS[name]("), "[name]/route.ts must dispatch"); + for (const banned of CONSTRUCTORS) { + assert.ok( + !src.includes(banned), + `[name]/route.ts names ${banned} — the dispatcher 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,78 @@ +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 { + loadActionableSummary, + widgetActionableRows, +} 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 per-channel + // counting (`widgetActionableRows`, which knows the availability exclusions) + // are the editor's; the filter and the sort are the view's. + widgetActionable: async () => { + const summary = await loadActionableSummary(getPaths()); + return NextResponse.json( + buildWidgetActionablePayload(widgetActionableRows(summary.rows)), + ); + }, + + // 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,54 +0,0 @@ -import { NextResponse } from "next/server"; -import { getPaths } from "yt-dlp-transcript-common/lib/paths"; -import { - actionableDigestReachableCount, - actionableUndownloadedCount, - actionableUntranscribedCount, - loadActionableSummary, -} from "../../../lib/actionable/loadActionable"; - -export const dynamic = "force-dynamic"; - -export type WidgetActionableChannel = { - slug: string; - undownloaded: number; - untranscribed: number; - // What the digest lane could act on today — the digest band's `reachable`, - // per channel. Reported but STILL NOT used to decide whether a channel - // "needs work", and the reasoning is unchanged: during the backfill this is - // ~99.87% of the corpus, so - // counting it would put every channel in the list forever and drown the two - // buckets a human can actually act on today. The registry's classification - // removes untranscribed and cues-stale videos from the number, which makes it - // smaller and more honest — nowhere near small enough to filter on. - digestReachable: number; -}; - -export type WidgetActionablePayload = { - channels: WidgetActionableChannel[]; -}; - -// Backs the monitor widget's optional "Needs work" strip. Reuses the same -// actionable census the operation pages and the dashboard draw, reduced to the -// two buckets a widget acts on — videos to download and videos to transcribe — -// for each channel that has any, sorted by the larger backlog first. -// -// 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()); - const channels = summary.rows - .map((row) => ({ - slug: row.channel.slug, - undownloaded: actionableUndownloadedCount(row), - untranscribed: actionableUntranscribedCount(row), - digestReachable: actionableDigestReachableCount(row), - })) - .filter((c) => c.undownloaded > 0 || c.untranscribed > 0) - .sort( - (a, b) => - b.undownloaded + b.untranscribed - (a.undownloaded + a.untranscribed), - ); - return NextResponse.json({ channels } satisfies WidgetActionablePayload); -} diff --git a/editor/app/api/widget/cleanable/route.ts b/editor/app/api/widget/cleanable/route.ts @@ -1,23 +0,0 @@ -import { NextResponse } from "next/server"; -import { getPaths } from "yt-dlp-transcript-common/lib/paths"; -import { cleanableChannels } from "../../../cleanup/lib/loadCleanup"; - -export const dynamic = "force-dynamic"; - -export type CleanableChannel = { slug: string; count: number; bytes: number }; - -export type CleanablePayload = { - bytes: number; - channels: CleanableChannel[]; -}; - -// 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); `bytes` is their sum. -export async function GET() { - const channels = await cleanableChannels(getPaths()); - const bytes = channels.reduce((sum, c) => sum + c.bytes, 0); - return NextResponse.json({ bytes, channels } satisfies CleanablePayload); -} 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/app/components/dashboard/DashboardCockpit.tsx b/editor/app/components/dashboard/DashboardCockpit.tsx @@ -2,9 +2,9 @@ import type { ActiveJobsPayload } from "yt-dlp-transcript-common/views/activeJobs"; import type { WorkersPayload } from "yt-dlp-transcript-common/views/workers"; -import type { WidgetActionablePayload } from "../../api/widget/actionable/route"; +import type { WidgetActionablePayload } from "yt-dlp-transcript-common/views/widgetActionable"; import type { WidgetSyncPayload } from "yt-dlp-transcript-common/views/widgetSync"; -import { usePolledPayload, useNow } from "../../widget/lib/usePolledPayload"; +import { usePolledPayload, useNow } from "../../lib/usePolledPayload"; import { PipelineBand } from "./PipelineBand"; import { NeedsWorkPanel } from "./NeedsWorkPanel"; import { QuickAddPanel } from "./QuickAddPanel"; @@ -37,26 +37,26 @@ export function DashboardCockpit({ const now = useNow(); const { data: jobs } = usePolledPayload<ActiveJobsPayload>( - "/api/jobs/active", + "/api/view/activeJobs", true, FAST_MS, initial.jobs, ); const { data: workers, refetch: refetchWorkers } = usePolledPayload<WorkersPayload>( - "/api/workers", + "/api/view/workers", true, FAST_MS, initial.workers, ); const { data: actionable } = usePolledPayload<WidgetActionablePayload>( - "/api/widget/actionable", + "/api/view/widgetActionable", true, SLOW_MS, initial.actionable, ); const { data: sync, refetch: refetchSync } = usePolledPayload<WidgetSyncPayload>( - "/api/widget/sync", + "/api/view/widgetSync", true, SLOW_MS, initial.sync, diff --git a/editor/app/components/dashboard/NeedsWorkPanel.tsx b/editor/app/components/dashboard/NeedsWorkPanel.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import type { WidgetActionablePayload } from "../../api/widget/actionable/route"; +import type { WidgetActionablePayload } from "yt-dlp-transcript-common/views/widgetActionable"; import { InlineActionButton } from "../actions/InlineActionButton"; const LIMIT = 10; diff --git a/editor/app/components/dashboard/PipelineBand.tsx b/editor/app/components/dashboard/PipelineBand.tsx @@ -5,7 +5,7 @@ import { useState } from "react"; import type { ActiveJobsPayload } from "yt-dlp-transcript-common/views/activeJobs"; import type { WorkersPayload } from "yt-dlp-transcript-common/views/workers"; import type { WidgetSyncPayload } from "yt-dlp-transcript-common/views/widgetSync"; -import type { WidgetActionablePayload } from "../../api/widget/actionable/route"; +import type { WidgetActionablePayload } from "yt-dlp-transcript-common/views/widgetActionable"; import { JobsTable } from "../../jobs/components/JobsTable"; import { LaneDeck } from "../lanes/LaneDeck"; import { syncAllChannelsAction, type SyncAllResult } from "../../channels/actions"; @@ -137,7 +137,7 @@ export function PipelineBand({ {jobList.length > 0 && jobs ? ( // The same one list /jobs draws, with no tail: the band is the head. - // The cockpit above already polls /api/jobs/active + // The cockpit above already polls /api/view/activeJobs // (DashboardCockpit.tsx) and this component polls it again while // anything is live — the double poll predates this change and is out of // scope here. diff --git a/editor/app/components/lanes/LaneDeck.tsx b/editor/app/components/lanes/LaneDeck.tsx @@ -2,7 +2,7 @@ import type { ReactNode } from "react"; import type { WidgetSyncPayload } from "yt-dlp-transcript-common/views/widgetSync"; -import type { WidgetActionablePayload } from "../../api/widget/actionable/route"; +import type { WidgetActionablePayload } from "yt-dlp-transcript-common/views/widgetActionable"; import type { ActiveJobsPayload } from "yt-dlp-transcript-common/views/activeJobs"; import type { WorkersPayload } from "yt-dlp-transcript-common/views/workers"; import { formatBytes } from "yt-dlp-transcript-common/lib/format"; diff --git a/editor/app/components/pulse.ts b/editor/app/components/pulse.ts @@ -57,8 +57,8 @@ async function poll(): Promise<void> { inFlight = true; try { const url = state.rev - ? `/api/pulse?rev=${encodeURIComponent(state.rev)}` - : "/api/pulse"; + ? `/api/view/pulse?rev=${encodeURIComponent(state.rev)}` + : "/api/view/pulse"; const res = await fetch(url, { cache: "no-store" }); if (!res.ok) return; const data = (await res.json()) as PulsePayload; diff --git a/editor/app/jobs/components/JobsTable.tsx b/editor/app/jobs/components/JobsTable.tsx @@ -3,6 +3,7 @@ import { useEffect, useMemo, useState } from "react"; import Link from "next/link"; import type { ActiveJobsPayload } from "yt-dlp-transcript-common/views/activeJobs"; +import { usePolledPayload } from "../../lib/usePolledPayload"; import type { JobRowView } from "yt-dlp-transcript-common/views/jobRowView"; import { isLive, mergeJobRows } from "yt-dlp-transcript-common/views/jobRows"; import { CancelJobButton } from "./CancelJobButton"; @@ -121,12 +122,28 @@ export function JobsTable({ }) { // null until mount → render everything (matches server HTML). const [filters, setFilters] = useState<JobsFilterState | null>(null); - const [polled, setPolled] = useState<ActiveJobsPayload | null>(null); useEffect(() => { setFilters(loadJobsFilters()); }, []); + // Whether the head poll runs. It is `anyLive` below, carried in state + // because the poll's result feeds the rows `anyLive` is derived from — the + // hook has to be called before the rows exist. Seeded from the props, which + // is exactly what `anyLive` is on the first render (no poll has landed). + const [polling, setPolling] = useState( + () => + initial.jobs.some(isLive) || + initial.recent.some(isLive) || + history.some(isLive), + ); + const { data: polled } = usePolledPayload<ActiveJobsPayload>( + "/api/view/activeJobs", + polling, + POLL_MS, + null, + ); + // FRESHEST SNAPSHOT WINS. A /jobs render served from the router cache // (staleTimes.dynamic) can be OLDER than the client's last poll, so naively // adopting a new `initial` prop would show a finished job as running again. @@ -146,29 +163,10 @@ export function JobsTable({ // the last poll's `recent` rows are what keeps a job that just finished on // screen until the paged tail catches up. const anyLive = head.some(isLive) || history.some(isLive); - - useEffect(() => { - if (!anyLive) return; - let cancelled = false; - let timer: ReturnType<typeof setTimeout> | null = null; - async function tick() { - try { - const res = await fetch("/api/jobs/active", { cache: "no-store" }); - if (res.ok && !cancelled) { - setPolled((await res.json()) as ActiveJobsPayload); - } - } catch { - // transient — keep polling - } finally { - if (!cancelled) timer = setTimeout(tick, POLL_MS); - } - } - void tick(); - return () => { - cancelled = true; - if (timer) clearTimeout(timer); - }; - }, [anyLive]); + // Adjust-state-during-render (React's documented pattern for state derived + // from the previous render): React re-renders before committing, so the poll + // starts and stops on the same render the old `useEffect(…, [anyLive])` did. + if (polling !== anyLive) setPolling(anyLive); // Channel-less jobs by kind, so each lane's line can carry its own runner's // log link and controls. diff --git a/editor/app/lib/actionable/loadActionable.ts b/editor/app/lib/actionable/loadActionable.ts @@ -1,6 +1,7 @@ import type { Paths } from "yt-dlp-transcript-common/lib/paths"; import { cache } from "react"; import type { ChannelBrief } from "yt-dlp-transcript-common/controller/channels"; +import type { WidgetActionableChannel } from "yt-dlp-transcript-common/views/widgetActionable"; import { getChannelBriefs } from "../requestCache"; import { digestWorkOf, @@ -151,6 +152,24 @@ export function actionableCleanExtraFormatsBytes(row: ActionableRow): number { return row.snapshot?.cleanupBytes?.multipleAudioFormats ?? 0; } +// The census rows as the widget's "Needs work" strip counts them: one row per +// channel, through the three count helpers above. The filter and the sort are +// `buildWidgetActionablePayload`'s (common/views/widgetActionable.ts); this is +// the half that knows the availability exclusions, so it stays beside them. +// Called by the /api/view/widgetActionable handler AND by +// plans/tools/phase3-view-numbers.ts, so the numbers tool measures the mapping +// the endpoint actually serves rather than a copy of it. +export function widgetActionableRows( + rows: readonly ActionableRow[], +): WidgetActionableChannel[] { + return rows.map((row) => ({ + slug: row.channel.slug, + undownloaded: actionableUndownloadedCount(row), + untranscribed: actionableUntranscribedCount(row), + digestReachable: actionableDigestReachableCount(row), + })); +} + export async function loadActionableSummary( paths: Paths, ): Promise<ActionableSummary> { diff --git a/editor/app/lib/usePolledPayload.test.ts b/editor/app/lib/usePolledPayload.test.ts @@ -0,0 +1,15 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { pollTimeoutMs } from "./usePolledPayload"; + +// The hook itself has no harness here (no DOM, no React renderer in the editor +// unit suite); its hang recovery is the timeout below plus the rule that a +// timed-out tick is a failed tick and schedules the next one. This pins the +// number, so "serial polling can stall forever" cannot come back as a 0 or an +// accidental per-interval timeout that misses every cold compile. +test("a poll is abandoned after three intervals, never sooner than 10 s", () => { + assert.equal(pollTimeoutMs(1_000), 10_000); + assert.equal(pollTimeoutMs(3_000), 10_000); + assert.equal(pollTimeoutMs(5_000), 15_000); + assert.equal(pollTimeoutMs(15_000), 45_000); +}); diff --git a/editor/app/lib/usePolledPayload.ts b/editor/app/lib/usePolledPayload.ts @@ -0,0 +1,119 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; + +// Shared client polling primitives. Extracted from the monitor widget so the +// dashboard cockpit could reuse the ~1s poll it proved out, and moved up to +// lib/ in one-core phase 3 slice 2 when it became THE client poller: the +// widget, the dashboard, /jobs, the operations board and the sync console all +// poll `/api/view/<name>` through this one hook, each at its own cadence (the +// cadence is a call-site argument on purpose — there is no batch endpoint). + +// Live wall-clock that re-renders once a second; null until mounted so SSR and +// the first client render agree (no Date.now() hydration mismatch). +export function useNow(): number | null { + const [now, setNow] = useState<number | null>(null); + useEffect(() => { + setNow(Date.now()); + const id = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(id); + }, []); + return now; +} + +// Generic poller: fetches `url` every `pollMs` while enabled, swallowing +// transient errors. Disabled (enabled=false) stops the timer and KEEPS the last +// value — it never nulls `data` — which /jobs relies on: its last poll's +// `recent` rows hold a just-finished job on screen after polling stops. +// Returns the latest payload plus a `refetch` so a control action can refresh +// it immediately instead of waiting for the next poll tick. +// +// The next tick is scheduled only after the previous fetch settles, so a slow +// response can never stack requests behind it. +// +// `immediate: false` skips the fetch on (re)subscribe and waits one interval +// first — for surfaces whose SSR seed is by construction fresh, where the +// first poll would re-fetch what the server just rendered. +export type PollOptions = { immediate?: boolean }; + +// How long one poll may take before it is abandoned and the next is scheduled: +// three intervals, never under 10 s (a cold dev-server compile of a route can +// legitimately take several seconds, and a timeout that fires on that would +// turn the first poll of every page into a guaranteed miss). +export function pollTimeoutMs(pollMs: number): number { + return Math.max(10_000, pollMs * 3); +} + +export function usePolledPayload<T>( + url: string, + enabled: boolean, + pollMs: number, + initial: T | null, + { immediate = true }: PollOptions = {}, +): { data: T | null; refetch: () => Promise<void> } { + const [data, setData] = useState<T | null>(initial); + // A `refetch` awaited by a control action can settle after the surface + // unmounted (the operations board and the sync console both guarded this by + // hand before they folded onto the hook). + const mounted = useRef(true); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + const refetch = useCallback(async () => { + try { + const res = await fetch(url, { + cache: "no-store", + signal: AbortSignal.timeout(pollTimeoutMs(pollMs)), + }); + if (!res.ok) return; + const next = (await res.json()) as T; + if (mounted.current) setData(next); + } catch { + // transient — ignore + } + }, [url, pollMs]); + useEffect(() => { + if (!enabled) return; + let cancelled = false; + let timer: ReturnType<typeof setTimeout> | null = null; + // One controller for this subscription: the cleanup aborts whatever tick + // is in flight, so an unmount or a disable never leaves a request running. + const ctrl = new AbortController(); + async function tick() { + try { + // THE TIMEOUT IS WHAT MAKES SERIAL POLLING SAFE. The next tick is only + // scheduled once this one settles, so a fetch that never settled would + // stop the poll for good — the old setInterval loops recovered from a + // hang by firing again regardless. A tick that times out is a failed + // tick like any other: it lands in `catch` and the next is scheduled. + const res = await fetch(url, { + cache: "no-store", + signal: AbortSignal.any([ + ctrl.signal, + AbortSignal.timeout(pollTimeoutMs(pollMs)), + ]), + }); + if (res.ok && !cancelled) setData((await res.json()) as T); + } catch { + // transient, aborted or timed out — keep polling + } finally { + if (!cancelled) timer = setTimeout(tick, pollMs); + } + } + // By default fetch immediately on (re)subscribe, then poll on the + // interval. Sections seeded from the server get a harmless refresh; those + // with a null initial render on the first tick instead of after a full + // interval. + if (immediate) void tick(); + else timer = setTimeout(tick, pollMs); + return () => { + cancelled = true; + ctrl.abort(); + if (timer) clearTimeout(timer); + }; + }, [url, enabled, pollMs, immediate]); + return { data, refetch }; +} diff --git a/editor/app/operations/components/sync/SyncConsole.tsx b/editor/app/operations/components/sync/SyncConsole.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import type { SchedulerStatusPayload } from "yt-dlp-transcript-common/views/schedulerStatus"; +import { usePolledPayload } from "../../../lib/usePolledPayload"; import { BulkCadenceBar } from "./BulkCadenceBar"; import { ChannelCadenceEditor } from "./ChannelCadenceEditor"; @@ -11,7 +12,7 @@ import { ChannelCadenceEditor } from "./ChannelCadenceEditor"; // OperationDetail renders it where a runner or a sweep lane would otherwise be. // // TWO POLLS ON THIS PAGE, deliberately not merged: this one is 5s over -// /api/scheduler/status (the schedule, whose facts move on the order of +// /api/view/schedulerStatus (the schedule, whose facts move on the order of // minutes) and the rail above is 3s over the auto-queue payload (lane state, // which moves per dispatch). One combined endpoint would make the cheaper // reader pay the more expensive reader's cadence, and buildSchedulerStatusPayload @@ -21,33 +22,31 @@ export function SyncConsole({ }: { initial: SchedulerStatusPayload; }) { - const [data, setData] = useState<SchedulerStatusPayload>(initial); const [busy, setBusy] = useState(false); const [message, setMessage] = useState<string | null>(null); // Slugs ticked for a bulk cadence edit. Kept as a Set of slugs (not indices) // so the 5s poll reordering or dropping a row can't retarget a selection. const [selected, setSelected] = useState<ReadonlySet<string>>(new Set()); + // Seeded by SSR; the first poll waits one interval, as before. + const { data: polled, refetch: refresh } = + usePolledPayload<SchedulerStatusPayload>( + "/api/view/schedulerStatus", + true, + 5000, + initial, + { immediate: false }, + ); + // Never null: the hook starts from `initial` and never clears it. + const data = polled ?? initial; + // Guards the Run-now button's own state (busy/message) after unmount; the + // payload's guard is the hook's. const mounted = useRef(true); - - const refresh = useCallback(async () => { - try { - const res = await fetch("/api/scheduler/status", { cache: "no-store" }); - if (!res.ok) return; - const next = (await res.json()) as SchedulerStatusPayload; - if (mounted.current) setData(next); - } catch { - /* transient; the next poll retries */ - } - }, []); - useEffect(() => { mounted.current = true; - const id = setInterval(refresh, 5000); return () => { mounted.current = false; - clearInterval(id); }; - }, [refresh]); + }, []); const runNow = useCallback(async () => { setBusy(true); diff --git a/editor/app/operations/components/useOperationsStatus.ts b/editor/app/operations/components/useOperationsStatus.ts @@ -1,7 +1,8 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import type { AutoQueueStatusPayload } from "yt-dlp-transcript-common/views/autoQueueStatus"; +import { usePolledPayload } from "../../lib/usePolledPayload"; // THE ONE POLL. Every operations surface — the board and each operation page — // reads the same payload from the same endpoint on the same 3-second cadence, @@ -11,37 +12,25 @@ import type { AutoQueueStatusPayload } from "yt-dlp-transcript-common/views/auto // are read TOGETHER, and two polls would let the rail and the lane below it // disagree about the same moment. // -// The endpoint keeps its /api/auto-queue/* path. It is addressed directly by -// three specs and by nothing user-facing, so renaming it would be churn with a -// test bill and no reader. +// It polls /api/view/autoQueueStatus. The old /api/auto-queue/status path is a +// rewrite onto the same handler (next.config.ts) — three specs still address +// it there, and that is the rewrite's regression test. export function useOperationsStatus(initial: AutoQueueStatusPayload): { data: AutoQueueStatusPayload; refresh: () => Promise<void>; } { - const [data, setData] = useState<AutoQueueStatusPayload>(initial); - const mounted = useRef(true); - - const refresh = useCallback(async () => { - try { - const res = await fetch("/api/auto-queue/status", { cache: "no-store" }); - if (!res.ok) return; - const next = (await res.json()) as AutoQueueStatusPayload; - if (mounted.current) setData(next); - } catch { - /* transient; next poll retries */ - } - }, []); - - useEffect(() => { - mounted.current = true; - const id = setInterval(refresh, 3000); - return () => { - mounted.current = false; - clearInterval(id); - }; - }, [refresh]); - - return { data, refresh }; + // Seeded by SSR, so the first poll waits one interval (`immediate: false`), + // as the hand-rolled loop this replaced did. Always enabled: the board is + // live whenever it is open. + const { data, refetch } = usePolledPayload<AutoQueueStatusPayload>( + "/api/view/autoQueueStatus", + true, + 3000, + initial, + { immediate: false }, + ); + // Never null: the hook starts from `initial` and never clears it. + return { data: data ?? initial, refresh: refetch }; } // "React is live on this subtree" — the same signal `now !== null` gives the diff --git a/editor/app/page.tsx b/editor/app/page.tsx @@ -22,7 +22,7 @@ import { buildWidgetSyncPayload } from "yt-dlp-transcript-common/views/widgetSyn import { widgetSyncInputs } from "./widget/lib/syncInputs"; import { DashboardCockpit } from "./components/dashboard/DashboardCockpit"; import type { DashboardChannel } from "./components/dashboard/types"; -import type { WidgetActionablePayload } from "./api/widget/actionable/route"; +import type { WidgetActionablePayload } from "yt-dlp-transcript-common/views/widgetActionable"; import type { ActionableRow } from "./lib/actionable/loadActionable"; export const dynamic = "force-dynamic"; diff --git a/editor/app/widget/components/MonitorWidget.tsx b/editor/app/widget/components/MonitorWidget.tsx @@ -2,7 +2,7 @@ import { Fragment, useState, type CSSProperties, type ReactNode } from "react"; import { formatDuration, formatBytes } from "yt-dlp-transcript-common/lib/format"; -import { usePolledPayload, useNow } from "../lib/usePolledPayload"; +import { usePolledPayload, useNow } from "../../lib/usePolledPayload"; import { fmtTime } from "../lib/relativeTime"; import type { ActiveJobsPayload, @@ -16,11 +16,11 @@ import type { import { jobKindLabel } from "../../jobs/jobKindLabels"; import type { WorkersPayload, WorkerView } from "yt-dlp-transcript-common/views/workers"; import { InlineActionButton } from "../../components/actions/InlineActionButton"; -import type { WidgetActionablePayload } from "../../api/widget/actionable/route"; +import type { WidgetActionablePayload } from "yt-dlp-transcript-common/views/widgetActionable"; import type { CleanablePayload, CleanableChannel, -} from "../../api/widget/cleanable/route"; +} from "yt-dlp-transcript-common/views/cleanable"; import type { WidgetSyncPayload } from "yt-dlp-transcript-common/views/widgetSync"; import { buildWidgetQuery, @@ -69,14 +69,14 @@ export function MonitorWidget({ // no clue that it was the gate rather than the disk gate being off. Same // pre-existing shape as the sync payload's gate below. const { data: jobsPayload } = usePolledPayload<ActiveJobsPayload>( - "/api/jobs/active", + "/api/view/activeJobs", config.jobs || config.disk, pollMs, initialJobs, ); const { data: workersPayload, refetch: refetchWorkers } = usePolledPayload<WorkersPayload>( - "/api/workers", + "/api/view/workers", workersEnabled, pollMs, initialWorkers, @@ -84,7 +84,7 @@ export function MonitorWidget({ // One poll serves both cleanable sections: the total-bytes strip and the // per-channel "Needs cleaning" list come from the same payload. const { data: cleanablePayload } = usePolledPayload<CleanablePayload>( - "/api/widget/cleanable", + "/api/view/cleanable", config.cleanable || config.cleanChannels, pollMs, null, @@ -93,7 +93,7 @@ export function MonitorWidget({ // every channel snapshot each tick is heavier than the other endpoints — so // poll it no faster than every 15s regardless of the configured cadence. const { data: actionablePayload } = usePolledPayload<WidgetActionablePayload>( - "/api/widget/actionable", + "/api/view/widgetActionable", config.actionable, Math.max(pollMs, 15000), null, @@ -108,7 +108,7 @@ export function MonitorWidget({ // list because the backfill pause reads its lane state from here. const { data: syncData, refetch: refetchSync } = usePolledPayload<WidgetSyncPayload>( - "/api/widget/sync", + "/api/view/widgetSync", config.lastSync || config.scheduler || config.backfill || config.controls, Math.max(pollMs, 15000), null, diff --git a/editor/app/widget/components/WidgetControls.tsx b/editor/app/widget/components/WidgetControls.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import type { WidgetSyncPayload } from "yt-dlp-transcript-common/views/widgetSync"; -import type { WidgetActionablePayload } from "../../api/widget/actionable/route"; +import type { WidgetActionablePayload } from "yt-dlp-transcript-common/views/widgetActionable"; import type { ActiveJobsPayload } from "yt-dlp-transcript-common/views/activeJobs"; import type { WorkersPayload } from "yt-dlp-transcript-common/views/workers"; import { LaneDeck } from "../../components/lanes/LaneDeck"; diff --git a/editor/app/widget/lib/syncInputs.ts b/editor/app/widget/lib/syncInputs.ts @@ -10,7 +10,8 @@ import { getChannelBriefs } from "../../lib/requestCache"; // four values. They are gathered here rather than in the route because BOTH the // route and the dashboard's SSR seed need them, and a builder that lived in a // route file was the smell that made `app/page.tsx` import out of -// `api/widget/sync/route.ts` to render a page. +// `api/widget/sync/route.ts` to render a page. (That route is gone; /api/widget/sync +// is rewritten to /api/view/widgetSync, whose handler calls this.) // // `getChannelBriefs` is the per-request memo (lib/requestCache.ts): the // dashboard derives the channel list three ways in one render, and this is one diff --git a/editor/app/widget/lib/usePolledPayload.ts b/editor/app/widget/lib/usePolledPayload.ts @@ -1,64 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useState } from "react"; - -// Shared client polling primitives, extracted from the monitor widget so the -// dashboard cockpit (editor/app/components/dashboard) can reuse the exact same -// ~1s poll pattern the widget proved out against the /api endpoints. - -// Live wall-clock that re-renders once a second; null until mounted so SSR and -// the first client render agree (no Date.now() hydration mismatch). -export function useNow(): number | null { - const [now, setNow] = useState<number | null>(null); - useEffect(() => { - setNow(Date.now()); - const id = setInterval(() => setNow(Date.now()), 1000); - return () => clearInterval(id); - }, []); - return now; -} - -// Generic poller: fetches `url` every `pollMs` while enabled, swallowing -// transient errors. Disabled (enabled=false) leaves the initial value as-is. -// Returns the latest payload plus a `refetch` so a control action can refresh -// it immediately instead of waiting for the next poll tick. -export function usePolledPayload<T>( - url: string, - enabled: boolean, - pollMs: number, - initial: T | null, -): { data: T | null; refetch: () => Promise<void> } { - const [data, setData] = useState<T | null>(initial); - const refetch = useCallback(async () => { - try { - const res = await fetch(url, { cache: "no-store" }); - if (res.ok) setData((await res.json()) as T); - } catch { - // transient — ignore - } - }, [url]); - useEffect(() => { - if (!enabled) return; - let cancelled = false; - let timer: ReturnType<typeof setTimeout> | null = null; - async function tick() { - try { - const res = await fetch(url, { cache: "no-store" }); - if (res.ok && !cancelled) setData((await res.json()) as T); - } catch { - // transient — keep polling - } finally { - if (!cancelled) timer = setTimeout(tick, pollMs); - } - } - // Fetch immediately on (re)subscribe, then poll on the interval. Sections - // seeded from the server get a harmless refresh; those with a null initial - // render on the first tick instead of after a full interval. - void tick(); - return () => { - cancelled = true; - if (timer) clearTimeout(timer); - }; - }, [url, enabled, pollMs]); - return { data, refetch }; -} diff --git a/editor/app/workers/components/WorkersView.tsx b/editor/app/workers/components/WorkersView.tsx @@ -44,7 +44,7 @@ export function WorkersView({ initial }: { initial: WorkersPayload }) { const refetch = useCallback(async () => { try { - const res = await fetch("/api/workers", { cache: "no-store" }); + const res = await fetch("/api/view/workers", { cache: "no-store" }); if (res.ok) setPayload((await res.json()) as WorkersPayload); } catch { // transient — keep polling diff --git a/editor/e2e/auto-refresh.spec.ts b/editor/e2e/auto-refresh.spec.ts @@ -104,7 +104,9 @@ test.describe("auto-refresh behavior", () => { let pulses = 0; page.on("request", (req) => { const url = new URL(req.url()); - if (url.pathname === "/api/pulse") { + // The client polls /api/view/pulse; /api/pulse is a rewrite onto it + // for pollers we cannot update. This counts what the browser SENDS. + if (url.pathname === "/api/view/pulse") { pulses++; return; } diff --git a/editor/e2e/view-route.spec.ts b/editor/e2e/view-route.spec.ts @@ -0,0 +1,120 @@ +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 fields that move between two back-to-back calls. 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. Dotted keys reach one level down. +const VOLATILE: Record<string, string[]> = { + // `builtAt` is Date.now() at build time. `disk.freeBytes` is a live statfs: + // it is null only while the fixture's disk gate is off (minFreeDiskGB: 0). + "/api/jobs/active": ["builtAt", "disk.freeBytes"], + // `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) { + const [head, tail] = key.split("."); + if (tail === undefined) { + delete copy[head]; + } else if (copy[head] && typeof copy[head] === "object") { + const inner = { ...(copy[head] as Record<string, unknown>) }; + delete inner[tail]; + copy[head] = inner; + } + } + 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*" }, diff --git a/plans/one-core-phase-3.md b/plans/one-core-phase-3.md @@ -477,3 +477,103 @@ claimed fixed; the reads are in-memory). > `buildAutoQueueStatusPayload` (`editor/app/operations/status.ts`) reads configs and state > ONCE via `Promise.all` and hands the same pair to all four lanes. `getAutoRunnerStatus` is > still called per lane and is genuinely in-memory. + +## Slice 2, as shipped — one polling route (2026-09-23) + +Branch `one-core/phase-3-s2` off `54cf1b31`, four commits, not merged: + +| commit | what | +|---|---| +| `5397f83c` | `common/views/{cleanable,widgetActionable}.ts` + tests; the two widget routes call them; seven type importers repointed off `api/widget/*/route` | +| `074f09a2` | `common/views/names.ts` (`VIEW_NAMES`, `ViewName`, `VIEW_CONTRACT`) + test; `editor/app/api/view/{[name]/route.ts,views.ts,pulseView.ts,views.test.ts}`; eight route files deleted; eight `rewrites()`; `e2e/view-route.spec.ts` | +| `edf56370` | `usePolledPayload` `git mv` to `editor/app/lib/`; JobsTable, useOperationsStatus, SyncConsole folded onto it; every client poll URL is `/api/view/<name>` | +| (this commit) | `plans/tools/phase3-view-numbers.ts`, two stale comments, this record | + +**One route, eight rewrites, no redirect.** `/api/view/[name]` is `force-dynamic`, checks +the name against `VIEW_NAMES` before any handler runs (unknown ⇒ 404), and dispatches into +a total `Record<ViewName, handler>`, so a name with no handler is a tsc error. It has no +auth and no `EDITOR_TEST_ROUTES` guard, same as the eight routes it replaces. Each handler +constructs its own inputs exactly as its old route did; there is no shared constructor in +the dispatcher. Pulse is the one `observe` view; `views.test.ts` reads `pulseView.ts` as +text, requires `observeInputs(` and bans `liveInputs(`, `getRegistry(`, `getSettings(`, +`getWorkerPool(`. `/api/widget/presets` stays its own route. Heal/no-heal unchanged. + +Gates: + +| gate | result | +|---|---| +| `pnpm -r exec tsc --noEmit` | clean after every commit | +| common tests | **1638/1638** (was 1625; +13: cleanable 3, widgetActionable 6, names 4) | +| editor unit (`tsx --test "app/**/*.test.ts"`) | **61/61** (was 59; +2: pulse textual guard, cleanable assignability) | +| `pnpm run test:scripts` | 156 pass / 1 skip / 0 fail | +| `next build` (editor) | clean; `├ ƒ /api/view/[name]` (dynamic), `├ ƒ /api/widget/presets` kept | +| e2e subset, 20 specs (`pulse`, `auto-refresh`, `dashboard`, `dashboard-paths`, `widget`, `jobs`, `jobs-active-order`, `jobs-channel`, `workers`, `worker-remote`, `auto-queue`, `lane-runner`, `scheduler`, `ops-api`, `disk-space`, `perf-budget`, `backfill`, `channel-storage`, `channel-rename`, `view-route`) | **174 passed, 0 failed, exit 0**, 8.0 min, one run, nothing re-run | + +**Numbers.** `plans/tools/phase3-view-numbers.ts` (offline tsx, read-only loaders, no +server) dumps `widgetActionable`, `cleanable` and `widgetSync` as sorted-key JSON over the +real corpus (`TRANSCRIPTS_DIR` → the primary checkout's `transcripts/`), with `now` frozen +at 2026-01-01Z so `widgetSync.scheduler.{nextRunAt,overdue}` cannot drift. Before (at +main, route bodies copied into a temporary variant of the script, since the views did not +exist yet) vs after (the committed script, importing the views): **diff empty**, 4,574 +bytes each. + +Line counts: the eight deleted routes were 190 lines; their replacement is 185 (route 41, +`views.ts` 87, `pulseView.ts` 57) + a 66-line test. `common/views/` gains 144 non-test +lines (`names` 55, `cleanable` 42, `widgetActionable` 47) and 129 test lines. `editor/app` +over commits 1–3, `--no-renames`: +421 / −345. + +Deviations: + +1. **`e2e/auto-refresh.spec.ts`: one line changed.** It counts the BROWSER's own requests by + `pathname === "/api/pulse"`; the client now sends `/api/view/pulse`, so it would count 0. + It matches `/api/view/pulse` now. No assertion that calls an old path was edited — those + are the rewrite's regression test. +2. **The cleanable assignability check lives in the editor** (`api/view/views.test.ts`, + `[A] extends [B]`): `CleanableChannelRow` is an editor type a common test cannot import. + The view's row type keeps the wire name `CleanableChannel` (MonitorWidget imports it). +3. **The hook gained `{ immediate?: boolean }`** (default true) and an unmount guard on + `refetch`. useOperationsStatus and SyncConsole are SSR-seeded and never fetched on + mount; they pass `immediate: false`. Both carried the unmount guard by hand. +4. **Behaviour change: `setInterval` → serial polling + a timeout** for those two. The + trade-off, named: serial polling means a slow response can no longer stack requests + behind it, but on its own it also meant a fetch that NEVER settled stopped the poll for + good — the old `setInterval` loops recovered from a hang by firing again regardless. + The review fix gives every tick an `AbortController` (aborted in the effect cleanup, so + an unmount or disable never leaves a request in flight) combined via `AbortSignal.any` + with `AbortSignal.timeout(pollTimeoutMs(pollMs))`, where `pollTimeoutMs = max(10 s, + 3 × pollMs)`; an abort or timeout is a failed tick and still schedules the next one. + `refetch` gets the same timeout. Cadences unchanged (3 s, 5 s). The hook has no React + harness in the editor unit suite, so the unit test (`lib/usePolledPayload.test.ts`) pins + `pollTimeoutMs` only; the hang recovery itself is not unit-tested. +5. **JobsTable's `enabled` is `polling` state**, seeded from the props and adjusted during + render with `if (polling !== anyLive) setPolling(anyLive)` — React's documented + "adjust state when a prop changes" form. It is needed because the poll's result feeds + the rows `anyLive` derives from, so the hook must be called first. It cannot loop: + `setPolling` only toggles the hook's timer effect and does not change `polled` in the + same render, so the immediate re-render computes the same `anyLive`, the guard is false, + and it settles after one extra render; new `polled` data only arrives from a completed + fetch. "An idle page makes no requests", freshest-snapshot-wins, `mergeJobRows` and the + never-nulled `data` are unchanged. +6. **Two comments named deleted routes**: `PipelineBand.tsx:140` and the + `widget/lib/syncInputs.ts` header now say the old path is rewritten to `/api/view/…`. +7. **Review fixes (one commit after `4288009b`)**: the hook timeout above; the + `CONSTRUCTORS` text ban now also covers `api/view/[name]/route.ts`, so a hoisted + constructor in the dispatcher fails a test; `view-route.spec.ts` also strips + `disk.freeBytes` from the activeJobs comparison (a live statfs, equal only while the + fixture's disk gate is off); the widgetActionable row mapping is one exported + `widgetActionableRows` in `lib/actionable/loadActionable.ts`, called by both the view + handler and the numbers tool; three more comments naming deleted routes fixed + (`common/views/inputs.ts`, `common/lib/operations.ts`, `PipelineBand.tsx`); an + `editor/CHANGELOG.md` entry. After the mapping moved, the numbers were re-checked: + the tool against a fresh read no longer matched the morning's `before` file, because + the live corpus had moved (videos 78,238 → 78,254, new sync timestamps — the running + editor syncing, not this code). So the comparison was re-run at ONE instant: the + pre-slice variant (route bodies copied inline) and the committed tool back to back over + the same corpus — **diff empty**. +8. **Commit trailers** on commits 2–3 were rewritten with `filter-branch` after e2e exited + (they had picked up the wrong model line); the shas above are post-rewrite. Code + unchanged by the rewrite. + +Not done here, by design: no batch route, no auth on views, `operations/status.ts`, +`requestCache.ts` and `liveInputs.ts` untouched. The full editor suite was not run; only +the 20-spec subset above. diff --git a/plans/tools/phase3-view-numbers.ts b/plans/tools/phase3-view-numbers.ts @@ -0,0 +1,108 @@ +#!/usr/bin/env tsx +// The one-core Phase 3 slice 2 measurement: the three polled payloads whose +// inputs are PURE LOADERS, printed deterministically so two runs can be diffed. +// +// WHY THESE THREE. Slice 2 moves eight polling routes behind `/api/view/[name]` +// and lifts two route-resident builders into `common/views/`. The claim is "no +// number moved". Five of the eight payloads are built from live singletons (the +// job registry, the scheduler, the worker pool) and cannot be reproduced +// offline — their content depends on what the server is doing at that instant. +// The remaining three — `widgetActionable`, `cleanable` and `widgetSync` — are +// folds over loaders that read only the corpus on disk, so they CAN be computed +// in-process, offline, and diffed. +// +// STRICTLY READ-ONLY. It opens `settings.json`, the scheduler state file and the +// per-channel `snapshot.json`/`config.json` through the ordinary loaders, and +// writes nothing anywhere. +// +// NEVER BOOT AN EDITOR FOR THIS. The loaders are called in-process; +// instrumentation.ts is not loaded, so no runner, sweep or scheduler is armed +// against the live corpus. +// +// TIME IS FROZEN. `widgetSyncInputs()` stamps `now: Date.now()`, and two of the +// fields it feeds (`scheduler.nextRunAt`, `scheduler.overdue`) are relative to +// it, so a run an hour later would "move a number" that no code change touched. +// The script overrides `now` with the constant below; everything else about the +// inputs is what the route would see. +// +// THE TWO RUNS DELIBERATELY EXERCISE DIFFERENT CODE. Before the slice, the +// `widgetActionable` and `cleanable` folds lived inline in their route files +// (`api/widget/{actionable,cleanable}/route.ts`), which cannot be imported +// offline; this script carried a copy. After the slice they are +// `buildWidgetActionablePayload` / `buildCleanablePayload` in `common/views/`, +// which is what it imports now. That is the point: an empty diff says the moved +// builders agree with the route bodies they replaced, over the real corpus. +// +// Usage, from anywhere in the repo (TRANSCRIPTS_DIR only if the checkout has no +// `transcripts/` of its own, e.g. a worktree): +// TRANSCRIPTS_DIR=…/yt-dlp-transcript-browser/transcripts \ +// pnpm --filter yt-dlp-transcript-common exec tsx ../plans/tools/phase3-view-numbers.ts + +import { getPaths } from "../../common/lib/paths"; +import { + buildCleanablePayload, + type CleanablePayload, +} from "../../common/views/cleanable"; +import { + buildWidgetActionablePayload, + type WidgetActionablePayload, +} from "../../common/views/widgetActionable"; +import { + buildWidgetSyncPayload, + type WidgetSyncPayload, +} from "../../common/views/widgetSync"; +import { cleanableChannels } from "../../editor/app/cleanup/lib/loadCleanup"; +import { + loadActionableSummary, + widgetActionableRows, +} from "../../editor/app/lib/actionable/loadActionable"; +import { widgetSyncInputs } from "../../editor/app/widget/lib/syncInputs"; + +// 2026-01-01T00:00:00Z. Any fixed instant does; what matters is that both runs +// use the same one. Deliberately in the past, so "is some channel due now?" is +// answered the same way on every machine and every day. +const FROZEN_NOW = Date.UTC(2026, 0, 1); + +// Deterministic JSON: object keys sorted, arrays in their payload order (which +// IS the thing under test for both widget payloads — the sort is part of the +// contract the slice moves). +function sortedJson(value: unknown): string { + return JSON.stringify( + value, + (_key, v) => { + if (v === null || typeof v !== "object" || Array.isArray(v)) return v; + const out: Record<string, unknown> = {}; + for (const k of Object.keys(v as Record<string, unknown>).sort()) { + out[k] = (v as Record<string, unknown>)[k]; + } + return out; + }, + 2, + ); +} + +async function main() { + const paths = getPaths(); + + // /api/view/widgetActionable — the handler's own mapping, then the pure fold. + const summary = await loadActionableSummary(paths); + const widgetActionable: WidgetActionablePayload = + buildWidgetActionablePayload(widgetActionableRows(summary.rows)); + + // /api/view/cleanable — the lean per-channel snapshot read, then the sum. + const cleanable: CleanablePayload = buildCleanablePayload( + await cleanableChannels(paths), + ); + + // /api/view/widgetSync — the route's own inputs with the clock frozen. + const widgetSync: WidgetSyncPayload = buildWidgetSyncPayload({ + ...(await widgetSyncInputs()), + now: FROZEN_NOW, + }); + + process.stdout.write( + sortedJson({ widgetActionable, cleanable, widgetSync }) + "\n", + ); +} + +await main();