# 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 ``, 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 `` 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 ``. 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 ']` (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 · `, 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 `` 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 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; 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` — the stats/worker-names/positions context built once, `fromRecord` per record. Both `buildActiveJobsPayload` and: - **`export async function liveJobRows(filter: (j: JobRecord) => boolean): Promise`** — `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)`; `` | | `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`. **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 `

`, `LANE_DOT/TEXT/WORD`, the `log` link, `DrainJobButton`, `CancelJobButton`. Props: `{ lanes: ActiveLaneView[]; jobs: Map }` (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 `

`, one `` 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(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:** `` (`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 ``** (`: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` ` · {position === 1 ? "next in line" : `${ordinal(position)} in line`}`; `toContainText("qShared")` etc. still hold. - **Status** (`:264-270`) becomes a `
` — 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 && }`, then `{j.tasks?.length ?
`. - 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 ']` | `getByRole("row").filter({ hasText: "" })` (+ `"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 `

`, 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 -- ; 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** (`` → ``; 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**, `` → ``. 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 `; 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.