import type { ChannelConfig } from "../lib/channelConfig"; import type { SyncSchedulerSettings } from "../lib/settings"; import { type ChannelPriority, effectiveTier, isChannelPaused, rankOf, tierOrder, } from "../lib/channelPriority"; import { resolveFullSweepIntervalMinutes } from "./deepSync"; import type { SchedulerSkip, SchedulerState } from "./syncSchedulerState"; // Pure, side-effect-free scheduling logic for the cron-driven sync system. It // decides WHICH channels are due to sync right now; the editor tick route does // the I/O (reads settings/state/registry, queues syncs, persists state). Keeping // this pure makes the due/skip/ordering rules unit-testable without a server. export type ChannelEntry = { slug: string; config: ChannelConfig; }; export type SelectDueInput = { channels: ReadonlyArray; scheduler: SyncSchedulerSettings; state: SchedulerState; // Slugs that already have a running or queued sync job (from the registry). activeSlugs: ReadonlySet; now: number; // THE CHANNEL PRIORITY MODEL, asked for the "sync" operation and nothing // else. It decides two things here and only two: which channels are skipped // (effective tier `paused`) and what order the survivors come back in. // Required rather than optional so a new caller cannot silently schedule a // paused channel; `getSettings().channelPriority` always exists and is // already sanitized (lib/settings.ts). priority: ChannelPriority; // The RESOLVED focus set. Focus is a compiled POSITION, never a stored tier, // so it cannot come out of the model alone — a `{kind:"site"}` focus resolves // against `transcripts/sites/*/site.json`, which is I/O this pure module must // not do. The caller runs `resolveFocusSlugs` and hands the answer in. focusSlugs?: readonly string[]; }; export type SelectDueResult = { // Slugs that should be synced now, ordered focus first, then by tier, then // by rank, then most-overdue first. due: string[]; // Channels deliberately held back, with a human reason (for the run log). // The common "not yet due" case is intentionally omitted to keep the log // meaningful — only noteworthy holds (backoff, already running, etc.) appear. skipped: SchedulerSkip[]; }; // Resolve a channel's effective interval in minutes. undefined inherits the // global default; 0 means auto-sync is disabled for the channel. export function resolveIntervalMinutes( config: ChannelConfig, scheduler: SyncSchedulerSettings, ): number { if (config.syncIntervalMinutes === undefined) { return scheduler.defaultIntervalMinutes; } return config.syncIntervalMinutes; } // Whether auto-sync is suppressed at `now` by the configured quiet-hours window. // The window is [start, end) in local clock hours and may wrap past midnight // (start=22, end=6). A null endpoint or a zero-length window means "never". export function isInQuietHours( now: number, start: number | null, end: number | null, ): boolean { if (start === null || end === null || start === end) return false; const hour = new Date(now).getHours(); if (start < end) return hour >= start && hour < end; // Wraps midnight. return hour >= start || hour < end; } // Exponential backoff (minutes) after N consecutive failures: base * 2^(N-1), // capped at max. 0 failures => no backoff. export function backoffMinutes( failures: number, scheduler: SyncSchedulerSettings, ): number { if (failures <= 0) return 0; const raw = scheduler.backoffBaseMinutes * 2 ** (failures - 1); return Math.min(raw, scheduler.backoffMaxMinutes); } // The epoch-ms instant a channel becomes eligible again after `failures` // consecutive failures observed at `now`. export function nextEligibleAfterFailure( now: number, failures: number, scheduler: SyncSchedulerSettings, ): number { return now + backoffMinutes(failures, scheduler) * 60_000; } // Core selection. Evaluates each channel against the config gates, the channel // priority model, the elapsed interval, the backoff window and the active-job // set, then orders the winners focus first, then by tier, then by rank, then // most-overdue first — so a concurrency-capped tick spends its slots on the // focused channels and services the stalest of them first. export function selectDueChannels(input: SelectDueInput): SelectDueResult { const { channels, scheduler, state, activeSlugs, now, priority } = input; const focus = new Set(input.focusSlugs ?? []); if (!scheduler.enabled) return { due: [], skipped: [] }; if ( isInQuietHours(now, scheduler.quietHoursStart, scheduler.quietHoursEnd) ) { return { due: [], skipped: [] }; } const skipped: SchedulerSkip[] = []; const due: { slug: string; overdueMs: number; tier: number; rank: number; }[] = []; for (const { slug, config } of channels) { if (!config.url) continue; // not auto-sync material; no noise in the log // ONE SKIP. `excludeFromSync` is gone (S5); the document's `sync` tier is // what it became, and the migration turned each of the 15 channels that // carried the flag into `overrides: {sync: "paused"}`. Silent `continue` — // "this channel does not auto-sync" is configuration, not a hold worth a // line in the run log. if (isChannelPaused(priority, slug, "sync")) continue; const interval = resolveIntervalMinutes(config, scheduler); if (interval <= 0) continue; // per-channel disabled if (activeSlugs.has(slug)) { skipped.push({ slug, reason: "already running" }); continue; } const cs = state.channels[slug]; if (cs?.nextEligibleAt != null && now < cs.nextEligibleAt) { skipped.push({ slug, reason: `backoff (${cs.consecutiveFailures} failures, until ${new Date( cs.nextEligibleAt, ).toISOString()})`, }); continue; } const overdueMs = overdueAmount(config.lastSyncedAt, interval, now); if (overdueMs === null) continue; // not yet due due.push({ slug, overdueMs, // Focus outranks the stored tier; paused already left the loop above, so // "focus wins over the stored tier, paused wins over focus" holds here // by construction. tier: tierOrder( focus.has(slug) ? "focus" : effectiveTier(priority, slug, "sync"), ), // Unranked sorts last inside its tier, which is what an absent `rank` // means everywhere else in the model. rank: rankOf(priority, slug) ?? Number.POSITIVE_INFINITY, }); } // TIER, THEN RANK, THEN MOST-OVERDUE-FIRST. Most-overdue-first survives // *within* a tier, so a focus channel due by a minute outranks a low channel // due by a day and the tick's `maxConcurrentSyncs` cap // (editor/app/scheduler/runTick.ts) spends its slots on focus first. Ties all // the way down keep the input order — `listChannelConfigs` returns slug // order and Array.prototype.sort is stable — which is the order this // function returned before the model existed. due.sort((a, b) => { if (a.tier !== b.tier) return a.tier - b.tier; if (a.rank !== b.rank) return a.rank < b.rank ? -1 : 1; return b.overdueMs - a.overdueMs; }); return { due: due.map((d) => d.slug), skipped }; } // A per-channel projection of the schedule for the observability panel. Pure: // the editor status route feeds it live channels/settings/state. export type ChannelScheduleView = { slug: string; name: string | null; // True when this channel is eligible for auto-sync (scheduler on, has a url, // not excluded, not paused for sync by the channel priority model, and a // positive resolved interval). THE SAME PREDICATE `selectDueChannels` skips // on, so the sync console can never show a channel as eligible that the // scheduler will not schedule. 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; // The full-sweep cadence, in the same three shapes as the auto-sync one // above: the raw stored value (undefined = inherit, 0 = off), the resolved // interval, and when the next sweep becomes due. nextFullSweepAt is null when // sweeps are off or the channel has never swept (never-swept = due now, the // same convention nextDueAt uses for never-synced). configuredFullSweepMinutes: number | undefined; fullSweepIntervalMinutes: number; // resolved; 0 = off lastFullSweepAt: string | null; nextFullSweepAt: number | null; lastSyncedAt: string | null; nextDueAt: number | null; // epoch ms; null when disabled or never synced overdue: boolean; consecutiveFailures: number; nextEligibleAt: number | null; lastOutcome: "ok" | "failed" | null; lastOutcomeAt: number | null; }; export function buildScheduleView(input: { channels: ReadonlyArray; scheduler: SyncSchedulerSettings; state: SchedulerState; now: number; // The same model `selectDueChannels` takes, for the same reason: the // projection must agree with the scheduler about who is skipped. Focus is // not needed — it changes the ORDER, not who is eligible. priority: ChannelPriority; }): ChannelScheduleView[] { const { channels, scheduler, state, now, priority } = input; return channels.map(({ slug, config }) => { const interval = resolveIntervalMinutes(config, scheduler); const sweepInterval = resolveFullSweepIntervalMinutes(config, scheduler); const cs = state.channels[slug]; const last = config.lastSyncedAt ?? null; const lastSweep = config.lastFullSweepAt ?? null; let nextDueAt: number | null = null; let overdue = false; if (interval > 0) { if (!last) { overdue = true; // never synced => due now } else { const parsed = Date.parse(last); if (Number.isNaN(parsed)) { overdue = true; } else { nextDueAt = parsed + interval * 60_000; overdue = now >= nextDueAt; } } } return { slug, name: config.name ?? null, autoSyncEligible: scheduler.enabled && !!config.url && !isChannelPaused(priority, slug, "sync") && interval > 0, intervalMinutes: interval, inheritsInterval: config.syncIntervalMinutes === undefined, configuredIntervalMinutes: config.syncIntervalMinutes, configuredFullSweepMinutes: config.fullSweepIntervalMinutes, fullSweepIntervalMinutes: sweepInterval, lastFullSweepAt: lastSweep, nextFullSweepAt: nextFullSweepAt(lastSweep, sweepInterval), lastSyncedAt: last, nextDueAt, overdue, consecutiveFailures: cs?.consecutiveFailures ?? 0, nextEligibleAt: cs?.nextEligibleAt ?? null, lastOutcome: cs?.lastOutcome ?? null, lastOutcomeAt: cs?.lastOutcomeAt ?? null, }; }); } // When the next full sweep becomes due, as epoch ms. Null when sweeps are off // or the channel has never swept — a never-swept channel is due immediately, and // there is no meaningful future timestamp to show for it (the same shape // nextDueAt uses for a never-synced channel). function nextFullSweepAt( lastFullSweepAt: string | null, intervalMinutes: number, ): number | null { if (intervalMinutes <= 0 || !lastFullSweepAt) return null; const parsed = Date.parse(lastFullSweepAt); if (Number.isNaN(parsed)) return null; return parsed + intervalMinutes * 60_000; } // How far past its interval a channel is, in ms. A never-synced channel is // maximally overdue (Infinity) so it sorts first. Returns null when not yet due. function overdueAmount( lastSyncedAt: string | undefined, intervalMinutes: number, now: number, ): number | null { if (!lastSyncedAt) return Number.POSITIVE_INFINITY; const last = Date.parse(lastSyncedAt); if (Number.isNaN(last)) return Number.POSITIVE_INFINITY; // unparseable => sync const elapsed = now - last; const intervalMs = intervalMinutes * 60_000; return elapsed >= intervalMs ? elapsed - intervalMs : null; }