commit beea654738fa96483cd7b8ef4c96127e5887fd1b
parent 9788a19ee9d6cfe57810f78116e88318f2e10ddd
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sun, 21 Jun 2026 22:04:39 -0400
reworked granular scheduler UI
Diffstat:
17 files changed, 776 insertions(+), 44 deletions(-)
diff --git a/SCHEDULED_SYNC.md b/SCHEDULED_SYNC.md
@@ -1,19 +1,36 @@
# Scheduled channel sync
Channels can sync automatically on a per-channel cadence (hourly, daily, every
-N minutes, …), inspired by the Laravel scheduler: a single dumb cron heartbeat
-runs often, and the **server** decides which channels are actually due.
+N minutes, …), inspired by the Laravel scheduler: a single dumb heartbeat runs
+often, and the **server** decides which channels are actually due.
## How it works
+The heartbeat can be driven two ways — pick one:
+
```
-cron (*/5 * * * *)
- └─ pnpm sync:tick external lightweight client
- └─ POST /api/scheduler/tick editor server
- └─ selectDueChannels() common/jobs/syncScheduler.ts
- └─ syncAction(slug) existing job queue + worker pool
+ ┌─ internal timer editor instrumentation hook
+heartbeat ──┤ │ (editor/instrumentation.ts -> heartbeat.ts)
+ └─ external cron ──┐ │ calls runSchedulerTick() directly, in-process
+ pnpm sync:tick │ │
+ └─ POST /api/scheduler/tick editor server
+ └─ runSchedulerTick()
+ └─ selectDueChannels() common/jobs/syncScheduler.ts
+ └─ syncAction(slug) existing job queue + worker pool
```
+- **Internal heartbeat (recommended, no cron needed):** the editor's Next.js
+ instrumentation hook arms an in-process timer at server startup that calls the
+ scheduler tick directly. Enable it by setting a cadence in **Settings → Sync
+ scheduler → Internal heartbeat** (see below). This is the simplest setup —
+ nothing external to install.
+- **External cron heartbeat (fallback):** leave the internal heartbeat off (0)
+ and have an OS cron job POST to the tick endpoint via `pnpm sync:tick`. Useful
+ when the editor runs behind something that drives schedules centrally, or for
+ multi-host setups. Both paths call the same `runSchedulerTick()`, so they're
+ interchangeable and may even coexist (the tick's overlap guard keeps them from
+ colliding).
+
- A channel is **due** when `now - lastSyncedAt >= its interval`. A missed tick
(server was down, machine asleep) just runs at the next tick — no catch-up
storms.
@@ -33,6 +50,12 @@ stored as `syncIntervalMinutes` in `channels/<slug>/config.json`:
Global controls live in **Settings → Sync scheduler**:
- **Enabled** — master on/off (off by default).
+- **Internal heartbeat (seconds)** — cadence for the in-process timer. `0` = off
+ (use an external cron heartbeat instead). Any positive value is clamped to
+ `[15, 3600]` seconds. Set this no larger than your smallest channel interval.
+ Changing it takes effect on the next tick; turning it on **from 0 requires a
+ server restart** (the timer is armed once at startup). The `SYNC_HEARTBEAT_SECONDS`
+ env var overrides this setting at runtime.
- **Default interval** — fallback for channels without an override.
- **Max concurrent syncs** — cap on simultaneous sync jobs. A tick queues at most
`cap - running` channels (most-overdue first); the rest roll to the next tick.
@@ -44,10 +67,29 @@ Global controls live in **Settings → Sync scheduler**:
Channels already marked **Exclude from sync** are never auto-synced.
-## Cron setup
+## Internal heartbeat (no cron)
+
+Set **Settings → Sync scheduler → Internal heartbeat** to a cadence (e.g. `300`
+for every 5 minutes) and restart the editor. The instrumentation hook
+(`editor/instrumentation.ts`) arms a single in-process timer that calls
+`runSchedulerTick()` directly — no cron, no `sync:tick` client, no token.
+
+Caveats:
+
+- The timer is armed **once per server instance** at startup. If you run multiple
+ editor instances against the same data directory (e.g. a process-manager
+ cluster), each one would beat and race on the scheduler state file — the same
+ single-trigger assumption the cron model had. In that case set
+ `SYNC_HEARTBEAT_SECONDS=0` on all-but-one instance (or use the external cron
+ heartbeat against a single instance).
+- Development and test servers leave the cadence at `0`, so nothing auto-ticks
+ there unless you explicitly configure it.
+
+## External cron heartbeat (fallback)
-Run the heartbeat at an interval no larger than your smallest channel interval
-(`*/5` comfortably serves a 10-minute minimum):
+Leave the internal heartbeat at `0` and run `pnpm sync:tick` from cron at an
+interval no larger than your smallest channel interval (`*/5` comfortably serves
+a 10-minute minimum):
```cron
*/5 * * * * cd /path/to/repo && pnpm sync:tick >> /var/log/ytt-sync.log 2>&1
@@ -70,4 +112,6 @@ error so cron can surface failures.
**Channels → Sync schedule** shows each channel's resolved interval, last sync,
next-due time, recent outcome, and any active backoff, plus a log of recent
-ticks. The same data is available as JSON at `GET /api/scheduler/status`.
+ticks. The header also reports how ticks are driven — "internal heartbeat every
+N" or "external heartbeat (cron)". The same data is available as JSON at
+`GET /api/scheduler/status` (including the effective `heartbeatSeconds`).
diff --git a/common/jobs/syncScheduler.ts b/common/jobs/syncScheduler.ts
@@ -135,6 +135,11 @@ export type ChannelScheduleView = {
autoSyncEligible: boolean;
intervalMinutes: number; // resolved; 0 = disabled
inheritsInterval: boolean; // using the global default vs a per-channel value
+ // The raw per-channel value as stored in config (undefined = inherit global,
+ // 0 = off, >0 = explicit). Lets an editor seed its controls unambiguously
+ // (inherit vs explicit-off vs explicit-minutes), which `intervalMinutes`
+ // alone can't express once it's resolved against the default.
+ configuredIntervalMinutes: number | undefined;
lastSyncedAt: string | null;
nextDueAt: number | null; // epoch ms; null when disabled or never synced
overdue: boolean;
@@ -180,6 +185,7 @@ export function buildScheduleView(input: {
interval > 0,
intervalMinutes: interval,
inheritsInterval: config.syncIntervalMinutes === undefined,
+ configuredIntervalMinutes: config.syncIntervalMinutes,
lastSyncedAt: last,
nextDueAt,
overdue,
diff --git a/common/lib/settings.ts b/common/lib/settings.ts
@@ -123,6 +123,13 @@ export type SyncSchedulerSettings = {
// channel waits min(base * 2^(N-1), max) minutes before it's eligible again.
backoffBaseMinutes: number;
backoffMaxMinutes: number;
+ // Cadence (seconds) for the editor's in-process heartbeat — the internal timer
+ // armed by the instrumentation hook (editor/instrumentation.ts) that calls the
+ // scheduler tick directly, so no external cron is needed. 0 = off: rely on the
+ // external `pnpm sync:tick` heartbeat instead. Any positive value is clamped to
+ // [SYNC_HEARTBEAT_MIN_SECONDS, SYNC_HEARTBEAT_MAX_SECONDS]. The env var
+ // SYNC_HEARTBEAT_SECONDS overrides this at runtime. See SCHEDULED_SYNC.md.
+ heartbeatSeconds: number;
};
export type SocialLink = {
@@ -182,6 +189,13 @@ export const SYNC_SCHEDULER_MAX_CONCURRENT_MAX = 16;
export const SYNC_SCHEDULER_BACKOFF_BASE_DEFAULT_MINUTES = 30;
export const SYNC_SCHEDULER_BACKOFF_MAX_DEFAULT_MINUTES = 1440;
+// Internal-heartbeat cadence bounds. 0 means "off" (use an external cron
+// heartbeat); any other value is clamped into [MIN, MAX] seconds. The floor
+// keeps the in-process timer from busy-looping; the ceiling is one hour.
+export const SYNC_HEARTBEAT_DEFAULT_SECONDS = 0;
+export const SYNC_HEARTBEAT_MIN_SECONDS = 15;
+export const SYNC_HEARTBEAT_MAX_SECONDS = 3600;
+
export function defaultSyncScheduler(): SyncSchedulerSettings {
return {
enabled: false,
@@ -191,9 +205,23 @@ export function defaultSyncScheduler(): SyncSchedulerSettings {
quietHoursEnd: null,
backoffBaseMinutes: SYNC_SCHEDULER_BACKOFF_BASE_DEFAULT_MINUTES,
backoffMaxMinutes: SYNC_SCHEDULER_BACKOFF_MAX_DEFAULT_MINUTES,
+ heartbeatSeconds: SYNC_HEARTBEAT_DEFAULT_SECONDS,
};
}
+// Clamp an internal-heartbeat cadence: 0 (off) passes through; any positive
+// value is clamped up into [MIN, MAX]; junk falls back to the default.
+export function clampHeartbeatSeconds(value: unknown): number {
+ if (typeof value !== "number" || !Number.isFinite(value)) {
+ return SYNC_HEARTBEAT_DEFAULT_SECONDS;
+ }
+ const n = Math.floor(value);
+ if (n <= 0) return 0;
+ if (n < SYNC_HEARTBEAT_MIN_SECONDS) return SYNC_HEARTBEAT_MIN_SECONDS;
+ if (n > SYNC_HEARTBEAT_MAX_SECONDS) return SYNC_HEARTBEAT_MAX_SECONDS;
+ return n;
+}
+
function clampHourOrNull(value: unknown): number | null {
if (typeof value !== "number" || !Number.isFinite(value)) return null;
const n = Math.floor(value);
@@ -249,6 +277,7 @@ export function sanitizeSyncScheduler(value: unknown): SyncSchedulerSettings {
SYNC_INTERVAL_MAX_MINUTES,
),
),
+ heartbeatSeconds: clampHeartbeatSeconds(r.heartbeatSeconds),
};
}
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -1,6 +1,8 @@
# Changelog
## [Unreleased]
+- **The Schedule page is now a one-stop editor for per-channel sync cadence.** The `/scheduler` page used to be read-only — you could see each channel's interval, last sync, and next-due time, but to *change* a cadence you had to open that channel's editor (Source → Auto-sync), one channel at a time, and the headline global toggles lived only in Settings. Now each row's **Interval** cell is an inline editor: pick a preset (Default / Off / Every 10–30m / Hourly / 6h / 12h / Daily / Weekly) **or** choose **Custom (minutes)…** and type an exact minute count, then **Save** — writing just `syncIntervalMinutes` to that channel's `config.json` and leaving every other field untouched (it does *not* go through the full channel-form merge). The page also gained a **Global controls** block to toggle the master **enable**, the **default interval**, and the **internal heartbeat** right there (the advanced knobs — concurrency, quiet hours, backoff — still link out to Settings). The underlying due logic is unchanged: a channel auto-syncs on the next heartbeat once `now − lastSyncedAt ≥ its interval`. The live status columns keep polling every 5s, but each row's editor holds its own state seeded once from the stored value, so a refresh can't clobber an in-progress edit. The preset list is shared with the channel editor (`editor/app/scheduler/intervalPresets.ts`), and `GET /api/scheduler/status` now carries each channel's raw `configuredIntervalMinutes` so the editor can tell *inherit-default* from *explicit-off* from *explicit-minutes*. See `editor/app/scheduler/actions.ts` and `editor/app/scheduler/components/{ChannelIntervalEditor,SchedulerSettingsForm}.tsx`.
+- **Scheduled sync can now run without an external cron job.** The sync scheduler previously only fired when an OS cron entry POSTed to `/api/scheduler/tick` (via `pnpm sync:tick`) — fine on a server, but a chore to set up just to call a function the editor already hosts in-process. The editor can now drive its own heartbeat through a **Next.js instrumentation hook** (`editor/instrumentation.ts`): on server startup it arms a single in-process timer that calls `runSchedulerTick()` directly — no HTTP, no cron, no token. Turn it on with **Settings → Sync scheduler → Internal heartbeat (seconds)**: `0` = off (keep using an external cron heartbeat), any positive value is clamped to `[15, 3600]`s and is the cadence the editor ticks itself at; the `SYNC_HEARTBEAT_SECONDS` env var overrides the setting at runtime. The timer is a self-rescheduling, `unref`'d `setTimeout` loop (so it never holds the process open and never overlaps a tick), re-reading the cadence each fire so a change takes effect on the next tick — though turning it on *from 0* needs a restart, since the timer is armed once at boot. It's modeled on the existing snapshot-scheduler timer and reuses the already-overlap-guarded `runSchedulerTick()`, so internal and external heartbeats are interchangeable and may even coexist. The **Schedule** page header now reports how ticks are driven ("internal heartbeat every N" vs. "external heartbeat (cron)"), and `GET /api/scheduler/status` carries the effective `heartbeatSeconds`. Defaults to off, so dev/test and existing cron installs are unchanged. One caveat for multi-instance deployments: the timer runs once *per server instance*, so a cluster against one data dir should set `SYNC_HEARTBEAT_SECONDS=0` on all but one — see `SCHEDULED_SYNC.md`.
- **The channel video-list filters are now combinable (intersection), with a new Partial filter.** The filter chips above a channel's video list used to be single-select — clicking one replaced the last — and there was no way to filter for partial downloads at all. Each chip is now an independent toggle, and selecting several narrows to videos matching **all** of them (an intersection). A new **Partial** chip surfaces videos with a leftover `.part` download. The headline use: **Transcribed + Partial** finds videos that are already transcribed but still carry an orphaned `audio.<ext>.part` (e.g. cornbreadman's, where the transcript is done but the partial lingers as cruft) — previously unreachable because a video's status is a single mutually-exclusive value where `partial_download` outranks `transcribed`. To make combinations meaningful the **Transcribed** and **Partial** chips now key off the row's independent `transcribed` / `partial` flags rather than that status enum, so "Transcribed" alone now also includes transcribed videos that happen to carry a `.part` or a failed-transcoding marker (they *are* transcribed). The active set is mirrored to the URL as a comma-joined `?filter=a,b` via `history.replaceState` (so reload/share preserves it without an RSC refetch per toggle, and without racing the global auto-refresh), and the **All** chip clears the selection. See `editor/app/channels/[slug]/lib/videoRows.ts` and `editor/app/channels/[slug]/components/VideoListPane.tsx`.
- **Bookmarks can be reordered on the management page, and the order carries to the compact menu.** Both the `/jobs/bookmarks` management list and the compact one-click menu atop `/jobs` and `/jobs/active` render bookmarks in stored order, but there was no way to change it — new bookmarks just landed on top. Each row on the management page now has **↑ / ↓** buttons that move the bookmark one slot (disabled at the ends), persisting the new order immediately to `transcripts/.bookmarks/bookmarks.json`. Because both views read the same array in order, reordering on the management page is reflected in the compact quick-run menu too, so you can put your most-used job first. See `moveBookmark` in `common/jobs/bookmarks.ts`, `moveBookmarkAction` in `editor/app/jobs/bookmarkActions.ts`, and `editor/app/jobs/components/BookmarksList.tsx`.
- **"Retry partial downloads" no longer skips partials that carry an audio-check snapshot.** A transcribe channel's retry-bucket prefilter decided a video was "already complete" by looking for any `audio.*` file not ending in `.part` — which wrongly matched the audio-integrity snapshots (`audio.<ext>.part.good` / `.part.testing`) and sidecars (`audio.info.json`, `audio.live_chat.json`, `audio.*.tmp-*`) left in a partial video's dir. So a genuine `audio.<ext>.part` that happened to sit next to a `.part.good` snapshot got prefiltered out (`Prefilter: 0 missing destination files, N already complete` → `Nothing to fetch`), even though that same snapshot is excluded when the video is placed in the **Partial downloads** bucket. The prefilter (`destinationExists`) now reuses the same `isRealAudioFile` predicate the bucket uses, so the two agree and genuine partials resume. See `common/ytdlp/runYtdlp.ts` and `common/lib/videoStatus.ts`.
diff --git a/editor/app/channels/components/ChannelForm.tsx b/editor/app/channels/components/ChannelForm.tsx
@@ -10,6 +10,7 @@ import {
AUDIO_CHECK_MAX_ROLLBACKS_MAX,
AUDIO_CHECK_MAX_ROLLBACKS_MIN,
} from "yt-dlp-transcript-common/lib/channelConfig";
+import { SYNC_INTERVAL_PRESETS } from "../../scheduler/intervalPresets";
type Props = {
action: string | ((formData: FormData) => void | Promise<void>);
@@ -259,18 +260,6 @@ function CollapsibleSection({
);
}
-const SYNC_INTERVAL_PRESETS: { value: string; label: string }[] = [
- { value: "", label: "Default (use global)" },
- { value: "0", label: "Off (never auto-sync)" },
- { value: "10", label: "Every 10 minutes" },
- { value: "30", label: "Every 30 minutes" },
- { value: "60", label: "Hourly" },
- { value: "360", label: "Every 6 hours" },
- { value: "720", label: "Every 12 hours" },
- { value: "1440", label: "Daily" },
- { value: "10080", label: "Weekly" },
-];
-
function SyncIntervalField({ value }: { value?: number }) {
const current = value != null ? String(value) : "";
const known = SYNC_INTERVAL_PRESETS.some((p) => p.value === current);
diff --git a/editor/app/scheduler/actions.ts b/editor/app/scheduler/actions.ts
@@ -0,0 +1,110 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import {
+ readChannelConfig,
+ writeChannelConfig,
+} from "yt-dlp-transcript-common/controller/channels";
+import { requestChannelSnapshot } from "yt-dlp-transcript-common/jobs/snapshotScheduler";
+import {
+ SYNC_INTERVAL_MAX_MINUTES,
+ SYNC_INTERVAL_MIN_MINUTES,
+} from "yt-dlp-transcript-common/lib/channelConfig";
+import {
+ getSettings,
+ writeSettings,
+ type SiteSettings,
+} from "yt-dlp-transcript-common/lib/settings";
+import { SYNC_INTERVAL_CUSTOM } from "./intervalPresets";
+
+export type SaveResult = { ok: true } | { ok: false; error: string };
+
+// Resolve the submitted interval to a stored value. The editor posts a `preset`
+// (one of SYNC_INTERVAL_PRESETS values, or SYNC_INTERVAL_CUSTOM) plus, in custom
+// mode, a free `customMinutes` number. Returns:
+// undefined -> clear the field (inherit the global default)
+// 0 -> off (never auto-sync)
+// >0 -> explicit minute count
+// Throws on invalid input so the action can surface a friendly error.
+function resolveSubmittedInterval(formData: FormData): number | undefined {
+ const preset = String(formData.get("preset") ?? "").trim();
+ const raw =
+ preset === SYNC_INTERVAL_CUSTOM
+ ? String(formData.get("customMinutes") ?? "").trim()
+ : preset;
+ if (!raw) return undefined; // inherit global default
+ const n = Number.parseInt(raw, 10);
+ if (
+ !Number.isFinite(n) ||
+ n < 0 ||
+ (n !== 0 && (n < SYNC_INTERVAL_MIN_MINUTES || n > SYNC_INTERVAL_MAX_MINUTES))
+ ) {
+ throw new Error(
+ `Auto-sync interval must be 0 (off) or ${SYNC_INTERVAL_MIN_MINUTES}–${SYNC_INTERVAL_MAX_MINUTES} minutes`,
+ );
+ }
+ return n;
+}
+
+// Update just one channel's per-channel auto-sync cadence. Unlike the full
+// channel form's updateChannelAction, this touches only syncIntervalMinutes and
+// preserves every other field, so it's safe to call from the scheduler page.
+export async function setChannelSyncIntervalAction(
+ slug: string,
+ _prev: SaveResult | undefined,
+ formData: FormData,
+): Promise<SaveResult> {
+ let minutes: number | undefined;
+ try {
+ minutes = resolveSubmittedInterval(formData);
+ } catch (e) {
+ return { ok: false, error: (e as Error).message };
+ }
+ const paths = getPaths();
+ const existing = await readChannelConfig(paths, slug);
+ if (!existing) return { ok: false, error: `Channel "${slug}" not found` };
+ const next = { ...existing };
+ if (minutes === undefined) {
+ delete next.syncIntervalMinutes;
+ } else {
+ next.syncIntervalMinutes = minutes;
+ }
+ await writeChannelConfig(paths, slug, next);
+ // Keep the channel report/badges in sync with the config edit.
+ requestChannelSnapshot(paths, slug);
+ revalidatePath("/scheduler");
+ revalidatePath("/channels");
+ revalidatePath(`/channels/${slug}`);
+ 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).
+export async function saveSchedulerSettingsAction(
+ _prev: SaveResult | undefined,
+ formData: FormData,
+): Promise<SaveResult> {
+ const intOrNaN = (key: string): number =>
+ Number.parseInt(String(formData.get(key) ?? "").trim(), 10);
+ const current = getSettings();
+ const next: SiteSettings = {
+ ...current,
+ syncScheduler: {
+ ...current.syncScheduler,
+ enabled: formData.get("syncSchedulerEnabled") === "on",
+ defaultIntervalMinutes: intOrNaN("syncSchedulerDefaultIntervalMinutes"),
+ heartbeatSeconds: intOrNaN("syncSchedulerHeartbeatSeconds"),
+ },
+ };
+ try {
+ await writeSettings(next);
+ } catch (e) {
+ return { ok: false, error: (e as Error).message };
+ }
+ revalidatePath("/scheduler");
+ revalidatePath("/settings");
+ return { ok: true };
+}
diff --git a/editor/app/scheduler/components/ChannelIntervalEditor.tsx b/editor/app/scheduler/components/ChannelIntervalEditor.tsx
@@ -0,0 +1,98 @@
+"use client";
+
+import { useActionState, useState } from "react";
+import {
+ setChannelSyncIntervalAction,
+ type SaveResult,
+} from "../actions";
+import {
+ isPresetIntervalMinutes,
+ SYNC_INTERVAL_CUSTOM,
+ SYNC_INTERVAL_PRESETS,
+} from "../intervalPresets";
+
+const inputClass =
+ "rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm";
+
+// Map a stored per-channel interval to the editor's initial control state.
+// undefined -> "" (inherit global default)
+// matches a preset -> that preset value
+// any other positive value -> custom mode, prefilled
+function initialState(configured: number | undefined): {
+ preset: string;
+ custom: string;
+} {
+ if (configured === undefined) return { preset: "", custom: "" };
+ if (configured === 0 || isPresetIntervalMinutes(configured)) {
+ return { preset: String(configured), custom: "" };
+ }
+ return { preset: SYNC_INTERVAL_CUSTOM, custom: String(configured) };
+}
+
+// Inline per-channel interval editor for the scheduler page. Holds its own state
+// (seeded once from `configured`) so the page's 5s status poll can't clobber an
+// in-progress edit — the surrounding live status columns keep refreshing.
+export function ChannelIntervalEditor({
+ slug,
+ configured,
+}: {
+ slug: string;
+ configured: number | undefined;
+}) {
+ const init = initialState(configured);
+ const [preset, setPreset] = useState(init.preset);
+ const [custom, setCustom] = useState(init.custom);
+ const [state, formAction, pending] = useActionState<
+ SaveResult | undefined,
+ FormData
+ >(setChannelSyncIntervalAction.bind(null, slug), undefined);
+
+ return (
+ <form action={formAction} className="flex flex-wrap items-center gap-2">
+ <select
+ name="preset"
+ aria-label={`Auto-sync interval for ${slug}`}
+ value={preset}
+ onChange={(e) => setPreset(e.target.value)}
+ className={inputClass}
+ >
+ {SYNC_INTERVAL_PRESETS.map((p) => (
+ <option key={p.value} value={p.value}>
+ {p.label}
+ </option>
+ ))}
+ <option value={SYNC_INTERVAL_CUSTOM}>Custom (minutes)…</option>
+ </select>
+ {preset === SYNC_INTERVAL_CUSTOM && (
+ <input
+ type="number"
+ name="customMinutes"
+ aria-label={`Custom interval minutes for ${slug}`}
+ min={1}
+ max={44640}
+ value={custom}
+ onChange={(e) => setCustom(e.target.value)}
+ placeholder="minutes"
+ className={`${inputClass} w-24`}
+ />
+ )}
+ <button
+ type="submit"
+ disabled={pending}
+ className="px-2 py-1 rounded-md bg-zinc-900 dark:bg-zinc-100 text-zinc-100 dark:text-zinc-900 text-xs font-medium hover:opacity-90 disabled:opacity-50"
+ >
+ {pending ? "Saving…" : "Save"}
+ </button>
+ {state?.ok === true && (
+ <span role="status" className="text-xs text-green-700 dark:text-green-300">
+ Saved
+ </span>
+ )}
+ {state?.ok === false && (
+ <span role="alert" className="text-xs text-red-700 dark:text-red-300">
+ {state.error}
+ </span>
+ )}
+ </form>
+ );
+}
diff --git a/editor/app/scheduler/components/SchedulerSettingsForm.tsx b/editor/app/scheduler/components/SchedulerSettingsForm.tsx
@@ -0,0 +1,101 @@
+"use client";
+
+import { useActionState } from "react";
+import type { SyncSchedulerSettings } from "yt-dlp-transcript-common/lib/settings";
+import {
+ saveSchedulerSettingsAction,
+ type SaveResult,
+} from "../actions";
+
+const inputClass =
+ "rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm";
+
+// 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-zinc-200 dark:border-zinc-800 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-zinc-500">
+ 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-zinc-500">
+ Master switch. When off, ticks are no-ops and only manual syncs run.
+ </span>
+ </span>
+ </label>
+ <div className="flex flex-wrap gap-3">
+ <label className="flex flex-col gap-1 text-sm">
+ <span className="font-medium">Default interval (minutes)</span>
+ <input
+ type="number"
+ name="syncSchedulerDefaultIntervalMinutes"
+ defaultValue={String(scheduler.defaultIntervalMinutes)}
+ className={`${inputClass} w-40`}
+ />
+ <span className="text-xs text-zinc-500">
+ Cadence for channels left on “Default”. 60 = hourly, 1440 = daily.
+ </span>
+ </label>
+ <label className="flex flex-col gap-1 text-sm">
+ <span className="font-medium">Internal heartbeat (seconds)</span>
+ <input
+ type="number"
+ name="syncSchedulerHeartbeatSeconds"
+ defaultValue={String(heartbeatSeconds)}
+ className={`${inputClass} w-40`}
+ />
+ <span className="text-xs text-zinc-500">
+ 0 = off (use an external cron heartbeat). Clamped to 15–3600s.
+ Turning it on from 0 needs a server restart.
+ </span>
+ </label>
+ </div>
+ <div className="flex items-center gap-3">
+ <button
+ type="submit"
+ disabled={pending}
+ className="px-3 py-1.5 rounded-md bg-zinc-900 dark:bg-zinc-100 text-zinc-100 dark:text-zinc-900 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-green-700 dark:text-green-300">
+ Saved.
+ </span>
+ )}
+ {state?.ok === false && (
+ <span role="alert" className="text-sm text-red-700 dark:text-red-300">
+ {state.error}
+ </span>
+ )}
+ </div>
+ </form>
+ );
+}
diff --git a/editor/app/scheduler/components/SchedulerView.tsx b/editor/app/scheduler/components/SchedulerView.tsx
@@ -2,6 +2,8 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { SchedulerStatusPayload } from "../status";
+import { ChannelIntervalEditor } from "./ChannelIntervalEditor";
+import { SchedulerSettingsForm } from "./SchedulerSettingsForm";
export function SchedulerView({
initial,
@@ -62,7 +64,7 @@ export function SchedulerView({
}
}, [refresh]);
- const { scheduler, channels, runs, now } = data;
+ const { scheduler, channels, runs, now, heartbeatSeconds } = data;
const eligible = channels.filter((c) => c.autoSyncEligible);
return (
@@ -80,7 +82,10 @@ export function SchedulerView({
<span className="text-sm text-zinc-500">
{eligible.length} channel{eligible.length === 1 ? "" : "s"} auto-syncing
· default {formatInterval(scheduler.defaultIntervalMinutes)} · max{" "}
- {scheduler.maxConcurrentSyncs} concurrent
+ {scheduler.maxConcurrentSyncs} concurrent ·{" "}
+ {heartbeatSeconds > 0
+ ? `internal heartbeat every ${formatSeconds(heartbeatSeconds)}`
+ : "external heartbeat (cron)"}
</span>
<button
type="button"
@@ -97,6 +102,11 @@ export function SchedulerView({
</p>
)}
+ <SchedulerSettingsForm
+ scheduler={scheduler}
+ heartbeatSeconds={heartbeatSeconds}
+ />
+
<div className="overflow-x-auto rounded border border-zinc-200 dark:border-zinc-800">
<table className="w-full text-sm">
<thead className="bg-zinc-50 dark:bg-zinc-900 text-left text-zinc-500">
@@ -126,17 +136,11 @@ export function SchedulerView({
{c.name ?? c.slug}
</a>
</td>
- <td className="px-3 py-2 text-zinc-600 dark:text-zinc-300">
- {c.intervalMinutes === 0 ? (
- <span className="text-zinc-400">off</span>
- ) : (
- <>
- {formatInterval(c.intervalMinutes)}
- {c.inheritsInterval && (
- <span className="text-zinc-400"> (default)</span>
- )}
- </>
- )}
+ <td className="px-3 py-2">
+ <ChannelIntervalEditor
+ slug={c.slug}
+ configured={c.configuredIntervalMinutes}
+ />
</td>
<td className="px-3 py-2 text-zinc-600 dark:text-zinc-300">
{c.lastSyncedAt ? formatAgo(c.lastSyncedAt, now) : "never"}
@@ -228,6 +232,13 @@ function formatInterval(minutes: number): string {
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`;
diff --git a/editor/app/scheduler/heartbeat.ts b/editor/app/scheduler/heartbeat.ts
@@ -0,0 +1,103 @@
+import {
+ getSettings,
+ clampHeartbeatSeconds,
+} from "yt-dlp-transcript-common/lib/settings";
+import { runSchedulerTick } from "./runTick";
+
+// In-process scheduler heartbeat.
+//
+// Historically the scheduler tick was driven only by an external cron heartbeat
+// (`pnpm sync:tick` -> POST /api/scheduler/tick). This module lets the editor
+// server drive the same tick itself: the instrumentation hook
+// (editor/instrumentation.ts) calls startSyncHeartbeat() once at startup, which
+// arms a self-rescheduling timer that calls runSchedulerTick() directly — no
+// HTTP, no cron, no token.
+//
+// Modeled on common/jobs/snapshotScheduler.ts: a single global timer, unref'd so
+// it never keeps the process (or a test runner) alive, with all state on
+// globalThis so HMR reloads / repeated register() calls can't spawn duplicates.
+
+type HeartbeatState = {
+ timer: ReturnType<typeof setTimeout> | null;
+ // Generation counter: bumped by stop() so a fire() scheduled before the stop
+ // can detect it's stale and not reschedule.
+ generation: number;
+};
+
+declare global {
+ // eslint-disable-next-line no-var
+ var __yttSyncHeartbeat__: HeartbeatState | undefined;
+}
+
+function getState(): HeartbeatState {
+ if (!globalThis.__yttSyncHeartbeat__) {
+ globalThis.__yttSyncHeartbeat__ = { timer: null, generation: 0 };
+ }
+ return globalThis.__yttSyncHeartbeat__;
+}
+
+// Effective cadence in seconds. The env var SYNC_HEARTBEAT_SECONDS overrides the
+// stored setting (ops knob, mirrors SYNC_TICK_URL/SYNC_TICK_TOKEN); 0 = off.
+// Settings are read from disk, so a live change to the cadence is picked up on
+// the next fire. Falls back to the setting if the env var is unset/invalid.
+export function resolveHeartbeatSeconds(): number {
+ const raw = process.env.SYNC_HEARTBEAT_SECONDS;
+ if (raw != null && raw.trim() !== "") {
+ const n = Number.parseInt(raw, 10);
+ if (Number.isFinite(n)) return clampHeartbeatSeconds(n);
+ }
+ try {
+ return clampHeartbeatSeconds(getSettings().syncScheduler.heartbeatSeconds);
+ } catch {
+ // getSettings reads the filesystem; if unavailable, stay off.
+ return 0;
+ }
+}
+
+function schedule(state: HeartbeatState, generation: number, seconds: number) {
+ state.timer = setTimeout(() => void fire(generation), seconds * 1000);
+ // Never let the heartbeat hold the event loop open.
+ state.timer.unref?.();
+}
+
+async function fire(generation: number): Promise<void> {
+ const state = getState();
+ // A stop() (or restart) happened while this fire was pending — abandon it.
+ if (generation !== state.generation) return;
+ state.timer = null;
+
+ try {
+ await runSchedulerTick();
+ } catch {
+ // A thrown tick must never become an unhandledRejection. runSchedulerTick
+ // already records its own outcome; the heartbeat just keeps beating.
+ }
+
+ // Re-read the cadence each fire so a live settings change is honored without a
+ // restart. If it has been turned off (0), the loop simply stops here.
+ if (generation !== state.generation) return;
+ const seconds = resolveHeartbeatSeconds();
+ if (seconds > 0) schedule(state, generation, seconds);
+}
+
+// Arm the heartbeat. Idempotent: a timer already running is left untouched, so
+// HMR reloads and repeated register() calls can't spawn duplicate loops. Does
+// nothing when the cadence resolves to 0 ("only run when configured"); to start
+// after enabling it from 0 the server must be restarted.
+export function startSyncHeartbeat(): void {
+ const state = getState();
+ if (state.timer) return;
+ const seconds = resolveHeartbeatSeconds();
+ if (seconds <= 0) return;
+ schedule(state, state.generation, seconds);
+}
+
+// Cancel the heartbeat and invalidate any pending fire. For test hygiene (the
+// unref'd timer would otherwise leak across specs) and clean teardown.
+export function stopSyncHeartbeat(): void {
+ const state = globalThis.__yttSyncHeartbeat__;
+ if (!state) return;
+ if (state.timer) clearTimeout(state.timer);
+ state.timer = null;
+ state.generation += 1;
+}
diff --git a/editor/app/scheduler/intervalPresets.ts b/editor/app/scheduler/intervalPresets.ts
@@ -0,0 +1,29 @@
+// Shared per-channel auto-sync interval presets, used by both the channel edit
+// form (ChannelForm) and the scheduler page's inline interval editor.
+// Values are the raw `syncIntervalMinutes` string the form submits:
+// "" -> inherit the global default (field absent from config)
+// "0" -> off (never auto-sync this channel)
+// "N" -> a concrete minute count
+export const SYNC_INTERVAL_PRESETS: { value: string; label: string }[] = [
+ { value: "", label: "Default (use global)" },
+ { value: "0", label: "Off (never auto-sync)" },
+ { value: "10", label: "Every 10 minutes" },
+ { value: "30", label: "Every 30 minutes" },
+ { value: "60", label: "Hourly" },
+ { value: "360", label: "Every 6 hours" },
+ { value: "720", label: "Every 12 hours" },
+ { value: "1440", label: "Daily" },
+ { value: "10080", label: "Weekly" },
+];
+
+// The sentinel the scheduler-page editor's <select> uses for "type an exact
+// minute count" — distinct from any real preset value so it never collides.
+export const SYNC_INTERVAL_CUSTOM = "custom";
+
+// True when `minutes` exactly matches one of the concrete preset values, so the
+// editor can decide whether to start in preset or custom mode.
+export function isPresetIntervalMinutes(minutes: number): boolean {
+ return SYNC_INTERVAL_PRESETS.some(
+ (p) => p.value !== "" && Number(p.value) === minutes,
+ );
+}
diff --git a/editor/app/scheduler/page.tsx b/editor/app/scheduler/page.tsx
@@ -18,10 +18,13 @@ export default async function SchedulerPage() {
</Link>
</div>
<p className="text-sm text-zinc-500">
- Per-channel auto-sync cadence, driven by a cron heartbeat
- (<code>pnpm sync:tick</code>). Set a channel's cadence under its
- Auto-sync field; global controls (enable, default interval, concurrency,
- quiet hours, backoff) live in{" "}
+ Per-channel auto-sync cadence, driven by a heartbeat
+ (<code>pnpm sync:tick</code> or the internal timer). Set each
+ channel'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'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>
diff --git a/editor/app/scheduler/status.ts b/editor/app/scheduler/status.ts
@@ -12,12 +12,17 @@ import {
readSchedulerState,
type SchedulerRun,
} from "yt-dlp-transcript-common/jobs/syncSchedulerState";
+import { resolveHeartbeatSeconds } from "./heartbeat";
export type SchedulerStatusPayload = {
now: number;
scheduler: SyncSchedulerSettings;
channels: ChannelScheduleView[];
runs: SchedulerRun[];
+ // Effective internal-heartbeat cadence in seconds (env override applied), so
+ // the UI can show whether ticks are internally driven. 0 = no internal timer
+ // (awaiting an external cron heartbeat).
+ heartbeatSeconds: number;
};
// Assemble the read-only "Sync schedule" view. Shared by the SSR page and the
@@ -37,5 +42,11 @@ export async function buildSchedulerStatusPayload(): Promise<SchedulerStatusPayl
state,
now,
});
- return { now, scheduler: settings.syncScheduler, channels: view, runs: state.runs };
+ return {
+ now,
+ scheduler: settings.syncScheduler,
+ channels: view,
+ runs: state.runs,
+ heartbeatSeconds: resolveHeartbeatSeconds(),
+ };
}
diff --git a/editor/app/settings/actions.ts b/editor/app/settings/actions.ts
@@ -142,6 +142,7 @@ export async function saveSettingsAction(
quietHoursEnd: hourOrNull("syncSchedulerQuietHoursEnd"),
backoffBaseMinutes: intOrNaN("syncSchedulerBackoffBaseMinutes"),
backoffMaxMinutes: intOrNaN("syncSchedulerBackoffMaxMinutes"),
+ heartbeatSeconds: intOrNaN("syncSchedulerHeartbeatSeconds"),
};
let socialInput: unknown;
diff --git a/editor/app/settings/components/SettingsForm.tsx b/editor/app/settings/components/SettingsForm.tsx
@@ -154,8 +154,10 @@ export function SettingsForm({ initial, apps }: Props) {
<legend className="px-1 text-sm font-medium">Sync scheduler</legend>
<p className="text-xs text-zinc-500">
Auto-sync channels on a per-channel cadence (set under each channel's
- Auto-sync). A cron heartbeat (<code>pnpm sync:tick</code>) pings the
- server, which syncs whichever channels are due. See SCHEDULED_SYNC.md.
+ 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
@@ -186,6 +188,13 @@ export function SettingsForm({ initial, apps }: Props) {
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."
/>
+ <Field
+ label="Internal heartbeat (seconds)"
+ name="syncSchedulerHeartbeatSeconds"
+ defaultValue={String(initial.syncScheduler.heartbeatSeconds)}
+ type="number"
+ hint="0 = off (use an external cron heartbeat). When > 0, the editor ticks the scheduler itself this often (clamped to 15–3600s). Changes take effect on the next tick; turning it on from 0 needs a server restart. The SYNC_HEARTBEAT_SECONDS env var overrides this."
+ />
<div className="flex gap-3">
<Field
label="Quiet hours start (0–23)"
diff --git a/editor/e2e/scheduler.spec.ts b/editor/e2e/scheduler.spec.ts
@@ -1,7 +1,7 @@
import { test, expect } from "@playwright/test";
import { writeFile } from "node:fs/promises";
import type { ChannelConfig } from "yt-dlp-transcript-common/lib/channelConfig";
-import { resetData, resolvePath, writeSettings } from "./helpers";
+import { readJson, resetData, resolvePath, writeSettings } from "./helpers";
// End-to-end coverage for the cron-driven sync scheduler. Uses the two-slow
// channels (their mock sync hangs long enough to stay "running"), drives the
@@ -88,6 +88,175 @@ test("scheduler queues due channels, skips not-due, and dedups running ones", as
).toBeVisible();
});
+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.
+ await resetData("two-slow-channels");
+
+ const baseSettings = {
+ adminTitle: "Test Admin",
+ maxTranscriptPageBytes: 8388608,
+ sleepBetweenDownloadsSeconds: 0,
+ syncScheduler: {
+ enabled: false,
+ defaultIntervalMinutes: 60,
+ maxConcurrentSyncs: 2,
+ quietHoursStart: null,
+ quietHoursEnd: null,
+ backoffBaseMinutes: 30,
+ backoffMaxMinutes: 1440,
+ },
+ };
+
+ // A sub-minimum positive cadence clamps up to the 15s floor.
+ await writeSettings({
+ ...baseSettings,
+ syncScheduler: { ...baseSettings.syncScheduler, heartbeatSeconds: 5 },
+ });
+ const status1 = (await (
+ await page.request.get("/api/scheduler/status")
+ ).json()) as { heartbeatSeconds: number };
+ expect(status1.heartbeatSeconds).toBe(15);
+ await page.goto("/scheduler");
+ await expect(page.getByText("internal heartbeat every 15s")).toBeVisible();
+
+ // 0 = off -> the panel reports it relies on an external cron heartbeat.
+ await writeSettings({
+ ...baseSettings,
+ syncScheduler: { ...baseSettings.syncScheduler, heartbeatSeconds: 0 },
+ });
+ const status2 = (await (
+ await page.request.get("/api/scheduler/status")
+ ).json()) as { heartbeatSeconds: number };
+ expect(status2.heartbeatSeconds).toBe(0);
+ await page.reload();
+ await expect(page.getByText("external heartbeat (cron)")).toBeVisible();
+});
+
+// --- Inline per-channel schedule editing on the /scheduler page -------------
+
+// Seed two channels so the editable status table has rows to act on.
+async function seedTwoChannels(opts?: { slowAInterval?: number }) {
+ await resetData("two-slow-channels");
+ await writeChannelConfig(SLOW_A_CONFIG, {
+ handling: "youtube",
+ name: "Slow A",
+ url: "https://www.youtube.com/@slow-a/videos",
+ ytdlpExtraArgs: ["--test-slow"],
+ ...(opts?.slowAInterval != null
+ ? { syncIntervalMinutes: opts.slowAInterval }
+ : {}),
+ });
+ await writeChannelConfig(SLOW_B_CONFIG, {
+ handling: "youtube",
+ name: "Slow B",
+ url: "https://www.youtube.com/@slow-b/videos",
+ ytdlpExtraArgs: ["--test-slow"],
+ });
+ // A baseline settings file with the scheduler off (editing intervals doesn't
+ // require it to be on).
+ await writeSettings({
+ adminTitle: "Test Admin",
+ maxTranscriptPageBytes: 8388608,
+ sleepBetweenDownloadsSeconds: 0,
+ syncScheduler: {
+ enabled: false,
+ defaultIntervalMinutes: 60,
+ maxConcurrentSyncs: 2,
+ quietHoursStart: null,
+ quietHoursEnd: null,
+ backoffBaseMinutes: 30,
+ backoffMaxMinutes: 1440,
+ },
+ });
+}
+
+test("schedule page saves a preset interval to the channel config", async ({
+ page,
+}) => {
+ await seedTwoChannels();
+ await page.goto("/scheduler");
+
+ const rowA = page.getByRole("row", { name: /Slow A/ });
+ await rowA.getByLabel(/Auto-sync interval/).selectOption("60"); // Hourly
+ await rowA.getByRole("button", { name: "Save" }).click();
+ await expect(rowA.getByText("Saved")).toBeVisible();
+
+ const cfg = await readJson<ChannelConfig>(SLOW_A_CONFIG);
+ expect(cfg.syncIntervalMinutes).toBe(60);
+});
+
+test("schedule page saves a custom minute count", async ({ page }) => {
+ await seedTwoChannels();
+ await page.goto("/scheduler");
+
+ const rowA = page.getByRole("row", { name: /Slow A/ });
+ await rowA.getByLabel(/Auto-sync interval/).selectOption("custom");
+ await rowA.getByLabel(/Custom interval minutes/).fill("45");
+ await rowA.getByRole("button", { name: "Save" }).click();
+ await expect(rowA.getByText("Saved")).toBeVisible();
+
+ const cfg = await readJson<ChannelConfig>(SLOW_A_CONFIG);
+ expect(cfg.syncIntervalMinutes).toBe(45);
+});
+
+test("schedule page clears an interval back to the global default", async ({
+ page,
+}) => {
+ // Start with an explicit per-channel interval, then pick "Default".
+ await seedTwoChannels({ slowAInterval: 30 });
+ await page.goto("/scheduler");
+
+ const rowA = page.getByRole("row", { name: /Slow A/ });
+ // The editor seeds to the "Every 30 minutes" preset; switch to Default ("").
+ await rowA.getByLabel(/Auto-sync interval/).selectOption("");
+ await rowA.getByRole("button", { name: "Save" }).click();
+ await expect(rowA.getByText("Saved")).toBeVisible();
+
+ const cfg = await readJson<ChannelConfig>(SLOW_A_CONFIG);
+ expect("syncIntervalMinutes" in cfg).toBe(false);
+});
+
+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");
+
+ const rowA = page.getByRole("row", { name: /Slow A/ });
+ await rowA.getByLabel(/Auto-sync interval/).selectOption("custom");
+ const customInput = rowA.getByLabel(/Custom interval minutes/);
+ await customInput.fill("123");
+ // Wait past one poll interval (5s) without saving.
+ await page.waitForTimeout(5500);
+ await expect(customInput).toHaveValue("123");
+});
+
+test("schedule page edits the global controls", async ({ page }) => {
+ await seedTwoChannels();
+ await page.goto("/scheduler");
+
+ await page
+ .getByRole("checkbox", { name: /Enable scheduled auto-sync/ })
+ .check();
+ await page.getByLabel("Default interval (minutes)").fill("120");
+ await page.getByRole("button", { name: "Save controls" }).click();
+ await expect(page.getByText("Saved.")).toBeVisible();
+
+ const settings = await readJson<{
+ syncScheduler: { enabled: boolean; defaultIntervalMinutes: number };
+ }>("test-settings.json");
+ expect(settings.syncScheduler.enabled).toBe(true);
+ expect(settings.syncScheduler.defaultIntervalMinutes).toBe(120);
+
+ // The status badge reflects the now-enabled scheduler.
+ await expect(page.getByText("Scheduler enabled")).toBeVisible();
+});
+
test("a disabled scheduler queues nothing", async ({ page }) => {
await resetData("two-slow-channels");
// Default test settings leave syncScheduler.enabled false.
diff --git a/editor/instrumentation.ts b/editor/instrumentation.ts
@@ -0,0 +1,17 @@
+// Next.js instrumentation hook. register() runs once when a server instance
+// starts (stable in Next 16). We use it to arm the in-process scheduler
+// heartbeat so auto-sync can run without an external cron job.
+//
+// See editor/app/scheduler/heartbeat.ts and SCHEDULED_SYNC.md.
+export async function register() {
+ // register() is called in every runtime (Node.js and Edge). The heartbeat and
+ // its transitive imports (runTick -> server actions, lmdb, fs) are Node-only,
+ // so guard the import — and run nothing on Edge.
+ if (process.env.NEXT_RUNTIME !== "nodejs") return;
+
+ // Lazy import inside the guard keeps server-only code out of the Edge bundle.
+ // startSyncHeartbeat only arms a timer (no synchronous tick), so it never
+ // blocks the server from becoming ready.
+ const { startSyncHeartbeat } = await import("./app/scheduler/heartbeat");
+ startSyncHeartbeat();
+}