| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308 |
- // Minimal cron expression parsing and next-run calculation.
- //
- // Supports the standard 5-field cron subset:
- // minute hour day-of-month month day-of-week
- //
- // Field syntax: wildcard, N, step (star-slash-N), range (N-M), list (N,M,...).
- // No L, W, ?, or name aliases. All times are interpreted in the process's
- // local timezone — "0 9 * * *" means 9am wherever the CLI is running.
- export type CronFields = {
- minute: number[]
- hour: number[]
- dayOfMonth: number[]
- month: number[]
- dayOfWeek: number[]
- }
- type FieldRange = { min: number; max: number }
- const FIELD_RANGES: FieldRange[] = [
- { min: 0, max: 59 }, // minute
- { min: 0, max: 23 }, // hour
- { min: 1, max: 31 }, // dayOfMonth
- { min: 1, max: 12 }, // month
- { min: 0, max: 6 }, // dayOfWeek (0=Sunday; 7 accepted as Sunday alias)
- ]
- // Parse a single cron field into a sorted array of matching values.
- // Supports: wildcard, N, star-slash-N (step), N-M (range), and comma-lists.
- // Returns null if invalid.
- function expandField(field: string, range: FieldRange): number[] | null {
- const { min, max } = range
- const out = new Set<number>()
- for (const part of field.split(',')) {
- // wildcard or star-slash-N
- const stepMatch = part.match(/^\*(?:\/(\d+))?$/)
- if (stepMatch) {
- const step = stepMatch[1] ? parseInt(stepMatch[1], 10) : 1
- if (step < 1) return null
- for (let i = min; i <= max; i += step) out.add(i)
- continue
- }
- // N-M or N-M/S
- const rangeMatch = part.match(/^(\d+)-(\d+)(?:\/(\d+))?$/)
- if (rangeMatch) {
- const lo = parseInt(rangeMatch[1]!, 10)
- const hi = parseInt(rangeMatch[2]!, 10)
- const step = rangeMatch[3] ? parseInt(rangeMatch[3], 10) : 1
- // dayOfWeek: accept 7 as Sunday alias in ranges (e.g. 5-7 = Fri,Sat,Sun → [5,6,0])
- const isDow = min === 0 && max === 6
- const effMax = isDow ? 7 : max
- if (lo > hi || step < 1 || lo < min || hi > effMax) return null
- for (let i = lo; i <= hi; i += step) {
- out.add(isDow && i === 7 ? 0 : i)
- }
- continue
- }
- // plain N
- const singleMatch = part.match(/^\d+$/)
- if (singleMatch) {
- let n = parseInt(part, 10)
- // dayOfWeek: accept 7 as Sunday alias → 0
- if (min === 0 && max === 6 && n === 7) n = 0
- if (n < min || n > max) return null
- out.add(n)
- continue
- }
- return null
- }
- if (out.size === 0) return null
- return Array.from(out).sort((a, b) => a - b)
- }
- /**
- * Parse a 5-field cron expression into expanded number arrays.
- * Returns null if invalid or unsupported syntax.
- */
- export function parseCronExpression(expr: string): CronFields | null {
- const parts = expr.trim().split(/\s+/)
- if (parts.length !== 5) return null
- const expanded: number[][] = []
- for (let i = 0; i < 5; i++) {
- const result = expandField(parts[i]!, FIELD_RANGES[i]!)
- if (!result) return null
- expanded.push(result)
- }
- return {
- minute: expanded[0]!,
- hour: expanded[1]!,
- dayOfMonth: expanded[2]!,
- month: expanded[3]!,
- dayOfWeek: expanded[4]!,
- }
- }
- /**
- * Compute the next Date strictly after `from` that matches the cron fields,
- * using the process's local timezone. Walks forward minute-by-minute. Bounded
- * at 366 days; returns null if no match (impossible for valid cron, but
- * satisfies the type).
- *
- * Standard cron semantics: when both dayOfMonth and dayOfWeek are constrained
- * (neither is the full range), a date matches if EITHER matches.
- *
- * DST: fixed-hour crons targeting a spring-forward gap (e.g. `30 2 * * *`
- * in a US timezone) skip the transition day — the gap hour never appears
- * in local time, so the hour-set check fails and the loop moves on.
- * Wildcard-hour crons (`30 * * * *`) fire at the first valid minute after
- * the gap. Fall-back repeats fire once (the step-forward logic jumps past
- * the second occurrence). This matches vixie-cron behavior.
- */
- export function computeNextCronRun(
- fields: CronFields,
- from: Date,
- ): Date | null {
- const minuteSet = new Set(fields.minute)
- const hourSet = new Set(fields.hour)
- const domSet = new Set(fields.dayOfMonth)
- const monthSet = new Set(fields.month)
- const dowSet = new Set(fields.dayOfWeek)
- // Is the field wildcarded (full range)?
- const domWild = fields.dayOfMonth.length === 31
- const dowWild = fields.dayOfWeek.length === 7
- // Round up to the next whole minute (strictly after `from`)
- const t = new Date(from.getTime())
- t.setSeconds(0, 0)
- t.setMinutes(t.getMinutes() + 1)
- const maxIter = 366 * 24 * 60
- for (let i = 0; i < maxIter; i++) {
- const month = t.getMonth() + 1
- if (!monthSet.has(month)) {
- // Jump to start of next month
- t.setMonth(t.getMonth() + 1, 1)
- t.setHours(0, 0, 0, 0)
- continue
- }
- const dom = t.getDate()
- const dow = t.getDay()
- // When both dom/dow are constrained, either match is sufficient (OR semantics)
- const dayMatches =
- domWild && dowWild
- ? true
- : domWild
- ? dowSet.has(dow)
- : dowWild
- ? domSet.has(dom)
- : domSet.has(dom) || dowSet.has(dow)
- if (!dayMatches) {
- // Jump to start of next day
- t.setDate(t.getDate() + 1)
- t.setHours(0, 0, 0, 0)
- continue
- }
- if (!hourSet.has(t.getHours())) {
- t.setHours(t.getHours() + 1, 0, 0, 0)
- continue
- }
- if (!minuteSet.has(t.getMinutes())) {
- t.setMinutes(t.getMinutes() + 1)
- continue
- }
- return t
- }
- return null
- }
- // --- cronToHuman ------------------------------------------------------------
- // Intentionally narrow: covers common patterns; falls through to the raw cron
- // string for anything else. The `utc` option exists for CCR remote triggers
- // (agents-platform.tsx), which run on servers and always use UTC cron strings
- // — that path translates UTC→local for display and needs midnight-crossing
- // logic for the weekday case. Local scheduled tasks (the default) need neither.
- const DAY_NAMES = [
- 'Sunday',
- 'Monday',
- 'Tuesday',
- 'Wednesday',
- 'Thursday',
- 'Friday',
- 'Saturday',
- ]
- function formatLocalTime(minute: number, hour: number): string {
- // January 1 — no DST gap anywhere. Using `new Date()` (today) would roll
- // 2am→3am on the one spring-forward day per year.
- const d = new Date(2000, 0, 1, hour, minute)
- return d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
- }
- function formatUtcTimeAsLocal(minute: number, hour: number): string {
- // Create a date in UTC and format in user's local timezone
- const d = new Date()
- d.setUTCHours(hour, minute, 0, 0)
- return d.toLocaleTimeString('en-US', {
- hour: 'numeric',
- minute: '2-digit',
- timeZoneName: 'short',
- })
- }
- export function cronToHuman(cron: string, opts?: { utc?: boolean }): string {
- const utc = opts?.utc ?? false
- const parts = cron.trim().split(/\s+/)
- if (parts.length !== 5) return cron
- const [minute, hour, dayOfMonth, month, dayOfWeek] = parts as [
- string,
- string,
- string,
- string,
- string,
- ]
- // Every N minutes: step/N * * * *
- const everyMinMatch = minute.match(/^\*\/(\d+)$/)
- if (
- everyMinMatch &&
- hour === '*' &&
- dayOfMonth === '*' &&
- month === '*' &&
- dayOfWeek === '*'
- ) {
- const n = parseInt(everyMinMatch[1]!, 10)
- return n === 1 ? 'Every minute' : `Every ${n} minutes`
- }
- // Every hour: 0 * * * *
- if (
- minute.match(/^\d+$/) &&
- hour === '*' &&
- dayOfMonth === '*' &&
- month === '*' &&
- dayOfWeek === '*'
- ) {
- const m = parseInt(minute, 10)
- if (m === 0) return 'Every hour'
- return `Every hour at :${m.toString().padStart(2, '0')}`
- }
- // Every N hours: 0 step/N * * *
- const everyHourMatch = hour.match(/^\*\/(\d+)$/)
- if (
- minute.match(/^\d+$/) &&
- everyHourMatch &&
- dayOfMonth === '*' &&
- month === '*' &&
- dayOfWeek === '*'
- ) {
- const n = parseInt(everyHourMatch[1]!, 10)
- const m = parseInt(minute, 10)
- const suffix = m === 0 ? '' : ` at :${m.toString().padStart(2, '0')}`
- return n === 1 ? `Every hour${suffix}` : `Every ${n} hours${suffix}`
- }
- // --- Remaining cases reference hour+minute: branch on utc ----------------
- if (!minute.match(/^\d+$/) || !hour.match(/^\d+$/)) return cron
- const m = parseInt(minute, 10)
- const h = parseInt(hour, 10)
- const fmtTime = utc ? formatUtcTimeAsLocal : formatLocalTime
- // Daily at specific time: M H * * *
- if (dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
- return `Every day at ${fmtTime(m, h)}`
- }
- // Specific day of week: M H * * D
- if (dayOfMonth === '*' && month === '*' && dayOfWeek.match(/^\d$/)) {
- const dayIndex = parseInt(dayOfWeek, 10) % 7 // normalize 7 (Sunday alias) -> 0
- let dayName: string | undefined
- if (utc) {
- // UTC day+time may land on a different local day (midnight crossing).
- // Compute the actual local weekday by constructing the UTC instant.
- const ref = new Date()
- const daysToAdd = (dayIndex - ref.getUTCDay() + 7) % 7
- ref.setUTCDate(ref.getUTCDate() + daysToAdd)
- ref.setUTCHours(h, m, 0, 0)
- dayName = DAY_NAMES[ref.getDay()]
- } else {
- dayName = DAY_NAMES[dayIndex]
- }
- if (dayName) return `Every ${dayName} at ${fmtTime(m, h)}`
- }
- // Weekdays: M H * * 1-5
- if (dayOfMonth === '*' && month === '*' && dayOfWeek === '1-5') {
- return `Weekdays at ${fmtTime(m, h)}`
- }
- return cron
- }
|