import { Effect } from "effect"; import type { AppsRuntime } from "../plugin/runtime"; // --------------------------------------------------------------------------- // Scheduler — starts due workflow runs from the schedules extracted into the // descriptor, and re-drives sleeping/waiting runs whose wake time has passed. // Self-hosted: a single in-process interval ticks and does both. The cloud // backing (future) is CF cron triggers + the DO alarm. Timezone handling is // minimal (UTC cron eval); DST-correct local-time cron is a documented cut. // // A cron field parser good enough for the extracted schedules ("0 9 * * 1-5"): // minute hour day-of-month month day-of-week, with `*`, lists, ranges, steps. // --------------------------------------------------------------------------- // A cron field parse that CANNOT hang on adversarial input (Fix 8). The naive // `for (v = lo; v <= hi; v += step)` loops forever on `step <= 0` and iterates // billions of times on a huge/negative/reversed range. This parser validates // every part against the field's [min, max] bounds and requires an integer // `step >= 1`, throwing `CronError` (never looping) on anything malformed. The // loop is additionally clamped to the field bounds, so the worst case is // `max - min` iterations (< 60) regardless of input. export class CronError extends Error { constructor(message: string) { super(message); this.name = "CronError"; } } type ParseResult = | { readonly ok: true; readonly value: A } | { readonly ok: false; readonly error: CronError }; const ok = (value: A): ParseResult => ({ ok: true, value }); const cronError = (message: string): ParseResult => ({ ok: false, error: new CronError(message), }); const parseInt10 = (value: string, what: string): ParseResult => { if (!/^-?\d+$/.test(value)) return cronError(`invalid ${what}: "${value}"`); return ok(Number(value)); }; const parseField = (field: string, min: number, max: number): ParseResult> => { const out = new Set(); if (field === "") return cronError("empty cron field"); for (const part of field.split(",")) { const [rangePart, stepPart, ...extra] = part.split("/"); if (extra.length > 0) return cronError(`invalid cron step in "${part}"`); let step = 1; if (stepPart !== undefined) { const parsedStep = parseInt10(stepPart, "cron step"); if (!parsedStep.ok) return parsedStep; step = parsedStep.value; // A step of 0 or negative would never advance the loop -> infinite loop. if (step < 1) return cronError(`cron step must be >= 1 (got ${step})`); } let lo = min; let hi = max; if (rangePart !== "*" && rangePart !== "") { const [a, b] = rangePart.split("-"); const parsedLo = parseInt10(a, "cron range start"); if (!parsedLo.ok) return parsedLo; lo = parsedLo.value; if (b !== undefined) { const parsedHi = parseInt10(b, "cron range end"); if (!parsedHi.ok) return parsedHi; hi = parsedHi.value; } else { hi = lo; } } else if (rangePart === "") { return cronError("empty cron range"); } // Bound the range to the field's valid domain, and reject a reversed range, // so the loop is always finite and small. if (lo < min || hi > max) { return cronError(`cron value out of range [${min}, ${max}]: ${lo}-${hi}`); } if (lo > hi) return cronError(`cron range is reversed: ${lo}-${hi}`); for (let v = lo; v <= hi; v += step) out.add(v); } return ok(out); }; const parseCron = ( cron: string, ): ParseResult<{ readonly minutes: Set; readonly hours: Set; readonly doms: Set; readonly mons: Set; readonly dows: Set; }> => { const parts = cron.trim().split(/\s+/); if (parts.length !== 5) { return cronError(`cron must have 5 fields, got ${parts.length}: "${cron}"`); } const [min, hour, dom, mon, dow] = parts; const minutes = parseField(min, 0, 59); if (!minutes.ok) return minutes; const hours = parseField(hour, 0, 23); if (!hours.ok) return hours; const doms = parseField(dom, 1, 31); if (!doms.ok) return doms; const mons = parseField(mon, 1, 12); if (!mons.ok) return mons; const dows = parseField(dow, 0, 6); if (!dows.ok) return dows; return ok({ minutes: minutes.value, hours: hours.value, doms: doms.value, mons: mons.value, dows: dows.value, }); }; /** Validate a 5-field cron expression by parsing every field. Throws `CronError` * on anything malformed (bad field count, step < 1, out-of-range, reversed * range). Returns nothing; used at PUBLISH time to reject adversarial crons and * as the shared validator the matcher builds on. */ export const validateCron = (cron: string): void => { const parsed = parseCron(cron); if (!parsed.ok) { // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: validateCron's public contract throws CronError throw parsed.error; } }; /** True if `date` (UTC) matches the 5-field cron expression. Defensive: an * invalid cron never matches (and never hangs) rather than throwing into the * tick loop — publish-time validation is the place a bad cron is rejected. */ export const cronMatches = (cron: string, date: Date): boolean => { const parsed = parseCron(cron); if (!parsed.ok) return false; const { minutes, hours, doms, mons, dows } = parsed.value; return ( minutes.has(date.getUTCMinutes()) && hours.has(date.getUTCHours()) && doms.has(date.getUTCDate()) && mons.has(date.getUTCMonth() + 1) && dows.has(date.getUTCDay()) ); }; export interface SchedulerOptions { readonly runtime: AppsRuntime; /** The scopes to schedule (self-host single-tenant: one scope). */ readonly scopes: readonly string[]; /** Tick interval ms (default 60_000 — cron granularity is one minute). */ readonly intervalMs?: number; /** Injected clock for tests. */ readonly now?: () => Date; } export interface Scheduler { /** Run one scheduler tick: fire any due schedules for the given minute. */ readonly tick: (at?: Date) => Effect.Effect; readonly start: () => void; readonly stop: () => void; } export const makeScheduler = (options: SchedulerOptions): Scheduler => { const runtime = options.runtime; const now = options.now ?? (() => new Date()); // Dedupe key: `${scope}:${workflow}:${yyyy-mm-ddThh:mm}` so a schedule fires // at most once per matching minute even across overlapping ticks. const fired = new Set(); let timer: ReturnType | undefined; const minuteKey = (date: Date) => date.toISOString().slice(0, 16); const tick = (at: Date = now()): Effect.Effect => Effect.gen(function* () { const started: string[] = []; for (const scope of options.scopes) { const descriptor = yield* runtime.getDescriptor(scope); if (!descriptor) continue; for (const wf of descriptor.workflows) { if (!wf.schedule) continue; if (!cronMatches(wf.schedule.cron, at)) continue; const key = `${scope}:${wf.name}:${minuteKey(at)}`; if (fired.has(key)) continue; fired.add(key); const runId = `sched-${scope}-${wf.name}-${minuteKey(at)}`; yield* runtime .startWorkflow({ scope, workflow: wf.name, input: {}, runId }) .pipe(Effect.orElseSucceed(() => undefined)); started.push(runId); } } return started as readonly string[]; }); return { tick, start: () => { if (timer) return; timer = setInterval(() => void Effect.runPromise(tick()), options.intervalMs ?? 60_000); }, stop: () => { if (timer) clearInterval(timer); timer = undefined; }, }; };