source dump of claude code
at main 308 lines 9.5 kB view raw
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 10export type CronFields = { 11 minute: number[] 12 hour: number[] 13 dayOfMonth: number[] 14 month: number[] 15 dayOfWeek: number[] 16} 17 18type FieldRange = { min: number; max: number } 19 20const 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. 31function 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 */ 83export 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 */ 119export 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 190const DAY_NAMES = [ 191 'Sunday', 192 'Monday', 193 'Tuesday', 194 'Wednesday', 195 'Thursday', 196 'Friday', 197 'Saturday', 198] 199 200function 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 207function 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 218export 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}