Archilyzer · Source

archilyzer

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

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

sync: its settings live on its page, and one action writes them

One block had two writers. The headline three fields were saved by
saveSchedulerSettingsAction from the schedule page; the other eight were a
fieldset on /settings saved by saveSettingsAction, and the only thing keeping
either from resetting the other's values was that both remembered to preserve
what their own render did not show.

The descriptor declares `settingsBlock: "syncScheduler"`, so the operation
page's exhaustive switch draws the whole block below the schedule — that is
what the closed union is for. saveSchedulerSettingsAction parses all of it
(hourOrNull comes with the quiet-hours pair, since blank is a VALUE there),
and /settings preserves the block exactly as it preserves digest, diarization
and the lane. No field name, label or persisted key changed.

The keep-latest interval moves with the block because it is a field OF the
block, and its hint now says whose knob it is: a Storage chore on the sync
heartbeat, not sync.

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

Diffstat:
Mcommon/lib/operations.test.ts | 3+++
Mcommon/lib/operations.ts | 10+++++++++-
Meditor/app/channels/[slug]/videos/[id]/lib/videoOperationPanels.ts | 7+++++++
Meditor/app/operations/[id]/page.tsx | 12++++++++++++
Meditor/app/operations/components/sync/SchedulerSettingsForm.tsx | 114++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Meditor/app/operations/components/sync/SyncConsole.tsx | 6------
Meditor/app/scheduler/actions.ts | 58+++++++++++++++++++++++++++++++++++++++++++++++++++-------
Meditor/app/settings/actions.ts | 58+---------------------------------------------------------
Meditor/app/settings/components/SettingsForm.tsx | 157-------------------------------------------------------------------------------
Meditor/app/settings/page.tsx | 6+++++-
Meditor/e2e/cadence-ui.spec.ts | 56+++++++++++++++++++++++++++++++++++++++++++++++++-------
11 files changed, 242 insertions(+), 245 deletions(-)

