Archilyzer · Source

archilyzer

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

commit 415ce1ee09e7772906b77b0b43458440a572e15b
parent dc748c1cf24f9776621e6a89d8bda615eae63c1c
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri, 28 Aug 2026 03:11:47 -0400

editor: /actionable retires and redirects to /operations

`editor/app/actionable/` is gone. With it: the `speakers` and `stale-reports`
configs (the lane sections on /operations and the Report column on /channels
are what they became), `laneAction`, and the hand-exhaustive `nothingPending`
gate that could collapse the whole page to "everything is up to date" while a
section it forgot to list had rows in it.

The census loses what only that page read: the `backfill` and `staleOrMissing`
lists, and `actionableBackfillCount` / `actionableBackfillMissingInputCount`.
No other consumer — the guard those two documented lives in
`backfillLaneEntriesOf`, tested at operations.test.ts:1503. `reportStateOf`
stays; /channels draws its Report column from it.

`/actionable` redirects to `/operations`, temporary (307) for the same reason
`/auto-queue` is: a 308 is cached by the browser forever and this is a
self-hosted admin surface where a wrong permanent redirect has no remedy but a
profile wipe. Query strings pass through. `/api/widget/actionable` is NOT
redirected and keeps its name — it is a wire contract a pinned widget is
polling right now.

The nav is twelve: the Actionable entry and its `TriangleAlert` import are
deleted, and "actionable needs attention todo" are keywords on Operations so
the palette still takes you somewhere for the word.

Every `revalidatePath("/actionable")` is gone; the targets added in commit 2
are what remain.

The prose sweep is comments only, across common/ and editor/: the surfaces that
named /actionable now name the operation pages, /channels, /cleanup, /review or
the widget payload, whichever is actually true of each one. Two spec comments
and one test title that still described the old page follow.

navigation.spec.ts drops /actionable from the heavy routes and gains the
retired-route guard in the shape auto-queue.spec.ts already uses: /actionable
lands on /operations with the Operations heading.

Nothing under transcripts/ was read or written for this commit.

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

Diffstat:
Mcommon/controller/channelSnapshot.ts | 2+-
Mcommon/controller/channels.ts | 2+-
Mcommon/controller/digestSharing.ts | 2+-
Mcommon/jobs/snapshotScheduler.ts | 1-
Mcommon/lib/duplicates.ts | 2+-
Mcommon/lib/operations.test.ts | 2+-
Mcommon/lib/operations.ts | 15++++++++-------
Deditor/app/actionable/page.tsx | 127-------------------------------------------------------------------------------
Meditor/app/api/widget/actionable/route.ts | 10+++++++---
Meditor/app/channels/[slug]/components/stages/DigestStage.tsx | 2+-
Meditor/app/channels/[slug]/components/stages/SpeakersStage.tsx | 2+-
Meditor/app/channels/[slug]/incompleteTranscriptActions.ts | 4++--
Meditor/app/channels/[slug]/pipelineActions.ts | 1-
Meditor/app/channels/actions.ts | 1-
Meditor/app/components/actions/InlineActionButton.tsx | 2+-
Meditor/app/components/channelWork/sections.test.ts | 2--
Meditor/app/components/lanes/LaneDeck.tsx | 8++++----
Meditor/app/lib/actionable/actions.ts | 2--
Meditor/app/lib/actionable/loadActionable.ts | 47-----------------------------------------------
Meditor/app/lib/nav.ts | 8++------
Meditor/app/lib/requestCache.ts | 10+++++-----
Meditor/app/widget/components/MonitorWidget.tsx | 4++--
Meditor/e2e/attribution.spec.ts | 2+-
Meditor/e2e/backfill.spec.ts | 2+-
Meditor/e2e/helpers.ts | 2+-
Meditor/e2e/navigation.spec.ts | 17++++++++++++++---
Meditor/e2e/widget.spec.ts | 4++--
Meditor/next.config.ts | 11+++++++++--
28 files changed, 66 insertions(+), 228 deletions(-)

