Skip to content

Commit 19ca9c7

Browse files
aadamgoughAdam Goughwaleedlatif1
authored
fix(schedule): fix for custom cron (#699)
* fix: added cronExpression field and fixed formatting * fix: modified the test.ts file #699 * added additional validation --------- Co-authored-by: Adam Gough <adamgough@Mac.attlocal.net> Co-authored-by: Waleed Latif <walif6@gmail.com>
1 parent b13f339 commit 19ca9c7

File tree

3 files changed

+146
-8
lines changed

3 files changed

+146
-8
lines changed

apps/sim/app/api/schedules/route.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
generateCronExpression,
1111
getScheduleTimeValues,
1212
getSubBlockValue,
13+
validateCronExpression,
1314
} from '@/lib/schedules/utils'
1415
import { db } from '@/db'
1516
import { workflowSchedule } from '@/db/schema'
@@ -192,6 +193,18 @@ export async function POST(req: NextRequest) {
192193

193194
cronExpression = generateCronExpression(defaultScheduleType, scheduleValues)
194195

196+
// Additional validation for custom cron expressions
197+
if (defaultScheduleType === 'custom' && cronExpression) {
198+
const validation = validateCronExpression(cronExpression)
199+
if (!validation.isValid) {
200+
logger.error(`[${requestId}] Invalid cron expression: ${validation.error}`)
201+
return NextResponse.json(
202+
{ error: `Invalid cron expression: ${validation.error}` },
203+
{ status: 400 }
204+
)
205+
}
206+
}
207+
195208
nextRunAt = calculateNextRunTime(defaultScheduleType, scheduleValues)
196209

197210
logger.debug(

apps/sim/lib/schedules/utils.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
getSubBlockValue,
1212
parseCronToHumanReadable,
1313
parseTimeString,
14+
validateCronExpression,
1415
} from '@/lib/schedules/utils'
1516

1617
describe('Schedule Utilities', () => {
@@ -102,6 +103,7 @@ describe('Schedule Utilities', () => {
102103
weeklyTime: [12, 0],
103104
monthlyDay: 15,
104105
monthlyTime: [14, 30],
106+
cronExpression: null,
105107
})
106108
})
107109

@@ -127,6 +129,7 @@ describe('Schedule Utilities', () => {
127129
weeklyTime: [9, 0], // Default
128130
monthlyDay: 1, // Default
129131
monthlyTime: [9, 0], // Default
132+
cronExpression: null,
130133
})
131134
})
132135
})
@@ -143,6 +146,7 @@ describe('Schedule Utilities', () => {
143146
monthlyDay: 15,
144147
monthlyTime: [14, 30] as [number, number],
145148
timezone: 'UTC',
149+
cronExpression: null,
146150
}
147151

148152
// Minutes (every 15 minutes)
@@ -196,6 +200,7 @@ describe('Schedule Utilities', () => {
196200
monthlyDay: 15,
197201
monthlyTime: [14, 30] as [number, number],
198202
timezone: 'UTC',
203+
cronExpression: null,
199204
}
200205

201206
expect(generateCronExpression('minutes', standardScheduleValues)).toBe('*/15 * * * *')
@@ -230,6 +235,7 @@ describe('Schedule Utilities', () => {
230235
weeklyTime: [9, 0] as [number, number],
231236
monthlyDay: 1,
232237
monthlyTime: [9, 0] as [number, number],
238+
cronExpression: null,
233239
}
234240

235241
const nextRun = calculateNextRunTime('minutes', scheduleValues)
@@ -254,6 +260,7 @@ describe('Schedule Utilities', () => {
254260
weeklyTime: [9, 0] as [number, number],
255261
monthlyDay: 1,
256262
monthlyTime: [9, 0] as [number, number],
263+
cronExpression: null,
257264
}
258265

259266
const nextRun = calculateNextRunTime('minutes', scheduleValues)
@@ -275,6 +282,7 @@ describe('Schedule Utilities', () => {
275282
weeklyTime: [9, 0] as [number, number],
276283
monthlyDay: 1,
277284
monthlyTime: [9, 0] as [number, number],
285+
cronExpression: null,
278286
}
279287

280288
const nextRun = calculateNextRunTime('hourly', scheduleValues)
@@ -297,6 +305,7 @@ describe('Schedule Utilities', () => {
297305
weeklyTime: [9, 0] as [number, number],
298306
monthlyDay: 1,
299307
monthlyTime: [9, 0] as [number, number],
308+
cronExpression: null,
300309
}
301310

302311
const nextRun = calculateNextRunTime('daily', scheduleValues)
@@ -320,6 +329,7 @@ describe('Schedule Utilities', () => {
320329
weeklyTime: [10, 0] as [number, number],
321330
monthlyDay: 1,
322331
monthlyTime: [9, 0] as [number, number],
332+
cronExpression: null,
323333
}
324334

325335
const nextRun = calculateNextRunTime('weekly', scheduleValues)
@@ -342,6 +352,7 @@ describe('Schedule Utilities', () => {
342352
weeklyTime: [9, 0] as [number, number],
343353
monthlyDay: 15,
344354
monthlyTime: [14, 30] as [number, number],
355+
cronExpression: null,
345356
}
346357

347358
const nextRun = calculateNextRunTime('monthly', scheduleValues)
@@ -366,6 +377,7 @@ describe('Schedule Utilities', () => {
366377
weeklyTime: [9, 0] as [number, number],
367378
monthlyDay: 1,
368379
monthlyTime: [9, 0] as [number, number],
380+
cronExpression: null,
369381
}
370382

371383
// Last ran 10 minutes ago
@@ -393,6 +405,7 @@ describe('Schedule Utilities', () => {
393405
weeklyTime: [9, 0] as [number, number],
394406
monthlyDay: 1,
395407
monthlyTime: [9, 0] as [number, number],
408+
cronExpression: null,
396409
}
397410

398411
const nextRun = calculateNextRunTime('minutes', scheduleValues)
@@ -413,6 +426,7 @@ describe('Schedule Utilities', () => {
413426
weeklyTime: [9, 0] as [number, number],
414427
monthlyDay: 1,
415428
monthlyTime: [9, 0] as [number, number],
429+
cronExpression: null,
416430
}
417431

418432
const nextRun = calculateNextRunTime('minutes', scheduleValues)
@@ -423,6 +437,50 @@ describe('Schedule Utilities', () => {
423437
})
424438
})
425439

440+
describe('validateCronExpression', () => {
441+
it.concurrent('should validate correct cron expressions', () => {
442+
expect(validateCronExpression('0 9 * * *')).toEqual({
443+
isValid: true,
444+
nextRun: expect.any(Date),
445+
})
446+
expect(validateCronExpression('*/15 * * * *')).toEqual({
447+
isValid: true,
448+
nextRun: expect.any(Date),
449+
})
450+
expect(validateCronExpression('30 14 15 * *')).toEqual({
451+
isValid: true,
452+
nextRun: expect.any(Date),
453+
})
454+
})
455+
456+
it.concurrent('should reject invalid cron expressions', () => {
457+
expect(validateCronExpression('invalid')).toEqual({
458+
isValid: false,
459+
error: expect.stringContaining('invalid'),
460+
})
461+
expect(validateCronExpression('60 * * * *')).toEqual({
462+
isValid: false,
463+
error: expect.any(String),
464+
})
465+
expect(validateCronExpression('')).toEqual({
466+
isValid: false,
467+
error: 'Cron expression cannot be empty',
468+
})
469+
expect(validateCronExpression(' ')).toEqual({
470+
isValid: false,
471+
error: 'Cron expression cannot be empty',
472+
})
473+
})
474+
475+
it.concurrent('should detect impossible cron expressions', () => {
476+
// This would be February 31st - impossible date
477+
expect(validateCronExpression('0 0 31 2 *')).toEqual({
478+
isValid: false,
479+
error: 'Cron expression produces no future occurrences',
480+
})
481+
})
482+
})
483+
426484
describe('parseCronToHumanReadable', () => {
427485
it.concurrent('should parse common cron patterns', () => {
428486
expect(parseCronToHumanReadable('* * * * *')).toBe('Every minute')

apps/sim/lib/schedules/utils.ts

Lines changed: 75 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,49 @@
1+
import { Cron } from 'croner'
12
import { createLogger } from '@/lib/logs/console-logger'
23
import { formatDateTime } from '@/lib/utils'
34

45
const logger = createLogger('ScheduleUtils')
56

7+
/**
8+
* Validates a cron expression and returns validation results
9+
* @param cronExpression - The cron expression to validate
10+
* @returns Validation result with isValid flag, error message, and next run date
11+
*/
12+
export function validateCronExpression(cronExpression: string): {
13+
isValid: boolean
14+
error?: string
15+
nextRun?: Date
16+
} {
17+
if (!cronExpression?.trim()) {
18+
return {
19+
isValid: false,
20+
error: 'Cron expression cannot be empty',
21+
}
22+
}
23+
24+
try {
25+
const cron = new Cron(cronExpression)
26+
const nextRun = cron.nextRun()
27+
28+
if (!nextRun) {
29+
return {
30+
isValid: false,
31+
error: 'Cron expression produces no future occurrences',
32+
}
33+
}
34+
35+
return {
36+
isValid: true,
37+
nextRun,
38+
}
39+
} catch (error) {
40+
return {
41+
isValid: false,
42+
error: error instanceof Error ? error.message : 'Invalid cron expression syntax',
43+
}
44+
}
45+
}
46+
647
export interface SubBlockValue {
748
value: string
849
}
@@ -60,6 +101,7 @@ export function getScheduleTimeValues(starterBlock: BlockState): {
60101
weeklyTime: [number, number]
61102
monthlyDay: number
62103
monthlyTime: [number, number]
104+
cronExpression: string | null
63105
timezone: string
64106
} {
65107
// Extract schedule time (common field that can override others)
@@ -92,6 +134,16 @@ export function getScheduleTimeValues(starterBlock: BlockState): {
92134
const monthlyDay = Number.parseInt(monthlyDayStr) || 1
93135
const monthlyTime = parseTimeString(getSubBlockValue(starterBlock, 'monthlyTime'))
94136

137+
const cronExpression = getSubBlockValue(starterBlock, 'cronExpression') || null
138+
139+
// Validate cron expression if provided
140+
if (cronExpression) {
141+
const validation = validateCronExpression(cronExpression)
142+
if (!validation.isValid) {
143+
throw new Error(`Invalid cron expression: ${validation.error}`)
144+
}
145+
}
146+
95147
return {
96148
scheduleTime,
97149
scheduleStartAt,
@@ -103,6 +155,7 @@ export function getScheduleTimeValues(starterBlock: BlockState): {
103155
weeklyTime,
104156
monthlyDay,
105157
monthlyTime,
158+
cronExpression,
106159
}
107160
}
108161

@@ -242,14 +295,10 @@ export function generateCronExpression(
242295
}
243296

244297
case 'custom': {
245-
const cronExpression = getSubBlockValue(
246-
scheduleValues as unknown as BlockState,
247-
'cronExpression'
248-
)
249-
if (!cronExpression) {
250-
throw new Error('No cron expression provided for custom schedule')
298+
if (!scheduleValues.cronExpression?.trim()) {
299+
throw new Error('Custom schedule requires a valid cron expression')
251300
}
252-
return cronExpression
301+
return scheduleValues.cronExpression
253302
}
254303

255304
default:
@@ -573,11 +622,29 @@ export const parseCronToHumanReadable = (cronExpression: string): string => {
573622
'November',
574623
'December',
575624
]
625+
576626
if (month.includes(',')) {
577627
const monthNames = month.split(',').map((m) => months[Number.parseInt(m, 10) - 1])
578628
description += `on day ${dayOfMonth} of ${monthNames.join(', ')}`
629+
} else if (month.includes('/')) {
630+
// Handle interval patterns like */3, 1/3, etc.
631+
const interval = month.split('/')[1]
632+
description += `on day ${dayOfMonth} every ${interval} months`
633+
} else if (month.includes('-')) {
634+
// Handle range patterns like 1-6
635+
const [start, end] = month.split('-').map((m) => Number.parseInt(m, 10))
636+
const startMonth = months[start - 1]
637+
const endMonth = months[end - 1]
638+
description += `on day ${dayOfMonth} from ${startMonth} to ${endMonth}`
579639
} else {
580-
description += `on day ${dayOfMonth} of ${months[Number.parseInt(month, 10) - 1]}`
640+
// Handle specific month numbers
641+
const monthIndex = Number.parseInt(month, 10) - 1
642+
const monthName = months[monthIndex]
643+
if (monthName) {
644+
description += `on day ${dayOfMonth} of ${monthName}`
645+
} else {
646+
description += `on day ${dayOfMonth} of month ${month}`
647+
}
581648
}
582649
} else if (dayOfWeek !== '*') {
583650
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']

0 commit comments

Comments
 (0)