diff --git a/common/lib/operations.test.ts b/common/lib/operations.test.ts @@ -1296,6 +1296,9 @@ test("`settingsBlock` names the settings.json block, off the descriptor", () => // because there is one `settings.attribution` and one form drawn on both // pages; the backfill LANE's block is not an operation's and is not here. const expected: Record<string, string> = { + // The one external operation with a block: the sync scheduler's settings + // are the sync operation's, and /operations/sync is where they are edited. + sync: "syncScheduler", digest: "digest", diarization: "diarization", "attribution-text": "attribution", diff --git a/common/lib/operations.ts b/common/lib/operations.ts @@ -334,7 +334,14 @@ export type OperationGroup = "media" | "transcript" | "digest" | "speakers" | "s // See Operation.settingsBlock. A closed union rather than `string` so the switch // that picks a settings form is exhaustive: a new block has to be named here and // then handled, instead of silently rendering nothing. -export type OperationSettingsBlock = "digest" | "diarization" | "attribution"; +export type OperationSettingsBlock = + | "digest" + | "diarization" + | "attribution" + // The WHOLE syncScheduler block — headline controls and the advanced knobs + // alike. An operation's settings live on its page (slice 3's rule), and + // splitting this one across two pages is what gave it two writers. + | "syncScheduler"; // Group order: upstream first. The /channels columns and the transit line both // lay their pipelines out in this order, so a reader moving between the two @@ -1334,6 +1341,7 @@ export const SYNC_OPERATION: OperationDescriptor = { dispatch: "external", scope: "channel", trigger: "cadence", + settingsBlock: "syncScheduler", }; export const EXTERNAL_OPERATIONS: readonly ExternalOperation[] = [ diff --git a/editor/app/channels/[slug]/videos/[id]/lib/videoOperationPanels.ts b/editor/app/channels/[slug]/videos/[id]/lib/videoOperationPanels.ts @@ -158,6 +158,13 @@ async function loadBody( }, }; } + // NEVER REACHED, and the switch has to say so anyway. `views` comes from + // videoOperations.ts, which walks the per-video registry; sync is + // `scope: "channel"` and has no per-video state at all, so its block can + // never arrive here. The arm exists because the union is closed and + // exhaustive — which is the property that makes a NEW per-video block a + // compile error rather than a blank panel. + case "syncScheduler": case undefined: return { block: undefined }; } diff --git a/editor/app/operations/[id]/page.tsx b/editor/app/operations/[id]/page.tsx @@ -21,6 +21,8 @@ import { buildSyncRow } from "../syncRow"; import { buildSchedulerStatusPayload } from "../../scheduler/status"; import { sweepLaneIdFor, type SweepLaneId } from "../lanes"; import { SyncConsole } from "../components/sync/SyncConsole"; +import { SchedulerSettingsForm } from "../components/sync/SchedulerSettingsForm"; +import { resolveHeartbeatSeconds } from "../../scheduler/heartbeat"; import { OperationDetail } from "../components/OperationDetail"; import { DigestSettingsForm } from "../components/settings/DigestSettingsForm"; import { DiarizationSettingsForm } from "../components/settings/DiarizationSettingsForm"; @@ -107,6 +109,16 @@ function settingsFormFor( digestApps={digestApps} /> ); + case "syncScheduler": + return ( + <SchedulerSettingsForm + key="syncScheduler" + scheduler={settings.syncScheduler} + // The EFFECTIVE cadence, not the stored one: SYNC_HEARTBEAT_SECONDS + // overrides the setting, and resolveHeartbeatSeconds is server-only. + heartbeatSeconds={resolveHeartbeatSeconds()} + /> + ); case undefined: return null; } diff --git a/editor/app/operations/components/sync/SchedulerSettingsForm.tsx b/editor/app/operations/components/sync/SchedulerSettingsForm.tsx @@ -6,12 +6,28 @@ import { saveSchedulerSettingsAction, type SaveResult, } from "../../../scheduler/actions"; +import { + SYNC_INTERVAL_MAX_MINUTES, + SYNC_INTERVAL_MIN_MINUTES, +} from "yt-dlp-transcript-common/lib/channelConfig"; import { DurationField } from "../../../components/DurationField"; +import { Field } from "../../../components/forms/Field"; 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. +// THE SYNC OPERATION'S SETTINGS BLOCK, whole. It is the form the descriptor's +// `settingsBlock: "syncScheduler"` selects, drawn below the schedule on +// /operations/sync. +// +// It was half a form here and a fieldset on /settings, which meant TWO writers +// of one block — and the only thing keeping them from clobbering each other was +// that both remembered to preserve the fields their own render did not show. +// One block, one page, one writer (saveSchedulerSettingsAction); /settings +// preserves it the way it preserves digest, diarization and the lane. +// +// `heartbeatSeconds` is the live EFFECTIVE value passed from the page +// (resolveHeartbeatSeconds), not the stored one: SYNC_HEARTBEAT_SECONDS +// overrides the setting, and a field showing the stored number beside a server +// running a different cadence is a lie the operator cannot see. export function SchedulerSettingsForm({ scheduler, heartbeatSeconds, @@ -27,14 +43,10 @@ export function SchedulerSettingsForm({ return ( <form action={formAction} + data-settings-block="syncScheduler" 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> + <h2 className="text-sm font-semibold">Sync scheduler</h2> <label className="flex items-start gap-2 text-sm"> <input type="checkbox" @@ -95,6 +107,90 @@ export function SchedulerSettingsForm({ className="max-w-sm" /> </div> + <div className="flex flex-wrap gap-4"> + <Field + label="Full-sweep auto-confirm cap" + name="syncSchedulerFullSweepConfirmMaxSuspects" + defaultValue={String(scheduler.fullSweepConfirmMaxSuspects)} + type="number" + hint="When a sweep finds at most this many videos missing from the listing, it probes each one upstream to confirm; above the cap it only flags them and leaves the probing to 'Check maybe-missing'. 0 = never auto-confirm. This is a count of videos, not a duration." + /> + <Field + label="Full-sweep shrink guard" + name="syncSchedulerFullSweepShrinkGuardPercent" + defaultValue={String(scheduler.fullSweepShrinkGuardPercent)} + type="number" + hint="If a fresh listing comes back smaller than the stored one by more than this percentage (and by more than 25 entries), it is treated as suspect: the stored video list and the missing-video flags are left alone and the sweep is retried on the next sync rather than after the full cadence. A second enumeration reporting a similar count confirms it and is accepted, so a genuine mass deletion still lands. 0 = off; an empty listing is always refused. This is a percentage, not a duration." + /> + <Field + label="Max concurrent syncs" + name="syncSchedulerMaxConcurrentSyncs" + defaultValue={String(scheduler.maxConcurrentSyncs)} + type="number" + hint="A tick queues at most (this − already-running) channels, most-overdue first; the rest roll to the next tick. Bounds load and staggers big batches." + /> + <DurationField + label="Keep-latest check interval" + name="syncSchedulerKeepLatestCheckIntervalMinutes" + baseUnit="minutes" + units={["hours", "days"]} + value={scheduler.keepLatestCheckIntervalMinutes} + min={SYNC_INTERVAL_MIN_MINUTES} + max={SYNC_INTERVAL_MAX_MINUTES} + hint="A Storage chore on the sync heartbeat, not sync: how often channels with a keep-latest limit are re-checked so old videos get pruned even when nothing new arrived." + className="max-w-sm" + /> + </div> + <div className="flex gap-3"> + <Field + label="Quiet hours start (0–23)" + name="syncSchedulerQuietHoursStart" + defaultValue={ + scheduler.quietHoursStart != null + ? String(scheduler.quietHoursStart) + : "" + } + type="number" + /> + <Field + label="Quiet hours end (0–23)" + name="syncSchedulerQuietHoursEnd" + defaultValue={ + scheduler.quietHoursEnd != null + ? String(scheduler.quietHoursEnd) + : "" + } + type="number" + /> + </div> + <p className="text-xs text-muted-foreground -mt-1"> + Optional local-clock window when auto-sync is suppressed (may wrap past + midnight, e.g. 22 → 6). Leave both blank to always allow. + </p> + <div className="flex flex-wrap gap-3"> + <DurationField + label="Backoff base" + name="syncSchedulerBackoffBaseMinutes" + baseUnit="minutes" + units={["minutes", "hours"]} + value={scheduler.backoffBaseMinutes} + min={SYNC_INTERVAL_MIN_MINUTES} + max={SYNC_INTERVAL_MAX_MINUTES} + /> + <DurationField + label="Backoff max" + name="syncSchedulerBackoffMaxMinutes" + baseUnit="minutes" + units={["minutes", "hours", "days"]} + value={scheduler.backoffMaxMinutes} + min={SYNC_INTERVAL_MIN_MINUTES} + max={SYNC_INTERVAL_MAX_MINUTES} + /> + </div> + <p className="text-xs text-muted-foreground -mt-1"> + After N consecutive failed syncs a channel waits base·2^(N-1) minutes + (capped at max) before retrying. + </p> <div className="flex items-center gap-3"> <button type="submit" diff --git a/editor/app/operations/components/sync/SyncConsole.tsx b/editor/app/operations/components/sync/SyncConsole.tsx @@ -4,7 +4,6 @@ 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 @@ -160,11 +159,6 @@ export function SyncConsole({ </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"> diff --git a/editor/app/scheduler/actions.ts b/editor/app/scheduler/actions.ts @@ -104,19 +104,28 @@ export async function setChannelCadencesAction( return { ok: true }; } -// Save the headline global scheduler controls from the scheduler page. Only the -// syncScheduler block is touched; every other setting is read back and preserved. -// Values are clamped/sanitized by sanitizeSyncScheduler inside writeSettings, so -// we only coerce here (NaN/blank fall back to defaults). +// THE ONE WRITER OF settings.syncScheduler. Reads the current file and replaces +// this block only; every other block is preserved verbatim — the contract +// cadence-ui.spec.ts holds every settings writer to. +// +// It used to be one of TWO: /settings carried the advanced knobs and this +// carried the headline three, so one block had two parsers and each relied on +// the other remembering to preserve what its own render did not show. The whole +// block is the sync operation's settings form now, on the sync operation's +// page, and this is what saves it. +// +// Values are clamped/sanitized by sanitizeSyncScheduler inside writeSettings, +// so we only coerce here. export async function saveSchedulerSettingsAction( _prev: SaveResult | undefined, formData: FormData, ): Promise<SaveResult> { const current = getSettings(); // An absent or blank field keeps the saved value rather than coercing to NaN - // and resetting it to the default — this form renders a subset of the sync - // scheduler block, so a "Save controls" click must never disturb the fields - // it doesn't show. + // and resetting it to the default. It is the whole block's contract now, not + // just this form's: a render that ever drops a field must not silently reset + // it, and several of these are 0-sentinel ("never") values a default would + // quietly overwrite. const intOrKeep = (key: string, fallback: number): number => { const raw = formData.get(key); if (raw === null) return fallback; @@ -125,6 +134,15 @@ export async function saveSchedulerSettingsAction( const n = Number.parseInt(trimmed, 10); return Number.isFinite(n) ? n : fallback; }; + // Quiet hours are the one pair where BLANK IS A VALUE — "no quiet window" — + // so they cannot go through intOrKeep. Moved here from settings/actions.ts + // with the fields. + const hourOrNull = (key: string): number | null => { + const raw = String(formData.get(key) ?? "").trim(); + if (!raw) return null; + const n = Number.parseInt(raw, 10); + return Number.isFinite(n) ? n : null; + }; const next: SiteSettings = { ...current, syncScheduler: { @@ -142,6 +160,32 @@ export async function saveSchedulerSettingsAction( "syncSchedulerFullSweepIntervalMinutes", current.syncScheduler.fullSweepIntervalMinutes, ), + fullSweepConfirmMaxSuspects: intOrKeep( + "syncSchedulerFullSweepConfirmMaxSuspects", + current.syncScheduler.fullSweepConfirmMaxSuspects, + ), + fullSweepShrinkGuardPercent: intOrKeep( + "syncSchedulerFullSweepShrinkGuardPercent", + current.syncScheduler.fullSweepShrinkGuardPercent, + ), + maxConcurrentSyncs: intOrKeep( + "syncSchedulerMaxConcurrentSyncs", + current.syncScheduler.maxConcurrentSyncs, + ), + keepLatestCheckIntervalMinutes: intOrKeep( + "syncSchedulerKeepLatestCheckIntervalMinutes", + current.syncScheduler.keepLatestCheckIntervalMinutes, + ), + quietHoursStart: hourOrNull("syncSchedulerQuietHoursStart"), + quietHoursEnd: hourOrNull("syncSchedulerQuietHoursEnd"), + backoffBaseMinutes: intOrKeep( + "syncSchedulerBackoffBaseMinutes", + current.syncScheduler.backoffBaseMinutes, + ), + backoffMaxMinutes: intOrKeep( + "syncSchedulerBackoffMaxMinutes", + current.syncScheduler.backoffMaxMinutes, + ), }, }; try { diff --git a/editor/app/settings/actions.ts b/editor/app/settings/actions.ts @@ -169,62 +169,6 @@ export async function saveSettingsAction( }; } - // Sync-scheduler block. Values are clamped by sanitizeSyncScheduler inside - // writeSettings, so we only coerce here (NaN/blank fall back to defaults). - const intOrNaN = (key: string): number => - Number.parseInt(String(formData.get(key) ?? "").trim(), 10); - const hourOrNull = (key: string): number | null => { - const raw = String(formData.get(key) ?? "").trim(); - if (!raw) return null; - const n = Number.parseInt(raw, 10); - return Number.isFinite(n) ? n : null; - }; - // For fields a given render of this form may not carry: an absent key keeps - // the saved value instead of coercing to NaN (which would reset it to the - // default). A present-but-blank value is treated as absent for the same reason. - const current = getSettings(); - const intOrKeep = (key: string, fallback: number): number => { - const raw = formData.get(key); - if (raw === null) return fallback; - const trimmed = String(raw).trim(); - if (!trimmed) return fallback; - const n = Number.parseInt(trimmed, 10); - return Number.isFinite(n) ? n : fallback; - }; - const syncScheduler = { - enabled: formData.get("syncSchedulerEnabled") === "on", - defaultIntervalMinutes: intOrNaN("syncSchedulerDefaultIntervalMinutes"), - maxConcurrentSyncs: intOrNaN("syncSchedulerMaxConcurrentSyncs"), - quietHoursStart: hourOrNull("syncSchedulerQuietHoursStart"), - quietHoursEnd: hourOrNull("syncSchedulerQuietHoursEnd"), - backoffBaseMinutes: intOrNaN("syncSchedulerBackoffBaseMinutes"), - backoffMaxMinutes: intOrNaN("syncSchedulerBackoffMaxMinutes"), - heartbeatSeconds: intOrNaN("syncSchedulerHeartbeatSeconds"), - // Had no input at all until the cadence UI landed, so every unrelated save - // used to coerce it to NaN and silently reset it to the default. intOrKeep - // makes that impossible even if a future render drops the field again. - keepLatestCheckIntervalMinutes: intOrKeep( - "syncSchedulerKeepLatestCheckIntervalMinutes", - current.syncScheduler.keepLatestCheckIntervalMinutes, - ), - // Full-sweep cadence + auto-confirm cap. Read from the form when present, - // otherwise the currently-saved value is preserved: these are 0-sentinel - // fields ("never"), so an unrelated save must not quietly reset a - // hand-tuned value to the default. - fullSweepIntervalMinutes: intOrKeep( - "syncSchedulerFullSweepIntervalMinutes", - current.syncScheduler.fullSweepIntervalMinutes, - ), - fullSweepConfirmMaxSuspects: intOrKeep( - "syncSchedulerFullSweepConfirmMaxSuspects", - current.syncScheduler.fullSweepConfirmMaxSuspects, - ), - fullSweepShrinkGuardPercent: intOrKeep( - "syncSchedulerFullSweepShrinkGuardPercent", - current.syncScheduler.fullSweepShrinkGuardPercent, - ), - }; - let socialInput: unknown; try { socialInput = JSON.parse(String(formData.get("socialLinksJson") ?? "[]")); @@ -298,7 +242,7 @@ export async function saveSettingsAction( archiveStorage, reportDebouncePreset, autoRefreshIntervalSeconds: autoRefreshParsed, - syncScheduler, + syncScheduler: getSettings().syncScheduler, // Preserve the existing auto-queue policy on an unrelated settings save // (this form doesn't edit it; the Auto-queue page does). writeSettings // re-sanitizes it regardless. diff --git a/editor/app/settings/components/SettingsForm.tsx b/editor/app/settings/components/SettingsForm.tsx @@ -11,13 +11,7 @@ import { DOWNLOAD_FORMAT_PRESETS, } from "yt-dlp-transcript-common/ytdlp/downloadFormat"; import type { TranscriptionAppDescriptor } from "yt-dlp-transcript-common/lib/transcriptionApps"; -import { - SYNC_INTERVAL_MAX_MINUTES, - SYNC_INTERVAL_MIN_MINUTES, -} from "yt-dlp-transcript-common/lib/channelConfig"; -import { DurationField } from "../../components/DurationField"; import { Field } from "../../components/forms/Field"; -import { FULL_SWEEP_PRESETS } from "../../scheduler/intervalPresets"; import { SocialLinksField, toSocialRow, @@ -312,157 +306,6 @@ export function SettingsForm({ initial, apps, workerTags }: Props) { </span> </label> <fieldset className="flex flex-col gap-3 border border-border rounded p-3"> - <legend className="px-1 text-sm font-medium">Sync scheduler</legend> - <p className="text-xs text-muted-foreground"> - Auto-sync channels on a per-channel cadence (set under each channel&apos;s - Auto-sync). A heartbeat fires periodically and the server syncs whichever - channels are due. Set the internal heartbeat below to run it in-process - (no cron needed), or leave it off and drive it with an external cron - heartbeat (<code>pnpm sync:tick</code>). See SCHEDULED_SYNC.md. - </p> - <label className="flex items-start gap-2 text-sm"> - <input - type="checkbox" - name="syncSchedulerEnabled" - defaultChecked={initial.syncScheduler.enabled} - className="mt-1" - /> - <span className="flex flex-col gap-1"> - <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> - <DurationField - label="Default interval" - name="syncSchedulerDefaultIntervalMinutes" - baseUnit="minutes" - units={["minutes", "hours", "days"]} - value={initial.syncScheduler.defaultIntervalMinutes} - min={SYNC_INTERVAL_MIN_MINUTES} - max={SYNC_INTERVAL_MAX_MINUTES} - rate={{ noun: "check", per: "channel" }} - hint="Auto-sync cadence for channels left on 'Default'. Checks the newest videos and downloads what's new." - /> - <DurationField - label="Default full sweep interval" - name="syncSchedulerFullSweepIntervalMinutes" - baseUnit="minutes" - units={["hours", "days"]} - value={initial.syncScheduler.fullSweepIntervalMinutes} - presets={FULL_SWEEP_PRESETS} - allowOff - offLabel="Off (never sweep)" - min={SYNC_INTERVAL_MIN_MINUTES} - max={SYNC_INTERVAL_MAX_MINUTES} - rate={{ - noun: "full listing fetch", - pluralNoun: "full listing fetches", - per: "channel", - }} - hint="How often a sync upgrades itself to re-read a channel's whole listing: refreshes the stored video list and flags videos that have gone missing upstream. Costs one full enumeration per channel, so it runs far less often than auto-sync. Individual channels can override this." - /> - <Field - label="Full-sweep auto-confirm cap" - name="syncSchedulerFullSweepConfirmMaxSuspects" - defaultValue={String( - initial.syncScheduler.fullSweepConfirmMaxSuspects, - )} - type="number" - hint="When a sweep finds at most this many videos missing from the listing, it probes each one upstream to confirm; above the cap it only flags them and leaves the probing to 'Check maybe-missing'. 0 = never auto-confirm. This is a count of videos, not a duration." - /> - <Field - label="Full-sweep shrink guard" - name="syncSchedulerFullSweepShrinkGuardPercent" - defaultValue={String( - initial.syncScheduler.fullSweepShrinkGuardPercent, - )} - type="number" - hint="If a fresh listing comes back smaller than the stored one by more than this percentage (and by more than 25 entries), it is treated as suspect: the stored video list and the missing-video flags are left alone and the sweep is retried on the next sync rather than after the full cadence. A second enumeration reporting a similar count confirms it and is accepted, so a genuine mass deletion still lands. 0 = off; an empty listing is always refused. This is a percentage, not a duration." - /> - <Field - label="Max concurrent syncs" - name="syncSchedulerMaxConcurrentSyncs" - defaultValue={String(initial.syncScheduler.maxConcurrentSyncs)} - type="number" - hint="A tick queues at most (this − already-running) channels, most-overdue first; the rest roll to the next tick. Bounds load and staggers big batches." - /> - <DurationField - label="Keep-latest check interval" - name="syncSchedulerKeepLatestCheckIntervalMinutes" - baseUnit="minutes" - units={["hours", "days"]} - value={initial.syncScheduler.keepLatestCheckIntervalMinutes} - min={SYNC_INTERVAL_MIN_MINUTES} - max={SYNC_INTERVAL_MAX_MINUTES} - hint="How often channels with a keep-latest limit are re-checked so old videos get pruned even when nothing new arrived." - /> - <DurationField - label="Internal heartbeat" - name="syncSchedulerHeartbeatSeconds" - baseUnit="seconds" - units={["seconds", "minutes"]} - value={initial.syncScheduler.heartbeatSeconds} - allowOff - offLabel="Off (external cron)" - min={15} - max={3600} - hint="When on, the editor ticks the scheduler itself this often (clamped to 15–3600s); when off, drive it with an external cron heartbeat. Changes take effect on the next tick; turning it on from off needs a server restart. The SYNC_HEARTBEAT_SECONDS env var overrides this." - /> - <div className="flex gap-3"> - <Field - label="Quiet hours start (0–23)" - name="syncSchedulerQuietHoursStart" - defaultValue={ - initial.syncScheduler.quietHoursStart != null - ? String(initial.syncScheduler.quietHoursStart) - : "" - } - type="number" - /> - <Field - label="Quiet hours end (0–23)" - name="syncSchedulerQuietHoursEnd" - defaultValue={ - initial.syncScheduler.quietHoursEnd != null - ? String(initial.syncScheduler.quietHoursEnd) - : "" - } - type="number" - /> - </div> - <p className="text-xs text-muted-foreground -mt-1"> - Optional local-clock window when auto-sync is suppressed (may wrap - past midnight, e.g. 22 → 6). Leave both blank to always allow. - </p> - <div className="flex flex-wrap gap-3"> - <DurationField - label="Backoff base" - name="syncSchedulerBackoffBaseMinutes" - baseUnit="minutes" - units={["minutes", "hours"]} - value={initial.syncScheduler.backoffBaseMinutes} - min={SYNC_INTERVAL_MIN_MINUTES} - max={SYNC_INTERVAL_MAX_MINUTES} - /> - <DurationField - label="Backoff max" - name="syncSchedulerBackoffMaxMinutes" - baseUnit="minutes" - units={["minutes", "hours", "days"]} - value={initial.syncScheduler.backoffMaxMinutes} - min={SYNC_INTERVAL_MIN_MINUTES} - max={SYNC_INTERVAL_MAX_MINUTES} - /> - </div> - <p className="text-xs text-muted-foreground -mt-1"> - After N consecutive failed syncs a channel waits base·2^(N-1) minutes - (capped at max) before retrying. - </p> - </fieldset> - <fieldset className="flex flex-col gap-3 border border-border rounded p-3"> <legend className="px-1 text-sm font-medium">Build pipeline</legend> <p className="text-xs text-muted-foreground"> How the static export is built. <strong>Basic</strong> runs the build diff --git a/editor/app/settings/page.tsx b/editor/app/settings/page.tsx @@ -73,7 +73,11 @@ export default async function SettingsPage() { > Speaker names (from the audio) </Link> - . + . The sync scheduler is one of them:{" "} + <Link href="/operations/sync" className={POINTER_LINK}> + Sync + </Link>{" "} + carries the whole block, below the schedule it drives. </p> </div> <SettingsForm diff --git a/editor/e2e/cadence-ui.spec.ts b/editor/e2e/cadence-ui.spec.ts @@ -101,24 +101,28 @@ test("clearing a cadence to Default removes the key from config", async ({ .toBe(false); }); -test("settings round-trips the full-sweep cadence and confirm cap", async ({ +test("the sync page round-trips the full-sweep cadence and confirm cap", async ({ page, }) => { await resetData(null); - await page.goto("/settings"); + // THE WHOLE BLOCK IS ON THE SYNC OPERATION'S PAGE NOW. These three fields + // were a /settings fieldset; the labels did not change, the page did. + await page.goto("/operations/sync"); + const form = page.locator('form[data-settings-block="syncScheduler"]'); // Preset: every 3 days. - await page + await form .getByLabel("Default full sweep interval", { exact: true }) .selectOption("4320"); - await page.getByLabel(/full-sweep auto-confirm cap/i).fill("5"); + await form.getByLabel(/full-sweep auto-confirm cap/i).fill("5"); // The keep-latest check interval had no input at all before this — and the // action read it with a coercion that reset it to the default on every save. - await page + await form .getByLabel("Keep-latest check interval unit") .selectOption("hours"); - await page.getByLabel("Keep-latest check interval amount").fill("8"); - await page.getByRole("button", { name: /save settings/i }).click(); + await form.getByLabel("Keep-latest check interval amount").fill("8"); + await form.getByRole("button", { name: "Save controls" }).click(); + await expect(form.getByRole("status")).toHaveText("Saved."); await expect .poll(async () => @@ -137,6 +141,11 @@ test("settings round-trips the full-sweep cadence and confirm cap", async ({ }); }); +// UNCHANGED, and it passes for a different reason now: /settings no longer +// parses the sync scheduler at all, so the interval survives by PRESERVATION +// rather than by being re-read off a field this form still renders. That is +// the stronger property — it is the contract every settings writer here is +// held to. test("an unrelated settings save no longer resets the keep-latest interval", async ({ page, }) => { @@ -169,6 +178,39 @@ test("an unrelated settings save no longer resets the keep-latest interval", asy }); }); +test("the sync page's save writes its block and nothing else", async ({ + page, +}) => { + // ONE WRITER PER BLOCK, proved from a browser: the sync form is the only + // writer of settings.syncScheduler, and saving it must leave every other + // block on disk exactly as it was. + await resetData(null); + await writeSettings({ + adminTitle: "Keep Me", + maxTranscriptPageBytes: 8388608, + sleepBetweenDownloadsSeconds: 0, + minFreeDiskGB: 0, + verifyAvailabilityBeforeClean: false, + }); + await page.goto("/operations/sync"); + const form = page.locator('form[data-settings-block="syncScheduler"]'); + await form.getByLabel("Max concurrent syncs").fill("3"); + await form.getByRole("button", { name: "Save controls" }).click(); + await expect(form.getByRole("status")).toHaveText("Saved."); + + await expect + .poll(async () => + readJson<{ + adminTitle: string; + syncScheduler: { maxConcurrentSyncs: number }; + }>("test-settings.json"), + ) + .toMatchObject({ + adminTitle: "Keep Me", + syncScheduler: { maxConcurrentSyncs: 3 }, + }); +}); + test("bulk apply retunes selected channels and leaves the rest alone", async ({ page, }) => {