Archilyzer · Source

archilyzer

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

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:
MSCHEDULED_SYNC.md | 66+++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mcommon/jobs/syncScheduler.ts | 6++++++
Mcommon/lib/settings.ts | 29+++++++++++++++++++++++++++++
Meditor/CHANGELOG.md | 2++
Meditor/app/channels/components/ChannelForm.tsx | 13+------------
Aeditor/app/scheduler/actions.ts | 110+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/scheduler/components/ChannelIntervalEditor.tsx | 98+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/scheduler/components/SchedulerSettingsForm.tsx | 101+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/scheduler/components/SchedulerView.tsx | 37++++++++++++++++++++++++-------------
Aeditor/app/scheduler/heartbeat.ts | 103+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/scheduler/intervalPresets.ts | 29+++++++++++++++++++++++++++++
Meditor/app/scheduler/page.tsx | 11+++++++----
Meditor/app/scheduler/status.ts | 13++++++++++++-
Meditor/app/settings/actions.ts | 1+
Meditor/app/settings/components/SettingsForm.tsx | 13+++++++++++--
Meditor/e2e/scheduler.spec.ts | 171++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Aeditor/instrumentation.ts | 17+++++++++++++++++
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&apos;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&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> 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&apos;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(); +}