commit 68e97c10e0a913ae591ff0c703044cfa3c4f20a7
parent 97ca5360750e22c8028e87b3fe2043e8ef99f9ed
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sun, 30 Aug 2026 11:05:07 -0400
plans: slice 8c planned
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Diffstat:
1 file changed, 850 insertions(+), 0 deletions(-)
diff --git a/plans/editor-ia-slice-8c.md b/plans/editor-ia-slice-8c.md
@@ -0,0 +1,850 @@
+# Editor IA slice 8c — `/jobs` is one list: the live head is polled, the history tail is paged
+
+## Context
+
+**Verified read-only against `a609566` (clean) on 2026-08-30.** Slice 8a+8b shipped at
+`ecf80a5`; the twelve commits since (`de9efeb` → `a609566`, another session's export
+responsive redesign) touch only `export/`, `common/components/*`, `common/lib/useMediaQuery.ts`
+and `plans/export-responsive-redesign.md` — nothing under `editor/`, `common/jobs/*` or any
+`common/lib` file this plan anchors (each checked with `git diff --quiet ecf80a5..HEAD`). This
+is the last third of the IA doc's slice 8 (`plans/editor-operations-ia.md:168-175`). STATE.md
+#12 (`:286-291`) asked the question first — *what is a ROW* — and the answer is the whole
+design.
+
+**Decided (operator, 2026-08-30):** (A) **A row is one job** — a registry `JobRecord`, an
+archived `.log`+sidecar entry, or a scheduler slot whose record the registry evicted (a
+phantom) — one view type `JobRowView`, one server builder with one adapter per source, merged
+VIEWS never merged registries (umtool's `lib/activity.ts:6-17`). (B) **The page is one list:
+live head, history tail, one `<table>`, no mode switch.** The head (running, queued, finished
+within ~30 s, stuck slots) comes from the live payload and is polled at 1 s only while a row is
+non-terminal; the tail is `listAllJobs` with the existing `?limit=` paging and rides the global
+`AutoRefresh` pulse. (C) **The scheduler reconciliation and its auto-heal fold into
+`buildActiveJobsPayload`**, so every consumer of the live payload — the jobs page, the dashboard,
+the widget, `/api/jobs/active` — heals drift when it observes it. Also decided: one `<tr>` per
+job, always (every `/jobs` spec locates by `getByRole("row")`); `/jobs/active` and
+`/jobs/queue` 307 to `/jobs`; the nav `Active` entry goes; the five hand-rolled
+`JobRecord → RunningJobsListItem` maps go through the one builder; `RunningJobsList` stays as
+their renderer.
+OUT: replacing `RunningJobsList` on the channel/video/build/operation pages; the dashboard's
+pre-existing double poll; the widget's own `JobRow`; `STUCK_AGE_MS`; `listAllJobs` paging;
+retention; `/jobs/[id]`; `PulsePayload`'s wire shape.
+
+**What the census found — every place the IA doc's wording cannot be implemented literally,
+and what to do instead:**
+
+1. **"One page with a mode and one table" is three pages with shared chrome.** The three
+ sources have three shapes with almost no overlap: `JobListEntry`
+ (`common/jobs/listJobs.ts:12-29`: `logSize`, `inRegistry`, `replayable`, `endedAt`,
+ `status: JobStatus | "archived"`), `RunningJobsListItem`
+ (`editor/app/jobs/components/RunningJobsList.tsx:38-69`: `progress`, `tasks`, `draining`,
+ `drainable`, `background`, `canMoveUp/Down`, `status: "queued" | "running"`), `QueueSlotView`
+ (`editor/app/jobs/queue/buildQueueView.ts:19-31`: `ageMs`, `stuck`, `stuckReason`, `pid`,
+ `lastLogLine`, `status: string` incl. `"evicted"`). **Instead:** one `JobRowView` that is
+ the union — every `RunningJobsListItem` field NAME kept, `status` widened to
+ `JobStatus | "archived" | "evicted"`, the history and slot fields optional. Verified
+ readers of the live row: `MonitorWidget.tsx` reads `status`, `kind`, `background`,
+ `channelSlug`, `progress`, `tasks` (`:1047-1086`) and the `progress`/`tasks` element types
+ (`:1121,1139,1176`); `PipelineBand.tsx:37-39` reads `jobs.jobs[].status`; `LaneDeck.tsx:64`
+ reads `jobs.disk`; `WidgetControls.tsx:75` passes the payload through. None narrows on
+ `status`, so the widening compiles; the only touch outside logic is the widget's one import
+ line (`:11`) and five type-name mentions.
+2. **The type dependency is backwards.** The server builder imports its row type from a
+ `"use client"` component (`buildActiveJobs.ts:18` ← `RunningJobsList.tsx:38`); it works
+ because it is `import type`, but a renderer owns the shape three surfaces read.
+ **Instead:** `editor/app/jobs/jobRowView.ts` is a types-only module (its only imports are
+ `import type { JobStatus, JobProgressMetric, JobTaskKind } from ".../jobs/registry"` —
+ `RunningJobsList.tsx:6-9` already does exactly this from a client file), and
+ `registry.ts:14-16`'s comment names it instead of `RunningJobsList.tsx`.
+3. **No page reads all three sources, and the registry is in two of them.** `/jobs` reads
+ `listAllJobs` = registry ∪ log files (`listJobs.ts:158-177`), never the scheduler;
+ `/jobs/active` reads the registry filtered to running/queued (`buildActiveJobs.ts:179-185`) —
+ a terminal record still holding a slot is invisible there, which is the whole reason
+ `/jobs/queue` exists (`buildQueueView.ts:94-98`); `/jobs/queue` reads scheduler ∪ registry,
+ never the logs. **Instead:** head = scheduler ∪ registry (live), tail = registry ∪ logs
+ (history), merged BY ID with the live row winning — a running job is in both and must be one
+ `<tr>`.
+4. **The auto-heal already runs on more surfaces than the queue page.** `/jobs/active`'s
+ server render calls `buildQueueView()` just for the badge count (`active/page.tsx:15-20`),
+ so the heal at `buildQueueView.ts:231-234` fires on every `/jobs/active` render and every 2 s
+ queue poll. Folding it into `buildActiveJobsPayload` widens the set of healers to the jobs
+ page, the dashboard cockpit's 1 s poll (`DashboardCockpit.tsx:39-44`), the widget's poll
+ (`MonitorWidget.tsx:71-76`) and `/api/jobs/active`. **That is the operator's decision**, and
+ the builder's header comment says it. `/api/pulse` does NOT join them — it observes and
+ "MUST NEVER CONSTRUCT" (`api/pulse/route.ts:36-53`).
+5. **`RunningJobsList` groups by channel; the table has no sections.** Eleven spec locators
+ are `section[aria-label='Active jobs for <displayName>']` (the display name comes from the
+ channel stat, `buildActiveJobs.ts:262-265`). **Instead:** row filters by slug — the
+ Channel cell's link text IS the slug — `page.getByRole("row").filter({ hasText: "drain-a" })`,
+ the locator every existing `/jobs` spec already uses. The `payload.channels` field has no
+ reader after `ActiveJobsLive` goes (grep verified) and is dropped.
+6. **The last-log-line tail is read for EVERY slot, every poll.** `buildQueueView.ts:137` reads
+ 8 KB per slot regardless of state. **Instead:** `readLastLogLine` moves into
+ `buildActiveJobs.ts` and is called only for rows with a `stuck` fact.
+7. **The `"running"` nav badge has five consumers and one spec.** `nav.ts:45,103`;
+ `layout.tsx:59` (seed) and `:106-117` (the `metric` switch); `SidebarBadges.tsx:22-33`
+ (`metric: "active" | "running"`); `api/pulse/route.ts:73,121` computes `runningJobs` and
+ derives `busy` from it; `components/pulse.ts:20,30,69` copies it into client state. Only
+ `jobs-active-order.spec.ts:64` pins `"1 running job"`. **Instead:** the nav key, the layout
+ seed and the `metric` prop go; **`PulsePayload.runningJobs` stays** — `busy` reads it and it
+ costs nothing; deleting it is a wire change no page needs.
+8. **"Queued in scheduler order" is a change, and the reorder spec expected it.** Today
+ `/jobs/active` orders running first then REGISTRY order (newest `queuedAt` first,
+ `buildActiveJobs.ts:182-185`); `jobs-reorder.spec.ts:2-4` says "the visual list is sorted by
+ recency, not queue position, so this asserts the EFFECT". **Instead:** queued rows sort by
+ (queue name, position) off the one `scheduler.queues()` snapshot — that is what "2nd in
+ line" in the Queue cell means — and the reorder spec keeps asserting the effect.
+9. **`nth(1)` means "the newest job" in nine specs.** With a live head, the first data row is
+ the first LIVE row. Each is checked in the locator table below; all hold, because in every
+ one the job under test is either running (head, first) or finished seconds ago (in `recent`,
+ first), and the pre-existing `refresh-report` hydration race (FACTS.md:1004-1010) is
+ exactly as it was.
+10. **`/jobs`'s empty state counts the registry, not the scheduler.** `total` from
+ `listAllJobs` is registry ∪ logs (`listJobs.ts:196`); a phantom slot is in neither.
+ **Instead:** the strip and the health line render ALWAYS ("a lane is not a job, so an
+ empty work list is not an empty page", `ActiveJobsLive.tsx:63-65`); "No jobs have run
+ yet." (pinned, `jobs.spec.ts:12-14`) renders when the merged rows are empty AND
+ `total === 0`; "No active queues." (`QueueViewLive.tsx:51`) is gone — the health line's
+ `0` beside "active queues" says it.
+11. **"Reap stuck" cannot be a header action.** Its visibility is `summary.stuck > 0` — a
+ live fact from the poll, not the SSR render (`QueueViewLive.tsx:99-123`). **Instead:** it
+ rides the health line, exactly where `HealthStrip` had it; the four static actions (Retry
+ all failed, Clear logs, Pause Transcriptions, Drain all) are the header.
+12. **`reapStuckJobsAction` reads the queue view.** `jobs/actions.ts:44-54` calls
+ `buildQueueView()` for `stuckIds`. **Instead:** `stuckJobIds()` exported from
+ `buildActiveJobs.ts` (the payload's stuck rows); `forceRelease` on an id the builder
+ already healed is safe — `registry.ts:273-276`: `scheduler.complete` is a no-op for an
+ unknown id and the method returns `true` regardless. **`forceRelease` also sets
+ `endedAt`** on a running/queued record (`registry.ts:266-269`), so a force-released job is
+ a `recent` row for 30 s — `queue.spec.ts`'s post-release assertion reads it from the head.
+13. **The two polls disagree about freshness.** A `/jobs` render served from the router cache
+ (`staleTimes.dynamic: 15`, `next.config.ts:46`) can be older than the client's last poll;
+ naively adopting a new `initial` prop would show a finished job as running again.
+ **Instead:** the payload carries `builtAt` (server `Date.now()`), and the client renders
+ whichever snapshot is newer — the pulse-driven prop or the poll's state. A job that just
+ finished therefore never vanishes and never regresses: the head keeps it ~30 s and the
+ tail re-renders within ~5 s.
+
+**Hazards carried through every commit:**
+
+- **Another session commits into this same checkout.** `de9efeb` → `a609566` landed while
+ this was planned, and a `duplicates-page` worktree exists (`git worktree list`). Stage by
+ explicit path only — never `git add -A` (slice 8a+8b's implementer swept a stray file in
+ that way) — and read `git status` before every commit; a commit of theirs landing between
+ yours is fine (it will not touch `editor/`), a stray untracked file of theirs in yours is not.
+- **Client graph.** `jobRowView.ts` is types-only. `jobRows.ts` (the pure adapters, order and
+ merge) is directive-free and client-safe by construction, like `laneState.ts`: its only
+ VALUE imports are `isDrainableKind` (`common/jobs/jobKinds.ts` — the file has zero imports,
+ and it is already in the client graph via `jobKindLabels.ts`) and `jobIdTime`
+ (`common/jobs/ulid.ts`, zero imports); everything from `registry`, `listJobs`,
+ `scheduler`, `controller/channels` is `import type`. `buildActiveJobs.ts` keeps the registry,
+ scheduler, `fs`, stats and worker pool; no client file imports it as a value. `LaneStrip.tsx`
+ imports `LANE_DOT/TEXT/WORD` from `laneState.ts` exactly as `ActiveJobsLive.tsx:11` does.
+- **`"use server"` files export functions only** — `stuckJobIds` lives in
+ `buildActiveJobs.ts`, `jobs/actions.ts` imports it.
+- **The guard** `noCorpusWalkInRenderPaths.test.ts` bans `listChannelStatsFromDisk |
+ buildBackfillSweepPlan | buildDigestSweepPlan` textually under `editor/app`. Nothing new
+ mentions them; `readChannelStat` (one stat file per active channel) is what
+ `buildActiveJobs.ts:196-198` already reads per poll.
+- **`next.config.ts:44-45`** names all three URLs in the `staleTimes` "DO NOT RAISE IT"
+ comment — rewritten, not deleted. **Redirects are checked before the filesystem**
+ (`next/dist/docs/01-app/03-api-reference/05-config/01-next-config-js/redirects.md`), so
+ `/jobs/active` never reaches `jobs/[id]/page.tsx` (which would `notFound()` on the id
+ `"active"`); `permanent: false` = 307, query passed through. `searchParams` is a Promise on
+ the page (`page.md:67-77`), as `jobs/page.tsx:28-32` already has it.
+- **The `refresh-report` default-hidden filter and the null-until-mount rule** (FACTS.md:
+ 1004-1010; `JobsTable.tsx:42-52`; `jobsFilterStorage.ts:11`). The filter now applies over the
+ MERGED rows, live included; `kinds`/`statuses` chips derive from all merged rows —
+ `channel-work.spec.ts:133-134` relies on the chip being present while the row is hidden.
+- **Hydration.** The first client render must equal the SSR: the head is `initial` until the
+ first poll lands (`polled` starts null); the 30 s window is cut on the SERVER (`recent` is a
+ payload field), never with `Date.now()` in render; the Duration cell keeps
+ `suppressHydrationWarning` (`JobsTable.tsx:273,280`).
+- **Playwright strict mode.** `page.getByText("stuck", { exact: true })` (`queue.spec.ts:29`)
+ must match ONE element: the health line's label. The badge's text is `stuck · <reason>`,
+ never bare `stuck`. `getByText("running", { exact: true })` inside a row is scoped, so the
+ health line's "running" label does not collide.
+- **`role="progressbar"` and the `gap-0.5` climb.** `jobs-batch-tasks-drain.spec.ts:87-89`
+ climbs from the task bar to `ancestor::div[contains(@class,'gap-0.5')][1]` and reads the
+ `m:ss` timer. Decision: **keep the class by sharing the component** — `TaskProgressBar` and
+ `JobProgressBar` are extracted from `RunningJobsList.tsx:211-373` byte-for-byte into
+ `JobProgressBars.tsx`, and the table's Status-cell stack uses `gap-1` so the nearest
+ `gap-0.5` ancestor is still the task's own wrapper (`:281`). The assertion is not rewritten.
+- **One `<tr>` per job.** No detail rows, no expandable log row (the id links to
+ `/jobs/[id]`; `JobLogTail`'s "Show log" toggle stays in `RunningJobsList` only).
+- **`revalidatePath("/jobs/queue")`** at `jobs/actions.ts:38,52` → dropped; every
+ `revalidatePath("/jobs")` stays (search params are ignored by it, so `?limit=` pages
+ revalidate too).
+- **The heal runs AFTER the rows are built** (a stuck row renders once) and only in
+ `buildActiveJobsPayload` — not in `liveJobRows` (the five pages), not in `/api/pulse`.
+- **`summary.running` counts scheduler SLOTS, not registry records** — a parallel (`""`) job
+ holds no slot (`registry.ts:173-175`); the comment says so, and the dashboard's
+ `running · queued` chip (`PipelineBand.tsx:38-39`) keeps counting rows.
+- **`payload.jobs` semantics are preserved for the dashboard and widget**: running, queued,
+ and stuck slots; the ~30 s finished window is the separate `recent` field, so
+ `PipelineBand.tsx:138` (`jobList.length > 0`) flips to "Pipeline idle" when work ends
+ exactly as today (`dashboard.spec.ts:32` pins it), and the widget's `ActiveJobsStrip`
+ (`MonitorWidget.tsx:989-1031`) lists no finished rows.
+- **`editor/app/jobs/[id]/page.tsx:41-46`** (the "Jobs" breadcrumb) and the five
+ `href="/jobs"` "view jobs" links (`FixAllIncompleteButton.tsx:79`,
+ `ChannelGroupLine.tsx:242`, `SyncAllChannelsButton.tsx:71`, `RefreshAllReportsButton.tsx:56`,
+ `NoReportYet.tsx:34`) are unaffected.
+- **Playwright name matching is case-insensitive substring unless `exact`.**
+ `dashboard.spec.ts:120` `sidebar.getByRole("link", { name: /jobs/i })` must find exactly one
+ link: with `Active` gone, "Jobs" (whose name includes the badge's `N active jobs`) is it.
+- **e2e runs detached (~25 min behind the machine-global lock; memory `e2e-run-detached`);
+ never boot the editor against `transcripts/`.**
+
+## Step 0 — the plan on disk
+
+Write this file verbatim to `plans/editor-ia-slice-8c.md`; commit alone:
+`plans: slice 8c planned`.
+
+## Order: four commits after the plan
+
+1. **`jobs: one row type, and one builder for every job list`** — `jobRowView.ts`,
+ `jobRows.ts` (adapters, order, merge, `RECENT_MS`), `buildActiveJobs.ts` re-based on them
+ (`recent`, `builtAt`, `liveJobRows`, `listJobRows`), the five hand-maps, `RunningJobsList`
+ retyped, the widget/`OperationDetail` type imports, `jobRows.test.ts`.
+2. **`jobs: one list — the live head is polled, the history tail is paged`** — `JobsTable`
+ rewritten, `LaneStrip.tsx` and `JobProgressBars.tsx` extracted, `/jobs/active` deleted,
+ redirect, nav, `next.config.ts` comment, dashboard embed, seven specs repointed.
+3. **`jobs: the scheduler's view rides the live payload, and heals its drift`** —
+ `reconcileSlots` + phantoms + `summary` + heal in the builder, the stuck badge /
+ Force-release / Reap stuck / last log line on `/jobs`, `buildQueueView.ts`,
+ `QueueViewLive.tsx`, `queue/page.tsx`, `api/jobs/queue/route.ts` deleted, redirect,
+ `queue.spec.ts` repointed.
+4. **`plans: slice 8c shipped, and the docs say so`** — CHANGELOG, IA doc, STATE, FACTS.
+
+Gates after each of 1–3: `pnpm -C <pkg> exec tsc --noEmit` for `common editor export homepage
+umtool mcp`; `pnpm -C common test`; `pnpm -C editor exec tsx --test "app/**/*.test.ts"`
+(re-measure the count before commit 1 — 98 at the end of slice 8a+8b; `jobRows.test.ts` adds
+to it). e2e once after commit 4, detached.
+
+---
+
+## Commit 1 — `jobs: one row type, and one builder for every job list`
+
+**New `editor/app/jobs/jobRowView.ts`** (types only):
+
+```ts
+import type { JobStatus, JobProgressMetric, JobTaskKind } from "yt-dlp-transcript-common/jobs/registry";
+
+// A ROW IS ONE JOB, whichever of three places knows about it: the registry (a
+// live JobRecord), the .jobs directory (a .log + .meta.json sidecar the
+// registry has forgotten), or the scheduler (a slot whose record was evicted —
+// a phantom, and the one row with no id the registry can answer for). Three
+// surfaces used to draw three shapes; every field name RunningJobsListItem had
+// is kept, so the widget and the dashboard read what they read before.
+// TYPES ONLY: this module is imported from "use client" files, and the builder
+// that fills it (buildActiveJobs.ts) reaches the registry and the filesystem.
+export type JobRowStatus = JobStatus | "archived" | "evicted";
+export type StuckReason = "record-evicted" | "terminal-but-holding-slot" | "possibly-stalled";
+export type JobRowTask = { …RunningJobsTask verbatim, RunningJobsList.tsx:17-36 };
+export type JobRowProgress = { …RunningJobsListItem["progress"] verbatim, :45-56 };
+export type JobRowView = {
+ id: string;
+ kind: string; // "" for an old log with no sidecar; the cell renders "—"
+ status: JobRowStatus;
+ queueKey?: string; // undefined = unknown (old log); "" = parallel ("(immediate)")
+ channelSlug?: string;
+ videoId?: string;
+ progress?: JobRowProgress;
+ tasks?: JobRowTask[];
+ draining?: boolean;
+ drainable?: boolean;
+ background?: boolean;
+ canMoveUp?: boolean;
+ canMoveDown?: boolean;
+ // The history side (listJobs.ts).
+ queuedAt?: number;
+ startedAt?: number;
+ endedAt?: number;
+ exitCode?: number;
+ logSize?: number;
+ inRegistry?: boolean;
+ replayable?: boolean;
+ // The slot side (the scheduler's view of the same id).
+ position?: number; // 0 = running head, 1.. = queued; absent for parallel/history
+ heldMs?: number;
+ stuck?: { reason: StuckReason; heldMs: number };
+ pid?: number;
+ lastLogLine?: string; // stuck rows only — see readLastLogLine
+ // Which adapter built it. Never rendered; tests and the merge read it.
+ source: "registry" | "archive" | "slot";
+};
+```
+
+**New `editor/app/jobs/jobRows.ts`** (directive-free, client-safe — header says so and why,
+naming `laneState.ts` as the precedent). Exports:
+
+- `RECENT_MS = 30_000` — "how long a finished job stays in the LIVE head. Long enough for the
+ 5 s pulse to re-render the tail with the same row twice over; short enough that the head
+ is still 'now'."
+- `fromRecord(j: JobRecord, ctx: { now: number; stat?: ChannelStat; workerNames: ReadonlyMap<string,string>; position: number; queueMax: number }): JobRowView`
+ — `buildActiveJobs.ts:224-260` verbatim (progress via `computeJobProgressView`, tasks with
+ worker names, `drainable`, `canMoveUp/Down` off `position`/`queueMax`), plus `queuedAt`,
+ `startedAt`, `endedAt`, `exitCode`, `inRegistry: true`, `replayable: Boolean(j.spec)`,
+ `position` when `>= 0`, `source: "registry"`. `computeEtaSeconds` (`:97-130`) and
+ `computeJobProgressView` (`:132-172`) move here unchanged — they are pure given the stat.
+- `fromEntry(e: JobListEntry): JobRowView` — `kind: e.kind ?? ""`, the rest field-for-field,
+ `source: "archive"`.
+- `orderLiveRows(rows: JobRowView[]): JobRowView[]` — rank 0 stuck, 1 running (registry
+ order: `queuedAt` desc, as today), 2 queued by (`queueKey` localeCompare, `position`),
+ 3 terminal by `endedAt` desc. Comment: finding 8.
+- `mergeJobRows(live: JobRowView[], history: JobRowView[]): JobRowView[]` — live rows first in
+ their order, then history rows whose id is not live, in history order. "LIVE WINS BY ID: the
+ same job is in the registry and on disk at once, and its live row is the one with progress."
+- `isLive(r: JobRowView): boolean` — `status === "running" || "queued" || "evicted" || Boolean(r.stuck)`.
+- (commit 3 adds `reconcileSlots`, `STUCK_AGE_MS`, `TERMINAL`.)
+
+**`editor/app/jobs/active/buildActiveJobs.ts`** (stays where it is — a route directory with no
+`page.tsx` is not routable; the `scheduler/` precedent, slice 8a+8b finding 8):
+
+- `:18` import deleted; `import type { JobRowView } from "../jobRowView"`; `fromRecord`,
+ `orderLiveRows`, `RECENT_MS` from `../jobRows`. `:97-172` deleted (moved).
+- `ActiveJobsPayload` (`:82-90`): `jobs: JobRowView[]` (running, queued — commit 3 adds the
+ stuck slots), **`recent: JobRowView[]`** (registry records terminal with
+ `endedAt >= builtAt - RECENT_MS`, `endedAt` desc), **`builtAt: number`**, `disk`, `lanes`;
+ `channels` dropped (finding 5). The comment on `recent`: "so a job that finishes between
+ two polls is shown finishing rather than vanishing — the /jobs head keeps it, the widget
+ and the dashboard ignore this field on purpose (idle must flip when work ends)."
+- One `scheduler.queues()` snapshot (`views`) replaces the per-record
+ `registry.positionInQueue` loop (`:213-221`): `position` = index of the id in
+ `[...v.running, ...v.queued]`, `queueMax` = that array's last index. Comment: one snapshot,
+ so a slot completing mid-build cannot move a position under us (`buildQueueView.ts:112-115`'s
+ point).
+- New internal `rowsForRecords(records: JobRecord[], views: QueueView[]): Promise<JobRowView[]>`
+ — the stats/worker-names/positions context built once, `fromRecord` per record. Both
+ `buildActiveJobsPayload` and:
+- **`export async function liveJobRows(filter: (j: JobRecord) => boolean): Promise<JobRowView[]>`**
+ — `registry.list().filter(running|queued).filter(filter)` → `rowsForRecords` →
+ `orderLiveRows`. Header: "THE ONE BUILDER FOR EVERY JOB LIST. Five pages hand-rolled a
+ six-field copy of this row and so drew the same job with progress bars on Active Jobs and
+ none on its channel page. No lanes, no disk, no reconciliation and NO HEAL here — a channel
+ page reading its own jobs must not free a scheduler slot as a side effect."
+- **`export async function listJobRows(paths, opts): Promise<{ rows: JobRowView[]; hasMore; total }>`**
+ — `listAllJobs` mapped through `fromEntry`. The tail's adapter, beside the head's.
+- `buildLanes(jobs)` (`:293`) typed on `JobRowView[]`; `:291` comment "/jobs/active, the
+ widget and the dashboard" → "/jobs, the widget and the dashboard". Header (`:174-176`):
+ "Shared by the /jobs page (initial state), the dashboard, the widget and the
+ /api/jobs/active poll route."
+
+**The five hand-maps** → `liveJobRows`:
+
+| file | today | after |
+|---|---|---|
+| `channels/[slug]/page.tsx:139-158` (social) | `getRegistry().list().filter(slug + live).map(six fields)` | `const socialRunningJobs = await liveJobRows((j) => j.channelSlug === slug)`; `<RunningJobsList jobs={socialRunningJobs} hideChannelSlug />` |
+| `channels/[slug]/page.tsx:185-191` + `:582-592` | same, `runningJobs` | `const runningJobs = await liveJobRows((j) => j.channelSlug === slug)`; `jobs={runningJobs}`. `registry` at `:183` stays for `activeQueueNames()` |
+| `channels/[slug]/videos/[id]/page.tsx:157-172` + `:221` | `.map(six fields)` | `await liveJobRows((j) => j.channelSlug === slug && j.videoId === id)` |
+| `build/page.tsx:22-36` + `:40` | `.map(six fields)`; the page is sync | `export default async function BuildPage()`; `await liveJobRows((j) => BUILD_KINDS.has(j.kind))` |
+| `operations/[id]/page.tsx:217-234` | `.map(six fields)` | `laneJobKinds.length === 0 ? [] : await liveJobRows((j) => laneJobKinds.includes(j.kind))`; `getRegistry` import (`:18`) dropped if unused |
+
+`jobs/active/buildActiveJobs` import path from each (the file does not move). Each call site
+keeps a one-line comment: "through the one builder, so this list has the same progress bars
+/jobs does (it used to drop `progress`, `tasks`, `drainable` and the reorder bounds)."
+
+**`RunningJobsList.tsx`**: `:17-69` deleted; `import type { JobRowView, JobRowTask } from
+"../jobRowView"`; `Props.jobs: JobRowView[]`, `JobRow.job: JobRowView`, `TaskProgressBar.task:
+JobRowTask`, `JobProgressBar.progress: NonNullable<JobRowView["progress"]>`. **No markup
+change.** Header gains: "THE CARD RENDERER for a page's own jobs (channel, video, build,
+operation pages). /jobs draws the same JobRowView as a table row; both read the one type."
+
+**Type imports repointed:** `MonitorWidget.tsx:11` → `import type { JobRowView } from
+"../../jobs/jobRowView"` and `RunningJobsListItem` → `JobRowView` at `:997,1040,1121,1139,1176`;
+`OperationDetail.tsx:18-20` → `import { RunningJobsList } from
+"../../jobs/components/RunningJobsList"; import type { JobRowView } from "../../jobs/jobRowView"`,
+`:53,170`. `common/jobs/registry.ts:14-16` comment → "one re-spelled copy in editor
+`jobRowView.ts`'s `JobRowProgress` — kept as an IMPORT there".
+
+**New `editor/app/jobs/jobRows.test.ts`** (node:test, the `app/**/*.test.ts` glob):
+
+- `fromRecord`: every `RunningJobsListItem` name present; `drainable` only when running, a
+ drainable kind (`isDrainableKind("whisper-all")`) and not draining; `canMoveUp` false at
+ position 1, true at 2; `canMoveDown` false at `queueMax`; `progress` undefined without a
+ stat; `replayable` follows `spec`.
+- `fromEntry`: `"archived"` survives, `logSize`/`inRegistry`/`replayable` carried, `kind ""`
+ for a sidecar-less log.
+- `orderLiveRows`: `[queued pos 2, running, queued pos 1, done 5 s ago]` → running, queued 1,
+ queued 2, done; two queues interleave by name then position.
+- `mergeJobRows`: live wins by id (the history copy of a running job is dropped, the live
+ one keeps `progress`); order = head then tail minus head; a job in `recent` stays first
+ even when the history page also has it.
+
+Records are built the way `common/jobs/registry.test.ts:12-22` builds them (a literal
+`JobRecord` with `newJobId()`); no registry, no scheduler.
+
+---
+
+## Commit 2 — `jobs: one list — the live head is polled, the history tail is paged`
+
+**New `editor/app/jobs/components/LaneStrip.tsx`** (`"use client"`): `LaneStrip` and `LaneRow`
+moved from `ActiveJobsLive.tsx:120-198` **byte-identical** — the header comment (`:120-126`),
+`section aria-label="System jobs: ${lane.label}"`, the `<h2>`, `LANE_DOT/TEXT/WORD`, the
+`log` link, `DrainJobButton`, `CancelJobButton`. Props: `{ lanes: ActiveLaneView[]; jobs:
+Map<string, JobRowView[]> }` (client-to-client, a Map is fine — `ActiveJobsLive.tsx:47-61`
+built the same one). `ActiveLaneView` is `import type` from `../active/buildActiveJobs`.
+Exported.
+
+**New `editor/app/jobs/components/JobProgressBars.tsx`** (`"use client"`): `useNow`
+(`RunningJobsList.tsx:211-219`), `TASK_KIND_VERB`, `METRIC_FILL_BY_TASK` (`:221-234`),
+`TaskProgressBar` (`:236-318`), `METRIC_LABELS`, `METRIC_FILL` (`:320-334`), `JobProgressBar`
+(`:336-373`) — moved verbatim, exported; `RunningJobsList.tsx` imports them. The
+`aria-label={`${verb} ${task.label}`}` (`:300`) and the `gap-0.5` wrapper (`:281`) are the
+contract (hazards). Header: "shared by the card renderer and the /jobs table; the aria-labels
+and the wrapper class are what the suite climbs from."
+
+**`editor/app/jobs/components/JobsTable.tsx`** — rewritten in place (`"use client"`):
+
+```ts
+export function JobsTable({ initial, history, total, embedded }: {
+ initial: ActiveJobsPayload; // import type
+ history: JobRowView[];
+ total: number;
+ // The dashboard's PipelineBand: strip + rows only — no filter bar, no health
+ // line, no empty paragraph; the band states running · queued itself.
+ embedded?: boolean;
+})
+```
+
+Header comment (WHY): "ONE LIST. The live head is what the registry and the scheduler say
+NOW, polled at 1 s while anything is non-terminal; the history tail is the paged directory
+listing the server rendered, refreshed by the global pulse like every other page. They meet in
+one `<table>`, one `<tr>` per job, merged by id with the live row winning. Nothing is a mode:
+a queued job and a job that failed a week ago are the same kind of row with different cells
+filled in."
+
+- **Freshest snapshot wins** (finding 13):
+ `const [polled, setPolled] = useState<ActiveJobsPayload | null>(null);`
+ `const live = polled && polled.builtAt >= initial.builtAt ? polled : initial;`
+- **Head, tail, merge:** `const head = [...live.jobs, ...live.recent]` (already ordered by the
+ server); `const rows = useMemo(() => mergeJobRows(head, history), …)`.
+- **The NowLive rule** (umtool `NowLive.tsx:8-10,41-56`, quoted in the comment):
+ `const anyLive = head.some(isLive) || history.some(isLive);` — an effect keyed on
+ `[anyLive]`: when true, fetch `/api/jobs/active` immediately (`cache: "no-store"`) then every
+ `POLL_MS = 1000` (the `setTimeout` chain from `ActiveJobsLive.tsx:23-43`, verbatim); when
+ false, nothing — "an idle page makes no requests; a refresh (the pulse, a navigation) that
+ brings a non-terminal row restarts it, because `anyLive` is derived from the props too".
+ `polled` is never reset: the last poll's `recent` rows are the thing that keeps a job that
+ just finished on screen until the tail catches up.
+- **Filters** — `:47-109` kept verbatim over `rows` (null-until-mount rule, comment `:42-45`
+ kept and extended: "…and it applies to the LIVE rows too — a running refresh-report is hidden
+ by the same chip"). `kinds`/`statuses` derived from `rows` (all of them). "Showing
+ {visible.length} of {rows.length} · {hidden} hidden" (`:174-177`, the `/1 hidden/` pin).
+ Hidden entirely when `embedded`.
+- **Above the table:** `<LaneStrip lanes={live.lanes} jobs={byKind} />` (`byKind` from
+ `live.jobs` with no `channelSlug`, as `ActiveJobsLive.tsx:50-60`), then (commit 3) the health
+ line. Both render even when `rows` is empty.
+- **Empty:** `!embedded && rows.length === 0 && total === 0` → the
+ `No jobs have run yet.` paragraph (`page.tsx:49-51` verbatim); otherwise the table.
+- **The `<tr>`** (`:213-221`): `data-kind={j.kind || undefined}` (comment kept), **`data-job-id={j.id}`**,
+ **`data-live=""` when the id is in `head`** ("came from the live payload — the SSR seed or the
+ poll"). Cells:
+ - ID (`:222-229`), Kind (`:230-232` + the `auto` badge from `RunningJobsList.tsx:126-133`
+ when `background`), Channel, Video (`:233-256`) unchanged.
+ - **Queue** (`:257-263`): the key as today, then for a queued row with `position >= 1`
+ `<span className="text-muted-foreground"> · {position === 1 ? "next in line" : `${ordinal(position)} in line`}</span>`;
+ `toContainText("qShared")` etc. still hold.
+ - **Status** (`:264-270`) becomes a `<div className="flex flex-col gap-1">` — NOT `gap-0.5`
+ (hazard): the status pill first (`:265-269` verbatim — text content stays the lowercase
+ raw word, uppercase via CSS; `getByText("running", { exact: true })` is pinned), then
+ (commit 3) the stuck badge, then `{j.progress && <JobProgressBar progress={j.progress} />}`,
+ then `{j.tasks?.length ? <ul aria-label="In-progress operations" …>` with one
+ `<TaskProgressBar task={t} />` per task (`RunningJobsList.tsx:191-202` markup). The column
+ gets `min-w-56 align-top` so a bar has room.
+ - **Started** (`:271-276`): `startedAt ?? queuedAt`, "—" when neither.
+ - **Duration** (`:277-282`): `endedAt ? endedAt - start : startedAt ? Date.now() - startedAt : 0`
+ — unchanged; for a live running row it is "running for", and it ticks because the row
+ re-renders on every poll.
+ - **Log size** (`:283-285`): `typeof logSize === "number" ? … : "—"` (a live row has none);
+ commit 3 puts the stuck row's last log line here.
+ - **Actions** (`:286-295`): `Retry` (failed + replayable), `ReorderJobButtons` (queued and
+ `canMoveUp || canMoveDown` — `RunningJobsList.tsx:163-169`), `Drain` (running and
+ `drainable || draining`, `:170-172`), `Cancel` (running or queued), `Force-release` (running,
+ `:174-178` with its comment). Commit 3 adds it for stuck rows regardless of status.
+
+**`editor/app/jobs/page.tsx`**: `Promise.all([listJobRows(getPaths(), { limit }),
+buildActiveJobsPayload()])`; `const paused = getWorkerPool().isPaused();`; header actions in
+this order: `{hasRetryableFailed && <RetryAllFailedButton />}`, `<ClearLogsMenu />`,
+`<PauseLaneButton lane="transcription" held={paused} />` (with `active/page.tsx:37-38`'s
+comment — no `onChange`, so it falls back to `router.refresh()`; this page is `force-dynamic`),
+`<DrainAllButton />`; then `<JobsTable initial={initial} history={rows} total={total} />`; the
+`hasMore` block (`:55-68`) unchanged. `metadata.title` stays "Jobs". Header comment: what the
+head and the tail are, and that `Load more` grows only the tail.
+
+**Deleted:** `editor/app/jobs/active/page.tsx`, `editor/app/jobs/components/ActiveJobsLive.tsx`
+(`git rm`). `buildActiveJobs.ts` stays in `jobs/active/`.
+
+**Dashboard.** `PipelineBand.tsx:9` → `import { JobsTable } from "../../jobs/components/JobsTable"`;
+`:139` → `<JobsTable initial={jobs} history={[]} total={0} embedded />`. Comment: "The same one
+list /jobs draws, with no tail: the band is the head. The cockpit above already polls
+`/api/jobs/active` (`DashboardCockpit.tsx:39-44`) and this component polls it again while
+anything is live — the double poll predates this change and is out of scope here."
+`DashboardCockpit.tsx` untouched.
+
+**Nav and redirect.** `lib/nav.ts:3` `Activity,` deleted; `:43` "the end state is twelve" →
+"eleven"; `:45` `NavBadgeKey = "jobs" | "changelog" | "cleanable"`; `:103` deleted; `:102`
+keywords → `"queue tasks active running live monitor stuck"` (the palette still answers
+"active"). `layout.tsx:59` deleted; `:106-117` → `if (link.badgeKey === "jobs")` with
+`<JobsBadge seed={activeJobs} />`. `SidebarBadges.tsx:17-43`: `metric` prop and the ternary
+go; `count = pulse ? pulse.activeJobs : seed`; `data-testid="badge-active"`; aria-label
+`${count} active job${count === 1 ? "" : "s"}` (the `2 active jobs` pin). Header `:6`
+"active/running job counts" → "the active-job count". `next.config.ts:44-45` →
+"DO NOT RAISE IT: /jobs is a live operational view — its head is polled, its tail rides the
+pulse — and a stale queue is worse than a slow one." `:59` gains a paragraph:
+
+```
+ // /jobs/active and /jobs/queue were the live third and the scheduler's third
+ // of one list; /jobs is that list now — the live head is polled, the history
+ // tail is paged. /api/jobs/active is NOT redirected: the dashboard, the widget
+ // and the page itself poll it.
+```
+
+and `:72` gains `{ source: "/jobs/active", destination: "/jobs", permanent: false },
+{ source: "/jobs/queue", destination: "/jobs", permanent: false }`. Comment-only
+rewrites: `pauseControl.tsx:9` "/jobs/active" → "/jobs", `common/lib/pauseGates.ts:32` ("the
+queue view" → "/jobs"), `common/ytdlp/downloadOneManaged.ts:73` "matching the /jobs/active
+poll" → "the /jobs poll", `api/jobs/active/route.ts:6-7` "the Active Jobs screen" → "the /jobs
+head, the dashboard and the widget".
+
+**Specs (commit 2):**
+
+- `jobs-active-order.spec.ts`: title → "the live head lists running before queued, oldest
+ running first"; `:47` → `/jobs`; `:49-60` →
+ ```ts
+ const rows = page.locator("tbody tr[data-live]");
+ await expect(rows).toHaveCount(2, { timeout: 15_000 });
+ await expect(rows.nth(0)).toContainText("slow-a");
+ await expect(rows.nth(1)).toContainText("slow-b");
+ await expect(rows.nth(0).getByText("running", { exact: true })).toBeVisible();
+ await expect(rows.nth(1).getByText("queued", { exact: true })).toBeVisible();
+ ```
+ `:62-63` comment → "Nav badge: Jobs counts running + queued; the separate Active badge is
+ gone with the page." `:64` deleted.
+- `jobs-reorder.spec.ts:1-4` header → the row's buttons on `/jobs`; `:87` → `/jobs`; `:88-90`
+ → `const rowA = page.getByRole("row").filter({ hasText: "reorder-a" })` (B, C likewise);
+ `:91-108` `sectionX` → `rowX`. (`data-kind="sync"` on all three; the slug is in the row.)
+- `jobs-batch-tasks-drain.spec.ts`: `:1-2` header "Active Jobs screen" → "/jobs"; six `goto`
+ (`:66,114,153,200,254,291`) → `/jobs`; `:116,154,201-203,255-257,292-294` →
+ `page.getByRole("row").filter({ hasText: "<slug>" }).filter({ hasText: "Transcribe all" })`
+ (the slug alone would also match the channel's `refresh-report` row once it lands — the
+ same reason `:304-307` gives); `:118,126` `section` → `row`; `:87-89` unchanged (hazard).
+- `workers.spec.ts:132,150,191` → `/jobs`; `:151-153,192-194` → the row filter by slug +
+ "Transcribe all"; `:148-149` comment → "/jobs lists it live".
+- `parakeet-partial.spec.ts:132` title "the Active Jobs screen" → "/jobs"; `:140` → `/jobs`.
+- `auto-queue.spec.ts:540,578,693,1282` → `/jobs`; `:516,569,1273` titles "Active Jobs" →
+ "/jobs"; every `section[aria-label='System jobs: …']`, heading and button assertion
+ unchanged (strip kept byte-identical). Note at `:694`: the runner's own table row now also
+ carries a Cancel; the loop already swallows a vanished button.
+- `navigation.spec.ts:105-108` comment gains "/jobs/active and /jobs/queue were two thirds of
+ /jobs."; after `:120`:
+ ```ts
+ for (const retired of ["/jobs/active", "/jobs/queue"]) {
+ await page.goto(retired);
+ await expect(page).toHaveURL(/\/jobs$/);
+ await expect(page.getByRole("heading", { name: "Jobs", level: 1 })).toBeVisible();
+ }
+ ```
+
+---
+
+## Commit 3 — `jobs: the scheduler's view rides the live payload, and heals its drift`
+
+**`jobRows.ts`** gains `STUCK_AGE_MS` (`buildQueueView.ts:10-15` verbatim), `TERMINAL`
+(`:17`) and:
+
+```ts
+export type SlotFact = {
+ id: string; queueKey: string; role: "running" | "queued"; position: number;
+ heldMs: number; stuck?: { reason: StuckReason; heldMs: number }; heal: boolean;
+};
+// THE SCHEDULER AGAINST THE REGISTRY, as facts. The scheduler is the source of
+// truth for SLOTS and still references ids whose record is terminal or gone;
+// the registry is the source of truth for STATUS and never sees a slot. Pure:
+// it takes the queues snapshot and a lookup, reports what it found, and does
+// not heal — the builder heals, AFTER the rows are built, so a stuck row is
+// drawn once before it vanishes. Classification is buildQueueView's, unchanged:
+// record-evicted and terminal-but-holding-slot are hard (heal); possibly-stalled
+// (a live record idle past STUCK_AGE_MS) is soft — surfaced, force-releasable,
+// never healed, since it may be a genuinely long operation.
+export function reconcileSlots(views: QueueView[], get: (id: string) => JobRecord | undefined, now: number): SlotFact[]
+```
+
+— `buildQueueView.ts:142-163` verbatim per running slot; queued slots get `position`/`heldMs`
+and no stuck fact. `fromSlot(f: SlotFact, kind: string, lastLogLine?: string): JobRowView` —
+the phantom: `status: "evicted"`, `queuedAt: jobIdTime(f.id) || undefined`, `heldMs`, `stuck`,
+`source: "slot"`.
+
+**`buildActiveJobs.ts`**: header rewritten to carry the operator's decision:
+
+```
+// EVERY CONSUMER OF THIS PAYLOAD HEALS THE SCHEDULER'S DRIFT WHEN IT OBSERVES
+// IT. The queue-diagnostics page used to be the one place that compared the
+// scheduler's slots to the registry's statuses and freed a slot whose record
+// was terminal or evicted; the Active Jobs page hid exactly those rows. Now
+// the /jobs page, the dashboard, the widget and /api/jobs/active all build this,
+// so any of them observing a terminal record holding a running slot frees it —
+// AFTER the rows are built, so the stuck row is drawn once. The decision is the
+// operator's (plans/editor-ia-slice-8c.md): a running slot with a terminal
+// record is never right, and the surface that sees it is the surface that
+// fixes it. The soft "possibly-stalled" case is surfaced, never auto-healed.
+// /api/pulse does NOT build this and must not: it observes, it never constructs.
+```
+
+In the builder: `views` (one snapshot, already there from commit 1) → `facts =
+reconcileSlots(views, (id) => registry.get(id), now)`; rows: every registry running/queued
+record via `rowsForRecords` with its fact's `stuck`/`heldMs`/`pid: j.child?.pid` attached;
+every fact whose record is terminal → `fromRecord` too (it holds a slot; it is in `jobs`);
+every fact with no record → `readJobMeta` for the kind (`buildQueueView.ts:165-170`) →
+`fromSlot`; `readLastLogLine` (moved from `:52-92`, unchanged) **only where `stuck` is set** —
+"per stuck row, not per slot per poll: a transcription log runs to megabytes and there is one
+line to show and one reason to show it". `jobs = orderLiveRows(...)`; `recent` as commit 1
+(excluding ids already in `jobs`); **then** `for (const f of facts) if (f.heal)
+scheduler.complete(f.id)` (`buildQueueView.ts:231-234`'s comment); `summary = { queues:
+views.length, running: Σ v.running.length, queued: Σ v.queued.length, stuck, workersPaused:
+pool.isPaused(), workerCount: pool.summary().length }` with the slots-not-records comment
+(hazards). `export async function stuckJobIds(): Promise<string[]>` — `(await
+buildActiveJobsPayload()).jobs.filter((r) => r.stuck).map((r) => r.id)`.
+
+**`jobs/actions.ts`**: `:21` → `import { stuckJobIds } from "./active/buildActiveJobs"`;
+`:32` comment "and the queue-diagnostics page" → "(the live payload re-reads the slot on the
+next poll)"; `:38` deleted; `:42-43` comment → "Reap every stuck slot the live payload
+reports: force-release each. The hard cases were already healed by the build that listed
+them; forceRelease is a no-op on a freed slot (registry.forceRelease)"; `:46` →
+`const stuckIds = await stuckJobIds();`; `:52` deleted.
+
+**`JobsTable.tsx`** (commit 2's file):
+
+- **Health line** under the strip, `!embedded` only: `<div aria-label="Queue health"
+ className={HealthStrip's, QueueViewLive.tsx:86}>` with five stats from `live.summary`, each
+ `<div data-stat="queues|running|queued|stuck|workers" className="flex flex-col">` holding the
+ value `<span>` and the label `<span>` (`:72-83` verbatim: the label text is exactly
+ `active queues`, `running`, `queued`, `stuck`, `workers` / `workers (paused)`). Right side:
+ `summary.stuck > 0 && <ReapStuckButton count={summary.stuck} />` — `:99-123` extracted to
+ `components/ReapStuckButton.tsx` (`"use client"`), label **"Reap stuck"** (was "Reap all
+ stuck"; not pinned), the confirm text kept. "Copy diagnostics" (`:124-140`) is not carried:
+ the payload is `/api/jobs/active`, one URL away.
+- **Status cell**, after the pill: `{j.stuck && <span data-stuck={j.stuck.reason}
+ title={j.pid ? `pid ${j.pid} — kill -9 ${j.pid}` : undefined} className={QueueViewLive.tsx:222}>
+ stuck · {j.stuck.reason}</span>}` (`:221-225`'s markup; `pid` folds into the title rather than
+ a fifth thing in the cell).
+- **Duration cell** for a row with `heldMs` and no `startedAt` (a phantom): `held for
+ ${formatDuration(Math.round(heldMs/1000)) || "0:00"}` (`:185,212`).
+- **Log size cell**: `j.stuck && j.lastLogLine ? <span className="font-mono text-xs
+ text-muted-foreground truncate max-w-64 block" title={j.lastLogLine}>{j.lastLogLine}</span>`
+ (`:230-234`) else as commit 2.
+- **Actions**: `ForceReleaseJobButton` for `status === "running" || j.stuck` (a stuck
+ terminal/evicted row has no Cancel to offer). `<tr data-stuck={j.stuck?.reason}>`.
+- The sr-only live region `QueueViewLive.tsx:60-62` (`{n} stuck slot(s)`) carried under the
+ health line.
+
+**Deleted (`git rm`):** `editor/app/jobs/queue/buildQueueView.ts`,
+`editor/app/jobs/queue/components/QueueViewLive.tsx`, `editor/app/jobs/queue/page.tsx`,
+`editor/app/api/jobs/queue/route.ts`. `editor/app/api/test/stuck-job/route.ts:13-14` comment
+"the Queue diagnostics page exists to surface" → "/jobs surfaces (the live payload's
+reconciliation)". `/api/jobs/active` stays.
+
+**Unit test (commit 3), in `jobRows.test.ts`:** `reconcileSlots` driven by
+`createScheduler()` (`common/jobs/scheduler.ts:204`, no registry): submit `a` (runs), `b`, `c`
+on one key with no-op `start`; a `Map` lookup — `a` terminal → `terminal-but-holding-slot`,
+`heal`; `a` absent → `record-evicted`, `heal`; `a` running with `startedAt = now - 11 min` and
+`tasks: []` → `possibly-stalled`, no heal; `startedAt = now - 1 min` → no `stuck`; `b`, `c`
+carry positions 1, 2 and no stuck; the heal list holds each id once; after
+`facts.filter(f => f.heal).forEach(f => scheduler.complete(f.id))`, `queues()[0].running`
+is `["b"]`. `fromSlot` yields `status: "evicted"`, `source: "slot"`; `orderLiveRows` puts a
+stuck row before a running one.
+
+**Specs (commit 3):** `queue.spec.ts` header `:1-5` → "/jobs: a job holding a running slot
+that has gone quiet is surfaced as STUCK on its own row…"; test 1 title → "a stuck slot shows
+on /jobs and Force-release clears it"; `:25` → `/jobs`; `:28` → heading "Jobs" level 1;
+`:29` unchanged; `:32-35` →
+```ts
+const row = page.locator(`tr[data-job-id="${id}"]`);
+await expect(row).toBeVisible();
+await expect(row.getByText(/possibly-stalled/)).toBeVisible();
+await expect(page.locator('[data-stat="stuck"]')).toContainText("1");
+```
+`:39` → `row.getByRole("button", { name: /^Force-release$/ }).click()`; `:41-47` →
+`await expect(row.getByText(/possibly-stalled/)).toHaveCount(0, { timeout: 10_000 });
+await expect(row).toContainText("cancelled"); await expect(page.locator('[data-stat="stuck"]')).toContainText("0");`
+(forceRelease marks the record cancelled and sets `endedAt`, `registry.ts:266-269`; it stays
+in the head as a `recent` row). Test 2 title → "loads with an empty queue and renders the
+health line"; `:54` → `/jobs`; `:55` → heading "Jobs"; `:56` unchanged; `:57` →
+`await expect(page.locator('[data-stat="queues"]')).toContainText("0");
+await expect(page.getByText("No jobs have run yet.")).toBeVisible();`.
+
+---
+
+## Locator / URL table (old → new; which spec pins it)
+
+| old | new | pinned by |
+|---|---|---|
+| `/jobs/active` | `/jobs` (307) | `jobs-active-order:47`, `jobs-reorder:87`, `jobs-batch-tasks-drain` ×6, `workers:132,150,191`, `parakeet-partial:140`, `auto-queue:540,578,693,1282`; `navigation` (new) |
+| `/jobs/queue` | `/jobs` (307) | `queue:25,54`; `navigation` (new) |
+| h1 "Active jobs" / "Queue" | h1 **"Jobs"** (unchanged) | `navigation:44,53,72,78`; `queue` (repointed) |
+| `section[aria-label^='Active jobs for ']` (order, count) | `tbody tr[data-live]` order | `jobs-active-order:49-60` |
+| `section[aria-label='Active jobs for <Name>']` | `getByRole("row").filter({ hasText: "<slug>" })` (+ `"Transcribe all"` where a refresh-report follows) | `jobs-reorder:88-90`, `jobs-batch-tasks-drain:116,154,201,255,292`, `workers:151,192` |
+| `section.getByText("running"/"queued", { exact: true })` | same, row-scoped | the same specs, `queues:134-135` |
+| `getByRole("progressbar", { name: /Transcribing slowop/ })` | **unchanged** (`JobProgressBars.tsx`) | `jobs-batch-tasks-drain:70`, `workers:134` |
+| `xpath=ancestor::div[contains(@class,'gap-0.5')][1]` | **unchanged** (component shared; the cell stack is `gap-1`) | `jobs-batch-tasks-drain:88` |
+| `Transcripts: n / 6`, `~m:ss left`, `on Only` | unchanged, inside the row | `jobs-batch-tasks-drain:118,129`, `workers:195` |
+| `promote job ${id} to front of queue`, `move job ${id} up/down`, `Drain`, `^Cancel$`, `Retry`, `retry job ${id}` | **unchanged** | `jobs-reorder:99-104`, `jobs-batch-tasks-drain`, `cancel:48`, `jobs-retry:83-89,146` |
+| `section[aria-label='System jobs: Auto-transcribe']`, its `<h2>`, Drain/Cancel, `nothing pending`, `System jobs: Digest`, no "Other" heading | **unchanged** (`LaneStrip.tsx`) | `auto-queue:542-545,580-593,1284-1300` |
+| "Pause Transcriptions" / "Resume Transcriptions" | unchanged, on `/jobs` | `parakeet-partial:141-150` |
+| "Drain all" | unchanged, on `/jobs` | `jobs-batch-tasks-drain:210` |
+| `getByLabel("2 active jobs")` | unchanged | `jobs-active-order:63` |
+| `getByLabel("1 running job")` | **gone** | `jobs-active-order:64` (deleted) |
+| `section[aria-label='Queue qStuck']`, `Job ${id}` | `tr[data-job-id="${id}"]` | `queue:32-34` |
+| `getByText("stuck", { exact: true })`, `getByText("active queues", { exact: true })` | unchanged (health line labels) | `queue:29,56` |
+| `/possibly-stalled/`, `^Force-release$` | unchanged, row-scoped | `queue:35,39` |
+| "No active queues." | `[data-stat="queues"]` reads `0` + "No jobs have run yet." | `queue:45,57` |
+| "Reap all stuck" | "Reap stuck" | none |
+| `tr[data-kind]`, `jobRowByKind` | unchanged, on every row (live too) | 16 specs (`helpers.ts:183-188`) |
+| `getByRole("row").nth(1)` = newest | = first LIVE row, else newest | see below |
+| "No jobs have run yet.", "Clear logs", "All finished logs", "Search jobs", `/1 hidden/`, "Reset filters", kind/status chips | unchanged | `jobs:8-14`, `jobs-filters:110-138`, `channel-work:135-144` |
+| sidebar link `/jobs/active` "Active" | gone; one sidebar link matches `/jobs/i` | `dashboard:120` |
+| `aria-label="pipeline idle"` | unchanged (`jobs.jobs` semantics kept) | `dashboard:32` |
+| `/api/jobs/active` `body.jobs[]{kind,status,queueKey,tasks[].phase}`, `body.disk` | unchanged (fields added, none removed except `channels`) | `backfill:22-24`, `audio-check-scenarios:601-609`, `auto-queue:658-668`, `disk-space:229-262` |
+
+**The `nth(1)`/`nth(2)` assertions, one by one** (order after the fold: stuck, running
+(newest first), queued (queue, position), finished ≤ 30 s (newest first), history newest
+first):
+
+- `queues.spec.ts:36` — slow-a's sync is running → first live row → holds.
+- `queues.spec.ts:126-129` — slow-a running (nth 1), slow-b queued (nth 2); both `qShared` →
+ holds (today it was the reverse order, and the assertion is symmetric).
+- `digest.spec.ts:382-384` — the digest job finished seconds ago → `recent`, first → holds;
+ the channel's `refresh-report` lands ~1 s after and is newer — the same pre-hydration race
+ as today (FACTS.md:1004-1010), unchanged.
+- `digest.spec.ts:466-467` — remote (newer) then local, both `recent` or the older in the
+ tail; either way remote precedes local → holds.
+- `bulk-actions.spec.ts:65,96,117` — the batch is running (head) or just finished (`recent`)
+ → first → holds, same refresh-report caveat.
+- `channels-actions.spec.ts:41,59` — check-availability / whisper-all just finished →
+ `recent` first → holds.
+- `jobs-channel.spec.ts:21` — store-playlist just finished → holds. `:43-52` — the running
+ row flips to "cancelled" via the 1 s poll now (it moves from `jobs` to `recent`), well inside
+ the 10 s.
+- `cancel.spec.ts:47-49`, `job-stream-cancel.spec.ts:65-67`, `jobs-retry.spec.ts:91-95`,
+ `sync-deep.spec.ts:250-253` (`toHaveCount(1)` — merge by id, one row), `sync-break-on-
+ existing.spec.ts:38-46`, `channel-groups.spec.ts:145-147`, `cleanup-actionable.spec.ts:97`,
+ `channel-work.spec.ts:135-144`, `jobs.spec.ts:28-40`, `jobs-filters.spec.ts` — row filters
+ by text/kind; unaffected by order.
+
+## Verification
+
+1. After each of commits 1–3: tsc in the six packages; `pnpm -C common test`; editor units
+ (`jobRows.test.ts` included).
+2. Grep gates after commit 3, over `editor/ common/` excluding `node_modules`, `.next`,
+ `CHANGELOG.md`, `plans/`:
+ - `/jobs/active|/jobs/queue` → only `editor/next.config.ts` (two `source`s + the comment)
+ and `editor/e2e/navigation.spec.ts`; the four comment sites named in commit 2 are
+ rewritten.
+ - `ActiveJobsLive|QueueViewLive|buildQueueView|RunningJobsListItem|RunningJobsTask|QueueSlotView|QueueViewPayload|QueueGroupView` → 0.
+ - `editor/app/jobs/active/page.tsx`, `editor/app/jobs/queue/`, `editor/app/api/jobs/queue/`,
+ `editor/app/jobs/components/ActiveJobsLive.tsx` do not exist; `editor/app/jobs/active/
+ buildActiveJobs.ts` does.
+ - `label: "Active"|Activity,|"running"` in `lib/nav.ts` → 0; `runningJobs|metric=` in
+ `layout.tsx` → 0; `metric` in `SidebarBadges.tsx` → 0; `runningJobs` in
+ `api/pulse/route.ts` → still present (kept on purpose).
+ - `revalidatePath("/jobs/queue")` → 0; `revalidatePath("/jobs")` count unchanged (16).
+ - `readLastLogLine(` → only `buildActiveJobs.ts`, and its one call site is under `stuck`.
+ - `scheduler.complete(|getScheduler().complete(` under `editor/app` → only the heal loop in
+ `buildActiveJobs.ts`.
+ - `Active jobs for |No active queues|Reap all stuck|Copy diagnostics` → 0 under `editor/`.
+ - `gap-0.5` → present in `JobProgressBars.tsx` (the task wrapper) and NOT in `JobsTable.tsx`'s
+ Status cell.
+ - `^import` in `jobRowView.ts` → type-only; in `jobRows.ts` → type-only plus `jobKinds` and
+ `ulid`; `from ".*buildActiveJobs"` in any `"use client"` file → every hit is `import type`.
+ - the render-path guard: `pnpm -C common exec tsx --test controller/noCorpusWalkInRenderPaths.test.ts`.
+3. e2e once after commit 4, detached (`cd editor && setsid nohup sh -c 'pnpm e2e -- <specs>;
+ echo exit=$?' > $CLAUDE_JOB_DIR/tmp/e2e.log 2>&1 < /dev/null & disown`):
+ `jobs-active-order.spec.ts jobs-reorder.spec.ts jobs-batch-tasks-drain.spec.ts
+ workers.spec.ts parakeet-partial.spec.ts auto-queue.spec.ts queue.spec.ts queues.spec.ts
+ navigation.spec.ts dashboard.spec.ts widget.spec.ts jobs.spec.ts jobs-filters.spec.ts
+ jobs-channel.spec.ts jobs-retry.spec.ts cancel.spec.ts job-stream-cancel.spec.ts
+ bulk-actions.spec.ts channels-actions.spec.ts digest.spec.ts channel-work.spec.ts
+ channel-groups.spec.ts cleanup-actionable.spec.ts sync-deep.spec.ts
+ sync-break-on-existing.spec.ts backfill.spec.ts disk-space.spec.ts
+ audio-check-scenarios.spec.ts pulse.spec.ts auto-refresh.spec.ts perf-budget.spec.ts`.
+ Port fallback `PORT=3111 EXPORT_PORT=3110 OLLAMA_STUB_PORT=11535`; never kill anything.
+4. **No editor boot against `transcripts/`, nothing written under it.**
+5. Manual (optional, `PORT=3021 pnpm dev:test`): `/jobs` shows the lane strip, the health line
+ with five figures, then one table; start a slow sync → its row is first with progress bars
+ in the Status cell and "next in line" on a queued sibling; cancel it → the row reads
+ `cancelled` within a second and stays until the tail carries it; `/jobs/active` and
+ `/jobs/queue` land on `/jobs`; the sidebar's Machine group has no Active entry; the
+ dashboard's Pipeline band draws the same rows; `GET /api/test/stuck-job?queue=q` then
+ `/jobs` → a `stuck · possibly-stalled` badge, the last log line in the Log-size column,
+ Force-release on the row and Reap stuck on the health line.
+
+## Commit 4 — `plans: slice 8c shipped, and the docs say so`
+
+- `editor/CHANGELOG.md` `[Unreleased]` first bullet, house voice (`:4` is the model): **Jobs
+ is one list.** `/jobs`, `/jobs/active` and `/jobs/queue` were three pages over three shapes
+ — the directory listing, the registry's running work with its progress bars, and the
+ scheduler's slots with their stuck reasons — and the same job was drawn three ways or hidden
+ by one page's filter. `/jobs` is one table now: **one row per job**, the live head first
+ (running, then queued in queue order with *next in line* / *2nd in line*, then anything
+ that finished in the last half-minute) and the paged history below it, refreshed as before.
+ A running row carries its per-task progress bars, its ETA and its *Transcripts: n / m* inside
+ the Status cell; a queued row its Promote / ↑ / ↓; a stuck slot its `stuck · reason` badge,
+ the last line of its log and **Force-release**, with **Reap stuck** on the health line above
+ (active queues · running · queued · stuck · workers) and the lane strip above that. The
+ kind/status/search filters apply to every row, live ones included. **The scheduler's drift
+ check rides the live payload now**: every surface that draws it — this page, the dashboard,
+ the monitor widget, `/api/jobs/active` — frees a running slot whose record is finished or
+ gone the moment it sees one, after drawing it once. The channel, video, build and operation
+ pages' *Active jobs* cards come off the same builder, so they show the progress bars they
+ used to drop. `/jobs/active` and `/jobs/queue` redirect; the sidebar's *Active* entry and
+ its running-count badge are gone (*Jobs* still counts running + queued); `/api/jobs/active`
+ never moved. **Nothing on disk changes.**
+- `plans/editor-operations-ia.md:68` "twelve" → "eleven"; `:73` → "Jobs *(Active and Queue
+ folded in, 2026-08-30)*, Workers, …"; `:168-175` slice-8 bullet → "one page and one LIST"
+ with "**8c SHIPPED** (`<sha>` → `<sha>`; see 'Slice 8c, as shipped')"; new
+ "## Slice 8c, as shipped" after the 8a+8b section: the four commits; findings 1–13; the three
+ operator decisions (a row is one job; one list, no mode; every consumer heals); what stayed
+ (`buildActiveJobs.ts`'s directory, `RunningJobsList` as the five pages' renderer, the
+ widget's `JobRow`, `PulsePayload.runningJobs`, `/api/jobs/active`, `STUCK_AGE_MS`, the
+ reorder spec's effect assertion, the `gap-0.5` climb).
+- `plans/STATE.md:6` "Last updated" prepend (previous entry demoted to "Previously:");
+ `:286-291` #12 → struck through, "**DONE 2026-08-30**, `<sha>` → `<sha>`. Plan:
+ `editor-ia-slice-8c.md`; outcome: 'Slice 8c, as shipped'; the design question was answered
+ 'a row is one job'"; "Recommended next": the remaining editor candidates are slice 5 (Sites),
+ the transcode band, and Phase 6.
+- `plans/FACTS.md`: `## Verified 2026-08-30 — editor IA slice 8c seams (/jobs is one list)`
+ appended after the 8a+8b section: the three shapes and their fields; the live-row readers
+ (finding 1's list with line numbers); the backwards type import; who called the heal before
+ and who calls it now; the badge's consumers; today's queued order vs the scheduler's; the
+ `nth(1)` table; `forceRelease`'s no-op contract and its `endedAt`; `jobKinds.ts` and
+ `ulid.ts` having no imports; `builtAt` and why.
+- Memory: left to the reviewer (`ia-slice-8c-shipped.md` + a `MEMORY.md` line; amend
+ `ia-slice-8ab-shipped`'s "slice 8c NOT done").
+
+## Out of scope
+
+- Replacing `RunningJobsList` on the channel, video, build and operation pages with the table.
+ The dashboard's double poll (`DashboardCockpit.tsx:39-44` + the embedded table). The widget's
+ own `JobRow`. `STUCK_AGE_MS`, `RECENT_MS` as settings. `listAllJobs`'s paging, retention and
+ `pruneJobLogs`. `/jobs/[id]` and `JobLogTail`. `PulsePayload`'s wire shape. A `data-task-id`
+ hook for the `gap-0.5` climb (only if it rots). The `bookmarks` surfaces the CHANGELOG
+ mentions — already gone from the tree.
+
+## Handoff — the cadence
+
+On approval, Fable does not implement (memory `plan-then-opus-implements`): write the plan to
+`plans/editor-ia-slice-8c.md`, commit it alone, then spawn one `general-purpose` agent,
+`model: "opus"`, with: the plan path, the shell caveats (the Bash tool runs zsh here — quote
+every glob, `--include='*.ts'`; no word-splitting of `$VAR` commands; POSIX loops inside
+`sh -c`; `git commit -F <file under $CLAUDE_JOB_DIR/tmp>`; quote `[slug]`/`[id]` paths; stage
+by explicit path, never `git add -A` — another session commits into this checkout), never
+boot against `transcripts/`, e2e detached (memory `e2e-run-detached`), tmp under
+`$CLAUDE_JOB_DIR/tmp`, the two trailer lines, and the report contract (shas + one line each;
+exact gate outputs; e2e per spec with retries; every divergence and why; anything undone).
+Fable reviews on return (`git log --oneline a609566..`; `jobRowView.ts`, `jobRows.ts` + test,
+`buildActiveJobs.ts` hunks — the header, `rowsForRecords`, `liveJobRows`, `listJobRows`,
+`reconcileSlots` call and the heal loop, `stuckJobIds`; the five hand-map sites;
+`RunningJobsList.tsx` diff-against-rename of the bars; `JobsTable.tsx`; `LaneStrip.tsx` and
+`JobProgressBars.tsx` diff-against-move; `jobs/page.tsx`; `PipelineBand.tsx`; `nav.ts`,
+`layout.tsx`, `SidebarBadges.tsx`, `next.config.ts`; `jobs/actions.ts`; the deletions; the
+eight repointed specs; re-runs grep gates + `pnpm -C common test` + editor units, not e2e),
+sends fixes via SendMessage, and reports.