# Editor IA slice 6 — the video page: one panel per operation ## Context **Verified read-only against `ce2ebd9` (clean) on 2026-08-28.** Slice 6 is the IA doc's bullet (`plans/editor-operations-ia.md:151-156`): `DigestPanel.tsx` generalizes to an `OperationPanel` built per enabled kind from `readVideoFiles()`; `hasDigest()` and `hasAttribution()` go; attribution and diarization get a per-video view for free; the "Open in umtool" link (`VideoPanel.tsx:219-231`, `data-umtool-link`) stays above the panels. The vision test (`:43-45`): a new operation is a registry entry and appears on the video page without a new page. **Decided (operator, 2026-08-28):** (a) panels are the four registry operations (`OPERATIONS`, `operations.ts:1498-1510`), shown when `op.enabled(settings)` OR one of `op.outputs` is in the listing; the three external operations (download / transcode / transcription) already have their per-video surface — the `PipelineStageCard`s inside `VideoPanel.tsx` (`:257-358`) — and get **no** panel. (b) State is `Operation.state(probe)`, never re-derived. (c) One generic frame, one body per `settingsBlock`, the digest body byte-identical in its labels. (d) One `stateCopy` table; the `digest.spec.ts` pill contract survives verbatim. (e) The speaker operations get a per-video **Run** via `ids` threaded through the backfill channel job. (f) `hasAttribution` deleted; `hasDigest` becomes a private helper of the channel count. (g) The reader lives in `common/controller/videoOperations.ts`; the `Operation` type is not changed. **What the census found (corrects the bullet in six places):** 1. **`hasDigest` is not a video-page helper.** Its one caller is `common/controller/channels.ts:94`, inside `countDataFiles` (`:67`) under `listChannelStatsFromDisk` (`:214`) — the batch ground-truth walk. It is identity-blind on purpose (`digest-server.ts:84-90`: "is there a digest at all", never "is it current") and `channelProjection.test.ts:66-67` pins that a record with a non-empty section counts. It is defined at `digest-server.ts:91-98`, not `:89`. Moving it into `channels.ts` as a private function and deleting the export is the whole of "deletes `hasDigest()`". 2. **`hasAttribution` was already dead** (`attribution-server.ts:36-38`; zero callers in `common editor export homepage umtool mcp`). **`hasDiarization` is NOT dead** and stays: `cleanAudioFromTranscribed.ts:125` (the audio-deletion guard), `diarizeOne.ts:185`, and `diarization.test.ts:57-92`. (`files.hasDiarization` on `VideoFiles` is a listing flag, a different thing, untouched.) 3. **The page duplicates digest `state()`.** `page.tsx:155-184` calls `resolveDigestTarget` with `lane: "local"` and folds `isSectionFresh` + the `derivedFrom` rule per section; the registry's `digest.state()` (`operations.ts:1104-1185`) does the same fold at `:1153` and `:1176-1178`, and its `resolveTarget` (`:1096-1103`) calls the same resolver with no lane, which `resolveDigestChunking` (`digestTarget.ts:69-79`) resolves to `localAppId` — the same identity. Dropping the page's copy changes no displayed value. The per-section `{present, fresh, provenance}` detail (`DigestPanel.tsx:38-43`) stays as the digest body's `sections`, and this slice makes it the SAME fold: a `digestSectionStates()` helper in `common/lib/digest.ts` that both `state()` and the body use. 4. **`readVideoFiles` has never been called from `editor/`.** The page's own `loadVideoDir` (`page.tsx:57-77`) is a `readdir` + per-file `stat` for size/mtime — the Files card needs those, so it stays; `readVideoFiles` is a second `readdir` (~free) that the registry's `state()` requires as its probe. 5. **`ids` already exists on the batch and not on the job.** `runBackfillBatch` takes `ids?: string[]` (`backfillBatch.ts:99`, intersected with disk at `:290-291`); `BackfillChannelJobOptions` (`operationJobs.ts:60-75`) does not, and `countBackfillWork` (`backfillBatch.ts:869-926`) walks every dir with no filter — so a per-video job would size its progress bar to the whole channel. `editor/app/jobs/jobReplayRegistry.ts:281-284` reads `p.kindIds` only. `digestBucketAction` (`digestActions.ts:83-135`) is the precedent for an id-scoped job on the other lane. The job's summary line (`operationJobs.ts:120-121`) is `Backfill : N done, …` — what the new e2e waits on. 6. **The e2e surface.** `digest.spec.ts:569-680` are the three panel tests; every selector they use is in the aria table below. `video-page.spec.ts` has 20 tests, all on `VideoPanel` labels; its fixture video has metadata + `transcript.en.vtt` and no `transcript.cues.json`, so digest classifies it `deferred` there (nothing asserts the pill in that spec today). No fixture on disk carries an AI sidecar. `attribution.spec.ts` `seedChannel()` (`:137-148`, writes `diarization.json` for `attrvid0001` by default) + `attributionSettings()` (`:101-135`, **diarization capture off**) is the seeding pattern — and it is exactly the "shown because its output is present" case for the diarization panel. `runBackfill()` (`:150-161`) shows the stub-backed run works in that spec. **Hazards carried through every commit:** - **Client graph.** `common/lib/operations.ts` imports `digest-server`, `diarization-server`, `attribution-server`, `videoStatus` — all `node:fs`. Every `"use client"` file in this slice imports from it **as `import type` only** (`OperationClassification`, `OperationSettingsBlock`, `OperationGroup`) — the `pauseControl.tsx` lesson. `common/lib/ digest.ts`, `diarization.ts`, `attribution.ts` have **no imports at all** (verified) and are safe for types and pure values. `deferredHint` and `dependsOn` labels reach the client as **props**, never by reading the registry. - **`"use server"` files must not re-export types** (`digestActions.ts:37-48` explains: the transform emits a runtime binding). The new action file declares nothing but functions. - **`StreamActionLog` contract** (`common/components/StreamActionLog.tsx`): `accessibleName = label ?? buttonLabel` (`:46`); the log is `role="log" aria-label="${accessibleName} output"` (`:238`); cancel `"Cancel ${accessibleName}"`; error `"${accessibleName} error"`; queue banner `"${accessibleName} queue status"`; the button is disabled until hydrated; on completion it calls `router.refresh()` (`:163`), which re-renders the pill. - **`resolveDigestTarget` reads `getSettings()` from disk** (`digestTarget.ts:69, 88`), not the probe's `settings`, and `getPaths()` memoizes. The reader's unit test sets `TRANSCRIPTS_DIR` and `SETTINGS_FILE` at module top before any import (`laneForOperation.test.ts:20-29` is the pattern; `readDigestContext` tolerates a missing note). - **The guard** `noCorpusWalkInRenderPaths.test.ts` bans `listChannelStatsFromDisk | buildBackfillSweepPlan | buildDigestSweepPlan` textually under `editor/app`, comments included. Nothing new mentions them. (`countBackfillWork` runs inside the job `fn`, as `backfillActions.ts` already does — not a render path.) - **`revalidatePath` in `onDone`** fires at job end outside a request; `backfillActions.ts:33` and `digestActions.ts:76` already do this. - **"held — " + `deferredHint`.** Both hints are plural fragments completing "N videos are …" (`operations.ts:397-399`): diarization ends "…to include **them**" (`:641-642`), digest "…to make **them** digestable" (`:1064-1065`). After "held — " they read acceptably for one video. Keep them verbatim — one copy, `operations.test.ts:1163-1177` pins the digest one — and say so in the comment. Do not add a singular copy. - **A shown-but-disabled operation** (finding 6) would read "stale — regenerating would replace this" beside nothing that can regenerate it. The frame adds a muted `switched off` beside the pill when `!view.enabled`, and the speaker body's Run button is not drawn for it. ## Step 0 — the plan on disk Write this file verbatim to `plans/editor-ia-slice-6.md`; commit alone: `plans: slice 6 planned`. ## Order: five commits after the plan 1. **`common: a video's operations are read once, from the registry`** — `videoOperations.ts` + test; `digestSectionStates` in `digest.ts` and `digest.state()` on it; `hasDigest` moves into `channels.ts`; `hasAttribution` deleted. 2. **`backfill: a channel job can be scoped to video ids`** — `ids` through `runBackfillChannelJob` → `countBackfillWork` → `runBackfillBatch`; in `spec.params`; the replay handler and `backfillChannelAction` carry it; the ids test. 3. **`video page: one panel per operation`** — `OperationPanel` frame, `DigestBody` (renamed `DigestPanel`), `SpeakerBodies`, `operationState.ts` + unit test, the page on `inspectVideoOperations`; `isSectionFresh` and `resolveDigestTarget` leave `page.tsx`. 4. **`video page: a speaker operation runs for one video`** — `operationActions.ts`, the Run button in the speaker bodies, two new e2e in `attribution.spec.ts`, one assertion in `video-page.spec.ts`. 5. **`plans: slice 6 shipped, and the docs say so`** — CHANGELOG, IA doc, STATE, FACTS, memory. Gates after each of 1–4: `pnpm -C exec tsc --noEmit` for `common editor export homepage umtool mcp`; `pnpm -C common test` (867 at plan time — re-measure before commit 1 — plus the new file's tests); `pnpm -C editor exec tsx --test "app/**/*.test.ts"` (90 + the `operationState` tests). e2e once after commit 5, detached (memory `e2e-run-detached`). --- ## Commit 1 — `common: a video's operations are read once, from the registry` **New helper in `common/lib/digest.ts`, beside `isSectionFresh` (`:513`):** ```ts // One row per section kind the current settings ask for. THE SAME FOLD the digest // operation's state() counts — a section is fresh here iff state() counts it fresh — // so the per-video panel and the channel's work list cannot disagree about one section. // A SHARED digest (derivedFrom) is fresh for the receiving video as long as it still // points at its canonical member (isSharedFrom's contract; state() returns present on // the same condition). export type DigestSectionState = { kind: DigestSectionKind; present: boolean; fresh: boolean; provenance?: DigestProvenance; }; export function digestSectionStates( record: DigestRecord | null, sections: readonly DigestSectionKind[], target: DigestFreshnessTarget, ): DigestSectionState[]; ``` `operations.ts:1176-1178` becomes `digestSectionStates(record, sections, freshness).filter((s) => s.fresh).length` (the `derivedFrom` early return at `:1153` stays; behaviour identical — `operations.test.ts` digest cases stay green). `DigestSectionState` is deleted from `DigestPanel.tsx:38-43` in commit 3 and imported from here. **New `common/controller/videoOperations.ts`:** ```ts import type { OperationClassification, OperationGroup, OperationSettingsBlock } from "../lib/operations"; export type VideoOperationView = { id: string; label: string; group: OperationGroup; settingsBlock?: OperationSettingsBlock; state: OperationClassification; // Operation.state(probe), never re-derived enabled: boolean; // op.enabled(settings) outputs: { name: string; present: boolean }[]; // op.outputs × files.entries deferredHint?: string; dependsOn: { id: string; label: string }[]; // operationLabel(depId), operations.ts:1416 // The resolved target, erased exactly as Operation.resolveTarget erases it. The one // consumer that narrows it is the digest body (it needs {target, sections} for its // per-section rows). Never crosses to a client. target: unknown; }; // ONE readVideoFiles per page, ONE resolveTarget + state per operation. Walks OPERATIONS // in registry order; filters nothing — `shownOnVideoPage` is the rule. export async function inspectVideoOperations(opts: { paths: Paths; channelSlug: string; videoId: string; settings: SiteSettings; }): Promise; // Shown when the feature is on OR its output is on disk: a sidecar captured before the // feature was switched off is still a thing the operator needs to see. export function shownOnVideoPage(view: VideoOperationView): boolean; // Group order first (OPERATION_GROUP_ORDER, operations.ts:332-337: media, transcript, // digest, speakers — the order the channel stages and /channels columns use), registry // order within a group: Digest, then the three speaker operations. Pure. export function orderForVideoPage(views: VideoOperationView[]): VideoOperationView[]; ``` Probe per operation, verbatim from decision (b): `{ videoDir, videoId, files: await readVideoFiles(videoDir, { checkUntranscribable: true }), target: await op.resolveTarget({ settings, paths, channelSlug }), settings }` — the shape `countBackfillWork` builds at `backfillBatch.ts:886-916` and `operations.test.ts:176-186` drives. Header comment: the external operations are deliberately absent (their per-video surface is `VideoPanel`'s stage cards); this reader is pure over the registry and is the reason a fifth operation needs no page. **`common/controller/videoOperations.test.ts`** (node:test; env seam at module top as `laneForOperation.test.ts:20-29`; fixtures as `operations.test.ts:107-168` — a temp `ROOT/channels//data/` with `transcript.json`, optional `diarization.json`, `attribution.json`, `ai-digest.json` + `transcript.cues.json`): one view per registry entry, in `OPERATIONS` order; a diarized video under diarization-enabled settings reads `diarization: present`, `attribution-diarized: missing`, `attribution-text: missing`; an undiarized one reads `attribution-diarized: blocked` with `dependsOn[0].label === "Speaker diarization"`; `outputs[0].present` tracks the listing; `shownOnVideoPage` is false for a disabled operation with no output and true for a disabled one whose sidecar exists; `orderForVideoPage` puts `digest` first; an untranscribed dir reads `not-applicable` on the speaker operations and `blocked` on digest; a video with no `cues.json` reads digest `deferred` with `deferredHint` populated. **`hasDigest` moves.** `common/controller/channels.ts:15` imports `loadDigest` instead; a private `async function hasDigestWithItems(dir: string): Promise` above `countDataFiles` (`:67`) carries the identity-blind comment from `digest-server.ts:84-90` verbatim and the body from `:91-98`; `:94` calls it. Delete `digest-server.ts:84-98`. `channelProjection.test.ts:66-67` stays green (its comment may say "the channel count"). **`hasAttribution` deleted:** `attribution-server.ts:36-38`. Its `:14-20` comment cites `hasDiarization` — still true, leave it. --- ## Commit 2 — `backfill: a channel job can be scoped to video ids` - `common/controller/operationJobs.ts:60-75` `BackfillChannelJobOptions` gains `ids?: string[]` — comment: "Only these videos (intersected with disk by the batch). A stage card passes none; the video page passes one. In `spec.params` so a replay stays scoped." `:80` destructures it; `:93` → `params: { kindIds, queueKey: opts.queueKey, ids }`; `:96` → `countBackfillWork(paths, channelSlug, kindIds, ids)`; `:97-110` passes `ids`. `runOperationChannelJob` is unchanged — the dispatcher stays channel-scoped. - `common/controller/backfillBatch.ts:869-878` `countBackfillWork(paths, channelSlug, kindIds?, ids?)`: filter the dir list to `ids` when given, before the walk. Comment: "the progress target must count what THIS run will pull, and an ids-scoped run pulls only those." `backfillSweep.ts:168` (three args) is unaffected. - `editor/app/channels/[slug]/backfillActions.ts:25-35` `backfillChannelAction(slug, queueKey?, kindIds?, ids?)` passes `ids` through. - `editor/app/jobs/jobReplayRegistry.ts:281-284`: parse `ids` exactly as `kindIds` (`Array.isArray(p.ids) ? p.ids.filter((k): k is string => typeof k === "string") : undefined`) and pass it as the fourth argument. Comment: a replayed per-video run stays per-video. - Test, in `videoOperations.test.ts` (it owns the settings seam; `backfillBatch.test.ts` is pure and has none): `countBackfillWork(paths, slug, ["attribution-text"], [idB])` over two transcribed dirs returns `reachable: 1`. --- ## Commit 3 — `video page: one panel per operation` All under `editor/app/channels/[slug]/videos/[id]/`. **`components/operationState.ts`** (no directive; pure; imported by client and server): ```ts import type { OperationClassification, OperationGroup, OperationSettingsBlock } from "yt-dlp-transcript-common/lib/operations"; // The serializable half of VideoOperationView — what crosses to the client. No `target`. export type OperationPanelView = { id: string; label: string; group: OperationGroup; settingsBlock?: OperationSettingsBlock; state: OperationClassification; enabled: boolean; outputs: { name: string; present: boolean }[]; deferredHint?: string; dependsOn: { id: string; label: string }[]; }; // THE ONE TABLE. The present / stale / missing rows are digest.spec.ts's contract // (`:580` "not generated", `:585,620` "current", `:628` contains "stale") and must not move. export function stateCopy(view: OperationPanelView): string; export function stateTone(state: OperationClassification): "success" | "warning" | "muted"; ``` | `state` | pill text | tone | |---|---|---| | `present` | `current` | success | | `stale` | `stale — regenerating would replace this` | warning | | `partial` | `partly stale — regenerating would replace the stale sections` | warning | | `missing` | `not generated` | muted | | `missing-input` | `input gone — needs media` | warning | | `deferred` | `held — ` + `deferredHint` (verbatim; `held` alone if none) | muted | | `blocked` | `waiting on ` + `joinLabels(dependsOn.map(d => d.label))` ("A", "A and B" — copy `OperationDetail.tsx:284-287`) | muted | | `not-applicable` | `not applicable` | muted | `partial` contains "stale", so `digest.spec.ts:628` (`toContainText("stale")`) passes whether the config change leaves the video `stale` or `partial` — say so in the test comment. Tone classes: `text-success` / `text-warning` / `text-muted-foreground`, as `DigestPanel.tsx:84-90`. **`components/operationState.test.ts`** (node:test, `app/**/*.test.ts` glob): the eight rows; `deferred` with and without a hint; `blocked` with one and two dependencies; the three digest strings asserted as literals with a comment naming `digest.spec.ts`. **`components/OperationPanel.tsx`** (`"use client"`; type imports only): ```tsx export type OperationPanelBody = | { block: "digest"; data: DigestBodyData } | { block: "diarization"; data: DiarizationBodyData } | { block: "attribution"; data: AttributionBodyData } | { block: undefined }; // an operation with no settings block: outputs list only export function OperationPanel({ slug, videoId, view, body }: { slug: string; videoId: string; view: OperationPanelView; body: OperationPanelBody; }) ``` Frame: `
`, header row with `

