chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,865 @@
|
||||
/**
|
||||
* Tests for schedule deploy utilities
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
mockInsert,
|
||||
mockDelete,
|
||||
mockOnConflictDoUpdate,
|
||||
mockValues,
|
||||
mockWhere,
|
||||
mockGenerateCronExpression,
|
||||
mockCalculateNextRunTime,
|
||||
mockValidateCronExpression,
|
||||
mockGetScheduleTimeValues,
|
||||
mockRandomUUID,
|
||||
mockTransaction,
|
||||
mockSelect,
|
||||
mockFrom,
|
||||
} = vi.hoisted(() => ({
|
||||
mockInsert: vi.fn(),
|
||||
mockDelete: vi.fn(),
|
||||
mockOnConflictDoUpdate: vi.fn(),
|
||||
mockValues: vi.fn(),
|
||||
mockWhere: vi.fn(),
|
||||
mockGenerateCronExpression: vi.fn(),
|
||||
mockCalculateNextRunTime: vi.fn(),
|
||||
mockValidateCronExpression: vi.fn(),
|
||||
mockGetScheduleTimeValues: vi.fn(),
|
||||
mockRandomUUID: vi.fn(),
|
||||
mockTransaction: vi.fn(),
|
||||
mockSelect: vi.fn(),
|
||||
mockFrom: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@sim/db', () => ({
|
||||
db: {
|
||||
transaction: mockTransaction,
|
||||
},
|
||||
workflowSchedule: {
|
||||
workflowId: 'workflow_id',
|
||||
blockId: 'block_id',
|
||||
deploymentVersionId: 'deployment_version_id',
|
||||
id: 'id',
|
||||
archivedAt: 'archived_at',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('drizzle-orm', () => ({
|
||||
eq: vi.fn((...args) => ({ type: 'eq', args })),
|
||||
and: vi.fn((...args) => ({ type: 'and', args })),
|
||||
inArray: vi.fn((...args) => ({ type: 'inArray', args })),
|
||||
isNull: vi.fn((...args) => ({ type: 'isNull', args })),
|
||||
sql: vi.fn((strings, ...values) => ({ type: 'sql', strings, values })),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/webhooks/deploy', () => ({
|
||||
cleanupWebhooksForWorkflow: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
vi.mock('./utils', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('./utils')>()
|
||||
return {
|
||||
...original,
|
||||
generateCronExpression: mockGenerateCronExpression,
|
||||
calculateNextRunTime: mockCalculateNextRunTime,
|
||||
validateCronExpression: mockValidateCronExpression,
|
||||
getScheduleTimeValues: mockGetScheduleTimeValues,
|
||||
}
|
||||
})
|
||||
|
||||
vi.stubGlobal('crypto', {
|
||||
randomUUID: mockRandomUUID,
|
||||
})
|
||||
|
||||
import { createSchedulesForDeploy, deleteSchedulesForWorkflow } from './deploy'
|
||||
import type { BlockState } from './utils'
|
||||
import { findScheduleBlocks, validateScheduleBlock, validateWorkflowSchedules } from './validation'
|
||||
|
||||
describe('Schedule Deploy Utilities', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
mockRandomUUID.mockReturnValue('test-uuid')
|
||||
mockGenerateCronExpression.mockReturnValue('0 9 * * *')
|
||||
mockCalculateNextRunTime.mockReturnValue(new Date('2025-04-15T09:00:00Z'))
|
||||
mockValidateCronExpression.mockReturnValue({ isValid: true, nextRun: new Date() })
|
||||
mockGetScheduleTimeValues.mockReturnValue({
|
||||
scheduleTime: '09:00',
|
||||
scheduleStartAt: '',
|
||||
timezone: 'UTC',
|
||||
minutesInterval: 15,
|
||||
hourlyMinute: 0,
|
||||
dailyTime: [9, 0],
|
||||
weeklyDay: 1,
|
||||
weeklyTime: [9, 0],
|
||||
monthlyDay: 1,
|
||||
monthlyTime: [9, 0],
|
||||
cronExpression: null,
|
||||
})
|
||||
|
||||
// Setup mock chain for insert
|
||||
mockOnConflictDoUpdate.mockResolvedValue({})
|
||||
mockValues.mockReturnValue({ onConflictDoUpdate: mockOnConflictDoUpdate })
|
||||
mockInsert.mockReturnValue({ values: mockValues })
|
||||
|
||||
// Setup mock chain for delete
|
||||
mockWhere.mockResolvedValue({})
|
||||
mockDelete.mockReturnValue({ where: mockWhere })
|
||||
|
||||
// Setup mock chain for select
|
||||
mockFrom.mockReturnValue({ where: vi.fn().mockResolvedValue([]) })
|
||||
mockSelect.mockReturnValue({ from: mockFrom })
|
||||
|
||||
// Setup transaction mock to execute callback with mock tx
|
||||
mockTransaction.mockImplementation(async (callback) => {
|
||||
const mockTx = {
|
||||
insert: mockInsert,
|
||||
delete: mockDelete,
|
||||
select: mockSelect,
|
||||
}
|
||||
return callback(mockTx)
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('findScheduleBlocks', () => {
|
||||
it('should find schedule blocks in a workflow', () => {
|
||||
const blocks: Record<string, BlockState> = {
|
||||
'block-1': { id: 'block-1', type: 'schedule', subBlocks: {} } as BlockState,
|
||||
'block-2': { id: 'block-2', type: 'agent', subBlocks: {} } as BlockState,
|
||||
'block-3': { id: 'block-3', type: 'schedule', subBlocks: {} } as BlockState,
|
||||
}
|
||||
|
||||
const result = findScheduleBlocks(blocks)
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result.map((b) => b.id)).toEqual(['block-1', 'block-3'])
|
||||
})
|
||||
|
||||
it('should return empty array when no schedule blocks exist', () => {
|
||||
const blocks: Record<string, BlockState> = {
|
||||
'block-1': { id: 'block-1', type: 'agent', subBlocks: {} } as BlockState,
|
||||
'block-2': { id: 'block-2', type: 'starter', subBlocks: {} } as BlockState,
|
||||
}
|
||||
|
||||
const result = findScheduleBlocks(blocks)
|
||||
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should handle empty blocks object', () => {
|
||||
const result = findScheduleBlocks({})
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should exclude disabled schedule blocks', () => {
|
||||
const blocks: Record<string, BlockState> = {
|
||||
'block-1': { id: 'block-1', type: 'schedule', enabled: true, subBlocks: {} } as BlockState,
|
||||
'block-2': { id: 'block-2', type: 'schedule', enabled: false, subBlocks: {} } as BlockState,
|
||||
'block-3': { id: 'block-3', type: 'schedule', subBlocks: {} } as BlockState, // enabled undefined = enabled
|
||||
}
|
||||
|
||||
const result = findScheduleBlocks(blocks)
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result.map((b) => b.id)).toEqual(['block-1', 'block-3'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateScheduleBlock', () => {
|
||||
describe('schedule type validation', () => {
|
||||
it('should fail when schedule type is missing', () => {
|
||||
const block: BlockState = {
|
||||
id: 'block-1',
|
||||
type: 'schedule',
|
||||
subBlocks: {},
|
||||
} as BlockState
|
||||
|
||||
const result = validateScheduleBlock(block)
|
||||
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toBe('Schedule type is required')
|
||||
})
|
||||
|
||||
it('should fail with empty schedule type', () => {
|
||||
const block: BlockState = {
|
||||
id: 'block-1',
|
||||
type: 'schedule',
|
||||
subBlocks: {
|
||||
scheduleType: { value: '' },
|
||||
},
|
||||
} as BlockState
|
||||
|
||||
const result = validateScheduleBlock(block)
|
||||
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toBe('Schedule type is required')
|
||||
})
|
||||
})
|
||||
|
||||
describe('minutes schedule validation', () => {
|
||||
it('should validate valid minutes interval', () => {
|
||||
const block: BlockState = {
|
||||
id: 'block-1',
|
||||
type: 'schedule',
|
||||
subBlocks: {
|
||||
scheduleType: { value: 'minutes' },
|
||||
minutesInterval: { value: '15' },
|
||||
timezone: { value: 'UTC' },
|
||||
},
|
||||
} as BlockState
|
||||
|
||||
const result = validateScheduleBlock(block)
|
||||
|
||||
expect(result.isValid).toBe(true)
|
||||
expect(result.cronExpression).toBeDefined()
|
||||
})
|
||||
|
||||
it('should fail with empty minutes interval', () => {
|
||||
const block: BlockState = {
|
||||
id: 'block-1',
|
||||
type: 'schedule',
|
||||
subBlocks: {
|
||||
scheduleType: { value: 'minutes' },
|
||||
minutesInterval: { value: '' },
|
||||
},
|
||||
} as BlockState
|
||||
|
||||
const result = validateScheduleBlock(block)
|
||||
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toBe('Minutes interval is required for minute-based schedules')
|
||||
})
|
||||
|
||||
it('should fail with invalid minutes interval (out of range)', () => {
|
||||
const block: BlockState = {
|
||||
id: 'block-1',
|
||||
type: 'schedule',
|
||||
subBlocks: {
|
||||
scheduleType: { value: 'minutes' },
|
||||
minutesInterval: { value: '0' },
|
||||
},
|
||||
} as BlockState
|
||||
|
||||
const result = validateScheduleBlock(block)
|
||||
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toBe('Minutes interval is required for minute-based schedules')
|
||||
})
|
||||
|
||||
it('should fail with minutes interval > 1440', () => {
|
||||
const block: BlockState = {
|
||||
id: 'block-1',
|
||||
type: 'schedule',
|
||||
subBlocks: {
|
||||
scheduleType: { value: 'minutes' },
|
||||
minutesInterval: { value: '1441' },
|
||||
},
|
||||
} as BlockState
|
||||
|
||||
const result = validateScheduleBlock(block)
|
||||
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toBe('Minutes interval is required for minute-based schedules')
|
||||
})
|
||||
})
|
||||
|
||||
describe('hourly schedule validation', () => {
|
||||
it('should validate valid hourly minute', () => {
|
||||
const block: BlockState = {
|
||||
id: 'block-1',
|
||||
type: 'schedule',
|
||||
subBlocks: {
|
||||
scheduleType: { value: 'hourly' },
|
||||
hourlyMinute: { value: '30' },
|
||||
timezone: { value: 'UTC' },
|
||||
},
|
||||
} as BlockState
|
||||
|
||||
const result = validateScheduleBlock(block)
|
||||
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
|
||||
it('should validate hourly minute of 0', () => {
|
||||
const block: BlockState = {
|
||||
id: 'block-1',
|
||||
type: 'schedule',
|
||||
subBlocks: {
|
||||
scheduleType: { value: 'hourly' },
|
||||
hourlyMinute: { value: '0' },
|
||||
timezone: { value: 'UTC' },
|
||||
},
|
||||
} as BlockState
|
||||
|
||||
const result = validateScheduleBlock(block)
|
||||
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
|
||||
it('should fail with empty hourly minute', () => {
|
||||
const block: BlockState = {
|
||||
id: 'block-1',
|
||||
type: 'schedule',
|
||||
subBlocks: {
|
||||
scheduleType: { value: 'hourly' },
|
||||
hourlyMinute: { value: '' },
|
||||
},
|
||||
} as BlockState
|
||||
|
||||
const result = validateScheduleBlock(block)
|
||||
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toBe('Minute value is required for hourly schedules')
|
||||
})
|
||||
|
||||
it('should fail with hourly minute > 59', () => {
|
||||
const block: BlockState = {
|
||||
id: 'block-1',
|
||||
type: 'schedule',
|
||||
subBlocks: {
|
||||
scheduleType: { value: 'hourly' },
|
||||
hourlyMinute: { value: '60' },
|
||||
},
|
||||
} as BlockState
|
||||
|
||||
const result = validateScheduleBlock(block)
|
||||
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toBe('Minute value is required for hourly schedules')
|
||||
})
|
||||
})
|
||||
|
||||
describe('daily schedule validation', () => {
|
||||
it('should validate valid daily time', () => {
|
||||
const block: BlockState = {
|
||||
id: 'block-1',
|
||||
type: 'schedule',
|
||||
subBlocks: {
|
||||
scheduleType: { value: 'daily' },
|
||||
dailyTime: { value: '09:30' },
|
||||
timezone: { value: 'America/New_York' },
|
||||
},
|
||||
} as BlockState
|
||||
|
||||
const result = validateScheduleBlock(block)
|
||||
|
||||
expect(result.isValid).toBe(true)
|
||||
expect(result.timezone).toBe('America/New_York')
|
||||
})
|
||||
|
||||
it('should fail with empty daily time', () => {
|
||||
const block: BlockState = {
|
||||
id: 'block-1',
|
||||
type: 'schedule',
|
||||
subBlocks: {
|
||||
scheduleType: { value: 'daily' },
|
||||
dailyTime: { value: '' },
|
||||
},
|
||||
} as BlockState
|
||||
|
||||
const result = validateScheduleBlock(block)
|
||||
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toBe('Time is required for daily schedules')
|
||||
})
|
||||
|
||||
it('should fail with invalid time format (no colon)', () => {
|
||||
const block: BlockState = {
|
||||
id: 'block-1',
|
||||
type: 'schedule',
|
||||
subBlocks: {
|
||||
scheduleType: { value: 'daily' },
|
||||
dailyTime: { value: '0930' },
|
||||
},
|
||||
} as BlockState
|
||||
|
||||
const result = validateScheduleBlock(block)
|
||||
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toBe('Time is required for daily schedules')
|
||||
})
|
||||
})
|
||||
|
||||
describe('weekly schedule validation', () => {
|
||||
it('should validate valid weekly configuration', () => {
|
||||
const block: BlockState = {
|
||||
id: 'block-1',
|
||||
type: 'schedule',
|
||||
subBlocks: {
|
||||
scheduleType: { value: 'weekly' },
|
||||
weeklyDay: { value: 'MON' },
|
||||
weeklyDayTime: { value: '10:00' },
|
||||
timezone: { value: 'UTC' },
|
||||
},
|
||||
} as BlockState
|
||||
|
||||
const result = validateScheduleBlock(block)
|
||||
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
|
||||
it('should fail with missing day', () => {
|
||||
const block: BlockState = {
|
||||
id: 'block-1',
|
||||
type: 'schedule',
|
||||
subBlocks: {
|
||||
scheduleType: { value: 'weekly' },
|
||||
weeklyDay: { value: '' },
|
||||
weeklyDayTime: { value: '10:00' },
|
||||
},
|
||||
} as BlockState
|
||||
|
||||
const result = validateScheduleBlock(block)
|
||||
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toBe('Day and time are required for weekly schedules')
|
||||
})
|
||||
|
||||
it('should fail with missing time', () => {
|
||||
const block: BlockState = {
|
||||
id: 'block-1',
|
||||
type: 'schedule',
|
||||
subBlocks: {
|
||||
scheduleType: { value: 'weekly' },
|
||||
weeklyDay: { value: 'MON' },
|
||||
weeklyDayTime: { value: '' },
|
||||
},
|
||||
} as BlockState
|
||||
|
||||
const result = validateScheduleBlock(block)
|
||||
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toBe('Day and time are required for weekly schedules')
|
||||
})
|
||||
})
|
||||
|
||||
describe('monthly schedule validation', () => {
|
||||
it('should validate valid monthly configuration', () => {
|
||||
const block: BlockState = {
|
||||
id: 'block-1',
|
||||
type: 'schedule',
|
||||
subBlocks: {
|
||||
scheduleType: { value: 'monthly' },
|
||||
monthlyDay: { value: '15' },
|
||||
monthlyTime: { value: '14:30' },
|
||||
timezone: { value: 'UTC' },
|
||||
},
|
||||
} as BlockState
|
||||
|
||||
const result = validateScheduleBlock(block)
|
||||
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
|
||||
it('should fail with day out of range (0)', () => {
|
||||
const block: BlockState = {
|
||||
id: 'block-1',
|
||||
type: 'schedule',
|
||||
subBlocks: {
|
||||
scheduleType: { value: 'monthly' },
|
||||
monthlyDay: { value: '0' },
|
||||
monthlyTime: { value: '14:30' },
|
||||
},
|
||||
} as BlockState
|
||||
|
||||
const result = validateScheduleBlock(block)
|
||||
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toBe('Day and time are required for monthly schedules')
|
||||
})
|
||||
|
||||
it('should fail with day out of range (32)', () => {
|
||||
const block: BlockState = {
|
||||
id: 'block-1',
|
||||
type: 'schedule',
|
||||
subBlocks: {
|
||||
scheduleType: { value: 'monthly' },
|
||||
monthlyDay: { value: '32' },
|
||||
monthlyTime: { value: '14:30' },
|
||||
},
|
||||
} as BlockState
|
||||
|
||||
const result = validateScheduleBlock(block)
|
||||
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toBe('Day and time are required for monthly schedules')
|
||||
})
|
||||
|
||||
it('should fail with missing time', () => {
|
||||
const block: BlockState = {
|
||||
id: 'block-1',
|
||||
type: 'schedule',
|
||||
subBlocks: {
|
||||
scheduleType: { value: 'monthly' },
|
||||
monthlyDay: { value: '15' },
|
||||
monthlyTime: { value: '' },
|
||||
},
|
||||
} as BlockState
|
||||
|
||||
const result = validateScheduleBlock(block)
|
||||
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toBe('Day and time are required for monthly schedules')
|
||||
})
|
||||
})
|
||||
|
||||
describe('custom cron schedule validation', () => {
|
||||
it('should validate valid custom cron expression', () => {
|
||||
mockGetScheduleTimeValues.mockReturnValue({
|
||||
scheduleTime: '',
|
||||
scheduleStartAt: '',
|
||||
timezone: 'UTC',
|
||||
minutesInterval: 15,
|
||||
hourlyMinute: 0,
|
||||
dailyTime: [9, 0],
|
||||
weeklyDay: 1,
|
||||
weeklyTime: [9, 0],
|
||||
monthlyDay: 1,
|
||||
monthlyTime: [9, 0],
|
||||
cronExpression: '*/5 * * * *',
|
||||
})
|
||||
|
||||
const block: BlockState = {
|
||||
id: 'block-1',
|
||||
type: 'schedule',
|
||||
subBlocks: {
|
||||
scheduleType: { value: 'custom' },
|
||||
cronExpression: { value: '*/5 * * * *' },
|
||||
timezone: { value: 'UTC' },
|
||||
},
|
||||
} as BlockState
|
||||
|
||||
const result = validateScheduleBlock(block)
|
||||
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
|
||||
it('should fail with empty cron expression', () => {
|
||||
const block: BlockState = {
|
||||
id: 'block-1',
|
||||
type: 'schedule',
|
||||
subBlocks: {
|
||||
scheduleType: { value: 'custom' },
|
||||
cronExpression: { value: '' },
|
||||
},
|
||||
} as BlockState
|
||||
|
||||
const result = validateScheduleBlock(block)
|
||||
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toBe('Cron expression is required for custom schedules')
|
||||
})
|
||||
})
|
||||
|
||||
describe('invalid cron expression handling', () => {
|
||||
it('should fail when generated cron is invalid', () => {
|
||||
mockValidateCronExpression.mockReturnValue({
|
||||
isValid: false,
|
||||
error: 'Invalid minute value',
|
||||
})
|
||||
|
||||
const block: BlockState = {
|
||||
id: 'block-1',
|
||||
type: 'schedule',
|
||||
subBlocks: {
|
||||
scheduleType: { value: 'daily' },
|
||||
dailyTime: { value: '09:00' },
|
||||
timezone: { value: 'UTC' },
|
||||
},
|
||||
} as BlockState
|
||||
|
||||
const result = validateScheduleBlock(block)
|
||||
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('Invalid cron expression')
|
||||
})
|
||||
|
||||
it('should handle exceptions during cron generation', () => {
|
||||
mockGenerateCronExpression.mockImplementation(() => {
|
||||
throw new Error('Failed to parse schedule type')
|
||||
})
|
||||
|
||||
const block: BlockState = {
|
||||
id: 'block-1',
|
||||
type: 'schedule',
|
||||
subBlocks: {
|
||||
scheduleType: { value: 'daily' },
|
||||
dailyTime: { value: '09:00' },
|
||||
timezone: { value: 'UTC' },
|
||||
},
|
||||
} as BlockState
|
||||
|
||||
const result = validateScheduleBlock(block)
|
||||
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toBe('Failed to parse schedule type')
|
||||
})
|
||||
})
|
||||
|
||||
describe('timezone handling', () => {
|
||||
it('should use UTC as default timezone', () => {
|
||||
const block: BlockState = {
|
||||
id: 'block-1',
|
||||
type: 'schedule',
|
||||
subBlocks: {
|
||||
scheduleType: { value: 'daily' },
|
||||
dailyTime: { value: '09:00' },
|
||||
},
|
||||
} as BlockState
|
||||
|
||||
const result = validateScheduleBlock(block)
|
||||
|
||||
expect(result.isValid).toBe(true)
|
||||
expect(result.timezone).toBe('UTC')
|
||||
})
|
||||
|
||||
it('should use specified timezone', () => {
|
||||
const block: BlockState = {
|
||||
id: 'block-1',
|
||||
type: 'schedule',
|
||||
subBlocks: {
|
||||
scheduleType: { value: 'daily' },
|
||||
dailyTime: { value: '09:00' },
|
||||
timezone: { value: 'Asia/Tokyo' },
|
||||
},
|
||||
} as BlockState
|
||||
|
||||
const result = validateScheduleBlock(block)
|
||||
|
||||
expect(result.isValid).toBe(true)
|
||||
expect(result.timezone).toBe('Asia/Tokyo')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateWorkflowSchedules', () => {
|
||||
it('should return valid for workflows without schedule blocks', () => {
|
||||
const blocks: Record<string, BlockState> = {
|
||||
'block-1': { id: 'block-1', type: 'agent', subBlocks: {} } as BlockState,
|
||||
}
|
||||
|
||||
const result = validateWorkflowSchedules(blocks)
|
||||
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
|
||||
it('should validate all schedule blocks', () => {
|
||||
const blocks: Record<string, BlockState> = {
|
||||
'block-1': {
|
||||
id: 'block-1',
|
||||
type: 'schedule',
|
||||
subBlocks: {
|
||||
scheduleType: { value: 'daily' },
|
||||
dailyTime: { value: '09:00' },
|
||||
timezone: { value: 'UTC' },
|
||||
},
|
||||
} as BlockState,
|
||||
'block-2': {
|
||||
id: 'block-2',
|
||||
type: 'schedule',
|
||||
subBlocks: {
|
||||
scheduleType: { value: 'hourly' },
|
||||
hourlyMinute: { value: '30' },
|
||||
timezone: { value: 'UTC' },
|
||||
},
|
||||
} as BlockState,
|
||||
}
|
||||
|
||||
const result = validateWorkflowSchedules(blocks)
|
||||
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
|
||||
it('should return first validation error found', () => {
|
||||
const blocks: Record<string, BlockState> = {
|
||||
'block-1': {
|
||||
id: 'block-1',
|
||||
type: 'schedule',
|
||||
subBlocks: {
|
||||
scheduleType: { value: 'daily' },
|
||||
dailyTime: { value: '09:00' },
|
||||
timezone: { value: 'UTC' },
|
||||
},
|
||||
} as BlockState,
|
||||
'block-2': {
|
||||
id: 'block-2',
|
||||
type: 'schedule',
|
||||
subBlocks: {
|
||||
scheduleType: { value: 'daily' },
|
||||
dailyTime: { value: '' }, // Invalid - missing time
|
||||
},
|
||||
} as BlockState,
|
||||
}
|
||||
|
||||
const result = validateWorkflowSchedules(blocks)
|
||||
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toBe('Time is required for daily schedules')
|
||||
})
|
||||
})
|
||||
|
||||
describe('createSchedulesForDeploy', () => {
|
||||
const setupMockTransaction = (
|
||||
existingSchedules: Array<{ id: string; blockId: string }> = []
|
||||
) => {
|
||||
mockFrom.mockReturnValue({ where: vi.fn().mockResolvedValue(existingSchedules) })
|
||||
mockSelect.mockReturnValue({ from: mockFrom })
|
||||
}
|
||||
|
||||
it('should return success with no schedule blocks', async () => {
|
||||
const blocks: Record<string, BlockState> = {
|
||||
'block-1': { id: 'block-1', type: 'agent', subBlocks: {} } as BlockState,
|
||||
}
|
||||
|
||||
setupMockTransaction()
|
||||
|
||||
const result = await createSchedulesForDeploy('workflow-1', blocks)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(mockTransaction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should create schedule for valid schedule block', async () => {
|
||||
const blocks: Record<string, BlockState> = {
|
||||
'block-1': {
|
||||
id: 'block-1',
|
||||
type: 'schedule',
|
||||
subBlocks: {
|
||||
scheduleType: { value: 'daily' },
|
||||
dailyTime: { value: '09:00' },
|
||||
timezone: { value: 'UTC' },
|
||||
},
|
||||
} as BlockState,
|
||||
}
|
||||
|
||||
setupMockTransaction()
|
||||
|
||||
const result = await createSchedulesForDeploy('workflow-1', blocks)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.scheduleId).toBe('test-uuid')
|
||||
expect(result.cronExpression).toBe('0 9 * * *')
|
||||
expect(result.nextRunAt).toEqual(new Date('2025-04-15T09:00:00Z'))
|
||||
expect(mockTransaction).toHaveBeenCalled()
|
||||
expect(mockInsert).toHaveBeenCalled()
|
||||
expect(mockOnConflictDoUpdate).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return error for invalid schedule block', async () => {
|
||||
const blocks: Record<string, BlockState> = {
|
||||
'block-1': {
|
||||
id: 'block-1',
|
||||
type: 'schedule',
|
||||
subBlocks: {
|
||||
scheduleType: { value: 'daily' },
|
||||
dailyTime: { value: '' }, // Invalid
|
||||
},
|
||||
} as BlockState,
|
||||
}
|
||||
|
||||
setupMockTransaction()
|
||||
|
||||
const result = await createSchedulesForDeploy('workflow-1', blocks)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe('Time is required for daily schedules')
|
||||
expect(mockTransaction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should write through a provided transaction without opening a new one', async () => {
|
||||
const blocks: Record<string, BlockState> = {
|
||||
'block-1': {
|
||||
id: 'block-1',
|
||||
type: 'schedule',
|
||||
subBlocks: {
|
||||
scheduleType: { value: 'daily' },
|
||||
dailyTime: { value: '09:00' },
|
||||
timezone: { value: 'UTC' },
|
||||
},
|
||||
} as BlockState,
|
||||
}
|
||||
|
||||
setupMockTransaction()
|
||||
const callerTx = { insert: mockInsert, delete: mockDelete, select: mockSelect } as any
|
||||
|
||||
const result = await createSchedulesForDeploy('workflow-1', blocks, callerTx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(mockTransaction).not.toHaveBeenCalled()
|
||||
expect(mockInsert).toHaveBeenCalled()
|
||||
expect(mockOnConflictDoUpdate).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should use onConflictDoUpdate for existing schedules', async () => {
|
||||
const blocks: Record<string, BlockState> = {
|
||||
'block-1': {
|
||||
id: 'block-1',
|
||||
type: 'schedule',
|
||||
subBlocks: {
|
||||
scheduleType: { value: 'minutes' },
|
||||
minutesInterval: { value: '30' },
|
||||
timezone: { value: 'UTC' },
|
||||
},
|
||||
} as BlockState,
|
||||
}
|
||||
|
||||
setupMockTransaction()
|
||||
|
||||
await createSchedulesForDeploy('workflow-1', blocks)
|
||||
|
||||
expect(mockOnConflictDoUpdate).toHaveBeenCalledWith({
|
||||
target: expect.any(Array),
|
||||
targetWhere: expect.objectContaining({ type: 'isNull' }),
|
||||
set: expect.objectContaining({
|
||||
blockId: 'block-1',
|
||||
cronExpression: '0 9 * * *',
|
||||
status: 'active',
|
||||
failedCount: 0,
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
it('should rollback on database error', async () => {
|
||||
const blocks: Record<string, BlockState> = {
|
||||
'block-1': {
|
||||
id: 'block-1',
|
||||
type: 'schedule',
|
||||
subBlocks: {
|
||||
scheduleType: { value: 'daily' },
|
||||
dailyTime: { value: '09:00' },
|
||||
timezone: { value: 'UTC' },
|
||||
},
|
||||
} as BlockState,
|
||||
}
|
||||
|
||||
mockTransaction.mockRejectedValueOnce(new Error('Database error'))
|
||||
|
||||
const result = await createSchedulesForDeploy('workflow-1', blocks)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe('Database error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteSchedulesForWorkflow', () => {
|
||||
it('should delete all schedules for a workflow', async () => {
|
||||
const mockTx = {
|
||||
insert: mockInsert,
|
||||
delete: mockDelete,
|
||||
}
|
||||
|
||||
await deleteSchedulesForWorkflow('workflow-1', mockTx as any)
|
||||
|
||||
expect(mockDelete).toHaveBeenCalled()
|
||||
expect(mockWhere).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,234 @@
|
||||
import { db, workflowSchedule } from '@sim/db'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { and, eq, inArray, isNull } from 'drizzle-orm'
|
||||
import type { DbOrTx } from '@/lib/db/types'
|
||||
import { cleanupWebhooksForWorkflow } from '@/lib/webhooks/deploy'
|
||||
import type { BlockState } from '@/lib/workflows/schedules/utils'
|
||||
import { findScheduleBlocks, validateScheduleBlock } from '@/lib/workflows/schedules/validation'
|
||||
|
||||
const logger = createLogger('ScheduleDeployUtils')
|
||||
|
||||
/**
|
||||
* Result of schedule creation during deploy
|
||||
*/
|
||||
export interface ScheduleDeployResult {
|
||||
success: boolean
|
||||
error?: string
|
||||
scheduleId?: string
|
||||
cronExpression?: string
|
||||
nextRunAt?: Date
|
||||
timezone?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update schedule records for a workflow during deployment.
|
||||
* Atomic either way: writes run inside the caller's transaction when `tx`
|
||||
* is provided, otherwise inside a transaction opened here.
|
||||
*/
|
||||
export async function createSchedulesForDeploy(
|
||||
workflowId: string,
|
||||
blocks: Record<string, BlockState>,
|
||||
tx?: DbOrTx,
|
||||
deploymentVersionId?: string
|
||||
): Promise<ScheduleDeployResult> {
|
||||
const scheduleBlocks = findScheduleBlocks(blocks)
|
||||
|
||||
if (scheduleBlocks.length === 0) {
|
||||
logger.info(`No schedule blocks found in workflow ${workflowId}`)
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
// Phase 1: Validate ALL blocks before making any DB changes
|
||||
const validatedBlocks: Array<{
|
||||
blockId: string
|
||||
cronExpression: string
|
||||
nextRunAt: Date
|
||||
timezone: string
|
||||
}> = []
|
||||
|
||||
for (const block of scheduleBlocks) {
|
||||
const blockId = block.id as string
|
||||
const validation = validateScheduleBlock(block)
|
||||
if (!validation.isValid) {
|
||||
return {
|
||||
success: false,
|
||||
error: validation.error,
|
||||
}
|
||||
}
|
||||
validatedBlocks.push({
|
||||
blockId,
|
||||
cronExpression: validation.cronExpression!,
|
||||
nextRunAt: validation.nextRunAt!,
|
||||
timezone: validation.timezone!,
|
||||
})
|
||||
}
|
||||
|
||||
// Phase 2: All validations passed - now do DB operations in a transaction
|
||||
let lastScheduleInfo: {
|
||||
scheduleId: string
|
||||
cronExpression?: string
|
||||
nextRunAt?: Date
|
||||
timezone?: string
|
||||
} | null = null
|
||||
|
||||
try {
|
||||
const writeSchedules = async (trx: DbOrTx) => {
|
||||
const currentBlockIds = new Set(validatedBlocks.map((b) => b.blockId))
|
||||
|
||||
const existingSchedules = await trx
|
||||
.select({ id: workflowSchedule.id, blockId: workflowSchedule.blockId })
|
||||
.from(workflowSchedule)
|
||||
.where(
|
||||
deploymentVersionId
|
||||
? and(
|
||||
eq(workflowSchedule.workflowId, workflowId),
|
||||
eq(workflowSchedule.deploymentVersionId, deploymentVersionId),
|
||||
isNull(workflowSchedule.archivedAt)
|
||||
)
|
||||
: and(eq(workflowSchedule.workflowId, workflowId), isNull(workflowSchedule.archivedAt))
|
||||
)
|
||||
|
||||
const orphanedScheduleIds = existingSchedules
|
||||
.filter((s) => s.blockId && !currentBlockIds.has(s.blockId))
|
||||
.map((s) => s.id)
|
||||
|
||||
if (orphanedScheduleIds.length > 0) {
|
||||
logger.info(
|
||||
`Deleting ${orphanedScheduleIds.length} orphaned schedule(s) for workflow ${workflowId}`
|
||||
)
|
||||
await trx.delete(workflowSchedule).where(inArray(workflowSchedule.id, orphanedScheduleIds))
|
||||
}
|
||||
|
||||
for (const validated of validatedBlocks) {
|
||||
const { blockId, cronExpression, nextRunAt, timezone } = validated
|
||||
const scheduleId = generateId()
|
||||
const now = new Date()
|
||||
|
||||
const values = {
|
||||
id: scheduleId,
|
||||
workflowId,
|
||||
deploymentVersionId: deploymentVersionId || null,
|
||||
blockId,
|
||||
cronExpression,
|
||||
triggerType: 'schedule',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
nextRunAt,
|
||||
timezone,
|
||||
status: 'active',
|
||||
failedCount: 0,
|
||||
infraRetryCount: 0,
|
||||
}
|
||||
|
||||
const setValues = {
|
||||
blockId,
|
||||
cronExpression,
|
||||
...(deploymentVersionId ? { deploymentVersionId } : {}),
|
||||
updatedAt: now,
|
||||
nextRunAt,
|
||||
timezone,
|
||||
status: 'active',
|
||||
failedCount: 0,
|
||||
infraRetryCount: 0,
|
||||
}
|
||||
|
||||
await trx
|
||||
.insert(workflowSchedule)
|
||||
.values(values)
|
||||
.onConflictDoUpdate({
|
||||
target: [
|
||||
workflowSchedule.workflowId,
|
||||
workflowSchedule.blockId,
|
||||
workflowSchedule.deploymentVersionId,
|
||||
],
|
||||
targetWhere: isNull(workflowSchedule.archivedAt),
|
||||
set: setValues,
|
||||
})
|
||||
|
||||
logger.info(`Schedule created/updated for workflow ${workflowId}, block ${blockId}`, {
|
||||
scheduleId: values.id,
|
||||
cronExpression,
|
||||
nextRunAt: nextRunAt?.toISOString(),
|
||||
})
|
||||
|
||||
lastScheduleInfo = { scheduleId: values.id, cronExpression, nextRunAt, timezone }
|
||||
}
|
||||
}
|
||||
|
||||
// The global client is not a transaction — wrap it so the atomicity
|
||||
// contract holds even if a caller passes `db` explicitly.
|
||||
await (tx && tx !== db ? writeSchedules(tx) : db.transaction(writeSchedules))
|
||||
} catch (error) {
|
||||
logger.error(`Failed to create schedules for workflow ${workflowId}`, error)
|
||||
return {
|
||||
success: false,
|
||||
error: getErrorMessage(error, 'Failed to create schedules'),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
...(lastScheduleInfo ?? {}),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all schedules for a workflow
|
||||
* This should be called within a database transaction during undeploy
|
||||
*/
|
||||
export async function deleteSchedulesForWorkflow(
|
||||
workflowId: string,
|
||||
tx: DbOrTx,
|
||||
deploymentVersionId?: string
|
||||
): Promise<void> {
|
||||
await tx
|
||||
.delete(workflowSchedule)
|
||||
.where(
|
||||
deploymentVersionId
|
||||
? and(
|
||||
eq(workflowSchedule.workflowId, workflowId),
|
||||
eq(workflowSchedule.deploymentVersionId, deploymentVersionId),
|
||||
isNull(workflowSchedule.archivedAt)
|
||||
)
|
||||
: and(eq(workflowSchedule.workflowId, workflowId), isNull(workflowSchedule.archivedAt))
|
||||
)
|
||||
|
||||
logger.info(
|
||||
deploymentVersionId
|
||||
? `Deleted schedules for workflow ${workflowId} deployment ${deploymentVersionId}`
|
||||
: `Deleted all schedules for workflow ${workflowId}`
|
||||
)
|
||||
}
|
||||
|
||||
async function cleanupDeploymentVersion(params: {
|
||||
workflowId: string
|
||||
workflow: Record<string, unknown>
|
||||
requestId: string
|
||||
deploymentVersionId: string
|
||||
/**
|
||||
* If true, skip external subscription cleanup (already done by saveTriggerWebhooksForDeploy).
|
||||
* Only deletes DB records.
|
||||
*/
|
||||
skipExternalCleanup?: boolean
|
||||
strictExternalCleanup?: boolean
|
||||
}): Promise<void> {
|
||||
const {
|
||||
workflowId,
|
||||
workflow,
|
||||
requestId,
|
||||
deploymentVersionId,
|
||||
skipExternalCleanup = false,
|
||||
strictExternalCleanup = false,
|
||||
} = params
|
||||
await cleanupWebhooksForWorkflow(
|
||||
workflowId,
|
||||
workflow,
|
||||
requestId,
|
||||
deploymentVersionId,
|
||||
skipExternalCleanup,
|
||||
strictExternalCleanup
|
||||
)
|
||||
await deleteSchedulesForWorkflow(workflowId, db, deploymentVersionId)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { env, envNumber } from '@/lib/core/config/env'
|
||||
|
||||
export const SCHEDULE_EXECUTION_QUEUE_NAME = 'schedule-execution'
|
||||
|
||||
export const SCHEDULE_EXECUTION_CONCURRENCY_LIMIT = envNumber(
|
||||
env.SCHEDULE_EXECUTION_CONCURRENCY_LIMIT,
|
||||
30,
|
||||
{ min: 1, integer: true }
|
||||
)
|
||||
|
||||
export const SCHEDULE_ENQUEUE_BUDGET_MULTIPLIER = envNumber(
|
||||
env.SCHEDULE_ENQUEUE_BUDGET_MULTIPLIER,
|
||||
2,
|
||||
{ min: 1, integer: true }
|
||||
)
|
||||
|
||||
export const SCHEDULE_WORKFLOW_ENQUEUE_LIMIT =
|
||||
SCHEDULE_EXECUTION_CONCURRENCY_LIMIT * SCHEDULE_ENQUEUE_BUDGET_MULTIPLIER
|
||||
|
||||
export const SCHEDULE_JITTER_MAX_MS = envNumber(env.SCHEDULE_JITTER_MAX_MS, 30_000, {
|
||||
min: 0,
|
||||
integer: true,
|
||||
})
|
||||
|
||||
export const SCHEDULE_INFRA_RETRY_BASE_MS = envNumber(env.SCHEDULE_INFRA_RETRY_BASE_MS, 60_000, {
|
||||
min: 1,
|
||||
integer: true,
|
||||
})
|
||||
|
||||
export const SCHEDULE_INFRA_RETRY_MAX_MS = envNumber(env.SCHEDULE_INFRA_RETRY_MAX_MS, 5 * 60_000, {
|
||||
min: 1,
|
||||
integer: true,
|
||||
})
|
||||
|
||||
export const SCHEDULE_INFRA_RETRY_MAX_ATTEMPTS = envNumber(
|
||||
env.SCHEDULE_INFRA_RETRY_MAX_ATTEMPTS,
|
||||
10,
|
||||
{
|
||||
min: 1,
|
||||
integer: true,
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
createSchedulesForDeploy,
|
||||
deleteSchedulesForWorkflow,
|
||||
} from './deploy'
|
||||
export { validateWorkflowSchedules } from './validation'
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
mockSelect,
|
||||
mockUpdate,
|
||||
mockUpdateSet,
|
||||
mockUpdateWhere,
|
||||
mockRecordAudit,
|
||||
mockCaptureServerEvent,
|
||||
} = vi.hoisted(() => ({
|
||||
mockSelect: vi.fn(),
|
||||
mockUpdate: vi.fn(),
|
||||
mockUpdateSet: vi.fn(),
|
||||
mockUpdateWhere: vi.fn(),
|
||||
mockRecordAudit: vi.fn(),
|
||||
mockCaptureServerEvent: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@sim/audit', () => ({
|
||||
AuditAction: { SCHEDULE_UPDATED: 'SCHEDULE_UPDATED' },
|
||||
AuditResourceType: { SCHEDULE: 'SCHEDULE' },
|
||||
recordAudit: mockRecordAudit,
|
||||
}))
|
||||
|
||||
vi.mock('@sim/db', () => ({
|
||||
db: {
|
||||
select: mockSelect,
|
||||
update: mockUpdate,
|
||||
},
|
||||
workflowSchedule: {
|
||||
id: 'id',
|
||||
sourceWorkspaceId: 'sourceWorkspaceId',
|
||||
sourceType: 'sourceType',
|
||||
archivedAt: 'archivedAt',
|
||||
timezone: 'timezone',
|
||||
status: 'status',
|
||||
cronExpression: 'cronExpression',
|
||||
jobTitle: 'jobTitle',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@sim/logger', () => ({
|
||||
createLogger: () => ({
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('drizzle-orm', () => ({
|
||||
and: vi.fn(),
|
||||
eq: vi.fn(),
|
||||
isNull: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/posthog/server', () => ({
|
||||
captureServerEvent: mockCaptureServerEvent,
|
||||
}))
|
||||
|
||||
import { performUpdateJob } from '@/lib/workflows/schedules/orchestration'
|
||||
|
||||
const BASE_JOB = {
|
||||
id: 'job-1',
|
||||
sourceWorkspaceId: 'workspace-1',
|
||||
sourceType: 'job',
|
||||
archivedAt: null,
|
||||
timezone: 'UTC',
|
||||
cronExpression: null,
|
||||
jobTitle: 'Nightly task',
|
||||
status: 'disabled',
|
||||
}
|
||||
|
||||
function mockExistingJob(job: typeof BASE_JOB) {
|
||||
mockSelect.mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockReturnValue([job]),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
describe('performUpdateJob', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUpdateSet.mockReturnValue({ where: mockUpdateWhere })
|
||||
mockUpdate.mockReturnValue({ set: mockUpdateSet })
|
||||
mockUpdateWhere.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('does not schedule a next run when editing time on a disabled job', async () => {
|
||||
mockExistingJob({ ...BASE_JOB, status: 'disabled' })
|
||||
|
||||
const result = await performUpdateJob({
|
||||
jobId: 'job-1',
|
||||
workspaceId: 'workspace-1',
|
||||
userId: 'user-1',
|
||||
time: '2099-01-01T09:00:00Z',
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(mockUpdateSet).toHaveBeenCalledTimes(1)
|
||||
expect(mockUpdateSet.mock.calls[0][0]).not.toHaveProperty('nextRunAt')
|
||||
})
|
||||
|
||||
it('schedules the next run when editing time on an active job', async () => {
|
||||
mockExistingJob({ ...BASE_JOB, status: 'active' })
|
||||
|
||||
const result = await performUpdateJob({
|
||||
jobId: 'job-1',
|
||||
workspaceId: 'workspace-1',
|
||||
userId: 'user-1',
|
||||
time: '2099-01-01T09:00:00Z',
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(mockUpdateSet).toHaveBeenCalledTimes(1)
|
||||
expect(mockUpdateSet.mock.calls[0][0]).toMatchObject({
|
||||
nextRunAt: new Date('2099-01-01T09:00:00Z'),
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,560 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db, workflowSchedule } from '@sim/db'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { and, eq, isNull } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import type { ScheduleContext } from '@/lib/api/contracts/schedules'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import {
|
||||
computeNextRunAt,
|
||||
parseCronToHumanReadable,
|
||||
validateCronExpression,
|
||||
} from '@/lib/workflows/schedules/utils'
|
||||
|
||||
const logger = createLogger('ScheduleOrchestration')
|
||||
|
||||
type ScheduleErrorCode = 'not_found' | 'validation' | 'internal'
|
||||
|
||||
interface ActorMetadata {
|
||||
actorName?: string | null
|
||||
actorEmail?: string | null
|
||||
request?: NextRequest
|
||||
}
|
||||
|
||||
export interface PerformCreateJobParams extends ActorMetadata {
|
||||
workspaceId: string
|
||||
userId: string
|
||||
title?: string | null
|
||||
prompt: string
|
||||
cronExpression?: string | null
|
||||
time?: string | null
|
||||
timezone: string
|
||||
lifecycle?: 'persistent' | 'until_complete'
|
||||
successCondition?: string | null
|
||||
maxRuns?: number | null
|
||||
startDate?: string | null
|
||||
/** Recurrence end on a date (ISO 8601); the schedule completes once its next run would fall after this. */
|
||||
endsAt?: string | null
|
||||
/** `@`-mentioned resources / `/`-invoked skills captured with the prompt. */
|
||||
contexts?: ScheduleContext[] | null
|
||||
sourceChatId?: string | null
|
||||
sourceTaskName?: string | null
|
||||
}
|
||||
|
||||
export interface PerformScheduleResult {
|
||||
success: boolean
|
||||
error?: string
|
||||
errorCode?: ScheduleErrorCode
|
||||
schedule?: typeof workflowSchedule.$inferSelect
|
||||
humanReadable?: string
|
||||
updatedFields?: string[]
|
||||
alreadyCompleted?: boolean
|
||||
}
|
||||
|
||||
export interface PerformUpdateJobParams extends ActorMetadata {
|
||||
jobId: string
|
||||
workspaceId: string
|
||||
userId: string
|
||||
title?: string
|
||||
prompt?: string
|
||||
cronExpression?: string | null
|
||||
time?: string | null
|
||||
timezone?: string
|
||||
status?: string
|
||||
lifecycle?: string
|
||||
successCondition?: string | null
|
||||
maxRuns?: number | null
|
||||
endsAt?: string | null
|
||||
contexts?: ScheduleContext[] | null
|
||||
}
|
||||
|
||||
export interface PerformExcludeOccurrenceParams extends ActorMetadata {
|
||||
jobId: string
|
||||
workspaceId: string
|
||||
userId: string
|
||||
/** The exact occurrence instant to skip (ISO 8601), as produced by the recurrence. */
|
||||
occurrence: string
|
||||
}
|
||||
|
||||
export interface PerformDeleteJobParams extends ActorMetadata {
|
||||
jobId: string
|
||||
workspaceId: string
|
||||
userId: string
|
||||
}
|
||||
|
||||
export interface PerformCompleteJobParams extends ActorMetadata {
|
||||
jobId: string
|
||||
workspaceId: string
|
||||
userId: string
|
||||
}
|
||||
|
||||
const activeJobCondition = (jobId: string, workspaceId: string) =>
|
||||
and(
|
||||
eq(workflowSchedule.id, jobId),
|
||||
eq(workflowSchedule.sourceWorkspaceId, workspaceId),
|
||||
eq(workflowSchedule.sourceType, 'job'),
|
||||
isNull(workflowSchedule.archivedAt)
|
||||
)
|
||||
|
||||
function parseOneTimeRun(time: string, timezone: string): Date | null {
|
||||
let timeStr = time
|
||||
const hasOffset = /[Zz]|[+-]\d{2}(:\d{2})?$/.test(timeStr)
|
||||
if (!hasOffset && timezone !== 'UTC') {
|
||||
try {
|
||||
const formatter = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: timezone,
|
||||
timeZoneName: 'shortOffset',
|
||||
})
|
||||
const parts = formatter.formatToParts(new Date())
|
||||
const offsetPart = parts.find((part) => part.type === 'timeZoneName')
|
||||
const match = offsetPart?.value.match(/GMT([+-]\d{1,2}(?::\d{2})?)/)
|
||||
if (match) {
|
||||
const [rawHours, rawMinutes = '00'] = match[1].split(':')
|
||||
const sign = rawHours.startsWith('-') ? '-' : '+'
|
||||
const hour = Number(rawHours.replace(/^[+-]/, ''))
|
||||
if (Number.isFinite(hour)) {
|
||||
const offset = `${sign}${String(hour).padStart(2, '0')}:${rawMinutes.padStart(2, '0')}`
|
||||
timeStr = `${timeStr}${offset}`
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const parsed = new Date(timeStr)
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed
|
||||
}
|
||||
|
||||
export async function performCreateJob(
|
||||
params: PerformCreateJobParams
|
||||
): Promise<PerformScheduleResult> {
|
||||
if (!params.prompt.trim()) {
|
||||
return { success: false, error: 'prompt is required', errorCode: 'validation' }
|
||||
}
|
||||
|
||||
const cronExpression = params.cronExpression || null
|
||||
if (!cronExpression && !params.time) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'At least one of cronExpression or time must be provided',
|
||||
errorCode: 'validation',
|
||||
}
|
||||
}
|
||||
|
||||
let endsAt: Date | null = null
|
||||
if (params.endsAt) {
|
||||
const parsedEndsAt = new Date(params.endsAt)
|
||||
if (Number.isNaN(parsedEndsAt.getTime())) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Invalid endsAt value: ${params.endsAt}`,
|
||||
errorCode: 'validation',
|
||||
}
|
||||
}
|
||||
endsAt = parsedEndsAt
|
||||
}
|
||||
|
||||
let nextRunAt: Date | null = null
|
||||
if (cronExpression) {
|
||||
const validation = validateCronExpression(cronExpression, params.timezone)
|
||||
if (!validation.isValid) {
|
||||
return {
|
||||
success: false,
|
||||
error: validation.error || 'Invalid cron expression',
|
||||
errorCode: 'validation',
|
||||
}
|
||||
}
|
||||
nextRunAt = computeNextRunAt({ cronExpression, timezone: params.timezone, endsAt })
|
||||
}
|
||||
|
||||
if (params.time) {
|
||||
const parsed = parseOneTimeRun(params.time, params.timezone)
|
||||
if (!parsed) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Invalid time value: ${params.time}`,
|
||||
errorCode: 'validation',
|
||||
}
|
||||
}
|
||||
if (!cronExpression || parsed > new Date()) nextRunAt = parsed
|
||||
}
|
||||
|
||||
if (params.startDate) {
|
||||
const start = new Date(params.startDate)
|
||||
if (start > new Date()) nextRunAt = start
|
||||
}
|
||||
|
||||
if (!nextRunAt) {
|
||||
return { success: false, error: 'Could not determine next run time', errorCode: 'validation' }
|
||||
}
|
||||
|
||||
try {
|
||||
const id = generateId()
|
||||
const now = new Date()
|
||||
await db.insert(workflowSchedule).values({
|
||||
id,
|
||||
workflowId: null,
|
||||
cronExpression,
|
||||
triggerType: 'schedule',
|
||||
sourceType: 'job',
|
||||
status: 'active',
|
||||
timezone: params.timezone,
|
||||
nextRunAt,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
failedCount: 0,
|
||||
jobTitle: params.title?.trim() || null,
|
||||
prompt: params.prompt.trim(),
|
||||
lifecycle: params.lifecycle || 'persistent',
|
||||
successCondition: params.successCondition || null,
|
||||
maxRuns: params.maxRuns ?? null,
|
||||
runCount: 0,
|
||||
contexts: params.contexts ?? null,
|
||||
excludedDates: null,
|
||||
endsAt,
|
||||
sourceChatId: params.sourceChatId || null,
|
||||
sourceTaskName: params.sourceTaskName || null,
|
||||
sourceUserId: params.userId,
|
||||
sourceWorkspaceId: params.workspaceId,
|
||||
})
|
||||
|
||||
const [schedule] = await db
|
||||
.select()
|
||||
.from(workflowSchedule)
|
||||
.where(eq(workflowSchedule.id, id))
|
||||
.limit(1)
|
||||
|
||||
const humanReadable = cronExpression
|
||||
? parseCronToHumanReadable(cronExpression, params.timezone)
|
||||
: `Once at ${nextRunAt.toISOString()}`
|
||||
|
||||
recordAudit({
|
||||
workspaceId: params.workspaceId,
|
||||
actorId: params.userId,
|
||||
actorName: params.actorName ?? undefined,
|
||||
actorEmail: params.actorEmail ?? undefined,
|
||||
action: AuditAction.SCHEDULE_CREATED,
|
||||
resourceType: AuditResourceType.SCHEDULE,
|
||||
resourceId: id,
|
||||
resourceName: params.title?.trim() || undefined,
|
||||
description: `Created job schedule "${params.title?.trim() || id}"`,
|
||||
metadata: {
|
||||
cronExpression,
|
||||
timezone: params.timezone,
|
||||
lifecycle: params.lifecycle || 'persistent',
|
||||
maxRuns: params.maxRuns ?? null,
|
||||
},
|
||||
request: params.request,
|
||||
})
|
||||
|
||||
captureServerEvent(
|
||||
params.userId,
|
||||
'scheduled_task_created',
|
||||
{ workspace_id: params.workspaceId },
|
||||
{ groups: { workspace: params.workspaceId } }
|
||||
)
|
||||
|
||||
if (schedule?.workflowId) {
|
||||
captureServerEvent(
|
||||
params.userId,
|
||||
'workflow_schedule_created',
|
||||
{ workflow_id: schedule.workflowId, workspace_id: params.workspaceId },
|
||||
{ groups: { workspace: params.workspaceId } }
|
||||
)
|
||||
}
|
||||
|
||||
return { success: true, schedule, humanReadable }
|
||||
} catch (error) {
|
||||
logger.error('Failed to create job', { error: toError(error).message })
|
||||
return { success: false, error: 'Failed to create job', errorCode: 'internal' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function performUpdateJob(
|
||||
params: PerformUpdateJobParams
|
||||
): Promise<PerformScheduleResult> {
|
||||
try {
|
||||
const [job] = await db
|
||||
.select()
|
||||
.from(workflowSchedule)
|
||||
.where(activeJobCondition(params.jobId, params.workspaceId))
|
||||
.limit(1)
|
||||
|
||||
if (!job)
|
||||
return { success: false, error: `Job not found: ${params.jobId}`, errorCode: 'not_found' }
|
||||
|
||||
const updates: Partial<typeof workflowSchedule.$inferInsert> = { updatedAt: new Date() }
|
||||
if (params.title !== undefined) updates.jobTitle = params.title.trim()
|
||||
if (params.prompt !== undefined) updates.prompt = params.prompt.trim()
|
||||
if (params.timezone !== undefined) updates.timezone = params.timezone
|
||||
if (params.status !== undefined) {
|
||||
if (!['active', 'paused', 'disabled'].includes(params.status)) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'status must be "active" or "paused"',
|
||||
errorCode: 'validation',
|
||||
}
|
||||
}
|
||||
updates.status = params.status === 'paused' ? 'disabled' : params.status
|
||||
}
|
||||
if (params.lifecycle !== undefined) {
|
||||
if (params.lifecycle !== 'persistent' && params.lifecycle !== 'until_complete') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'lifecycle must be "persistent" or "until_complete"',
|
||||
errorCode: 'validation',
|
||||
}
|
||||
}
|
||||
updates.lifecycle = params.lifecycle
|
||||
if (params.lifecycle === 'persistent') updates.maxRuns = null
|
||||
}
|
||||
if (params.successCondition !== undefined) updates.successCondition = params.successCondition
|
||||
if (params.maxRuns !== undefined) updates.maxRuns = params.maxRuns
|
||||
if (params.contexts !== undefined) updates.contexts = params.contexts
|
||||
const effectiveStatus = updates.status ?? job.status
|
||||
|
||||
let endsAt: Date | null = job.endsAt
|
||||
if (params.endsAt !== undefined) {
|
||||
if (params.endsAt === null) {
|
||||
endsAt = null
|
||||
} else {
|
||||
const parsedEndsAt = new Date(params.endsAt)
|
||||
if (Number.isNaN(parsedEndsAt.getTime())) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Invalid endsAt value: ${params.endsAt}`,
|
||||
errorCode: 'validation',
|
||||
}
|
||||
}
|
||||
endsAt = parsedEndsAt
|
||||
}
|
||||
updates.endsAt = endsAt
|
||||
}
|
||||
|
||||
if (params.cronExpression !== undefined && params.cronExpression !== null) {
|
||||
const timezone = params.timezone || job.timezone || 'UTC'
|
||||
const validation = validateCronExpression(params.cronExpression, timezone)
|
||||
if (!validation.isValid) {
|
||||
return {
|
||||
success: false,
|
||||
error: validation.error || 'Invalid cron expression',
|
||||
errorCode: 'validation',
|
||||
}
|
||||
}
|
||||
updates.cronExpression = params.cronExpression
|
||||
if (effectiveStatus === 'active') {
|
||||
updates.nextRunAt = computeNextRunAt({
|
||||
cronExpression: params.cronExpression,
|
||||
timezone,
|
||||
excludedDates: job.excludedDates,
|
||||
endsAt,
|
||||
})
|
||||
}
|
||||
} else if (params.cronExpression === null) {
|
||||
updates.cronExpression = null
|
||||
}
|
||||
if (params.time !== undefined && params.time !== null) {
|
||||
const timezone = params.timezone || job.timezone || 'UTC'
|
||||
const parsed = parseOneTimeRun(params.time, timezone)
|
||||
if (!parsed) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Invalid time value: ${params.time}`,
|
||||
errorCode: 'validation',
|
||||
}
|
||||
}
|
||||
const cronExpression =
|
||||
params.cronExpression !== undefined ? params.cronExpression : job.cronExpression
|
||||
if (effectiveStatus === 'active' && (!cronExpression || parsed > new Date())) {
|
||||
updates.nextRunAt = parsed
|
||||
}
|
||||
}
|
||||
|
||||
const updatedFields = Object.keys(updates).filter((key) => key !== 'updatedAt')
|
||||
|
||||
await db
|
||||
.update(workflowSchedule)
|
||||
.set(updates)
|
||||
.where(and(eq(workflowSchedule.id, params.jobId), isNull(workflowSchedule.archivedAt)))
|
||||
|
||||
recordAudit({
|
||||
workspaceId: params.workspaceId,
|
||||
actorId: params.userId,
|
||||
actorName: params.actorName ?? undefined,
|
||||
actorEmail: params.actorEmail ?? undefined,
|
||||
action: AuditAction.SCHEDULE_UPDATED,
|
||||
resourceType: AuditResourceType.SCHEDULE,
|
||||
resourceId: params.jobId,
|
||||
resourceName: job.jobTitle ?? undefined,
|
||||
description: `Updated job schedule "${job.jobTitle ?? params.jobId}"`,
|
||||
metadata: { operation: 'update', updatedFields },
|
||||
request: params.request,
|
||||
})
|
||||
|
||||
return { success: true, updatedFields }
|
||||
} catch (error) {
|
||||
logger.error('Failed to update job', { error: toError(error).message })
|
||||
return { success: false, error: 'Failed to update job', errorCode: 'internal' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function performExcludeOccurrence(
|
||||
params: PerformExcludeOccurrenceParams
|
||||
): Promise<PerformScheduleResult> {
|
||||
const occurrence = new Date(params.occurrence)
|
||||
if (Number.isNaN(occurrence.getTime())) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Invalid occurrence value: ${params.occurrence}`,
|
||||
errorCode: 'validation',
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const [job] = await db
|
||||
.select()
|
||||
.from(workflowSchedule)
|
||||
.where(activeJobCondition(params.jobId, params.workspaceId))
|
||||
.limit(1)
|
||||
|
||||
if (!job)
|
||||
return { success: false, error: `Job not found: ${params.jobId}`, errorCode: 'not_found' }
|
||||
if (!job.cronExpression) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Only recurring tasks have individual occurrences to delete',
|
||||
errorCode: 'validation',
|
||||
}
|
||||
}
|
||||
|
||||
const occurrenceIso = occurrence.toISOString()
|
||||
const excludedDates = Array.from(new Set([...(job.excludedDates ?? []), occurrenceIso]))
|
||||
|
||||
const updates: Partial<typeof workflowSchedule.$inferInsert> = {
|
||||
excludedDates,
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
|
||||
if (job.nextRunAt && job.nextRunAt.getTime() === occurrence.getTime()) {
|
||||
const nextRunAt = computeNextRunAt({
|
||||
cronExpression: job.cronExpression,
|
||||
timezone: job.timezone || 'UTC',
|
||||
from: occurrence,
|
||||
excludedDates,
|
||||
endsAt: job.endsAt,
|
||||
})
|
||||
updates.nextRunAt = nextRunAt
|
||||
if (!nextRunAt) updates.status = 'completed'
|
||||
}
|
||||
|
||||
await db
|
||||
.update(workflowSchedule)
|
||||
.set(updates)
|
||||
.where(and(eq(workflowSchedule.id, params.jobId), isNull(workflowSchedule.archivedAt)))
|
||||
|
||||
recordAudit({
|
||||
workspaceId: params.workspaceId,
|
||||
actorId: params.userId,
|
||||
actorName: params.actorName ?? undefined,
|
||||
actorEmail: params.actorEmail ?? undefined,
|
||||
action: AuditAction.SCHEDULE_UPDATED,
|
||||
resourceType: AuditResourceType.SCHEDULE,
|
||||
resourceId: params.jobId,
|
||||
resourceName: job.jobTitle ?? undefined,
|
||||
description: `Deleted one occurrence of job "${job.jobTitle ?? params.jobId}"`,
|
||||
metadata: { operation: 'exclude_occurrence', occurrence: occurrenceIso },
|
||||
request: params.request,
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
logger.error('Failed to exclude occurrence', { error: toError(error).message })
|
||||
return { success: false, error: 'Failed to delete occurrence', errorCode: 'internal' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function performDeleteJob(
|
||||
params: PerformDeleteJobParams
|
||||
): Promise<PerformScheduleResult> {
|
||||
const [job] = await db
|
||||
.select()
|
||||
.from(workflowSchedule)
|
||||
.where(activeJobCondition(params.jobId, params.workspaceId))
|
||||
.limit(1)
|
||||
|
||||
if (!job)
|
||||
return { success: false, error: `Job not found: ${params.jobId}`, errorCode: 'not_found' }
|
||||
|
||||
await db.delete(workflowSchedule).where(eq(workflowSchedule.id, params.jobId))
|
||||
recordAudit({
|
||||
workspaceId: params.workspaceId,
|
||||
actorId: params.userId,
|
||||
actorName: params.actorName ?? undefined,
|
||||
actorEmail: params.actorEmail ?? undefined,
|
||||
action: AuditAction.SCHEDULE_DELETED,
|
||||
resourceType: AuditResourceType.SCHEDULE,
|
||||
resourceId: params.jobId,
|
||||
resourceName: job.jobTitle ?? undefined,
|
||||
description: `Deleted job "${job.jobTitle ?? params.jobId}"`,
|
||||
metadata: {
|
||||
sourceType: job.sourceType,
|
||||
cronExpression: job.cronExpression,
|
||||
timezone: job.timezone,
|
||||
},
|
||||
request: params.request,
|
||||
})
|
||||
|
||||
captureServerEvent(
|
||||
params.userId,
|
||||
'scheduled_task_deleted',
|
||||
{ workspace_id: params.workspaceId },
|
||||
{ groups: { workspace: params.workspaceId } }
|
||||
)
|
||||
|
||||
if (job.workflowId) {
|
||||
captureServerEvent(
|
||||
params.userId,
|
||||
'workflow_schedule_deleted',
|
||||
{ workflow_id: job.workflowId, workspace_id: params.workspaceId },
|
||||
{ groups: { workspace: params.workspaceId } }
|
||||
)
|
||||
}
|
||||
|
||||
return { success: true, schedule: job }
|
||||
}
|
||||
|
||||
export async function performCompleteJob(
|
||||
params: PerformCompleteJobParams
|
||||
): Promise<PerformScheduleResult> {
|
||||
const [job] = await db
|
||||
.select()
|
||||
.from(workflowSchedule)
|
||||
.where(activeJobCondition(params.jobId, params.workspaceId))
|
||||
.limit(1)
|
||||
|
||||
if (!job)
|
||||
return { success: false, error: `Job not found: ${params.jobId}`, errorCode: 'not_found' }
|
||||
if (job.status === 'completed') return { success: true, schedule: job, alreadyCompleted: true }
|
||||
|
||||
const [updatedJob] = await db
|
||||
.update(workflowSchedule)
|
||||
.set({ status: 'completed', nextRunAt: null, updatedAt: new Date() })
|
||||
.where(and(eq(workflowSchedule.id, params.jobId), isNull(workflowSchedule.archivedAt)))
|
||||
.returning()
|
||||
|
||||
recordAudit({
|
||||
workspaceId: params.workspaceId,
|
||||
actorId: params.userId,
|
||||
actorName: params.actorName ?? undefined,
|
||||
actorEmail: params.actorEmail ?? undefined,
|
||||
action: AuditAction.SCHEDULE_UPDATED,
|
||||
resourceType: AuditResourceType.SCHEDULE,
|
||||
resourceId: params.jobId,
|
||||
description: 'Completed job',
|
||||
metadata: { operation: 'complete' },
|
||||
request: params.request,
|
||||
})
|
||||
|
||||
return { success: true, schedule: updatedJob, alreadyCompleted: false }
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,682 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage, toError } from '@sim/utils/errors'
|
||||
import { formatDateTime, getTimezoneAbbreviation } from '@sim/utils/formatting'
|
||||
import { Cron } from 'croner'
|
||||
import cronstrue from 'cronstrue'
|
||||
|
||||
const logger = createLogger('ScheduleUtils')
|
||||
|
||||
/**
|
||||
* Validates a cron expression and returns validation results
|
||||
* @param cronExpression - The cron expression to validate
|
||||
* @param timezone - Optional IANA timezone string (e.g., 'America/Los_Angeles'). Defaults to 'UTC'
|
||||
* @returns Validation result with isValid flag, error message, and next run date
|
||||
*/
|
||||
export function validateCronExpression(
|
||||
cronExpression: string,
|
||||
timezone?: 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, timezone ? { timezone } : undefined)
|
||||
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: getErrorMessage(error, 'Invalid cron expression syntax'),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Upper bound on how many excluded occurrences the next-run search skips before giving up. */
|
||||
const MAX_OCCURRENCE_SKIP = 1000
|
||||
|
||||
/**
|
||||
* Computes the next run instant for a recurring schedule, skipping occurrences
|
||||
* the user deleted individually and stopping at the recurrence end boundary.
|
||||
* Returns `null` when the recurrence has no remaining run (past `endsAt`, or
|
||||
* every candidate within the search bound is excluded).
|
||||
*
|
||||
* Excluded occurrences are matched by exact instant, so callers must record the
|
||||
* cron-produced occurrence time (not a rounded value) when excluding.
|
||||
*/
|
||||
export function computeNextRunAt(params: {
|
||||
cronExpression: string
|
||||
timezone?: string
|
||||
from?: Date
|
||||
excludedDates?: string[] | null
|
||||
endsAt?: Date | null
|
||||
}): Date | null {
|
||||
const { cronExpression, timezone, from, excludedDates, endsAt } = params
|
||||
let cron: Cron
|
||||
try {
|
||||
cron = new Cron(cronExpression, timezone ? { timezone } : undefined)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
const excluded = new Set(
|
||||
(excludedDates ?? []).map((iso) => new Date(iso).getTime()).filter((ms) => !Number.isNaN(ms))
|
||||
)
|
||||
|
||||
let cursor = from ?? new Date()
|
||||
for (let i = 0; i < MAX_OCCURRENCE_SKIP; i++) {
|
||||
const next = cron.nextRun(cursor)
|
||||
if (!next) return null
|
||||
if (endsAt && next.getTime() > endsAt.getTime()) return null
|
||||
if (!excluded.has(next.getTime())) return next
|
||||
cursor = next
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
interface SubBlockValue {
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface BlockState {
|
||||
type: string
|
||||
subBlocks: Record<string, SubBlockValue | any>
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export const DAY_MAP: Record<string, number> = {
|
||||
MON: 1,
|
||||
TUE: 2,
|
||||
WED: 3,
|
||||
THU: 4,
|
||||
FRI: 5,
|
||||
SAT: 6,
|
||||
SUN: 0,
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely extract a value from a block's subBlocks
|
||||
*/
|
||||
export function getSubBlockValue(block: BlockState, id: string): string {
|
||||
const subBlock = block.subBlocks[id] as SubBlockValue | undefined
|
||||
return subBlock?.value || ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and extract hours and minutes from a time string
|
||||
* @param timeString - Time string in format "HH:MM"
|
||||
* @returns Array with [hours, minutes] as numbers, or [9, 0] as default
|
||||
*/
|
||||
export function parseTimeString(timeString: string | undefined | null): [number, number] {
|
||||
if (!timeString || !timeString.includes(':')) {
|
||||
return [9, 0] // Default to 9:00 AM
|
||||
}
|
||||
|
||||
const [hours, minutes] = timeString.split(':').map(Number)
|
||||
return [Number.isNaN(hours) ? 9 : hours, Number.isNaN(minutes) ? 0 : minutes]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get time values from starter block for scheduling
|
||||
* @param starterBlock - The starter block containing schedule configuration
|
||||
* @returns Object with parsed time values
|
||||
*/
|
||||
export function getScheduleTimeValues(starterBlock: BlockState): {
|
||||
scheduleTime: string
|
||||
scheduleStartAt?: string
|
||||
minutesInterval: number
|
||||
hourlyMinute: number
|
||||
dailyTime: [number, number]
|
||||
weeklyDay: number
|
||||
weeklyTime: [number, number]
|
||||
monthlyDay: number
|
||||
monthlyTime: [number, number]
|
||||
cronExpression: string | null
|
||||
timezone: string
|
||||
} {
|
||||
// Extract schedule time (common field that can override others)
|
||||
const scheduleTime = getSubBlockValue(starterBlock, 'scheduleTime')
|
||||
|
||||
// Extract schedule start date
|
||||
const scheduleStartAt = getSubBlockValue(starterBlock, 'scheduleStartAt')
|
||||
|
||||
// Extract timezone (default to UTC)
|
||||
const timezone = getSubBlockValue(starterBlock, 'timezone') || 'UTC'
|
||||
|
||||
// Get minutes interval (default to 15)
|
||||
const minutesIntervalStr = getSubBlockValue(starterBlock, 'minutesInterval')
|
||||
const minutesInterval = Number.parseInt(minutesIntervalStr) || 15
|
||||
|
||||
// Get hourly minute (default to 0)
|
||||
const hourlyMinuteStr = getSubBlockValue(starterBlock, 'hourlyMinute')
|
||||
const hourlyMinute = Number.parseInt(hourlyMinuteStr) || 0
|
||||
|
||||
// Get daily time
|
||||
const dailyTime = parseTimeString(getSubBlockValue(starterBlock, 'dailyTime'))
|
||||
|
||||
// Get weekly config
|
||||
const weeklyDayStr = getSubBlockValue(starterBlock, 'weeklyDay') || 'MON'
|
||||
const weeklyDay = DAY_MAP[weeklyDayStr] || 1
|
||||
const weeklyTime = parseTimeString(getSubBlockValue(starterBlock, 'weeklyDayTime'))
|
||||
|
||||
// Get monthly config
|
||||
const monthlyDayStr = getSubBlockValue(starterBlock, 'monthlyDay')
|
||||
const monthlyDay = Number.parseInt(monthlyDayStr) || 1
|
||||
const monthlyTime = parseTimeString(getSubBlockValue(starterBlock, 'monthlyTime'))
|
||||
|
||||
const cronExpression = getSubBlockValue(starterBlock, 'cronExpression') || null
|
||||
|
||||
return {
|
||||
scheduleTime,
|
||||
scheduleStartAt,
|
||||
timezone,
|
||||
minutesInterval,
|
||||
hourlyMinute,
|
||||
dailyTime,
|
||||
weeklyDay,
|
||||
weeklyTime,
|
||||
monthlyDay,
|
||||
monthlyTime,
|
||||
cronExpression,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to create a date with the specified time in the correct timezone.
|
||||
* This function calculates the corresponding UTC time for a given local date,
|
||||
* local time, and IANA timezone name, correctly handling DST.
|
||||
*
|
||||
* @param dateInput Date string or Date object representing the local date.
|
||||
* @param timeStr Time string in format "HH:MM" representing the local time.
|
||||
* @param timezone IANA timezone string (e.g., 'America/Los_Angeles', 'Europe/Paris'). Defaults to 'UTC'.
|
||||
* @returns Date object representing the absolute point in time (UTC).
|
||||
*/
|
||||
export function createDateWithTimezone(
|
||||
dateInput: string | Date,
|
||||
timeStr: string,
|
||||
timezone = 'UTC'
|
||||
): Date {
|
||||
try {
|
||||
// 1. Parse the base date and target time
|
||||
const baseDate = typeof dateInput === 'string' ? new Date(dateInput) : new Date(dateInput)
|
||||
const [targetHours, targetMinutes] = parseTimeString(timeStr)
|
||||
|
||||
// Ensure baseDate reflects the date part only, setting time to 00:00:00 in UTC
|
||||
// This prevents potential issues if dateInput string includes time/timezone info.
|
||||
const year = baseDate.getUTCFullYear()
|
||||
const monthIndex = baseDate.getUTCMonth() // 0-based
|
||||
const day = baseDate.getUTCDate()
|
||||
|
||||
// 2. Create a tentative UTC Date object using the target date and time components
|
||||
// This assumes, for a moment, that the target H:M were meant for UTC.
|
||||
const tentativeUTCDate = new Date(
|
||||
Date.UTC(year, monthIndex, day, targetHours, targetMinutes, 0)
|
||||
)
|
||||
|
||||
// 3. If the target timezone is UTC, we're done.
|
||||
if (timezone === 'UTC') {
|
||||
return tentativeUTCDate
|
||||
}
|
||||
|
||||
// 4. Format the tentative UTC date into the target timezone's local time components.
|
||||
// Use 'en-CA' locale for unambiguous YYYY-MM-DD and 24-hour format.
|
||||
const formatter = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: timezone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit', // Use 2-digit for consistency
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hourCycle: 'h23', // Use 24-hour format (00-23)
|
||||
})
|
||||
|
||||
const parts = formatter.formatToParts(tentativeUTCDate)
|
||||
const getPart = (type: Intl.DateTimeFormatPartTypes) =>
|
||||
parts.find((p) => p.type === type)?.value
|
||||
|
||||
const formattedYear = Number.parseInt(getPart('year') || '0', 10)
|
||||
const formattedMonth = Number.parseInt(getPart('month') || '0', 10) // 1-based
|
||||
const formattedDay = Number.parseInt(getPart('day') || '0', 10)
|
||||
const formattedHour = Number.parseInt(getPart('hour') || '0', 10)
|
||||
const formattedMinute = Number.parseInt(getPart('minute') || '0', 10)
|
||||
|
||||
// Create a Date object representing the local time *in the target timezone*
|
||||
// when the tentative UTC date occurs.
|
||||
// Note: month needs to be adjusted back to 0-based for Date.UTC()
|
||||
const actualLocalTimeInTargetZone = Date.UTC(
|
||||
formattedYear,
|
||||
formattedMonth - 1,
|
||||
formattedDay,
|
||||
formattedHour,
|
||||
formattedMinute,
|
||||
0 // seconds
|
||||
)
|
||||
|
||||
// 5. Calculate the difference between the intended local time and the actual local time
|
||||
// that resulted from the tentative UTC date. This difference represents the offset
|
||||
// needed to adjust the UTC time.
|
||||
// Create the intended local time as a UTC timestamp for comparison purposes.
|
||||
const intendedLocalTimeAsUTC = Date.UTC(year, monthIndex, day, targetHours, targetMinutes, 0)
|
||||
|
||||
// The offset needed for UTC time is the difference between the intended local time
|
||||
// and the actual local time (when both are represented as UTC timestamps).
|
||||
const offsetMilliseconds = intendedLocalTimeAsUTC - actualLocalTimeInTargetZone
|
||||
|
||||
// 6. Adjust the tentative UTC date by the calculated offset.
|
||||
const finalUTCTimeMilliseconds = tentativeUTCDate.getTime() + offsetMilliseconds
|
||||
const finalDate = new Date(finalUTCTimeMilliseconds)
|
||||
|
||||
return finalDate
|
||||
} catch (e) {
|
||||
logger.error('Error creating date with timezone:', e, { dateInput, timeStr, timezone })
|
||||
// Fallback to a simple UTC interpretation on error
|
||||
try {
|
||||
const baseDate = typeof dateInput === 'string' ? new Date(dateInput) : new Date(dateInput)
|
||||
const [hours, minutes] = parseTimeString(timeStr)
|
||||
const year = baseDate.getUTCFullYear()
|
||||
const monthIndex = baseDate.getUTCMonth()
|
||||
const day = baseDate.getUTCDate()
|
||||
return new Date(Date.UTC(year, monthIndex, day, hours, minutes, 0))
|
||||
} catch (fallbackError) {
|
||||
logger.error('Error during fallback date creation:', fallbackError)
|
||||
throw new Error(
|
||||
`Failed to create date with timezone (${timezone}): ${toError(fallbackError).message}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate cron expression based on schedule type and values
|
||||
*
|
||||
* IMPORTANT: The generated cron expressions use local time values (hours/minutes)
|
||||
* from the user's configured timezone. When used with Croner, pass the timezone
|
||||
* option to ensure proper scheduling:
|
||||
*
|
||||
* Example:
|
||||
* const cronExpr = generateCronExpression('daily', { dailyTime: [14, 30], timezone: 'America/Los_Angeles' })
|
||||
* const cron = new Cron(cronExpr, { timezone: 'America/Los_Angeles' })
|
||||
*
|
||||
* This will schedule the job at 2:30 PM Pacific Time, which Croner will correctly
|
||||
* convert to the appropriate UTC time, handling DST transitions automatically.
|
||||
*
|
||||
* @param scheduleType - Type of schedule (minutes, hourly, daily, weekly, monthly, custom)
|
||||
* @param scheduleValues - Object containing schedule configuration including timezone
|
||||
* @returns Cron expression string representing the schedule in local time
|
||||
*/
|
||||
export function generateCronExpression(
|
||||
scheduleType: string,
|
||||
scheduleValues: ReturnType<typeof getScheduleTimeValues>
|
||||
): string {
|
||||
switch (scheduleType) {
|
||||
case 'minutes':
|
||||
return `*/${scheduleValues.minutesInterval} * * * *`
|
||||
|
||||
case 'hourly':
|
||||
return `${scheduleValues.hourlyMinute} * * * *`
|
||||
|
||||
case 'daily': {
|
||||
const [hours, minutes] = scheduleValues.dailyTime
|
||||
return `${minutes} ${hours} * * *`
|
||||
}
|
||||
|
||||
case 'weekly': {
|
||||
const [hours, minutes] = scheduleValues.weeklyTime
|
||||
return `${minutes} ${hours} * * ${scheduleValues.weeklyDay}`
|
||||
}
|
||||
|
||||
case 'monthly': {
|
||||
const [hours, minutes] = scheduleValues.monthlyTime
|
||||
return `${minutes} ${hours} ${scheduleValues.monthlyDay} * *`
|
||||
}
|
||||
|
||||
case 'custom': {
|
||||
if (!scheduleValues.cronExpression?.trim()) {
|
||||
throw new Error('Custom schedule requires a valid cron expression')
|
||||
}
|
||||
return scheduleValues.cronExpression
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported schedule type: ${scheduleType}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the next run time based on schedule configuration
|
||||
* Uses Croner library with timezone support for accurate scheduling across timezones and DST transitions
|
||||
* @param scheduleType - Type of schedule (minutes, hourly, daily, etc)
|
||||
* @param scheduleValues - Object with schedule configuration values
|
||||
* @returns Date object for next execution time
|
||||
*/
|
||||
export function calculateNextRunTime(
|
||||
scheduleType: string,
|
||||
scheduleValues: ReturnType<typeof getScheduleTimeValues>
|
||||
): Date {
|
||||
// Get timezone (default to UTC)
|
||||
const timezone = scheduleValues.timezone || 'UTC'
|
||||
|
||||
// Get the current time
|
||||
const baseDate = new Date()
|
||||
|
||||
// If we have both a start date and time, use them together with timezone awareness
|
||||
if (scheduleValues.scheduleStartAt && scheduleValues.scheduleTime) {
|
||||
try {
|
||||
logger.debug(
|
||||
`Creating date with: startAt=${scheduleValues.scheduleStartAt}, time=${scheduleValues.scheduleTime}, timezone=${timezone}`
|
||||
)
|
||||
|
||||
const combinedDate = createDateWithTimezone(
|
||||
scheduleValues.scheduleStartAt,
|
||||
scheduleValues.scheduleTime,
|
||||
timezone
|
||||
)
|
||||
|
||||
logger.debug(`Combined date result: ${combinedDate.toISOString()}`)
|
||||
|
||||
// If the combined date is in the future, use it as our next run time
|
||||
if (combinedDate > baseDate) {
|
||||
return combinedDate
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Error combining scheduled date and time:', e)
|
||||
}
|
||||
}
|
||||
// If only scheduleStartAt is set (without scheduleTime), parse it directly
|
||||
else if (scheduleValues.scheduleStartAt) {
|
||||
try {
|
||||
// Check if the date string already includes time information
|
||||
const startAtStr = scheduleValues.scheduleStartAt
|
||||
const hasTimeComponent =
|
||||
startAtStr.includes('T') && (startAtStr.includes(':') || startAtStr.includes('.'))
|
||||
|
||||
if (hasTimeComponent) {
|
||||
// If the string already has time info, parse it directly but with timezone awareness
|
||||
const startDate = new Date(startAtStr)
|
||||
|
||||
// If it's a UTC ISO string (ends with Z), use it directly
|
||||
if (startAtStr.endsWith('Z') && timezone === 'UTC') {
|
||||
if (startDate > baseDate) {
|
||||
return startDate
|
||||
}
|
||||
} else {
|
||||
// For non-UTC dates or when timezone isn't UTC, we need to interpret it in the specified timezone
|
||||
// Extract time from the date string (crude but effective for ISO format)
|
||||
const timeMatch = startAtStr.match(/T(\d{2}:\d{2})/)
|
||||
const timeStr = timeMatch ? timeMatch[1] : '00:00'
|
||||
|
||||
// Use our timezone-aware function with the extracted time
|
||||
const tzAwareDate = createDateWithTimezone(
|
||||
startAtStr.split('T')[0], // Just the date part
|
||||
timeStr, // Time extracted from string
|
||||
timezone
|
||||
)
|
||||
|
||||
if (tzAwareDate > baseDate) {
|
||||
return tzAwareDate
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// If no time component in the string, use midnight in the specified timezone
|
||||
const startDate = createDateWithTimezone(
|
||||
scheduleValues.scheduleStartAt,
|
||||
'00:00', // Use midnight in the specified timezone
|
||||
timezone
|
||||
)
|
||||
|
||||
if (startDate > baseDate) {
|
||||
return startDate
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Error parsing scheduleStartAt:', e)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const cronExpression = generateCronExpression(scheduleType, scheduleValues)
|
||||
logger.debug(`Using cron expression: ${cronExpression} with timezone: ${timezone}`)
|
||||
|
||||
const cron = new Cron(cronExpression, {
|
||||
timezone,
|
||||
})
|
||||
|
||||
const nextDate = cron.nextRun()
|
||||
|
||||
if (!nextDate) {
|
||||
throw new Error(`No next run date calculated for cron: ${cronExpression}`)
|
||||
}
|
||||
|
||||
logger.debug(`Next run calculated: ${nextDate.toISOString()}`)
|
||||
return nextDate
|
||||
} catch (error) {
|
||||
logger.error('Error calculating next run with Croner:', error)
|
||||
throw new Error(
|
||||
`Failed to calculate next run time for schedule type ${scheduleType}: ${toError(error).message}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a cron expression to a human-readable string format
|
||||
* Uses the cronstrue library for accurate parsing of complex cron expressions
|
||||
*
|
||||
* Croner spells the last weekday of the month as `<weekday>#L` (e.g. `1#L`) while
|
||||
* cronstrue spells the same thing `<weekday>L`; the expression is normalized to
|
||||
* cronstrue's syntax for display only, so the stored cron keeps croner's syntax
|
||||
* and scheduling stays correct.
|
||||
*
|
||||
* @param cronExpression - The cron expression to parse
|
||||
* @param timezone - Optional IANA timezone string to include in the description
|
||||
* @returns Human-readable description of the schedule
|
||||
*/
|
||||
export const parseCronToHumanReadable = (cronExpression: string, timezone?: string): string => {
|
||||
try {
|
||||
const forDisplay = cronExpression.replace(/([0-7])#L\b/g, '$1L')
|
||||
const baseDescription = cronstrue
|
||||
.toString(forDisplay, {
|
||||
use24HourTimeFormat: false,
|
||||
verbose: false,
|
||||
})
|
||||
.replace(/\b0(\d:\d{2})/g, '$1')
|
||||
|
||||
if (timezone && timezone !== 'UTC') {
|
||||
const tzAbbr = getTimezoneAbbreviation(timezone)
|
||||
return `${baseDescription} (${tzAbbr})`
|
||||
}
|
||||
|
||||
return baseDescription
|
||||
} catch (error) {
|
||||
logger.warn('Failed to parse cron expression with cronstrue:', {
|
||||
cronExpression,
|
||||
error: toError(error).message,
|
||||
})
|
||||
return `Schedule: ${cronExpression}${timezone && timezone !== 'UTC' ? ` (${getTimezoneAbbreviation(timezone)})` : ''}`
|
||||
}
|
||||
}
|
||||
|
||||
const REVERSE_DAY_MAP: Record<number, string> = {
|
||||
0: 'SUN',
|
||||
1: 'MON',
|
||||
2: 'TUE',
|
||||
3: 'WED',
|
||||
4: 'THU',
|
||||
5: 'FRI',
|
||||
6: 'SAT',
|
||||
7: 'SUN',
|
||||
}
|
||||
|
||||
export type ScheduleType = 'minutes' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'custom'
|
||||
|
||||
export interface CronFormState {
|
||||
scheduleType: ScheduleType
|
||||
minutesInterval: string
|
||||
hourlyMinute: string
|
||||
dailyTime: string
|
||||
weeklyDay: string
|
||||
weeklyDayTime: string
|
||||
monthlyDay: string
|
||||
monthlyTime: string
|
||||
cronExpression: string
|
||||
}
|
||||
|
||||
const CRON_FORM_DEFAULTS: CronFormState = {
|
||||
scheduleType: 'custom',
|
||||
minutesInterval: '15',
|
||||
hourlyMinute: '0',
|
||||
dailyTime: '09:00',
|
||||
weeklyDay: 'MON',
|
||||
weeklyDayTime: '09:00',
|
||||
monthlyDay: '1',
|
||||
monthlyTime: '09:00',
|
||||
cronExpression: '',
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse-parses a cron expression into schedule type and form field values.
|
||||
* Used to pre-populate the schedule modal when editing an existing schedule.
|
||||
*/
|
||||
export function parseCronToScheduleType(cronExpression: string | null | undefined): CronFormState {
|
||||
if (!cronExpression?.trim()) {
|
||||
return { ...CRON_FORM_DEFAULTS }
|
||||
}
|
||||
|
||||
const parts = cronExpression.trim().split(/\s+/)
|
||||
if (parts.length !== 5) {
|
||||
return { ...CRON_FORM_DEFAULTS, cronExpression }
|
||||
}
|
||||
|
||||
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
|
||||
if (
|
||||
minute.startsWith('*/') &&
|
||||
hour === '*' &&
|
||||
dayOfMonth === '*' &&
|
||||
month === '*' &&
|
||||
dayOfWeek === '*'
|
||||
) {
|
||||
const interval = Number.parseInt(minute.slice(2), 10)
|
||||
if (!Number.isNaN(interval) && interval > 0) {
|
||||
return { ...CRON_FORM_DEFAULTS, scheduleType: 'minutes', minutesInterval: String(interval) }
|
||||
}
|
||||
}
|
||||
|
||||
const m = Number.parseInt(minute, 10)
|
||||
const h = Number.parseInt(hour, 10)
|
||||
|
||||
if (
|
||||
!Number.isNaN(m) &&
|
||||
hour === '*' &&
|
||||
dayOfMonth === '*' &&
|
||||
month === '*' &&
|
||||
dayOfWeek === '*'
|
||||
) {
|
||||
return { ...CRON_FORM_DEFAULTS, scheduleType: 'hourly', hourlyMinute: String(m) }
|
||||
}
|
||||
|
||||
if (
|
||||
!Number.isNaN(m) &&
|
||||
!Number.isNaN(h) &&
|
||||
dayOfMonth === '*' &&
|
||||
month === '*' &&
|
||||
dayOfWeek === '*'
|
||||
) {
|
||||
return { ...CRON_FORM_DEFAULTS, scheduleType: 'daily', dailyTime: `${pad(h)}:${pad(m)}` }
|
||||
}
|
||||
|
||||
if (
|
||||
!Number.isNaN(m) &&
|
||||
!Number.isNaN(h) &&
|
||||
dayOfMonth === '*' &&
|
||||
month === '*' &&
|
||||
dayOfWeek !== '*'
|
||||
) {
|
||||
const dow = Number.parseInt(dayOfWeek, 10)
|
||||
const dayName = REVERSE_DAY_MAP[dow]
|
||||
if (dayName) {
|
||||
return {
|
||||
...CRON_FORM_DEFAULTS,
|
||||
scheduleType: 'weekly',
|
||||
weeklyDay: dayName,
|
||||
weeklyDayTime: `${pad(h)}:${pad(m)}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!Number.isNaN(m) &&
|
||||
!Number.isNaN(h) &&
|
||||
dayOfMonth !== '*' &&
|
||||
month === '*' &&
|
||||
dayOfWeek === '*'
|
||||
) {
|
||||
const dom = Number.parseInt(dayOfMonth, 10)
|
||||
if (!Number.isNaN(dom) && dom >= 1 && dom <= 31) {
|
||||
return {
|
||||
...CRON_FORM_DEFAULTS,
|
||||
scheduleType: 'monthly',
|
||||
monthlyDay: String(dom),
|
||||
monthlyTime: `${pad(h)}:${pad(m)}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { ...CRON_FORM_DEFAULTS, cronExpression }
|
||||
}
|
||||
|
||||
/**
|
||||
* Format schedule information for display
|
||||
*/
|
||||
export const getScheduleInfo = (
|
||||
cronExpression: string | null,
|
||||
nextRunAt: string | null,
|
||||
lastRanAt: string | null,
|
||||
scheduleType?: string | null,
|
||||
timezone?: string | null
|
||||
): {
|
||||
scheduleTiming: string
|
||||
nextRunFormatted: string | null
|
||||
lastRunFormatted: string | null
|
||||
} => {
|
||||
if (!nextRunAt) {
|
||||
return {
|
||||
scheduleTiming: 'Unknown schedule',
|
||||
nextRunFormatted: null,
|
||||
lastRunFormatted: null,
|
||||
}
|
||||
}
|
||||
|
||||
let scheduleTiming = 'Unknown schedule'
|
||||
|
||||
if (cronExpression) {
|
||||
scheduleTiming = parseCronToHumanReadable(cronExpression, timezone || undefined)
|
||||
} else if (scheduleType) {
|
||||
scheduleTiming = `${scheduleType.charAt(0).toUpperCase() + scheduleType.slice(1)}`
|
||||
}
|
||||
|
||||
return {
|
||||
scheduleTiming,
|
||||
nextRunFormatted: formatDateTime(new Date(nextRunAt)),
|
||||
lastRunFormatted: lastRanAt ? formatDateTime(new Date(lastRanAt)) : null,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Client-safe schedule validation functions
|
||||
* These can be used in both client and server contexts
|
||||
*/
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import {
|
||||
type BlockState,
|
||||
calculateNextRunTime,
|
||||
generateCronExpression,
|
||||
getScheduleTimeValues,
|
||||
getSubBlockValue,
|
||||
validateCronExpression,
|
||||
} from '@/lib/workflows/schedules/utils'
|
||||
|
||||
/**
|
||||
* Result of schedule validation
|
||||
*/
|
||||
export interface ScheduleValidationResult {
|
||||
isValid: boolean
|
||||
error?: string
|
||||
scheduleType?: string
|
||||
cronExpression?: string
|
||||
nextRunAt?: Date
|
||||
timezone?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a time value is valid (not empty/null/undefined)
|
||||
*/
|
||||
function isValidTimeValue(value: string | null | undefined): boolean {
|
||||
return !!value && value.trim() !== '' && value.includes(':')
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a schedule type has valid configuration
|
||||
* Uses raw block values to avoid false positives from default parsing
|
||||
*/
|
||||
function hasValidScheduleConfig(scheduleType: string | undefined, block: BlockState): boolean {
|
||||
switch (scheduleType) {
|
||||
case 'minutes': {
|
||||
const rawValue = getSubBlockValue(block, 'minutesInterval')
|
||||
const numValue = Number(rawValue)
|
||||
return !!rawValue && !Number.isNaN(numValue) && numValue >= 1 && numValue <= 1440
|
||||
}
|
||||
case 'hourly': {
|
||||
const rawValue = getSubBlockValue(block, 'hourlyMinute')
|
||||
const numValue = Number(rawValue)
|
||||
return rawValue !== '' && !Number.isNaN(numValue) && numValue >= 0 && numValue <= 59
|
||||
}
|
||||
case 'daily': {
|
||||
const rawTime = getSubBlockValue(block, 'dailyTime')
|
||||
return isValidTimeValue(rawTime)
|
||||
}
|
||||
case 'weekly': {
|
||||
const rawDay = getSubBlockValue(block, 'weeklyDay')
|
||||
const rawTime = getSubBlockValue(block, 'weeklyDayTime')
|
||||
return !!rawDay && isValidTimeValue(rawTime)
|
||||
}
|
||||
case 'monthly': {
|
||||
const rawDay = getSubBlockValue(block, 'monthlyDay')
|
||||
const rawTime = getSubBlockValue(block, 'monthlyTime')
|
||||
const dayNum = Number(rawDay)
|
||||
return (
|
||||
!!rawDay &&
|
||||
!Number.isNaN(dayNum) &&
|
||||
dayNum >= 1 &&
|
||||
dayNum <= 31 &&
|
||||
isValidTimeValue(rawTime)
|
||||
)
|
||||
}
|
||||
case 'custom':
|
||||
return !!getSubBlockValue(block, 'cronExpression')
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable error message for missing schedule configuration
|
||||
*/
|
||||
function getMissingConfigError(scheduleType: string): string {
|
||||
switch (scheduleType) {
|
||||
case 'minutes':
|
||||
return 'Minutes interval is required for minute-based schedules'
|
||||
case 'hourly':
|
||||
return 'Minute value is required for hourly schedules'
|
||||
case 'daily':
|
||||
return 'Time is required for daily schedules'
|
||||
case 'weekly':
|
||||
return 'Day and time are required for weekly schedules'
|
||||
case 'monthly':
|
||||
return 'Day and time are required for monthly schedules'
|
||||
case 'custom':
|
||||
return 'Cron expression is required for custom schedules'
|
||||
default:
|
||||
return 'Schedule type is required'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find schedule blocks in a workflow's blocks
|
||||
* Only returns enabled schedule blocks (disabled blocks are skipped)
|
||||
*/
|
||||
export function findScheduleBlocks(blocks: Record<string, BlockState>): BlockState[] {
|
||||
return Object.values(blocks).filter(
|
||||
(block) => block.type === 'schedule' && block.enabled !== false
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a schedule block's configuration
|
||||
* Returns validation result with error details if invalid
|
||||
*/
|
||||
export function validateScheduleBlock(block: BlockState): ScheduleValidationResult {
|
||||
const scheduleType = getSubBlockValue(block, 'scheduleType')
|
||||
|
||||
if (!scheduleType) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: 'Schedule type is required',
|
||||
}
|
||||
}
|
||||
|
||||
const hasValidConfig = hasValidScheduleConfig(scheduleType, block)
|
||||
|
||||
if (!hasValidConfig) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: getMissingConfigError(scheduleType),
|
||||
scheduleType,
|
||||
}
|
||||
}
|
||||
|
||||
const timezone = getSubBlockValue(block, 'timezone') || 'UTC'
|
||||
|
||||
try {
|
||||
// Get parsed schedule values (safe to use after validation passes)
|
||||
const scheduleValues = getScheduleTimeValues(block)
|
||||
const sanitizedScheduleValues =
|
||||
scheduleType !== 'custom' ? { ...scheduleValues, cronExpression: null } : scheduleValues
|
||||
|
||||
const cronExpression = generateCronExpression(scheduleType, sanitizedScheduleValues)
|
||||
|
||||
const validation = validateCronExpression(cronExpression, timezone)
|
||||
if (!validation.isValid) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: `Invalid cron expression: ${validation.error}`,
|
||||
scheduleType,
|
||||
}
|
||||
}
|
||||
|
||||
const nextRunAt = calculateNextRunTime(scheduleType, sanitizedScheduleValues)
|
||||
|
||||
return {
|
||||
isValid: true,
|
||||
scheduleType,
|
||||
cronExpression,
|
||||
nextRunAt,
|
||||
timezone,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: getErrorMessage(error, 'Failed to generate schedule'),
|
||||
scheduleType,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate all schedule blocks in a workflow
|
||||
* Returns the first validation error found, or success if all are valid
|
||||
*/
|
||||
export function validateWorkflowSchedules(
|
||||
blocks: Record<string, BlockState>
|
||||
): ScheduleValidationResult {
|
||||
const scheduleBlocks = findScheduleBlocks(blocks)
|
||||
|
||||
for (const block of scheduleBlocks) {
|
||||
const result = validateScheduleBlock(block)
|
||||
if (!result.isValid) {
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
return { isValid: true }
|
||||
}
|
||||
Reference in New Issue
Block a user