diff --git a/common/controller/channelSnapshot.ts b/common/controller/channelSnapshot.ts @@ -266,7 +266,7 @@ export type ChannelSnapshot = { // permanently-gone / needs_auth / error — what verifyBeforeClean is likely to // refuse at sweep time. An annotation on the remainder, deliberately NOT a // fifth gate: the real gate needs a live probe, and folding a guess into the - // hero figure would move the sidebar badge and /actionable's Est. reclaim too. + // hero figure would move the sidebar badge and /cleanup's Est. reclaim too. reclaimableAtRiskBytes?: number; }; diff --git a/common/controller/channels.ts b/common/controller/channels.ts @@ -238,7 +238,7 @@ export async function listChannelStatsFromDisk( downloadCount: counts.downloads, // `countDataFiles` has always computed this and the corpus walk has // always discarded it — unlike readChannelStat, which emits it. One line, - // and every cross-channel surface (the dashboard, /actionable, the + // and every cross-channel surface (the dashboard, /channels, the // widget) gets a coverage counter it was already paying the I/O for. digestCount: counts.digests, }); diff --git a/common/controller/digestSharing.ts b/common/controller/digestSharing.ts @@ -280,7 +280,7 @@ export async function shareDigestToCluster( return outcomes; } -// Convenience for the /actionable per-cluster action: share from whatever the +// Convenience for /review's per-cluster action: share from whatever the // effective canonical member currently is. export async function shareClusterFromCanonical( paths: Paths, diff --git a/common/jobs/snapshotScheduler.ts b/common/jobs/snapshotScheduler.ts @@ -200,7 +200,6 @@ async function fire(): Promise<void> { const { revalidatePath } = await import("next/cache"); for (const slug of regenerated) revalidatePath(`/channels/${slug}`); revalidatePath("/channels"); - revalidatePath("/actionable"); revalidatePath("/operations/[id]", "page"); revalidatePath("/cleanup"); revalidatePath("/"); diff --git a/common/lib/duplicates.ts b/common/lib/duplicates.ts @@ -329,7 +329,7 @@ export function resolveCanonicalSlug( } // Whether a human has recorded a decision for this cluster. Drives the -// /actionable "awaiting review" list — a cluster with no decision is work. +// /review "awaiting review" list — a cluster with no decision is work. export function isClusterReviewed( cluster: DuplicateCluster, overrides?: DuplicateOverrides | null, diff --git a/common/lib/operations.test.ts b/common/lib/operations.test.ts @@ -1506,7 +1506,7 @@ test("backfillLaneEntriesOf keeps a digest entry OUT of the lane's sums", () => // Object.values(snapshot.backfill) on the assumption that the map WAS the // backfill lane. Now that the snapshot carries an entry per catalog operation, // that assumption would fold ~75,000 digest videos into the dashboard's - // backfill instrument, /actionable's backfill rows and the widget. + // backfill instrument, the /channels speaker columns and the widget. const backfill = { diarization: { ...emptyOperationCounts(), missing: 3, ids: [], eligible: 3 }, digest: { diff --git a/common/lib/operations.ts b/common/lib/operations.ts @@ -56,9 +56,9 @@ // registry with one entry is a wrapper: nothing proved that "a feature declares // what it needs and the system supplies the lane, the share and the indicator" // was true. Attribution is the test of it, and it passed — registering -// `attribution-diarized` and `attribution-text` lit the channel stage card, -// /actionable, the dashboard instrument and the widget strip with ZERO UI -// changes, because all four iterate snapshot.backfill[operationId]. +// `attribution-diarized` and `attribution-text` lit the channel stage card, the +// /channels speaker columns, the dashboard instrument and the widget strip with +// ZERO UI changes, because all four iterate snapshot.backfill[operationId]. // // It also exercised the parts of the shape that one entry could not: // `missing-input` for something other than audio (diarization.json, of which @@ -1528,8 +1528,9 @@ export function allOperations(settings: SiteSettings): Operation[] { // // The queue filter is a SAFETY RAIL, not a tidy-up. Everything downstream of // this function — backfillBatch's dispatch, the channel Backfill card, the -// dashboard's backfill instrument, /actionable's backfill rows — treats these -// as "one lane, one job, one set of counters". Digest satisfies none of that: +// dashboard's backfill instrument, the /channels speaker columns — treats +// these as "one lane, one job, one set of counters". Digest satisfies none of +// that: // // - DISPATCH. backfillBatch runs its kinds in one job on one queue under // backfillLimit(). Handing it digest would SERIALIZE the GPU digest lane @@ -1571,8 +1572,8 @@ export function backfillLaneOperations(settings: SiteSettings): Operation[] { // THIS EXISTS BECAUSE THE SNAPSHOT MAP STOPPED BEING THE LANE. It used to be // written from backfillLaneOperations, so `Object.values(snapshot.backfill)` and "the // backfill lane" were the same set by construction, and four surfaces summed it -// generically on that basis — the channel dashboard's backfill instrument, -// /actionable's two backfill functions and the widget's sync payload. The moment +// generically on that basis — the channel dashboard's backfill instrument, the +// channel page's speakers stage and the widget's sync payload. The moment // channelSnapshot writes an entry per CATALOG operation, that identity breaks: // those four would silently absorb ~75,000 digest videos into a number that has // only ever meant diarization plus attribution. diff --git a/editor/app/actionable/page.tsx b/editor/app/actionable/page.tsx @@ -1,127 +0,0 @@ -import type { Metadata } from "next"; -import { getPaths } from "yt-dlp-transcript-common/lib/paths"; -import { getSettings } from "yt-dlp-transcript-common/lib/settings"; -import { - backfillLaneOperations, - operationsActionLabel, -} from "yt-dlp-transcript-common/lib/operations"; -import { - actionableBackfillCount, - actionableBackfillMissingInputCount, - loadActionableSummary, -} from "../lib/actionable/loadActionable"; -import { - channelWorkSections, - type SectionConfig, -} from "../components/channelWork/sections"; -import { ChannelWorkTable } from "../components/channelWork/ChannelWorkTable"; -import { InlineActionButton } from "../components/actions/InlineActionButton"; -import { RefreshAllReportsButton } from "../channels/components/RefreshAllReportsButton"; - -export const dynamic = "force-dynamic"; - -export const metadata: Metadata = { title: "Actionable" }; - -export default async function ActionablePage() { - const paths = getPaths(); - const summary = await loadActionableSummary(paths); - // What the derived-data lane is actually called, given which operations are - // enabled. "Backfill" is its queue key; on this install it stands for three - // operations, and no button anyone presses should be named after a queue. - const laneAction = `Run ${operationsActionLabel( - backfillLaneOperations(getSettings()).map((k) => k.id), - )}`; - const nothingPending = - summary.undownloaded.length === 0 && - summary.missingNeverFetched.length === 0 && - summary.untranscribed.length === 0 && - summary.incompleteTranscripts.length === 0 && - summary.shortAudio.length === 0 && - summary.cleanTranscribedAudio.length === 0 && - summary.cleanExtraFormats.length === 0 && - summary.staleOrMissing.length === 0 && - summary.digestWarnings.length === 0 && - // HAND-EXHAUSTIVE, and a section missing from here is a section that never - // renders — the whole page collapses to "nothing pending" while its own - // list has rows in it. - summary.backfill.length === 0; - - // The two configs that do NOT move to components/channelWork: they die with - // this page. `speakers` becomes the lane sections on /operations, and - // `stale-reports` becomes the Report column on /channels. - const speakersConfig: SectionConfig = { - // "speakers" is the backfill lane's section: named for the operations - // it holds, as the channel page's speakers card and the /channels - // station are, not for the queue they share. - id: "speakers", - operation: null, - role: "work", - getRows: (s) => s.backfill, - title: "Channels missing derived data the corpus predates", - description: - `Videos an enabled derived-data operation has nothing on disk for — no record, or one produced by a different engine/model/threshold than the current settings. Run “${laneAction}” to catch them up. The count is what the lane can do TODAY; the second column is the separate population whose source media has already been deleted, which needs the opt-in re-download to reach at all.`, - countLabel: "reachable", - emptyLabel: "Nothing pending.", - getCount: actionableBackfillCount, - // Same treatment "Est. reclaim" gets, and for a stronger reason: these - // two numbers differ by ~91x on the real corpus, so a single total would - // be dominated by work no button on this page can start. - extraColumn: { - label: "Needs media", - getValue: (r) => actionableBackfillMissingInputCount(r).toLocaleString(), - }, - primaryAction: (r) => ( - <InlineActionButton - variant={{ kind: "backfillChannel", slug: r.channel.slug }} - // Named after the operations that are actually on, resolved here on - // the server. The button used to read "Backfill", which is the - // queue key three unrelated operations happen to share. - label={laneAction} - /> - ), - }; - - const staleReportsConfig: SectionConfig = { - id: "stale-reports", - operation: null, - role: "attention", - getRows: (s) => s.staleOrMissing, - title: "Channels with stale or missing reports", - description: - "Reports are older than the most recent sync (or have never been generated). The counts above might be wrong until you refresh.", - countLabel: "report age", - emptyLabel: "All reports are current.", - getCount: () => 0, - primaryAction: (r) => ( - <InlineActionButton - variant={{ kind: "refreshReport", slug: r.channel.slug }} - /> - ), - }; - - return ( - <div className="flex flex-col gap-6"> - <div className="flex items-center justify-between flex-wrap gap-2"> - <h1 className="text-2xl font-semibold">Actionable items</h1> - <RefreshAllReportsButton /> - </div> - {nothingPending ? ( - <p - aria-label="actionable items empty" - className="text-sm text-muted-foreground border border-dashed border-border rounded p-4" - > - Everything is up to date. - </p> - ) : ( - <ChannelWorkTable - sections={[ - ...channelWorkSections(), - speakersConfig, - staleReportsConfig, - ]} - summary={summary} - /> - )} - </div> - ); -} diff --git a/editor/app/api/widget/actionable/route.ts b/editor/app/api/widget/actionable/route.ts @@ -29,9 +29,13 @@ export type WidgetActionablePayload = { }; // Backs the monitor widget's optional "Needs work" strip. Reuses the same -// actionable summary that powers the /actionable page, reduced to the two -// buckets a widget acts on — videos to download and videos to transcribe — for -// each channel that has any, sorted by the larger backlog first. +// actionable census the operation pages and the dashboard draw, reduced to the +// two buckets a widget acts on — videos to download and videos to transcribe — +// for each channel that has any, sorted by the larger backlog first. +// +// THE PATH KEEPS ITS NAME. /actionable the page is gone; /api/widget/actionable +// is a wire contract a pinned widget in someone's browser is polling right now, +// and renaming it would break that for no gain. export async function GET() { const summary = await loadActionableSummary(getPaths()); const channels = summary.rows diff --git a/editor/app/channels/[slug]/components/stages/DigestStage.tsx b/editor/app/channels/[slug]/components/stages/DigestStage.tsx @@ -2,7 +2,7 @@ // The minimum surface needed to RUN a digest sweep on a channel: a count, a lane // picker, and a button. Deliberately not a review UI — the per-video review -// panel, the /actionable cluster section and the settings form are a separate +// panel, /review's cluster section and the settings form are a separate // piece of work. What this exists for is that the sweep has to be startable from // a browser at all, both for an operator and for the e2e suite that proves the // job path end to end. diff --git a/editor/app/channels/[slug]/components/stages/SpeakersStage.tsx b/editor/app/channels/[slug]/components/stages/SpeakersStage.tsx @@ -120,7 +120,7 @@ export function SpeakersStage({ // Every label on this card says "speakers": the section, its populations // and its rows are named for the operations the lane holds, never for // the queue key they share. "speakers" is the STATION name — the same id - // the /channels group line and the /actionable section use — and the + // the /channels station and the operation pages use — and the // visible heading stays derived (Speaker work / Derived data). sectionLabel="speakers section" heading={ diff --git a/editor/app/channels/[slug]/incompleteTranscriptActions.ts b/editor/app/channels/[slug]/incompleteTranscriptActions.ts @@ -32,8 +32,8 @@ function dedupeIds(ids: string[]): string[] { // brings the runner up now without a server restart (same path the auto-queue // admin page and /api/auto-queue/control use). NOTE: the runner only picks up a // channel its policy tree actually matches — cleared videos also surface in the -// manual "Download missing" / "Transcribe pending" actionable sections as a -// fallback. +// manual "Download missing" / "Transcribe pending" sections on the operation +// pages as a fallback. export async function enableAutoRunners(): Promise<void> { const current = getSettings(); if ( diff --git a/editor/app/channels/[slug]/pipelineActions.ts b/editor/app/channels/[slug]/pipelineActions.ts @@ -210,7 +210,6 @@ async function runPipelineAction( // runManagedFunction's completion. See common/jobs/snapshotScheduler.ts. revalidatePath(`/channels/${slug}`); revalidatePath("/channels"); - revalidatePath("/actionable"); revalidatePath("/operations/[id]", "page"); revalidatePath("/cleanup"); revalidatePath("/"); diff --git a/editor/app/channels/actions.ts b/editor/app/channels/actions.ts @@ -384,7 +384,6 @@ export async function refreshAllChannelSnapshotsAction(): Promise<RefreshAllResu // jobs all run in parallel, so this waits roughly the time of the // slowest snapshot, not the sum. await Promise.all(streams.map(drainStream)); - revalidatePath("/actionable"); revalidatePath("/channels"); revalidatePath("/operations/[id]", "page"); revalidatePath("/cleanup"); diff --git a/editor/app/components/actions/InlineActionButton.tsx b/editor/app/components/actions/InlineActionButton.tsx @@ -49,7 +49,7 @@ const LABEL: Record<Variant["kind"], { idle: string; running: string }> = { cleanExtraFormats: { idle: "Clean extra formats", running: "Queuing…" }, // "Backfill" is a queue key, not a thing anyone asked for. The real name // depends on which operations are enabled, which only the server knows — so - // this is the fallback and `label` overrides it. See /actionable's page. + // this is the fallback and `label` overrides it. See sections.tsx. backfillChannel: { idle: "Run derived data", running: "Queuing…" }, digestChannel: { idle: "Digest channel", running: "Queuing…" }, refreshReport: { idle: "Refresh report", running: "Refreshing…" }, diff --git a/editor/app/components/channelWork/sections.test.ts b/editor/app/components/channelWork/sections.test.ts @@ -27,9 +27,7 @@ function distinctSummary(): ActionableSummary { shortAudio: fresh(), cleanTranscribedAudio: fresh(), cleanExtraFormats: fresh(), - staleOrMissing: fresh(), digestWarnings: fresh(), - backfill: fresh(), }; } diff --git a/editor/app/components/lanes/LaneDeck.tsx b/editor/app/components/lanes/LaneDeck.tsx @@ -73,8 +73,8 @@ export function LaneDeck({ const disk = jobs?.disk ?? null; const diskLow = disk?.low ?? false; - // The two backlogs, summed over the channels that HAVE one — /actionable - // already filters to those, so the channel count is the number of channels + // The two backlogs, summed over the channels that HAVE one — the widget + // payload already filters to those, so the channel count is the number of channels // carrying that particular bucket rather than the array length (a channel with // downloads outstanding and nothing to transcribe is in the array, and must // not be counted in the transcription sentence). @@ -604,8 +604,8 @@ function sumBacklog( // "12,486 videos awaiting transcription across 31 channels". // // A backlog of zero is a real, useful answer here — unlike formatCount's dash, -// which stands for a measurement nobody took. The distinction is that /actionable -// reported and found nothing, versus not having reported at all. +// which stands for a measurement nobody took. The distinction is that the +// widget payload reported and found nothing, versus not having reported at all. // // `empty` is its own phrase rather than "nothing " + verb, because the verbs are // not all positive: the downloads lane's is "not downloaded", and the composed diff --git a/editor/app/lib/actionable/actions.ts b/editor/app/lib/actionable/actions.ts @@ -40,7 +40,6 @@ export async function clearAllIncompleteTranscriptsAction(): Promise<GlobalIncom cleared += r.succeeded; } await enableAutoRunners(); - revalidatePath("/actionable"); revalidatePath("/operations/[id]", "page"); return { ok: true, channels: slugs.length, affected: cleared }; } @@ -61,7 +60,6 @@ export async function redownloadAllIncompleteTranscriptsAction(): Promise<Global queued++; } } - revalidatePath("/actionable"); revalidatePath("/operations/[id]", "page"); return { ok: true, channels: slugs.length, affected: queued }; } diff --git a/editor/app/lib/actionable/loadActionable.ts b/editor/app/lib/actionable/loadActionable.ts @@ -1,9 +1,5 @@ import type { Paths } from "yt-dlp-transcript-common/lib/paths"; import { cache } from "react"; -import { - backfillLaneEntriesOf, - reachableOperationWork, -} from "yt-dlp-transcript-common/lib/operations"; import type { ChannelBrief } from "yt-dlp-transcript-common/controller/channels"; import { getChannelBriefs } from "../requestCache"; import { @@ -28,9 +24,7 @@ export type ActionableSummary = { shortAudio: ActionableRow[]; cleanTranscribedAudio: ActionableRow[]; cleanExtraFormats: ActionableRow[]; - staleOrMissing: ActionableRow[]; digestWarnings: ActionableRow[]; - backfill: ActionableRow[]; }; // How old a channel's report is, in the three states /channels draws: @@ -136,37 +130,6 @@ export function actionableDigestWarningsCount(row: ActionableRow): number { return row.snapshot?.buckets.digestWarnings?.length ?? 0; } -// The backfill lane's two numbers, and they are two FUNCTIONS on purpose so no -// caller can accidentally add them. -// -// `reachable` (missing + stale) is what the lane can do today and the only thing -// that decides whether a channel appears in the section at all. `missingInput` -// is the population that needs its media re-acquired first — measured at ~91x -// the reachable count corpus-wide, so filtering on it would put every channel in -// the list forever. That is not a hypothetical: it is the documented reason -// /api/widget/actionable refuses to filter on the digest work count. -// backfillLaneEntriesOf, not Object.values: the snapshot map is every catalog operation -// now, and digest is one of them. These two functions decide whether a channel -// appears in the BACKFILL section at all, so folding a ~75,000-video operation -// that runs on another queue into them would put every channel in the list -// forever — the same trap /api/widget/actionable documents for the digest work -// count, hit from the other direction. -export function actionableBackfillCount(row: ActionableRow): number { - return backfillLaneEntriesOf(row.snapshot?.backfill).reduce( - (n, e) => n + reachableOperationWork(e), - 0, - ); -} - -export function actionableBackfillMissingInputCount( - row: ActionableRow, -): number { - return backfillLaneEntriesOf(row.snapshot?.backfill).reduce( - (n, e) => n + e.missingInput, - 0, - ); -} - // Estimated bytes each cleanup would reclaim (default 0 for snapshots written // before cleanupBytes existed). export function actionableCleanTranscribedBytes(row: ActionableRow): number { @@ -242,14 +205,6 @@ export async function loadActionableSummary( actionableDigestWarningsCount(b) - actionableDigestWarningsCount(a), ); - const backfill = rows - .filter((r) => actionableBackfillCount(r) > 0) - .sort((a, b) => actionableBackfillCount(b) - actionableBackfillCount(a)); - - const staleOrMissing = rows - .filter(isStaleOrMissing) - .sort((a, b) => a.channel.slug.localeCompare(b.channel.slug)); - return { rows, undownloaded, @@ -259,9 +214,7 @@ export async function loadActionableSummary( shortAudio, cleanTranscribedAudio, cleanExtraFormats, - staleOrMissing, digestWarnings, - backfill, }; } diff --git a/editor/app/lib/nav.ts b/editor/app/lib/nav.ts @@ -15,7 +15,6 @@ import { Rocket, ScrollText, Settings, - TriangleAlert, Tv, Bookmark, Trash2, @@ -42,7 +41,7 @@ import { // // So: FOLD, DO NOT ADD, and every retired route REDIRECTS rather than 404s // (see editor/next.config.ts). The entries marked "interim" below are the ones -// later slices fold into the page above them; the end state is eleven. +// later slices fold into the page above them; the end state is twelve. export type NavBadgeKey = "jobs" | "running" | "changelog" | "cleanable"; @@ -81,11 +80,8 @@ export const NAV_GROUPS: NavGroup[] = [ href: "/operations", label: "Operations", icon: ListPlus, - keywords: "auto queue runner policy lane sweep pipeline digest diarization attribution download transcription", + keywords: "auto queue runner policy lane sweep pipeline digest diarization attribution download transcription actionable needs attention todo", }, - // Interim, until slice 4 moves its per-operation sections onto - // /operations/<id> and the rest onto /cleanup, Sites and the dashboard. - { href: "/actionable", label: "Actionable", icon: TriangleAlert, keywords: "needs attention todo" }, // Interim, until slice 8 makes this /operations/sync. { href: "/scheduler", label: "Schedule", icon: CalendarClock, keywords: "sync cron cadence" }, ], diff --git a/editor/app/lib/requestCache.ts b/editor/app/lib/requestCache.ts @@ -6,9 +6,9 @@ import { getSettings } from "yt-dlp-transcript-common/lib/settings"; // Per-REQUEST memoization for loaders that more than one component on the same // page reaches for. The dashboard derives the channel list three separate ways -// in a single render (the layout's reclaimable-disk badge, loadActionableSummary -// and buildWidgetSyncPayload); without this it reads the same 65 files each -// time. +// in a single render (the layout's reclaimable-disk badge, the actionable +// census and buildWidgetSyncPayload); without this it reads the same 65 files +// each time. // // `React.cache` is scoped to one request, so there is no staleness risk here by // construction — the request boundary is already the consistency boundary, and @@ -26,8 +26,8 @@ import { getSettings } from "yt-dlp-transcript-common/lib/settings"; // call. Pass it straight through; don't spread or rebuild it. // // (loadActionableSummary gets the same treatment, but its cached wrapper lives -// beside it in ./actionable/loadActionable — this module deliberately -// imports nothing from app/ so those loaders can import IT.) +// beside it in ./actionable/loadActionable — this module deliberately imports +// nothing from app/ so those loaders can import IT.) export const getChannelBriefs = cache((paths: Paths) => listChannelBriefs(paths), ); diff --git a/editor/app/widget/components/MonitorWidget.tsx b/editor/app/widget/components/MonitorWidget.tsx @@ -824,7 +824,7 @@ function ListOverflowToggle({ // echoing the ↓ (download) / ✎ (transcribe) verbs used by the task bars so the // worklist reads in the same language. Read-only counts by default; when the // widget is interactive (controls=1), each row gains the same per-channel -// Download/Transcribe buttons as the /actionable page. +// Download/Transcribe buttons as the operation pages. const ACTIONABLE_LIMIT = 6; function ActionableStrip({ @@ -914,7 +914,7 @@ function ActionableStrip({ // Compact "needs cleaning" list: the channels holding the reclaimable audio the // Cleanable-data strip totals (same sweep, so the rows sum to that number). // Read-only bytes by default; when the widget is interactive (controls=1) each -// row gains the same per-channel Clean audio button as the /actionable page — +// row gains the same per-channel Clean audio button as /cleanup — // its own window.confirm covers the destructive step. const CLEANABLE_LIMIT = 6; diff --git a/editor/e2e/attribution.spec.ts b/editor/e2e/attribution.spec.ts @@ -290,7 +290,7 @@ test("the text lane will not overwrite a record made from the audio", async ({ // 4. The indicators, which were supposed to need no UI work at all // --------------------------------------------------------------------------- -test("the stage card and /actionable show attribution beside diarization, with the two numbers still apart", async ({ +test("the stage card and the operation pages show attribution beside diarization, with the two numbers still apart", async ({ page, }) => { test.setTimeout(SLOW); diff --git a/editor/e2e/backfill.spec.ts b/editor/e2e/backfill.spec.ts @@ -1104,7 +1104,7 @@ test("re-acquired audio is still removed when no auto-transcribe policy would ta // operation, not just the ones on the backfill lane — that is what makes the // digest kind's state() reachable by anything at all. Four surfaces used to sum // `Object.values(snapshot.backfill)` on the assumption that the map WAS the -// lane: the channel Backfill card, /actionable's two backfill functions and the +// lane: the channel Backfill card, the /channels speaker columns and the // widget sync payload. On the measured corpus the digest entry is ~75,000 // videos against the lane's 9, so getting this wrong does not shift a number // slightly — it replaces it. diff --git a/editor/e2e/helpers.ts b/editor/e2e/helpers.ts @@ -41,7 +41,7 @@ export async function resetData(fixtureName: string | null = null) { // operation with linear backoff on exactly that errno set (also EBUSY/EPERM), // which is enough for a runner that is about to notice its job is gone. // Observed as two unrelated-looking full-suite failures at - // actionable.spec:181 and pipeline.spec:106; both pass in isolation. + // channel-work.spec and pipeline.spec:106; both pass in isolation. await rm(testTranscriptsDir, { recursive: true, force: true, diff --git a/editor/e2e/navigation.spec.ts b/editor/e2e/navigation.spec.ts @@ -32,15 +32,15 @@ import { resetData } from "./helpers"; // skeletons. What IS worth pinning is the thing users feel: these routes // arrive quickly, the chrome never locks up, and a missing thing still 404s. -// Sidebar links that used to cost seconds each. /channels and /actionable were -// the worst (4.5 s and 5.4 s) because each rendered a full corpus walk. +// Sidebar links that used to cost seconds each. /channels and the retired +// /actionable were the worst (4.5 s and 5.4 s) because each rendered a full +// corpus walk. const HEAVY_ROUTES = [ { link: "Channels", heading: "Channels", path: "/channels" }, // The board: one row per operation, built from the registry catalog, with the // arbiter above them. It reads every channel's snapshot to draw the rail, so // it belongs with the heavy routes rather than beside the cheap ones. { link: "Operations", heading: "Operations", path: "/operations" }, - { link: "Actionable", heading: "Actionable items", path: "/actionable" }, { link: "Jobs", heading: "Jobs", path: "/jobs" }, ] as const; @@ -102,6 +102,17 @@ test.describe("navigation", () => { // status before the page body decides, silently turning every "not found" // into a 200. That is invisible in a browser and wrong for anything that // reads the status. + // A RETIRED ROUTE REDIRECTS, NEVER 404s — the nav rule this repo shares with + // umtool (see editor/app/lib/nav.ts). /actionable was four answers to "what + // needs doing" on one page; the board is where a bookmark lands now. + test("a retired route redirects, never 404s", async ({ page }) => { + await page.goto("/actionable"); + await expect(page).toHaveURL(/\/operations$/); + await expect( + page.getByRole("heading", { name: "Operations", exact: true }), + ).toBeVisible(); + }); + test("a missing resource returns 404, not 200", async ({ page }) => { for (const url of [ "/channels/definitely-not-a-channel", diff --git a/editor/e2e/widget.spec.ts b/editor/e2e/widget.spec.ts @@ -248,7 +248,7 @@ test("act=1&controls=1 gives the list per-channel Download actions", async ({ }); await expect(download).toBeVisible({ timeout: 10_000 }); - // Reuses the /actionable inline action: clicking queues a download-missing job. + // Reuses the shared inline action: clicking queues a download-missing job. await download.click(); await expect( page.getByLabel("download missing test-youtube job"), @@ -317,7 +317,7 @@ test("cleanlist=1&controls=1 gives the list per-channel Clean audio actions", as }); await expect(clean).toBeVisible({ timeout: 10_000 }); - // Reuses the /actionable inline action, confirm dialog included: accepting + // Reuses the shared inline action, confirm dialog included: accepting // queues a clean-audio-transcribed job. page.on("dialog", (d) => d.accept()); await clean.click(); diff --git a/editor/next.config.ts b/editor/next.config.ts @@ -50,13 +50,20 @@ const nextConfig: NextConfig = { // for two runner lanes and grew into a board over six operations; it is // /operations now, one page per operation. // + // /actionable was FOUR answers to "what needs doing" on one page; its + // sections live with the operation that runs them, with /channels, with + // /cleanup and with /review now, and the board is where a bookmark lands. + // // TEMPORARY, not permanent: a 308 is cached by the browser forever, and this // is a self-hosted admin surface where a wrong permanent redirect is a - // support call with no remedy but a profile wipe. The API paths under - // /api/auto-queue/* are NOT redirected — they never moved. + // support call with no remedy but a profile wipe. `permanent: false` is a + // 307, and query strings pass through to the destination. The API paths under + // /api/auto-queue/* and /api/widget/actionable are NOT redirected — they + // never moved. async redirects() { return [ { source: "/auto-queue", destination: "/operations", permanent: false }, + { source: "/actionable", destination: "/operations", permanent: false }, ]; }, // Serve the built export artifacts (stats/summaries/transcripts) through a