cron.ts 9.2 KB

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