diff --git a/apps/docs/content/docs/en/triggers/schedule.mdx b/apps/docs/content/docs/en/triggers/schedule.mdx index 584589b68..bb7bfbaa8 100644 --- a/apps/docs/content/docs/en/triggers/schedule.mdx +++ b/apps/docs/content/docs/en/triggers/schedule.mdx @@ -5,7 +5,6 @@ title: Schedule import { Callout } from 'fumadocs-ui/components/callout' import { Tab, Tabs } from 'fumadocs-ui/components/tabs' import { Image } from '@/components/ui/image' -import { Video } from '@/components/ui/video' The Schedule block automatically triggers workflows on a recurring schedule at specified intervals or times. @@ -21,16 +20,16 @@ The Schedule block automatically triggers workflows on a recurring schedule at s ## Schedule Options -Configure when your workflow runs using the dropdown options: +Configure when your workflow runs: @@ -43,24 +42,25 @@ Configure when your workflow runs using the dropdown options: -## Configuring Schedules +## Activation -When a workflow is scheduled: -- The schedule becomes **active** and shows the next execution time -- Click the **"Scheduled"** button to deactivate the schedule -- Schedules automatically deactivate after **3 consecutive failures** +Schedules are tied to workflow deployment: -
- Active Schedule Block -
+- **Deploy workflow** → Schedule becomes active and starts running +- **Undeploy workflow** → Schedule is removed +- **Redeploy workflow** → Schedule is recreated with current configuration -## Disabled Schedules + +You must deploy your workflow for the schedule to start running. Configure the schedule block, then deploy from the toolbar. + + +## Automatic Disabling + +Schedules automatically disable after **10 consecutive failures** to prevent runaway errors. When disabled: + +- A warning badge appears on the schedule block +- The schedule stops executing +- Click the badge to reactivate the schedule
-Disabled schedules show when they were last active. Click the **"Disabled"** badge to reactivate the schedule. - -Schedule blocks cannot receive incoming connections and serve as pure workflow triggers. - \ No newline at end of file +Schedule blocks cannot receive incoming connections and serve as workflow entry points only. + diff --git a/apps/docs/public/static/blocks/schedule-2.png b/apps/docs/public/static/blocks/schedule-2.png deleted file mode 100644 index c6b97881a..000000000 Binary files a/apps/docs/public/static/blocks/schedule-2.png and /dev/null differ diff --git a/apps/sim/app/api/schedules/[id]/route.test.ts b/apps/sim/app/api/schedules/[id]/route.test.ts new file mode 100644 index 000000000..a24fb07a7 --- /dev/null +++ b/apps/sim/app/api/schedules/[id]/route.test.ts @@ -0,0 +1,652 @@ +/** + * Tests for schedule reactivate PUT API route + * + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetSession, mockGetUserEntityPermissions, mockDbSelect, mockDbUpdate } = vi.hoisted( + () => ({ + mockGetSession: vi.fn(), + mockGetUserEntityPermissions: vi.fn(), + mockDbSelect: vi.fn(), + mockDbUpdate: vi.fn(), + }) +) + +vi.mock('@/lib/auth', () => ({ + getSession: mockGetSession, +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getUserEntityPermissions: mockGetUserEntityPermissions, +})) + +vi.mock('@sim/db', () => ({ + db: { + select: mockDbSelect, + update: mockDbUpdate, + }, +})) + +vi.mock('@sim/db/schema', () => ({ + workflow: { id: 'id', userId: 'userId', workspaceId: 'workspaceId' }, + workflowSchedule: { id: 'id', workflowId: 'workflowId', status: 'status' }, +})) + +vi.mock('drizzle-orm', () => ({ + eq: vi.fn(), +})) + +vi.mock('@/lib/core/utils/request', () => ({ + generateRequestId: () => 'test-request-id', +})) + +vi.mock('@/lib/logs/console/logger', () => ({ + createLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), +})) + +import { PUT } from './route' + +function createRequest(body: Record): NextRequest { + return new NextRequest(new URL('http://test/api/schedules/sched-1'), { + method: 'PUT', + body: JSON.stringify(body), + headers: { 'Content-Type': 'application/json' }, + }) +} + +function createParams(id: string): { params: Promise<{ id: string }> } { + return { params: Promise.resolve({ id }) } +} + +function mockDbChain(selectResults: unknown[][]) { + let selectCallIndex = 0 + mockDbSelect.mockImplementation(() => ({ + from: () => ({ + where: () => ({ + limit: () => selectResults[selectCallIndex++] || [], + }), + }), + })) + + mockDbUpdate.mockImplementation(() => ({ + set: () => ({ + where: vi.fn().mockResolvedValue({}), + }), + })) +} + +describe('Schedule PUT API (Reactivate)', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) + mockGetUserEntityPermissions.mockResolvedValue('write') + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + describe('Authentication', () => { + it('returns 401 when user is not authenticated', async () => { + mockGetSession.mockResolvedValue(null) + + const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1')) + + expect(res.status).toBe(401) + const data = await res.json() + expect(data.error).toBe('Unauthorized') + }) + }) + + describe('Request Validation', () => { + it('returns 400 when action is not reactivate', async () => { + mockDbChain([ + [{ id: 'sched-1', workflowId: 'wf-1', status: 'disabled' }], + [{ userId: 'user-1', workspaceId: null }], + ]) + + const res = await PUT(createRequest({ action: 'disable' }), createParams('sched-1')) + + expect(res.status).toBe(400) + const data = await res.json() + expect(data.error).toBe('Invalid request body') + }) + + it('returns 400 when action is missing', async () => { + mockDbChain([ + [{ id: 'sched-1', workflowId: 'wf-1', status: 'disabled' }], + [{ userId: 'user-1', workspaceId: null }], + ]) + + const res = await PUT(createRequest({}), createParams('sched-1')) + + expect(res.status).toBe(400) + const data = await res.json() + expect(data.error).toBe('Invalid request body') + }) + }) + + describe('Schedule Not Found', () => { + it('returns 404 when schedule does not exist', async () => { + mockDbChain([[]]) + + const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-999')) + + expect(res.status).toBe(404) + const data = await res.json() + expect(data.error).toBe('Schedule not found') + }) + + it('returns 404 when workflow does not exist for schedule', async () => { + mockDbChain([[{ id: 'sched-1', workflowId: 'wf-1', status: 'disabled' }], []]) + + const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1')) + + expect(res.status).toBe(404) + const data = await res.json() + expect(data.error).toBe('Workflow not found') + }) + }) + + describe('Authorization', () => { + it('returns 403 when user is not workflow owner', async () => { + mockDbChain([ + [{ id: 'sched-1', workflowId: 'wf-1', status: 'disabled' }], + [{ userId: 'other-user', workspaceId: null }], + ]) + + const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1')) + + expect(res.status).toBe(403) + const data = await res.json() + expect(data.error).toBe('Not authorized to modify this schedule') + }) + + it('returns 403 for workspace member with only read permission', async () => { + mockGetUserEntityPermissions.mockResolvedValue('read') + mockDbChain([ + [{ id: 'sched-1', workflowId: 'wf-1', status: 'disabled' }], + [{ userId: 'other-user', workspaceId: 'ws-1' }], + ]) + + const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1')) + + expect(res.status).toBe(403) + }) + + it('allows workflow owner to reactivate', async () => { + mockDbChain([ + [ + { + id: 'sched-1', + workflowId: 'wf-1', + status: 'disabled', + cronExpression: '*/5 * * * *', + timezone: 'UTC', + }, + ], + [{ userId: 'user-1', workspaceId: null }], + ]) + + const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1')) + + expect(res.status).toBe(200) + const data = await res.json() + expect(data.message).toBe('Schedule activated successfully') + }) + + it('allows workspace member with write permission to reactivate', async () => { + mockGetUserEntityPermissions.mockResolvedValue('write') + mockDbChain([ + [ + { + id: 'sched-1', + workflowId: 'wf-1', + status: 'disabled', + cronExpression: '*/5 * * * *', + timezone: 'UTC', + }, + ], + [{ userId: 'other-user', workspaceId: 'ws-1' }], + ]) + + const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1')) + + expect(res.status).toBe(200) + }) + + it('allows workspace admin to reactivate', async () => { + mockGetUserEntityPermissions.mockResolvedValue('admin') + mockDbChain([ + [ + { + id: 'sched-1', + workflowId: 'wf-1', + status: 'disabled', + cronExpression: '*/5 * * * *', + timezone: 'UTC', + }, + ], + [{ userId: 'other-user', workspaceId: 'ws-1' }], + ]) + + const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1')) + + expect(res.status).toBe(200) + }) + }) + + describe('Schedule State Handling', () => { + it('returns success message when schedule is already active', async () => { + mockDbChain([ + [{ id: 'sched-1', workflowId: 'wf-1', status: 'active' }], + [{ userId: 'user-1', workspaceId: null }], + ]) + + const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1')) + + expect(res.status).toBe(200) + const data = await res.json() + expect(data.message).toBe('Schedule is already active') + expect(mockDbUpdate).not.toHaveBeenCalled() + }) + + it('successfully reactivates disabled schedule', async () => { + mockDbChain([ + [ + { + id: 'sched-1', + workflowId: 'wf-1', + status: 'disabled', + cronExpression: '*/5 * * * *', + timezone: 'UTC', + }, + ], + [{ userId: 'user-1', workspaceId: null }], + ]) + + const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1')) + + expect(res.status).toBe(200) + const data = await res.json() + expect(data.message).toBe('Schedule activated successfully') + expect(data.nextRunAt).toBeDefined() + expect(mockDbUpdate).toHaveBeenCalled() + }) + + it('returns 400 when schedule has no cron expression', async () => { + mockDbChain([ + [ + { + id: 'sched-1', + workflowId: 'wf-1', + status: 'disabled', + cronExpression: null, + timezone: 'UTC', + }, + ], + [{ userId: 'user-1', workspaceId: null }], + ]) + + const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1')) + + expect(res.status).toBe(400) + const data = await res.json() + expect(data.error).toBe('Schedule has no cron expression') + }) + + it('returns 400 when schedule has invalid cron expression', async () => { + mockDbChain([ + [ + { + id: 'sched-1', + workflowId: 'wf-1', + status: 'disabled', + cronExpression: 'invalid-cron', + timezone: 'UTC', + }, + ], + [{ userId: 'user-1', workspaceId: null }], + ]) + + const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1')) + + expect(res.status).toBe(400) + const data = await res.json() + expect(data.error).toBe('Schedule has invalid cron expression') + }) + + it('calculates nextRunAt from stored cron expression (every 5 minutes)', async () => { + mockDbChain([ + [ + { + id: 'sched-1', + workflowId: 'wf-1', + status: 'disabled', + cronExpression: '*/5 * * * *', + timezone: 'UTC', + }, + ], + [{ userId: 'user-1', workspaceId: null }], + ]) + + const beforeCall = Date.now() + const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1')) + const afterCall = Date.now() + + expect(res.status).toBe(200) + const data = await res.json() + const nextRunAt = new Date(data.nextRunAt).getTime() + + // nextRunAt should be within 0-5 minutes in the future + expect(nextRunAt).toBeGreaterThan(beforeCall) + expect(nextRunAt).toBeLessThanOrEqual(afterCall + 5 * 60 * 1000 + 1000) + // Should align with 5-minute intervals (minute divisible by 5) + expect(new Date(nextRunAt).getMinutes() % 5).toBe(0) + }) + + it('calculates nextRunAt from daily cron expression', async () => { + mockDbChain([ + [ + { + id: 'sched-1', + workflowId: 'wf-1', + status: 'disabled', + cronExpression: '30 14 * * *', // 2:30 PM daily + timezone: 'UTC', + }, + ], + [{ userId: 'user-1', workspaceId: null }], + ]) + + const beforeCall = Date.now() + const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1')) + + expect(res.status).toBe(200) + const data = await res.json() + const nextRunAt = new Date(data.nextRunAt) + + // Should be a future date at 14:30 UTC + expect(nextRunAt.getTime()).toBeGreaterThan(beforeCall) + expect(nextRunAt.getUTCHours()).toBe(14) + expect(nextRunAt.getUTCMinutes()).toBe(30) + expect(nextRunAt.getUTCSeconds()).toBe(0) + }) + + it('calculates nextRunAt from weekly cron expression', async () => { + mockDbChain([ + [ + { + id: 'sched-1', + workflowId: 'wf-1', + status: 'disabled', + cronExpression: '0 9 * * 1', // Monday at 9:00 AM + timezone: 'UTC', + }, + ], + [{ userId: 'user-1', workspaceId: null }], + ]) + + const beforeCall = Date.now() + const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1')) + + expect(res.status).toBe(200) + const data = await res.json() + const nextRunAt = new Date(data.nextRunAt) + + // Should be a future date on Monday at 09:00 UTC + expect(nextRunAt.getTime()).toBeGreaterThan(beforeCall) + expect(nextRunAt.getUTCDay()).toBe(1) // Monday + expect(nextRunAt.getUTCHours()).toBe(9) + expect(nextRunAt.getUTCMinutes()).toBe(0) + }) + + it('calculates nextRunAt from monthly cron expression', async () => { + mockDbChain([ + [ + { + id: 'sched-1', + workflowId: 'wf-1', + status: 'disabled', + cronExpression: '0 10 15 * *', // 15th of month at 10:00 AM + timezone: 'UTC', + }, + ], + [{ userId: 'user-1', workspaceId: null }], + ]) + + const beforeCall = Date.now() + const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1')) + + expect(res.status).toBe(200) + const data = await res.json() + const nextRunAt = new Date(data.nextRunAt) + + // Should be a future date on the 15th at 10:00 UTC + expect(nextRunAt.getTime()).toBeGreaterThan(beforeCall) + expect(nextRunAt.getUTCDate()).toBe(15) + expect(nextRunAt.getUTCHours()).toBe(10) + expect(nextRunAt.getUTCMinutes()).toBe(0) + }) + }) + + describe('Timezone Handling in Reactivation', () => { + it('calculates nextRunAt with America/New_York timezone', async () => { + mockDbChain([ + [ + { + id: 'sched-1', + workflowId: 'wf-1', + status: 'disabled', + cronExpression: '0 9 * * *', // 9:00 AM Eastern + timezone: 'America/New_York', + }, + ], + [{ userId: 'user-1', workspaceId: null }], + ]) + + const beforeCall = Date.now() + const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1')) + + expect(res.status).toBe(200) + const data = await res.json() + const nextRunAt = new Date(data.nextRunAt) + + // Should be a future date + expect(nextRunAt.getTime()).toBeGreaterThan(beforeCall) + // The exact UTC hour will depend on DST, but it should be 13:00 or 14:00 UTC + const utcHour = nextRunAt.getUTCHours() + expect([13, 14]).toContain(utcHour) // 9 AM ET = 1-2 PM UTC depending on DST + expect(nextRunAt.getUTCMinutes()).toBe(0) + }) + + it('calculates nextRunAt with Asia/Tokyo timezone', async () => { + mockDbChain([ + [ + { + id: 'sched-1', + workflowId: 'wf-1', + status: 'disabled', + cronExpression: '30 15 * * *', // 3:30 PM Japan Time + timezone: 'Asia/Tokyo', + }, + ], + [{ userId: 'user-1', workspaceId: null }], + ]) + + const beforeCall = Date.now() + const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1')) + + expect(res.status).toBe(200) + const data = await res.json() + const nextRunAt = new Date(data.nextRunAt) + + // Should be a future date + expect(nextRunAt.getTime()).toBeGreaterThan(beforeCall) + // 3:30 PM JST (UTC+9) = 6:30 AM UTC + expect(nextRunAt.getUTCHours()).toBe(6) + expect(nextRunAt.getUTCMinutes()).toBe(30) + }) + + it('calculates nextRunAt with Europe/London timezone', async () => { + mockDbChain([ + [ + { + id: 'sched-1', + workflowId: 'wf-1', + status: 'disabled', + cronExpression: '0 12 * * 5', // Friday at noon London time + timezone: 'Europe/London', + }, + ], + [{ userId: 'user-1', workspaceId: null }], + ]) + + const beforeCall = Date.now() + const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1')) + + expect(res.status).toBe(200) + const data = await res.json() + const nextRunAt = new Date(data.nextRunAt) + + // Should be a future date on Friday + expect(nextRunAt.getTime()).toBeGreaterThan(beforeCall) + expect(nextRunAt.getUTCDay()).toBe(5) // Friday + // UTC hour depends on BST/GMT (11:00 or 12:00 UTC) + const utcHour = nextRunAt.getUTCHours() + expect([11, 12]).toContain(utcHour) + expect(nextRunAt.getUTCMinutes()).toBe(0) + }) + + it('uses UTC as default timezone when timezone is not set', async () => { + mockDbChain([ + [ + { + id: 'sched-1', + workflowId: 'wf-1', + status: 'disabled', + cronExpression: '0 10 * * *', // 10:00 AM + timezone: null, + }, + ], + [{ userId: 'user-1', workspaceId: null }], + ]) + + const beforeCall = Date.now() + const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1')) + + expect(res.status).toBe(200) + const data = await res.json() + const nextRunAt = new Date(data.nextRunAt) + + // Should be a future date at 10:00 UTC + expect(nextRunAt.getTime()).toBeGreaterThan(beforeCall) + expect(nextRunAt.getUTCHours()).toBe(10) + expect(nextRunAt.getUTCMinutes()).toBe(0) + }) + + it('handles minutely schedules with timezone correctly', async () => { + mockDbChain([ + [ + { + id: 'sched-1', + workflowId: 'wf-1', + status: 'disabled', + cronExpression: '*/10 * * * *', // Every 10 minutes + timezone: 'America/Los_Angeles', + }, + ], + [{ userId: 'user-1', workspaceId: null }], + ]) + + const beforeCall = Date.now() + const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1')) + + expect(res.status).toBe(200) + const data = await res.json() + const nextRunAt = new Date(data.nextRunAt) + + // Should be a future date within the next 10 minutes + expect(nextRunAt.getTime()).toBeGreaterThan(beforeCall) + expect(nextRunAt.getTime()).toBeLessThanOrEqual(beforeCall + 10 * 60 * 1000 + 1000) + // Should align with 10-minute intervals + expect(nextRunAt.getMinutes() % 10).toBe(0) + }) + + it('handles hourly schedules with timezone correctly', async () => { + mockDbChain([ + [ + { + id: 'sched-1', + workflowId: 'wf-1', + status: 'disabled', + cronExpression: '15 * * * *', // At minute 15 of every hour + timezone: 'America/Chicago', + }, + ], + [{ userId: 'user-1', workspaceId: null }], + ]) + + const beforeCall = Date.now() + const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1')) + + expect(res.status).toBe(200) + const data = await res.json() + const nextRunAt = new Date(data.nextRunAt) + + // Should be a future date at minute 15 + expect(nextRunAt.getTime()).toBeGreaterThan(beforeCall) + expect(nextRunAt.getMinutes()).toBe(15) + expect(nextRunAt.getSeconds()).toBe(0) + }) + + it('handles custom cron expressions with complex patterns and timezone', async () => { + mockDbChain([ + [ + { + id: 'sched-1', + workflowId: 'wf-1', + status: 'disabled', + cronExpression: '0 9 * * 1-5', // Weekdays at 9 AM + timezone: 'America/New_York', + }, + ], + [{ userId: 'user-1', workspaceId: null }], + ]) + + const beforeCall = Date.now() + const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1')) + + expect(res.status).toBe(200) + const data = await res.json() + const nextRunAt = new Date(data.nextRunAt) + + // Should be a future date on a weekday (1-5) + expect(nextRunAt.getTime()).toBeGreaterThan(beforeCall) + const dayOfWeek = nextRunAt.getUTCDay() + expect([1, 2, 3, 4, 5]).toContain(dayOfWeek) + }) + }) + + describe('Error Handling', () => { + it('returns 500 when database operation fails', async () => { + mockDbSelect.mockImplementation(() => { + throw new Error('Database connection failed') + }) + + const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1')) + + expect(res.status).toBe(500) + const data = await res.json() + expect(data.error).toBe('Failed to update schedule') + }) + }) +}) diff --git a/apps/sim/app/api/schedules/[id]/route.ts b/apps/sim/app/api/schedules/[id]/route.ts index cd5000517..c3aa491e0 100644 --- a/apps/sim/app/api/schedules/[id]/route.ts +++ b/apps/sim/app/api/schedules/[id]/route.ts @@ -6,104 +6,26 @@ import { z } from 'zod' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { createLogger } from '@/lib/logs/console/logger' +import { validateCronExpression } from '@/lib/workflows/schedules/utils' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('ScheduleAPI') export const dynamic = 'force-dynamic' -const scheduleActionEnum = z.enum(['reactivate', 'disable']) -const scheduleStatusEnum = z.enum(['active', 'disabled']) - -const scheduleUpdateSchema = z - .object({ - action: scheduleActionEnum.optional(), - status: scheduleStatusEnum.optional(), - }) - .refine((data) => data.action || data.status, { - message: 'Either action or status must be provided', - }) +const scheduleUpdateSchema = z.object({ + action: z.literal('reactivate'), +}) /** - * Delete a schedule - */ -export async function DELETE( - request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - const requestId = generateRequestId() - - try { - const { id } = await params - logger.debug(`[${requestId}] Deleting schedule with ID: ${id}`) - - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized schedule deletion attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - // Find the schedule and check ownership - const schedules = await db - .select({ - schedule: workflowSchedule, - workflow: { - id: workflow.id, - userId: workflow.userId, - workspaceId: workflow.workspaceId, - }, - }) - .from(workflowSchedule) - .innerJoin(workflow, eq(workflowSchedule.workflowId, workflow.id)) - .where(eq(workflowSchedule.id, id)) - .limit(1) - - if (schedules.length === 0) { - logger.warn(`[${requestId}] Schedule not found: ${id}`) - return NextResponse.json({ error: 'Schedule not found' }, { status: 404 }) - } - - const workflowRecord = schedules[0].workflow - - // Check authorization - either the user owns the workflow or has write/admin workspace permissions - let isAuthorized = workflowRecord.userId === session.user.id - - // If not authorized by ownership and the workflow belongs to a workspace, check workspace permissions - if (!isAuthorized && workflowRecord.workspaceId) { - const userPermission = await getUserEntityPermissions( - session.user.id, - 'workspace', - workflowRecord.workspaceId - ) - isAuthorized = userPermission === 'write' || userPermission === 'admin' - } - - if (!isAuthorized) { - logger.warn(`[${requestId}] Unauthorized schedule deletion attempt for schedule: ${id}`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 403 }) - } - - // Delete the schedule - await db.delete(workflowSchedule).where(eq(workflowSchedule.id, id)) - - logger.info(`[${requestId}] Successfully deleted schedule: ${id}`) - return NextResponse.json({ success: true }, { status: 200 }) - } catch (error) { - logger.error(`[${requestId}] Error deleting schedule`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -} - -/** - * Update a schedule - can be used to reactivate a disabled schedule + * Reactivate a disabled schedule */ export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { const requestId = generateRequestId() try { - const { id } = await params - const scheduleId = id - logger.debug(`[${requestId}] Updating schedule with ID: ${scheduleId}`) + const { id: scheduleId } = await params + logger.debug(`[${requestId}] Reactivating schedule with ID: ${scheduleId}`) const session = await getSession() if (!session?.user?.id) { @@ -115,18 +37,16 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ const validation = scheduleUpdateSchema.safeParse(body) if (!validation.success) { - const firstError = validation.error.errors[0] - logger.warn(`[${requestId}] Validation error:`, firstError) - return NextResponse.json({ error: firstError.message }, { status: 400 }) + return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }) } - const { action, status: requestedStatus } = validation.data - const [schedule] = await db .select({ id: workflowSchedule.id, workflowId: workflowSchedule.workflowId, status: workflowSchedule.status, + cronExpression: workflowSchedule.cronExpression, + timezone: workflowSchedule.timezone, }) .from(workflowSchedule) .where(eq(workflowSchedule.id, scheduleId)) @@ -164,57 +84,40 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ error: 'Not authorized to modify this schedule' }, { status: 403 }) } - if (action === 'reactivate' || (requestedStatus && requestedStatus === 'active')) { - if (schedule.status === 'active') { - return NextResponse.json({ message: 'Schedule is already active' }, { status: 200 }) - } + if (schedule.status === 'active') { + return NextResponse.json({ message: 'Schedule is already active' }, { status: 200 }) + } - const now = new Date() - const nextRunAt = new Date(now.getTime() + 60 * 1000) // Schedule to run in 1 minute + if (!schedule.cronExpression) { + logger.error(`[${requestId}] Schedule has no cron expression: ${scheduleId}`) + return NextResponse.json({ error: 'Schedule has no cron expression' }, { status: 400 }) + } - await db - .update(workflowSchedule) - .set({ - status: 'active', - failedCount: 0, - updatedAt: now, - nextRunAt, - }) - .where(eq(workflowSchedule.id, scheduleId)) + const cronResult = validateCronExpression(schedule.cronExpression, schedule.timezone || 'UTC') + if (!cronResult.isValid || !cronResult.nextRun) { + logger.error(`[${requestId}] Invalid cron expression for schedule: ${scheduleId}`) + return NextResponse.json({ error: 'Schedule has invalid cron expression' }, { status: 400 }) + } - logger.info(`[${requestId}] Reactivated schedule: ${scheduleId}`) + const now = new Date() + const nextRunAt = cronResult.nextRun - return NextResponse.json({ - message: 'Schedule activated successfully', + await db + .update(workflowSchedule) + .set({ + status: 'active', + failedCount: 0, + updatedAt: now, nextRunAt, }) - } + .where(eq(workflowSchedule.id, scheduleId)) - if (action === 'disable' || (requestedStatus && requestedStatus === 'disabled')) { - if (schedule.status === 'disabled') { - return NextResponse.json({ message: 'Schedule is already disabled' }, { status: 200 }) - } + logger.info(`[${requestId}] Reactivated schedule: ${scheduleId}`) - const now = new Date() - - await db - .update(workflowSchedule) - .set({ - status: 'disabled', - updatedAt: now, - nextRunAt: null, // Clear next run time when disabled - }) - .where(eq(workflowSchedule.id, scheduleId)) - - logger.info(`[${requestId}] Disabled schedule: ${scheduleId}`) - - return NextResponse.json({ - message: 'Schedule disabled successfully', - }) - } - - logger.warn(`[${requestId}] Unsupported update action for schedule: ${scheduleId}`) - return NextResponse.json({ error: 'Unsupported update action' }, { status: 400 }) + return NextResponse.json({ + message: 'Schedule activated successfully', + nextRunAt, + }) } catch (error) { logger.error(`[${requestId}] Error updating schedule`, error) return NextResponse.json({ error: 'Failed to update schedule' }, { status: 500 }) diff --git a/apps/sim/app/api/schedules/[id]/status/route.test.ts b/apps/sim/app/api/schedules/[id]/status/route.test.ts deleted file mode 100644 index 29d269ab0..000000000 --- a/apps/sim/app/api/schedules/[id]/status/route.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -/** - * Integration tests for schedule status API route - * - * @vitest-environment node - */ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { createMockRequest, mockScheduleStatusDb } from '@/app/api/__test-utils__/utils' - -// Common mocks -const mockSchedule = { - id: 'schedule-id', - workflowId: 'workflow-id', - status: 'active', - failedCount: 0, - lastRanAt: new Date('2024-01-01T00:00:00.000Z'), - lastFailedAt: null, - nextRunAt: new Date('2024-01-02T00:00:00.000Z'), -} - -beforeEach(() => { - vi.resetModules() - - vi.doMock('@/lib/logs/console/logger', () => ({ - createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }), - })) - - vi.doMock('crypto', () => ({ - randomUUID: vi.fn(() => 'test-uuid'), - default: { randomUUID: vi.fn(() => 'test-uuid') }, - })) -}) - -afterEach(() => { - vi.clearAllMocks() -}) - -describe('Schedule Status API Route', () => { - it('returns schedule status successfully', async () => { - mockScheduleStatusDb({}) // default mocks - - vi.doMock('@/lib/auth', () => ({ - getSession: vi.fn().mockResolvedValue({ user: { id: 'user-id' } }), - })) - - const req = createMockRequest('GET') - - const { GET } = await import('@/app/api/schedules/[id]/status/route') - - const res = await GET(req, { params: Promise.resolve({ id: 'schedule-id' }) }) - - expect(res.status).toBe(200) - const data = await res.json() - - expect(data).toMatchObject({ - status: 'active', - failedCount: 0, - nextRunAt: mockSchedule.nextRunAt.toISOString(), - isDisabled: false, - }) - }) - - it('marks disabled schedules with isDisabled = true', async () => { - mockScheduleStatusDb({ schedule: [{ ...mockSchedule, status: 'disabled' }] }) - - vi.doMock('@/lib/auth', () => ({ - getSession: vi.fn().mockResolvedValue({ user: { id: 'user-id' } }), - })) - - const req = createMockRequest('GET') - const { GET } = await import('@/app/api/schedules/[id]/status/route') - const res = await GET(req, { params: Promise.resolve({ id: 'schedule-id' }) }) - - expect(res.status).toBe(200) - const data = await res.json() - expect(data).toHaveProperty('status', 'disabled') - expect(data).toHaveProperty('isDisabled', true) - expect(data).toHaveProperty('lastFailedAt') - }) - - it('returns 404 if schedule not found', async () => { - mockScheduleStatusDb({ schedule: [] }) - - vi.doMock('@/lib/auth', () => ({ - getSession: vi.fn().mockResolvedValue({ user: { id: 'user-id' } }), - })) - - const req = createMockRequest('GET') - const { GET } = await import('@/app/api/schedules/[id]/status/route') - const res = await GET(req, { params: Promise.resolve({ id: 'missing-id' }) }) - - expect(res.status).toBe(404) - const data = await res.json() - expect(data).toHaveProperty('error', 'Schedule not found') - }) - - it('returns 404 if related workflow not found', async () => { - mockScheduleStatusDb({ workflow: [] }) - - vi.doMock('@/lib/auth', () => ({ - getSession: vi.fn().mockResolvedValue({ user: { id: 'user-id' } }), - })) - - const req = createMockRequest('GET') - const { GET } = await import('@/app/api/schedules/[id]/status/route') - const res = await GET(req, { params: Promise.resolve({ id: 'schedule-id' }) }) - - expect(res.status).toBe(404) - const data = await res.json() - expect(data).toHaveProperty('error', 'Workflow not found') - }) - - it('returns 403 when user is not owner of workflow', async () => { - mockScheduleStatusDb({ workflow: [{ userId: 'another-user' }] }) - - vi.doMock('@/lib/auth', () => ({ - getSession: vi.fn().mockResolvedValue({ user: { id: 'user-id' } }), - })) - - const req = createMockRequest('GET') - const { GET } = await import('@/app/api/schedules/[id]/status/route') - const res = await GET(req, { params: Promise.resolve({ id: 'schedule-id' }) }) - - expect(res.status).toBe(403) - const data = await res.json() - expect(data).toHaveProperty('error', 'Not authorized to view this schedule') - }) - - it('returns 401 when user is not authenticated', async () => { - mockScheduleStatusDb({}) - - vi.doMock('@/lib/auth', () => ({ - getSession: vi.fn().mockResolvedValue(null), - })) - - const req = createMockRequest('GET') - const { GET } = await import('@/app/api/schedules/[id]/status/route') - const res = await GET(req, { params: Promise.resolve({ id: 'schedule-id' }) }) - - expect(res.status).toBe(401) - const data = await res.json() - expect(data).toHaveProperty('error', 'Unauthorized') - }) -}) diff --git a/apps/sim/app/api/schedules/[id]/status/route.ts b/apps/sim/app/api/schedules/[id]/status/route.ts deleted file mode 100644 index 59c7533b6..000000000 --- a/apps/sim/app/api/schedules/[id]/status/route.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { db } from '@sim/db' -import { workflow, workflowSchedule } from '@sim/db/schema' -import { eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { getSession } from '@/lib/auth' -import { generateRequestId } from '@/lib/core/utils/request' -import { createLogger } from '@/lib/logs/console/logger' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' - -const logger = createLogger('ScheduleStatusAPI') - -export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateRequestId() - const { id } = await params - const scheduleId = id - - try { - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized schedule status request`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const [schedule] = await db - .select({ - id: workflowSchedule.id, - workflowId: workflowSchedule.workflowId, - status: workflowSchedule.status, - failedCount: workflowSchedule.failedCount, - lastRanAt: workflowSchedule.lastRanAt, - lastFailedAt: workflowSchedule.lastFailedAt, - nextRunAt: workflowSchedule.nextRunAt, - }) - .from(workflowSchedule) - .where(eq(workflowSchedule.id, scheduleId)) - .limit(1) - - if (!schedule) { - logger.warn(`[${requestId}] Schedule not found: ${scheduleId}`) - return NextResponse.json({ error: 'Schedule not found' }, { status: 404 }) - } - - const [workflowRecord] = await db - .select({ userId: workflow.userId, workspaceId: workflow.workspaceId }) - .from(workflow) - .where(eq(workflow.id, schedule.workflowId)) - .limit(1) - - if (!workflowRecord) { - logger.warn(`[${requestId}] Workflow not found for schedule: ${scheduleId}`) - return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) - } - - // Check authorization - either the user owns the workflow or has workspace permissions - let isAuthorized = workflowRecord.userId === session.user.id - - // If not authorized by ownership and the workflow belongs to a workspace, check workspace permissions - if (!isAuthorized && workflowRecord.workspaceId) { - const userPermission = await getUserEntityPermissions( - session.user.id, - 'workspace', - workflowRecord.workspaceId - ) - isAuthorized = userPermission !== null - } - - if (!isAuthorized) { - logger.warn(`[${requestId}] User not authorized to view this schedule: ${scheduleId}`) - return NextResponse.json({ error: 'Not authorized to view this schedule' }, { status: 403 }) - } - - return NextResponse.json({ - status: schedule.status, - failedCount: schedule.failedCount, - lastRanAt: schedule.lastRanAt, - lastFailedAt: schedule.lastFailedAt, - nextRunAt: schedule.nextRunAt, - isDisabled: schedule.status === 'disabled', - }) - } catch (error) { - logger.error(`[${requestId}] Error retrieving schedule status: ${scheduleId}`, error) - return NextResponse.json({ error: 'Failed to retrieve schedule status' }, { status: 500 }) - } -} diff --git a/apps/sim/app/api/schedules/route.test.ts b/apps/sim/app/api/schedules/route.test.ts index bfc65ec1c..ac1ece178 100644 --- a/apps/sim/app/api/schedules/route.test.ts +++ b/apps/sim/app/api/schedules/route.test.ts @@ -1,43 +1,15 @@ /** - * Integration tests for schedule configuration API route + * Tests for schedule GET API route * * @vitest-environment node */ +import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { createMockRequest, mockExecutionDependencies } from '@/app/api/__test-utils__/utils' -const { - mockGetSession, - mockGetUserEntityPermissions, - mockSelectLimit, - mockInsertValues, - mockOnConflictDoUpdate, - mockInsert, - mockUpdate, - mockDelete, - mockTransaction, - mockRandomUUID, - mockGetScheduleTimeValues, - mockGetSubBlockValue, - mockGenerateCronExpression, - mockCalculateNextRunTime, - mockValidateCronExpression, -} = vi.hoisted(() => ({ +const { mockGetSession, mockGetUserEntityPermissions, mockDbSelect } = vi.hoisted(() => ({ mockGetSession: vi.fn(), mockGetUserEntityPermissions: vi.fn(), - mockSelectLimit: vi.fn(), - mockInsertValues: vi.fn(), - mockOnConflictDoUpdate: vi.fn(), - mockInsert: vi.fn(), - mockUpdate: vi.fn(), - mockDelete: vi.fn(), - mockTransaction: vi.fn(), - mockRandomUUID: vi.fn(), - mockGetScheduleTimeValues: vi.fn(), - mockGetSubBlockValue: vi.fn(), - mockGenerateCronExpression: vi.fn(), - mockCalculateNextRunTime: vi.fn(), - mockValidateCronExpression: vi.fn(), + mockDbSelect: vi.fn(), })) vi.mock('@/lib/auth', () => ({ @@ -50,231 +22,136 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({ vi.mock('@sim/db', () => ({ db: { - select: vi.fn().mockReturnValue({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: mockSelectLimit, - }), - }), - }), - insert: mockInsert, - update: mockUpdate, - delete: mockDelete, + select: mockDbSelect, }, })) vi.mock('@sim/db/schema', () => ({ - workflow: { - id: 'workflow_id', - userId: 'user_id', - workspaceId: 'workspace_id', - }, - workflowSchedule: { - id: 'schedule_id', - workflowId: 'workflow_id', - blockId: 'block_id', - cronExpression: 'cron_expression', - nextRunAt: 'next_run_at', - status: 'status', - }, + workflow: { id: 'id', userId: 'userId', workspaceId: 'workspaceId' }, + workflowSchedule: { workflowId: 'workflowId', blockId: 'blockId' }, })) vi.mock('drizzle-orm', () => ({ - eq: vi.fn((...args) => ({ type: 'eq', args })), - and: vi.fn((...args) => ({ type: 'and', args })), -})) - -vi.mock('crypto', () => ({ - randomUUID: mockRandomUUID, - default: { - randomUUID: mockRandomUUID, - }, -})) - -vi.mock('@/lib/workflows/schedules/utils', () => ({ - getScheduleTimeValues: mockGetScheduleTimeValues, - getSubBlockValue: mockGetSubBlockValue, - generateCronExpression: mockGenerateCronExpression, - calculateNextRunTime: mockCalculateNextRunTime, - validateCronExpression: mockValidateCronExpression, - BlockState: {}, + eq: vi.fn(), + and: vi.fn(), })) vi.mock('@/lib/core/utils/request', () => ({ - generateRequestId: vi.fn(() => 'test-request-id'), + generateRequestId: () => 'test-request-id', })) vi.mock('@/lib/logs/console/logger', () => ({ - createLogger: vi.fn(() => ({ + createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), - debug: vi.fn(), - })), + }), })) -vi.mock('@/lib/core/telemetry', () => ({ - trackPlatformEvent: vi.fn(), -})) +import { GET } from '@/app/api/schedules/route' -import { db } from '@sim/db' -import { POST } from '@/app/api/schedules/route' +function createRequest(url: string): NextRequest { + return new NextRequest(new URL(url), { method: 'GET' }) +} -describe('Schedule Configuration API Route', () => { +function mockDbChain(results: any[]) { + let callIndex = 0 + mockDbSelect.mockImplementation(() => ({ + from: () => ({ + where: () => ({ + limit: () => results[callIndex++] || [], + }), + }), + })) +} + +describe('Schedule GET API', () => { beforeEach(() => { vi.clearAllMocks() - - ;(db as any).transaction = mockTransaction - - mockExecutionDependencies() - - mockGetSession.mockResolvedValue({ - user: { - id: 'user-id', - email: 'test@example.com', - }, - }) - - mockGetUserEntityPermissions.mockResolvedValue('admin') - - mockSelectLimit.mockReturnValue([ - { - id: 'workflow-id', - userId: 'user-id', - workspaceId: null, - }, - ]) - - mockInsertValues.mockImplementation(() => ({ - onConflictDoUpdate: mockOnConflictDoUpdate, - })) - mockOnConflictDoUpdate.mockResolvedValue({}) - - mockInsert.mockReturnValue({ - values: mockInsertValues, - }) - - mockUpdate.mockImplementation(() => ({ - set: vi.fn().mockImplementation(() => ({ - where: vi.fn().mockResolvedValue([]), - })), - })) - - mockDelete.mockImplementation(() => ({ - where: vi.fn().mockResolvedValue([]), - })) - - mockTransaction.mockImplementation(async (callback) => { - const tx = { - insert: vi.fn().mockReturnValue({ - values: mockInsertValues, - }), - } - return callback(tx) - }) - - mockRandomUUID.mockReturnValue('test-uuid') - - mockGetScheduleTimeValues.mockReturnValue({ - scheduleTime: '09:30', - minutesInterval: 15, - hourlyMinute: 0, - dailyTime: [9, 30], - weeklyDay: 1, - weeklyTime: [9, 30], - monthlyDay: 1, - monthlyTime: [9, 30], - }) - - mockGetSubBlockValue.mockImplementation((block: any, id: string) => { - const subBlocks = { - startWorkflow: 'schedule', - scheduleType: 'daily', - scheduleTime: '09:30', - dailyTime: '09:30', - } - return subBlocks[id as keyof typeof subBlocks] || '' - }) - - mockGenerateCronExpression.mockReturnValue('0 9 * * *') - mockCalculateNextRunTime.mockReturnValue(new Date()) - mockValidateCronExpression.mockReturnValue({ isValid: true }) + mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) + mockGetUserEntityPermissions.mockResolvedValue('read') }) afterEach(() => { vi.clearAllMocks() }) - it('should create a new schedule successfully', async () => { - const req = createMockRequest('POST', { - workflowId: 'workflow-id', - state: { - blocks: { - 'starter-id': { - type: 'starter', - subBlocks: { - startWorkflow: { value: 'schedule' }, - scheduleType: { value: 'daily' }, - scheduleTime: { value: '09:30' }, - dailyTime: { value: '09:30' }, - }, - }, - }, - edges: [], - loops: {}, - }, - }) + it('returns schedule data for authorized user', async () => { + mockDbChain([ + [{ userId: 'user-1', workspaceId: null }], + [{ id: 'sched-1', cronExpression: '0 9 * * *', status: 'active', failedCount: 0 }], + ]) - const response = await POST(req) + const res = await GET(createRequest('http://test/api/schedules?workflowId=wf-1')) + const data = await res.json() - expect(response).toBeDefined() - expect(response.status).toBe(200) - - const responseData = await response.json() - expect(responseData).toHaveProperty('message', 'Schedule updated') - expect(responseData).toHaveProperty('cronExpression', '0 9 * * *') - expect(responseData).toHaveProperty('nextRunAt') + expect(res.status).toBe(200) + expect(data.schedule.cronExpression).toBe('0 9 * * *') + expect(data.isDisabled).toBe(false) }) - it('should handle errors gracefully', async () => { - mockSelectLimit.mockReturnValue([]) + it('returns null when no schedule exists', async () => { + mockDbChain([[{ userId: 'user-1', workspaceId: null }], []]) - const req = createMockRequest('POST', { - workflowId: 'workflow-id', - state: { blocks: {}, edges: [], loops: {} }, - }) + const res = await GET(createRequest('http://test/api/schedules?workflowId=wf-1')) + const data = await res.json() - const response = await POST(req) - - expect(response.status).toBeGreaterThanOrEqual(400) - const data = await response.json() - expect(data).toHaveProperty('error') + expect(res.status).toBe(200) + expect(data.schedule).toBeNull() }) - it('should require authentication', async () => { + it('requires authentication', async () => { mockGetSession.mockResolvedValue(null) - const req = createMockRequest('POST', { - workflowId: 'workflow-id', - state: { blocks: {}, edges: [], loops: {} }, - }) + const res = await GET(createRequest('http://test/api/schedules?workflowId=wf-1')) - const response = await POST(req) - - expect(response.status).toBe(401) - const data = await response.json() - expect(data).toHaveProperty('error', 'Unauthorized') + expect(res.status).toBe(401) }) - it('should validate input data', async () => { - const req = createMockRequest('POST', { - workflowId: 'workflow-id', - }) + it('requires workflowId parameter', async () => { + const res = await GET(createRequest('http://test/api/schedules')) - const response = await POST(req) + expect(res.status).toBe(400) + }) - expect(response.status).toBe(400) - const data = await response.json() - expect(data).toHaveProperty('error', 'Invalid request data') + it('returns 404 for non-existent workflow', async () => { + mockDbChain([[]]) + + const res = await GET(createRequest('http://test/api/schedules?workflowId=wf-1')) + + expect(res.status).toBe(404) + }) + + it('denies access for unauthorized user', async () => { + mockDbChain([[{ userId: 'other-user', workspaceId: null }]]) + + const res = await GET(createRequest('http://test/api/schedules?workflowId=wf-1')) + + expect(res.status).toBe(403) + }) + + it('allows workspace members to view', async () => { + mockDbChain([ + [{ userId: 'other-user', workspaceId: 'ws-1' }], + [{ id: 'sched-1', status: 'active', failedCount: 0 }], + ]) + + const res = await GET(createRequest('http://test/api/schedules?workflowId=wf-1')) + + expect(res.status).toBe(200) + }) + + it('indicates disabled schedule with failures', async () => { + mockDbChain([ + [{ userId: 'user-1', workspaceId: null }], + [{ id: 'sched-1', status: 'disabled', failedCount: 10 }], + ]) + + const res = await GET(createRequest('http://test/api/schedules?workflowId=wf-1')) + const data = await res.json() + + expect(res.status).toBe(200) + expect(data.isDisabled).toBe(true) + expect(data.hasFailures).toBe(true) }) }) diff --git a/apps/sim/app/api/schedules/route.ts b/apps/sim/app/api/schedules/route.ts index 65c0ad30c..07f8cbc95 100644 --- a/apps/sim/app/api/schedules/route.ts +++ b/apps/sim/app/api/schedules/route.ts @@ -2,61 +2,13 @@ import { db } from '@sim/db' import { workflow, workflowSchedule } from '@sim/db/schema' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { createLogger } from '@/lib/logs/console/logger' -import { - type BlockState, - calculateNextRunTime, - generateCronExpression, - getScheduleTimeValues, - getSubBlockValue, - validateCronExpression, -} from '@/lib/workflows/schedules/utils' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('ScheduledAPI') -const ScheduleRequestSchema = z.object({ - workflowId: z.string(), - blockId: z.string().optional(), - state: z.object({ - blocks: z.record(z.any()), - edges: z.array(z.any()), - loops: z.record(z.any()), - }), -}) - -function hasValidScheduleConfig( - scheduleType: string | undefined, - scheduleValues: ReturnType, - starterBlock: BlockState -): boolean { - switch (scheduleType) { - case 'minutes': - return !!scheduleValues.minutesInterval - case 'hourly': - return scheduleValues.hourlyMinute !== undefined - case 'daily': - return !!scheduleValues.dailyTime[0] || !!scheduleValues.dailyTime[1] - case 'weekly': - return ( - !!scheduleValues.weeklyDay && - (!!scheduleValues.weeklyTime[0] || !!scheduleValues.weeklyTime[1]) - ) - case 'monthly': - return ( - !!scheduleValues.monthlyDay && - (!!scheduleValues.monthlyTime[0] || !!scheduleValues.monthlyTime[1]) - ) - case 'custom': - return !!getSubBlockValue(starterBlock, 'cronExpression') - default: - return false - } -} - /** * Get schedule information for a workflow */ @@ -65,11 +17,6 @@ export async function GET(req: NextRequest) { const url = new URL(req.url) const workflowId = url.searchParams.get('workflowId') const blockId = url.searchParams.get('blockId') - const mode = url.searchParams.get('mode') - - if (mode && mode !== 'schedule') { - return NextResponse.json({ schedule: null }) - } try { const session = await getSession() @@ -145,262 +92,3 @@ export async function GET(req: NextRequest) { return NextResponse.json({ error: 'Failed to retrieve workflow schedule' }, { status: 500 }) } } - -const saveAttempts = new Map() -const RATE_LIMIT_WINDOW = 60000 // 1 minute -const RATE_LIMIT_MAX = 10 // 10 saves per minute - -/** - * Create or update a schedule for a workflow - */ -export async function POST(req: NextRequest) { - const requestId = generateRequestId() - - try { - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized schedule update attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const now = Date.now() - const userKey = session.user.id - const limit = saveAttempts.get(userKey) - - if (limit && limit.resetAt > now) { - if (limit.count >= RATE_LIMIT_MAX) { - logger.warn(`[${requestId}] Rate limit exceeded for user: ${userKey}`) - return NextResponse.json( - { error: 'Too many save attempts. Please wait a moment and try again.' }, - { status: 429 } - ) - } - limit.count++ - } else { - saveAttempts.set(userKey, { count: 1, resetAt: now + RATE_LIMIT_WINDOW }) - } - - const body = await req.json() - const { workflowId, blockId, state } = ScheduleRequestSchema.parse(body) - - logger.info(`[${requestId}] Processing schedule update for workflow ${workflowId}`) - - const [workflowRecord] = await db - .select({ userId: workflow.userId, workspaceId: workflow.workspaceId }) - .from(workflow) - .where(eq(workflow.id, workflowId)) - .limit(1) - - if (!workflowRecord) { - logger.warn(`[${requestId}] Workflow not found: ${workflowId}`) - return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) - } - - let isAuthorized = workflowRecord.userId === session.user.id - - if (!isAuthorized && workflowRecord.workspaceId) { - const userPermission = await getUserEntityPermissions( - session.user.id, - 'workspace', - workflowRecord.workspaceId - ) - isAuthorized = userPermission === 'write' || userPermission === 'admin' - } - - if (!isAuthorized) { - logger.warn( - `[${requestId}] User not authorized to modify schedule for workflow: ${workflowId}` - ) - return NextResponse.json({ error: 'Not authorized to modify this workflow' }, { status: 403 }) - } - - let targetBlock: BlockState | undefined - if (blockId) { - targetBlock = Object.values(state.blocks).find((block: any) => block.id === blockId) as - | BlockState - | undefined - } else { - targetBlock = Object.values(state.blocks).find( - (block: any) => block.type === 'starter' || block.type === 'schedule' - ) as BlockState | undefined - } - - if (!targetBlock) { - logger.warn(`[${requestId}] No starter or schedule block found in workflow ${workflowId}`) - return NextResponse.json( - { error: 'No starter or schedule block found in workflow' }, - { status: 400 } - ) - } - - const startWorkflow = getSubBlockValue(targetBlock, 'startWorkflow') - const scheduleType = getSubBlockValue(targetBlock, 'scheduleType') - - const scheduleValues = getScheduleTimeValues(targetBlock) - - const hasScheduleConfig = hasValidScheduleConfig(scheduleType, scheduleValues, targetBlock) - - const isScheduleBlock = targetBlock.type === 'schedule' - const hasValidConfig = isScheduleBlock || (startWorkflow === 'schedule' && hasScheduleConfig) - - logger.info(`[${requestId}] Schedule validation debug:`, { - workflowId, - blockId, - blockType: targetBlock.type, - isScheduleBlock, - startWorkflow, - scheduleType, - hasScheduleConfig, - hasValidConfig, - scheduleValues: { - minutesInterval: scheduleValues.minutesInterval, - dailyTime: scheduleValues.dailyTime, - cronExpression: scheduleValues.cronExpression, - }, - }) - - if (!hasValidConfig) { - logger.info( - `[${requestId}] Removing schedule for workflow ${workflowId} - no valid configuration found` - ) - const deleteConditions = [eq(workflowSchedule.workflowId, workflowId)] - if (blockId) { - deleteConditions.push(eq(workflowSchedule.blockId, blockId)) - } - - await db - .delete(workflowSchedule) - .where(deleteConditions.length > 1 ? and(...deleteConditions) : deleteConditions[0]) - - return NextResponse.json({ message: 'Schedule removed' }) - } - - if (isScheduleBlock) { - logger.info(`[${requestId}] Processing schedule trigger block for workflow ${workflowId}`) - } else if (startWorkflow !== 'schedule') { - logger.info( - `[${requestId}] Setting workflow to scheduled mode based on schedule configuration` - ) - } - - logger.debug(`[${requestId}] Schedule type for workflow ${workflowId}: ${scheduleType}`) - - let cronExpression: string | null = null - let nextRunAt: Date | undefined - const timezone = getSubBlockValue(targetBlock, 'timezone') || 'UTC' - - try { - const defaultScheduleType = scheduleType || 'daily' - const scheduleStartAt = getSubBlockValue(targetBlock, 'scheduleStartAt') - const scheduleTime = getSubBlockValue(targetBlock, 'scheduleTime') - - logger.debug(`[${requestId}] Schedule configuration:`, { - type: defaultScheduleType, - timezone, - startDate: scheduleStartAt || 'not specified', - time: scheduleTime || 'not specified', - }) - - const sanitizedScheduleValues = - defaultScheduleType !== 'custom' - ? { ...scheduleValues, cronExpression: null } - : scheduleValues - - cronExpression = generateCronExpression(defaultScheduleType, sanitizedScheduleValues) - - if (cronExpression) { - const validation = validateCronExpression(cronExpression, timezone) - if (!validation.isValid) { - logger.error(`[${requestId}] Invalid cron expression: ${validation.error}`, { - scheduleType: defaultScheduleType, - cronExpression, - }) - return NextResponse.json( - { error: `Invalid schedule configuration: ${validation.error}` }, - { status: 400 } - ) - } - } - - nextRunAt = calculateNextRunTime(defaultScheduleType, sanitizedScheduleValues) - - logger.debug( - `[${requestId}] Generated cron: ${cronExpression}, next run at: ${nextRunAt.toISOString()}` - ) - } catch (error: any) { - logger.error(`[${requestId}] Error generating schedule: ${error}`) - const errorMessage = error?.message || 'Failed to generate schedule' - return NextResponse.json({ error: errorMessage }, { status: 400 }) - } - - const values = { - id: crypto.randomUUID(), - workflowId, - blockId, - cronExpression, - triggerType: 'schedule', - createdAt: new Date(), - updatedAt: new Date(), - nextRunAt, - timezone, - status: 'active', // Ensure new schedules are active - failedCount: 0, // Reset failure count for new schedules - } - - const setValues = { - blockId, - cronExpression, - updatedAt: new Date(), - nextRunAt, - timezone, - status: 'active', // Reactivate if previously disabled - failedCount: 0, // Reset failure count on reconfiguration - } - - await db.transaction(async (tx) => { - await tx - .insert(workflowSchedule) - .values(values) - .onConflictDoUpdate({ - target: [workflowSchedule.workflowId, workflowSchedule.blockId], - set: setValues, - }) - }) - - logger.info(`[${requestId}] Schedule updated for workflow ${workflowId}`, { - nextRunAt: nextRunAt?.toISOString(), - cronExpression, - }) - - try { - const { trackPlatformEvent } = await import('@/lib/core/telemetry') - trackPlatformEvent('platform.schedule.created', { - 'workflow.id': workflowId, - 'schedule.type': scheduleType || 'daily', - 'schedule.timezone': timezone, - 'schedule.is_custom': scheduleType === 'custom', - }) - } catch (_e) { - // Silently fail - } - - return NextResponse.json({ - message: 'Schedule updated', - schedule: { id: values.id }, - nextRunAt, - cronExpression, - }) - } catch (error: any) { - logger.error(`[${requestId}] Error updating workflow schedule`, error) - - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - - const errorMessage = error?.message || 'Failed to update workflow schedule' - return NextResponse.json({ error: errorMessage }, { status: 500 }) - } -} diff --git a/apps/sim/app/api/workflows/[id]/deploy/route.ts b/apps/sim/app/api/workflows/[id]/deploy/route.ts index 58a291ea2..cb898ff5d 100644 --- a/apps/sim/app/api/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/workflows/[id]/deploy/route.ts @@ -3,7 +3,12 @@ import { and, desc, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { generateRequestId } from '@/lib/core/utils/request' import { createLogger } from '@/lib/logs/console/logger' -import { deployWorkflow } from '@/lib/workflows/persistence/utils' +import { deployWorkflow, loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' +import { + createSchedulesForDeploy, + deleteSchedulesForWorkflow, + validateWorkflowSchedules, +} from '@/lib/workflows/schedules' import { validateWorkflowPermissions } from '@/lib/workflows/utils' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' @@ -98,13 +103,25 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ return createErrorResponse(error.message, error.status) } - // Attribution: this route is UI-only; require session user as actor const actorUserId: string | null = session?.user?.id ?? null if (!actorUserId) { logger.warn(`[${requestId}] Unable to resolve actor user for workflow deployment: ${id}`) return createErrorResponse('Unable to determine deploying user', 400) } + const normalizedData = await loadWorkflowFromNormalizedTables(id) + if (!normalizedData) { + return createErrorResponse('Failed to load workflow state', 500) + } + + const scheduleValidation = validateWorkflowSchedules(normalizedData.blocks) + if (!scheduleValidation.isValid) { + logger.warn( + `[${requestId}] Schedule validation failed for workflow ${id}: ${scheduleValidation.error}` + ) + return createErrorResponse(`Invalid schedule configuration: ${scheduleValidation.error}`, 400) + } + const deployResult = await deployWorkflow({ workflowId: id, deployedBy: actorUserId, @@ -117,6 +134,23 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ const deployedAt = deployResult.deployedAt! + let scheduleInfo: { scheduleId?: string; cronExpression?: string; nextRunAt?: Date } = {} + const scheduleResult = await createSchedulesForDeploy(id, normalizedData.blocks, db) + if (!scheduleResult.success) { + logger.error( + `[${requestId}] Failed to create schedule for workflow ${id}: ${scheduleResult.error}` + ) + } else if (scheduleResult.scheduleId) { + scheduleInfo = { + scheduleId: scheduleResult.scheduleId, + cronExpression: scheduleResult.cronExpression, + nextRunAt: scheduleResult.nextRunAt, + } + logger.info( + `[${requestId}] Schedule created for workflow ${id}: ${scheduleResult.scheduleId}` + ) + } + logger.info(`[${requestId}] Workflow deployed successfully: ${id}`) const responseApiKeyInfo = workflowData!.workspaceId @@ -127,6 +161,13 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ apiKey: responseApiKeyInfo, isDeployed: true, deployedAt, + schedule: scheduleInfo.scheduleId + ? { + id: scheduleInfo.scheduleId, + cronExpression: scheduleInfo.cronExpression, + nextRunAt: scheduleInfo.nextRunAt, + } + : undefined, }) } catch (error: any) { logger.error(`[${requestId}] Error deploying workflow: ${id}`, { @@ -156,6 +197,8 @@ export async function DELETE( } await db.transaction(async (tx) => { + await deleteSchedulesForWorkflow(id, tx) + await tx .update(workflowDeploymentVersion) .set({ isActive: false }) @@ -169,7 +212,6 @@ export async function DELETE( logger.info(`[${requestId}] Workflow undeployed successfully: ${id}`) - // Track workflow undeployment try { const { trackPlatformEvent } = await import('@/lib/core/telemetry') trackPlatformEvent('platform.workflow.undeployed', { diff --git a/apps/sim/app/api/workflows/[id]/state/route.ts b/apps/sim/app/api/workflows/[id]/state/route.ts index 0d85044b2..ba68cb696 100644 --- a/apps/sim/app/api/workflows/[id]/state/route.ts +++ b/apps/sim/app/api/workflows/[id]/state/route.ts @@ -1,5 +1,5 @@ import { db } from '@sim/db' -import { webhook, workflow, workflowSchedule } from '@sim/db/schema' +import { webhook, workflow } from '@sim/db/schema' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' @@ -10,12 +10,6 @@ import { createLogger } from '@/lib/logs/console/logger' import { extractAndPersistCustomTools } from '@/lib/workflows/persistence/custom-tools-persistence' import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/sanitization/validation' -import { - calculateNextRunTime, - generateCronExpression, - getScheduleTimeValues, - validateCronExpression, -} from '@/lib/workflows/schedules/utils' import { getWorkflowAccessContext } from '@/lib/workflows/utils' import type { BlockState } from '@/stores/workflows/workflow/types' import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils' @@ -210,7 +204,6 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ } await syncWorkflowWebhooks(workflowId, workflowState.blocks) - await syncWorkflowSchedules(workflowId, workflowState.blocks) // Extract and persist custom tools to database try { @@ -318,79 +311,6 @@ async function syncWorkflowWebhooks( }) } -type ScheduleBlockInput = Parameters[0] - -async function syncWorkflowSchedules( - workflowId: string, - blocks: Record -): Promise { - await syncBlockResources(workflowId, blocks, { - resourceName: 'schedule', - subBlockId: 'scheduleId', - buildMetadata: buildScheduleMetadata, - applyMetadata: upsertScheduleRecord, - }) -} - -interface ScheduleMetadata { - cronExpression: string | null - nextRunAt: Date | null - timezone: string -} - -function buildScheduleMetadata(block: BlockState): ScheduleMetadata | null { - const scheduleType = getSubBlockValue(block, 'scheduleType') || 'daily' - const scheduleBlock = convertToScheduleBlock(block) - - const scheduleValues = getScheduleTimeValues(scheduleBlock) - const sanitizedValues = - scheduleType !== 'custom' ? { ...scheduleValues, cronExpression: null } : scheduleValues - - try { - const cronExpression = generateCronExpression(scheduleType, sanitizedValues) - const timezone = scheduleValues.timezone || 'UTC' - - if (cronExpression) { - const validation = validateCronExpression(cronExpression, timezone) - if (!validation.isValid) { - logger.warn('Invalid cron expression while syncing schedule', { - blockId: block.id, - cronExpression, - error: validation.error, - }) - return null - } - } - - const nextRunAt = calculateNextRunTime(scheduleType, sanitizedValues) - - return { - cronExpression, - timezone, - nextRunAt, - } - } catch (error) { - logger.error('Failed to build schedule metadata during sync', { - blockId: block.id, - error, - }) - return null - } -} - -function convertToScheduleBlock(block: BlockState): ScheduleBlockInput { - const subBlocks: ScheduleBlockInput['subBlocks'] = {} - - Object.entries(block.subBlocks || {}).forEach(([id, subBlock]) => { - subBlocks[id] = { value: stringifySubBlockValue(subBlock?.value) } - }) - - return { - type: block.type, - subBlocks, - } -} - interface WebhookMetadata { triggerPath: string provider: string | null @@ -473,58 +393,6 @@ async function upsertWebhookRecord( }) } -async function upsertScheduleRecord( - workflowId: string, - block: BlockState, - scheduleId: string, - metadata: ScheduleMetadata -): Promise { - const now = new Date() - const [existing] = await db - .select({ - id: workflowSchedule.id, - nextRunAt: workflowSchedule.nextRunAt, - }) - .from(workflowSchedule) - .where(eq(workflowSchedule.id, scheduleId)) - .limit(1) - - if (existing) { - await db - .update(workflowSchedule) - .set({ - workflowId, - blockId: block.id, - cronExpression: metadata.cronExpression, - nextRunAt: metadata.nextRunAt ?? existing.nextRunAt, - timezone: metadata.timezone, - updatedAt: now, - }) - .where(eq(workflowSchedule.id, scheduleId)) - return - } - - await db.insert(workflowSchedule).values({ - id: scheduleId, - workflowId, - blockId: block.id, - cronExpression: metadata.cronExpression, - nextRunAt: metadata.nextRunAt ?? null, - triggerType: 'schedule', - timezone: metadata.timezone, - status: 'active', - failedCount: 0, - createdAt: now, - updatedAt: now, - }) - - logger.info('Recreated missing schedule after workflow save', { - workflowId, - blockId: block.id, - scheduleId, - }) -} - interface BlockResourceSyncConfig { resourceName: string subBlockId: string @@ -573,27 +441,3 @@ async function syncBlockResources( } } } - -function stringifySubBlockValue(value: unknown): string { - if (value === undefined || value === null) { - return '' - } - - if (typeof value === 'string') { - return value - } - - if (typeof value === 'number' || typeof value === 'boolean') { - return String(value) - } - - if (value instanceof Date) { - return value.toISOString() - } - - try { - return JSON.stringify(value) - } catch { - return String(value) - } -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx index 7ea3acfd5..31c7645c9 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx @@ -464,6 +464,8 @@ export function DeployModal({ setDeploymentInfo((prev) => (prev ? { ...prev, needsRedeployment: false } : prev)) } catch (error: unknown) { logger.error('Error redeploying workflow:', { error }) + const errorMessage = error instanceof Error ? error.message : 'Failed to redeploy workflow' + setApiDeployError(errorMessage) } finally { setIsSubmitting(false) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-deployment.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-deployment.ts index 8d2186546..1851f77c9 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-deployment.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-deployment.ts @@ -1,6 +1,10 @@ import { useCallback, useState } from 'react' import { createLogger } from '@/lib/logs/console/logger' +import { useNotificationStore } from '@/stores/notifications/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +import { mergeSubblockState } from '@/stores/workflows/utils' +import { useWorkflowStore } from '@/stores/workflows/workflow/store' +import { runPreDeployChecks } from './use-predeploy-checks' const logger = createLogger('useDeployment') @@ -20,54 +24,94 @@ export function useDeployment({ }: UseDeploymentProps) { const [isDeploying, setIsDeploying] = useState(false) const setDeploymentStatus = useWorkflowRegistry((state) => state.setDeploymentStatus) + const addNotification = useNotificationStore((state) => state.addNotification) + const blocks = useWorkflowStore((state) => state.blocks) + const edges = useWorkflowStore((state) => state.edges) + const loops = useWorkflowStore((state) => state.loops) + const parallels = useWorkflowStore((state) => state.parallels) /** - * Handle initial deployment and open modal + * Handle deploy button click + * First deploy: calls API to deploy, then opens modal on success + * Redeploy: validates client-side, then opens modal if valid */ const handleDeployClick = useCallback(async () => { if (!workflowId) return { success: false, shouldOpenModal: false } - // If undeployed, deploy first then open modal - if (!isDeployed) { - setIsDeploying(true) - try { - const response = await fetch(`/api/workflows/${workflowId}/deploy`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - deployChatEnabled: false, - }), + if (isDeployed) { + const liveBlocks = mergeSubblockState(blocks, workflowId) + const checkResult = runPreDeployChecks({ + blocks: liveBlocks, + edges, + loops, + parallels, + workflowId, + }) + if (!checkResult.passed) { + addNotification({ + level: 'error', + message: checkResult.error || 'Pre-deploy validation failed', + workflowId, }) - - if (response.ok) { - const responseData = await response.json() - const isDeployedStatus = responseData.isDeployed ?? false - const deployedAtTime = responseData.deployedAt - ? new Date(responseData.deployedAt) - : undefined - setDeploymentStatus( - workflowId, - isDeployedStatus, - deployedAtTime, - responseData.apiKey || '' - ) - await refetchDeployedState() - return { success: true, shouldOpenModal: true } - } - return { success: false, shouldOpenModal: true } - } catch (error) { - logger.error('Error deploying workflow:', error) - return { success: false, shouldOpenModal: true } - } finally { - setIsDeploying(false) + return { success: false, shouldOpenModal: false } } + return { success: true, shouldOpenModal: true } } - // If already deployed, just signal to open modal - return { success: true, shouldOpenModal: true } - }, [workflowId, isDeployed, refetchDeployedState, setDeploymentStatus]) + setIsDeploying(true) + try { + const response = await fetch(`/api/workflows/${workflowId}/deploy`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + deployChatEnabled: false, + }), + }) + + if (response.ok) { + const responseData = await response.json() + const isDeployedStatus = responseData.isDeployed ?? false + const deployedAtTime = responseData.deployedAt + ? new Date(responseData.deployedAt) + : undefined + setDeploymentStatus(workflowId, isDeployedStatus, deployedAtTime, responseData.apiKey || '') + await refetchDeployedState() + return { success: true, shouldOpenModal: true } + } + + const errorData = await response.json() + const errorMessage = errorData.error || 'Failed to deploy workflow' + addNotification({ + level: 'error', + message: errorMessage, + workflowId, + }) + return { success: false, shouldOpenModal: false } + } catch (error) { + logger.error('Error deploying workflow:', error) + const errorMessage = error instanceof Error ? error.message : 'Failed to deploy workflow' + addNotification({ + level: 'error', + message: errorMessage, + workflowId, + }) + return { success: false, shouldOpenModal: false } + } finally { + setIsDeploying(false) + } + }, [ + workflowId, + isDeployed, + blocks, + edges, + loops, + parallels, + refetchDeployedState, + setDeploymentStatus, + addNotification, + ]) return { isDeploying, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-predeploy-checks.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-predeploy-checks.ts new file mode 100644 index 000000000..ef4d94122 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-predeploy-checks.ts @@ -0,0 +1,65 @@ +import type { Edge } from 'reactflow' +import { validateWorkflowSchedules } from '@/lib/workflows/schedules/validation' +import { Serializer } from '@/serializer' +import type { BlockState, Loop, Parallel } from '@/stores/workflows/workflow/types' + +export interface PreDeployCheckResult { + passed: boolean + error?: string +} + +export interface PreDeployContext { + blocks: Record + edges: Edge[] + loops: Record + parallels: Record + workflowId: string +} + +type PreDeployCheck = (context: PreDeployContext) => PreDeployCheckResult + +/** + * Validates schedule block configuration + */ +const scheduleValidationCheck: PreDeployCheck = ({ blocks }) => { + const result = validateWorkflowSchedules(blocks) + return { + passed: result.isValid, + error: result.error ? `Invalid schedule configuration: ${result.error}` : undefined, + } +} + +/** + * Validates required fields using the serializer's validation + */ +const requiredFieldsCheck: PreDeployCheck = ({ blocks, edges, loops, parallels }) => { + try { + const serializer = new Serializer() + serializer.serializeWorkflow(blocks, edges, loops, parallels, true) + return { passed: true } + } catch (error) { + return { + passed: false, + error: error instanceof Error ? error.message : 'Workflow validation failed', + } + } +} + +/** + * All pre-deploy checks in execution order + * Add new checks here as needed + */ +const preDeployChecks: PreDeployCheck[] = [scheduleValidationCheck, requiredFieldsCheck] + +/** + * Runs all pre-deploy checks and returns the first failure or success + */ +export function runPreDeployChecks(context: PreDeployContext): PreDeployCheckResult { + for (const check of preDeployChecks) { + const result = check(context) + if (!result.passed) { + return result + } + } + return { passed: true } +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts index dc5ba115e..4eaab626d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts @@ -21,7 +21,7 @@ export { McpToolSelector } from './mcp-server-modal/mcp-tool-selector' export { MessagesInput } from './messages-input/messages-input' export { ProjectSelectorInput } from './project-selector/project-selector-input' export { ResponseFormat } from './response/response-format' -export { ScheduleSave } from './schedule-save/schedule-save' +export { ScheduleInfo } from './schedule-info/schedule-info' export { ShortInput } from './short-input/short-input' export { SlackSelectorInput } from './slack-selector/slack-selector-input' export { SliderInput } from './slider-input/slider-input' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/schedule-info/schedule-info.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/schedule-info/schedule-info.tsx new file mode 100644 index 000000000..5c2f5e487 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/schedule-info/schedule-info.tsx @@ -0,0 +1,194 @@ +import { useCallback, useEffect, useState } from 'react' +import { AlertTriangle } from 'lucide-react' +import { useParams } from 'next/navigation' +import { createLogger } from '@/lib/logs/console/logger' +import { parseCronToHumanReadable } from '@/lib/workflows/schedules/utils' +import { useSubBlockStore } from '@/stores/workflows/subblock/store' + +const logger = createLogger('ScheduleStatus') + +interface ScheduleInfoProps { + blockId: string + isPreview?: boolean +} + +/** + * Schedule status display component. + * Shows the current schedule status, next run time, and last run time. + * Schedule creation/deletion is handled during workflow deploy/undeploy. + */ +export function ScheduleInfo({ blockId, isPreview = false }: ScheduleInfoProps) { + const params = useParams() + const workflowId = params.workflowId as string + const [scheduleStatus, setScheduleStatus] = useState<'active' | 'disabled' | null>(null) + const [nextRunAt, setNextRunAt] = useState(null) + const [lastRanAt, setLastRanAt] = useState(null) + const [failedCount, setFailedCount] = useState(0) + const [isLoadingStatus, setIsLoadingStatus] = useState(true) + const [savedCronExpression, setSavedCronExpression] = useState(null) + const [isRedeploying, setIsRedeploying] = useState(false) + const [hasSchedule, setHasSchedule] = useState(false) + + const scheduleTimezone = useSubBlockStore((state) => state.getValue(blockId, 'timezone')) + + const fetchScheduleStatus = useCallback(async () => { + if (isPreview) return + + setIsLoadingStatus(true) + try { + const response = await fetch(`/api/schedules?workflowId=${workflowId}&blockId=${blockId}`) + if (response.ok) { + const data = await response.json() + if (data.schedule) { + setHasSchedule(true) + setScheduleStatus(data.schedule.status) + setNextRunAt(data.schedule.nextRunAt ? new Date(data.schedule.nextRunAt) : null) + setLastRanAt(data.schedule.lastRanAt ? new Date(data.schedule.lastRanAt) : null) + setFailedCount(data.schedule.failedCount || 0) + setSavedCronExpression(data.schedule.cronExpression || null) + } else { + // No schedule exists (workflow not deployed or no schedule block) + setHasSchedule(false) + setScheduleStatus(null) + setNextRunAt(null) + setLastRanAt(null) + setFailedCount(0) + setSavedCronExpression(null) + } + } + } catch (error) { + logger.error('Error fetching schedule status', { error }) + } finally { + setIsLoadingStatus(false) + } + }, [workflowId, blockId, isPreview]) + + useEffect(() => { + if (!isPreview) { + fetchScheduleStatus() + } + }, [isPreview, fetchScheduleStatus]) + + /** + * Handles redeploying the workflow when schedule is disabled due to failures. + * Redeploying will recreate the schedule with reset failure count. + */ + const handleRedeploy = async () => { + if (isPreview || isRedeploying) return + + setIsRedeploying(true) + try { + const response = await fetch(`/api/workflows/${workflowId}/deploy`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ deployChatEnabled: false }), + }) + + if (response.ok) { + // Refresh schedule status after redeploy + await fetchScheduleStatus() + logger.info('Workflow redeployed successfully to reset schedule', { workflowId, blockId }) + } else { + const errorData = await response.json() + logger.error('Failed to redeploy workflow', { error: errorData.error }) + } + } catch (error) { + logger.error('Error redeploying workflow', { error }) + } finally { + setIsRedeploying(false) + } + } + + // Don't render anything if there's no deployed schedule + if (!hasSchedule && !isLoadingStatus) { + return null + } + + return ( +
+ {isLoadingStatus ? ( +
+
+ Loading schedule status... +
+ ) : ( +
+ {/* Failure badge with redeploy action */} + {failedCount >= 10 && scheduleStatus === 'disabled' && ( + + )} + + {/* Show warning for failed runs under threshold */} + {failedCount > 0 && failedCount < 10 && ( +
+ + ⚠️ {failedCount} failed run{failedCount !== 1 ? 's' : ''} + +
+ )} + + {/* Cron expression human-readable description */} + {savedCronExpression && ( +

+ Runs{' '} + {parseCronToHumanReadable( + savedCronExpression, + scheduleTimezone || 'UTC' + ).toLowerCase()} +

+ )} + + {/* Next run time */} + {nextRunAt && ( +

+ Next run:{' '} + {nextRunAt.toLocaleString('en-US', { + timeZone: scheduleTimezone || 'UTC', + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + hour12: true, + })}{' '} + {scheduleTimezone || 'UTC'} +

+ )} + + {/* Last ran time */} + {lastRanAt && ( +

+ Last ran:{' '} + {lastRanAt.toLocaleString('en-US', { + timeZone: scheduleTimezone || 'UTC', + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + hour12: true, + })}{' '} + {scheduleTimezone || 'UTC'} +

+ )} +
+ )} +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/schedule-save/schedule-save.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/schedule-save/schedule-save.tsx deleted file mode 100644 index 94bf588c4..000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/schedule-save/schedule-save.tsx +++ /dev/null @@ -1,499 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { useParams } from 'next/navigation' -import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/emcn' -import { Trash } from '@/components/emcn/icons/trash' -import { Alert, AlertDescription } from '@/components/ui/alert' -import { cn } from '@/lib/core/utils/cn' -import { createLogger } from '@/lib/logs/console/logger' -import { parseCronToHumanReadable } from '@/lib/workflows/schedules/utils' -import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' -import { useScheduleManagement } from '@/hooks/use-schedule-management' -import { useSubBlockStore } from '@/stores/workflows/subblock/store' - -const logger = createLogger('ScheduleSave') - -interface ScheduleSaveProps { - blockId: string - isPreview?: boolean - disabled?: boolean -} - -type SaveStatus = 'idle' | 'saving' | 'saved' | 'error' - -export function ScheduleSave({ blockId, isPreview = false, disabled = false }: ScheduleSaveProps) { - const params = useParams() - const workflowId = params.workflowId as string - const [saveStatus, setSaveStatus] = useState('idle') - const [errorMessage, setErrorMessage] = useState(null) - const [deleteStatus, setDeleteStatus] = useState<'idle' | 'deleting'>('idle') - const [showDeleteDialog, setShowDeleteDialog] = useState(false) - const [scheduleStatus, setScheduleStatus] = useState<'active' | 'disabled' | null>(null) - const [nextRunAt, setNextRunAt] = useState(null) - const [lastRanAt, setLastRanAt] = useState(null) - const [failedCount, setFailedCount] = useState(0) - const [isLoadingStatus, setIsLoadingStatus] = useState(false) - const [savedCronExpression, setSavedCronExpression] = useState(null) - - const { collaborativeSetSubblockValue } = useCollaborativeWorkflow() - - const { scheduleId, saveConfig, deleteConfig, isSaving } = useScheduleManagement({ - blockId, - isPreview, - }) - - const scheduleType = useSubBlockStore((state) => state.getValue(blockId, 'scheduleType')) - const scheduleMinutesInterval = useSubBlockStore((state) => - state.getValue(blockId, 'minutesInterval') - ) - const scheduleHourlyMinute = useSubBlockStore((state) => state.getValue(blockId, 'hourlyMinute')) - const scheduleDailyTime = useSubBlockStore((state) => state.getValue(blockId, 'dailyTime')) - const scheduleWeeklyDay = useSubBlockStore((state) => state.getValue(blockId, 'weeklyDay')) - const scheduleWeeklyTime = useSubBlockStore((state) => state.getValue(blockId, 'weeklyDayTime')) - const scheduleMonthlyDay = useSubBlockStore((state) => state.getValue(blockId, 'monthlyDay')) - const scheduleMonthlyTime = useSubBlockStore((state) => state.getValue(blockId, 'monthlyTime')) - const scheduleCronExpression = useSubBlockStore((state) => - state.getValue(blockId, 'cronExpression') - ) - const scheduleTimezone = useSubBlockStore((state) => state.getValue(blockId, 'timezone')) - - const validateRequiredFields = useCallback((): { valid: boolean; missingFields: string[] } => { - const missingFields: string[] = [] - - if (!scheduleType) { - missingFields.push('Frequency') - return { valid: false, missingFields } - } - - switch (scheduleType) { - case 'minutes': { - const minutesNum = Number(scheduleMinutesInterval) - if ( - !scheduleMinutesInterval || - Number.isNaN(minutesNum) || - minutesNum < 1 || - minutesNum > 1440 - ) { - missingFields.push('Minutes Interval (must be 1-1440)') - } - break - } - case 'hourly': { - const hourlyNum = Number(scheduleHourlyMinute) - if ( - scheduleHourlyMinute === null || - scheduleHourlyMinute === undefined || - scheduleHourlyMinute === '' || - Number.isNaN(hourlyNum) || - hourlyNum < 0 || - hourlyNum > 59 - ) { - missingFields.push('Minute (must be 0-59)') - } - break - } - case 'daily': - if (!scheduleDailyTime) { - missingFields.push('Time') - } - break - case 'weekly': - if (!scheduleWeeklyDay) { - missingFields.push('Day of Week') - } - if (!scheduleWeeklyTime) { - missingFields.push('Time') - } - break - case 'monthly': { - const monthlyNum = Number(scheduleMonthlyDay) - if (!scheduleMonthlyDay || Number.isNaN(monthlyNum) || monthlyNum < 1 || monthlyNum > 31) { - missingFields.push('Day of Month (must be 1-31)') - } - if (!scheduleMonthlyTime) { - missingFields.push('Time') - } - break - } - case 'custom': - if (!scheduleCronExpression) { - missingFields.push('Cron Expression') - } - break - } - - if (!scheduleTimezone && scheduleType !== 'minutes' && scheduleType !== 'hourly') { - missingFields.push('Timezone') - } - - return { - valid: missingFields.length === 0, - missingFields, - } - }, [ - scheduleType, - scheduleMinutesInterval, - scheduleHourlyMinute, - scheduleDailyTime, - scheduleWeeklyDay, - scheduleWeeklyTime, - scheduleMonthlyDay, - scheduleMonthlyTime, - scheduleCronExpression, - scheduleTimezone, - ]) - - const requiredSubBlockIds = useMemo(() => { - return [ - 'scheduleType', - 'minutesInterval', - 'hourlyMinute', - 'dailyTime', - 'weeklyDay', - 'weeklyDayTime', - 'monthlyDay', - 'monthlyTime', - 'cronExpression', - 'timezone', - ] - }, []) - - const subscribedSubBlockValues = useSubBlockStore( - useCallback( - (state) => { - const values: Record = {} - requiredSubBlockIds.forEach((subBlockId) => { - const value = state.getValue(blockId, subBlockId) - if (value !== null && value !== undefined && value !== '') { - values[subBlockId] = value - } - }) - return values - }, - [blockId, requiredSubBlockIds] - ) - ) - - const previousValuesRef = useRef>({}) - const validationTimeoutRef = useRef(null) - - useEffect(() => { - if (saveStatus !== 'error') { - previousValuesRef.current = subscribedSubBlockValues - return - } - - const hasChanges = Object.keys(subscribedSubBlockValues).some( - (key) => - previousValuesRef.current[key] !== (subscribedSubBlockValues as Record)[key] - ) - - if (!hasChanges) { - return - } - - if (validationTimeoutRef.current) { - clearTimeout(validationTimeoutRef.current) - } - - validationTimeoutRef.current = setTimeout(() => { - const validation = validateRequiredFields() - - if (validation.valid) { - setErrorMessage(null) - setSaveStatus('idle') - logger.debug('Error cleared after validation passed', { blockId }) - } else { - setErrorMessage(`Missing required fields: ${validation.missingFields.join(', ')}`) - logger.debug('Error message updated', { - blockId, - missingFields: validation.missingFields, - }) - } - - previousValuesRef.current = subscribedSubBlockValues - }, 300) - - return () => { - if (validationTimeoutRef.current) { - clearTimeout(validationTimeoutRef.current) - } - } - }, [blockId, subscribedSubBlockValues, saveStatus, validateRequiredFields]) - - const fetchScheduleStatus = useCallback(async () => { - if (!scheduleId || isPreview) return - - setIsLoadingStatus(true) - try { - const response = await fetch( - `/api/schedules?workflowId=${workflowId}&blockId=${blockId}&mode=schedule` - ) - if (response.ok) { - const data = await response.json() - if (data.schedule) { - setScheduleStatus(data.schedule.status) - setNextRunAt(data.schedule.nextRunAt ? new Date(data.schedule.nextRunAt) : null) - setLastRanAt(data.schedule.lastRanAt ? new Date(data.schedule.lastRanAt) : null) - setFailedCount(data.schedule.failedCount || 0) - setSavedCronExpression(data.schedule.cronExpression || null) - } - } - } catch (error) { - logger.error('Error fetching schedule status', { error }) - } finally { - setIsLoadingStatus(false) - } - }, [workflowId, blockId, scheduleId, isPreview]) - - useEffect(() => { - if (scheduleId && !isPreview) { - fetchScheduleStatus() - } - }, [scheduleId, isPreview, fetchScheduleStatus]) - - const handleSave = async () => { - if (isPreview || disabled) return - - setSaveStatus('saving') - setErrorMessage(null) - - try { - const validation = validateRequiredFields() - if (!validation.valid) { - setErrorMessage(`Missing required fields: ${validation.missingFields.join(', ')}`) - setSaveStatus('error') - return - } - - const result = await saveConfig() - if (!result.success) { - throw new Error('Save config returned false') - } - - setSaveStatus('saved') - setErrorMessage(null) - - const scheduleIdValue = useSubBlockStore.getState().getValue(blockId, 'scheduleId') - collaborativeSetSubblockValue(blockId, 'scheduleId', scheduleIdValue) - - if (result.nextRunAt) { - setNextRunAt(new Date(result.nextRunAt)) - setScheduleStatus('active') - } - - // Fetch additional status info, then apply cron from save result to prevent stale data - await fetchScheduleStatus() - - if (result.cronExpression) { - setSavedCronExpression(result.cronExpression) - } - - setTimeout(() => { - setSaveStatus('idle') - }, 2000) - - logger.info('Schedule configuration saved successfully', { - blockId, - hasScheduleId: !!scheduleId, - }) - } catch (error: any) { - setSaveStatus('error') - setErrorMessage(error.message || 'An error occurred while saving.') - logger.error('Error saving schedule config', { error }) - } - } - - const handleDelete = async () => { - if (isPreview || disabled) return - - setShowDeleteDialog(false) - setDeleteStatus('deleting') - - try { - const success = await deleteConfig() - if (!success) { - throw new Error('Failed to delete schedule') - } - - setScheduleStatus(null) - setNextRunAt(null) - setLastRanAt(null) - setFailedCount(0) - - collaborativeSetSubblockValue(blockId, 'scheduleId', null) - - logger.info('Schedule deleted successfully', { blockId }) - } catch (error: any) { - setErrorMessage(error.message || 'An error occurred while deleting.') - logger.error('Error deleting schedule', { error }) - } finally { - setDeleteStatus('idle') - } - } - - const handleDeleteConfirm = () => { - handleDelete() - } - - const handleToggleStatus = async () => { - if (!scheduleId || isPreview || disabled) return - - try { - const action = scheduleStatus === 'active' ? 'disable' : 'reactivate' - const response = await fetch(`/api/schedules/${scheduleId}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ action }), - }) - - if (response.ok) { - await fetchScheduleStatus() - logger.info(`Schedule ${action}d successfully`, { scheduleId }) - } else { - throw new Error(`Failed to ${action} schedule`) - } - } catch (error: any) { - setErrorMessage( - error.message || - `An error occurred while ${scheduleStatus === 'active' ? 'disabling' : 'reactivating'} the schedule.` - ) - logger.error('Error toggling schedule status', { error }) - } - } - - return ( -
-
- - - {scheduleId && ( - - )} -
- - {errorMessage && ( - - {errorMessage} - - )} - - {scheduleId && (scheduleStatus || isLoadingStatus || nextRunAt) && ( -
- {isLoadingStatus ? ( -
-
- Loading schedule status... -
- ) : ( - <> - {failedCount > 0 && ( -
- - ⚠️ {failedCount} failed run{failedCount !== 1 ? 's' : ''} - -
- )} - - {savedCronExpression && ( -

- Runs{' '} - {parseCronToHumanReadable( - savedCronExpression, - scheduleTimezone || 'UTC' - ).toLowerCase()} -

- )} - - {nextRunAt && ( -

- Next run:{' '} - {nextRunAt.toLocaleString('en-US', { - timeZone: scheduleTimezone || 'UTC', - year: 'numeric', - month: 'numeric', - day: 'numeric', - hour: 'numeric', - minute: '2-digit', - hour12: true, - })}{' '} - {scheduleTimezone || 'UTC'} -

- )} - - {lastRanAt && ( -

- Last ran:{' '} - {lastRanAt.toLocaleString('en-US', { - timeZone: scheduleTimezone || 'UTC', - year: 'numeric', - month: 'numeric', - day: 'numeric', - hour: 'numeric', - minute: '2-digit', - hour12: true, - })}{' '} - {scheduleTimezone || 'UTC'} -

- )} - - )} -
- )} - - - - Delete Schedule - -

- Are you sure you want to delete this schedule configuration? This will stop the - workflow from running automatically.{' '} - This action cannot be undone. -

-
- - - - -
-
-
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx index 0fb5dc6ed..c807666b7 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx @@ -29,7 +29,7 @@ import { MessagesInput, ProjectSelectorInput, ResponseFormat, - ScheduleSave, + ScheduleInfo, ShortInput, SlackSelectorInput, SliderInput, @@ -592,8 +592,8 @@ function SubBlockComponent({ /> ) - case 'schedule-save': - return + case 'schedule-info': + return case 'oauth-input': return ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/hooks/use-schedule-info.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/hooks/use-schedule-info.ts index b970fb3cd..0d740344d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/hooks/use-schedule-info.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/hooks/use-schedule-info.ts @@ -15,17 +15,15 @@ export interface UseScheduleInfoReturn { isLoading: boolean /** Function to reactivate a disabled schedule */ reactivateSchedule: (scheduleId: string) => Promise - /** Function to disable an active schedule */ - disableSchedule: (scheduleId: string) => Promise } /** - * Custom hook for managing schedule information + * Custom hook for fetching schedule information * * @param blockId - The ID of the block * @param blockType - The type of the block * @param workflowId - The current workflow ID - * @returns Schedule information state and operations + * @returns Schedule information state and reactivate function */ export function useScheduleInfo( blockId: string, @@ -44,7 +42,6 @@ export function useScheduleInfo( const params = new URLSearchParams({ workflowId: wfId, - mode: 'schedule', blockId, }) @@ -77,6 +74,7 @@ export function useScheduleInfo( timezone: scheduleTimezone, status: schedule.status, isDisabled: schedule.status === 'disabled', + failedCount: schedule.failedCount || 0, id: schedule.id, }) } catch (error) { @@ -94,14 +92,12 @@ export function useScheduleInfo( try { const response = await fetch(`/api/schedules/${scheduleId}`, { method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'reactivate' }), }) if (response.ok && workflowId) { - fetchScheduleInfo(workflowId) + await fetchScheduleInfo(workflowId) } else { logger.error('Failed to reactivate schedule') } @@ -112,29 +108,6 @@ export function useScheduleInfo( [workflowId, fetchScheduleInfo] ) - const disableSchedule = useCallback( - async (scheduleId: string) => { - try { - const response = await fetch(`/api/schedules/${scheduleId}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ action: 'disable' }), - }) - - if (response.ok && workflowId) { - fetchScheduleInfo(workflowId) - } else { - logger.error('Failed to disable schedule') - } - } catch (error) { - logger.error('Error disabling schedule:', error) - } - }, - [workflowId, fetchScheduleInfo] - ) - useEffect(() => { if (blockType === 'schedule' && workflowId) { fetchScheduleInfo(workflowId) @@ -152,6 +125,5 @@ export function useScheduleInfo( scheduleInfo, isLoading, reactivateSchedule, - disableSchedule, } } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/types.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/types.ts index 5ba34fb90..e3e3f0146 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/types.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/types.ts @@ -24,5 +24,6 @@ export interface ScheduleInfo { timezone: string status?: string isDisabled?: boolean + failedCount?: number id?: string } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx index d548ee0be..9b3339c15 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx @@ -564,7 +564,6 @@ export const WorkflowBlock = memo(function WorkflowBlock({ scheduleInfo, isLoading: isLoadingScheduleInfo, reactivateSchedule, - disableSchedule, } = useScheduleInfo(id, type, currentWorkflowId) const { childWorkflowId, childIsDeployed, childNeedsRedeploy, refetchDeployment } = diff --git a/apps/sim/background/schedule-execution.ts b/apps/sim/background/schedule-execution.ts index 31ccf4db3..2d8f618f5 100644 --- a/apps/sim/background/schedule-execution.ts +++ b/apps/sim/background/schedule-execution.ts @@ -565,6 +565,7 @@ export async function executeScheduleJob(payload: ScheduleExecutionPayload) { updatedAt: now, nextRunAt, failedCount: 0, + lastQueuedAt: null, }, requestId, `Error updating schedule ${payload.scheduleId} after success`, diff --git a/apps/sim/blocks/blocks/schedule.ts b/apps/sim/blocks/blocks/schedule.ts index 027ece7d8..1e384f228 100644 --- a/apps/sim/blocks/blocks/schedule.ts +++ b/apps/sim/blocks/blocks/schedule.ts @@ -155,18 +155,11 @@ export const ScheduleBlock: BlockConfig = { }, { - id: 'scheduleSave', - type: 'schedule-save', + id: 'scheduleInfo', + type: 'schedule-info', mode: 'trigger', hideFromPreview: true, }, - - { - id: 'scheduleId', - type: 'short-input', - hidden: true, - mode: 'trigger', - }, ], tools: { diff --git a/apps/sim/blocks/types.ts b/apps/sim/blocks/types.ts index 64c1a89a2..40d4ad426 100644 --- a/apps/sim/blocks/types.ts +++ b/apps/sim/blocks/types.ts @@ -55,7 +55,7 @@ export type SubBlockType = | 'time-input' // Time input | 'oauth-input' // OAuth credential selector | 'webhook-config' // Webhook configuration - | 'schedule-save' // Schedule save button with status display + | 'schedule-info' // Schedule status display (next run, last ran, failure badge) | 'file-selector' // File selector for Google Drive, etc. | 'project-selector' // Project selector for Jira, Discord, etc. | 'channel-selector' // Channel selector for Slack, Discord, etc. diff --git a/apps/sim/hooks/use-schedule-management.ts b/apps/sim/hooks/use-schedule-management.ts deleted file mode 100644 index c825f04b5..000000000 --- a/apps/sim/hooks/use-schedule-management.ts +++ /dev/null @@ -1,231 +0,0 @@ -import { useCallback, useEffect, useState } from 'react' -import { useParams } from 'next/navigation' -import { createLogger } from '@/lib/logs/console/logger' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -import { useSubBlockStore } from '@/stores/workflows/subblock/store' -import { useWorkflowStore } from '@/stores/workflows/workflow/store' - -const logger = createLogger('useScheduleManagement') - -interface UseScheduleManagementProps { - blockId: string - isPreview?: boolean -} - -interface SaveConfigResult { - success: boolean - nextRunAt?: string - cronExpression?: string -} - -interface ScheduleManagementState { - scheduleId: string | null - isLoading: boolean - isSaving: boolean - saveConfig: () => Promise - deleteConfig: () => Promise -} - -/** - * Hook to manage schedule lifecycle for schedule blocks - * Handles: - * - Loading existing schedules from the API - * - Saving schedule configurations - * - Deleting schedule configurations - */ -export function useScheduleManagement({ - blockId, - isPreview = false, -}: UseScheduleManagementProps): ScheduleManagementState { - const params = useParams() - const workflowId = params.workflowId as string - - const scheduleId = useSubBlockStore( - useCallback((state) => state.getValue(blockId, 'scheduleId') as string | null, [blockId]) - ) - - const isLoading = useSubBlockStore((state) => state.loadingSchedules.has(blockId)) - const isChecked = useSubBlockStore((state) => state.checkedSchedules.has(blockId)) - - const [isSaving, setIsSaving] = useState(false) - - useEffect(() => { - if (isPreview) { - return - } - - const store = useSubBlockStore.getState() - const currentlyLoading = store.loadingSchedules.has(blockId) - const alreadyChecked = store.checkedSchedules.has(blockId) - const currentScheduleId = store.getValue(blockId, 'scheduleId') - - if (currentlyLoading || (alreadyChecked && currentScheduleId)) { - return - } - - const loadSchedule = async () => { - useSubBlockStore.setState((state) => ({ - loadingSchedules: new Set([...state.loadingSchedules, blockId]), - })) - - try { - const response = await fetch( - `/api/schedules?workflowId=${workflowId}&blockId=${blockId}&mode=schedule` - ) - - if (response.ok) { - const data = await response.json() - - if (data.schedule?.id) { - useSubBlockStore.getState().setValue(blockId, 'scheduleId', data.schedule.id) - logger.info('Schedule loaded from API', { - blockId, - scheduleId: data.schedule.id, - }) - } else { - useSubBlockStore.getState().setValue(blockId, 'scheduleId', null) - } - - useSubBlockStore.setState((state) => ({ - checkedSchedules: new Set([...state.checkedSchedules, blockId]), - })) - } else { - logger.warn('API response not OK', { - blockId, - workflowId, - status: response.status, - statusText: response.statusText, - }) - } - } catch (error) { - logger.error('Error loading schedule:', { error, blockId, workflowId }) - } finally { - useSubBlockStore.setState((state) => { - const newSet = new Set(state.loadingSchedules) - newSet.delete(blockId) - return { loadingSchedules: newSet } - }) - } - } - - loadSchedule() - }, [isPreview, workflowId, blockId]) - - const saveConfig = async (): Promise => { - if (isPreview || isSaving) { - return { success: false } - } - - try { - setIsSaving(true) - - const workflowStore = useWorkflowStore.getState() - const subBlockStore = useSubBlockStore.getState() - - const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId - const subBlockValues = activeWorkflowId - ? subBlockStore.workflowValues[activeWorkflowId] || {} - : {} - - const { mergeSubblockStateAsync } = await import('@/stores/workflows/server-utils') - const mergedBlocks = await mergeSubblockStateAsync(workflowStore.blocks, subBlockValues) - - const workflowState = { - blocks: mergedBlocks, - edges: workflowStore.edges, - loops: workflowStore.loops, - } - - const response = await fetch('/api/schedules', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - workflowId, - blockId, - state: workflowState, - }), - }) - - if (!response.ok) { - let errorMessage = 'Failed to save schedule' - try { - const errorData = await response.json() - errorMessage = errorData.details || errorData.error || errorMessage - } catch { - // If response is not JSON, use default message - } - logger.error('Failed to save schedule', { errorMessage }) - throw new Error(errorMessage) - } - - const data = await response.json() - - if (data.schedule?.id) { - useSubBlockStore.getState().setValue(blockId, 'scheduleId', data.schedule.id) - useSubBlockStore.setState((state) => ({ - checkedSchedules: new Set([...state.checkedSchedules, blockId]), - })) - } - - logger.info('Schedule saved successfully', { - scheduleId: data.schedule?.id, - blockId, - nextRunAt: data.nextRunAt, - cronExpression: data.cronExpression, - }) - - return { success: true, nextRunAt: data.nextRunAt, cronExpression: data.cronExpression } - } catch (error) { - logger.error('Error saving schedule:', error) - throw error - } finally { - setIsSaving(false) - } - } - - const deleteConfig = async (): Promise => { - if (isPreview || !scheduleId) { - return false - } - - try { - setIsSaving(true) - - const response = await fetch(`/api/schedules/${scheduleId}`, { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - workspaceId: params.workspaceId as string, - }), - }) - - if (!response.ok) { - logger.error('Failed to delete schedule') - return false - } - - useSubBlockStore.getState().setValue(blockId, 'scheduleId', null) - useSubBlockStore.setState((state) => { - const newSet = new Set(state.checkedSchedules) - newSet.delete(blockId) - return { checkedSchedules: newSet } - }) - - logger.info('Schedule deleted successfully') - return true - } catch (error) { - logger.error('Error deleting schedule:', error) - return false - } finally { - setIsSaving(false) - } - } - - return { - scheduleId, - isLoading, - isSaving, - saveConfig, - deleteConfig, - } -} diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts index d12c554a0..dc4f55a73 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts @@ -1824,7 +1824,7 @@ function applyOperationsToWorkflowState( validationErrors.push(...validationResult.errors) Object.entries(validationResult.validInputs).forEach(([key, value]) => { - // Skip runtime subblock IDs (webhookId, triggerPath, testUrl, testUrlExpiresAt, scheduleId) + // Skip runtime subblock IDs (webhookId, triggerPath, testUrl, testUrlExpiresAt) if (TRIGGER_RUNTIME_SUBBLOCK_IDS.includes(key)) { return } diff --git a/apps/sim/lib/workflows/schedules/deploy.test.ts b/apps/sim/lib/workflows/schedules/deploy.test.ts new file mode 100644 index 000000000..3fb7cca40 --- /dev/null +++ b/apps/sim/lib/workflows/schedules/deploy.test.ts @@ -0,0 +1,786 @@ +/** + * 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, +} = 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(), +})) + +vi.mock('@sim/db', () => ({ + db: {}, + workflowSchedule: { + workflowId: 'workflow_id', + blockId: 'block_id', + }, +})) + +vi.mock('drizzle-orm', () => ({ + eq: vi.fn((...args) => ({ type: 'eq', args })), +})) + +vi.mock('@/lib/logs/console/logger', () => ({ + createLogger: vi.fn(() => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + })), +})) + +vi.mock('./utils', async (importOriginal) => { + const original = await importOriginal() + 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 }) + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + describe('findScheduleBlocks', () => { + it('should find schedule blocks in a workflow', () => { + const blocks: Record = { + '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 = { + '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) + }) + }) + + 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 = { + '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 = { + '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 = { + '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', () => { + it('should return success with no schedule blocks', async () => { + const blocks: Record = { + 'block-1': { id: 'block-1', type: 'agent', subBlocks: {} } as BlockState, + } + + const mockTx = { + insert: mockInsert, + delete: mockDelete, + } + + const result = await createSchedulesForDeploy('workflow-1', blocks, mockTx as any) + + expect(result.success).toBe(true) + expect(mockInsert).not.toHaveBeenCalled() + }) + + it('should create schedule for valid schedule block', async () => { + const blocks: Record = { + 'block-1': { + id: 'block-1', + type: 'schedule', + subBlocks: { + scheduleType: { value: 'daily' }, + dailyTime: { value: '09:00' }, + timezone: { value: 'UTC' }, + }, + } as BlockState, + } + + const mockTx = { + insert: mockInsert, + delete: mockDelete, + } + + const result = await createSchedulesForDeploy('workflow-1', blocks, mockTx as any) + + 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(mockInsert).toHaveBeenCalled() + expect(mockOnConflictDoUpdate).toHaveBeenCalled() + }) + + it('should return error for invalid schedule block', async () => { + const blocks: Record = { + 'block-1': { + id: 'block-1', + type: 'schedule', + subBlocks: { + scheduleType: { value: 'daily' }, + dailyTime: { value: '' }, // Invalid + }, + } as BlockState, + } + + const mockTx = { + insert: mockInsert, + delete: mockDelete, + } + + const result = await createSchedulesForDeploy('workflow-1', blocks, mockTx as any) + + expect(result.success).toBe(false) + expect(result.error).toBe('Time is required for daily schedules') + expect(mockInsert).not.toHaveBeenCalled() + }) + + it('should use onConflictDoUpdate for existing schedules', async () => { + const blocks: Record = { + 'block-1': { + id: 'block-1', + type: 'schedule', + subBlocks: { + scheduleType: { value: 'minutes' }, + minutesInterval: { value: '30' }, + timezone: { value: 'UTC' }, + }, + } as BlockState, + } + + const mockTx = { + insert: mockInsert, + delete: mockDelete, + } + + await createSchedulesForDeploy('workflow-1', blocks, mockTx as any) + + expect(mockOnConflictDoUpdate).toHaveBeenCalledWith({ + target: expect.any(Array), + set: expect.objectContaining({ + blockId: 'block-1', + cronExpression: '0 9 * * *', + status: 'active', + failedCount: 0, + }), + }) + }) + }) + + 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() + }) + }) +}) diff --git a/apps/sim/lib/workflows/schedules/deploy.ts b/apps/sim/lib/workflows/schedules/deploy.ts new file mode 100644 index 000000000..8c6346bbb --- /dev/null +++ b/apps/sim/lib/workflows/schedules/deploy.ts @@ -0,0 +1,131 @@ +import { type db, workflowSchedule } from '@sim/db' +import type * as schema from '@sim/db/schema' +import type { ExtractTablesWithRelations } from 'drizzle-orm' +import { eq } from 'drizzle-orm' +import type { PgTransaction } from 'drizzle-orm/pg-core' +import type { PostgresJsQueryResultHKT } from 'drizzle-orm/postgres-js' +import { createLogger } from '@/lib/logs/console/logger' +import type { BlockState } from '@/lib/workflows/schedules/utils' +import { findScheduleBlocks, validateScheduleBlock } from '@/lib/workflows/schedules/validation' + +const logger = createLogger('ScheduleDeployUtils') + +/** + * Type for database or transaction context + * This allows the functions to work with either the db instance or a transaction + */ +type DbOrTx = + | typeof db + | PgTransaction< + PostgresJsQueryResultHKT, + typeof schema, + ExtractTablesWithRelations + > + +/** + * 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 + * This should be called within a database transaction + */ +export async function createSchedulesForDeploy( + workflowId: string, + blocks: Record, + tx: DbOrTx +): Promise { + const scheduleBlocks = findScheduleBlocks(blocks) + + if (scheduleBlocks.length === 0) { + logger.info(`No schedule blocks found in workflow ${workflowId}`) + return { success: true } + } + + let lastScheduleInfo: { + scheduleId: string + cronExpression?: string + nextRunAt?: Date + timezone?: string + } | null = null + + for (const block of scheduleBlocks) { + const blockId = block.id as string + + const validation = validateScheduleBlock(block) + if (!validation.isValid) { + return { + success: false, + error: validation.error, + } + } + + const { cronExpression, nextRunAt, timezone } = validation + + const scheduleId = crypto.randomUUID() + const now = new Date() + + const values = { + id: scheduleId, + workflowId, + blockId, + cronExpression: cronExpression!, + triggerType: 'schedule', + createdAt: now, + updatedAt: now, + nextRunAt: nextRunAt!, + timezone: timezone!, + status: 'active', + failedCount: 0, + } + + const setValues = { + blockId, + cronExpression: cronExpression!, + updatedAt: now, + nextRunAt: nextRunAt!, + timezone: timezone!, + status: 'active', + failedCount: 0, + } + + await tx + .insert(workflowSchedule) + .values(values) + .onConflictDoUpdate({ + target: [workflowSchedule.workflowId, workflowSchedule.blockId], + 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 } + } + + 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): Promise { + await tx.delete(workflowSchedule).where(eq(workflowSchedule.workflowId, workflowId)) + + logger.info(`Deleted all schedules for workflow ${workflowId}`) +} diff --git a/apps/sim/lib/workflows/schedules/index.ts b/apps/sim/lib/workflows/schedules/index.ts new file mode 100644 index 000000000..0a5eb94ba --- /dev/null +++ b/apps/sim/lib/workflows/schedules/index.ts @@ -0,0 +1,23 @@ +export { + createSchedulesForDeploy, + deleteSchedulesForWorkflow, + type ScheduleDeployResult, +} from './deploy' +export { + type BlockState, + calculateNextRunTime, + DAY_MAP, + generateCronExpression, + getScheduleInfo, + getScheduleTimeValues, + getSubBlockValue, + parseCronToHumanReadable, + parseTimeString, + validateCronExpression, +} from './utils' +export { + findScheduleBlocks, + type ScheduleValidationResult, + validateScheduleBlock, + validateWorkflowSchedules, +} from './validation' diff --git a/apps/sim/lib/workflows/schedules/utils.test.ts b/apps/sim/lib/workflows/schedules/utils.test.ts index 949cb3231..834f189b7 100644 --- a/apps/sim/lib/workflows/schedules/utils.test.ts +++ b/apps/sim/lib/workflows/schedules/utils.test.ts @@ -782,4 +782,397 @@ describe('Schedule Utilities', () => { expect(date.toISOString()).toBe('2025-10-14T14:00:00.000Z') }) }) + + describe('Edge Cases and DST Transitions', () => { + describe('DST Transition Edge Cases', () => { + it.concurrent('should handle DST spring forward transition (2:00 AM skipped)', () => { + // In US timezones, DST spring forward happens at 2:00 AM -> jumps to 3:00 AM + // March 9, 2025 is DST transition day in America/New_York + const scheduleValues = { + scheduleTime: '', + scheduleStartAt: '', + timezone: 'America/New_York', + minutesInterval: 15, + hourlyMinute: 0, + dailyTime: [2, 30] as [number, number], // 2:30 AM (during the skipped hour) + weeklyDay: 1, + weeklyTime: [2, 30] as [number, number], + monthlyDay: 1, + monthlyTime: [2, 30] as [number, number], + cronExpression: null, + } + + // Should handle the skipped hour gracefully + const nextRun = calculateNextRunTime('daily', scheduleValues) + expect(nextRun instanceof Date).toBe(true) + expect(nextRun > new Date()).toBe(true) + }) + + it.concurrent('should handle DST fall back transition (1:00 AM repeated)', () => { + // In US timezones, DST fall back happens at 2:00 AM -> falls back to 1:00 AM + // November 2, 2025 is DST fall back day in America/Los_Angeles + vi.useFakeTimers() + vi.setSystemTime(new Date('2025-11-01T12:00:00.000Z')) + + const scheduleValues = { + scheduleTime: '', + scheduleStartAt: '', + timezone: 'America/Los_Angeles', + minutesInterval: 15, + hourlyMinute: 0, + dailyTime: [1, 30] as [number, number], // 1:30 AM (during the repeated hour) + weeklyDay: 1, + weeklyTime: [1, 30] as [number, number], + monthlyDay: 1, + monthlyTime: [1, 30] as [number, number], + cronExpression: null, + } + + const nextRun = calculateNextRunTime('daily', scheduleValues) + expect(nextRun instanceof Date).toBe(true) + expect(nextRun > new Date()).toBe(true) + + vi.useRealTimers() + }) + }) + + describe('End of Month Edge Cases', () => { + it.concurrent('should handle February 29th in non-leap year', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2025-01-15T12:00:00.000Z')) // 2025 is not a leap year + + const scheduleValues = { + scheduleTime: '', + scheduleStartAt: '', + timezone: 'UTC', + minutesInterval: 15, + hourlyMinute: 0, + dailyTime: [9, 0] as [number, number], + weeklyDay: 1, + weeklyTime: [9, 0] as [number, number], + monthlyDay: 29, // Feb doesn't have 29 days in 2025 + monthlyTime: [9, 0] as [number, number], + cronExpression: null, + } + + const nextRun = calculateNextRunTime('monthly', scheduleValues) + // Should skip February and schedule for next valid month (March 29) + expect(nextRun.getUTCMonth()).not.toBe(1) // Not February + + vi.useRealTimers() + }) + + it.concurrent('should handle February 29th in leap year', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2024-01-15T12:00:00.000Z')) // 2024 is a leap year + + const scheduleValues = { + scheduleTime: '', + scheduleStartAt: '', + timezone: 'UTC', + minutesInterval: 15, + hourlyMinute: 0, + dailyTime: [9, 0] as [number, number], + weeklyDay: 1, + weeklyTime: [9, 0] as [number, number], + monthlyDay: 29, // Feb has 29 days in 2024 + monthlyTime: [9, 0] as [number, number], + cronExpression: null, + } + + const nextRun = calculateNextRunTime('monthly', scheduleValues) + expect(nextRun instanceof Date).toBe(true) + + vi.useRealTimers() + }) + + it.concurrent('should handle day 31 in months with only 30 days', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2025-04-05T12:00:00.000Z')) // April has 30 days + + const scheduleValues = { + scheduleTime: '', + scheduleStartAt: '', + timezone: 'UTC', + minutesInterval: 15, + hourlyMinute: 0, + dailyTime: [9, 0] as [number, number], + weeklyDay: 1, + weeklyTime: [9, 0] as [number, number], + monthlyDay: 31, // April only has 30 days + monthlyTime: [9, 0] as [number, number], + cronExpression: null, + } + + const nextRun = calculateNextRunTime('monthly', scheduleValues) + // Should skip April and schedule for May 31 + expect(nextRun.getUTCDate()).toBe(31) + expect(nextRun.getUTCMonth()).toBe(4) // May (0-indexed) + + vi.useRealTimers() + }) + }) + + describe('Timezone-specific Edge Cases', () => { + it.concurrent('should handle Australia/Lord_Howe with 30-minute DST shift', () => { + // Lord Howe Island has a unique 30-minute DST shift + const scheduleValues = { + scheduleTime: '', + scheduleStartAt: '', + timezone: 'Australia/Lord_Howe', + minutesInterval: 15, + hourlyMinute: 0, + dailyTime: [14, 0] as [number, number], + weeklyDay: 1, + weeklyTime: [14, 0] as [number, number], + monthlyDay: 1, + monthlyTime: [14, 0] as [number, number], + cronExpression: null, + } + + const nextRun = calculateNextRunTime('daily', scheduleValues) + expect(nextRun instanceof Date).toBe(true) + expect(nextRun > new Date()).toBe(true) + }) + + it.concurrent('should handle negative UTC offsets correctly', () => { + // America/Sao_Paulo (UTC-3) + const scheduleValues = { + scheduleTime: '', + scheduleStartAt: '', + timezone: 'America/Sao_Paulo', + minutesInterval: 15, + hourlyMinute: 0, + dailyTime: [23, 30] as [number, number], // 11:30 PM local + weeklyDay: 1, + weeklyTime: [23, 30] as [number, number], + monthlyDay: 1, + monthlyTime: [23, 30] as [number, number], + cronExpression: null, + } + + const nextRun = calculateNextRunTime('daily', scheduleValues) + expect(nextRun instanceof Date).toBe(true) + // 11:30 PM in UTC-3 should be 2:30 AM UTC next day + expect(nextRun.getUTCHours()).toBeGreaterThanOrEqual(2) + }) + }) + + describe('Complex Cron Pattern Edge Cases', () => { + it.concurrent('should handle cron with specific days of month and week', () => { + const scheduleValues = { + scheduleTime: '', + scheduleStartAt: '', + timezone: 'UTC', + minutesInterval: 15, + hourlyMinute: 0, + dailyTime: [9, 0] as [number, number], + weeklyDay: 1, + weeklyTime: [9, 0] as [number, number], + monthlyDay: 1, + monthlyTime: [9, 0] as [number, number], + cronExpression: '0 9 13 * 5', // Friday the 13th at 9:00 AM + } + + const result = validateCronExpression(scheduleValues.cronExpression, 'UTC') + expect(result.isValid).toBe(true) + expect(result.nextRun).toBeInstanceOf(Date) + }) + + it.concurrent('should handle cron with multiple specific hours', () => { + const scheduleValues = { + scheduleTime: '', + scheduleStartAt: '', + timezone: 'America/New_York', + minutesInterval: 15, + hourlyMinute: 0, + dailyTime: [9, 0] as [number, number], + weeklyDay: 1, + weeklyTime: [9, 0] as [number, number], + monthlyDay: 1, + monthlyTime: [9, 0] as [number, number], + cronExpression: '0 9,12,15,18 * * *', // At 9 AM, noon, 3 PM, 6 PM + } + + const result = validateCronExpression(scheduleValues.cronExpression, 'America/New_York') + expect(result.isValid).toBe(true) + expect(result.nextRun).toBeInstanceOf(Date) + }) + + it.concurrent('should handle cron with step values in multiple fields', () => { + const scheduleValues = { + scheduleTime: '', + scheduleStartAt: '', + timezone: 'Europe/Paris', + minutesInterval: 15, + hourlyMinute: 0, + dailyTime: [9, 0] as [number, number], + weeklyDay: 1, + weeklyTime: [9, 0] as [number, number], + monthlyDay: 1, + monthlyTime: [9, 0] as [number, number], + cronExpression: '*/15 */2 * * *', // Every 15 minutes, every 2 hours + } + + const result = validateCronExpression(scheduleValues.cronExpression, 'Europe/Paris') + expect(result.isValid).toBe(true) + expect(result.nextRun).toBeInstanceOf(Date) + }) + + it.concurrent('should handle cron with ranges', () => { + const scheduleValues = { + scheduleTime: '', + scheduleStartAt: '', + timezone: 'Asia/Singapore', + minutesInterval: 15, + hourlyMinute: 0, + dailyTime: [9, 0] as [number, number], + weeklyDay: 1, + weeklyTime: [9, 0] as [number, number], + monthlyDay: 1, + monthlyTime: [9, 0] as [number, number], + cronExpression: '0 9-17 * * 1-5', // Business hours (9 AM - 5 PM) on weekdays + } + + const result = validateCronExpression(scheduleValues.cronExpression, 'Asia/Singapore') + expect(result.isValid).toBe(true) + expect(result.nextRun).toBeInstanceOf(Date) + }) + }) + + describe('Validation Edge Cases', () => { + it.concurrent('should reject cron with invalid day of week', () => { + const result = validateCronExpression('0 9 * * 8', 'UTC') // Day 8 doesn't exist (0-7) + expect(result.isValid).toBe(false) + expect(result.error).toBeDefined() + }) + + it.concurrent('should reject cron with invalid month', () => { + const result = validateCronExpression('0 9 1 13 *', 'UTC') // Month 13 doesn't exist (1-12) + expect(result.isValid).toBe(false) + expect(result.error).toBeDefined() + }) + + it.concurrent('should reject cron with invalid hour', () => { + const result = validateCronExpression('0 25 * * *', 'UTC') // Hour 25 doesn't exist (0-23) + expect(result.isValid).toBe(false) + expect(result.error).toBeDefined() + }) + + it.concurrent('should reject cron with invalid minute', () => { + const result = validateCronExpression('60 9 * * *', 'UTC') // Minute 60 doesn't exist (0-59) + expect(result.isValid).toBe(false) + expect(result.error).toBeDefined() + }) + + it.concurrent('should handle standard cron expressions correctly', () => { + // Croner requires proper spacing, so test with standard spaces + const result = validateCronExpression('0 9 * * *', 'UTC') + expect(result.isValid).toBe(true) + expect(result.nextRun).toBeInstanceOf(Date) + }) + + it.concurrent('should reject cron with too few fields', () => { + const result = validateCronExpression('0 9 * *', 'UTC') // Missing day of week field + expect(result.isValid).toBe(false) + expect(result.error).toBeDefined() + }) + + it.concurrent('should reject cron with too many fields', () => { + const result = validateCronExpression('0 0 9 * * * *', 'UTC') // Too many fields (has seconds) + expect(result.isValid).toBe(false) + expect(result.error).toBeDefined() + }) + + it.concurrent('should handle timezone with invalid IANA name', () => { + const result = validateCronExpression('0 9 * * *', 'Invalid/Timezone') + // Croner might handle this differently, but it should either reject or fall back + expect(result).toBeDefined() + }) + }) + + describe('Boundary Conditions', () => { + it.concurrent('should handle midnight (00:00)', () => { + const scheduleValues = { + scheduleTime: '', + scheduleStartAt: '', + timezone: 'UTC', + minutesInterval: 15, + hourlyMinute: 0, + dailyTime: [0, 0] as [number, number], // Midnight + weeklyDay: 1, + weeklyTime: [0, 0] as [number, number], + monthlyDay: 1, + monthlyTime: [0, 0] as [number, number], + cronExpression: null, + } + + const nextRun = calculateNextRunTime('daily', scheduleValues) + expect(nextRun.getUTCHours()).toBe(0) + expect(nextRun.getUTCMinutes()).toBe(0) + }) + + it.concurrent('should handle end of day (23:59)', () => { + const scheduleValues = { + scheduleTime: '', + scheduleStartAt: '', + timezone: 'UTC', + minutesInterval: 15, + hourlyMinute: 0, + dailyTime: [23, 59] as [number, number], // One minute before midnight + weeklyDay: 1, + weeklyTime: [23, 59] as [number, number], + monthlyDay: 1, + monthlyTime: [23, 59] as [number, number], + cronExpression: null, + } + + const nextRun = calculateNextRunTime('daily', scheduleValues) + expect(nextRun.getUTCHours()).toBe(23) + expect(nextRun.getUTCMinutes()).toBe(59) + }) + + it.concurrent('should handle first day of month', () => { + const scheduleValues = { + scheduleTime: '', + scheduleStartAt: '', + timezone: 'UTC', + minutesInterval: 15, + hourlyMinute: 0, + dailyTime: [9, 0] as [number, number], + weeklyDay: 1, + weeklyTime: [9, 0] as [number, number], + monthlyDay: 1, // First day of month + monthlyTime: [9, 0] as [number, number], + cronExpression: null, + } + + const nextRun = calculateNextRunTime('monthly', scheduleValues) + expect(nextRun.getUTCDate()).toBe(1) + expect(nextRun.getUTCHours()).toBe(9) + }) + + it.concurrent('should handle minimum interval (every minute)', () => { + const scheduleValues = { + scheduleTime: '', + scheduleStartAt: '', + timezone: 'UTC', + minutesInterval: 1, // Every minute + hourlyMinute: 0, + dailyTime: [9, 0] as [number, number], + weeklyDay: 1, + weeklyTime: [9, 0] as [number, number], + monthlyDay: 1, + monthlyTime: [9, 0] as [number, number], + cronExpression: null, + } + + const nextRun = calculateNextRunTime('minutes', scheduleValues) + const now = Date.now() + // Should be within the next minute + expect(nextRun.getTime()).toBeGreaterThan(now) + expect(nextRun.getTime()).toBeLessThanOrEqual(now + 60 * 1000 + 1000) + }) + }) + }) }) diff --git a/apps/sim/lib/workflows/schedules/utils.ts b/apps/sim/lib/workflows/schedules/utils.ts index 233715c71..2658ddca7 100644 --- a/apps/sim/lib/workflows/schedules/utils.ts +++ b/apps/sim/lib/workflows/schedules/utils.ts @@ -324,7 +324,7 @@ export function generateCronExpression( * 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 - * @param lastRanAt - Optional last execution time + * @param lastRanAt - Optional last execution time (currently unused, Croner calculates from current time) * @returns Date object for next execution time */ export function calculateNextRunTime( diff --git a/apps/sim/lib/workflows/schedules/validation.ts b/apps/sim/lib/workflows/schedules/validation.ts new file mode 100644 index 000000000..2ad436983 --- /dev/null +++ b/apps/sim/lib/workflows/schedules/validation.ts @@ -0,0 +1,184 @@ +/** + * Client-safe schedule validation functions + * These can be used in both client and server contexts + */ +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 + */ +export function findScheduleBlocks(blocks: Record): BlockState[] { + return Object.values(blocks).filter((block) => block.type === 'schedule') +} + +/** + * 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: error instanceof Error ? error.message : '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 +): ScheduleValidationResult { + const scheduleBlocks = findScheduleBlocks(blocks) + + for (const block of scheduleBlocks) { + const result = validateScheduleBlock(block) + if (!result.isValid) { + return result + } + } + + return { isValid: true } +} diff --git a/apps/sim/stores/constants.ts b/apps/sim/stores/constants.ts index 8c7ef8670..5e8249470 100644 --- a/apps/sim/stores/constants.ts +++ b/apps/sim/stores/constants.ts @@ -1,6 +1,5 @@ export const API_ENDPOINTS = { ENVIRONMENT: '/api/environment', - SCHEDULE: '/api/schedules', SETTINGS: '/api/settings', WORKFLOWS: '/api/workflows', WORKSPACE_PERMISSIONS: (id: string) => `/api/workspaces/${id}/permissions`, diff --git a/apps/sim/stores/workflows/registry/store.ts b/apps/sim/stores/workflows/registry/store.ts index c3bdf6318..3f8e78136 100644 --- a/apps/sim/stores/workflows/registry/store.ts +++ b/apps/sim/stores/workflows/registry/store.ts @@ -3,7 +3,6 @@ import { devtools } from 'zustand/middleware' import { withOptimisticUpdate } from '@/lib/core/utils/optimistic-update' import { createLogger } from '@/lib/logs/console/logger' import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults' -import { API_ENDPOINTS } from '@/stores/constants' import { useVariablesStore } from '@/stores/panel/variables/store' import type { DeploymentStatus, @@ -673,21 +672,6 @@ export const useWorkflowRegistry = create()( } logger.info(`Successfully deleted workflow ${id} from database`) - - fetch(API_ENDPOINTS.SCHEDULE, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - workflowId: id, - state: { - blocks: {}, - edges: [], - loops: {}, - }, - }), - }).catch((error) => { - logger.error(`Error cancelling schedule for deleted workflow ${id}:`, error) - }) }, rollback: (originalState) => { set({ diff --git a/apps/sim/stores/workflows/subblock/store.ts b/apps/sim/stores/workflows/subblock/store.ts index c68812c4c..5b4a7ab37 100644 --- a/apps/sim/stores/workflows/subblock/store.ts +++ b/apps/sim/stores/workflows/subblock/store.ts @@ -27,8 +27,6 @@ export const useSubBlockStore = create()( workflowValues: {}, loadingWebhooks: new Set(), checkedWebhooks: new Set(), - loadingSchedules: new Set(), - checkedSchedules: new Set(), setValue: (blockId: string, subBlockId: string, value: any) => { const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId diff --git a/apps/sim/stores/workflows/subblock/types.ts b/apps/sim/stores/workflows/subblock/types.ts index d6f13fd92..243e12bf0 100644 --- a/apps/sim/stores/workflows/subblock/types.ts +++ b/apps/sim/stores/workflows/subblock/types.ts @@ -2,8 +2,6 @@ export interface SubBlockState { workflowValues: Record>> // Store values per workflow ID loadingWebhooks: Set // Track which blockIds are currently loading webhooks checkedWebhooks: Set // Track which blockIds have been checked for webhooks - loadingSchedules: Set // Track which blockIds are currently loading schedules - checkedSchedules: Set // Track which blockIds have been checked for schedules } export interface SubBlockStore extends SubBlockState { diff --git a/apps/sim/triggers/constants.ts b/apps/sim/triggers/constants.ts index ff511b2e9..3c47d671f 100644 --- a/apps/sim/triggers/constants.ts +++ b/apps/sim/triggers/constants.ts @@ -31,7 +31,7 @@ export const TRIGGER_PERSISTED_SUBBLOCK_IDS: string[] = [ ] /** - * Trigger and schedule-related subblock IDs that represent runtime metadata. They should remain + * Trigger-related subblock IDs that represent runtime metadata. They should remain * in the workflow state but must not be modified or cleared by diff operations. */ export const TRIGGER_RUNTIME_SUBBLOCK_IDS: string[] = [ @@ -39,5 +39,4 @@ export const TRIGGER_RUNTIME_SUBBLOCK_IDS: string[] = [ 'triggerPath', 'testUrl', 'testUrlExpiresAt', - 'scheduleId', ] diff --git a/bun.lock b/bun.lock index a17a53241..8b813ec71 100644 --- a/bun.lock +++ b/bun.lock @@ -37,7 +37,7 @@ "drizzle-kit": "^0.31.4", "husky": "9.1.7", "lint-staged": "16.0.0", - "turbo": "2.7.1", + "turbo": "2.7.2", }, }, "apps/docs": { @@ -3304,19 +3304,19 @@ "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], - "turbo": ["turbo@2.7.1", "", { "optionalDependencies": { "turbo-darwin-64": "2.7.1", "turbo-darwin-arm64": "2.7.1", "turbo-linux-64": "2.7.1", "turbo-linux-arm64": "2.7.1", "turbo-windows-64": "2.7.1", "turbo-windows-arm64": "2.7.1" }, "bin": { "turbo": "bin/turbo" } }, "sha512-zAj9jGc7VDvuAo/5Jbos4QTtWz9uUpkMhMKGyTjDJkx//hdL2bM31qQoJSAbU+7JyK5vb0LPzpwf6DUt3zayqg=="], + "turbo": ["turbo@2.7.2", "", { "optionalDependencies": { "turbo-darwin-64": "2.7.2", "turbo-darwin-arm64": "2.7.2", "turbo-linux-64": "2.7.2", "turbo-linux-arm64": "2.7.2", "turbo-windows-64": "2.7.2", "turbo-windows-arm64": "2.7.2" }, "bin": { "turbo": "bin/turbo" } }, "sha512-5JIA5aYBAJSAhrhbyag1ZuMSgUZnHtI+Sq3H8D3an4fL8PeF+L1yYvbEJg47akP1PFfATMf5ehkqFnxfkmuwZQ=="], - "turbo-darwin-64": ["turbo-darwin-64@2.7.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-EaA7UfYujbY9/Ku0WqPpvfctxm91h9LF7zo8vjielz+omfAPB54Si+ADmUoBczBDC6RoLgbURC3GmUW2alnjJg=="], + "turbo-darwin-64": ["turbo-darwin-64@2.7.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-dxY3X6ezcT5vm3coK6VGixbrhplbQMwgNsCsvZamS/+/6JiebqW9DKt4NwpgYXhDY2HdH00I7FWs3wkVuan4rA=="], - "turbo-darwin-arm64": ["turbo-darwin-arm64@2.7.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-/pWGSygtBugd7sKQOeMm+jKY3qN1vyB0RiHBM6bN/6qUOo2VHo8IQwBTIaSgINN4Ue6fzEU+WfePNvonSU9yXw=="], + "turbo-darwin-arm64": ["turbo-darwin-arm64@2.7.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-1bXmuwPLqNFt3mzrtYcVx1sdJ8UYb124Bf48nIgcpMCGZy3kDhgxNv1503kmuK/37OGOZbsWSQFU4I08feIuSg=="], - "turbo-linux-64": ["turbo-linux-64@2.7.1", "", { "os": "linux", "cpu": "x64" }, "sha512-Y5H11mdhASw/dJuRFyGtTCDFX5/MPT73EKsVEiHbw5MkFc77lx3nMc5L/Q7bKEhef/vYJAsAb61QuHsB6qdP8Q=="], + "turbo-linux-64": ["turbo-linux-64@2.7.2", "", { "os": "linux", "cpu": "x64" }, "sha512-kP+TiiMaiPugbRlv57VGLfcjFNsFbo8H64wMBCPV2270Or2TpDCBULMzZrvEsvWFjT3pBFvToYbdp8/Kw0jAQg=="], - "turbo-linux-arm64": ["turbo-linux-arm64@2.7.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-L/r77jD7cqIEXoyu2LGBUrTY5GJSi/XcGLsQ2nZ/fefk6x3MpljTvwsXUVG1BUkiBPc4zaKRj6yGyWMo5MbLxQ=="], + "turbo-linux-arm64": ["turbo-linux-arm64@2.7.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-VDJwQ0+8zjAfbyY6boNaWfP6RIez4ypKHxwkuB6SrWbOSk+vxTyW5/hEjytTwK8w/TsbKVcMDyvpora8tEsRFw=="], - "turbo-windows-64": ["turbo-windows-64@2.7.1", "", { "os": "win32", "cpu": "x64" }, "sha512-rkeuviXZ/1F7lCare7TNKvYtT/SH9dZR55FAMrxrFRh88b+ZKwlXEBfq5/1OctEzRUo/VLIm+s5LJMOEy+QshA=="], + "turbo-windows-64": ["turbo-windows-64@2.7.2", "", { "os": "win32", "cpu": "x64" }, "sha512-rPjqQXVnI6A6oxgzNEE8DNb6Vdj2Wwyhfv3oDc+YM3U9P7CAcBIlKv/868mKl4vsBtz4ouWpTQNXG8vljgJO+w=="], - "turbo-windows-arm64": ["turbo-windows-arm64@2.7.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-1rZk9htm3+iP/rWCf/h4/DFQey9sMs2TJPC4T5QQfwqAdMWsphgrxBuFqHdxczlbBCgbWNhVw0CH2bTxe1/GFg=="], + "turbo-windows-arm64": ["turbo-windows-arm64@2.7.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-tcnHvBhO515OheIFWdxA+qUvZzNqqcHbLVFc1+n+TJ1rrp8prYicQtbtmsiKgMvr/54jb9jOabU62URAobnB7g=="], "tweetnacl": ["tweetnacl@0.14.5", "", {}, "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="], diff --git a/package.json b/package.json index 7d82e0de6..ef795e12e 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "drizzle-kit": "^0.31.4", "husky": "9.1.7", "lint-staged": "16.0.0", - "turbo": "2.7.1" + "turbo": "2.7.2" }, "lint-staged": { "*.{js,jsx,ts,tsx,json,css,scss}": [