{view.label}

`, the pill `{stateCopy(view)}`, and, when `!view.enabled`, `switched off`. Body: `switch (body.block)` — **exhaustive**, the `settingsFormFor` precedent (`operations/[id]/page.tsx:71-110`). The `undefined` arm renders `
    ` of `outputs` with present / absent — that arm is the vision test's guarantee for a registered operation with no body yet. **`components/DigestBody.tsx`** = `git mv DigestPanel.tsx DigestBody.tsx`, then: delete the `
    `/`

    `/pill (`:76-98`) — the frame owns them; export `DigestBody` (was `DigestPanel`) and `DigestBodyData` (was `DigestPanelData`, `:45-60`, same fields; `sections: DigestSectionState[]` now imported from `common/lib/digest`); delete `:38-43`. Everything else stays byte-for-byte: `SharedFrom`, `Warnings`, `SectionProvenance`, `Chapters`, `Tags`, `OverridesEditor`, the `StreamActionLog` block (`:127-150`, `digestBucketAction`, "Regenerate digest", the lane select). Header comment (`:3-17`) is kept and gains one line: the frame is `OperationPanel`; this is the digest's body. **`components/SpeakerBodies.tsx`** (`"use client"`; `import type { DiarizationEngine }` from `common/lib/diarization`, `import type { AttributionProvenance }` from `common/lib/attribution`): ```ts export type DiarizationBodyData = { record: { generatedAt: string; audioSeconds?: number; durationMs?: number; speakers: number; turns: number; engine: DiarizationEngine; windowing?: { windows: number; windowSeconds: number; overlapSeconds: number } } | null; }; export type AttributionBodyData = { record: { generatedAt: string; speakers: { index: number; label: string; cluster?: number; confidence?: number; seconds?: number }[]; segments: number; warnings: number; provenance: AttributionProvenance } | null; }; export function DiarizationBody({ slug, videoId, view, data }: …) export function AttributionBody({ slug, videoId, view, data }: …) ``` (Field names above follow `DiarizationRecord` (`diarization.ts:52-82`) and `AttributionRecord` (`attribution.ts:91-138`) — verify each against the types and project only what exists.) `DiarizationBody`: `record === null` → `

    No speaker turns have been captured for this video.

    `; else `
    ` with `Row`s (copy `DigestPanel.tsx:323-330`) for `engine`, `segmentationModel`, `embeddingModel`, `model`, `version`, `threshold` (each only when set), `generated`, `audio` (m:ss), `took` (`durationMs`), `windows` when present; then `

    {speakers} speakers · {turns} turns

    `. `AttributionBody`: `record === null` → `

    No speaker names for this video yet.

    `; else `
    ` for `method`, `engine` (`appId`), `model` (the `modelRequested → model` rule from `DigestPanel.tsx:277-284`), `promptVersion`, `transcriptSource`, `diarizationGeneratedAt`, `chunks` (`chunksOk of chunks usable`), `generated`, `cost`; `
      ` one `
    • ` per speaker; `

      {segments} segments · {warnings} warnings

      `. File header comment: **both attribution operations share `attribution.json`** (`operations.ts:789-812`), so `attribution-diarized` and `attribution-text` panels read the same record with their own `state()` — the pill differs, the body does not; the `provenance.method` row is how a reader tells which lane wrote it. The Run button is commit 4. **`lib/videoOperationPanels.ts`** (server-only, no directive): ```ts export type VideoOperationPanel = { view: OperationPanelView; body: OperationPanelBody }; export async function loadVideoOperationPanels(opts: { paths: Paths; channelSlug: string; videoId: string; settings: SiteSettings; }): Promise ``` Calls `inspectVideoOperations`, `.filter(shownOnVideoPage)`, `orderForVideoPage`, and per view loads the body by `settingsBlock`: `"digest"` → `loadDigest` + `loadDigestOverrides` (one `Promise.all`, as `page.tsx:159-163` today), narrows `view.target as { target: DigestFreshnessTarget; sections: DigestSectionKind[] }`, builds `DigestBodyData` with `sections: digestSectionStates(record, sections, target)`, `digest: effectiveDigest(record, overrides)`, `hasMachineDigest: record !== null`, `hasOverrides`, `note`, `remoteEnabled: settings.digest.remoteEnabled`; `"diarization"` → `loadDiarization`, projected; `"attribution"` → `loadAttribution` **once, memoized across the two attribution views**; `undefined` → `{ block: undefined }`. Strips `target` before returning. **`page.tsx`:** delete the imports of `loadDigest`, `loadDigestOverrides`, `effectiveDigest`, `isSectionFresh`, `resolveDigestTarget`, `DigestPanel`/`DigestPanelData`/`DigestSectionState` (`:30-45`); delete `:155-184`; add `const panels = await loadVideoOperationPanels({ paths: getPaths(), channelSlug: slug, videoId: id, settings: getSettings() })` (keep the `getSettings` import); `:275` becomes `{panels.map((p) => )}`. `loadVideoDir`, `loadMeta`, the VTT provenance loop, coverage, `RunningJobsList`, `VideoPanel` and its `umtoolUrl` prop (`:254-273`) are untouched. Comment above the map: the external operations' per-video surface is the stage cards inside `VideoPanel`; these panels are the registry's. **Aria-label table (old → new; which spec pins it):** | old (`DigestPanel.tsx`) | new | pinned by | |---|---|---| | `section[aria-label="digest panel"]` (`:77`) | `"${id} panel"` → `digest panel` | none (kept) | | `h2` "AI digest" (`:81`) | `view.label` → **"Digest"** | none (grep: "AI digest" only in `sections.tsx:154` prose) | | `"digest freshness"` (`:83`) | `"${id} freshness"` → `digest freshness` | `digest.spec.ts:580,585,620,628` | | `"digest shared from"` (`:173`) | unchanged | none | | `"digest empty"` (`:105`) | unchanged | `digest.spec.ts:579` | | `"digest warnings"` / `"digest warnings empty"` (`:215,202`) | unchanged | `:602,605` | | `"digest provenance"` / `"digest section ${kind}"` (`:256,260`) | unchanged | `:592,629` | | `"digest chapters"` / `"digest chapters empty"` (`:343,335`) | unchanged | `:588,671` | | `"digest tags"` (`:367`) | unchanged | none | | "Correct this digest" / `"digest override note"` / "Save corrections" / `role="status"` "Saved." | unchanged | `:648-654` | | "Regenerate digest" / `"lane for Regenerate digest"` (`:130-139`) | unchanged | none | | — | `diarization panel`, `diarization freshness`, `diarization empty`, `diarization provenance`, `diarization summary`, `diarization off` | new e2e (commit 4) | | — | `attribution-diarized panel` / `attribution-text panel`, `… freshness`, `attribution empty`, `attribution provenance`, `attribution speakers`, `attribution speaker ${index}`, `attribution summary` | new e2e (commit 4) | | — | `"${id} outputs"` (no-block arm) | unit-tested shape only | `getByLabel("digest freshness")` is substring-matched; no new label contains that string, and `digest empty` / `digest warnings` retain their `exact` guards. --- ## Commit 4 — `video page: a speaker operation runs for one video` **New `editor/app/channels/[slug]/videos/[id]/operationActions.ts`** (`"use server"`): ```ts // Run ONE registry operation over ONE video, from its panel. The speaker lane only: // digest keeps digestBucketAction (its own lane, its own guards). export async function runOperationForVideoAction( slug: string, videoId: string, operationId: string, ): Promise ``` Body: `const lane = backfillLaneOperations(getSettings())` (`operations.ts:1554`); if no entry has `id === operationId` → `{ ok: false, error: \`${operationLabel(operationId)} is not switched on, so there is nothing to run — enable it on its Operations page.\` }` (a disabled operation's panel does not draw the button; this is the belt); else `runBackfillChannelJob({ paths: getPaths(), channelSlug: slug, kindIds: [operationId], ids: [videoId], onDone: () => { revalidatePath(\`/channels/${slug}/videos/${videoId}\`); revalidatePath(\`/channels/${slug}\`); } })`. No `queueKey` (the lane's own key, `operationJobs.ts:83`); no `background` (a hand-clicked run goes in front of the sweep's — `backfillActions.ts:18`). **`SpeakerBodies.tsx`:** both bodies render, when `view.enabled`, after the record: ```tsx
      runOperationForVideoAction(slug, videoId, view.id)} cancelAction={cancelJobAction} // jobs/actions, as DigestPanel.tsx:33 buttonLabel={`Run ${view.label}`} // "Run Speaker diarization", "Run Speaker names (from the audio)", "Run Speaker names (from the transcript)" runningLabel="Running…" label={`Run ${view.label}`} // → aria-label "Run
      ``` **e2e — `editor/e2e/attribution.spec.ts`, two tests after `:291`, reusing `seedChannel`, `attributionSettings`, `dataRel`, `readAttribution`, `pathExists`, `SLOW`, `CHANNEL`, `DIARIZED`, `TEXTONLY`:** 1. `"the video page draws one panel per operation, with the registry's own state"`: `await seedChannel(); await writeSettings(attributionSettings());` `goto(/channels/${CHANNEL}/videos/${DIARIZED})`: `getByLabel("diarization panel")` visible (shown because `diarization.json` exists while capture is off) and `getByLabel("diarization off")` visible; `getByLabel("diarization provenance")` contains `fake-diarize`; `getByLabel("diarization summary")` contains `2 speakers` (check `diarizationRecord()`); `getByLabel("attribution-diarized freshness")` has text `not generated`; `getByLabel("attribution-text freshness")` has text `not generated`; `getByLabel("attribution empty")` count `2` (both attribution panels, one shared file); `getByLabel("digest freshness")` has text `not generated` (`writeDigestVideo` writes a fresh `cues.json`, so digest is `missing`, not `deferred`); `getByRole("heading", { name: "Speaker names (from the audio)" })` visible. Then `goto(…/${TEXTONLY})`: `getByLabel("diarization panel")` count `0` (off, no sidecar); `getByLabel("attribution-diarized freshness")` has text `waiting on Speaker diarization`. 2. `"Run from the video page runs that video and no other"` (`test.setTimeout(SLOW)`): `seedChannel(); writeSettings(attributionSettings());` `goto(…/${TEXTONLY})`; `getByRole("button", { name: "Run Speaker names (from the transcript)", exact: true }).click()`; `expect(getByLabel("Run Speaker names (from the transcript) output")).toContainText("1 done", { timeout: 90_000 })` (the summary line `operationJobs.ts:120-121`); `readAttribution(TEXTONLY).provenance.method === "text-only"`; `pathExists(dataRel(DIARIZED, "attribution.json")) === false` — DIARIZED is reachable for this operation too, so only the `ids` scope kept it untouched; `expect(getByLabel("attribution-text freshness")).toHaveText("current")` (after `router.refresh()`). **`editor/e2e/video-page.spec.ts`, one assertion appended to the first test (`:11-27`):** `await expect(page.getByLabel("digest freshness")).toContainText("held — waiting on a normalized transcript")` — the fixture has no `cues.json`; this is the first place the digest's deferred reason is visible per video. --- ## Commit 5 — `plans: slice 6 shipped, and the docs say so` - `editor/CHANGELOG.md` `[Unreleased]` first bullet, house voice (the slice 7 bullet at `:4` is the model): the video page shows one panel per operation — Digest as before, and now Speaker diarization and both Speaker names operations, each with what produced its record, the same state word the channel's counts use (*current*, *stale*, *partly stale*, *not generated*, *held — …*, *waiting on …*), and a **Run** button that runs that one video on the speaker lane; a record whose feature is switched off is still shown, marked *switched off*; the digest panel's heading is **Digest** rather than *AI digest* and nothing else about it moved; a replayed per-video run stays per-video; **nothing on disk changes**. - `plans/editor-operations-ia.md:151-156` → SHIPPED with hashes; new "## Slice 6, as shipped" between the Slice 4 (`:324`) and Slice 7 (`:379`) sections, recording: `hasDigest` was the channel count's, not the page's; `hasAttribution` was dead and `hasDiarization` is a cleanup guard and stays; the page re-derived digest `state()` and `digestSectionStates` is now the one fold; the external operations' surface is the stage cards; `ids` on the channel job; the "switched off" case; the vision test now holds for the video page (the no-block arm). - `plans/STATE.md`: "Last updated" prepend; "Recommended next" (`:184`) adds a DONE line. - `plans/FACTS.md`: `## Verified 2026-08-28 — editor IA slice 6 seams` (this census + "As shipped"), after the slice 7 section; the stale `:2316` line about `DigestPanel.tsx:144` ("metered — disabled in Settings", already fixed by slice 3) corrected. - Memory: `ia-slice-6-shipped.md` + a `MEMORY.md` line; amend `ia-slice-7-shipped`'s "next candidates". --- ## Verification 1. After each of commits 1–4: tsc in `common editor export homepage umtool mcp`; `pnpm -C common test` (867 + `videoOperations` tests; re-measure first); editor units (90 + `operationState` tests). 2. Grep gates after commit 3, over `common/ editor/` excluding `node_modules`/`.next`: - `hasDigest\b` → only `common/components/digestCache.ts` and `PlayerProvider.tsx` (a different function: the published manifest check) and the private `hasDigestWithItems` in `channels.ts`; **0** in `digest-server.ts`. - `hasAttribution` → **0** in code (plans/ prose is commit 5's). - `hasDiarization` → unchanged set (`diarization-server.ts`, `cleanAudioFromTranscribed.ts`, `diarizeOne.ts`, `diarization.test.ts`, comments, and the `VideoFiles` field sites). - `isSectionFresh|resolveDigestTarget|effectiveDigest` in `videos/[id]/page.tsx` → **0**. - `DigestPanel\b|DigestPanelData` → **0** anywhere. - `from "yt-dlp-transcript-common/lib/operations"` in any `"use client"` file under `videos/[id]/components` → every hit is `import type`. - `listChannelStatsFromDisk|buildBackfillSweepPlan|buildDigestSweepPlan` under `editor/app` → 0 (run the guard: `pnpm -C common exec tsx --test controller/noCorpusWalkInRenderPaths.test.ts`). - `"AI digest"` → only `sections.tsx:154`. 3. e2e once after commit 5, detached (`cd editor && setsid nohup sh -c 'pnpm e2e -- ; echo exit=$?' > $CLAUDE_JOB_DIR/tmp/e2e.log 2>&1 < /dev/null & disown`): `digest.spec.ts video-page.spec.ts attribution.spec.ts diarization.spec.ts backfill.spec.ts do-not-clean.spec.ts truncated-check.spec.ts incomplete-transcript.spec.ts transcript-source.spec.ts whisper-video.spec.ts saved-videos.spec.ts cleanup-holds.spec.ts reconcile.spec.ts undownloaded.spec.ts reconstruct-download-url.spec.ts download-format-guard.spec.ts media-file-abort.spec.ts auto-report-refresh.spec.ts jobs-active-order.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`): a `writeDigestVideo`-shaped video shows four panels in the order Digest, Speaker diarization, Speaker names (from the audio), Speaker names (from the transcript); the umtool link (with `UMTOOL_URL` set) is above them inside `VideoPanel`; the two-pane `/channels//videos?video=` shows no panels. ## Out of scope - The two-pane `/channels/[slug]/videos?video=` route stays `VideoPanel`-only (`videos/page.tsx:202-224`). - The external operations' cards in `VideoPanel.tsx`; any `Operation` type change; the channel counts' semantics (`hasDigestWithItems` is a move, not a rewrite); slices 5/8/9. - `hasDiarization` (live guard). `readVideoDigestAction` (`digestActions.ts:149`) has zero callers — note it in FACTS, do not delete it here. - `runOperationChannelJob` gaining `ids`; a per-video digest Run via the backfill job (digest keeps `digestBucketAction`); a per-video Run for a disabled operation. - Restyling `StreamActionLog`; any change to `writeDigestVideo` or the ollama stub. ## Handoff — the cadence On approval, Fable does not implement (memory `plan-then-opus-implements`): write the plan to `plans/editor-ia-slice-6.md`, commit it alone, then spawn one `general-purpose` agent, `model: "opus"`, with: the plan path, the fish-shell caveats (`git commit -F `; quote `[slug]`/`[id]` paths and globs; POSIX loops only inside `sh -c`), 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 ce2ebd9..`; `videoOperations.ts` + test, `digest.ts`'s `digestSectionStates` and the `operations.ts:1176` hunk, `channels.ts`, `operationJobs.ts`, `backfillBatch.ts:869-`, `jobReplayRegistry.ts`, `OperationPanel.tsx`, `DigestBody.tsx` diff-against-rename, `SpeakerBodies.tsx`, `operationState.ts` + test, `videoOperationPanels.ts`, `page.tsx`, `operationActions.ts`, the new specs; re-runs grep gates + `pnpm -C common test` + editor units, not e2e), sends fixes via SendMessage, and reports.