mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
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>
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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']
|
||||
|
||||
Reference in New Issue
Block a user