From 153cb09bab5a4cafabbfdc939621e6e7d06746d1 Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 23 Dec 2025 16:55:26 -0800 Subject: [PATCH 1/6] feat(schedules): remove save button for schedules, couple schedule deployment with workflow deployment --- .../content/docs/en/triggers/schedule.mdx | 50 +- apps/sim/app/api/schedules/[id]/route.ts | 159 +--- .../api/schedules/[id]/status/route.test.ts | 143 ---- .../app/api/schedules/[id]/status/route.ts | 84 -- apps/sim/app/api/schedules/route.test.ts | 305 ++----- apps/sim/app/api/schedules/route.ts | 312 ------- .../app/api/workflows/[id]/deploy/route.ts | 51 +- .../sim/app/api/workflows/[id]/state/route.ts | 158 +--- .../components/deploy-modal/deploy-modal.tsx | 20 +- .../components/sub-block/components/index.ts | 2 +- .../schedule-info/schedule-info.tsx | 195 +++++ .../schedule-save/schedule-save.tsx | 499 ----------- .../editor/components/sub-block/sub-block.tsx | 6 +- .../workflow-block/hooks/use-schedule-info.ts | 38 +- .../components/workflow-block/types.ts | 1 + .../workflow-block/workflow-block.tsx | 1 - apps/sim/blocks/blocks/schedule.ts | 11 +- apps/sim/blocks/types.ts | 2 +- apps/sim/hooks/use-schedule-management.ts | 231 ----- .../tools/server/workflow/edit-workflow.ts | 2 +- .../lib/workflows/schedules/deploy.test.ts | 792 ++++++++++++++++++ apps/sim/lib/workflows/schedules/deploy.ts | 305 +++++++ apps/sim/stores/constants.ts | 1 - apps/sim/stores/workflows/registry/store.ts | 16 - apps/sim/stores/workflows/subblock/store.ts | 2 - apps/sim/stores/workflows/subblock/types.ts | 2 - apps/sim/triggers/constants.ts | 3 +- 27 files changed, 1516 insertions(+), 1875 deletions(-) delete mode 100644 apps/sim/app/api/schedules/[id]/status/route.test.ts delete mode 100644 apps/sim/app/api/schedules/[id]/status/route.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/schedule-info/schedule-info.tsx delete mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/schedule-save/schedule-save.tsx delete mode 100644 apps/sim/hooks/use-schedule-management.ts create mode 100644 apps/sim/lib/workflows/schedules/deploy.test.ts create mode 100644 apps/sim/lib/workflows/schedules/deploy.ts diff --git a/apps/docs/content/docs/en/triggers/schedule.mdx b/apps/docs/content/docs/en/triggers/schedule.mdx index 584589b681..bb7bfbaa80 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:
    -
  • Every few minutes: 5, 15, 30 minute intervals
  • -
  • Hourly: Every hour or every few hours
  • -
  • Daily: Once or multiple times per day
  • -
  • Weekly: Specific days of the week
  • -
  • Monthly: Specific days of the month
  • +
  • Every X Minutes: Run at minute intervals (1-1440)
  • +
  • Hourly: Run at a specific minute each hour
  • +
  • Daily: Run at a specific time each day
  • +
  • Weekly: Run on a specific day and time each week
  • +
  • Monthly: Run on a specific day and time each month
