/** * KHAELOR * File: src/daemon/cron.ts * Description: Dependency-free cron parser (5 fields: min hour dom month dow) with next-run computation (v2 design ยง7). * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ export interface CronSpec { minutes: Set; hours: Set; daysOfMonth: Set; months: Set; daysOfWeek: Set; } function parseField(field: string, min: number, max: number): Set | null { const out = new Set(); for (const part of field.split(",")) { const step = /^(\*|\d+(?:-\d+)?)\/(\d+)$/.exec(part); if (step !== null) { const stepBy = Number.parseInt(step[2] as string, 10); if (Number.isNaN(stepBy) || stepBy <= 0) return null; let lo = min; let hi = max; const range = step[1] as string; if (range !== "*") { const dash = range.split("-"); lo = Number.parseInt(dash[0] as string, 10); hi = dash.length > 1 ? Number.parseInt(dash[1] as string, 10) : max; } for (let v = lo; v <= hi; v += stepBy) out.add(v); continue; } if (part === "*") { for (let v = min; v <= max; v += 1) out.add(v); continue; } const dash = /^(\d+)-(\d+)$/.exec(part); if (dash !== null) { const lo = Number.parseInt(dash[1] as string, 10); const hi = Number.parseInt(dash[2] as string, 10); if (lo < min || hi > max || lo > hi) return null; for (let v = lo; v <= hi; v += 1) out.add(v); continue; } const value = Number.parseInt(part, 10); if (Number.isNaN(value) || value < min || value > max) return null; out.add(value); } return out.size > 0 ? out : null; } /** Parse a 5-field cron expression. Returns null when invalid. */ export function parseCron(expr: string): CronSpec | null { const fields = expr.trim().split(/\s+/); if (fields.length !== 5) return null; const minutes = parseField(fields[0] as string, 0, 59); const hours = parseField(fields[1] as string, 0, 23); const daysOfMonth = parseField(fields[2] as string, 1, 31); const months = parseField(fields[3] as string, 1, 12); const daysOfWeek = parseField(fields[4] as string, 0, 7); if (minutes === null || hours === null || daysOfMonth === null || months === null || daysOfWeek === null) { return null; } // Cron convention: 7 == 0 == Sunday. if (daysOfWeek.has(7)) daysOfWeek.add(0); return { minutes, hours, daysOfMonth, months, daysOfWeek }; } function matches(spec: CronSpec, date: Date): boolean { return ( spec.minutes.has(date.getMinutes()) && spec.hours.has(date.getHours()) && spec.daysOfMonth.has(date.getDate()) && spec.months.has(date.getMonth() + 1) && spec.daysOfWeek.has(date.getDay()) ); } const MAX_SCAN_MINUTES = 366 * 24 * 60; /** Next fire time strictly after `from`. Null when the spec can never fire within a year. */ export function nextCronRun(spec: CronSpec, from: Date): Date | null { const cursor = new Date(from.getTime()); cursor.setSeconds(0, 0); for (let i = 0; i < MAX_SCAN_MINUTES; i += 1) { cursor.setTime(cursor.getTime() + 60_000); if (matches(spec, cursor)) return new Date(cursor.getTime()); } return null; } /** Parse "07:00-23:00"-style active hours. Null = always active. */ export function parseActiveHours(value: string | undefined): { start: number; end: number } | null { if (value === undefined) return null; const match = /^(\d{1,2}):(\d{2})-(\d{1,2}):(\d{2})$/.exec(value.trim()); if (match === null) return null; const start = Number.parseInt(match[1] as string, 10) * 60 + Number.parseInt(match[2] as string, 10); const end = Number.parseInt(match[3] as string, 10) * 60 + Number.parseInt(match[4] as string, 10); return { start, end }; } /** True when `date` falls inside the active-hours window (wrapping windows supported). */ export function withinActiveHours( hours: { start: number; end: number } | null, date: Date, ): boolean { if (hours === null) return true; const minute = date.getHours() * 60 + date.getMinutes(); if (hours.start <= hours.end) return minute >= hours.start && minute < hours.end; return minute >= hours.start || minute < hours.end; }