diff --git a/apps/sim/app/api/schedules/route.ts b/apps/sim/app/api/schedules/route.ts index 1d6cec641a..6c4e4d0128 100644 --- a/apps/sim/app/api/schedules/route.ts +++ b/apps/sim/app/api/schedules/route.ts @@ -10,6 +10,7 @@ import { generateCronExpression, getScheduleTimeValues, getSubBlockValue, + validateCronExpression, } from '@/lib/schedules/utils' import { db } from '@/db' import { workflowSchedule } from '@/db/schema' @@ -192,6 +193,18 @@ export async function POST(req: NextRequest) { cronExpression = generateCronExpression(defaultScheduleType, scheduleValues) + // Additional validation for custom cron expressions + if (defaultScheduleType === 'custom' && cronExpression) { + const validation = validateCronExpression(cronExpression) + if (!validation.isValid) { + logger.error(`[${requestId}] Invalid cron expression: ${validation.error}`) + return NextResponse.json( + { error: `Invalid cron expression: ${validation.error}` }, + { status: 400 } + ) + } + } + nextRunAt = calculateNextRunTime(defaultScheduleType, scheduleValues) logger.debug( diff --git a/apps/sim/lib/schedules/utils.test.ts b/apps/sim/lib/schedules/utils.test.ts index d1fdc92584..e946237bec 100644 --- a/apps/sim/lib/schedules/utils.test.ts +++ b/apps/sim/lib/schedules/utils.test.ts @@ -11,6 +11,7 @@ import { getSubBlockValue, parseCronToHumanReadable, parseTimeString, + validateCronExpression, } from '@/lib/schedules/utils' describe('Schedule Utilities', () => { @@ -102,6 +103,7 @@ describe('Schedule Utilities', () => { weeklyTime: [12, 0], monthlyDay: 15, monthlyTime: [14, 30], + cronExpression: null, }) }) @@ -127,6 +129,7 @@ describe('Schedule Utilities', () => { weeklyTime: [9, 0], // Default monthlyDay: 1, // Default monthlyTime: [9, 0], // Default + cronExpression: null, }) }) }) @@ -143,6 +146,7 @@ describe('Schedule Utilities', () => { monthlyDay: 15, monthlyTime: [14, 30] as [number, number], timezone: 'UTC', + cronExpression: null, } // Minutes (every 15 minutes) @@ -196,6 +200,7 @@ describe('Schedule Utilities', () => { monthlyDay: 15, monthlyTime: [14, 30] as [number, number], timezone: 'UTC', + cronExpression: null, } expect(generateCronExpression('minutes', standardScheduleValues)).toBe('*/15 * * * *') @@ -230,6 +235,7 @@ describe('Schedule Utilities', () => { weeklyTime: [9, 0] as [number, number], monthlyDay: 1, monthlyTime: [9, 0] as [number, number], + cronExpression: null, } const nextRun = calculateNextRunTime('minutes', scheduleValues) @@ -254,6 +260,7 @@ describe('Schedule Utilities', () => { weeklyTime: [9, 0] as [number, number], monthlyDay: 1, monthlyTime: [9, 0] as [number, number], + cronExpression: null, } const nextRun = calculateNextRunTime('minutes', scheduleValues) @@ -275,6 +282,7 @@ describe('Schedule Utilities', () => { weeklyTime: [9, 0] as [number, number], monthlyDay: 1, monthlyTime: [9, 0] as [number, number], + cronExpression: null, } const nextRun = calculateNextRunTime('hourly', scheduleValues) @@ -297,6 +305,7 @@ describe('Schedule Utilities', () => { weeklyTime: [9, 0] as [number, number], monthlyDay: 1, monthlyTime: [9, 0] as [number, number], + cronExpression: null, } const nextRun = calculateNextRunTime('daily', scheduleValues) @@ -320,6 +329,7 @@ describe('Schedule Utilities', () => { weeklyTime: [10, 0] as [number, number], monthlyDay: 1, monthlyTime: [9, 0] as [number, number], + cronExpression: null, } const nextRun = calculateNextRunTime('weekly', scheduleValues) @@ -342,6 +352,7 @@ describe('Schedule Utilities', () => { weeklyTime: [9, 0] as [number, number], monthlyDay: 15, monthlyTime: [14, 30] as [number, number], + cronExpression: null, } const nextRun = calculateNextRunTime('monthly', scheduleValues) @@ -366,6 +377,7 @@ describe('Schedule Utilities', () => { weeklyTime: [9, 0] as [number, number], monthlyDay: 1, monthlyTime: [9, 0] as [number, number], + cronExpression: null, } // Last ran 10 minutes ago @@ -393,6 +405,7 @@ describe('Schedule Utilities', () => { weeklyTime: [9, 0] as [number, number], monthlyDay: 1, monthlyTime: [9, 0] as [number, number], + cronExpression: null, } const nextRun = calculateNextRunTime('minutes', scheduleValues) @@ -413,6 +426,7 @@ describe('Schedule Utilities', () => { weeklyTime: [9, 0] as [number, number], monthlyDay: 1, monthlyTime: [9, 0] as [number, number], + cronExpression: null, } const nextRun = calculateNextRunTime('minutes', scheduleValues) @@ -423,6 +437,50 @@ describe('Schedule Utilities', () => { }) }) + describe('validateCronExpression', () => { + it.concurrent('should validate correct cron expressions', () => { + expect(validateCronExpression('0 9 * * *')).toEqual({ + isValid: true, + nextRun: expect.any(Date), + }) + expect(validateCronExpression('*/15 * * * *')).toEqual({ + isValid: true, + nextRun: expect.any(Date), + }) + expect(validateCronExpression('30 14 15 * *')).toEqual({ + isValid: true, + nextRun: expect.any(Date), + }) + }) + + it.concurrent('should reject invalid cron expressions', () => { + expect(validateCronExpression('invalid')).toEqual({ + isValid: false, + error: expect.stringContaining('invalid'), + }) + expect(validateCronExpression('60 * * * *')).toEqual({ + isValid: false, + error: expect.any(String), + }) + expect(validateCronExpression('')).toEqual({ + isValid: false, + error: 'Cron expression cannot be empty', + }) + expect(validateCronExpression(' ')).toEqual({ + isValid: false, + error: 'Cron expression cannot be empty', + }) + }) + + it.concurrent('should detect impossible cron expressions', () => { + // This would be February 31st - impossible date + expect(validateCronExpression('0 0 31 2 *')).toEqual({ + isValid: false, + error: 'Cron expression produces no future occurrences', + }) + }) + }) + describe('parseCronToHumanReadable', () => { it.concurrent('should parse common cron patterns', () => { expect(parseCronToHumanReadable('* * * * *')).toBe('Every minute') diff --git a/apps/sim/lib/schedules/utils.ts b/apps/sim/lib/schedules/utils.ts index 73d60c1418..80a6d16625 100644 --- a/apps/sim/lib/schedules/utils.ts +++ b/apps/sim/lib/schedules/utils.ts @@ -1,8 +1,49 @@ +import { Cron } from 'croner' import { createLogger } from '@/lib/logs/console-logger' import { formatDateTime } from '@/lib/utils' const logger = createLogger('ScheduleUtils') +/** + * Validates a cron expression and returns validation results + * @param cronExpression - The cron expression to validate + * @returns Validation result with isValid flag, error message, and next run date + */ +export function validateCronExpression(cronExpression: string): { + isValid: boolean + error?: string + nextRun?: Date +} { + if (!cronExpression?.trim()) { + return { + isValid: false, + error: 'Cron expression cannot be empty', + } + } + + try { + const cron = new Cron(cronExpression) + const nextRun = cron.nextRun() + + if (!nextRun) { + return { + isValid: false, + error: 'Cron expression produces no future occurrences', + } + } + + return { + isValid: true, + nextRun, + } + } catch (error) { + return { + isValid: false, + error: error instanceof Error ? error.message : 'Invalid cron expression syntax', + } + } +} + export interface SubBlockValue { value: string } @@ -60,6 +101,7 @@ export function getScheduleTimeValues(starterBlock: BlockState): { weeklyTime: [number, number] monthlyDay: number monthlyTime: [number, number] + cronExpression: string | null timezone: string } { // Extract schedule time (common field that can override others) @@ -92,6 +134,16 @@ export function getScheduleTimeValues(starterBlock: BlockState): { const monthlyDay = Number.parseInt(monthlyDayStr) || 1 const monthlyTime = parseTimeString(getSubBlockValue(starterBlock, 'monthlyTime')) + const cronExpression = getSubBlockValue(starterBlock, 'cronExpression') || null + + // Validate cron expression if provided + if (cronExpression) { + const validation = validateCronExpression(cronExpression) + if (!validation.isValid) { + throw new Error(`Invalid cron expression: ${validation.error}`) + } + } + return { scheduleTime, scheduleStartAt, @@ -103,6 +155,7 @@ export function getScheduleTimeValues(starterBlock: BlockState): { weeklyTime, monthlyDay, monthlyTime, + cronExpression, } } @@ -242,14 +295,10 @@ export function generateCronExpression( } case 'custom': { - const cronExpression = getSubBlockValue( - scheduleValues as unknown as BlockState, - 'cronExpression' - ) - if (!cronExpression) { - throw new Error('No cron expression provided for custom schedule') + if (!scheduleValues.cronExpression?.trim()) { + throw new Error('Custom schedule requires a valid cron expression') } - return cronExpression + return scheduleValues.cronExpression } default: @@ -573,11 +622,29 @@ export const parseCronToHumanReadable = (cronExpression: string): string => { 'November', 'December', ] + if (month.includes(',')) { const monthNames = month.split(',').map((m) => months[Number.parseInt(m, 10) - 1]) description += `on day ${dayOfMonth} of ${monthNames.join(', ')}` + } else if (month.includes('/')) { + // Handle interval patterns like */3, 1/3, etc. + const interval = month.split('/')[1] + description += `on day ${dayOfMonth} every ${interval} months` + } else if (month.includes('-')) { + // Handle range patterns like 1-6 + const [start, end] = month.split('-').map((m) => Number.parseInt(m, 10)) + const startMonth = months[start - 1] + const endMonth = months[end - 1] + description += `on day ${dayOfMonth} from ${startMonth} to ${endMonth}` } else { - description += `on day ${dayOfMonth} of ${months[Number.parseInt(month, 10) - 1]}` + // Handle specific month numbers + const monthIndex = Number.parseInt(month, 10) - 1 + const monthName = months[monthIndex] + if (monthName) { + description += `on day ${dayOfMonth} of ${monthName}` + } else { + description += `on day ${dayOfMonth} of month ${month}` + } } } else if (dayOfWeek !== '*') { const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']