diff --git a/packages/protocol/src/schedule/cron-expression.ts b/packages/protocol/src/schedule/cron-expression.ts index cc93652afa..5cfffbbb1b 100644 --- a/packages/protocol/src/schedule/cron-expression.ts +++ b/packages/protocol/src/schedule/cron-expression.ts @@ -5,6 +5,7 @@ interface CronFieldBounds { } export interface CronFieldMatcher { + isWildcard: boolean; matches(value: number): boolean; } @@ -101,6 +102,7 @@ function parseField(source: string, bounds: CronFieldBounds): CronFieldMatcher { } return { + isWildcard: trimmed.startsWith("*"), matches(value: number): boolean { return allowed.has(value); }, diff --git a/packages/server/src/server/schedule/cron.test.ts b/packages/server/src/server/schedule/cron.test.ts index 9d4172ff27..da8d9760ed 100644 --- a/packages/server/src/server/schedule/cron.test.ts +++ b/packages/server/src/server/schedule/cron.test.ts @@ -20,6 +20,37 @@ describe("schedule cron cadence", () => { expect(next.toISOString()).toBe("2026-01-05T09:15:00.000Z"); }); + test("matches either restricted day field", () => { + const next = computeNextRunAt( + { type: "cron", expression: "0 9 1 * 1" }, + new Date("2026-10-17T00:00:00.000Z"), + ); + + expect(next.toISOString()).toBe("2026-10-19T09:00:00.000Z"); + }); + + test.each([ + ["0 9 1 * 1", "2026-10-30T10:00:00.000Z", "2026-11-01T09:00:00.000Z"], + ["0 9 1 * *", "2026-10-17T00:00:00.000Z", "2026-11-01T09:00:00.000Z"], + ["0 9 * * 1", "2026-10-17T00:00:00.000Z", "2026-10-19T09:00:00.000Z"], + ["0 9 */2 * 1", "2026-10-19T09:00:00.000Z", "2026-11-09T09:00:00.000Z"], + ["0 9 1-31 * 1", "2026-10-17T00:00:00.000Z", "2026-10-17T09:00:00.000Z"], + ["0 9 1,15 * 5", "2026-10-17T00:00:00.000Z", "2026-10-23T09:00:00.000Z"], + ])("honors day-field semantics for %s", (expression, after, expected) => { + const next = computeNextRunAt({ type: "cron", expression }, new Date(after)); + + expect(next.toISOString()).toBe(expected); + }); + + test("matches either restricted day field in the schedule's time zone", () => { + const next = computeNextRunAt( + { type: "cron", expression: "0 9 1 * 1", timezone: "America/New_York" }, + new Date("2026-10-17T00:00:00.000Z"), + ); + + expect(next.toISOString()).toBe("2026-10-19T13:00:00.000Z"); + }); + test("computes timezone cron matches at the requested wall-clock time", () => { const winter = computeNextRunAt( { type: "cron", expression: "0 9 * * 1-5", timezone: "America/New_York" }, diff --git a/packages/server/src/server/schedule/cron.ts b/packages/server/src/server/schedule/cron.ts index 21491ff065..972289322d 100644 --- a/packages/server/src/server/schedule/cron.ts +++ b/packages/server/src/server/schedule/cron.ts @@ -99,19 +99,24 @@ export function computeNextRunAt(cadence: ScheduleCadence, after: Date): Date { } const cron = parseCronExpression(cadence.expression); + const hasWildcardDay = cron.dayOfMonth.isWildcard || cron.dayOfWeek.isWildcard; const readDateParts = createCronDatePartsReader(cadence.timezone); const limit = 366 * 24 * 60; let cursor = startOfNextMinute(after); for (let index = 0; index < limit; index += 1) { const { minute, hour, dayOfMonth, month, dayOfWeek } = readDateParts(cursor); + const dayOfMonthMatches = cron.dayOfMonth.matches(dayOfMonth); + const dayOfWeekMatches = cron.dayOfWeek.matches(dayOfWeek); + const bothDaysMatch = dayOfMonthMatches && dayOfWeekMatches; + const eitherDayMatches = dayOfMonthMatches || dayOfWeekMatches; + const dayMatches = hasWildcardDay ? bothDaysMatch : eitherDayMatches; if ( cron.minute.matches(minute) && cron.hour.matches(hour) && - cron.dayOfMonth.matches(dayOfMonth) && cron.month.matches(month) && - cron.dayOfWeek.matches(dayOfWeek) + dayMatches ) { return cursor; }