Archilyzer · Source

archilyzer

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

commit d417d8f27be891f23be89b43eb431f2b766398d8
parent f9bcb3399d8a86330baa1eea14617d791fe2fb5d
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sat, 29 Aug 2026 18:17:45 -0400

plans: slice 8a+8b shipped, and the docs say so

CHANGELOG gets the operator-facing bullet: the rail's first row is Sync,
/operations/sync is the schedule that was /scheduler with every Sync scheduler
setting below it, the workers are configured on Workers, and the two Storage
chores that ride the heartbeat are named as such.

The IA doc's Sync row becomes SHIPPED with the two corrections the census
forced — "keeps its own runner" could not be `runner`, and the board row is
composed rather than folded into a band — and "Slice 8a+8b, as shipped" records
all nine places the bullet could not be implemented literally, plus the group
decision and the operator's decision to move the WHOLE settings block.

STATE gets the dated entry and a DONE line, and names the /jobs fold as the next
IA candidate with the question to settle first: what a ROW is, before "one
table" over three sources. FACTS gets the catalog-consumer table, the
length pin in pauseGates.test.ts, the ExternalOperation fold, two-copies-and-a-
paraphrase, where the chores actually show, and the one consumer the plan did
not name (videoOperationPanels' exhaustive switch). SCHEDULED_SYNC.md points at
Operations → Sync and says the old address redirects.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Diffstat:
MSCHEDULED_SYNC.md | 11+++++++----
Meditor/CHANGELOG.md | 1+
Mplans/FACTS.md | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mplans/STATE.md | 30+++++++++++++++++++++++++++++-
Mplans/editor-operations-ia.md | 100++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
5 files changed, 215 insertions(+), 8 deletions(-)

diff --git a/SCHEDULED_SYNC.md b/SCHEDULED_SYNC.md @@ -21,8 +21,8 @@ heartbeat ──┤ │ (editor/instrumentation.ts -> he - **Internal heartbeat (recommended, no cron needed):** the editor's Next.js instrumentation hook arms an in-process timer at server startup that calls the - scheduler tick directly. Enable it by setting a cadence in **Settings → Sync - scheduler → Internal heartbeat** (see below). This is the simplest setup — + scheduler tick directly. Enable it by setting a cadence in **Operations → Sync + → Internal heartbeat** (see below). This is the simplest setup — nothing external to install. - **External cron heartbeat (fallback):** leave the internal heartbeat off (0) and have an OS cron job POST to the tick endpoint via `pnpm sync:tick`. Useful @@ -47,7 +47,10 @@ stored as `syncIntervalMinutes` in `channels/<slug>/config.json`: - *Off* — never auto-sync this channel (manual sync still works). - A concrete interval (10m / 30m / hourly / 6h / daily / …). -Global controls live in **Settings → Sync scheduler**: +All of the scheduler's settings live on the sync operation's own page — +**Operations → Sync** (`/operations/sync`), below the schedule it drives. (They +were split between that page and Settings until 2026-08-29; `/scheduler`, the +schedule's old address, redirects to `/operations/sync`.) - **Enabled** — master on/off (off by default). - **Internal heartbeat (seconds)** — cadence for the in-process timer. `0` = off @@ -69,7 +72,7 @@ Channels already marked **Exclude from sync** are never auto-synced. ## Internal heartbeat (no cron) -Set **Settings → Sync scheduler → Internal heartbeat** to a cadence (e.g. `300` +Set **Operations → Sync → Internal heartbeat** to a cadence (e.g. `300` for every 5 minutes) and restart the editor. The instrumentation hook (`editor/instrumentation.ts`) arms a single in-process timer that calls `runSchedulerTick()` directly — no cron, no `sync:tick` client, no token. diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] +- **Sync is an operation on the board, and the schedule is its page.** The pipelines rail's first row is **Sync** — channels on a cadence, due now, overdue, in the same four state words every other row uses — because a corpus that has stopped noticing new videos is not idle, it is broken, and until now that fact sat in a panel of its own below the rail. Its page is **`/operations/sync`**, which is the schedule that used to be at `/scheduler`: the per-channel cadence table, the bulk retune bar, *Run scheduler now* and the recent ticks, every control unchanged — with **every Sync scheduler setting below it**, the fieldset that used to be on **Settings**, saved by one button. `/scheduler` redirects, so bookmarks land; the API paths and the `pnpm sync:tick` cron client never moved; the sidebar's separate *Schedule* entry is gone, and searching the command palette for "schedule" or "cadence" finds Operations. The two **Storage chores** that ride the same heartbeat — the keep-latest deletion check and the saved-video backup — are named as such on the console, in a tick's own summary line and on **Saved videos**: they are not sync, they just share the one timer the editor has. **Transcription workers are configured on Workers now**, directly under the live list, with their own *Save workers* — so seeing a worker and changing it are no longer two pages. **Settings** keeps the machine and the site, and points at both. The reason a lane is not working is now one table rather than two. **Nothing on disk changes; no setting is renamed.** - **A video's page shows one panel per operation, not just the digest's.** Speaker diarization and both *Speaker names* operations wrote records that no per-video screen could show — and for diarization that record is often the only surviving evidence of audio the cleanup sweep has since deleted. Each operation now has its own panel: what produced its record (engine, models, threshold, which transcript the names were read from, how many chunks survived), and the **same state word the channel's own counts use** — *current*, *stale*, *partly stale*, *not generated*, *held — …* with the reason spelled out, or *waiting on …* naming what has to happen first. Each speaker panel carries a **Run** button that runs **that one video** on the speaker lane, in front of the sweep's work; the channel's Speakers stage still runs the whole channel. A record whose feature has since been switched off is still shown, marked *switched off*, with no Run button — hiding it is how a record that cost audio nobody has any more becomes invisible. Both *Speaker names* operations read the one `attribution.json` they share, so their panels differ in the state word and not in the record; the `method` line says which lane wrote it. The digest panel is unchanged apart from its heading, which is now **Digest** rather than *AI digest* — the name the rest of the editor already uses for it — and the page no longer works out the digest's freshness a second way of its own, so a video's panel and its channel's count cannot disagree. A re-run of a per-video run from **Jobs** stays per-video. **Nothing on disk changes.** - **One pause, drawn wherever a lane is — and the runner pages have one now too.** Four lanes could be held, and each of them had grown its own button: three components (one of which nothing imported), four inline descriptors on the dashboard, and a fifth on the sweep panels with the opposite emphasis and an aria-label of its own. They are one control now, over one pair of server actions, keyed by LANE rather than by operation — three speaker operations share one backfill queue and therefore one pause, so an operation page is not a per-operation switch. **Every button name is unchanged.** New: `/operations/transcription` and `/operations/download` have their pause beside **Start**, **Drain** and **Stop** — those three act on the runner, the pause holds the lane, and a held lane outlives any runner. A held runner lane now shows as *Holding* on the operations rail, which it could never do before. The transcription page also stops contradicting itself: pausing disables every worker, so it used to say "no enabled worker to run it" directly beside its own *Resume Transcriptions* button, and now says "transcriptions are paused". **Resume is always clickable** — on a lane with no workers, or one switched off, the pause is disabled and the resume is not, because a paused pool with zero workers has to be releasable. The operation page's hold button also adopts the dashboard's emphasis: filled while the lane is HELD, where it used to tint itself while the lane was running fine. **Nothing on disk changes** — the same four settings fields, and the Speaker lane's *Run the backfill lane* checkbox and the pause button still write the same one, on purpose. The monitor widget's own data feed renames `digest.paused` and `backfill.enabled` to `held`; a pinned widget tab shows that lane as not held until it is reloaded. - **"What needs doing" is answered on the page that does it, and `/actionable` is gone.** One page listed ten kinds of pending work, and it was the fourth place in the editor that answered the same question — so a channel with undownloaded videos appeared on the dashboard, on `/channels`, on `/operations` and there, four times, in four vocabularies. Each of its sections now sits with the work: **Download** and **Transcription** each carry the channels with that operation's backlog and its attention items (videos lost before they were ever fetched, truncated downloads, truncated transcripts — the ones a runner cannot simply retry), **Digest** carries the passes that recorded warnings, `/cleanup` carries the two reclaimable-audio tables, and `/channels` carries how fresh each channel's report is, with *Refresh report* on the row and *Update all reports* in the header — the report is the caveat on every count in that row, so it belongs beside them. Duplicate clusters and media-integrity findings get a new **Review** page under *Corpus*: both are flag-only, both a human decides, and neither is an operation or a pile of bytes. `/actionable` redirects to `/operations`, so every bookmark still lands. **Nothing on disk changes** — no report field, no setting, no widget section. The dashboard's *No digest* badge and column are now **Digest to do**: it is the same number under its right name, the figure the digest lane could act on today, which is what it has always been. The monitor widget's own data feed renames its `noDigest` field to `digestReachable` to match; a pinned widget keeps working and shows the same figure. diff --git a/plans/FACTS.md b/plans/FACTS.md @@ -2815,3 +2815,84 @@ in this slice imports from it as `import type` only. `lib/digest.ts`, `lib/diari callers**; the two-pane `/channels/[slug]/videos?video=` route is still `VideoPanel`-only; `common/components/TranscriptModal.tsx` has its own unrelated local `DigestPanel` (the published player's), which is why a repo-wide `DigestPanel\b` grep is not zero. + +## Verified 2026-08-29 — editor IA slice 8a+8b seams (sync is an operation; its settings and the workers' live on their pages) + +Census taken read-only against `6f6c307` before writing `editor-ia-slice-8ab.md`; the "As +shipped" notes were added with the docs commit. + +**Every consumer of `operationCatalog()`, and what a `sync` entry does to it.** The catalog +went from seven ids to eight, and this is the whole blast radius: + +| consumer | before | after | +| --- | --- | --- | +| `operations.ts` `operationApplies/Label/Group/ShortLabel/CostBasis` | `find` by id | unchanged; `operationLabel("sync")` is "Sync" | +| `pauseGates.ts` `pauseLaneFor` | walks the catalog | `null` for sync — no runner, and its queue key is neither sweep's; `pauseGates.test.ts` pins it | +| `operations/[id]/page.tsx` `descriptorFor` | the 404 gate | `/operations/sync` resolves, and the page reads `op.trigger` for the console | +| `settings/page.tsx` worker tags | every id | moved to `workers/page.tsx`, filtered `scope === "video"` | +| `channels/page.tsx` `pipelineColumns` | Map lookup; `ids` = `EXTERNAL_BAND_IDS` + enabled kinds | unchanged — sync is in neither list, and its group is not in `OPERATION_GROUP_ORDER` | +| `channels/[slug]/lib/channelFlow.ts` | Map lookup over `buildChannelBands` ids | unchanged, same reason | +| `operations/lanes.ts` `sweepLaneIdFor` | queue-key switch | `null` for sync | +| `components/pipelines/buildBands.ts` | `EXTERNAL_BAND_IDS` + operation ids | unchanged — **no sync band, deliberately** | +| `controller/videoOperations.ts` | walks `OPERATIONS` | unchanged; sync is `scope: "channel"`, so a per-video reader has nothing to ask it | +| `allOperations` / `backfillLaneOperations` / `countBackfillWork` / arbiter / `SweepScope` | `OPERATIONS` | untouched — the registry, not the catalog | + +**`pauseGates.test.ts` pins the catalog's LENGTH**, which is what makes a new entry a failing +test rather than a silent omission: `assert.equal(ids.length, 8)` plus an expected lane for +every id. That is the pin that forced sync to state `null` on purpose. + +**`ExternalOperation` was a duplicate field list, and is now a projection.** It repeated +`OperationDescriptor`'s eight fields plus `runner` and `appliesTo`; it is +`Omit<OperationDescriptor, "dispatch" | "settingsBlock"> & { dispatch: "external" }` now, so +`scope` and `trigger` are declared once and a future field cannot go missing from one of the +two. `SYNC_OPERATION` is typed `OperationDescriptor` directly (it HAS a `settingsBlock`) and +is not a member of `EXTERNAL_OPERATIONS` — that list is the media-derived pipelines the rail +draws bands for. + +**The lane NOTE strings were TWO copies and a paraphrase, not three copies.** +`railStates.ts` and `buildActiveJobs.ts` held byte-identical four-row tables ("no operation +switched on", "sweep armed, lane paused", "lane paused", "no sweep armed"); +`buildActiveJobs.ts` ALSO re-stated `deriveLaneState`'s precedence beside its table. +`SweepLane.tsx` is a sentence over the same derivation, and stays one. **No spec pinned any of +the four strings** (grep-verified before the change), so `laneState.test.ts` is the first +thing that does. + +**Where the two Storage chores actually show.** `keptChecksQueued` / `savedVideoBackupQueued` +are fields of `SchedulerTickResult` and reach **no UI**: `SchedulerView`/`SyncConsole` read +`queued`/`skipped`/`reason`, and `SchedulerRun` records neither. They are visible only as (a) +the `skipped` reasons (`kept-check: …` and the pseudo-slug `(saved-video backup)` — now +`storage chore (…)` in both), (b) the *Keep-latest check interval* field, (c) the +`/saved-videos` sentence. Recording them in `SchedulerRun` is a state-shape change and was out +of scope; the Run-now message reads them off the POST body instead, which needed no tick +change at all. + +**`editor/app/scheduler/` is the sync operation's RUNNER, and only `page.tsx` was deleted.** +Importers of the rest, all still live: `instrumentation.ts` (`heartbeat.ts`), +`api/scheduler/status/route.ts` and `api/scheduler/tick/route.ts` (`status.ts`, `runTick.ts`, +`auth.ts`), `operations/syncRow.ts` (`status.ts`), `ChannelForm.tsx` and the moved cadence +forms (`intervalPresets.ts`, `actions.ts`). A route directory with no `page.tsx` is not +routable, so `/scheduler` is answered by a `permanent: false` (307) redirect in +`next.config.ts`; `/api/scheduler/*` is deliberately NOT redirected. + +**One writer per settings block, now true of two more blocks.** `saveSchedulerSettingsAction` +is the only writer of `settings.syncScheduler` and `saveWorkersAction` the only writer of +`settings.workers`; both read the current file and replace their own block, and +`settings/actions.ts` preserves both by name beside `digest` / `diarization` / `backfill` / +`attribution` / `autoQueue` / `savedVideoBackup`. `hourOrNull` moved from `settings/actions.ts` +to `scheduler/actions.ts` with the quiet-hours pair: those are the one place in the block where +BLANK IS A VALUE, so they cannot go through `intOrKeep`. + +**One consumer the plan did not name, found by tsc.** +`channels/[slug]/videos/[id]/lib/videoOperationPanels.ts` switches exhaustively over +`OperationSettingsBlock | undefined` to pick a per-video panel body. Widening the union broke +it. `case "syncScheduler":` falls through to `case undefined:` with a comment: `views` comes +from the per-video registry walk, so a channel-scoped block can never arrive — and the arm +exists precisely because the exhaustive switch is what makes a NEW per-video block a compile +error rather than a blank panel. + +**As shipped.** `editor/app/operations/components/sync/` holds `SyncConsole.tsx` (was +`scheduler/components/SchedulerView.tsx`), `SchedulerSettingsForm.tsx`, +`ChannelCadenceEditor.tsx`, `BulkCadenceBar.tsx` and `cadence.ts`; +`editor/app/workers/components/` holds `WorkersField.tsx` (was under `settings/components/`) +and the new `WorkersConfigForm.tsx`; `sweepLaneNote` is in +`editor/app/components/lanes/laneState.ts` with `laneState.test.ts` beside it. diff --git a/plans/STATE.md b/plans/STATE.md @@ -3,7 +3,23 @@ The working memory for the local-AI derived-corpus work. Rewritten at the end of every session, before context is cleared. See [`README.md`](README.md) for the protocol. -**Last updated:** 2026-08-28 — **editor IA slice 6 shipped** (`6986efc` → `d5ca90a`): the +**Last updated:** 2026-08-29 — **editor IA slice 8a+8b shipped** (`ea04b4e` → `fecac0f`): +**sync is a catalogued operation and the schedule is its page.** `OperationDescriptor` gains +`scope` ("video" | "channel") and `trigger` ("backlog" | "cadence"), `SYNC_OPERATION` is first +in `operationCatalog()`, and `/operations/sync` — today's `/scheduler`, which 307s — is the +per-channel cadence table with the **whole `syncScheduler` block** below it. Two things the IA +doc's wording could not buy: `runner` is typed `AutoQueueKind` and the heartbeat is not one, so +the console is chosen off `trigger`; and a band's populations are videos, so the rail row is a +composed `SyncRailRow` rather than a hollow band. `saveSchedulerSettingsAction` is the block's +one writer (it had two), `WorkersField` and a new `WorkersConfigForm` move to `/workers` with +`saveWorkersAction` as *its* one writer, and the worker-tag vocabulary is the first consumer to +filter on `scope === "video"`. The four lane-note strings are one `sweepLaneNote` beside +`deriveLaneState` — they were two copies and a paraphrase, and nothing pinned them. +The two Storage chores that ride the heartbeat (keep-latest check, saved-video backup) are +labelled as such everywhere they show; **no tick logic, no settings key and nothing on disk +changed**. The `/jobs` fold — the last third of slice 8 — is its own plan. See "Slice 8a+8b, as +shipped" in `editor-operations-ia.md`. +Previously: 2026-08-28 — **editor IA slice 6 shipped** (`6986efc` → `d5ca90a`): the video page shows one panel per registry operation. `common/controller/videoOperations.ts` is the reader — one `readVideoFiles`, one `resolveTarget` and one `state()` per entry, never re-derived — and `digestSectionStates` in `lib/digest.ts` is now the ONE per-section fold the @@ -262,6 +278,18 @@ nothing renders. "Slice 6, as shipped" in `editor-operations-ia.md` and the FACTS section "Verified 2026-08-28 — editor IA slice 6 seams". The registry's per-video reader is `common/controller/videoOperations.ts`, and a fifth operation now needs no page. +11. ~~**Editor IA slice 8a+8b** (sync is an operation; its settings and the workers' live on + their pages)~~ — **DONE 2026-08-29**, `ea04b4e` → `fecac0f`. Plan: + [`editor-ia-slice-8ab.md`](editor-ia-slice-8ab.md); outcome: "Slice 8a+8b, as shipped" in + `editor-operations-ia.md` and the FACTS section "Verified 2026-08-29 — editor IA slice + 8a+8b seams". +12. **The `/jobs` fold — the next IA candidate, and the last third of slice 8.** `/jobs`, + `/jobs/active` and `/jobs/queue` become one page with a mode and one table (umtool's `/` + is already that shape). **The design question to settle before writing the plan:** the + three pages read three different sources — the registry's job list, `buildActiveJobs`'s + lane-and-task view, and `buildQueueView`'s registry-vs-scheduler drift check — and "one + table" is only honest if a row means the same thing in all three modes. Decide what a ROW + is first; a mode switch over three shapes is three pages with shared chrome. --- diff --git a/plans/editor-operations-ia.md b/plans/editor-operations-ia.md @@ -54,7 +54,7 @@ and concludes the model was wrong. | Thing | Verdict | | --- | --- | -| **Sync** | An operation, but channel-scoped and cadence-triggered rather than per-video and backlog-driven. Add `scope: "video" \| "channel"` and `trigger: "backlog" \| "cadence"` to `OperationDescriptor`; 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 that ride the same heartbeat (`editor/app/scheduler/runTick.ts:25-27`) are **Storage chores**, not sync, and should be labelled as such. | +| **Sync** | **SHIPPED 2026-08-29** (slice 8a+8b). An operation, but channel-scoped and cadence-triggered: `scope: "video" \| "channel"` and `trigger: "backlog" \| "cadence"` are on `OperationDescriptor`, `SYNC_OPERATION` is first in `operationCatalog()`, and `/operations/sync` is its page. Two corrections to the wording above. **(1) "Keeps its own runner" could not be `runner`** — that field is typed `AutoQueueKind` and the heartbeat is not one of them, so the page chooses the cadence console off `trigger` and `runner` stays `undefined` (`operations.test.ts` pins it, with the reason). **(2) The board row is composed, not folded into a band** — `OperationBand`'s five populations are videos and sync's figures are channels, so `buildOperationBands` gains no sync band and `OperationRail` draws a `SyncRailRow` first in the same `<ul>`. The keep-latest checks and the saved-video backup that ride the same heartbeat (`editor/app/scheduler/runTick.ts`) are **Storage chores**, not sync, and are labelled as such on the console, in the skip reasons and on Saved videos. | | **Build / deploy** | **Not** operations. Per-site, no per-video state, no lane. They are the Site's *publish* verb → `/sites/[siteId]`. | | **Cleanup / saved-videos / relocate** | **Not** operations: they consume outputs rather than producing derived artifacts. Third noun, **Storage**, filed under Machine. The channel `cleanup` stage stays where it is. | | **Transcode** | **Registered 2026-08-26** (the vocabulary pass, commit 3): an external descriptor, group `media`, `lane: { TRANSCRIPTION_QUEUE, cpu }`, `dependsOn: ["download"]`, no runner, and the registry's first `appliesTo(config)` — `handling === "transcribe" && !!audioFormat`, exposed as `operationApplies(id, config)` and replacing the three verbatim copies of that gate. `/operations/transcode` is slice 2's no-console panel. **Its BAND waits**: `buildOperationBands` is snapshot-only and pure, and `ChannelSnapshot` carries no `handling`/`audioFormat`, so a band could not tell "never transcodes" from "finished". The honest route is the snapshot writer recording a transcode population per channel — a snapshot-shape change. **It was filed "with unified-ops step 1" and that coupling is void as of 2026-08-26**: both were thought to need one regeneration of the snapshots, and step 1 shipped needing none. This is now its own change and its own plan — the writer recording a transcode population (`appliesTo(config)` plus a kept-media denominator beside `buckets.untranscoded`) so `EXTERNAL_BAND_IDS` can go to three. It stays two until that lands. | @@ -68,7 +68,7 @@ and concludes the model was wrong. Four groups, twelve top-level entries, down from three groups and nineteen: - **Corpus** — Dashboard, Channels, Review -- **Operations** — Operations *(+ Schedule until slice 8)* +- **Operations** — Operations *(Schedule folded in, 2026-08-29: it is `/operations/sync`)* - **Sites** — Sites *(+ Charts, Search aliases, Deploy, Build, Homepage until slice 5)* - **Machine** — Jobs, Active, Workers, Cleanup, Saved videos, Settings, Changelog @@ -169,6 +169,10 @@ dependencies allow. Sizes are S/M/L. table; `WorkersField.tsx` (556 lines) moves to `/workers`; `/scheduler` becomes `/operations/sync`. umtool's `/` already reads "one activity list over both job registries" — this is the same shape. **M–L.** + **8a+8b SHIPPED** (`ea04b4e` → `fecac0f`; see "Slice 8a+8b, as shipped" below): sync is + catalogued and `/operations/sync` is its page with the whole `syncScheduler` block on it, + and the worker list is configured on `/workers`. **The `/jobs` fold is its own plan** and + is what remains of this slice. 9. **Sweeps retire; the tree dispatches everything.** unified-ops step 6 plus arbiter persistence. Deletes `digestSweep.ts`, `backfillSweep.ts`, `SweepLane.tsx`, `SweepScope.tsx`, the sweep checks at `arbiter.ts:205-213` and the resume hooks at @@ -501,4 +505,94 @@ payload keeps its `paused` / `downloadsPaused` field names (the poll re-reads th `dashboard.spec.ts` documents that), the `/api/widget/sync` path is unchanged, and no settings key moved: unified-ops step 6 migrates storage behind `isGateHeld`/`withGateHeld` later. The three copies of the lane NOTE strings (`buildActiveJobs.ts`, `railStates.ts`, `SweepLane.tsx`) -are still three — that is slice 8. +are still three — that is slice 8. *(Done 2026-08-29: they were two copies and a paraphrase, +and they are one `sweepLaneNote` now. See below.)* + +## Slice 8a+8b, as shipped + +Six commits after the plan ([`editor-ia-slice-8ab.md`](editor-ia-slice-8ab.md), `b8c485b`): +`ea04b4e` (the catalog entry, `scope`/`trigger`, group `sync`) → `23d2cd8` (the rail row, the +page, the redirect, the Storage-chore labels) → `f8e0185` (the whole `syncScheduler` block on +`/operations/sync`, one writer) → `1cdb823` (`WorkersField` and the persisted list on +`/workers`) → `fecac0f` (`sweepLaneNote`) → this docs commit. **The `/jobs` fold — the third +third of slice 8 — is deliberately not here and is its own plan.** + +**Nine places the slice-8 bullet could not be implemented literally, and what happened +instead.** The census is in FACTS.md ("Verified 2026-08-29 — editor IA slice 8a+8b seams"); +these are the decisions. + +1. **"Keeps its own runner" cannot be `runner`.** `OperationDescriptor.runner` is typed + `AutoQueueKind` (`"transcription" | "download"`), `operations.test.ts` pins it `undefined` + for every other id, and `operations/[id]/page.tsx` feeds it straight into + `RunnerOperationView`. The sync heartbeat is a runner in this document's sense and not one + of those. **`trigger: "cadence"` is the switch instead:** the page builds the console as a + server slot when `op.trigger === "cadence"` and `OperationDetail` renders it where it would + otherwise draw `NoConsoleView` — which stays the fallthrough for a registered operation + nothing drives (transcode). +2. **Sync has no band, so the row is composed rather than folded.** `OperationRail` drew one + `RailRow` per `OperationBand`, and a band's five populations are VIDEOS. Sync's figures are + channels. Giving it a band would have printed "coverage unknown" over a hollow outline for + a thing that has no per-video coverage; `buildOperationBands` therefore gains nothing. + `OperationRail` takes `sync?: SyncRowView` and renders a `SyncRailRow` + `<li data-operation="sync" data-scope="channel">` first in the same `<ul>` — same + link/dot/state-word anatomy, its own figures, and where every other row draws its band it + says *per channel, on a cadence*. Still SSR, not polled: a cadence measured in minutes does + not need a 3-second poll. `OperationsBoard`'s `SyncRow` is deleted. +3. **`group` is required and exhaustive in three places, so sync got a fifth group.** + `groupLabel`, `groupActionLabel` and `GROUP_STAGES: Record<OperationGroup, …>` all switch + or key on it. Sync is not `media` — that is download + transcode's `/channels` column + group. `"sync"` is **not** in `OPERATION_GROUP_ORDER` (the column order and the transit + line's station order), and `GROUP_STAGES.sync = []` because sync's channel surface is the + hand-listed `playlist` bookend — a chore, not a stage this Record owns. +4. **"Three copies of the lane NOTE strings" were two copies and a paraphrase.** + `railStates.ts` and `buildActiveJobs.ts` carried byte-identical tables; `SweepLane.tsx` says + the same two facts in a sentence with room to say what to do about it. `sweepLaneNote()` + lives beside `deriveLaneState` and takes ITS input rather than its output, so the word and + the note follow one precedence and cannot disagree. `buildActiveJobs`'s restatement of that + precedence goes with the table. The sweep panel keeps its prose and says in a comment that + it is the long form of the same row. Nothing pinned any of the four strings before; + `laneState.test.ts` does now. +5. **"Wherever the Storage chores show" is smaller than it sounds.** `keptChecksQueued` and + `savedVideoBackupQueued` reach no UI — `SchedulerRun` records neither, and recording them + would be a state-shape change. They show as the `skipped` reasons, the keep-latest interval + field, and the Saved-videos sentence. All are labelled; the Run-now message names them off + the POST body it already carried; a paragraph under *Recent ticks* says which two they are. + **No tick logic changed.** +6. **The catalog is walked by a test that pins its length.** `pauseGates.test.ts` asserts + `ids.length` and an expected lane for every id. Sync is `null` there: the scheduler's own + `enabled` is its switch, and its queue key is neither sweep's. +7. **A worker tag would have grown a `sync` entry.** The vocabulary was + `operationCatalog().map(o => o.id)`, and a tag names a per-video operation a delegate can + take a UNIT of. The builder moved to `workers/page.tsx` and filters `scope === "video"` — + the first consumer to use the field for the reason it exists. +8. **`editor/app/scheduler/` is the RUNNER, not the page.** Only `page.tsx` is deleted (the + `/actionable` precedent). `runTick.ts`, `heartbeat.ts`, `status.ts`, `auth.ts`, `actions.ts` + and `intervalPresets.ts` stay: `instrumentation.ts`, both `/api/scheduler/*` routes, + `syncRow.ts`, `ChannelForm.tsx` and the moved forms import them. A route directory with no + `page.tsx` is not routable, and a 307 answers `/scheduler`. The four view components and + `cadence.ts` moved under `operations/components/sync/` by `git mv`, so the diff reads as a + rename; `SchedulerView` is `SyncConsole`. +9. **Two writers of one settings block became one — and the operator moved the WHOLE block.** + `SettingsForm`'s 14-field *Sync scheduler* fieldset and `SchedulerSettingsForm`'s three + overlapping fields both wrote `syncScheduler`, each relying on the other to preserve what + its own render did not show. The descriptor declares `settingsBlock: "syncScheduler"`, the + operation page's exhaustive switch draws the form below the console, and + `saveSchedulerSettingsAction` is the one writer (`hourOrNull` came with the quiet-hours + pair, where blank is a VALUE). `/settings` preserves the block the way it preserves + `digest`, `diarization`, `backfill` and `attribution`. **The keep-latest interval moved with + it** — it is a field OF this block even though it is a Storage chore's knob, and its hint + now says so. + +**Workers: the list is configured beside the workers.** `WorkersField.tsx` moved to +`workers/components/`, `WorkersConfigForm` wraps it with its own *Save workers*, and +`saveWorkersAction` is the one writer of `settings.workers` — read the file, replace that +block, then `reconfigure(workers, { applyEnabled: true })` on the live pool exactly as the +whole-object form did. Nothing else on `/workers` is a form, which is the structural property +slice 3 bought for the operation blocks. Every worker aria-label is byte-identical; three +tests moved from `settings.spec.ts` to `workers.spec.ts`. + +**What stayed.** The `scheduler/` runner directory (see 8). The widget's *Auto-sync scheduler* +strip and `/api/widget/sync`. `SweepLane`'s prose (see 4). `SchedulerRun`'s shape, +`syncSchedulerState`, `runSchedulerTick`'s logic and every settings key. `Field.tsx` / +`CardField`. No `console` field on the descriptor — one cadence operation exists, and a second +would name its console rather than have `page.tsx` branch on an id.