commit 45b6eada6508600daf7a3e37d26adb6e660d4fbd
parent 30f1621da1144c19431f08dd6c76efa5be4e7c08
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sat, 29 Aug 2026 17:48:35 -0400
plans: slice 8a+8b planned
Sync becomes a catalogued operation with a scope and a trigger; its page is
/operations/sync with the whole syncScheduler block below the schedule; the
worker list is configured on /workers; the lane note strings become one.
Six commits after this one; the plan names every consumer and every spec.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Diffstat:
1 file changed, 655 insertions(+), 0 deletions(-)
diff --git a/plans/editor-ia-slice-8ab.md b/plans/editor-ia-slice-8ab.md
@@ -0,0 +1,655 @@
+# Editor IA slice 8a+8b — sync is an operation; its settings and the workers' live on their pages
+
+## Context
+
+**Verified read-only against `6f6c307` (clean) on 2026-08-29.** This is the Sync and Workers
+thirds of the IA doc's slice 8 (`plans/editor-operations-ia.md:168-171`); the `/jobs` +
+`/jobs/active` + `/jobs/queue` fold is a later plan of its own. The noun-model row for Sync
+(`:57`): an operation, channel-scoped and cadence-triggered; `OperationDescriptor` gains
+`scope` and `trigger`; it gets a board row and `/operations/sync` (today's `/scheduler`) and
+"keeps its own runner"; the keep-latest checks and the saved-video backup on the same
+heartbeat (`editor/app/scheduler/runTick.ts:25-27`) are Storage chores and labelled so. The
+nav end state (`:66-80`) has the Operations group down to one entry.
+
+**Decided (operator, 2026-08-29):** (A) `/scheduler` → `/operations/sync`; `scope`/`trigger`
+on the descriptor; a `sync` catalog entry; the board's SyncRow becomes a rail row; a 307
+redirect; the nav's interim "Schedule" entry goes; the Storage chores are labelled. (B) **The
+whole `syncScheduler` block — headline and advanced fields — is the sync operation's
+settings form on its page** (slice 3's rule), declared as `settingsBlock: "syncScheduler"`,
+with `saveSchedulerSettingsAction` its one writer and `/settings` preserving the block the way
+it preserves `digest`/`diarization`. (C) `WorkersField.tsx` moves to `/workers` with its own
+action writing only `settings.workers`. (D) The lane NOTE strings become one.
+OUT: the `/jobs` fold; any change to tick logic, `SchedulerRun`, `syncSchedulerState` or a
+settings key; `Field.tsx`/`CardField`; moving `editor/app/scheduler/{runTick,heartbeat,status,
+auth,actions,intervalPresets}.ts`.
+
+**What the census found — every place the IA doc's wording cannot be implemented literally,
+and what to do instead:**
+
+1. **"Keeps its own runner" cannot be `runner`.** `OperationDescriptor.runner` is typed
+ `AutoQueueKind` (`common/lib/operations.ts:1382`; `autoQueueState.ts` = `"transcription" |
+ "download"`), `operations.test.ts:1245-1262` pins it `undefined` for every other id, and
+ `operations/[id]/page.tsx:138` feeds it straight into `RunnerOperationView`. The heartbeat
+ is not an auto-queue runner. **Instead:** `trigger: "cadence"` is the switch — the page
+ builds the scheduler console as a server slot when `op.trigger === "cadence"` (the
+ `operationSettings`/`laneSettings`/`channelWork` slot pattern, `page.tsx:152-214`) and
+ `OperationDetail` renders it where it would otherwise draw `NoConsoleView`
+ (`OperationDetail.tsx:113-115`). `runner` stays `undefined` on sync; the test says why.
+2. **Sync has no band, so "a real rail row" is composed, not folded.** `OperationRail` draws
+ one `RailRow` per `OperationBand` (`OperationRail.tsx:74-81`) and a band's populations are
+ videos (`band.ts`); the sync row's figures are channels (`syncRow.ts:8-22`).
+ `buildOperationBands` (`buildBands.ts:159-193`) must not gain a sync band — "coverage
+ unknown" over a hollow outline for a thing with no per-video coverage. **Instead:**
+ `OperationRail` takes `sync?: SyncRowView` and renders a `SyncRailRow`
+ `<li data-operation="sync">` first in the same `<ul>`, same link/dot/state/figures
+ anatomy; the board's `SyncRow` (`OperationsBoard.tsx:55-130`) is deleted. Still SSR, not
+ polled (`:65-67` says why; `useOperationsStatus.ts`'s 3 s poll is untouched).
+3. **`group` is required and exhaustive in three places.** `OperationGroup` (`:322`) is
+ switched by `groupLabel` (`:339`) and `groupActionLabel` (`:1455`) and keyed by
+ `GROUP_STAGES: Record<OperationGroup, readonly StageId[]>`
+ (`channels/[slug]/lib/stageStatus.ts:85-90`); `operations.test.ts:1296-1305` requires a
+ group. Sync is not `media` (download + transcode's `/channels` column group).
+ **Instead:** a fifth group `"sync"` (label "Sync", action label "syncs"), **not** added to
+ `OPERATION_GROUP_ORDER` (`:332-337` — the `/channels` column order and the transit line's
+ station order; `stageOrder.test.ts` pins the derived list), and `GROUP_STAGES.sync = []`
+ with the comment that sync's channel surface is the hand-listed `playlist` bookend
+ (`JOB_KIND_TO_STAGE` `sync: "playlist"`, `stageStatus.ts:107`) and `:80-84` says not to
+ finish that derivation. `rank()` in `channels/page.tsx:81-85` and
+ `videoOperations.ts:122-125` give an unknown group the last rank and never see `sync`.
+4. **"Three copies of the lane NOTE strings" are two copies and a paraphrase.**
+ `railStates.ts:48-56` and `buildActiveJobs.ts:368-375` are byte-identical tables (`"no
+ operation switched on"`, `"sweep armed, lane paused"`, `"lane paused"`, `"no sweep
+ armed"`); `SweepLane.tsx:215-222` is a sentence over the same `deriveLaneState`.
+ `buildActiveJobs.ts:356-363` also re-states `deriveLaneState`'s precedence (slice 7's
+ out-of-scope note, `editor-ia-slice-7.md:359-361`). **Instead:** one `sweepLaneNote()`
+ beside `deriveLaneState` in `laneState.ts`; both tables and the precedence copy deleted;
+ the sweep panel keeps its prose and says in a comment it is the long form. No spec pins
+ any of the four strings (grep verified).
+5. **"Wherever they show" is smaller than it sounds.** `keptChecksQueued` /
+ `savedVideoBackupQueued` (`runTick.ts:37-40`) reach no UI: `SchedulerView.tsx:44-67` reads
+ `queued`/`skipped`/`reason`; `SchedulerRun` records neither. They show as (a) the
+ `skipped` reasons `kept-check: …` (`runTick.ts:172`) and the pseudo-slug `(saved-video
+ backup)` (`:196`), (b) the "Keep-latest check interval" field (moving with the block,
+ commit 3), (c) `/saved-videos` "the sync scheduler runs the backup"
+ (`SavedVideosControls.tsx:86-87`). Recording chores in `SchedulerRun` is a state-shape
+ change and out of scope. **Instead:** label those surfaces, add a "storage chores" clause
+ to the Run-now message from the POST body, one paragraph on the console. No tick logic
+ changes.
+6. **The catalog is walked by a test that pins its length.** `pauseGates.test.ts:100-101`
+ asserts `ids.length === 7` and every id in an expected map; `pauseGates.ts:60` says "all
+ seven ids". A `sync` entry needs `sync: null` and `8`.
+7. **A worker tag would grow a `sync` entry.** `settings/page.tsx:82-87` builds `knownTags`
+ from `operationCatalog().map(o => o.id)`; a tag names a per-video operation a delegate
+ can take a UNIT of (`lanes.ts:264-296`). The builder moves to `/workers` (commit 4) and
+ filters on `scope === "video"` — the first consumer that uses `scope` for the reason the
+ field exists.
+8. **`editor/app/scheduler/` is the runner, not the page.** The `/actionable` precedent
+ deleted only `page.tsx` and left the lib in place. `runTick.ts`, `heartbeat.ts`,
+ `status.ts`, `auth.ts`, `actions.ts`, `intervalPresets.ts` are imported by
+ `instrumentation.ts`, both API routes (`api/scheduler/{status,tick}/route.ts`),
+ `syncRow.ts:2`, `ChannelForm.tsx`, `SettingsForm.tsx:20`. They stay. Only `page.tsx` is
+ deleted; the four view components + `cadence.ts` move under
+ `operations/components/sync/` by `git mv` (review is diff-against-rename). A route
+ directory with no `page.tsx` is not routable; the 307 answers `/scheduler`.
+9. **Two writers of the headline scheduler fields become one.** `SettingsForm.tsx:314-464`
+ ("Sync scheduler", 14 fields) and `SchedulerSettingsForm.tsx` (3 of the same names) both
+ wrote `syncScheduler` — the `settings/actions.ts:172-226` parse and
+ `saveSchedulerSettingsAction` (`scheduler/actions.ts:111-152`, already `intOrKeep` +
+ whole-object preserve). Decision (B) makes `saveSchedulerSettingsAction` the only
+ writer, exactly the slice-3 shape; `/settings` preserves the block. The keep-latest
+ interval is a Storage chore's knob but a field of this block: it moves with it and its
+ hint says whose knob it is.
+
+**Hazards carried through every commit:**
+
+- **Client graph.** `common/lib/operations.ts` reaches `node:fs`; every `"use client"` file
+ imports from it `as type` only. `SyncRowView` is a type import from `../syncRow` (a server
+ module; `OperationsBoard.tsx:5` already does this). The sync row's `label` reaches the
+ client as a prop. `WorkersField.tsx:3-4`'s two `import type` lines are load-bearing
+ (`workers.ts:15-21 → transcriptionApps.ts:13 → paths.ts → node:fs`) — the tag vocabulary
+ is built on the server and passed down, as today.
+- **`"use server"` files export functions only** (`digestActions.ts:37-48`); a `type` export
+ is fine (`workers/actions.ts:16` already has one).
+- **The guard** `noCorpusWalkInRenderPaths.test.ts` bans `listChannelStatsFromDisk |
+ buildBackfillSweepPlan | buildDigestSweepPlan` textually under `editor/app`. Nothing new
+ mentions them. `buildSchedulerStatusPayload` (`scheduler/status.ts`) is `listChannelConfigs`
+ + `readSchedulerState` — configs, not a walk — and now runs on every operation page (for
+ the rail row) and the board.
+- **`section[data-lane]` count is pinned at 0 on the board** (`auto-queue.spec.ts:1108`).
+ The sync row is an `<li>`; the console's `<section>`s carry no `data-lane`.
+- **Playwright name matching is case-insensitive substring unless `exact`.** Use
+ `getByRole("heading", { name: "Sync", level: 1 })`. `getByLabel("Default interval unit")`
+ does not match "Default full sweep interval unit" (different substring) — verified.
+- **`role="status"` ×3 on `/workers`** (`WorkersView.tsx:132,141` + the config form) and
+ **`form[data-settings-block]` count** — every new assertion scopes to
+ `form[data-settings-block="workers"]` / `form[data-settings-block="syncScheduler"]`.
+ `operation-settings.spec.ts:239-241` pins count **0** on `/operations/transcode` and
+ `/download` — unaffected.
+- **`getByLabel("worker 1 name")` vs `aria-label="worker ${w.name}"`** (`WorkersView.tsx`):
+ substring, but "worker 1 name" is a substring of no worker-name label. `getByRole
+ ("listitem")` in `workers.spec.ts` is unaffected — `WorkerCard` rows are `<div>`s.
+- **`writeSettings` in e2e replaces the file wholesale** (`helpers.ts:66-69`) and
+ `getSettings()` re-reads on every call. Both new actions read-then-write like every block
+ action (`saveSchedulerSettingsAction` is the model).
+- **`revalidatePath("/operations/sync")`** — the literal, not `"/operations/[id]", "page"`:
+ only the sync page changed.
+
+## Step 0 — the plan on disk
+
+Write this file verbatim to `plans/editor-ia-slice-8ab.md`; commit alone:
+`plans: slice 8a+8b planned`.
+
+## Order: six commits after the plan
+
+1. **`common: sync is a catalogued operation, with a scope and a trigger`** — `scope`/`trigger`,
+ `SYNC_OPERATION`, group `sync`, `ExternalOperation` de-duplicated, `GROUP_STAGES.sync`, tests.
+2. **`operations: sync has a rail row and a page, and /scheduler redirects`** — `SyncRailRow`,
+ the cadence console slot, `scheduler/page.tsx` deleted, components moved, nav entry
+ deleted, 307, Storage chores labelled, specs repointed.
+3. **`sync: its settings live on its page, and one action writes them`** —
+ `settingsBlock: "syncScheduler"`, the full form on `/operations/sync`, the `/settings`
+ fieldset and parse deleted, `cadence-ui.spec.ts` repointed + one new test.
+4. **`workers: the worker list is configured where the workers are`** — `WorkersField` moved,
+ `WorkersConfigForm` + `saveWorkersAction`, the `/settings` fieldset and parse deleted,
+ three specs moved.
+5. **`lanes: one note for why a lane is not working`** — `sweepLaneNote`; two tables and one
+ precedence copy deleted; unit test.
+6. **`plans: slice 8a+8b shipped, and the docs say so`** — CHANGELOG, IA doc, STATE, FACTS,
+ SCHEDULED_SYNC.md, memory.
+
+Gates after each of 1–5: `pnpm -C <pkg> exec tsc --noEmit` for `common editor export homepage
+umtool mcp`; `pnpm -C common test` (876 at plan time — re-measure before commit 1 — plus the
+new cases); `pnpm -C editor exec tsx --test "app/**/*.test.ts"` (94 + `laneState.test.ts`).
+e2e once after commit 6, detached (memory `e2e-run-detached`).
+
+---
+
+## Commit 1 — `common: sync is a catalogued operation, with a scope and a trigger`
+
+**`common/lib/operations.ts`.** Two unions beside `OperationDispatch` (`:243`):
+
+```ts
+// WHAT AN OPERATION ACTS ON, and WHAT MAKES IT RUN. Every media-derived
+// operation is per-video and backlog-driven: it has a state per video, and its
+// work list is whichever videos lack it. Sync is the one exception the IA doc
+// names (plans/editor-operations-ia.md, "Where the noun breaks"): a CHANNEL has
+// a sync state — last synced, next due — and nothing runs it but a cadence. A
+// surface that draws per-video things (a band, a video-page panel, a channel
+// column, a worker tag) filters on `scope`, never on the id.
+export type OperationScope = "video" | "channel";
+export type OperationTrigger = "backlog" | "cadence";
+```
+
+`OperationDescriptor` (`:1369-1387`) gains `scope: OperationScope; trigger: OperationTrigger;`
+(required). The `runner`/`appliesTo` comments move here from `ExternalOperation`
+(`:1300-1317`) and **`ExternalOperation` becomes**
+`Omit<OperationDescriptor, "dispatch" | "settingsBlock"> & { dispatch: "external" }` — the
+duplicate field list at `:1294-1318` goes, so the two new fields are declared once. `runner`'s
+comment gains: "typed to the auto-queue kinds on purpose; the sync heartbeat is a runner in
+the IA doc's sense but not one of these, which is why `/operations/sync` is chosen off
+`trigger`." Each of the three externals (`:1320-1367`) declares `scope: "video", trigger:
+"backlog"`; `operationCatalog()` (`:1389-1405`) maps registry entries with `scope: "video" as
+const, trigger: "backlog" as const` — the registry is per-video by construction
+(`Operation.state(probe)` is a per-video probe).
+
+New, before `EXTERNAL_OPERATIONS`, first in the catalog:
+
+```ts
+// THE SYNC OPERATION. Catalogued so the board, the rail and /operations/sync
+// come off the same table as everything else, and NOT in EXTERNAL_OPERATIONS —
+// that list is the media-derived pipelines the rail draws bands for (editor
+// buildBands.ts, EXTERNAL_BAND_IDS); sync has no per-video population. The lane
+// is the shape download declares: syncAction runs through runPipelineAction on
+// the per-platform download queue (pipelineActions.ts, downloadQueueKey).
+export const SYNC_OPERATION: OperationDescriptor = {
+ id: "sync",
+ label: "Sync",
+ hint: "Noticing new videos: re-reading each channel's listing on its cadence and fetching what is new. Dispatched by the sync scheduler's heartbeat, and by hand per channel or all at once. A corpus that has stopped noticing new videos is not idle — it is broken.",
+ group: "sync",
+ shortLabel: "Sync",
+ costBasis: "one listing fetch per channel, over the network",
+ lane: { queueKey: "download:<platform>", contendsFor: "network" },
+ dispatch: "external",
+ scope: "channel",
+ trigger: "cadence",
+};
+```
+
+`operationCatalog()` returns `[SYNC_OPERATION, ...EXTERNAL_OPERATIONS, ...OPERATIONS.map(…)]`
+— upstream first; the `:1366-1368` comment ("what a future scheduler enumerates") is
+rewritten: the scheduler is in it. `OperationGroup` gains `"sync"`; `groupLabel` `case
+"sync": return "Sync"`; `groupActionLabel` (`:1455`) `case "sync": return "syncs"`.
+`OPERATION_GROUP_ORDER` unchanged, plus one comment line (finding 3).
+
+**`editor/app/channels/[slug]/lib/stageStatus.ts:85-90`** — `sync: []` with finding 3's
+comment. `stageOrder.test.ts` stays green untouched.
+
+**`common/lib/pauseGates.ts:60`** "all seven ids" → "all eight ids — sync included, and its
+answer is null: the scheduler's `enabled` is its own switch, not a lane gate".
+**`pauseGates.test.ts:88-104`**: `sync: null` in `expected`; `assert.equal(ids.length, 8)`.
+
+**`common/lib/operations.test.ts`:** `:1223-1233` add `"sync"`; the runner test (`:1245`)
+adds `assert.equal(runners.get("sync"), undefined)` with finding 1's reason; `:1320-1329`
+add `assert.equal(operationGroup("sync"), "sync")`; the `settingsBlock` test (`:1266`) is
+untouched here (sync's block arrives in commit 3). New test after `:1329`:
+
+```ts
+test("`scope` and `trigger`: sync is the one channel-scoped, cadence-triggered operation", () => {
+ // The two fields exist so a per-video surface can EXCLUDE sync by a declared
+ // fact rather than by its id. Every other entry is per-video and backlog-fed.
+ for (const op of operationCatalog()) {
+ assert.equal(op.scope === "channel", op.id === "sync", `${op.id} scope ${op.scope}`);
+ assert.equal(op.trigger === "cadence", op.id === "sync", `${op.id} trigger ${op.trigger}`);
+ }
+ assert.equal(operationCatalog()[0].id, "sync"); // upstream first
+});
+```
+
+**Every consumer of the catalog, and what each does with a `sync` entry** (verified):
+
+| consumer | today | after |
+|---|---|---|
+| `operations.ts:1412-1438` `operationApplies/Label/Group/ShortLabel/CostBasis` | `find` by id | unchanged; `operationLabel("sync")` = "Sync" |
+| `pauseGates.ts:63` `pauseLaneFor` | walks catalog | `null` for sync (no runner; queue key is neither sweep's) — test pins it |
+| `operations/[id]/page.tsx:60-62,131` `descriptorFor` | 404 gate | `/operations/sync` resolves; commit 2 reads `op.trigger` |
+| `settings/page.tsx:84` worker tags | every id | moves to `workers/page.tsx`, filtered `scope === "video"` (commit 4) |
+| `channels/page.tsx:77-107` `pipelineColumns` | Map lookup; ids = `EXTERNAL_BAND_IDS + allOperations` | unchanged — sync never in `ids`; comment gains "sync is catalogued but channel-scoped, so no column" |
+| `channels/[slug]/lib/channelFlow.ts:191` | Map lookup over `buildChannelBands` ids | unchanged, same reason |
+| `operations/lanes.ts:74-81` `sweepLaneIdFor` | queue-key switch | `null` for sync |
+| `buildBands.ts:157-193` | `EXTERNAL_BAND_IDS` + `allOperations` ids | unchanged — no sync band; comment at `:87-99` gains one sentence |
+| `controller/videoOperations.ts` (slice 6) | walks `OPERATIONS` | unchanged; header gains "sync is `scope: "channel"` — a per-video reader has nothing to ask it" |
+| `allOperations` / `backfillLaneOperations` / `countBackfillWork` / arbiter / `SweepScope` | `OPERATIONS` | untouched |
+
+---
+
+## Commit 2 — `operations: sync has a rail row and a page, and /scheduler redirects`
+
+**`editor/app/operations/syncRow.ts`:** header `:4-6` rewritten (the row is the rail's; the
+schedule is `/operations/sync`); `SyncRowView` gains `id: string; label: string;` from
+`SYNC_OPERATION.id` / `operationLabel(SYNC_OPERATION.id)`.
+
+**`OperationRail.tsx`:** prop `sync?: SyncRowView`. Inside the `<ul>` (`:73-82`), before the
+band rows: `{sync && <SyncRailRow sync={sync} selected={selectedId === sync.id} />}`.
+
+```tsx
+// THE ONE ROW THAT IS NOT A BAND. Sync is channel-scoped and cadence-triggered
+// (the descriptor says so: scope/trigger), so it has no reachable/blocked
+// population to draw and a StateBand here would be a hollow outline over a
+// fact that does not exist. Same anatomy as every other row — the name is the
+// link, the dot is aria-hidden, the state word stands alone — and its figures
+// are channels. First because it is upstream of everything else. RENDERED FROM
+// THE SSR PAYLOAD, not the 3-second poll: a cadence measured in minutes does
+// not need one, and a second poll would buy a spinner nobody asked for.
+function SyncRailRow({ sync, selected }: { sync: SyncRowView; selected: boolean })
+```
+
+`<li data-operation="sync" data-scope="channel" className={RailRow's, :103-105}>`;
+`<Link href={`/operations/${sync.id}`} className={RailRow's, :114-116}>` + dot + `{sync.label}`;
+in the band's slot `<span className="text-xs text-muted-foreground" aria-label="Sync scope">per
+channel, on a cadence</span>`; then the state word + note **moved verbatim** from
+`OperationsBoard.tsx:73-86`; then the figures span verbatim from `:107-126` ("channels on a
+cadence", "due now", "overdue"). `OperationsBoard.tsx`: delete `SyncRow` (`:55-130`) and the
+`Link`, `LANE_*` imports; `<OperationRail … sync={sync} />`.
+
+**`OperationDetail.tsx`:** props gain `sync: SyncRowView` (to `OperationRail`) and
+`cadenceConsole?: ReactNode`; `:113-115` becomes
+`: cadenceConsole ? <>{cadenceConsole}{operationSettings}</> : <NoConsoleView … />` — comment:
+built on the server off `op.trigger`, never off the id; `NoConsoleView` stays the
+fallthrough for a registered operation nothing drives. (`operationSettings` is rendered
+here so commit 3's form has a slot; today it is drawn only in `SweepOperationView`.)
+
+**`operations/[id]/page.tsx`:** `:143-146` `Promise.all` gains `buildSyncRow()`; after `:173`:
+
+```ts
+// THE CADENCE CONSOLE, off the descriptor's trigger. A cadence-triggered
+// operation has no backlog to sweep and no runner to start — its console is
+// its schedule. `runner` cannot say this (it is typed AutoQueueKind, and the
+// heartbeat is not one), so `trigger` does. ONE such operation exists; a
+// second would need the descriptor to name its console, not this file to
+// branch on an id. buildSchedulerStatusPayload runs ONLY in this arm.
+const cadenceConsole =
+ op.trigger === "cadence" ? (
+ <SyncConsole key="sync" initial={await buildSchedulerStatusPayload()} />
+ ) : null;
+```
+
+passed as `cadenceConsole={cadenceConsole} sync={sync}`. `generateMetadata` already titles
+it "Sync".
+
+**`git mv editor/app/scheduler/components/SchedulerView.tsx
+editor/app/operations/components/sync/SyncConsole.tsx`** (export `SyncConsole`; every label
+byte-for-byte). Additions: the intro paragraph from `scheduler/page.tsx:20-33` above the
+status bar (drop "and toggle the headline global controls … here too" and the `/settings`
+pointer — commit 3 moves those fields onto this page); the `runNow` message (`:56-61`)
+reads `keptChecksQueued`/`savedVideoBackupQueued` and appends
+`` · storage chores: ${k} keep-latest check${k===1?"":"s"}${backup ? ", saved-video backup" : ""} ``
+when either is non-zero; under Recent ticks: `<p className="text-xs text-muted-foreground">Two
+Storage chores ride this heartbeat and are not sync: the keep-latest deletion check (its
+interval is in the settings below) and the saved-video backup (Saved videos).</p>`. Header
+comment notes the two polls on this page (5 s status here, 3 s auto-queue from the rail)
+and why they are not merged. **`git mv`** likewise `SchedulerSettingsForm.tsx`,
+`ChannelCadenceEditor.tsx`, `BulkCadenceBar.tsx`, `cadence.ts` into the same directory
+(imports become `../../../scheduler/actions` etc.). **`git rm editor/app/scheduler/page.tsx`.**
+`runTick.ts:1` gains a header line: this directory is the sync operation's runner — the
+heartbeat, the tick, the status payload; its page is `/operations/sync` (rendered by
+`operations/[id]`), and `/scheduler` redirects there.
+
+**Storage chores, labelled** (no logic change): `runTick.ts:22-27` imports get "STORAGE CHORES
+riding this heartbeat because it is the one timer the editor has. Neither is sync (IA doc,
+the Sync row): the keep-latest check consumes outputs, the backup copies them"; the comments
+at `:154-158` / `:181-183` open "Storage chore:"; `:172` reason → `` `storage chore
+(keep-latest check): ${result.error}` ``; `:196` slug → `"storage chore (saved-video
+backup)"`; `SchedulerTickResult` field comments (`:37-40`) say "Storage chore".
+`SavedVideosControls.tsx:86-87` → "the sync heartbeat runs this Storage chore on the cadence
+below".
+
+**Nav and redirect.** `lib/nav.ts:85-86` deleted, `CalendarClock` dropped from the import,
+the Operations entry's `keywords` (`:83`) gains `" sync schedule cadence cron"` (the palette,
+`CommandPalette.tsx:254`, still finds it). `next.config.ts:66-67` gains `{ source:
+"/scheduler", destination: "/operations/sync", permanent: false }` and the comment
+(`:48-62`) one sentence: "/scheduler was the sync operation's page before sync was
+catalogued; `/api/scheduler/*` is NOT redirected — the cron client and the console's own
+poll never moved." `HowPriorityWorks.tsx:41` → `href="/operations/sync"`.
+`scheduler/actions.ts:102,152` → `revalidatePath("/operations/sync")`.
+`api/scheduler/status/route.ts` comment → "the /operations/sync console".
+
+**Specs (commit 2):** `scheduler.spec.ts` — header `:6-12` and the `:86` comment →
+`/operations/sync`; all seven `goto`; test 1 (`:26-93`) adds `getByRole("heading", { name:
+"Sync", level: 1 })` visible and `page.locator('li[data-operation="sync"]')` contains
+"channels on a cadence". `navigation.spec.ts:108-114`: after the `/actionable` block,
+`goto("/scheduler")` → `toHaveURL(/\/operations\/sync$/)` → the "Sync" h1; comment gains
+"/scheduler was the sync operation's page before sync was catalogued".
+`auto-queue.spec.ts:1088-1093`: comment gains "and sync, the one channel-scoped row"; add
+`rail.filter({ hasText: "Sync" }).first()` visible and `rail.getByRole("link", { name: "Sync",
+exact: true })` has `href="/operations/sync"`. `:1108` and `:1166` pass unchanged.
+
+---
+
+## Commit 3 — `sync: its settings live on its page, and one action writes them`
+
+**`common/lib/operations.ts`:** `OperationSettingsBlock` (`:327`) gains `"syncScheduler"`;
+`SYNC_OPERATION.settingsBlock = "syncScheduler"`. **`operations.test.ts:1266-1280`** expected
+map gains `sync: "syncScheduler"`.
+
+**`operations/[id]/page.tsx` `settingsFormFor`** (`:71-110`) gains the arm — the exhaustive
+switch is exactly why the union grew:
+
+```tsx
+case "syncScheduler":
+ return (
+ <SchedulerSettingsForm
+ key="syncScheduler"
+ scheduler={settings.syncScheduler}
+ heartbeatSeconds={resolveHeartbeatSeconds()} // scheduler/heartbeat.ts:43, server-only
+ />
+ );
+```
+
+`OperationDetail` already renders `operationSettings` in the cadence branch (commit 2).
+
+**`operations/components/sync/SchedulerSettingsForm.tsx`** becomes the whole block: h2
+"Global controls" → **"Sync scheduler"**; the `/settings` "Advanced (…)" link (`:33-35`)
+deleted; `<form … data-settings-block="syncScheduler">` (the slice-3 forms' attribute,
+`DigestSettingsForm.tsx:41` etc.); the eleven fields it lacks moved **verbatim** from
+`SettingsForm.tsx:314-464` — `Full-sweep auto-confirm cap`, `Full-sweep shrink guard`, `Max
+concurrent syncs`, `Keep-latest check interval`, `Quiet hours start (0–23)`, `Quiet hours end
+(0–23)`, `Backoff base`, `Backoff max` (its three existing fields keep their names:
+`syncSchedulerEnabled`, `syncSchedulerDefaultIntervalMinutes`,
+`syncSchedulerFullSweepIntervalMinutes`, `syncSchedulerHeartbeatSeconds`); the
+`Field`/`DurationField`/`FULL_SWEEP_PRESETS`/`SYNC_INTERVAL_*` imports come with them. Keep
+the button **"Save controls"** and `role="status"` "Saved." / `role="alert"` — pinned. The
+keep-latest hint → "A Storage chore on the sync heartbeat, not sync: how often channels with
+a keep-latest limit are re-checked so old videos get pruned even when nothing new arrived."
+**`SyncConsole.tsx`** stops rendering `SchedulerSettingsForm` (`:130-133` + import): the
+page's settings slot draws it below the console.
+
+**`scheduler/actions.ts` `saveSchedulerSettingsAction`** (`:111-152`) parses every field of
+the block. Move `hourOrNull` from `settings/actions.ts:176-181`; keep `intOrKeep` for every
+numeric field (an absent or blank field keeps the saved value — the existing contract at
+`:117-121`, now true of the whole block); header: "THE ONE WRITER OF settings.syncScheduler.
+Reads the current file and replaces this block only; every other block is preserved
+verbatim — the contract `cadence-ui.spec.ts` holds every settings writer to."
+`revalidatePath("/operations/sync")` + `"/settings"` stays (the `/settings` pointer prose).
+
+**Deletions on `/settings`:** `SettingsForm.tsx:314-464` (the fieldset) and the now-unused
+imports (`FULL_SWEEP_PRESETS` `:20`, `SYNC_INTERVAL_MIN/MAX_MINUTES` `:14-17`, `DurationField`
+`:18` if no other user — tsc/lint decide); `settings/actions.ts:172-226` (the parse) →
+`syncScheduler: getSettings().syncScheduler,` at `:301` with the `:311-322` preserve comment
+extended ("syncScheduler: edited on /operations/sync"). `settings/page.tsx:55-77` pointer
+paragraph gains `<Link href="/operations/sync">Sync</Link>` beside the four operation links.
+
+**Specs (commit 3):** `cadence-ui.spec.ts:104-138` → `goto("/operations/sync")`, `const form =
+page.locator('form[data-settings-block="syncScheduler"]')`, `form.getByLabel(…)` for the four
+fields, `form.getByRole("button", { name: "Save controls" })`, `await
+expect(form.getByRole("status")).toHaveText("Saved.")` before the poll. `:140-171` (the
+unrelated `/settings` save) is **unchanged** and now passes by preservation rather than by
+re-parse — its comment says so. New test after it, in `cadence-ui.spec.ts`:
+`"the sync page's save writes its block and nothing else"` — `writeSettings({adminTitle:
+"Keep Me", …})`, goto `/operations/sync`, fill `Max concurrent syncs` = 3 in the form, Save
+controls, poll `test-settings.json` → `{ adminTitle: "Keep Me", syncScheduler: {
+maxConcurrentSyncs: 3 } }`. `scheduler.spec.ts:260-282` ("edits the global controls") passes
+unchanged — same labels, same button. `operation-settings.spec.ts:239-241` (count 0 on
+transcode/download) unaffected.
+
+---
+
+## Commit 4 — `workers: the worker list is configured where the workers are`
+
+**`git mv editor/app/settings/components/WorkersField.tsx
+editor/app/workers/components/WorkersField.tsx`** — header `:7-12` "the save action" →
+`saveWorkersAction`; nothing else (the aria-labels at `:231,240,251,260,267,277,286,301,498`
+are the contract).
+
+**New `editor/app/workers/components/WorkersConfigForm.tsx`** (`"use client"`; `import type
+{ Worker }`, `import type { TranscriptionAppDescriptor }` — both load-bearing type-only):
+
+```tsx
+export function WorkersConfigForm({ initial, apps, knownTags }: {
+ initial: Worker[]; apps: TranscriptionAppDescriptor[]; knownTags: string[];
+})
+```
+
+`useActionState(saveWorkersAction, undefined)` (`SettingsForm.tsx:37-40`); `<form
+action={formAction} data-settings-block="workers" className="flex flex-col gap-3 rounded border
+border-border p-3">`; `<h2 className="text-sm font-semibold">Configuration</h2>`; the prose
+from `SettingsForm.tsx:72-82` with its last sentence "Enable, disable and drain individual
+workers live in the list above."; `<WorkersField initial={initial} apps={apps}
+name="workersJson" knownTags={knownTags} />`; button **"Save workers"**; `<span
+role="status">Saved.</span>` / `<span role="alert">{error}</span>` (`SettingsForm.tsx:530-546`).
+Header: this form writes ONE block and nothing else on the page is a form, which is what
+lets a save here never read another block's checkbox as off.
+
+**`editor/app/workers/actions.ts`** (already `"use server"`): header `:7-14` rewritten; add
+
+```ts
+export type SaveWorkersResult = { ok: true } | { ok: false; error: string };
+// THE ONE WRITER OF settings.workers. Reads the current file and replaces this
+// block only — every other block is preserved verbatim, the contract
+// cadence-ui.spec.ts holds every settings writer to. Then the live pool: a
+// saved Enabled change is an explicit intent and takes effect now
+// (applyEnabled), exactly as the whole-object settings form did before this
+// moved here.
+export async function saveWorkersAction(_prev: SaveWorkersResult | undefined, formData: FormData): Promise<SaveWorkersResult>
+```
+
+Body: `settings/actions.ts:86-96` verbatim (parse → `sanitizeWorkers` → `validateWorkers`),
+`await writeSettings({ ...getSettings(), workers })` in `try`, `getWorkerPool().reconfigure
+(workers, { applyEnabled: true })` (`:332-334`), `revalidatePath("/workers")`.
+
+**`editor/app/workers/page.tsx`:** delete the "Configure workers" `Link` (`:13-16`) and the
+"Worker config … lives on the Settings page" sentence (`:23-28`); server reads
+`getSettings().workers`, `listTranscriptionApps()` and
+
+```ts
+// The tag vocabulary a worker can be matched on: every PER-VIDEO operation (a
+// delegate takes a UNIT of one — lanes.ts, runsOnFor) plus the resources.
+// `scope` is the filter, not the id: sync is catalogued and channel-scoped,
+// and no worker will ever be handed a channel.
+const knownTags = [...new Set([
+ ...operationCatalog().filter((o) => o.scope === "video").map((o) => o.id),
+ ...WORKER_RESOURCE_TAGS,
+])];
+```
+
+(`settings/page.tsx:82-87` was this without the filter.) Render `<WorkersConfigForm …/>`
+after `<WorkersView>`.
+
+**Deletions on `/settings`:** `SettingsForm.tsx:13` (`TranscriptionAppDescriptor`), `:26`
+(`WorkersField`), `:30-34` (`apps`, `workerTags`), `:36` destructure, `:68-90` (the fieldset).
+`settings/page.tsx:5-7` imports and `:80-87` props; the pointer paragraph gains "The
+transcription worker list is configured on <Link href="/workers">Workers</Link>, beside the
+workers it describes." `settings/actions.ts:31-35` imports, `:84-96` parse, `:329-334`
+reconfigure, `:336` `revalidatePath("/workers")` deleted; `:282` → `workers:
+getSettings().workers,` with the preserve comment extended ("workers: edited on /workers").
+
+**Specs (commit 4):** `settings.spec.ts:60-118` (three tests: migrated defaults + add, copy,
+no-enabled-workers) → **moved to `workers.spec.ts`** (same `beforeEach resetData("empty")`),
+`goto("/workers")`, `const form = page.locator('form[data-settings-block="workers"]')`,
+`form.getByRole("button", { name: "Save workers" })`, `await
+expect(form.getByRole("status")).toHaveText("Saved.")`, `form.getByRole("alert")`; the
+`page.reload()` re-check (`:83-84`) stays. `settings.spec.ts` keeps its other five tests.
+`transcription-app-migration.spec.ts:21,37` → `/workers` (header `:3-6` "the Settings page"
+→ "the Workers page"). `parakeet-partial.spec.ts:206-215` → `/workers`, "Save workers",
+form-scoped status. `workers.spec.ts:1-3` header: "plus the persisted worker list, which is
+configured here now".
+
+---
+
+## Commit 5 — `lanes: one note for why a lane is not working`
+
+**`editor/app/components/lanes/laneState.ts`**, after `deriveLaneState` (`:23-47`; no
+directive, imports only a type — safe on both sides):
+
+```ts
+// WHY A SWEEP LANE IS NOT WORKING, in words; null when it is, or when there is
+// nothing to say. ONE COPY: the operations rail (railStates.ts) and the Active
+// Jobs lane strip (buildActiveJobs.ts) carried this table twice, byte for
+// byte, and the sweep panel says the same two facts in sentences. Takes
+// deriveLaneState's input and follows its precedence, so the word and the
+// note cannot disagree about which fact wins.
+export function sweepLaneNote(input: Parameters<typeof deriveLaneState>[0]): string | null {
+ if (input.available === false) return "no operation switched on";
+ if (input.gateHeld) return input.feedRunning ? "sweep armed, lane paused" : "lane paused";
+ if (input.feedRunning || (input.activeCount ?? 0) > 0) return null;
+ return "no sweep armed";
+}
+```
+
+`railStates.ts:41-57` → build `input` once; `state: deriveLaneState(input), note:
+sweepLaneNote(input)`. `buildActiveJobs.ts:346-379` `sweepLane` → the same two calls over
+`{ available, gateHeld, feedRunning: sweeping, activeCount: inFlight }`, deleting its
+precedence copy (`:356-363`) and table (`:367-375`). `SweepLane.tsx:215-222`: comment only —
+the long form of `sweepLaneNote`'s `holding` row. New **`laneState.test.ts`** (node:test,
+`app/**/*.test.ts` glob): the four strings as literals naming the two consumers; `null` for
+feed-running and for in-flight; precedence (`available: false` beats `gateHeld`, `gateHeld`
+beats `feedRunning`); `deriveLaneState` and `sweepLaneNote` agree on `holding` ⇔ a paused
+note.
+
+---
+
+## Aria-label / URL table (old → new; which spec pins it)
+
+| old | new | pinned by |
+|---|---|---|
+| `/scheduler` | `/operations/sync` (307 from the old) | `scheduler.spec.ts` ×7, `cadence-ui.spec.ts:184`, `navigation.spec.ts:108-114` (extended) |
+| h1 "Sync schedule" | h1 `op.label` **"Sync"** | new in `scheduler.spec.ts` + `navigation.spec.ts` |
+| sidebar "Schedule" → `/scheduler` | gone | none (`dashboard.spec.ts:108-125` pins Channels/Build/Jobs/Settings only) |
+| board `Link` "Sync schedule" | rail `li[data-operation="sync"]` link **"Sync"** | new assertion in `auto-queue.spec.ts` |
+| "Run scheduler now", "Scheduler enabled/disabled", `select ${slug}`, "Edit cadence for ${slug}", "Auto-sync interval for ${slug}" (+ unit/amount), row /Slow A/, "Save", `bulk cadence`, "Bulk full sweep interval", "Apply" | **unchanged** | `scheduler.spec.ts`, `cadence-ui.spec.ts:184-215` |
+| "Enable scheduled auto-sync", "Default interval" (+ unit/amount), "Default full sweep interval", "Internal heartbeat", "Save controls", "Saved." | unchanged, now in `form[data-settings-block="syncScheduler"]` | `scheduler.spec.ts:260-282` |
+| `/settings` "Default full sweep interval", /full-sweep auto-confirm cap/i, "Keep-latest check interval unit/amount" | same labels on `/operations/sync` | `cadence-ui.spec.ts:104-138` (repointed) |
+| `/settings` worker labels (`worker N …`, "+ Local worker", "copy worker 1", /chough server URL/i, /^Device/) | **unchanged**, now on `/workers` | `settings.spec.ts:60-118` (moved), `transcription-app-migration.spec.ts`, `parakeet-partial.spec.ts:206-215` |
+| /save settings/i (for worker / sync edits) | "Save workers" / "Save controls" | the moved tests |
+| `page.locator("form").getByRole("alert")` | `form[data-settings-block="workers"]` `role="alert"` | moved test |
+| — | `aria-label="Sync scope"` on the rail row | none |
+| lane notes (four strings) | unchanged strings, one source | none |
+
+## Verification
+
+1. After each of commits 1–5: tsc in the six packages; `pnpm -C common test`; editor units.
+2. Grep gates after commit 5, over `editor/ common/` excluding `node_modules`/`.next`:
+ - `"/scheduler"` → only `next.config.ts` (the redirect source); `revalidatePath("/scheduler")` → 0.
+ - `SchedulerView\b|scheduler/components` → 0; `editor/app/scheduler/page.tsx` and
+ `editor/app/scheduler/components/` do not exist.
+ - `label: "Schedule"|CalendarClock` in `nav.ts` → 0. `SyncRow\b` → 0.
+ - `syncScheduler` in `settings/actions.ts` → exactly the one preserve line;
+ `syncScheduler(Enabled|DefaultInterval|…)` field names in `SettingsForm.tsx` → 0.
+ - `WorkersField` → only under `editor/app/workers/`; `workersJson` → only
+ `workers/actions.ts` and `WorkersConfigForm.tsx`; `sanitizeWorkers|validateWorkers|
+ getWorkerPool` in `settings/actions.ts` → 0; "Transcription workers" legend → 0.
+ - `"no sweep armed"|"lane paused"|"no operation switched on"` → only `laneState.ts` + test.
+ - `all seven` in `pauseGates.ts` → 0.
+ - `from "yt-dlp-transcript-common/lib/operations"` in any `"use client"` file under
+ `operations/components` and `workers/components` → every hit is `import type`.
+ - the render-path guard: `pnpm -C common exec tsx --test controller/noCorpusWalkInRenderPaths.test.ts`.
+3. e2e once after commit 6, detached (`cd editor && setsid nohup sh -c 'pnpm e2e -- <specs>;
+ echo exit=$?' > $CLAUDE_JOB_DIR/tmp/e2e.log 2>&1 < /dev/null & disown`):
+ `scheduler.spec.ts cadence-ui.spec.ts navigation.spec.ts auto-queue.spec.ts
+ operation-settings.spec.ts settings.spec.ts workers.spec.ts
+ transcription-app-migration.spec.ts parakeet-partial.spec.ts worker-remote.spec.ts
+ dashboard.spec.ts widget.spec.ts jobs-active-order.spec.ts backfill.spec.ts
+ saved-videos.spec.ts channels-actions.spec.ts sync-deep.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`): `/operations` rail reads Sync, Download,
+ Transcription, Digest, …; `/scheduler` lands on `/operations/sync` with the Sync row
+ highlighted, the cadence table, bulk bar, recent ticks and the full Sync scheduler form
+ below; sidebar Operations group has one entry; `/workers` shows the live list then
+ Configuration with Save workers; `/settings` has neither fieldset and points at both.
+
+## Commit 6 — `plans: slice 8a+8b shipped, and the docs say so`
+
+- `editor/CHANGELOG.md` `[Unreleased]` first bullet, house voice (`:4` is the model): **Sync
+ is an operation on the board, and the schedule is its page.** The rail's first row is Sync
+ — channels on a cadence, due now, overdue, the same four state words — and
+ `/operations/sync` is the schedule that was `/scheduler` (per-channel cadences, bulk retune,
+ recent ticks; every control unchanged) with **every Sync scheduler setting below it** — the
+ fieldset that was on Settings, saved by one button. `/scheduler` redirects; the API paths
+ and the cron client never moved; the sidebar's Schedule entry is gone. The two Storage
+ chores that ride the heartbeat — the keep-latest check and the saved-video backup — are
+ named as such on the console and on Saved videos. **Transcription workers are configured on
+ Workers now**, under the live list, with their own Save; Settings keeps the machine and the
+ site and points at both pages. The reason a lane is not working is one table. **Nothing on
+ disk changes; no setting is renamed.**
+- `plans/editor-operations-ia.md:57` Sync row → SHIPPED with findings 1 and 2; `:71` loses
+ the interim; `:168-171` → "8a+8b SHIPPED `<sha>` → `<sha>`; the `/jobs` fold is its own
+ plan"; new "## Slice 8a+8b, as shipped" after "Slice 7, as shipped": the six commits;
+ findings 1–9; the group decision; the settings-block decision (the operator moved the
+ whole block; the keep-latest interval moved with it, labelled a Storage chore's knob);
+ what stayed (the runner directory, the widget's scheduler strip, `SweepLane`'s prose).
+- `plans/STATE.md`: "Last updated" prepend; "Recommended next" DONE line; the `/jobs` fold
+ as the next IA candidate with the design question ("one table" over three sources).
+- `plans/FACTS.md`: `## Verified 2026-08-29 — editor IA slice 8a+8b seams` after the
+ slice 6 section: the catalog-consumer table, `pauseGates.test.ts:100`'s pin, the
+ `ExternalOperation` fold, two-copies-and-a-paraphrase, where the chores show, the
+ `scheduler/` directory decision, the one-writer rule for `syncScheduler` and `workers`.
+- `SCHEDULED_SYNC.md`: "Settings → Sync scheduler" → "Operations → Sync (`/operations/sync`),
+ below the schedule"; a sentence that `/scheduler` redirects.
+- Memory: `ia-slice-8ab-shipped.md` + a `MEMORY.md` line; amend `ia-slice-7-shipped` (the
+ three-copies note is done) and `ia-slice-6-shipped`'s "remaining candidates".
+
+## Out of scope
+
+- The `/jobs` fold (its own plan). Any change to `runSchedulerTick`, `SchedulerRun`,
+ `syncSchedulerState` or a settings key. The widget's "Auto-sync scheduler" strip and
+ `/api/widget/sync`. `Field.tsx`/`CardField`. Moving the `scheduler/` runner modules. A
+ `console` field on the descriptor (one cadence operation exists). `SweepLane`'s prose.
+
+## Handoff — the cadence
+
+On approval, Fable does not implement (memory `plan-then-opus-implements`): write the plan to
+`plans/editor-ia-slice-8ab.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 — no
+word-splitting of `$VAR` commands; POSIX loops inside `sh -c`; `git commit -F <file under
+$CLAUDE_JOB_DIR/tmp>`; quote `[slug]`/`[id]` paths and globs), never boot against
+`transcripts/`, e2e 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
+6f6c307..`; `operations.ts` hunks + tests, `pauseGates.test.ts`, `stageStatus.ts`,
+`syncRow.ts`, `OperationRail.tsx`, `OperationsBoard.tsx`, `OperationDetail.tsx`,
+`operations/[id]/page.tsx`, `SyncConsole.tsx` + `SchedulerSettingsForm.tsx`
+diff-against-rename, `scheduler/actions.ts`, `next.config.ts`, `nav.ts`, `runTick.ts`
+comment hunks, `WorkersConfigForm.tsx`, `workers/actions.ts`, `workers/page.tsx`,
+`settings/{page,actions,components/SettingsForm}` deletions, `laneState.ts` + test,
+`railStates.ts`, `buildActiveJobs.ts`, the moved specs; re-runs grep gates + `pnpm -C common
+test` + editor units, not e2e), sends fixes via SendMessage, and reports.