Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions apps/sim/app/api/schedules/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
generateCronExpression,
getScheduleTimeValues,
getSubBlockValue,
validateCronExpression,
} from '@/lib/schedules/utils'
import { db } from '@/db'
import { workflowSchedule } from '@/db/schema'
Expand Down Expand Up @@ -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(
Expand Down
58 changes: 58 additions & 0 deletions apps/sim/lib/schedules/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
getSubBlockValue,
parseCronToHumanReadable,
parseTimeString,
validateCronExpression,
} from '@/lib/schedules/utils'

describe('Schedule Utilities', () => {
Expand Down Expand Up @@ -102,6 +103,7 @@ describe('Schedule Utilities', () => {
weeklyTime: [12, 0],
monthlyDay: 15,
monthlyTime: [14, 30],
cronExpression: null,
})
})

Expand All @@ -127,6 +129,7 @@ describe('Schedule Utilities', () => {
weeklyTime: [9, 0], // Default
monthlyDay: 1, // Default
monthlyTime: [9, 0], // Default
cronExpression: null,
})
})
})
Expand All @@ -143,6 +146,7 @@ describe('Schedule Utilities', () => {
monthlyDay: 15,
monthlyTime: [14, 30] as [number, number],
timezone: 'UTC',
cronExpression: null,
}

// Minutes (every 15 minutes)
Expand Down Expand Up @@ -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 * * * *')
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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')
Expand Down
83 changes: 75 additions & 8 deletions apps/sim/lib/schedules/utils.ts
Original file line number Diff line number Diff line change
@@ -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
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -103,6 +155,7 @@ export function getScheduleTimeValues(starterBlock: BlockState): {
weeklyTime,
monthlyDay,
monthlyTime,
cronExpression,
}
}

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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']
Expand Down