Archilyzer · Source

archilyzer

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

commit 2e5c7394d997acfc0cff24bd57c9deb2a71a4565
parent f7f2f1898713ac7139af4e6a63790529ae2f07f3
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sat, 29 Aug 2026 18:02:41 -0400

operations: sync has a rail row and a page, and /scheduler redirects

The board's sync panel is a rail row now — first, upstream of everything else,
with the same link/dot/state-word anatomy as the rest and its own figures in
channels. It is composed rather than folded from a band on purpose: a band's
five populations are videos, and drawing one for a channel-scoped operation
would put "coverage unknown" over a hollow outline.

/operations/sync is the schedule. The page builds a cadence console as a server
slot when `op.trigger === "cadence"` — never off the id — and OperationDetail
renders it where a runner or a sweep lane would otherwise be; NoConsoleView is
still the fallthrough for a registered operation nothing drives. The four view
components and cadence.ts move under operations/components/sync/ by git mv, so
this reviews as a rename. Only scheduler/page.tsx is deleted: runTick, the
heartbeat, the status payload and the actions are the sync operation's RUNNER
and are imported by instrumentation, both API routes and two forms.

/scheduler answers with a 307. The API paths are deliberately not redirected.
The nav's interim Schedule entry is gone and its keywords fold into Operations.

The two Storage chores that ride this heartbeat are labelled as such —
in runTick's comments, in the skip reasons, on the console under Recent ticks
and in the Run-now message, which now names them off the POST body it already
carried. No tick logic changed.

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

Diffstat:
Meditor/app/api/scheduler/status/route.ts | 2+-
Meditor/app/lib/nav.ts | 5+----
Meditor/app/operations/[id]/page.tsx | 19++++++++++++++++++-
Meditor/app/operations/components/HowPriorityWorks.tsx | 2+-
Meditor/app/operations/components/OperationDetail.tsx | 25+++++++++++++++++++++++++
Meditor/app/operations/components/OperationRail.tsx | 96+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/operations/components/OperationsBoard.tsx | 87++++++-------------------------------------------------------------------------
Aeditor/app/operations/components/sync/BulkCadenceBar.tsx | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/operations/components/sync/ChannelCadenceEditor.tsx | 127+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/operations/components/sync/SchedulerSettingsForm.tsx | 119+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/operations/components/sync/SyncConsole.tsx | 372+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Reditor/app/scheduler/components/cadence.ts -> editor/app/operations/components/sync/cadence.ts | 0
Meditor/app/operations/syncRow.ts | 18+++++++++++++++---
Meditor/app/saved-videos/components/SavedVideosControls.tsx | 5+++--
Meditor/app/scheduler/actions.ts | 4++--
Deditor/app/scheduler/components/BulkCadenceBar.tsx | 99-------------------------------------------------------------------------------
Deditor/app/scheduler/components/ChannelCadenceEditor.tsx | 127-------------------------------------------------------------------------------
Deditor/app/scheduler/components/SchedulerSettingsForm.tsx | 119-------------------------------------------------------------------------------
Deditor/app/scheduler/components/SchedulerView.tsx | 329-------------------------------------------------------------------------------
Deditor/app/scheduler/page.tsx | 36------------------------------------
Meditor/app/scheduler/runTick.ts | 41+++++++++++++++++++++++++++++------------
Meditor/e2e/auto-queue.spec.ts | 11+++++++++--
Meditor/e2e/navigation.spec.ts | 7+++++++
Meditor/e2e/scheduler.spec.ts | 36+++++++++++++++++++++++-------------
Meditor/next.config.ts | 5+++++
25 files changed, 958 insertions(+), 832 deletions(-)

