Files
orca/src/shared/automation-schedules.ts
Neil e4a9d24e0c fix(automations): repair cron step expansion and day restriction (#20202)
The semantic half of the cron repair. Both defects change what an already-saved
schedule does, so they ship together and behind a decision.

#15723: parseCronField set end = start for a bare numeric field even with a
slash step, so 5/15 expanded to [5] and fired hourly instead of every fifteen
minutes. N/step is the open-ended N-max/step sequence now.

#15896: day restriction came from expanded set cardinality, so 1-31 read as
unrestricted and */2 as restricted. Restriction is lexical now: a day field
restricts iff no term of it ranges over a star, matching vixie cron and
robfig/cron rather than crontab(5)'s prose. Verified differentially against
robfig/cron v1.2.0 across 22 expressions, 424 days, zero divergences.

The two cannot ship apart: 0 9 1/1 * 1 matches 124 days under the old parser,
104 under #15723 alone, and 730 under both, because the old cardinality flags
react to the corrected expansion.

describeAutomationScheduleDrift reads a saved expression under both semantics
and reports the ones that moved, so neither direction is silent; the service
names them once at startup. No expression Orca's own presets generate drifts.

Fixes #15723
Fixes #15896
2026-09-15 01:28:23 -07:00

200 lines
6.6 KiB
TypeScript

import {
parseAutomationRrule,
parseCronExpression,
parseSchedule,
type ParsedCron
} from './automation-schedule-parsing'
import { cronHasPossibleOccurrence } from './automation-cron-occurrence'
export type AutomationCronScheduleClassification =
| { kind: 'hourly'; minute: number; label: string }
| { kind: 'daily'; hour: number; minute: number; label: string }
| { kind: 'weekdays'; hour: number; minute: number; label: string }
| { kind: 'weekly'; hour: number; minute: number; dayOfWeek: number; label: string }
| { kind: 'custom'; label: string }
| { kind: 'invalid'; label: string }
/** Locale-free schedule shape: the renderer localizes it, the CLI renders it in English. */
export type AutomationScheduleDescriptor =
| { kind: 'hourly'; minute: number }
| { kind: 'daily'; hour: number; minute: number }
| { kind: 'weekdays'; hour: number; minute: number }
| { kind: 'weekly'; hour: number; minute: number; dayOfWeek: number }
| { kind: 'custom' }
| { kind: 'invalid' }
// Why: shared labels feed the CLI, which must stay English regardless of OS or UI locale.
const EN_DAY_NAMES = [
'Sunday',
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday'
] as const
/** Clock-time portion of a schedule label; numeric shape follows the OS region by convention. */
export function formatAutomationScheduleTime(hour: number, minute: number): string {
return formatTime(hour, minute)
}
function formatTime(hour: number, minute: number): string {
const date = new Date()
date.setHours(hour, minute, 0, 0)
return new Intl.DateTimeFormat(undefined, {
hour: 'numeric',
minute: '2-digit'
}).format(date)
}
function getSingleSetValue(values: Set<number>): number | null {
if (values.size !== 1) {
return null
}
return values.values().next().value as number
}
function setContainsExactly(values: Set<number>, expected: readonly number[]): boolean {
if (values.size !== expected.length) {
return false
}
return expected.every((value) => values.has(value))
}
function setContainsRange(values: Set<number>, min: number, max: number): boolean {
if (values.size !== max - min + 1) {
return false
}
for (let value = min; value <= max; value += 1) {
if (!values.has(value)) {
return false
}
}
return true
}
function formatParsedRruleSchedule(schedule: ReturnType<typeof parseAutomationRrule>): string {
if (schedule.preset === 'hourly') {
return `Hourly at :${String(schedule.minute).padStart(2, '0')}`
}
const time = formatTime(schedule.hour, schedule.minute)
if (schedule.preset === 'daily') {
return `Daily at ${time}`
}
if (schedule.preset === 'weekdays') {
return `Weekdays at ${time}`
}
return `${EN_DAY_NAMES[schedule.dayOfWeek]}s at ${time}`
}
function classifyParsedCronSchedule(rule: ParsedCron): AutomationCronScheduleClassification {
if (!cronHasPossibleOccurrence(rule, Date.now())) {
return { kind: 'invalid', label: 'Invalid schedule' }
}
const minute = getSingleSetValue(rule.minutes)
const hour = getSingleSetValue(rule.hours)
const unrestrictedMonth = setContainsRange(rule.months, 1, 12)
const everyDayOfMonth = setContainsRange(rule.daysOfMonth, 1, 31)
const everyDayOfWeek = setContainsRange(rule.daysOfWeek, 0, 6)
// Labels describe the days the rule actually fires on, so they need coverage of the
// matched set, not the lexical restriction flags that pick OR over AND.
const matchesEitherDayField = rule.dayOfMonthRestricted && rule.dayOfWeekRestricted
const everyDay = matchesEitherDayField
? everyDayOfMonth || everyDayOfWeek
: everyDayOfMonth && everyDayOfWeek
const unrestrictedCalendar = everyDayOfMonth && unrestrictedMonth
if (minute !== null && setContainsRange(rule.hours, 0, 23) && unrestrictedMonth && everyDay) {
return {
kind: 'hourly',
minute,
label: `Hourly at :${String(minute).padStart(2, '0')}`
}
}
if (minute !== null && hour !== null && unrestrictedMonth && everyDay) {
return { kind: 'daily', hour, minute, label: `Daily at ${formatTime(hour, minute)}` }
}
// Weekday/weekly names only read true under AND; under OR the day-of-month half fires too.
if (minute !== null && hour !== null && unrestrictedCalendar && !matchesEitherDayField) {
const time = formatTime(hour, minute)
if (setContainsExactly(rule.daysOfWeek, [1, 2, 3, 4, 5])) {
return { kind: 'weekdays', hour, minute, label: `Weekdays at ${time}` }
}
const dayOfWeek = getSingleSetValue(rule.daysOfWeek)
if (dayOfWeek !== null) {
return {
kind: 'weekly',
hour,
minute,
dayOfWeek,
label: `${EN_DAY_NAMES[dayOfWeek]}s at ${time}`
}
}
}
return { kind: 'custom', label: 'Custom schedule' }
}
export function classifyAutomationCronSchedule(
schedule: string
): AutomationCronScheduleClassification {
try {
return classifyParsedCronSchedule(parseCronExpression(schedule.trim()))
} catch {
return { kind: 'invalid', label: 'Invalid schedule' }
}
}
export function formatAutomationSchedule(scheduleExpression: string): string {
try {
const trimmed = scheduleExpression.trim()
const schedule = parseSchedule(trimmed)
if (schedule.kind === 'cron') {
return classifyParsedCronSchedule(schedule).label
}
return formatParsedRruleSchedule(parseAutomationRrule(trimmed))
} catch {
return 'Invalid schedule'
}
}
function toScheduleDescriptor(
classification: AutomationCronScheduleClassification
): AutomationScheduleDescriptor {
const { label: _label, ...descriptor } = classification
return descriptor
}
/**
* Parse a cron or RRULE expression into its locale-free shape so callers can render their own
* copy. Same single parse path as `formatAutomationSchedule`, minus the English label.
*/
export function describeAutomationSchedule(
scheduleExpression: string
): AutomationScheduleDescriptor {
try {
const trimmed = scheduleExpression.trim()
const schedule = parseSchedule(trimmed)
if (schedule.kind === 'cron') {
return toScheduleDescriptor(classifyParsedCronSchedule(schedule))
}
const rrule = parseAutomationRrule(trimmed)
if (rrule.preset === 'hourly') {
return { kind: 'hourly', minute: rrule.minute }
}
if (rrule.preset === 'weekly') {
return {
kind: 'weekly',
hour: rrule.hour,
minute: rrule.minute,
dayOfWeek: rrule.dayOfWeek
}
}
if (rrule.preset === 'weekdays') {
return { kind: 'weekdays', hour: rrule.hour, minute: rrule.minute }
}
return { kind: 'daily', hour: rrule.hour, minute: rrule.minute }
} catch {
return { kind: 'invalid' }
}
}