/ utils / cron.ts
cron.ts
  1  // Minimal cron expression parsing and next-run calculation.
  2  //
  3  // Supports the standard 5-field cron subset:
  4  //   minute hour day-of-month month day-of-week
  5  //
  6  // Field syntax: wildcard, N, step (star-slash-N), range (N-M), list (N,M,...).
  7  // No L, W, ?, or name aliases. All times are interpreted in the process's
  8  // local timezone — "0 9 * * *" means 9am wherever the CLI is running.
  9  
 10  export type CronFields = {
 11    minute: number[]
 12    hour: number[]
 13    dayOfMonth: number[]
 14    month: number[]
 15    dayOfWeek: number[]
 16  }
 17  
 18  type FieldRange = { min: number; max: number }
 19  
 20  const FIELD_RANGES: FieldRange[] = [
 21    { min: 0, max: 59 }, // minute
 22    { min: 0, max: 23 }, // hour
 23    { min: 1, max: 31 }, // dayOfMonth
 24    { min: 1, max: 12 }, // month
 25    { min: 0, max: 6 }, // dayOfWeek (0=Sunday; 7 accepted as Sunday alias)
 26  ]
 27  
 28  // Parse a single cron field into a sorted array of matching values.
 29  // Supports: wildcard, N, star-slash-N (step), N-M (range), and comma-lists.
 30  // Returns null if invalid.
 31  function expandField(field: string, range: FieldRange): number[] | null {
 32    const { min, max } = range
 33    const out = new Set<number>()
 34  
 35    for (const part of field.split(',')) {
 36      // wildcard or star-slash-N
 37      const stepMatch = part.match(/^\*(?:\/(\d+))?$/)
 38      if (stepMatch) {
 39        const step = stepMatch[1] ? parseInt(stepMatch[1], 10) : 1
 40        if (step < 1) return null
 41        for (let i = min; i <= max; i += step) out.add(i)
 42        continue
 43      }
 44  
 45      // N-M or N-M/S
 46      const rangeMatch = part.match(/^(\d+)-(\d+)(?:\/(\d+))?$/)
 47      if (rangeMatch) {
 48        const lo = parseInt(rangeMatch[1]!, 10)
 49        const hi = parseInt(rangeMatch[2]!, 10)
 50        const step = rangeMatch[3] ? parseInt(rangeMatch[3], 10) : 1
 51        // dayOfWeek: accept 7 as Sunday alias in ranges (e.g. 5-7 = Fri,Sat,Sun → [5,6,0])
 52        const isDow = min === 0 && max === 6
 53        const effMax = isDow ? 7 : max
 54        if (lo > hi || step < 1 || lo < min || hi > effMax) return null
 55        for (let i = lo; i <= hi; i += step) {
 56          out.add(isDow && i === 7 ? 0 : i)
 57        }
 58        continue
 59      }
 60  
 61      // plain N
 62      const singleMatch = part.match(/^\d+$/)
 63      if (singleMatch) {
 64        let n = parseInt(part, 10)
 65        // dayOfWeek: accept 7 as Sunday alias → 0
 66        if (min === 0 && max === 6 && n === 7) n = 0
 67        if (n < min || n > max) return null
 68        out.add(n)
 69        continue
 70      }
 71  
 72      return null
 73    }
 74  
 75    if (out.size === 0) return null
 76    return Array.from(out).sort((a, b) => a - b)
 77  }
 78  
 79  /**
 80   * Parse a 5-field cron expression into expanded number arrays.
 81   * Returns null if invalid or unsupported syntax.
 82   */
 83  export function parseCronExpression(expr: string): CronFields | null {
 84    const parts = expr.trim().split(/\s+/)
 85    if (parts.length !== 5) return null
 86  
 87    const expanded: number[][] = []
 88    for (let i = 0; i < 5; i++) {
 89      const result = expandField(parts[i]!, FIELD_RANGES[i]!)
 90      if (!result) return null
 91      expanded.push(result)
 92    }
 93  
 94    return {
 95      minute: expanded[0]!,
 96      hour: expanded[1]!,
 97      dayOfMonth: expanded[2]!,
 98      month: expanded[3]!,
 99      dayOfWeek: expanded[4]!,
100    }
101  }
102  
103  /**
104   * Compute the next Date strictly after `from` that matches the cron fields,
105   * using the process's local timezone. Walks forward minute-by-minute. Bounded
106   * at 366 days; returns null if no match (impossible for valid cron, but
107   * satisfies the type).
108   *
109   * Standard cron semantics: when both dayOfMonth and dayOfWeek are constrained
110   * (neither is the full range), a date matches if EITHER matches.
111   *
112   * DST: fixed-hour crons targeting a spring-forward gap (e.g. `30 2 * * *`
113   * in a US timezone) skip the transition day — the gap hour never appears
114   * in local time, so the hour-set check fails and the loop moves on.
115   * Wildcard-hour crons (`30 * * * *`) fire at the first valid minute after
116   * the gap. Fall-back repeats fire once (the step-forward logic jumps past
117   * the second occurrence). This matches vixie-cron behavior.
118   */
119  export function computeNextCronRun(
120    fields: CronFields,
121    from: Date,
122  ): Date | null {
123    const minuteSet = new Set(fields.minute)
124    const hourSet = new Set(fields.hour)
125    const domSet = new Set(fields.dayOfMonth)
126    const monthSet = new Set(fields.month)
127    const dowSet = new Set(fields.dayOfWeek)
128  
129    // Is the field wildcarded (full range)?
130    const domWild = fields.dayOfMonth.length === 31
131    const dowWild = fields.dayOfWeek.length === 7
132  
133    // Round up to the next whole minute (strictly after `from`)
134    const t = new Date(from.getTime())
135    t.setSeconds(0, 0)
136    t.setMinutes(t.getMinutes() + 1)
137  
138    const maxIter = 366 * 24 * 60
139    for (let i = 0; i < maxIter; i++) {
140      const month = t.getMonth() + 1
141      if (!monthSet.has(month)) {
142        // Jump to start of next month
143        t.setMonth(t.getMonth() + 1, 1)
144        t.setHours(0, 0, 0, 0)
145        continue
146      }
147  
148      const dom = t.getDate()
149      const dow = t.getDay()
150      // When both dom/dow are constrained, either match is sufficient (OR semantics)
151      const dayMatches =
152        domWild && dowWild
153          ? true
154          : domWild
155            ? dowSet.has(dow)
156            : dowWild
157              ? domSet.has(dom)
158              : domSet.has(dom) || dowSet.has(dow)
159  
160      if (!dayMatches) {
161        // Jump to start of next day
162        t.setDate(t.getDate() + 1)
163        t.setHours(0, 0, 0, 0)
164        continue
165      }
166  
167      if (!hourSet.has(t.getHours())) {
168        t.setHours(t.getHours() + 1, 0, 0, 0)
169        continue
170      }
171  
172      if (!minuteSet.has(t.getMinutes())) {
173        t.setMinutes(t.getMinutes() + 1)
174        continue
175      }
176  
177      return t
178    }
179  
180    return null
181  }
182  
183  // --- cronToHuman ------------------------------------------------------------
184  // Intentionally narrow: covers common patterns; falls through to the raw cron
185  // string for anything else. The `utc` option exists for CCR remote triggers
186  // (agents-platform.tsx), which run on servers and always use UTC cron strings
187  // — that path translates UTC→local for display and needs midnight-crossing
188  // logic for the weekday case. Local scheduled tasks (the default) need neither.
189  
190  const DAY_NAMES = [
191    'Sunday',
192    'Monday',
193    'Tuesday',
194    'Wednesday',
195    'Thursday',
196    'Friday',
197    'Saturday',
198  ]
199  
200  function formatLocalTime(minute: number, hour: number): string {
201    // January 1 — no DST gap anywhere. Using `new Date()` (today) would roll
202    // 2am→3am on the one spring-forward day per year.
203    const d = new Date(2000, 0, 1, hour, minute)
204    return d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
205  }
206  
207  function formatUtcTimeAsLocal(minute: number, hour: number): string {
208    // Create a date in UTC and format in user's local timezone
209    const d = new Date()
210    d.setUTCHours(hour, minute, 0, 0)
211    return d.toLocaleTimeString('en-US', {
212      hour: 'numeric',
213      minute: '2-digit',
214      timeZoneName: 'short',
215    })
216  }
217  
218  export function cronToHuman(cron: string, opts?: { utc?: boolean }): string {
219    const utc = opts?.utc ?? false
220    const parts = cron.trim().split(/\s+/)
221    if (parts.length !== 5) return cron
222  
223    const [minute, hour, dayOfMonth, month, dayOfWeek] = parts as [
224      string,
225      string,
226      string,
227      string,
228      string,
229    ]
230  
231    // Every N minutes: step/N * * * *
232    const everyMinMatch = minute.match(/^\*\/(\d+)$/)
233    if (
234      everyMinMatch &&
235      hour === '*' &&
236      dayOfMonth === '*' &&
237      month === '*' &&
238      dayOfWeek === '*'
239    ) {
240      const n = parseInt(everyMinMatch[1]!, 10)
241      return n === 1 ? 'Every minute' : `Every ${n} minutes`
242    }
243  
244    // Every hour: 0 * * * *
245    if (
246      minute.match(/^\d+$/) &&
247      hour === '*' &&
248      dayOfMonth === '*' &&
249      month === '*' &&
250      dayOfWeek === '*'
251    ) {
252      const m = parseInt(minute, 10)
253      if (m === 0) return 'Every hour'
254      return `Every hour at :${m.toString().padStart(2, '0')}`
255    }
256  
257    // Every N hours: 0 step/N * * *
258    const everyHourMatch = hour.match(/^\*\/(\d+)$/)
259    if (
260      minute.match(/^\d+$/) &&
261      everyHourMatch &&
262      dayOfMonth === '*' &&
263      month === '*' &&
264      dayOfWeek === '*'
265    ) {
266      const n = parseInt(everyHourMatch[1]!, 10)
267      const m = parseInt(minute, 10)
268      const suffix = m === 0 ? '' : ` at :${m.toString().padStart(2, '0')}`
269      return n === 1 ? `Every hour${suffix}` : `Every ${n} hours${suffix}`
270    }
271  
272    // --- Remaining cases reference hour+minute: branch on utc ----------------
273  
274    if (!minute.match(/^\d+$/) || !hour.match(/^\d+$/)) return cron
275    const m = parseInt(minute, 10)
276    const h = parseInt(hour, 10)
277    const fmtTime = utc ? formatUtcTimeAsLocal : formatLocalTime
278  
279    // Daily at specific time: M H * * *
280    if (dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
281      return `Every day at ${fmtTime(m, h)}`
282    }
283  
284    // Specific day of week: M H * * D
285    if (dayOfMonth === '*' && month === '*' && dayOfWeek.match(/^\d$/)) {
286      const dayIndex = parseInt(dayOfWeek, 10) % 7 // normalize 7 (Sunday alias) -> 0
287      let dayName: string | undefined
288      if (utc) {
289        // UTC day+time may land on a different local day (midnight crossing).
290        // Compute the actual local weekday by constructing the UTC instant.
291        const ref = new Date()
292        const daysToAdd = (dayIndex - ref.getUTCDay() + 7) % 7
293        ref.setUTCDate(ref.getUTCDate() + daysToAdd)
294        ref.setUTCHours(h, m, 0, 0)
295        dayName = DAY_NAMES[ref.getDay()]
296      } else {
297        dayName = DAY_NAMES[dayIndex]
298      }
299      if (dayName) return `Every ${dayName} at ${fmtTime(m, h)}`
300    }
301  
302    // Weekdays: M H * * 1-5
303    if (dayOfMonth === '*' && month === '*' && dayOfWeek === '1-5') {
304      return `Weekdays at ${fmtTime(m, h)}`
305    }
306  
307    return cron
308  }