diff --git a/editor/app/api/scheduler/status/route.ts b/editor/app/api/scheduler/status/route.ts @@ -3,7 +3,7 @@ import { buildSchedulerStatusPayload } from "../../../scheduler/status"; export const dynamic = "force-dynamic"; -// Read-only view for the "Sync schedule" panel: the resolved per-channel +// Read-only view for the /operations/sync console: the resolved per-channel // schedule (next due / last outcome / backoff) plus the recent tick log and the // effective scheduler settings. Backs a passive UI poll, like /api/jobs/active. export async function GET() { diff --git a/editor/app/lib/nav.ts b/editor/app/lib/nav.ts @@ -1,7 +1,6 @@ import type { LucideIcon } from "lucide-react"; import { Activity, - CalendarClock, ChartColumnBig, ClipboardCheck, Cpu, @@ -80,10 +79,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 actionable needs attention todo", + keywords: "auto queue runner policy lane sweep pipeline digest diarization attribution download transcription actionable needs attention todo sync schedule cadence cron", }, - // Interim, until slice 8 makes this /operations/sync. - { href: "/scheduler", label: "Schedule", icon: CalendarClock, keywords: "sync cron cadence" }, ], }, { diff --git a/editor/app/operations/[id]/page.tsx b/editor/app/operations/[id]/page.tsx @@ -17,7 +17,10 @@ import { import type { AutoQueueKind } from "yt-dlp-transcript-common/jobs/autoQueueState"; import { getRegistry } from "yt-dlp-transcript-common/jobs/registry"; import { buildAutoQueueStatusPayload } from "../status"; +import { buildSyncRow } from "../syncRow"; +import { buildSchedulerStatusPayload } from "../../scheduler/status"; import { sweepLaneIdFor, type SweepLaneId } from "../lanes"; +import { SyncConsole } from "../components/sync/SyncConsole"; import { OperationDetail } from "../components/OperationDetail"; import { DigestSettingsForm } from "../components/settings/DigestSettingsForm"; import { DiarizationSettingsForm } from "../components/settings/DiarizationSettingsForm"; @@ -140,9 +143,10 @@ export default async function OperationPage({ // OperationDetail renders that as "no console here" rather than borrowing one. const laneId = sweepLaneIdFor(op); - const [initial, channels] = await Promise.all([ + const [initial, channels, sync] = await Promise.all([ buildAutoQueueStatusPayload(), listChannelConfigs(getPaths()), + buildSyncRow(), ]); const channelOptions = channels.map((c) => ({ slug: c.slug, @@ -172,6 +176,17 @@ export default async function OperationPage({ <LaneSettingsForm key="backfill-lane" initial={settings.backfill} /> ) : null; + // THE CADENCE CONSOLE, off the descriptor's trigger. A cadence-triggered + // operation has no backlog to sweep and no runner to start — its console is + // its schedule. `runner` cannot say this (it is typed AutoQueueKind, and the + // heartbeat is not one), so `trigger` does. ONE such operation exists; a + // second would need the descriptor to name its console, not this file to + // branch on an id. buildSchedulerStatusPayload runs ONLY in this arm. + const cadenceConsole = + op.trigger === "cadence" ? ( + <SyncConsole key="sync" initial={await buildSchedulerStatusPayload()} /> + ) : null; + const laneJobKinds = runnerKind || !laneId ? [] : JOB_KINDS_BY_LANE[laneId]; const activeJobs = @@ -233,6 +248,8 @@ export default async function OperationPage({ activeJobs={activeJobs} runnerKind={runnerKind} laneId={laneId} + sync={sync} + cadenceConsole={cadenceConsole} dependsOn={[...(op.dependsOn ?? [])].map((depId) => ({ id: depId, label: operationLabel(depId), diff --git a/editor/app/operations/components/HowPriorityWorks.tsx b/editor/app/operations/components/HowPriorityWorks.tsx @@ -38,7 +38,7 @@ export function HowPriorityWorks() { <p> A video is claimed by exactly one rule (the first that matches), so overlapping rules never double-process it. This is independent of the{" "} - <Link href="/scheduler" className="underline"> + <Link href="/operations/sync" className="underline"> sync schedule </Link> , which only decides when to re-fetch each channel, and manual diff --git a/editor/app/operations/components/OperationDetail.tsx b/editor/app/operations/components/OperationDetail.tsx @@ -4,6 +4,8 @@ import type { ReactNode } from "react"; import type { AutoQueueKind } from "yt-dlp-transcript-common/jobs/autoQueueState"; import type { AutoQueueStatusPayload } from "../status"; import type { SweepLaneId } from "../lanes"; +// Type-only: syncRow.ts is a server module. See OperationRail. +import type { SyncRowView } from "../syncRow"; import { HowPriorityWorks } from "./HowPriorityWorks"; import { OperationRail } from "./OperationRail"; import { RunnerOperationView } from "./RunnerOperationView"; @@ -33,6 +35,8 @@ export function OperationDetail({ activeJobs, runnerKind, laneId, + sync, + cadenceConsole, dependsOn, operationSettings, laneSettings, @@ -55,6 +59,14 @@ export function OperationDetail({ // The sweep lane that dispatches this, or null for one neither sweep runs. // Also resolved on the server, off the descriptor's queue key. laneId: SweepLaneId | null; + // The rail's sync row, for the rail at the top of every operation page. + sync: SyncRowView; + // THE CADENCE-TRIGGERED OPERATION'S CONSOLE, built on the server off + // `op.trigger` — never off the id. A cadence operation has no backlog to + // sweep and no runner to start, so its console is its schedule; `runner` + // cannot say this because it is typed to the AutoQueueKinds and the sync + // heartbeat is not one of them. + cadenceConsole?: ReactNode; // This operation's declared inputs, labelled. Only rendered when there is no // console — where it is most of what the page has to say. dependsOn: { id: string; label: string }[]; @@ -88,6 +100,7 @@ export function OperationDetail({ bands={data.lanes.bands} states={railStates(data)} selectedId={id} + sync={sync} /> {runnerKind ? ( @@ -113,7 +126,19 @@ export function OperationDetail({ operationSettings={operationSettings} laneSettings={laneSettings} /> + ) : cadenceConsole ? ( + // The settings slot is drawn here rather than inside the console: the + // console is a moved component with its own contract, and this keeps + // "console, then this operation's settings" the same order the sweep + // pages already use. + <> + {cadenceConsole} + {operationSettings} + </> ) : ( + // STILL THE FALLTHROUGH, for a registered operation nothing drives — + // transcode today. A cadence console is a fourth arm, not a + // replacement for this one. <NoConsoleView id={id} data={data} dependsOn={dependsOn} /> )} diff --git a/editor/app/operations/components/OperationRail.tsx b/editor/app/operations/components/OperationRail.tsx @@ -12,6 +12,10 @@ import { } from "../../components/pipelines/StateBand"; import type { LaneState } from "../../components/lanes/laneState"; import { LANE_DOT, LANE_TEXT, LANE_WORD } from "../../components/lanes/laneState"; +// A TYPE IMPORT AND IT HAS TO STAY ONE: syncRow.ts is a server module (it +// reaches the job registry and the scheduler status payload). The row's own +// label rides in on the value. +import type { SyncRowView } from "../syncRow"; // What a rail row says about the lane that would do this operation's work, in // the vocabulary deriveLaneState already established: Running / Holding / Idle / @@ -55,6 +59,7 @@ export function OperationRail({ bands, states, selectedId, + sync, }: { bands: OperationBand[]; // operation id -> the state of the lane that dispatches it. Several @@ -64,6 +69,9 @@ export function OperationRail({ // The operation this page is about, highlighted so the rail says where you // are. Null on the board, which is about all of them. selectedId: string | null; + // The sync row's figures, built on the server. Optional so a caller with no + // scheduler payload still renders a rail rather than failing. + sync?: SyncRowView; }) { return ( <div className="rounded-lg border border-border bg-card"> @@ -71,6 +79,7 @@ export function OperationRail({ Pipelines </p> <ul className="divide-y divide-border"> + {sync && <SyncRailRow sync={sync} selected={selectedId === sync.id} />} {bands.map((band) => ( <RailRow key={band.id} @@ -85,6 +94,93 @@ export function OperationRail({ ); } +// THE ONE ROW THAT IS NOT A BAND. Sync is channel-scoped and cadence-triggered +// (the descriptor says so: scope/trigger), so it has no reachable/blocked +// population to draw and a StateBand here would be a hollow outline over a +// fact that does not exist. Same anatomy as every other row — the name is the +// link, the dot is aria-hidden, the state word stands alone — and its figures +// are channels. First because it is upstream of everything else. RENDERED FROM +// THE SSR PAYLOAD, not the 3-second poll: a cadence measured in minutes does +// not need one, and a second poll would buy a spinner nobody asked for. +function SyncRailRow({ + sync, + selected, +}: { + sync: SyncRowView; + selected: boolean; +}) { + // The same four words the rail uses, and they mean the same things here. + // "Holding" is the honest one for a channel that is due and has not been + // picked up: the scheduler is not stopped and it is not working — the next + // tick is what moves it. + const state = !sync.enabled + ? "unavailable" + : sync.running > 0 + ? "running" + : sync.due > 0 + ? "holding" + : "idle"; + const note = !sync.enabled + ? "the scheduler is off — new videos are only noticed by a manual sync" + : sync.heartbeatSeconds === 0 + ? "no internal timer; ticks come from an external `pnpm sync:tick`" + : sync.due > 0 + ? "picked up on the next tick" + : null; + + return ( + <li + data-operation="sync" + data-scope="channel" + className={`flex flex-wrap items-center gap-x-4 gap-y-1 px-4 py-2.5 text-sm ${ + selected ? "bg-muted/40" : "" + }`} + > + <Link + href={`/operations/${sync.id}`} + className={`flex min-w-36 shrink-0 items-center gap-2 hover:underline hover:underline-offset-2 ${ + selected ? "font-medium text-foreground" : "text-muted-foreground" + }`} + > + <span + aria-hidden="true" + className={`size-2 shrink-0 rounded-full ${LANE_DOT[state]}`} + /> + {sync.label} + </Link> + {/* WHERE EVERY OTHER ROW DRAWS ITS BAND. Saying what this row is measured + in is what keeps the empty space from reading as a missing figure. */} + <span className="text-xs text-muted-foreground" aria-label="Sync scope"> + per channel, on a cadence + </span> + <span className={`shrink-0 text-xs ${LANE_TEXT[state]}`}> + {LANE_WORD[state]} + {note && <span className="text-muted-foreground">: {note}</span>} + </span> + <span className="ml-auto flex shrink-0 items-baseline gap-3 text-xs text-muted-foreground"> + <span> + <span className="tabular-nums text-foreground"> + {sync.eligible.toLocaleString()} + </span>{" "} + channels on a cadence + </span> + {sync.due > 0 && ( + <span> + <span className="tabular-nums">{sync.due.toLocaleString()}</span> due + now + </span> + )} + {sync.overdue > 0 && ( + <span className="text-warning"> + <span className="tabular-nums">{sync.overdue.toLocaleString()}</span>{" "} + overdue + </span> + )} + </span> + </li> + ); +} + function RailRow({ band, lane, diff --git a/editor/app/operations/components/OperationsBoard.tsx b/editor/app/operations/components/OperationsBoard.tsx @@ -1,13 +1,11 @@ "use client"; -import Link from "next/link"; import type { AutoQueueStatusPayload } from "../status"; import type { SyncRowView } from "../syncRow"; import { ArbiterBar } from "./ArbiterBar"; import { OperationRail } from "./OperationRail"; import { railStates } from "./railStates"; import { useHydrated, useOperationsStatus } from "./useOperationsStatus"; -import { LANE_DOT, LANE_TEXT, LANE_WORD } from "../../components/lanes/laneState"; // THE BOARD: every operation this install runs, one line each, and the // dispatcher above them. It is the whole of the page — there is nothing here @@ -39,92 +37,19 @@ export function OperationsBoard({ data-hydrated={hydrated ? "true" : undefined} className="flex flex-col gap-6" > + {/* SYNC IS A ROW ON THE RAIL, not a panel below it. The board would be + lying by omission without it — a corpus that has stopped noticing new + videos is not idle, it is broken, and every figure on the rail would + still read "all caught up" — and it belongs IN the rail now that sync + is a catalogued operation with a page of its own. */} <OperationRail bands={data.lanes.bands} states={railStates(data)} selectedId={null} + sync={sync} /> <ArbiterBar arbiter={data.lanes.arbiter} onRefresh={refresh} /> - - <SyncRow sync={sync} /> - </div> - ); -} - -// SYNC IS AN OPERATION TOO, and the board would be lying by omission without -// it: a corpus that has stopped noticing new videos is not idle, it is broken, -// and every figure on the rail above would still read "all caught up". -// -// It is a row rather than a rail entry because it is the one operation that is -// CHANNEL-scoped and CADENCE-triggered rather than per-video and backlog-fed, -// so it has no reachable/blocked band to draw. It gets a real rail row when the -// descriptor gains `scope` and `trigger` and /scheduler becomes /operations/sync -// — see plans/editor-operations-ia.md, slices 1 and 8. -// -// RENDERED FROM THE SSR PAYLOAD, not polled. A cadence measured in minutes does -// not need a 3-second poll, and a second poll on this page would buy a spinner -// nobody asked for. -function SyncRow({ sync }: { sync: SyncRowView }) { - // The same four words the rail uses, and they mean the same things here. - // "Holding" is the honest one for a channel that is due and has not been - // picked up: the scheduler is not stopped and it is not working — the next - // tick is what moves it. - const state = !sync.enabled - ? "unavailable" - : sync.running > 0 - ? "running" - : sync.due > 0 - ? "holding" - : "idle"; - const note = !sync.enabled - ? "the scheduler is off — new videos are only noticed by a manual sync" - : sync.heartbeatSeconds === 0 - ? "no internal timer; ticks come from an external `pnpm sync:tick`" - : sync.due > 0 - ? "picked up on the next tick" - : null; - return ( - <div className="rounded-lg border border-border bg-card"> - <p className="border-b border-border px-4 py-2 font-mono text-xs uppercase tracking-[0.14em] text-muted-foreground"> - Sync - </p> - <div className="flex flex-wrap items-center gap-x-4 gap-y-1 px-4 py-2.5 text-sm"> - <Link - href="/scheduler" - className="flex min-w-36 shrink-0 items-center gap-2 text-muted-foreground hover:underline hover:underline-offset-2" - > - <span - aria-hidden="true" - className={`size-2 shrink-0 rounded-full ${LANE_DOT[state]}`} - /> - Sync schedule - </Link> - <span className={`shrink-0 text-xs ${LANE_TEXT[state]}`}> - {LANE_WORD[state]} - {note && <span className="text-muted-foreground">: {note}</span>} - </span> - <span className="ml-auto flex shrink-0 items-baseline gap-3 text-xs text-muted-foreground"> - <span> - <span className="tabular-nums text-foreground"> - {sync.eligible.toLocaleString()} - </span>{" "} - channels on a cadence - </span> - {sync.due > 0 && ( - <span> - <span className="tabular-nums">{sync.due.toLocaleString()}</span> due - now - </span> - )} - {sync.overdue > 0 && ( - <span className="text-warning"> - <span className="tabular-nums">{sync.overdue.toLocaleString()}</span>{" "} - overdue - </span> - )} - </span> - </div> </div> ); } diff --git a/editor/app/operations/components/sync/BulkCadenceBar.tsx b/editor/app/operations/components/sync/BulkCadenceBar.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { useActionState } from "react"; +import { setChannelCadencesAction, type SaveResult } from "../../../scheduler/actions"; +import { DurationField } from "../../../components/DurationField"; +import { + FULL_SWEEP_PRESETS, + SYNC_INTERVAL_PRESETS, +} from "../../../scheduler/intervalPresets"; + +// Sticky footer bar that appears once rows are selected. Both fields default to +// "Leave unchanged", so sweeps can be retuned across forty channels without +// disturbing anyone's auto-sync — the whole reason this page is the right place +// for a per-channel override on a corpus where one channel wants a weekly sweep +// and everything else wants a daily one. +export function BulkCadenceBar({ + slugs, + onClear, + onApplied, +}: { + slugs: string[]; + onClear: () => void; + onApplied: () => void; +}) { + const [state, formAction, pending] = useActionState< + SaveResult | undefined, + FormData + >(async (prev, formData) => { + const result = await setChannelCadencesAction(slugs, prev, formData); + if (result.ok) onApplied(); + return result; + }, undefined); + + return ( + <form + action={formAction} + aria-label="bulk cadence" + className="sticky bottom-0 z-10 flex flex-col gap-3 rounded border border-border bg-card p-3 shadow-lg sm:flex-row sm:flex-wrap sm:items-end motion-safe:animate-in motion-safe:fade-in motion-safe:slide-in-from-bottom-2" + > + <span className="text-sm font-medium sm:self-center"> + {slugs.length} selected + </span> + <DurationField + name="syncIntervalMinutes" + label="Bulk auto-sync interval" + baseUnit="minutes" + units={["minutes", "hours", "days"]} + value={undefined} + presets={SYNC_INTERVAL_PRESETS} + allowUnchanged + allowInherit + allowOff + offLabel="Off (never auto-sync)" + max={44640} + className="sm:w-64" + /> + <DurationField + name="fullSweepIntervalMinutes" + label="Bulk full sweep interval" + baseUnit="minutes" + units={["hours", "days"]} + value={undefined} + presets={FULL_SWEEP_PRESETS} + allowUnchanged + allowInherit + allowOff + offLabel="Off (never sweep)" + max={44640} + className="sm:w-64" + /> + <div className="flex items-center gap-2 sm:pb-1"> + <button + type="submit" + disabled={pending} + className="px-3 py-1.5 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 disabled:opacity-50" + > + {pending ? "Applying…" : "Apply"} + </button> + <button + type="button" + onClick={onClear} + className="px-3 py-1.5 rounded-md border border-border text-sm hover:bg-muted" + > + Clear + </button> + {state?.ok === true && ( + <span role="status" className="text-sm text-success"> + Applied. + </span> + )} + {state?.ok === false && ( + <span role="alert" className="text-sm text-destructive"> + {state.error} + </span> + )} + </div> + </form> + ); +} diff --git a/editor/app/operations/components/sync/ChannelCadenceEditor.tsx b/editor/app/operations/components/sync/ChannelCadenceEditor.tsx @@ -0,0 +1,127 @@ +"use client"; + +import { useActionState, useState } from "react"; +import { setChannelCadencesAction, type SaveResult } from "../../../scheduler/actions"; +import { DurationField } from "../../../components/DurationField"; +import { + FULL_SWEEP_PRESETS, + SYNC_INTERVAL_PRESETS, +} from "../../../scheduler/intervalPresets"; +import { formatCadence } from "./cadence"; + +// Per-row cadence cell for the scheduler table. Collapsed to two read-only +// chips by default — this page's job is to show every channel's cadence at +// once, and two full editors per row would bury that — expanding to the pair of +// editors on click. +// +// Holds its own state (seeded once from props) so the page's 5s status poll +// can't clobber an in-progress edit: the poll replaces the whole channels array +// and re-renders every row, but rows are keyed by slug so this is re-rendered, +// never remounted, and React discards the recomputed initial value. There must +// be no prop -> state effect here. scheduler.spec.ts guards exactly this. +export function ChannelCadenceEditor({ + slug, + configuredInterval, + configuredFullSweep, + defaultIntervalMinutes, + defaultFullSweepMinutes, +}: { + slug: string; + configuredInterval: number | undefined; + configuredFullSweep: number | undefined; + defaultIntervalMinutes: number; + defaultFullSweepMinutes: number; +}) { + const [open, setOpen] = useState(false); + const [state, formAction, pending] = useActionState< + SaveResult | undefined, + FormData + >(setChannelCadencesAction.bind(null, [slug]), undefined); + + if (!open) { + return ( + <button + type="button" + onClick={() => setOpen(true)} + aria-label={`Edit cadence for ${slug}`} + className="flex flex-wrap items-center gap-1.5 rounded px-1 py-0.5 text-left hover:bg-muted" + > + <Chip label="Auto" value={formatCadence(configuredInterval)} /> + <Chip label="Sweep" value={formatCadence(configuredFullSweep)} /> + </button> + ); + } + + return ( + <form action={formAction} className="flex flex-col gap-2 min-w-72"> + <DurationField + name="syncIntervalMinutes" + label={`Auto-sync interval for ${slug}`} + hideLabel + baseUnit="minutes" + units={["minutes", "hours", "days"]} + value={configuredInterval} + presets={SYNC_INTERVAL_PRESETS} + allowInherit + allowOff + offLabel="Off (never auto-sync)" + inheritValue={defaultIntervalMinutes} + max={44640} + /> + {/* Distinct label prefix on purpose: a second control matching + /Auto-sync interval/ would make the existing row-scoped getByLabel + queries strict-mode-ambiguous. */} + <DurationField + name="fullSweepIntervalMinutes" + label={`Full sweep interval for ${slug}`} + hideLabel + baseUnit="minutes" + units={["hours", "days"]} + value={configuredFullSweep} + presets={FULL_SWEEP_PRESETS} + allowInherit + allowOff + offLabel="Off (never sweep)" + inheritValue={defaultFullSweepMinutes} + max={44640} + /> + {/* One Save for both cadences — a second per-row submit would make + getByRole("button", { name: "Save" }) ambiguous inside the row. */} + <div className="flex items-center gap-2"> + <button + type="submit" + disabled={pending} + className="px-2 py-1 rounded-md bg-primary text-primary-foreground text-xs font-medium hover:opacity-90 disabled:opacity-50" + > + {pending ? "Saving…" : "Save"} + </button> + <button + type="button" + onClick={() => setOpen(false)} + className="px-2 py-1 rounded-md border border-border text-xs hover:bg-muted" + > + Close + </button> + {state?.ok === true && ( + <span role="status" className="text-xs text-success"> + Saved + </span> + )} + {state?.ok === false && ( + <span role="alert" className="text-xs text-destructive"> + {state.error} + </span> + )} + </div> + </form> + ); +} + +function Chip({ label, value }: { label: string; value: string }) { + return ( + <span className="rounded-full border border-border bg-card px-2 py-0.5 text-xs whitespace-nowrap"> + <span className="text-muted-foreground">{label} </span> + <span className="tabular-nums">{value}</span> + </span> + ); +} diff --git a/editor/app/operations/components/sync/SchedulerSettingsForm.tsx b/editor/app/operations/components/sync/SchedulerSettingsForm.tsx @@ -0,0 +1,119 @@ +"use client"; + +import { useActionState } from "react"; +import type { SyncSchedulerSettings } from "yt-dlp-transcript-common/lib/settings"; +import { + saveSchedulerSettingsAction, + type SaveResult, +} from "../../../scheduler/actions"; +import { DurationField } from "../../../components/DurationField"; +import { FULL_SWEEP_PRESETS } from "../../../scheduler/intervalPresets"; + +// The headline global controls, editable inline on the scheduler page. Advanced +// knobs (concurrency, quiet hours, backoff) stay on /settings to avoid a sprawl; +// `heartbeatSeconds` is the live effective value passed from the page. +export function SchedulerSettingsForm({ + scheduler, + heartbeatSeconds, +}: { + scheduler: SyncSchedulerSettings; + heartbeatSeconds: number; +}) { + const [state, formAction, pending] = useActionState< + SaveResult | undefined, + FormData + >(saveSchedulerSettingsAction, undefined); + + return ( + <form + action={formAction} + className="flex flex-col gap-3 border border-border rounded p-3" + > + <div className="flex items-center justify-between gap-3"> + <h2 className="text-sm font-semibold">Global controls</h2> + <a href="/settings" className="text-xs underline text-muted-foreground"> + Advanced (concurrency, quiet hours, backoff) + </a> + </div> + <label className="flex items-start gap-2 text-sm"> + <input + type="checkbox" + name="syncSchedulerEnabled" + defaultChecked={scheduler.enabled} + className="mt-1" + /> + <span className="flex flex-col gap-0.5"> + <span className="font-medium">Enable scheduled auto-sync</span> + <span className="text-xs text-muted-foreground"> + Master switch. When off, ticks are no-ops and only manual syncs run. + </span> + </span> + </label> + <div className="flex flex-wrap gap-4"> + <DurationField + label="Default interval" + name="syncSchedulerDefaultIntervalMinutes" + baseUnit="minutes" + units={["minutes", "hours", "days"]} + value={scheduler.defaultIntervalMinutes} + min={1} + max={44640} + rate={{ noun: "check", per: "channel" }} + hint="Auto-sync cadence for channels left on “Default”." + className="max-w-sm" + /> + <DurationField + label="Default full sweep interval" + name="syncSchedulerFullSweepIntervalMinutes" + baseUnit="minutes" + units={["hours", "days"]} + value={scheduler.fullSweepIntervalMinutes} + presets={FULL_SWEEP_PRESETS} + allowOff + offLabel="Off (never sweep)" + min={1} + max={44640} + rate={{ + noun: "full listing fetch", + pluralNoun: "full listing fetches", + per: "channel", + }} + hint="How often a sync re-reads a channel's whole listing to refresh it and flag missing videos. Channels can override this." + className="max-w-sm" + /> + <DurationField + label="Internal heartbeat" + name="syncSchedulerHeartbeatSeconds" + baseUnit="seconds" + units={["seconds", "minutes"]} + value={heartbeatSeconds} + allowOff + offLabel="Off (external cron)" + min={15} + max={3600} + hint="When off, drive ticks with an external cron heartbeat. Clamped to 15–3600s; turning it on from off needs a server restart." + className="max-w-sm" + /> + </div> + <div className="flex items-center gap-3"> + <button + type="submit" + disabled={pending} + className="px-3 py-1.5 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 disabled:opacity-50" + > + {pending ? "Saving…" : "Save controls"} + </button> + {state?.ok === true && ( + <span role="status" className="text-sm text-success"> + Saved. + </span> + )} + {state?.ok === false && ( + <span role="alert" className="text-sm text-destructive"> + {state.error} + </span> + )} + </div> + </form> + ); +} diff --git a/editor/app/operations/components/sync/SyncConsole.tsx b/editor/app/operations/components/sync/SyncConsole.tsx @@ -0,0 +1,372 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import type { SchedulerStatusPayload } from "../../../scheduler/status"; +import { BulkCadenceBar } from "./BulkCadenceBar"; +import { ChannelCadenceEditor } from "./ChannelCadenceEditor"; +import { SchedulerSettingsForm } from "./SchedulerSettingsForm"; + +// THE SYNC OPERATION'S CONSOLE — the schedule, because a cadence-triggered +// operation has no backlog to sweep and no runner to start. It is built as a +// server slot in operations/[id]/page.tsx off `op.trigger === "cadence"`, and +// OperationDetail renders it where a runner or a sweep lane would otherwise be. +// +// TWO POLLS ON THIS PAGE, deliberately not merged: this one is 5s over +// /api/scheduler/status (the schedule, whose facts move on the order of +// minutes) and the rail above is 3s over the auto-queue payload (lane state, +// which moves per dispatch). One combined endpoint would make the cheaper +// reader pay the more expensive reader's cadence, and buildSchedulerStatusPayload +// lists channel configs while the auto-queue payload does not. +export function SyncConsole({ + initial, +}: { + initial: SchedulerStatusPayload; +}) { + const [data, setData] = useState<SchedulerStatusPayload>(initial); + const [busy, setBusy] = useState(false); + const [message, setMessage] = useState<string | null>(null); + // Slugs ticked for a bulk cadence edit. Kept as a Set of slugs (not indices) + // so the 5s poll reordering or dropping a row can't retarget a selection. + const [selected, setSelected] = useState<ReadonlySet<string>>(new Set()); + const mounted = useRef(true); + + const refresh = useCallback(async () => { + try { + const res = await fetch("/api/scheduler/status", { cache: "no-store" }); + if (!res.ok) return; + const next = (await res.json()) as SchedulerStatusPayload; + if (mounted.current) setData(next); + } catch { + /* transient; the next poll retries */ + } + }, []); + + useEffect(() => { + mounted.current = true; + const id = setInterval(refresh, 5000); + return () => { + mounted.current = false; + clearInterval(id); + }; + }, [refresh]); + + const runNow = useCallback(async () => { + setBusy(true); + setMessage(null); + try { + const res = await fetch("/api/scheduler/tick", { method: "POST" }); + const body = (await res.json()) as { + queued?: string[]; + skipped?: unknown[]; + reason?: string; + keptChecksQueued?: string[]; + savedVideoBackupQueued?: boolean; + }; + if (!res.ok) { + setMessage(`Tick failed (${res.status}).`); + } else { + const q = body.queued?.length ?? 0; + const s = body.skipped?.length ?? 0; + // THE STORAGE CHORES, named separately from the syncs. They ride this + // heartbeat because it is the one timer the editor has, and a tick that + // reported "queued 0" while it had just started a keep-latest deletion + // pass would be describing the wrong operation. Read from the POST + // body, which has carried both fields all along — nothing on the tick + // changed to say this. + const k = body.keptChecksQueued?.length ?? 0; + const backup = body.savedVideoBackupQueued === true; + const chores = + k > 0 || backup + ? ` · storage chores: ${k} keep-latest check${k === 1 ? "" : "s"}${ + backup ? ", saved-video backup" : "" + }` + : ""; + setMessage( + body.reason + ? `Tick: ${body.reason} (queued ${q}, skipped ${s})${chores}.` + : `Tick queued ${q}, skipped ${s}${chores}.`, + ); + } + await refresh(); + } catch (err) { + setMessage(`Tick failed: ${(err as Error).message}`); + } finally { + if (mounted.current) setBusy(false); + } + }, [refresh]); + + const { scheduler, channels, runs, now, heartbeatSeconds } = data; + const eligible = channels.filter((c) => c.autoSyncEligible); + // A slug can vanish between polls (channel deleted elsewhere), so the bar + // always acts on the intersection with what's on screen. + const selectedSlugs = channels + .map((c) => c.slug) + .filter((s) => selected.has(s)); + const allSelected = + channels.length > 0 && selectedSlugs.length === channels.length; + + function toggleOne(slug: string) { + setSelected((prev) => { + const next = new Set(prev); + if (!next.delete(slug)) next.add(slug); + return next; + }); + } + + function toggleAll(checked: boolean) { + setSelected(checked ? new Set(channels.map((c) => c.slug)) : new Set()); + } + + return ( + <div className="flex flex-col gap-5"> + <p className="text-sm text-muted-foreground"> + Per-channel auto-sync cadence, driven by a heartbeat + (<code>pnpm sync:tick</code> or the internal timer). Set each + channel&apos;s interval inline below — pick a preset or type an exact + minute count. A channel auto-syncs on the next heartbeat once + it&apos;s been longer than its interval since the last sync. + </p> + <div className="flex flex-wrap items-center gap-3"> + <span + className={`text-sm rounded-full px-3 py-1 border ${ + scheduler.enabled + ? "border-success/30 bg-success-soft text-success" + : "border-border bg-muted text-muted-foreground" + }`} + > + {scheduler.enabled ? "Scheduler enabled" : "Scheduler disabled"} + </span> + <span className="text-sm text-muted-foreground"> + {eligible.length} channel{eligible.length === 1 ? "" : "s"} auto-syncing + · default {formatInterval(scheduler.defaultIntervalMinutes)} · full + sweep {formatInterval(scheduler.fullSweepIntervalMinutes)} · max{" "} + {scheduler.maxConcurrentSyncs} concurrent ·{" "} + {heartbeatSeconds > 0 + ? `internal heartbeat every ${formatSeconds(heartbeatSeconds)}` + : "external heartbeat (cron)"} + </span> + <button + type="button" + onClick={runNow} + disabled={busy} + className="ml-auto px-3 py-1.5 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 disabled:opacity-50" + > + {busy ? "Running…" : "Run scheduler now"} + </button> + </div> + {message && ( + <p role="status" className="text-sm text-muted-foreground"> + {message} + </p> + )} + + <SchedulerSettingsForm + scheduler={scheduler} + heartbeatSeconds={heartbeatSeconds} + /> + + <div className="overflow-x-auto rounded border border-border"> + <table className="w-full text-sm"> + <thead className="bg-card text-left text-muted-foreground"> + <tr> + <th className="px-2 py-2 font-medium"> + <input + type="checkbox" + aria-label="select all channels" + checked={allSelected} + onChange={(e) => toggleAll(e.target.checked)} + /> + </th> + <th className="px-3 py-2 font-medium">Channel</th> + <th className="px-3 py-2 font-medium">Cadence</th> + <th className="px-3 py-2 font-medium">Last synced</th> + <th className="px-3 py-2 font-medium">Next due</th> + <th className="px-3 py-2 font-medium">Status</th> + </tr> + </thead> + <tbody> + {channels.length === 0 && ( + <tr> + <td colSpan={6} className="px-3 py-4 text-muted-foreground"> + No channels. + </td> + </tr> + )} + {channels.map((c) => ( + <tr + key={c.slug} + className="border-t border-border" + > + <td className="px-2 py-2"> + <input + type="checkbox" + aria-label={`select ${c.slug}`} + checked={selected.has(c.slug)} + onChange={() => toggleOne(c.slug)} + /> + </td> + <td className="px-3 py-2"> + <a href={`/channels/${c.slug}`} className="hover:underline"> + {c.name ?? c.slug} + </a> + </td> + <td className="px-3 py-2"> + <ChannelCadenceEditor + slug={c.slug} + configuredInterval={c.configuredIntervalMinutes} + configuredFullSweep={c.configuredFullSweepMinutes} + defaultIntervalMinutes={scheduler.defaultIntervalMinutes} + defaultFullSweepMinutes={scheduler.fullSweepIntervalMinutes} + /> + </td> + <td className="px-3 py-2 text-muted-foreground"> + {c.lastSyncedAt ? formatAgo(c.lastSyncedAt, now) : "never"} + </td> + <td className="px-3 py-2 text-muted-foreground"> + <span>{nextDueLabel(c, now)}</span> + <span className="block text-xs"> + {nextSweepLabel(c, now)} + </span> + </td> + <td className="px-3 py-2">{statusBadge(c)}</td> + </tr> + ))} + </tbody> + </table> + </div> + + {selectedSlugs.length > 0 && ( + <BulkCadenceBar + slugs={selectedSlugs} + onClear={() => setSelected(new Set())} + onApplied={refresh} + /> + )} + + <section className="flex flex-col gap-2"> + <h2 className="text-sm font-semibold">Recent ticks</h2> + {/* NOT SYNC, and the console has to say so: a tick that reports a + deletion pass or a backup is not a tick that noticed new videos. + They ride this heartbeat because it is the one timer the editor + has. */} + <p className="text-xs text-muted-foreground"> + Two Storage chores ride this heartbeat and are not sync: the + keep-latest deletion check (its interval is in the settings below) and + the saved-video backup (Saved videos). + </p> + {runs.length === 0 ? ( + <p className="text-sm text-muted-foreground">No ticks recorded yet.</p> + ) : ( + <ul className="flex flex-col gap-1 text-sm"> + {runs.slice(0, 15).map((r, i) => ( + <li + key={`${r.at}-${i}`} + className="flex flex-wrap gap-x-3 gap-y-0.5 text-muted-foreground" + > + <span className="tabular-nums text-muted-foreground"> + {formatClock(r.at)} + </span> + <span> + queued {r.queued.length} + {r.queued.length > 0 && ( + <span className="text-muted-foreground"> ({r.queued.join(", ")})</span> + )} + </span> + {r.skipped.length > 0 && ( + <span className="text-muted-foreground"> + skipped {r.skipped.length} + </span> + )} + </li> + ))} + </ul> + )} + </section> + </div> + ); +} + +type ChannelRow = SchedulerStatusPayload["channels"][number]; + +function statusBadge(c: ChannelRow) { + if (c.intervalMinutes === 0 || !c.autoSyncEligible) { + return <span className="text-muted-foreground">—</span>; + } + if (c.nextEligibleAt != null && c.nextEligibleAt > Date.now()) { + return ( + <span className="text-warning"> + backoff ({c.consecutiveFailures} fail + {c.consecutiveFailures === 1 ? "" : "s"}) + </span> + ); + } + if (c.lastOutcome === "failed") { + return <span className="text-destructive">last failed</span>; + } + if (c.overdue) { + return <span className="text-info">due</span>; + } + return <span className="text-success">ok</span>; +} + +function nextDueLabel(c: ChannelRow, now: number): string { + if (c.intervalMinutes === 0) return "—"; + if (c.overdue) return "due now"; + if (c.nextDueAt == null) return "—"; + return `in ${formatDuration(c.nextDueAt - now)}`; +} + +// The full-sweep half of "Next due". A never-swept channel with sweeps on is due +// immediately — that first sweep is what seeds the stored playlist and the +// missing-video flags — which is why nextFullSweepAt is null there rather than a +// timestamp. +function nextSweepLabel(c: ChannelRow, now: number): string { + if (c.fullSweepIntervalMinutes <= 0) return "sweep off"; + if (c.nextFullSweepAt == null) return "sweep due"; + if (now >= c.nextFullSweepAt) return "sweep due"; + return `sweep in ${formatDuration(c.nextFullSweepAt - now)}`; +} + +function formatInterval(minutes: number): string { + if (minutes <= 0) return "off"; + if (minutes % 1440 === 0) { + const d = minutes / 1440; + return d === 1 ? "daily" : `every ${d}d`; + } + if (minutes % 60 === 0) { + const h = minutes / 60; + return h === 1 ? "hourly" : `every ${h}h`; + } + return `every ${minutes}m`; +} + +function formatSeconds(sec: number): string { + if (sec < 60) return `${sec}s`; + if (sec % 3600 === 0) return `${sec / 3600}h`; + if (sec % 60 === 0) return `${sec / 60}m`; + return `${sec}s`; +} + +function formatDuration(ms: number): string { + const sec = Math.max(0, Math.round(ms / 1000)); + if (sec < 60) return `${sec}s`; + const min = Math.round(sec / 60); + if (min < 60) return `${min}m`; + const hr = Math.round(min / 60); + if (hr < 48) return `${hr}h`; + return `${Math.round(hr / 24)}d`; +} + +function formatAgo(iso: string, now: number): string { + const t = Date.parse(iso); + if (Number.isNaN(t)) return iso; + return `${formatDuration(now - t)} ago`; +} + +function formatClock(ms: number): string { + if (!ms) return "—"; + return new Date(ms).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }); +} diff --git a/editor/app/scheduler/components/cadence.ts b/editor/app/operations/components/sync/cadence.ts diff --git a/editor/app/operations/syncRow.ts b/editor/app/operations/syncRow.ts @@ -1,10 +1,20 @@ import { getRegistry } from "yt-dlp-transcript-common/jobs/registry"; +import { + SYNC_OPERATION, + operationLabel, +} from "yt-dlp-transcript-common/lib/operations"; import { buildSchedulerStatusPayload } from "../scheduler/status"; -// THE SYNC ROW, folded down to what the board can act on. The full -// per-channel schedule stays on /scheduler (which becomes /operations/sync in -// slice 8 — see plans/editor-operations-ia.md). +// THE SYNC ROW ON THE OPERATIONS RAIL, folded down to what a one-line row can +// say. Sync is channel-scoped, so these are channel figures and there is no +// band to draw — see SyncRailRow. The full per-channel schedule is the sync +// operation's own page, /operations/sync. export type SyncRowView = { + // Off the descriptor, so the row's link and its name cannot drift from the + // catalog the rest of the rail is built from. `label` travels as a prop + // because the row is a client component and operations.ts reaches node:fs. + id: string; + label: string; enabled: boolean; // Channels the scheduler would sync on a cadence — not the channel count. eligible: number; @@ -26,6 +36,8 @@ export async function buildSyncRow(): Promise<SyncRowView> { const payload = await buildSchedulerStatusPayload(); const eligible = payload.channels.filter((c) => c.autoSyncEligible); return { + id: SYNC_OPERATION.id, + label: operationLabel(SYNC_OPERATION.id), enabled: payload.scheduler.enabled, eligible: eligible.length, due: eligible.filter( diff --git a/editor/app/saved-videos/components/SavedVideosControls.tsx b/editor/app/saved-videos/components/SavedVideosControls.tsx @@ -83,8 +83,9 @@ export function SavedVideosControls({ enabled, dest, intervalMinutes }: Props) { <span className="flex flex-col gap-0.5"> <span className="font-medium">Scheduled backup</span> <span className="text-xs text-muted-foreground"> - When on (and a destination is set), the sync scheduler runs the - backup on the cadence below. Manual backups work regardless. + When on (and a destination is set), the sync heartbeat runs this + Storage chore on the cadence below. Manual backups work + regardless. </span> </span> </label> diff --git a/editor/app/scheduler/actions.ts b/editor/app/scheduler/actions.ts @@ -99,7 +99,7 @@ export async function setChannelCadencesAction( requestChannelSnapshot(paths, slug); revalidatePath(`/channels/${slug}`); } - revalidatePath("/scheduler"); + revalidatePath("/operations/sync"); revalidatePath("/channels"); return { ok: true }; } @@ -149,7 +149,7 @@ export async function saveSchedulerSettingsAction( } catch (e) { return { ok: false, error: (e as Error).message }; } - revalidatePath("/scheduler"); + revalidatePath("/operations/sync"); revalidatePath("/settings"); return { ok: true }; } diff --git a/editor/app/scheduler/components/BulkCadenceBar.tsx b/editor/app/scheduler/components/BulkCadenceBar.tsx @@ -1,99 +0,0 @@ -"use client"; - -import { useActionState } from "react"; -import { setChannelCadencesAction, type SaveResult } from "../actions"; -import { DurationField } from "../../components/DurationField"; -import { - FULL_SWEEP_PRESETS, - SYNC_INTERVAL_PRESETS, -} from "../intervalPresets"; - -// Sticky footer bar that appears once rows are selected. Both fields default to -// "Leave unchanged", so sweeps can be retuned across forty channels without -// disturbing anyone's auto-sync — the whole reason this page is the right place -// for a per-channel override on a corpus where one channel wants a weekly sweep -// and everything else wants a daily one. -export function BulkCadenceBar({ - slugs, - onClear, - onApplied, -}: { - slugs: string[]; - onClear: () => void; - onApplied: () => void; -}) { - const [state, formAction, pending] = useActionState< - SaveResult | undefined, - FormData - >(async (prev, formData) => { - const result = await setChannelCadencesAction(slugs, prev, formData); - if (result.ok) onApplied(); - return result; - }, undefined); - - return ( - <form - action={formAction} - aria-label="bulk cadence" - className="sticky bottom-0 z-10 flex flex-col gap-3 rounded border border-border bg-card p-3 shadow-lg sm:flex-row sm:flex-wrap sm:items-end motion-safe:animate-in motion-safe:fade-in motion-safe:slide-in-from-bottom-2" - > - <span className="text-sm font-medium sm:self-center"> - {slugs.length} selected - </span> - <DurationField - name="syncIntervalMinutes" - label="Bulk auto-sync interval" - baseUnit="minutes" - units={["minutes", "hours", "days"]} - value={undefined} - presets={SYNC_INTERVAL_PRESETS} - allowUnchanged - allowInherit - allowOff - offLabel="Off (never auto-sync)" - max={44640} - className="sm:w-64" - /> - <DurationField - name="fullSweepIntervalMinutes" - label="Bulk full sweep interval" - baseUnit="minutes" - units={["hours", "days"]} - value={undefined} - presets={FULL_SWEEP_PRESETS} - allowUnchanged - allowInherit - allowOff - offLabel="Off (never sweep)" - max={44640} - className="sm:w-64" - /> - <div className="flex items-center gap-2 sm:pb-1"> - <button - type="submit" - disabled={pending} - className="px-3 py-1.5 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 disabled:opacity-50" - > - {pending ? "Applying…" : "Apply"} - </button> - <button - type="button" - onClick={onClear} - className="px-3 py-1.5 rounded-md border border-border text-sm hover:bg-muted" - > - Clear - </button> - {state?.ok === true && ( - <span role="status" className="text-sm text-success"> - Applied. - </span> - )} - {state?.ok === false && ( - <span role="alert" className="text-sm text-destructive"> - {state.error} - </span> - )} - </div> - </form> - ); -} diff --git a/editor/app/scheduler/components/ChannelCadenceEditor.tsx b/editor/app/scheduler/components/ChannelCadenceEditor.tsx @@ -1,127 +0,0 @@ -"use client"; - -import { useActionState, useState } from "react"; -import { setChannelCadencesAction, type SaveResult } from "../actions"; -import { DurationField } from "../../components/DurationField"; -import { - FULL_SWEEP_PRESETS, - SYNC_INTERVAL_PRESETS, -} from "../intervalPresets"; -import { formatCadence } from "./cadence"; - -// Per-row cadence cell for the scheduler table. Collapsed to two read-only -// chips by default — this page's job is to show every channel's cadence at -// once, and two full editors per row would bury that — expanding to the pair of -// editors on click. -// -// Holds its own state (seeded once from props) so the page's 5s status poll -// can't clobber an in-progress edit: the poll replaces the whole channels array -// and re-renders every row, but rows are keyed by slug so this is re-rendered, -// never remounted, and React discards the recomputed initial value. There must -// be no prop -> state effect here. scheduler.spec.ts guards exactly this. -export function ChannelCadenceEditor({ - slug, - configuredInterval, - configuredFullSweep, - defaultIntervalMinutes, - defaultFullSweepMinutes, -}: { - slug: string; - configuredInterval: number | undefined; - configuredFullSweep: number | undefined; - defaultIntervalMinutes: number; - defaultFullSweepMinutes: number; -}) { - const [open, setOpen] = useState(false); - const [state, formAction, pending] = useActionState< - SaveResult | undefined, - FormData - >(setChannelCadencesAction.bind(null, [slug]), undefined); - - if (!open) { - return ( - <button - type="button" - onClick={() => setOpen(true)} - aria-label={`Edit cadence for ${slug}`} - className="flex flex-wrap items-center gap-1.5 rounded px-1 py-0.5 text-left hover:bg-muted" - > - <Chip label="Auto" value={formatCadence(configuredInterval)} /> - <Chip label="Sweep" value={formatCadence(configuredFullSweep)} /> - </button> - ); - } - - return ( - <form action={formAction} className="flex flex-col gap-2 min-w-72"> - <DurationField - name="syncIntervalMinutes" - label={`Auto-sync interval for ${slug}`} - hideLabel - baseUnit="minutes" - units={["minutes", "hours", "days"]} - value={configuredInterval} - presets={SYNC_INTERVAL_PRESETS} - allowInherit - allowOff - offLabel="Off (never auto-sync)" - inheritValue={defaultIntervalMinutes} - max={44640} - /> - {/* Distinct label prefix on purpose: a second control matching - /Auto-sync interval/ would make the existing row-scoped getByLabel - queries strict-mode-ambiguous. */} - <DurationField - name="fullSweepIntervalMinutes" - label={`Full sweep interval for ${slug}`} - hideLabel - baseUnit="minutes" - units={["hours", "days"]} - value={configuredFullSweep} - presets={FULL_SWEEP_PRESETS} - allowInherit - allowOff - offLabel="Off (never sweep)" - inheritValue={defaultFullSweepMinutes} - max={44640} - /> - {/* One Save for both cadences — a second per-row submit would make - getByRole("button", { name: "Save" }) ambiguous inside the row. */} - <div className="flex items-center gap-2"> - <button - type="submit" - disabled={pending} - className="px-2 py-1 rounded-md bg-primary text-primary-foreground text-xs font-medium hover:opacity-90 disabled:opacity-50" - > - {pending ? "Saving…" : "Save"} - </button> - <button - type="button" - onClick={() => setOpen(false)} - className="px-2 py-1 rounded-md border border-border text-xs hover:bg-muted" - > - Close - </button> - {state?.ok === true && ( - <span role="status" className="text-xs text-success"> - Saved - </span> - )} - {state?.ok === false && ( - <span role="alert" className="text-xs text-destructive"> - {state.error} - </span> - )} - </div> - </form> - ); -} - -function Chip({ label, value }: { label: string; value: string }) { - return ( - <span className="rounded-full border border-border bg-card px-2 py-0.5 text-xs whitespace-nowrap"> - <span className="text-muted-foreground">{label} </span> - <span className="tabular-nums">{value}</span> - </span> - ); -} diff --git a/editor/app/scheduler/components/SchedulerSettingsForm.tsx b/editor/app/scheduler/components/SchedulerSettingsForm.tsx @@ -1,119 +0,0 @@ -"use client"; - -import { useActionState } from "react"; -import type { SyncSchedulerSettings } from "yt-dlp-transcript-common/lib/settings"; -import { - saveSchedulerSettingsAction, - type SaveResult, -} from "../actions"; -import { DurationField } from "../../components/DurationField"; -import { FULL_SWEEP_PRESETS } from "../intervalPresets"; - -// The headline global controls, editable inline on the scheduler page. Advanced -// knobs (concurrency, quiet hours, backoff) stay on /settings to avoid a sprawl; -// `heartbeatSeconds` is the live effective value passed from the page. -export function SchedulerSettingsForm({ - scheduler, - heartbeatSeconds, -}: { - scheduler: SyncSchedulerSettings; - heartbeatSeconds: number; -}) { - const [state, formAction, pending] = useActionState< - SaveResult | undefined, - FormData - >(saveSchedulerSettingsAction, undefined); - - return ( - <form - action={formAction} - className="flex flex-col gap-3 border border-border rounded p-3" - > - <div className="flex items-center justify-between gap-3"> - <h2 className="text-sm font-semibold">Global controls</h2> - <a href="/settings" className="text-xs underline text-muted-foreground"> - Advanced (concurrency, quiet hours, backoff) - </a> - </div> - <label className="flex items-start gap-2 text-sm"> - <input - type="checkbox" - name="syncSchedulerEnabled" - defaultChecked={scheduler.enabled} - className="mt-1" - /> - <span className="flex flex-col gap-0.5"> - <span className="font-medium">Enable scheduled auto-sync</span> - <span className="text-xs text-muted-foreground"> - Master switch. When off, ticks are no-ops and only manual syncs run. - </span> - </span> - </label> - <div className="flex flex-wrap gap-4"> - <DurationField - label="Default interval" - name="syncSchedulerDefaultIntervalMinutes" - baseUnit="minutes" - units={["minutes", "hours", "days"]} - value={scheduler.defaultIntervalMinutes} - min={1} - max={44640} - rate={{ noun: "check", per: "channel" }} - hint="Auto-sync cadence for channels left on “Default”." - className="max-w-sm" - /> - <DurationField - label="Default full sweep interval" - name="syncSchedulerFullSweepIntervalMinutes" - baseUnit="minutes" - units={["hours", "days"]} - value={scheduler.fullSweepIntervalMinutes} - presets={FULL_SWEEP_PRESETS} - allowOff - offLabel="Off (never sweep)" - min={1} - max={44640} - rate={{ - noun: "full listing fetch", - pluralNoun: "full listing fetches", - per: "channel", - }} - hint="How often a sync re-reads a channel's whole listing to refresh it and flag missing videos. Channels can override this." - className="max-w-sm" - /> - <DurationField - label="Internal heartbeat" - name="syncSchedulerHeartbeatSeconds" - baseUnit="seconds" - units={["seconds", "minutes"]} - value={heartbeatSeconds} - allowOff - offLabel="Off (external cron)" - min={15} - max={3600} - hint="When off, drive ticks with an external cron heartbeat. Clamped to 15–3600s; turning it on from off needs a server restart." - className="max-w-sm" - /> - </div> - <div className="flex items-center gap-3"> - <button - type="submit" - disabled={pending} - className="px-3 py-1.5 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 disabled:opacity-50" - > - {pending ? "Saving…" : "Save controls"} - </button> - {state?.ok === true && ( - <span role="status" className="text-sm text-success"> - Saved. - </span> - )} - {state?.ok === false && ( - <span role="alert" className="text-sm text-destructive"> - {state.error} - </span> - )} - </div> - </form> - ); -} diff --git a/editor/app/scheduler/components/SchedulerView.tsx b/editor/app/scheduler/components/SchedulerView.tsx @@ -1,329 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useRef, useState } from "react"; -import type { SchedulerStatusPayload } from "../status"; -import { BulkCadenceBar } from "./BulkCadenceBar"; -import { ChannelCadenceEditor } from "./ChannelCadenceEditor"; -import { SchedulerSettingsForm } from "./SchedulerSettingsForm"; - -export function SchedulerView({ - initial, -}: { - initial: SchedulerStatusPayload; -}) { - const [data, setData] = useState<SchedulerStatusPayload>(initial); - const [busy, setBusy] = useState(false); - const [message, setMessage] = useState<string | null>(null); - // Slugs ticked for a bulk cadence edit. Kept as a Set of slugs (not indices) - // so the 5s poll reordering or dropping a row can't retarget a selection. - const [selected, setSelected] = useState<ReadonlySet<string>>(new Set()); - const mounted = useRef(true); - - const refresh = useCallback(async () => { - try { - const res = await fetch("/api/scheduler/status", { cache: "no-store" }); - if (!res.ok) return; - const next = (await res.json()) as SchedulerStatusPayload; - if (mounted.current) setData(next); - } catch { - /* transient; the next poll retries */ - } - }, []); - - useEffect(() => { - mounted.current = true; - const id = setInterval(refresh, 5000); - return () => { - mounted.current = false; - clearInterval(id); - }; - }, [refresh]); - - const runNow = useCallback(async () => { - setBusy(true); - setMessage(null); - try { - const res = await fetch("/api/scheduler/tick", { method: "POST" }); - const body = (await res.json()) as { - queued?: string[]; - skipped?: unknown[]; - reason?: string; - }; - if (!res.ok) { - setMessage(`Tick failed (${res.status}).`); - } else { - const q = body.queued?.length ?? 0; - const s = body.skipped?.length ?? 0; - setMessage( - body.reason - ? `Tick: ${body.reason} (queued ${q}, skipped ${s}).` - : `Tick queued ${q}, skipped ${s}.`, - ); - } - await refresh(); - } catch (err) { - setMessage(`Tick failed: ${(err as Error).message}`); - } finally { - if (mounted.current) setBusy(false); - } - }, [refresh]); - - const { scheduler, channels, runs, now, heartbeatSeconds } = data; - const eligible = channels.filter((c) => c.autoSyncEligible); - // A slug can vanish between polls (channel deleted elsewhere), so the bar - // always acts on the intersection with what's on screen. - const selectedSlugs = channels - .map((c) => c.slug) - .filter((s) => selected.has(s)); - const allSelected = - channels.length > 0 && selectedSlugs.length === channels.length; - - function toggleOne(slug: string) { - setSelected((prev) => { - const next = new Set(prev); - if (!next.delete(slug)) next.add(slug); - return next; - }); - } - - function toggleAll(checked: boolean) { - setSelected(checked ? new Set(channels.map((c) => c.slug)) : new Set()); - } - - return ( - <div className="flex flex-col gap-5"> - <div className="flex flex-wrap items-center gap-3"> - <span - className={`text-sm rounded-full px-3 py-1 border ${ - scheduler.enabled - ? "border-success/30 bg-success-soft text-success" - : "border-border bg-muted text-muted-foreground" - }`} - > - {scheduler.enabled ? "Scheduler enabled" : "Scheduler disabled"} - </span> - <span className="text-sm text-muted-foreground"> - {eligible.length} channel{eligible.length === 1 ? "" : "s"} auto-syncing - · default {formatInterval(scheduler.defaultIntervalMinutes)} · full - sweep {formatInterval(scheduler.fullSweepIntervalMinutes)} · max{" "} - {scheduler.maxConcurrentSyncs} concurrent ·{" "} - {heartbeatSeconds > 0 - ? `internal heartbeat every ${formatSeconds(heartbeatSeconds)}` - : "external heartbeat (cron)"} - </span> - <button - type="button" - onClick={runNow} - disabled={busy} - className="ml-auto px-3 py-1.5 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 disabled:opacity-50" - > - {busy ? "Running…" : "Run scheduler now"} - </button> - </div> - {message && ( - <p role="status" className="text-sm text-muted-foreground"> - {message} - </p> - )} - - <SchedulerSettingsForm - scheduler={scheduler} - heartbeatSeconds={heartbeatSeconds} - /> - - <div className="overflow-x-auto rounded border border-border"> - <table className="w-full text-sm"> - <thead className="bg-card text-left text-muted-foreground"> - <tr> - <th className="px-2 py-2 font-medium"> - <input - type="checkbox" - aria-label="select all channels" - checked={allSelected} - onChange={(e) => toggleAll(e.target.checked)} - /> - </th> - <th className="px-3 py-2 font-medium">Channel</th> - <th className="px-3 py-2 font-medium">Cadence</th> - <th className="px-3 py-2 font-medium">Last synced</th> - <th className="px-3 py-2 font-medium">Next due</th> - <th className="px-3 py-2 font-medium">Status</th> - </tr> - </thead> - <tbody> - {channels.length === 0 && ( - <tr> - <td colSpan={6} className="px-3 py-4 text-muted-foreground"> - No channels. - </td> - </tr> - )} - {channels.map((c) => ( - <tr - key={c.slug} - className="border-t border-border" - > - <td className="px-2 py-2"> - <input - type="checkbox" - aria-label={`select ${c.slug}`} - checked={selected.has(c.slug)} - onChange={() => toggleOne(c.slug)} - /> - </td> - <td className="px-3 py-2"> - <a href={`/channels/${c.slug}`} className="hover:underline"> - {c.name ?? c.slug} - </a> - </td> - <td className="px-3 py-2"> - <ChannelCadenceEditor - slug={c.slug} - configuredInterval={c.configuredIntervalMinutes} - configuredFullSweep={c.configuredFullSweepMinutes} - defaultIntervalMinutes={scheduler.defaultIntervalMinutes} - defaultFullSweepMinutes={scheduler.fullSweepIntervalMinutes} - /> - </td> - <td className="px-3 py-2 text-muted-foreground"> - {c.lastSyncedAt ? formatAgo(c.lastSyncedAt, now) : "never"} - </td> - <td className="px-3 py-2 text-muted-foreground"> - <span>{nextDueLabel(c, now)}</span> - <span className="block text-xs"> - {nextSweepLabel(c, now)} - </span> - </td> - <td className="px-3 py-2">{statusBadge(c)}</td> - </tr> - ))} - </tbody> - </table> - </div> - - {selectedSlugs.length > 0 && ( - <BulkCadenceBar - slugs={selectedSlugs} - onClear={() => setSelected(new Set())} - onApplied={refresh} - /> - )} - - <section className="flex flex-col gap-2"> - <h2 className="text-sm font-semibold">Recent ticks</h2> - {runs.length === 0 ? ( - <p className="text-sm text-muted-foreground">No ticks recorded yet.</p> - ) : ( - <ul className="flex flex-col gap-1 text-sm"> - {runs.slice(0, 15).map((r, i) => ( - <li - key={`${r.at}-${i}`} - className="flex flex-wrap gap-x-3 gap-y-0.5 text-muted-foreground" - > - <span className="tabular-nums text-muted-foreground"> - {formatClock(r.at)} - </span> - <span> - queued {r.queued.length} - {r.queued.length > 0 && ( - <span className="text-muted-foreground"> ({r.queued.join(", ")})</span> - )} - </span> - {r.skipped.length > 0 && ( - <span className="text-muted-foreground"> - skipped {r.skipped.length} - </span> - )} - </li> - ))} - </ul> - )} - </section> - </div> - ); -} - -type ChannelRow = SchedulerStatusPayload["channels"][number]; - -function statusBadge(c: ChannelRow) { - if (c.intervalMinutes === 0 || !c.autoSyncEligible) { - return <span className="text-muted-foreground">—</span>; - } - if (c.nextEligibleAt != null && c.nextEligibleAt > Date.now()) { - return ( - <span className="text-warning"> - backoff ({c.consecutiveFailures} fail - {c.consecutiveFailures === 1 ? "" : "s"}) - </span> - ); - } - if (c.lastOutcome === "failed") { - return <span className="text-destructive">last failed</span>; - } - if (c.overdue) { - return <span className="text-info">due</span>; - } - return <span className="text-success">ok</span>; -} - -function nextDueLabel(c: ChannelRow, now: number): string { - if (c.intervalMinutes === 0) return "—"; - if (c.overdue) return "due now"; - if (c.nextDueAt == null) return "—"; - return `in ${formatDuration(c.nextDueAt - now)}`; -} - -// The full-sweep half of "Next due". A never-swept channel with sweeps on is due -// immediately — that first sweep is what seeds the stored playlist and the -// missing-video flags — which is why nextFullSweepAt is null there rather than a -// timestamp. -function nextSweepLabel(c: ChannelRow, now: number): string { - if (c.fullSweepIntervalMinutes <= 0) return "sweep off"; - if (c.nextFullSweepAt == null) return "sweep due"; - if (now >= c.nextFullSweepAt) return "sweep due"; - return `sweep in ${formatDuration(c.nextFullSweepAt - now)}`; -} - -function formatInterval(minutes: number): string { - if (minutes <= 0) return "off"; - if (minutes % 1440 === 0) { - const d = minutes / 1440; - return d === 1 ? "daily" : `every ${d}d`; - } - if (minutes % 60 === 0) { - const h = minutes / 60; - return h === 1 ? "hourly" : `every ${h}h`; - } - return `every ${minutes}m`; -} - -function formatSeconds(sec: number): string { - if (sec < 60) return `${sec}s`; - if (sec % 3600 === 0) return `${sec / 3600}h`; - if (sec % 60 === 0) return `${sec / 60}m`; - return `${sec}s`; -} - -function formatDuration(ms: number): string { - const sec = Math.max(0, Math.round(ms / 1000)); - if (sec < 60) return `${sec}s`; - const min = Math.round(sec / 60); - if (min < 60) return `${min}m`; - const hr = Math.round(min / 60); - if (hr < 48) return `${hr}h`; - return `${Math.round(hr / 24)}d`; -} - -function formatAgo(iso: string, now: number): string { - const t = Date.parse(iso); - if (Number.isNaN(t)) return iso; - return `${formatDuration(now - t)} ago`; -} - -function formatClock(ms: number): string { - if (!ms) return "—"; - return new Date(ms).toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - }); -} diff --git a/editor/app/scheduler/page.tsx b/editor/app/scheduler/page.tsx @@ -1,36 +0,0 @@ -import type { Metadata } from "next"; -import Link from "next/link"; -import { buildSchedulerStatusPayload } from "./status"; -import { SchedulerView } from "./components/SchedulerView"; - -export const dynamic = "force-dynamic"; - -export const metadata: Metadata = { title: "Schedule" }; - -export default async function SchedulerPage() { - const initial = await buildSchedulerStatusPayload(); - return ( - <div className="flex flex-col gap-4"> - <div className="flex items-center justify-between"> - <h1 className="text-2xl font-semibold">Sync schedule</h1> - <Link href="/settings" className="text-sm underline"> - Scheduler settings - </Link> - </div> - <p className="text-sm text-muted-foreground"> - Per-channel auto-sync cadence, driven by a heartbeat - (<code>pnpm sync:tick</code> or the internal timer). Set each - channel&apos;s interval inline below — pick a preset or type an exact - minute count — and toggle the headline global controls (enable, default - interval, heartbeat) here too. A channel auto-syncs on the next heartbeat - once it&apos;s been longer than its interval since the last sync. The - advanced knobs (concurrency, quiet hours, backoff) live in{" "} - <Link href="/settings" className="underline"> - Settings - </Link> - . - </p> - <SchedulerView initial={initial} /> - </div> - ); -} diff --git a/editor/app/scheduler/runTick.ts b/editor/app/scheduler/runTick.ts @@ -1,3 +1,8 @@ +// THIS DIRECTORY IS THE SYNC OPERATION'S RUNNER — the heartbeat, the tick and +// the status payload. Its PAGE is /operations/sync (rendered by +// operations/[id]), and /scheduler redirects there; nothing here moved, because +// instrumentation.ts, both /api/scheduler/* routes, the operations rail's sync +// row and two forms import from it. import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { listChannelConfigs } from "yt-dlp-transcript-common/controller/channels"; import { getSettings } from "yt-dlp-transcript-common/lib/settings"; @@ -21,6 +26,9 @@ import { import { isSocialChannel } from "yt-dlp-transcript-common/lib/channelConfig"; import { syncAction } from "../channels/[slug]/pipelineActions"; import { fetchPostsAction } from "../channels/[slug]/socialActions"; +// STORAGE CHORES riding this heartbeat because it is the one timer the editor +// has. Neither is sync (IA doc, the Sync row): the keep-latest check consumes +// outputs, the backup copies them. import { checkKeptDeletedAction } from "../channels/[slug]/whisperActions"; import { backupSavedVideosAction } from "../saved-videos/backupActions"; @@ -34,9 +42,10 @@ export type SchedulerTickResult = { running: number; // Channels for which a sync was queued this tick. queued: string[]; - // Channels for which a keep-latest deletion check was queued this tick. + // Storage chore: channels for which a keep-latest deletion check was queued + // this tick. keptChecksQueued: string[]; - // Whether a saved-video backup was queued this tick. + // Storage chore: whether a saved-video backup was queued this tick. savedVideoBackupQueued: boolean; // Channels deliberately held back, with a reason. skipped: SchedulerSkip[]; @@ -151,11 +160,12 @@ export async function runSchedulerTick(): Promise<SchedulerTickResult> { scheduler.quietHoursEnd, ); - // Keep-latest deletion checks run on their own cadence, on the per-channel - // local queue (channel:<slug>) — separate from the platform download queue a - // sync uses, so the two don't serialize against each other. Suppressed during - // quiet hours, capped per tick like syncs, and skipped for any channel we - // just queued a sync for (avoid hitting the source twice in one tick). + // Storage chore: keep-latest deletion checks run on their own cadence, on + // the per-channel local queue (channel:<slug>) — separate from the platform + // download queue a sync uses, so the two don't serialize against each + // other. Suppressed during quiet hours, capped per tick like syncs, and + // skipped for any channel we just queued a sync for (avoid hitting the + // source twice in one tick). const keptChecksQueued: string[] = []; if (!quiet) { const queuedThisTick = new Set(queued); @@ -169,7 +179,10 @@ export async function runSchedulerTick(): Promise<SchedulerTickResult> { for (const c of dueForKeptCheck.slice(0, scheduler.maxConcurrentSyncs)) { const result = await checkKeptDeletedAction(c.slug); if (!result.ok) { - skipped.push({ slug: c.slug, reason: `kept-check: ${result.error}` }); + skipped.push({ + slug: c.slug, + reason: `storage chore (keep-latest check): ${result.error}`, + }); continue; } channelState(state, c.slug).lastKeptCheckAt = now; @@ -178,9 +191,10 @@ export async function runSchedulerTick(): Promise<SchedulerTickResult> { } } - // Saved-video backup: a single global job on its own cadence. Suppressed - // during quiet hours; runs at most once per intervalMinutes. Independent of - // the per-channel concurrency cap (it touches no source provider). + // Storage chore: the saved-video backup, a single global job on its own + // cadence. Suppressed during quiet hours; runs at most once per + // intervalMinutes. Independent of the per-channel concurrency cap (it + // touches no source provider). let savedVideoBackupQueued = false; const backup = settings.savedVideoBackup; if (!quiet && backup.enabled && backup.dest.trim()) { @@ -193,7 +207,10 @@ export async function runSchedulerTick(): Promise<SchedulerTickResult> { savedVideoBackupQueued = true; void result.stream.cancel(); } else { - skipped.push({ slug: "(saved-video backup)", reason: result.error }); + skipped.push({ + slug: "storage chore (saved-video backup)", + reason: result.error, + }); } } } diff --git a/editor/e2e/auto-queue.spec.ts b/editor/e2e/auto-queue.spec.ts @@ -1086,9 +1086,11 @@ test("UI: the board carries every pipeline, and each row links to it", async ({ await awaitHydration(page.locator('[data-board="operations"]')); // The rail carries a row per pipeline — the two the runners dispatch, the one - // digest owns, and every speaker operation that is switched on. It is the - // CATALOG view: what this install can do, not what it is doing. + // digest owns, every speaker operation that is switched on, and sync, the one + // channel-scoped row. It is the CATALOG view: what this install can do, not + // what it is doing. const rail = page.locator("li[data-operation]"); + await expect(rail.filter({ hasText: "Sync" }).first()).toBeVisible(); await expect(rail.filter({ hasText: "Download" }).first()).toBeVisible(); await expect(rail.filter({ hasText: "Transcription" }).first()).toBeVisible(); await expect(rail.filter({ hasText: "Digest" }).first()).toBeVisible(); @@ -1099,6 +1101,11 @@ test("UI: the board carries every pipeline, and each row links to it", async ({ await expect( rail.getByRole("link", { name: "Transcription", exact: true }), ).toHaveAttribute("href", "/operations/transcription"); + // Sync's row is composed rather than folded from a band — it has no per-video + // population — but it is the same anatomy, and its name links to its page. + await expect( + rail.getByRole("link", { name: "Sync", exact: true }), + ).toHaveAttribute("href", "/operations/sync"); await rail.getByRole("link", { name: "Digest", exact: true }).click(); await expect(page).toHaveURL(/\/operations\/digest$/); diff --git a/editor/e2e/navigation.spec.ts b/editor/e2e/navigation.spec.ts @@ -105,12 +105,19 @@ test.describe("navigation", () => { // 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. + // /scheduler was the sync operation's page before sync was catalogued. 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(); + + await page.goto("/scheduler"); + await expect(page).toHaveURL(/\/operations\/sync$/); + await expect( + page.getByRole("heading", { name: "Sync", level: 1 }), + ).toBeVisible(); }); test("a missing resource returns 404, not 200", async ({ page }) => { diff --git a/editor/e2e/scheduler.spec.ts b/editor/e2e/scheduler.spec.ts @@ -9,7 +9,8 @@ import { readJson, resetData, resolvePath, writeSettings } from "./helpers"; // // Covers the core rules of common/jobs/syncScheduler.selectDueChannels via the // live server: elapsed-interval due selection, the not-yet-due gate, and the -// already-running dedup — plus the editor /scheduler panel. +// already-running dedup — plus the sync operation's console at +// /operations/sync (the page that was /scheduler before sync was catalogued). const SLOW_A_CONFIG = "test-transcripts/channels/slow-a/config.json"; const SLOW_B_CONFIG = "test-transcripts/channels/slow-b/config.json"; @@ -83,8 +84,16 @@ test("scheduler queues due channels, skips not-due, and dedups running ones", as reason: "already running", }); - // The /scheduler panel reflects the enabled scheduler and lists the channels. - await page.goto("/scheduler"); + // The /operations/sync console reflects the enabled scheduler and lists the + // channels. The page is the sync OPERATION's, so it carries the operation's + // own heading and its row on the rail above. + await page.goto("/operations/sync"); + await expect( + page.getByRole("heading", { name: "Sync", level: 1 }), + ).toBeVisible(); + await expect(page.locator('li[data-operation="sync"]')).toContainText( + "channels on a cadence", + ); await expect(page.getByText("Scheduler enabled")).toBeVisible(); await expect(page.getByRole("link", { name: "Slow A" })).toBeVisible(); await expect( @@ -96,9 +105,10 @@ test("internal heartbeat cadence is configurable and surfaced", async ({ page, }) => { // Exercises the config plumbing for the in-process heartbeat (settings -> - // sanitize -> /api/scheduler/status -> /scheduler header). The live timer is - // armed only at server startup (editor/instrumentation.ts), so changing the - // setting at runtime is intentionally inert here — no real ticks fire. + // sanitize -> /api/scheduler/status -> the /operations/sync header). The + // live timer is armed only at server startup (editor/instrumentation.ts), so + // changing the setting at runtime is intentionally inert here — no real ticks + // fire. await resetData("two-slow-channels"); const baseSettings = { @@ -129,7 +139,7 @@ test("internal heartbeat cadence is configurable and surfaced", async ({ await page.request.get("/api/scheduler/status") ).json()) as { heartbeatSeconds: number }; expect(status1.heartbeatSeconds).toBe(15); - await page.goto("/scheduler"); + await page.goto("/operations/sync"); await expect(page.getByText("internal heartbeat every 15s")).toBeVisible(); // 0 = off -> the panel reports it relies on an external cron heartbeat. @@ -145,7 +155,7 @@ test("internal heartbeat cadence is configurable and surfaced", async ({ await expect(page.getByText("external heartbeat (cron)")).toBeVisible(); }); -// --- Inline per-channel schedule editing on the /scheduler page ------------- +// --- Inline per-channel schedule editing on /operations/sync ---------------- // Seed two channels so the editable status table has rows to act on. async function seedTwoChannels(opts?: { slowAInterval?: number }) { @@ -191,7 +201,7 @@ test("schedule page saves a preset interval to the channel config", async ({ page, }) => { await seedTwoChannels(); - await page.goto("/scheduler"); + await page.goto("/operations/sync"); const rowA = page.getByRole("row", { name: /Slow A/ }); // The cadence cell is collapsed to chips until you click into it. @@ -206,7 +216,7 @@ test("schedule page saves a preset interval to the channel config", async ({ test("schedule page saves a custom minute count", async ({ page }) => { await seedTwoChannels(); - await page.goto("/scheduler"); + await page.goto("/operations/sync"); const rowA = page.getByRole("row", { name: /Slow A/ }); await rowA.getByRole("button", { name: "Edit cadence for slow-a" }).click(); @@ -227,7 +237,7 @@ test("schedule page clears an interval back to the global default", async ({ }) => { // Start with an explicit per-channel interval, then pick "Default". await seedTwoChannels({ slowAInterval: 30 }); - await page.goto("/scheduler"); + await page.goto("/operations/sync"); const rowA = page.getByRole("row", { name: /Slow A/ }); await rowA.getByRole("button", { name: "Edit cadence for slow-a" }).click(); @@ -245,7 +255,7 @@ test("an in-progress custom edit survives the status poll", async ({ page }) => // The 5s status poll re-renders the table; the per-row editor must not reset a // value the user is mid-typing. await seedTwoChannels(); - await page.goto("/scheduler"); + await page.goto("/operations/sync"); const rowA = page.getByRole("row", { name: /Slow A/ }); await rowA.getByRole("button", { name: "Edit cadence for slow-a" }).click(); @@ -259,7 +269,7 @@ test("an in-progress custom edit survives the status poll", async ({ page }) => test("schedule page edits the global controls", async ({ page }) => { await seedTwoChannels(); - await page.goto("/scheduler"); + await page.goto("/operations/sync"); await page .getByRole("checkbox", { name: /Enable scheduled auto-sync/ }) diff --git a/editor/next.config.ts b/editor/next.config.ts @@ -54,6 +54,10 @@ const nextConfig: NextConfig = { // sections live with the operation that runs them, with /channels, with // /cleanup and with /review now, and the board is where a bookmark lands. // + // /scheduler was the sync operation's page before sync was catalogued; + // /api/scheduler/* is NOT redirected — the cron client and the console's own + // poll never moved. + // // 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. `permanent: false` is a @@ -64,6 +68,7 @@ const nextConfig: NextConfig = { return [ { source: "/auto-queue", destination: "/operations", permanent: false }, { source: "/actionable", destination: "/operations", permanent: false }, + { source: "/scheduler", destination: "/operations/sync", permanent: false }, ]; }, // Serve the built export artifacts (stats/summaries/transcripts) through a