commit 688894977476c3b78f8a31837fc74cb026772873
parent f40684a54269b56b0881a9b29830e88e3adc3cb8
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Wed, 23 Sep 2026 19:35:45 -0400
plans: slice 2 record, the view-numbers tool, two comments that named deleted routes
`plans/tools/phase3-view-numbers.ts` dumps the three pure-loader view payloads
(widgetActionable, cleanable, widgetSync) as sorted-key JSON, offline and
read-only, with `now` frozen. Before (main) vs after (this branch) over the real
corpus: empty diff.
`plans/one-core-phase-3.md` gains "Slice 2, as shipped": shas, gate counts
(common 1638, editor unit 61, e2e subset 174/174), line counts, and every
deviation. `PipelineBand.tsx` and `widget/lib/syncInputs.ts` now say their old
path is rewritten to /api/view/….
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
4 files changed, 199 insertions(+), 2 deletions(-)
diff --git a/editor/app/components/dashboard/PipelineBand.tsx b/editor/app/components/dashboard/PipelineBand.tsx
@@ -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/jobs/active (rewritten to /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/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/plans/one-core-phase-3.md b/plans/one-core-phase-3.md
@@ -477,3 +477,81 @@ 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** for those two. The hook schedules
+ the next tick only after the previous fetch settles, so a slow response can no longer
+ stack requests. Cadences unchanged (3 s, 5 s).
+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. **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,118 @@
+#!/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 {
+ actionableDigestReachableCount,
+ actionableUndownloadedCount,
+ actionableUntranscribedCount,
+ loadActionableSummary,
+} 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 shell's map through the four count
+ // helpers, then the pure fold.
+ const summary = await loadActionableSummary(paths);
+ const widgetActionable: WidgetActionablePayload =
+ buildWidgetActionablePayload(
+ summary.rows.map((row) => ({
+ slug: row.channel.slug,
+ undownloaded: actionableUndownloadedCount(row),
+ untranscribed: actionableUntranscribedCount(row),
+ digestReachable: actionableDigestReachableCount(row),
+ })),
+ );
+
+ // /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();