Archilyzer · Source

archilyzer

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

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:
Aplans/editor-ia-slice-8c.md | 850+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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.