@@ -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 + + +You must deploy your workflow for the schedule to start running. Configure the schedule block, then deploy from the toolbar. + -## Disabled Schedules +## 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/sim/app/api/schedules/[id]/route.ts b/apps/sim/app/api/schedules/[id]/route.ts index cd50005178..f9b981e6af 100644 --- a/apps/sim/app/api/schedules/[id]/route.ts +++ b/apps/sim/app/api/schedules/[id]/route.ts @@ -12,98 +12,19 @@ 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,13 +36,9 @@ 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, @@ -164,57 +81,29 @@ 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 }) - } - - const now = new Date() - const nextRunAt = new Date(now.getTime() + 60 * 1000) // Schedule to run in 1 minute - - await db - .update(workflowSchedule) - .set({ - status: 'active', - failedCount: 0, - updatedAt: now, - nextRunAt, - }) - .where(eq(workflowSchedule.id, scheduleId)) + if (schedule.status === 'active') { + return NextResponse.json({ message: 'Schedule is already active' }, { status: 200 }) + } - logger.info(`[${requestId}] Reactivated schedule: ${scheduleId}`) + const now = new Date() + const nextRunAt = new Date(now.getTime() + 60 * 1000) // Schedule to run in 1 minute - return NextResponse.json({ - message: 'Schedule activated successfully', + await db + .update(workflowSchedule) + .set({ + status: 'active', + failedCount: 0, + updatedAt: now, nextRunAt, }) - } - - if (action === 'disable' || (requestedStatus && requestedStatus === 'disabled')) { - if (schedule.status === 'disabled') { - return NextResponse.json({ message: 'Schedule is already disabled' }, { status: 200 }) - } - - 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}`) + .where(eq(workflowSchedule.id, scheduleId)) - return NextResponse.json({ - message: 'Schedule disabled successfully', - }) - } + logger.info(`[${requestId}] Reactivated schedule: ${scheduleId}`) - 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 29d269ab0a..0000000000 --- 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 59c7533b6e..0000000000 --- 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 bfc65ec1c2..ac1ece178d 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' + +function createRequest(url: string): NextRequest { + return new NextRequest(new URL(url), { method: 'GET' }) +} -import { db } from '@sim/db' -import { POST } from '@/app/api/schedules/route' +function mockDbChain(results: any[]) { + let callIndex = 0 + mockDbSelect.mockImplementation(() => ({ + from: () => ({ + where: () => ({ + limit: () => results[callIndex++] || [], + }), + }), + })) +} -describe('Schedule Configuration API Route', () => { +describe('Schedule GET API', () => { beforeEach(() => { vi.clearAllMocks() + mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) + mockGetUserEntityPermissions.mockResolvedValue('read') + }) - ;(db as any).transaction = mockTransaction + afterEach(() => { + vi.clearAllMocks() + }) - mockExecutionDependencies() + it('returns schedule data for authorized user', async () => { + mockDbChain([ + [{ userId: 'user-1', workspaceId: null }], + [{ id: 'sched-1', cronExpression: '0 9 * * *', status: 'active', failedCount: 0 }], + ]) - mockGetSession.mockResolvedValue({ - user: { - id: 'user-id', - email: 'test@example.com', - }, - }) + const res = await GET(createRequest('http://test/api/schedules?workflowId=wf-1')) + const data = await res.json() - mockGetUserEntityPermissions.mockResolvedValue('admin') + expect(res.status).toBe(200) + expect(data.schedule.cronExpression).toBe('0 9 * * *') + expect(data.isDisabled).toBe(false) + }) - mockSelectLimit.mockReturnValue([ - { - id: 'workflow-id', - userId: 'user-id', - workspaceId: null, - }, - ]) + it('returns null when no schedule exists', async () => { + mockDbChain([[{ userId: 'user-1', 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 }) - }) + const res = await GET(createRequest('http://test/api/schedules?workflowId=wf-1')) + const data = await res.json() - afterEach(() => { - vi.clearAllMocks() + expect(res.status).toBe(200) + expect(data.schedule).toBeNull() }) - 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: {}, - }, - }) - - const response = await POST(req) - - 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') + it('requires authentication', async () => { + mockGetSession.mockResolvedValue(null) + + const res = await GET(createRequest('http://test/api/schedules?workflowId=wf-1')) + + expect(res.status).toBe(401) }) - it('should handle errors gracefully', async () => { - mockSelectLimit.mockReturnValue([]) + it('requires workflowId parameter', async () => { + const res = await GET(createRequest('http://test/api/schedules')) - const req = createMockRequest('POST', { - workflowId: 'workflow-id', - state: { blocks: {}, edges: [], loops: {} }, - }) + expect(res.status).toBe(400) + }) + + it('returns 404 for non-existent workflow', async () => { + mockDbChain([[]]) - const response = await POST(req) + const res = await GET(createRequest('http://test/api/schedules?workflowId=wf-1')) - expect(response.status).toBeGreaterThanOrEqual(400) - const data = await response.json() - expect(data).toHaveProperty('error') + expect(res.status).toBe(404) }) - it('should require authentication', async () => { - mockGetSession.mockResolvedValue(null) + 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')) - const req = createMockRequest('POST', { - workflowId: 'workflow-id', - state: { blocks: {}, edges: [], loops: {} }, - }) + 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 response = await POST(req) + const res = await GET(createRequest('http://test/api/schedules?workflowId=wf-1')) - expect(response.status).toBe(401) - const data = await response.json() - expect(data).toHaveProperty('error', 'Unauthorized') + expect(res.status).toBe(200) }) - it('should validate input data', async () => { - const req = createMockRequest('POST', { - workflowId: 'workflow-id', - }) + it('indicates disabled schedule with failures', async () => { + mockDbChain([ + [{ userId: 'user-1', workspaceId: null }], + [{ id: 'sched-1', status: 'disabled', failedCount: 10 }], + ]) - const response = await POST(req) + const res = await GET(createRequest('http://test/api/schedules?workflowId=wf-1')) + const data = await res.json() - expect(response.status).toBe(400) - const data = await response.json() - expect(data).toHaveProperty('error', 'Invalid request data') + 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 65c0ad30c1..07f8cbc952 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 58a291ea26..d2fdbb7610 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/deploy' import { validateWorkflowPermissions } from '@/lib/workflows/utils' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' @@ -105,6 +110,21 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ return createErrorResponse('Unable to determine deploying user', 400) } + // Load current workflow state to validate schedule blocks before deployment + const normalizedData = await loadWorkflowFromNormalizedTables(id) + if (!normalizedData) { + return createErrorResponse('Failed to load workflow state', 500) + } + + // Validate schedule blocks before deploying + 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 +137,25 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ const deployedAt = deployResult.deployedAt! + // Create or update schedules for the deployed workflow + let scheduleInfo: { scheduleId?: string; cronExpression?: string; nextRunAt?: Date } = {} + const scheduleResult = await createSchedulesForDeploy(id, normalizedData.blocks, db) + if (!scheduleResult.success) { + // Log but don't fail deployment - schedule creation is secondary + 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 +166,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 +202,9 @@ export async function DELETE( } await db.transaction(async (tx) => { + // Delete all schedules for this workflow + await deleteSchedulesForWorkflow(id, tx) + await tx .update(workflowDeploymentVersion) .set({ isActive: false }) diff --git a/apps/sim/app/api/workflows/[id]/state/route.ts b/apps/sim/app/api/workflows/[id]/state/route.ts index 0d85044b2b..ba68cb6966 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 7ea3acfd59..245313367c 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 @@ -19,6 +19,7 @@ import { createLogger } from '@/lib/logs/console/logger' import { getInputFormatExample as getInputFormatExampleUtil } from '@/lib/workflows/operations/deployment-utils' import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils' import { startsWithUuid } from '@/executor/constants' +import { useNotificationStore } from '@/stores/notifications/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' import type { WorkflowState } from '@/stores/workflows/workflow/types' @@ -68,6 +69,7 @@ export function DeployModal({ ) const isDeployed = deploymentStatus?.isDeployed ?? isDeployedProp const setDeploymentStatus = useWorkflowRegistry((state) => state.setDeploymentStatus) + const addNotification = useNotificationStore((state) => state.addNotification) const [isSubmitting, setIsSubmitting] = useState(false) const [isUndeploying, setIsUndeploying] = useState(false) const [deploymentInfo, setDeploymentInfo] = useState(null) @@ -258,7 +260,14 @@ export function DeployModal({ } catch (error: unknown) { logger.error('Error deploying workflow:', { error }) const errorMessage = error instanceof Error ? error.message : 'Failed to deploy workflow' - setApiDeployError(errorMessage) + + // Close modal and show notification for deploy errors + onOpenChange(false) + addNotification({ + level: 'error', + message: errorMessage, + workflowId: workflowId || undefined, + }) } finally { setIsSubmitting(false) } @@ -464,6 +473,15 @@ 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' + + // Close modal and show notification for redeploy errors + onOpenChange(false) + addNotification({ + level: 'error', + message: errorMessage, + workflowId: workflowId || undefined, + }) } finally { setIsSubmitting(false) } 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 dc5ba115e0..4eaab626d8 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 0000000000..38c16c2e31 --- /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,195 @@ +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 + disabled?: 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 94bf588c4a..0000000000 --- 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 0fb5dc6eda..e33c1e6482 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 b970fb3cd0..0d740344d6 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 5ba34fb906..e3e3f0146f 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 d548ee0be0..9b3339c159 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/blocks/blocks/schedule.ts b/apps/sim/blocks/blocks/schedule.ts index 027ece7d8d..1e384f2282 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 64c1a89a24..40d4ad426b 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 c825f04b5b..0000000000 --- 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 d12c554a09..dc4f55a73b 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 0000000000..2e3774a31d --- /dev/null +++ b/apps/sim/lib/workflows/schedules/deploy.test.ts @@ -0,0 +1,792 @@ +/** + * 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, + } +}) + +// Mock crypto.randomUUID +vi.stubGlobal('crypto', { + randomUUID: mockRandomUUID, +}) + +import { + createSchedulesForDeploy, + deleteSchedulesForWorkflow, + findScheduleBlocks, + validateScheduleBlock, + validateWorkflowSchedules, +} from './deploy' +import type { BlockState } from './utils' + +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 0000000000..bae7bf9d3b --- /dev/null +++ b/apps/sim/lib/workflows/schedules/deploy.ts @@ -0,0 +1,305 @@ +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, + calculateNextRunTime, + generateCronExpression, + getScheduleTimeValues, + getSubBlockValue, + validateCronExpression, +} from './utils' + +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 validation + */ +export interface ScheduleValidationResult { + isValid: boolean + error?: string + scheduleType?: string + cronExpression?: string + nextRunAt?: Date + timezone?: string +} + +/** + * Result of schedule creation during deploy + */ +export interface ScheduleDeployResult { + success: boolean + error?: string + scheduleId?: 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 } +} + +/** + * 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 } + } + + 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(), + }) + + return { + success: true, + scheduleId: values.id, + cronExpression, + nextRunAt, + timezone, + } + } + + return { success: true } +} + +/** + * 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/stores/constants.ts b/apps/sim/stores/constants.ts index 8c7ef86707..5e82494708 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 c3bdf63183..3f8e781362 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 c68812c4c1..5b4a7ab37e 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 d6f13fd92b..243e12bf01 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 ff511b2e92..3c47d671f5 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', ] From bb589df70e4747718c09e45c1ea611494350a1a0 Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 23 Dec 2025 17:45:11 -0800 Subject: [PATCH 2/6] added tests --- apps/docs/public/static/blocks/schedule-2.png | Bin 141766 -> 0 bytes apps/sim/app/api/schedules/[id]/route.test.ts | 286 ++++++++++++++++++ .../components/deploy-modal/deploy-modal.tsx | 20 +- .../components/deploy/hooks/use-deployment.ts | 120 +++++--- .../deploy/hooks/use-predeploy-checks.ts | 65 ++++ .../sim/lib/workflows/schedules/validation.ts | 184 +++++++++++ 6 files changed, 619 insertions(+), 56 deletions(-) delete mode 100644 apps/docs/public/static/blocks/schedule-2.png create mode 100644 apps/sim/app/api/schedules/[id]/route.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-predeploy-checks.ts create mode 100644 apps/sim/lib/workflows/schedules/validation.ts 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 c6b97881a68108cfeec83291e1c1b9104efd1994..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 141766 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 RTT8)@`B9snXr`_|{~HQ}RB!+Q 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 0000000000..a0f456b2f1 --- /dev/null +++ b/apps/sim/app/api/schedules/[id]/route.test.ts @@ -0,0 +1,286 @@ +/** + * 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 '@/app/api/schedules/[id]/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' }], + [{ 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' }], + [{ 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' }], + [{ 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' }], + [{ 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('sets nextRunAt to approximately 1 minute in future', async () => { + mockDbChain([ + [{ id: 'sched-1', workflowId: 'wf-1', status: 'disabled' }], + [{ 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 ~60 seconds from now + expect(nextRunAt).toBeGreaterThanOrEqual(beforeCall + 60000 - 1000) + expect(nextRunAt).toBeLessThanOrEqual(afterCall + 60000 + 1000) + }) + }) + + 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/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 245313367c..31c7645c90 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 @@ -19,7 +19,6 @@ import { createLogger } from '@/lib/logs/console/logger' import { getInputFormatExample as getInputFormatExampleUtil } from '@/lib/workflows/operations/deployment-utils' import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils' import { startsWithUuid } from '@/executor/constants' -import { useNotificationStore } from '@/stores/notifications/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' import type { WorkflowState } from '@/stores/workflows/workflow/types' @@ -69,7 +68,6 @@ export function DeployModal({ ) const isDeployed = deploymentStatus?.isDeployed ?? isDeployedProp const setDeploymentStatus = useWorkflowRegistry((state) => state.setDeploymentStatus) - const addNotification = useNotificationStore((state) => state.addNotification) const [isSubmitting, setIsSubmitting] = useState(false) const [isUndeploying, setIsUndeploying] = useState(false) const [deploymentInfo, setDeploymentInfo] = useState(null) @@ -260,14 +258,7 @@ export function DeployModal({ } catch (error: unknown) { logger.error('Error deploying workflow:', { error }) const errorMessage = error instanceof Error ? error.message : 'Failed to deploy workflow' - - // Close modal and show notification for deploy errors - onOpenChange(false) - addNotification({ - level: 'error', - message: errorMessage, - workflowId: workflowId || undefined, - }) + setApiDeployError(errorMessage) } finally { setIsSubmitting(false) } @@ -474,14 +465,7 @@ export function DeployModal({ } catch (error: unknown) { logger.error('Error redeploying workflow:', { error }) const errorMessage = error instanceof Error ? error.message : 'Failed to redeploy workflow' - - // Close modal and show notification for redeploy errors - onOpenChange(false) - addNotification({ - level: 'error', - message: errorMessage, - workflowId: workflowId || undefined, - }) + 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 8d2186546d..1851f77c97 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 0000000000..ef4d941226 --- /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/lib/workflows/schedules/validation.ts b/apps/sim/lib/workflows/schedules/validation.ts new file mode 100644 index 0000000000..3f061733a4 --- /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 './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 } +} From 27be90590829a1611eb4e5f0ed1266f78d7927c3 Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 23 Dec 2025 17:54:21 -0800 Subject: [PATCH 3/6] ack PR comments --- apps/sim/lib/workflows/schedules/deploy.ts | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/apps/sim/lib/workflows/schedules/deploy.ts b/apps/sim/lib/workflows/schedules/deploy.ts index bae7bf9d3b..a437697fd5 100644 --- a/apps/sim/lib/workflows/schedules/deploy.ts +++ b/apps/sim/lib/workflows/schedules/deploy.ts @@ -228,6 +228,13 @@ export async function createSchedulesForDeploy( 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 @@ -282,16 +289,13 @@ export async function createSchedulesForDeploy( nextRunAt: nextRunAt?.toISOString(), }) - return { - success: true, - scheduleId: values.id, - cronExpression, - nextRunAt, - timezone, - } + lastScheduleInfo = { scheduleId: values.id, cronExpression, nextRunAt, timezone } } - return { success: true } + return { + success: true, + ...lastScheduleInfo, + } } /** From 1738bcc541c491cbaa846cd7ef74519ff1face3b Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 23 Dec 2025 17:54:52 -0800 Subject: [PATCH 4/6] update turborepo --- bun.lock | 16 ++++++++-------- package.json | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/bun.lock b/bun.lock index a17a532417..8b813ec712 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 7d82e0de6b..ef795e12ed 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}": [ From 7724a9b5859a6231b3cf7a0b488b2b48fd68c0cf Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 23 Dec 2025 18:12:22 -0800 Subject: [PATCH 5/6] cleanup, edge cases --- apps/sim/app/api/schedules/[id]/route.test.ts | 386 ++++++++++++++++- apps/sim/app/api/schedules/[id]/route.ts | 16 +- .../app/api/workflows/[id]/deploy/route.ts | 9 +- apps/sim/background/schedule-execution.ts | 1 + .../lib/workflows/schedules/deploy.test.ts | 10 +- apps/sim/lib/workflows/schedules/deploy.ts | 182 +------- apps/sim/lib/workflows/schedules/index.ts | 23 + .../sim/lib/workflows/schedules/utils.test.ts | 393 ++++++++++++++++++ apps/sim/lib/workflows/schedules/utils.ts | 2 +- .../sim/lib/workflows/schedules/validation.ts | 2 +- 10 files changed, 815 insertions(+), 209 deletions(-) create mode 100644 apps/sim/lib/workflows/schedules/index.ts diff --git a/apps/sim/app/api/schedules/[id]/route.test.ts b/apps/sim/app/api/schedules/[id]/route.test.ts index a0f456b2f1..a24fb07a78 100644 --- a/apps/sim/app/api/schedules/[id]/route.test.ts +++ b/apps/sim/app/api/schedules/[id]/route.test.ts @@ -52,7 +52,7 @@ vi.mock('@/lib/logs/console/logger', () => ({ }), })) -import { PUT } from '@/app/api/schedules/[id]/route' +import { PUT } from './route' function createRequest(body: Record): NextRequest { return new NextRequest(new URL('http://test/api/schedules/sched-1'), { @@ -184,7 +184,15 @@ describe('Schedule PUT API (Reactivate)', () => { it('allows workflow owner to reactivate', async () => { mockDbChain([ - [{ id: 'sched-1', workflowId: 'wf-1', status: 'disabled' }], + [ + { + id: 'sched-1', + workflowId: 'wf-1', + status: 'disabled', + cronExpression: '*/5 * * * *', + timezone: 'UTC', + }, + ], [{ userId: 'user-1', workspaceId: null }], ]) @@ -198,7 +206,15 @@ describe('Schedule PUT API (Reactivate)', () => { it('allows workspace member with write permission to reactivate', async () => { mockGetUserEntityPermissions.mockResolvedValue('write') mockDbChain([ - [{ id: 'sched-1', workflowId: 'wf-1', status: 'disabled' }], + [ + { + id: 'sched-1', + workflowId: 'wf-1', + status: 'disabled', + cronExpression: '*/5 * * * *', + timezone: 'UTC', + }, + ], [{ userId: 'other-user', workspaceId: 'ws-1' }], ]) @@ -210,7 +226,15 @@ describe('Schedule PUT API (Reactivate)', () => { it('allows workspace admin to reactivate', async () => { mockGetUserEntityPermissions.mockResolvedValue('admin') mockDbChain([ - [{ id: 'sched-1', workflowId: 'wf-1', status: 'disabled' }], + [ + { + id: 'sched-1', + workflowId: 'wf-1', + status: 'disabled', + cronExpression: '*/5 * * * *', + timezone: 'UTC', + }, + ], [{ userId: 'other-user', workspaceId: 'ws-1' }], ]) @@ -237,7 +261,15 @@ describe('Schedule PUT API (Reactivate)', () => { it('successfully reactivates disabled schedule', async () => { mockDbChain([ - [{ id: 'sched-1', workflowId: 'wf-1', status: 'disabled' }], + [ + { + id: 'sched-1', + workflowId: 'wf-1', + status: 'disabled', + cronExpression: '*/5 * * * *', + timezone: 'UTC', + }, + ], [{ userId: 'user-1', workspaceId: null }], ]) @@ -250,9 +282,59 @@ describe('Schedule PUT API (Reactivate)', () => { expect(mockDbUpdate).toHaveBeenCalled() }) - it('sets nextRunAt to approximately 1 minute in future', async () => { + it('returns 400 when schedule has no cron expression', async () => { mockDbChain([ - [{ id: 'sched-1', workflowId: 'wf-1', status: 'disabled' }], + [ + { + 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 }], ]) @@ -264,9 +346,293 @@ describe('Schedule PUT API (Reactivate)', () => { const data = await res.json() const nextRunAt = new Date(data.nextRunAt).getTime() - // nextRunAt should be ~60 seconds from now - expect(nextRunAt).toBeGreaterThanOrEqual(beforeCall + 60000 - 1000) - expect(nextRunAt).toBeLessThanOrEqual(afterCall + 60000 + 1000) + // 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) }) }) diff --git a/apps/sim/app/api/schedules/[id]/route.ts b/apps/sim/app/api/schedules/[id]/route.ts index f9b981e6af..c3aa491e00 100644 --- a/apps/sim/app/api/schedules/[id]/route.ts +++ b/apps/sim/app/api/schedules/[id]/route.ts @@ -6,6 +6,7 @@ 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') @@ -44,6 +45,8 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: workflowSchedule.id, workflowId: workflowSchedule.workflowId, status: workflowSchedule.status, + cronExpression: workflowSchedule.cronExpression, + timezone: workflowSchedule.timezone, }) .from(workflowSchedule) .where(eq(workflowSchedule.id, scheduleId)) @@ -85,8 +88,19 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ message: 'Schedule is already active' }, { status: 200 }) } + if (!schedule.cronExpression) { + logger.error(`[${requestId}] Schedule has no cron expression: ${scheduleId}`) + return NextResponse.json({ error: 'Schedule has no cron expression' }, { status: 400 }) + } + + 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 }) + } + const now = new Date() - const nextRunAt = new Date(now.getTime() + 60 * 1000) // Schedule to run in 1 minute + const nextRunAt = cronResult.nextRun await db .update(workflowSchedule) diff --git a/apps/sim/app/api/workflows/[id]/deploy/route.ts b/apps/sim/app/api/workflows/[id]/deploy/route.ts index d2fdbb7610..cb898ff5d3 100644 --- a/apps/sim/app/api/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/workflows/[id]/deploy/route.ts @@ -8,7 +8,7 @@ import { createSchedulesForDeploy, deleteSchedulesForWorkflow, validateWorkflowSchedules, -} from '@/lib/workflows/schedules/deploy' +} from '@/lib/workflows/schedules' import { validateWorkflowPermissions } from '@/lib/workflows/utils' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' @@ -103,20 +103,17 @@ 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) } - // Load current workflow state to validate schedule blocks before deployment const normalizedData = await loadWorkflowFromNormalizedTables(id) if (!normalizedData) { return createErrorResponse('Failed to load workflow state', 500) } - // Validate schedule blocks before deploying const scheduleValidation = validateWorkflowSchedules(normalizedData.blocks) if (!scheduleValidation.isValid) { logger.warn( @@ -137,11 +134,9 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ const deployedAt = deployResult.deployedAt! - // Create or update schedules for the deployed workflow let scheduleInfo: { scheduleId?: string; cronExpression?: string; nextRunAt?: Date } = {} const scheduleResult = await createSchedulesForDeploy(id, normalizedData.blocks, db) if (!scheduleResult.success) { - // Log but don't fail deployment - schedule creation is secondary logger.error( `[${requestId}] Failed to create schedule for workflow ${id}: ${scheduleResult.error}` ) @@ -202,7 +197,6 @@ export async function DELETE( } await db.transaction(async (tx) => { - // Delete all schedules for this workflow await deleteSchedulesForWorkflow(id, tx) await tx @@ -218,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/background/schedule-execution.ts b/apps/sim/background/schedule-execution.ts index f0e778f79c..b5940b8f7a 100644 --- a/apps/sim/background/schedule-execution.ts +++ b/apps/sim/background/schedule-execution.ts @@ -573,6 +573,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/lib/workflows/schedules/deploy.test.ts b/apps/sim/lib/workflows/schedules/deploy.test.ts index 2e3774a31d..3fb7cca40a 100644 --- a/apps/sim/lib/workflows/schedules/deploy.test.ts +++ b/apps/sim/lib/workflows/schedules/deploy.test.ts @@ -61,19 +61,13 @@ vi.mock('./utils', async (importOriginal) => { } }) -// Mock crypto.randomUUID vi.stubGlobal('crypto', { randomUUID: mockRandomUUID, }) -import { - createSchedulesForDeploy, - deleteSchedulesForWorkflow, - findScheduleBlocks, - validateScheduleBlock, - validateWorkflowSchedules, -} from './deploy' +import { createSchedulesForDeploy, deleteSchedulesForWorkflow } from './deploy' import type { BlockState } from './utils' +import { findScheduleBlocks, validateScheduleBlock, validateWorkflowSchedules } from './validation' describe('Schedule Deploy Utilities', () => { beforeEach(() => { diff --git a/apps/sim/lib/workflows/schedules/deploy.ts b/apps/sim/lib/workflows/schedules/deploy.ts index a437697fd5..8c6346bbb2 100644 --- a/apps/sim/lib/workflows/schedules/deploy.ts +++ b/apps/sim/lib/workflows/schedules/deploy.ts @@ -5,14 +5,8 @@ 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, - calculateNextRunTime, - generateCronExpression, - getScheduleTimeValues, - getSubBlockValue, - validateCronExpression, -} from './utils' +import type { BlockState } from '@/lib/workflows/schedules/utils' +import { findScheduleBlocks, validateScheduleBlock } from '@/lib/workflows/schedules/validation' const logger = createLogger('ScheduleDeployUtils') @@ -28,18 +22,6 @@ type DbOrTx = ExtractTablesWithRelations > -/** - * Result of schedule validation - */ -export interface ScheduleValidationResult { - isValid: boolean - error?: string - scheduleType?: string - cronExpression?: string - nextRunAt?: Date - timezone?: string -} - /** * Result of schedule creation during deploy */ @@ -52,166 +34,6 @@ export interface ScheduleDeployResult { 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 } -} - /** * Create or update schedule records for a workflow during deployment * This should be called within a database transaction diff --git a/apps/sim/lib/workflows/schedules/index.ts b/apps/sim/lib/workflows/schedules/index.ts new file mode 100644 index 0000000000..0a5eb94bac --- /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 949cb32311..834f189b7f 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 233715c711..2658ddca7e 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 index 3f061733a4..2ad436983e 100644 --- a/apps/sim/lib/workflows/schedules/validation.ts +++ b/apps/sim/lib/workflows/schedules/validation.ts @@ -9,7 +9,7 @@ import { getScheduleTimeValues, getSubBlockValue, validateCronExpression, -} from './utils' +} from '@/lib/workflows/schedules/utils' /** * Result of schedule validation From 9625db51ad1850cf8b8f87732bb35fb1935fde88 Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 23 Dec 2025 18:32:47 -0800 Subject: [PATCH 6/6] ack PR comment --- .../sub-block/components/schedule-info/schedule-info.tsx | 1 - .../panel/components/editor/components/sub-block/sub-block.tsx | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) 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 index 38c16c2e31..5c2f5e487a 100644 --- 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 @@ -10,7 +10,6 @@ const logger = createLogger('ScheduleStatus') interface ScheduleInfoProps { blockId: string isPreview?: boolean - disabled?: boolean } /** 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 e33c1e6482..c807666b7a 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 @@ -593,7 +593,7 @@ function SubBlockComponent({ ) case 'schedule-info': - return + return case 'oauth-input': return (