Archilyzer · Source

archilyzer

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

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