mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-08 22:48:14 -05:00
feat(schedules): remove save button for schedules, couple schedule deployment with workflow deployment (#2566)
* feat(schedules): remove save button for schedules, couple schedule deployment with workflow deployment * added tests * ack PR comments * update turborepo * cleanup, edge cases * ack PR comment
This commit is contained in:
@@ -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:
|
||||
|
||||
<Tabs items={['Simple Intervals', 'Cron Expressions']}>
|
||||
<Tab>
|
||||
<ul className="list-disc space-y-1 pl-6">
|
||||
<li><strong>Every few minutes</strong>: 5, 15, 30 minute intervals</li>
|
||||
<li><strong>Hourly</strong>: Every hour or every few hours</li>
|
||||
<li><strong>Daily</strong>: Once or multiple times per day</li>
|
||||
<li><strong>Weekly</strong>: Specific days of the week</li>
|
||||
<li><strong>Monthly</strong>: Specific days of the month</li>
|
||||
<li><strong>Every X Minutes</strong>: Run at minute intervals (1-1440)</li>
|
||||
<li><strong>Hourly</strong>: Run at a specific minute each hour</li>
|
||||
<li><strong>Daily</strong>: Run at a specific time each day</li>
|
||||
<li><strong>Weekly</strong>: Run on a specific day and time each week</li>
|
||||
<li><strong>Monthly</strong>: Run on a specific day and time each month</li>
|
||||
</ul>
|
||||
</Tab>
|
||||
<Tab>
|
||||
@@ -43,24 +42,25 @@ Configure when your workflow runs using the dropdown options:
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
## 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:
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Image
|
||||
src="/static/blocks/schedule-2.png"
|
||||
alt="Active Schedule Block"
|
||||
width={500}
|
||||
height={400}
|
||||
className="my-6"
|
||||
/>
|
||||
</div>
|
||||
- **Deploy workflow** → Schedule becomes active and starts running
|
||||
- **Undeploy workflow** → Schedule is removed
|
||||
- **Redeploy workflow** → Schedule is recreated with current configuration
|
||||
|
||||
## Disabled Schedules
|
||||
<Callout>
|
||||
You must deploy your workflow for the schedule to start running. Configure the schedule block, then deploy from the toolbar.
|
||||
</Callout>
|
||||
|
||||
## 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
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Image
|
||||
@@ -72,8 +72,6 @@ When a workflow is scheduled:
|
||||
/>
|
||||
</div>
|
||||
|
||||
Disabled schedules show when they were last active. Click the **"Disabled"** badge to reactivate the schedule.
|
||||
|
||||
<Callout>
|
||||
Schedule blocks cannot receive incoming connections and serve as pure workflow triggers.
|
||||
Schedule blocks cannot receive incoming connections and serve as workflow entry points only.
|
||||
</Callout>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 138 KiB |
652
apps/sim/app/api/schedules/[id]/route.test.ts
Normal file
652
apps/sim/app/api/schedules/[id]/route.test.ts
Normal file
@@ -0,0 +1,652 @@
|
||||
/**
|
||||
* Tests for schedule reactivate PUT API route
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { NextRequest } from 'next/server'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockGetSession, mockGetUserEntityPermissions, mockDbSelect, mockDbUpdate } = vi.hoisted(
|
||||
() => ({
|
||||
mockGetSession: vi.fn(),
|
||||
mockGetUserEntityPermissions: vi.fn(),
|
||||
mockDbSelect: vi.fn(),
|
||||
mockDbUpdate: vi.fn(),
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/lib/auth', () => ({
|
||||
getSession: mockGetSession,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workspaces/permissions/utils', () => ({
|
||||
getUserEntityPermissions: mockGetUserEntityPermissions,
|
||||
}))
|
||||
|
||||
vi.mock('@sim/db', () => ({
|
||||
db: {
|
||||
select: mockDbSelect,
|
||||
update: mockDbUpdate,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@sim/db/schema', () => ({
|
||||
workflow: { id: 'id', userId: 'userId', workspaceId: 'workspaceId' },
|
||||
workflowSchedule: { id: 'id', workflowId: 'workflowId', status: 'status' },
|
||||
}))
|
||||
|
||||
vi.mock('drizzle-orm', () => ({
|
||||
eq: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/utils/request', () => ({
|
||||
generateRequestId: () => 'test-request-id',
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/logs/console/logger', () => ({
|
||||
createLogger: () => ({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
import { PUT } from './route'
|
||||
|
||||
function createRequest(body: Record<string, unknown>): NextRequest {
|
||||
return new NextRequest(new URL('http://test/api/schedules/sched-1'), {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
function createParams(id: string): { params: Promise<{ id: string }> } {
|
||||
return { params: Promise.resolve({ id }) }
|
||||
}
|
||||
|
||||
function mockDbChain(selectResults: unknown[][]) {
|
||||
let selectCallIndex = 0
|
||||
mockDbSelect.mockImplementation(() => ({
|
||||
from: () => ({
|
||||
where: () => ({
|
||||
limit: () => selectResults[selectCallIndex++] || [],
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
mockDbUpdate.mockImplementation(() => ({
|
||||
set: () => ({
|
||||
where: vi.fn().mockResolvedValue({}),
|
||||
}),
|
||||
}))
|
||||
}
|
||||
|
||||
describe('Schedule PUT API (Reactivate)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
|
||||
mockGetUserEntityPermissions.mockResolvedValue('write')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Authentication', () => {
|
||||
it('returns 401 when user is not authenticated', async () => {
|
||||
mockGetSession.mockResolvedValue(null)
|
||||
|
||||
const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1'))
|
||||
|
||||
expect(res.status).toBe(401)
|
||||
const data = await res.json()
|
||||
expect(data.error).toBe('Unauthorized')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Request Validation', () => {
|
||||
it('returns 400 when action is not reactivate', async () => {
|
||||
mockDbChain([
|
||||
[{ id: 'sched-1', workflowId: 'wf-1', status: 'disabled' }],
|
||||
[{ userId: 'user-1', workspaceId: null }],
|
||||
])
|
||||
|
||||
const res = await PUT(createRequest({ action: 'disable' }), createParams('sched-1'))
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
const data = await res.json()
|
||||
expect(data.error).toBe('Invalid request body')
|
||||
})
|
||||
|
||||
it('returns 400 when action is missing', async () => {
|
||||
mockDbChain([
|
||||
[{ id: 'sched-1', workflowId: 'wf-1', status: 'disabled' }],
|
||||
[{ userId: 'user-1', workspaceId: null }],
|
||||
])
|
||||
|
||||
const res = await PUT(createRequest({}), createParams('sched-1'))
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
const data = await res.json()
|
||||
expect(data.error).toBe('Invalid request body')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Schedule Not Found', () => {
|
||||
it('returns 404 when schedule does not exist', async () => {
|
||||
mockDbChain([[]])
|
||||
|
||||
const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-999'))
|
||||
|
||||
expect(res.status).toBe(404)
|
||||
const data = await res.json()
|
||||
expect(data.error).toBe('Schedule not found')
|
||||
})
|
||||
|
||||
it('returns 404 when workflow does not exist for schedule', async () => {
|
||||
mockDbChain([[{ id: 'sched-1', workflowId: 'wf-1', status: 'disabled' }], []])
|
||||
|
||||
const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1'))
|
||||
|
||||
expect(res.status).toBe(404)
|
||||
const data = await res.json()
|
||||
expect(data.error).toBe('Workflow not found')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Authorization', () => {
|
||||
it('returns 403 when user is not workflow owner', async () => {
|
||||
mockDbChain([
|
||||
[{ id: 'sched-1', workflowId: 'wf-1', status: 'disabled' }],
|
||||
[{ userId: 'other-user', workspaceId: null }],
|
||||
])
|
||||
|
||||
const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1'))
|
||||
|
||||
expect(res.status).toBe(403)
|
||||
const data = await res.json()
|
||||
expect(data.error).toBe('Not authorized to modify this schedule')
|
||||
})
|
||||
|
||||
it('returns 403 for workspace member with only read permission', async () => {
|
||||
mockGetUserEntityPermissions.mockResolvedValue('read')
|
||||
mockDbChain([
|
||||
[{ id: 'sched-1', workflowId: 'wf-1', status: 'disabled' }],
|
||||
[{ userId: 'other-user', workspaceId: 'ws-1' }],
|
||||
])
|
||||
|
||||
const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1'))
|
||||
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it('allows workflow owner to reactivate', async () => {
|
||||
mockDbChain([
|
||||
[
|
||||
{
|
||||
id: 'sched-1',
|
||||
workflowId: 'wf-1',
|
||||
status: 'disabled',
|
||||
cronExpression: '*/5 * * * *',
|
||||
timezone: 'UTC',
|
||||
},
|
||||
],
|
||||
[{ userId: 'user-1', workspaceId: null }],
|
||||
])
|
||||
|
||||
const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1'))
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const data = await res.json()
|
||||
expect(data.message).toBe('Schedule activated successfully')
|
||||
})
|
||||
|
||||
it('allows workspace member with write permission to reactivate', async () => {
|
||||
mockGetUserEntityPermissions.mockResolvedValue('write')
|
||||
mockDbChain([
|
||||
[
|
||||
{
|
||||
id: 'sched-1',
|
||||
workflowId: 'wf-1',
|
||||
status: 'disabled',
|
||||
cronExpression: '*/5 * * * *',
|
||||
timezone: 'UTC',
|
||||
},
|
||||
],
|
||||
[{ userId: 'other-user', workspaceId: 'ws-1' }],
|
||||
])
|
||||
|
||||
const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1'))
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
|
||||
it('allows workspace admin to reactivate', async () => {
|
||||
mockGetUserEntityPermissions.mockResolvedValue('admin')
|
||||
mockDbChain([
|
||||
[
|
||||
{
|
||||
id: 'sched-1',
|
||||
workflowId: 'wf-1',
|
||||
status: 'disabled',
|
||||
cronExpression: '*/5 * * * *',
|
||||
timezone: 'UTC',
|
||||
},
|
||||
],
|
||||
[{ userId: 'other-user', workspaceId: 'ws-1' }],
|
||||
])
|
||||
|
||||
const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1'))
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Schedule State Handling', () => {
|
||||
it('returns success message when schedule is already active', async () => {
|
||||
mockDbChain([
|
||||
[{ id: 'sched-1', workflowId: 'wf-1', status: 'active' }],
|
||||
[{ userId: 'user-1', workspaceId: null }],
|
||||
])
|
||||
|
||||
const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1'))
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const data = await res.json()
|
||||
expect(data.message).toBe('Schedule is already active')
|
||||
expect(mockDbUpdate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('successfully reactivates disabled schedule', async () => {
|
||||
mockDbChain([
|
||||
[
|
||||
{
|
||||
id: 'sched-1',
|
||||
workflowId: 'wf-1',
|
||||
status: 'disabled',
|
||||
cronExpression: '*/5 * * * *',
|
||||
timezone: 'UTC',
|
||||
},
|
||||
],
|
||||
[{ userId: 'user-1', workspaceId: null }],
|
||||
])
|
||||
|
||||
const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1'))
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const data = await res.json()
|
||||
expect(data.message).toBe('Schedule activated successfully')
|
||||
expect(data.nextRunAt).toBeDefined()
|
||||
expect(mockDbUpdate).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 400 when schedule has no cron expression', async () => {
|
||||
mockDbChain([
|
||||
[
|
||||
{
|
||||
id: 'sched-1',
|
||||
workflowId: 'wf-1',
|
||||
status: 'disabled',
|
||||
cronExpression: null,
|
||||
timezone: 'UTC',
|
||||
},
|
||||
],
|
||||
[{ userId: 'user-1', workspaceId: null }],
|
||||
])
|
||||
|
||||
const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1'))
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
const data = await res.json()
|
||||
expect(data.error).toBe('Schedule has no cron expression')
|
||||
})
|
||||
|
||||
it('returns 400 when schedule has invalid cron expression', async () => {
|
||||
mockDbChain([
|
||||
[
|
||||
{
|
||||
id: 'sched-1',
|
||||
workflowId: 'wf-1',
|
||||
status: 'disabled',
|
||||
cronExpression: 'invalid-cron',
|
||||
timezone: 'UTC',
|
||||
},
|
||||
],
|
||||
[{ userId: 'user-1', workspaceId: null }],
|
||||
])
|
||||
|
||||
const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1'))
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
const data = await res.json()
|
||||
expect(data.error).toBe('Schedule has invalid cron expression')
|
||||
})
|
||||
|
||||
it('calculates nextRunAt from stored cron expression (every 5 minutes)', async () => {
|
||||
mockDbChain([
|
||||
[
|
||||
{
|
||||
id: 'sched-1',
|
||||
workflowId: 'wf-1',
|
||||
status: 'disabled',
|
||||
cronExpression: '*/5 * * * *',
|
||||
timezone: 'UTC',
|
||||
},
|
||||
],
|
||||
[{ userId: 'user-1', workspaceId: null }],
|
||||
])
|
||||
|
||||
const beforeCall = Date.now()
|
||||
const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1'))
|
||||
const afterCall = Date.now()
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const data = await res.json()
|
||||
const nextRunAt = new Date(data.nextRunAt).getTime()
|
||||
|
||||
// nextRunAt should be within 0-5 minutes in the future
|
||||
expect(nextRunAt).toBeGreaterThan(beforeCall)
|
||||
expect(nextRunAt).toBeLessThanOrEqual(afterCall + 5 * 60 * 1000 + 1000)
|
||||
// Should align with 5-minute intervals (minute divisible by 5)
|
||||
expect(new Date(nextRunAt).getMinutes() % 5).toBe(0)
|
||||
})
|
||||
|
||||
it('calculates nextRunAt from daily cron expression', async () => {
|
||||
mockDbChain([
|
||||
[
|
||||
{
|
||||
id: 'sched-1',
|
||||
workflowId: 'wf-1',
|
||||
status: 'disabled',
|
||||
cronExpression: '30 14 * * *', // 2:30 PM daily
|
||||
timezone: 'UTC',
|
||||
},
|
||||
],
|
||||
[{ userId: 'user-1', workspaceId: null }],
|
||||
])
|
||||
|
||||
const beforeCall = Date.now()
|
||||
const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1'))
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const data = await res.json()
|
||||
const nextRunAt = new Date(data.nextRunAt)
|
||||
|
||||
// Should be a future date at 14:30 UTC
|
||||
expect(nextRunAt.getTime()).toBeGreaterThan(beforeCall)
|
||||
expect(nextRunAt.getUTCHours()).toBe(14)
|
||||
expect(nextRunAt.getUTCMinutes()).toBe(30)
|
||||
expect(nextRunAt.getUTCSeconds()).toBe(0)
|
||||
})
|
||||
|
||||
it('calculates nextRunAt from weekly cron expression', async () => {
|
||||
mockDbChain([
|
||||
[
|
||||
{
|
||||
id: 'sched-1',
|
||||
workflowId: 'wf-1',
|
||||
status: 'disabled',
|
||||
cronExpression: '0 9 * * 1', // Monday at 9:00 AM
|
||||
timezone: 'UTC',
|
||||
},
|
||||
],
|
||||
[{ userId: 'user-1', workspaceId: null }],
|
||||
])
|
||||
|
||||
const beforeCall = Date.now()
|
||||
const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1'))
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const data = await res.json()
|
||||
const nextRunAt = new Date(data.nextRunAt)
|
||||
|
||||
// Should be a future date on Monday at 09:00 UTC
|
||||
expect(nextRunAt.getTime()).toBeGreaterThan(beforeCall)
|
||||
expect(nextRunAt.getUTCDay()).toBe(1) // Monday
|
||||
expect(nextRunAt.getUTCHours()).toBe(9)
|
||||
expect(nextRunAt.getUTCMinutes()).toBe(0)
|
||||
})
|
||||
|
||||
it('calculates nextRunAt from monthly cron expression', async () => {
|
||||
mockDbChain([
|
||||
[
|
||||
{
|
||||
id: 'sched-1',
|
||||
workflowId: 'wf-1',
|
||||
status: 'disabled',
|
||||
cronExpression: '0 10 15 * *', // 15th of month at 10:00 AM
|
||||
timezone: 'UTC',
|
||||
},
|
||||
],
|
||||
[{ userId: 'user-1', workspaceId: null }],
|
||||
])
|
||||
|
||||
const beforeCall = Date.now()
|
||||
const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1'))
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const data = await res.json()
|
||||
const nextRunAt = new Date(data.nextRunAt)
|
||||
|
||||
// Should be a future date on the 15th at 10:00 UTC
|
||||
expect(nextRunAt.getTime()).toBeGreaterThan(beforeCall)
|
||||
expect(nextRunAt.getUTCDate()).toBe(15)
|
||||
expect(nextRunAt.getUTCHours()).toBe(10)
|
||||
expect(nextRunAt.getUTCMinutes()).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Timezone Handling in Reactivation', () => {
|
||||
it('calculates nextRunAt with America/New_York timezone', async () => {
|
||||
mockDbChain([
|
||||
[
|
||||
{
|
||||
id: 'sched-1',
|
||||
workflowId: 'wf-1',
|
||||
status: 'disabled',
|
||||
cronExpression: '0 9 * * *', // 9:00 AM Eastern
|
||||
timezone: 'America/New_York',
|
||||
},
|
||||
],
|
||||
[{ userId: 'user-1', workspaceId: null }],
|
||||
])
|
||||
|
||||
const beforeCall = Date.now()
|
||||
const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1'))
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const data = await res.json()
|
||||
const nextRunAt = new Date(data.nextRunAt)
|
||||
|
||||
// Should be a future date
|
||||
expect(nextRunAt.getTime()).toBeGreaterThan(beforeCall)
|
||||
// The exact UTC hour will depend on DST, but it should be 13:00 or 14:00 UTC
|
||||
const utcHour = nextRunAt.getUTCHours()
|
||||
expect([13, 14]).toContain(utcHour) // 9 AM ET = 1-2 PM UTC depending on DST
|
||||
expect(nextRunAt.getUTCMinutes()).toBe(0)
|
||||
})
|
||||
|
||||
it('calculates nextRunAt with Asia/Tokyo timezone', async () => {
|
||||
mockDbChain([
|
||||
[
|
||||
{
|
||||
id: 'sched-1',
|
||||
workflowId: 'wf-1',
|
||||
status: 'disabled',
|
||||
cronExpression: '30 15 * * *', // 3:30 PM Japan Time
|
||||
timezone: 'Asia/Tokyo',
|
||||
},
|
||||
],
|
||||
[{ userId: 'user-1', workspaceId: null }],
|
||||
])
|
||||
|
||||
const beforeCall = Date.now()
|
||||
const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1'))
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const data = await res.json()
|
||||
const nextRunAt = new Date(data.nextRunAt)
|
||||
|
||||
// Should be a future date
|
||||
expect(nextRunAt.getTime()).toBeGreaterThan(beforeCall)
|
||||
// 3:30 PM JST (UTC+9) = 6:30 AM UTC
|
||||
expect(nextRunAt.getUTCHours()).toBe(6)
|
||||
expect(nextRunAt.getUTCMinutes()).toBe(30)
|
||||
})
|
||||
|
||||
it('calculates nextRunAt with Europe/London timezone', async () => {
|
||||
mockDbChain([
|
||||
[
|
||||
{
|
||||
id: 'sched-1',
|
||||
workflowId: 'wf-1',
|
||||
status: 'disabled',
|
||||
cronExpression: '0 12 * * 5', // Friday at noon London time
|
||||
timezone: 'Europe/London',
|
||||
},
|
||||
],
|
||||
[{ userId: 'user-1', workspaceId: null }],
|
||||
])
|
||||
|
||||
const beforeCall = Date.now()
|
||||
const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1'))
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const data = await res.json()
|
||||
const nextRunAt = new Date(data.nextRunAt)
|
||||
|
||||
// Should be a future date on Friday
|
||||
expect(nextRunAt.getTime()).toBeGreaterThan(beforeCall)
|
||||
expect(nextRunAt.getUTCDay()).toBe(5) // Friday
|
||||
// UTC hour depends on BST/GMT (11:00 or 12:00 UTC)
|
||||
const utcHour = nextRunAt.getUTCHours()
|
||||
expect([11, 12]).toContain(utcHour)
|
||||
expect(nextRunAt.getUTCMinutes()).toBe(0)
|
||||
})
|
||||
|
||||
it('uses UTC as default timezone when timezone is not set', async () => {
|
||||
mockDbChain([
|
||||
[
|
||||
{
|
||||
id: 'sched-1',
|
||||
workflowId: 'wf-1',
|
||||
status: 'disabled',
|
||||
cronExpression: '0 10 * * *', // 10:00 AM
|
||||
timezone: null,
|
||||
},
|
||||
],
|
||||
[{ userId: 'user-1', workspaceId: null }],
|
||||
])
|
||||
|
||||
const beforeCall = Date.now()
|
||||
const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1'))
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const data = await res.json()
|
||||
const nextRunAt = new Date(data.nextRunAt)
|
||||
|
||||
// Should be a future date at 10:00 UTC
|
||||
expect(nextRunAt.getTime()).toBeGreaterThan(beforeCall)
|
||||
expect(nextRunAt.getUTCHours()).toBe(10)
|
||||
expect(nextRunAt.getUTCMinutes()).toBe(0)
|
||||
})
|
||||
|
||||
it('handles minutely schedules with timezone correctly', async () => {
|
||||
mockDbChain([
|
||||
[
|
||||
{
|
||||
id: 'sched-1',
|
||||
workflowId: 'wf-1',
|
||||
status: 'disabled',
|
||||
cronExpression: '*/10 * * * *', // Every 10 minutes
|
||||
timezone: 'America/Los_Angeles',
|
||||
},
|
||||
],
|
||||
[{ userId: 'user-1', workspaceId: null }],
|
||||
])
|
||||
|
||||
const beforeCall = Date.now()
|
||||
const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1'))
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const data = await res.json()
|
||||
const nextRunAt = new Date(data.nextRunAt)
|
||||
|
||||
// Should be a future date within the next 10 minutes
|
||||
expect(nextRunAt.getTime()).toBeGreaterThan(beforeCall)
|
||||
expect(nextRunAt.getTime()).toBeLessThanOrEqual(beforeCall + 10 * 60 * 1000 + 1000)
|
||||
// Should align with 10-minute intervals
|
||||
expect(nextRunAt.getMinutes() % 10).toBe(0)
|
||||
})
|
||||
|
||||
it('handles hourly schedules with timezone correctly', async () => {
|
||||
mockDbChain([
|
||||
[
|
||||
{
|
||||
id: 'sched-1',
|
||||
workflowId: 'wf-1',
|
||||
status: 'disabled',
|
||||
cronExpression: '15 * * * *', // At minute 15 of every hour
|
||||
timezone: 'America/Chicago',
|
||||
},
|
||||
],
|
||||
[{ userId: 'user-1', workspaceId: null }],
|
||||
])
|
||||
|
||||
const beforeCall = Date.now()
|
||||
const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1'))
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const data = await res.json()
|
||||
const nextRunAt = new Date(data.nextRunAt)
|
||||
|
||||
// Should be a future date at minute 15
|
||||
expect(nextRunAt.getTime()).toBeGreaterThan(beforeCall)
|
||||
expect(nextRunAt.getMinutes()).toBe(15)
|
||||
expect(nextRunAt.getSeconds()).toBe(0)
|
||||
})
|
||||
|
||||
it('handles custom cron expressions with complex patterns and timezone', async () => {
|
||||
mockDbChain([
|
||||
[
|
||||
{
|
||||
id: 'sched-1',
|
||||
workflowId: 'wf-1',
|
||||
status: 'disabled',
|
||||
cronExpression: '0 9 * * 1-5', // Weekdays at 9 AM
|
||||
timezone: 'America/New_York',
|
||||
},
|
||||
],
|
||||
[{ userId: 'user-1', workspaceId: null }],
|
||||
])
|
||||
|
||||
const beforeCall = Date.now()
|
||||
const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1'))
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const data = await res.json()
|
||||
const nextRunAt = new Date(data.nextRunAt)
|
||||
|
||||
// Should be a future date on a weekday (1-5)
|
||||
expect(nextRunAt.getTime()).toBeGreaterThan(beforeCall)
|
||||
const dayOfWeek = nextRunAt.getUTCDay()
|
||||
expect([1, 2, 3, 4, 5]).toContain(dayOfWeek)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('returns 500 when database operation fails', async () => {
|
||||
mockDbSelect.mockImplementation(() => {
|
||||
throw new Error('Database connection failed')
|
||||
})
|
||||
|
||||
const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1'))
|
||||
|
||||
expect(res.status).toBe(500)
|
||||
const data = await res.json()
|
||||
expect(data.error).toBe('Failed to update schedule')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -6,104 +6,26 @@ import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { validateCronExpression } from '@/lib/workflows/schedules/utils'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('ScheduleAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const scheduleActionEnum = z.enum(['reactivate', 'disable'])
|
||||
const scheduleStatusEnum = z.enum(['active', 'disabled'])
|
||||
|
||||
const scheduleUpdateSchema = z
|
||||
.object({
|
||||
action: scheduleActionEnum.optional(),
|
||||
status: scheduleStatusEnum.optional(),
|
||||
})
|
||||
.refine((data) => data.action || data.status, {
|
||||
message: 'Either action or status must be provided',
|
||||
})
|
||||
const scheduleUpdateSchema = z.object({
|
||||
action: z.literal('reactivate'),
|
||||
})
|
||||
|
||||
/**
|
||||
* Delete a schedule
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const { id } = await params
|
||||
logger.debug(`[${requestId}] Deleting schedule with ID: ${id}`)
|
||||
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized schedule deletion attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Find the schedule and check ownership
|
||||
const schedules = await db
|
||||
.select({
|
||||
schedule: workflowSchedule,
|
||||
workflow: {
|
||||
id: workflow.id,
|
||||
userId: workflow.userId,
|
||||
workspaceId: workflow.workspaceId,
|
||||
},
|
||||
})
|
||||
.from(workflowSchedule)
|
||||
.innerJoin(workflow, eq(workflowSchedule.workflowId, workflow.id))
|
||||
.where(eq(workflowSchedule.id, id))
|
||||
.limit(1)
|
||||
|
||||
if (schedules.length === 0) {
|
||||
logger.warn(`[${requestId}] Schedule not found: ${id}`)
|
||||
return NextResponse.json({ error: 'Schedule not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const workflowRecord = schedules[0].workflow
|
||||
|
||||
// Check authorization - either the user owns the workflow or has write/admin workspace permissions
|
||||
let isAuthorized = workflowRecord.userId === session.user.id
|
||||
|
||||
// If not authorized by ownership and the workflow belongs to a workspace, check workspace permissions
|
||||
if (!isAuthorized && workflowRecord.workspaceId) {
|
||||
const userPermission = await getUserEntityPermissions(
|
||||
session.user.id,
|
||||
'workspace',
|
||||
workflowRecord.workspaceId
|
||||
)
|
||||
isAuthorized = userPermission === 'write' || userPermission === 'admin'
|
||||
}
|
||||
|
||||
if (!isAuthorized) {
|
||||
logger.warn(`[${requestId}] Unauthorized schedule deletion attempt for schedule: ${id}`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Delete the schedule
|
||||
await db.delete(workflowSchedule).where(eq(workflowSchedule.id, id))
|
||||
|
||||
logger.info(`[${requestId}] Successfully deleted schedule: ${id}`)
|
||||
return NextResponse.json({ success: true }, { status: 200 })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error deleting schedule`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a schedule - can be used to reactivate a disabled schedule
|
||||
* Reactivate a disabled schedule
|
||||
*/
|
||||
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const { id } = await params
|
||||
const scheduleId = id
|
||||
logger.debug(`[${requestId}] Updating schedule with ID: ${scheduleId}`)
|
||||
const { id: scheduleId } = await params
|
||||
logger.debug(`[${requestId}] Reactivating schedule with ID: ${scheduleId}`)
|
||||
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
@@ -115,18 +37,16 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||
const validation = scheduleUpdateSchema.safeParse(body)
|
||||
|
||||
if (!validation.success) {
|
||||
const firstError = validation.error.errors[0]
|
||||
logger.warn(`[${requestId}] Validation error:`, firstError)
|
||||
return NextResponse.json({ error: firstError.message }, { status: 400 })
|
||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
|
||||
}
|
||||
|
||||
const { action, status: requestedStatus } = validation.data
|
||||
|
||||
const [schedule] = await db
|
||||
.select({
|
||||
id: workflowSchedule.id,
|
||||
workflowId: workflowSchedule.workflowId,
|
||||
status: workflowSchedule.status,
|
||||
cronExpression: workflowSchedule.cronExpression,
|
||||
timezone: workflowSchedule.timezone,
|
||||
})
|
||||
.from(workflowSchedule)
|
||||
.where(eq(workflowSchedule.id, scheduleId))
|
||||
@@ -164,13 +84,23 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||
return NextResponse.json({ error: 'Not authorized to modify this schedule' }, { status: 403 })
|
||||
}
|
||||
|
||||
if (action === 'reactivate' || (requestedStatus && requestedStatus === 'active')) {
|
||||
if (schedule.status === 'active') {
|
||||
return NextResponse.json({ message: 'Schedule is already active' }, { status: 200 })
|
||||
}
|
||||
|
||||
if (!schedule.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)
|
||||
@@ -188,33 +118,6 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||
message: 'Schedule activated successfully',
|
||||
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}`)
|
||||
|
||||
return NextResponse.json({
|
||||
message: 'Schedule disabled successfully',
|
||||
})
|
||||
}
|
||||
|
||||
logger.warn(`[${requestId}] Unsupported update action for schedule: ${scheduleId}`)
|
||||
return NextResponse.json({ error: 'Unsupported update action' }, { status: 400 })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error updating schedule`, error)
|
||||
return NextResponse.json({ error: 'Failed to update schedule' }, { status: 500 })
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -1,43 +1,15 @@
|
||||
/**
|
||||
* Integration tests for schedule configuration API route
|
||||
* Tests for schedule GET API route
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { NextRequest } from 'next/server'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createMockRequest, mockExecutionDependencies } from '@/app/api/__test-utils__/utils'
|
||||
|
||||
const {
|
||||
mockGetSession,
|
||||
mockGetUserEntityPermissions,
|
||||
mockSelectLimit,
|
||||
mockInsertValues,
|
||||
mockOnConflictDoUpdate,
|
||||
mockInsert,
|
||||
mockUpdate,
|
||||
mockDelete,
|
||||
mockTransaction,
|
||||
mockRandomUUID,
|
||||
mockGetScheduleTimeValues,
|
||||
mockGetSubBlockValue,
|
||||
mockGenerateCronExpression,
|
||||
mockCalculateNextRunTime,
|
||||
mockValidateCronExpression,
|
||||
} = vi.hoisted(() => ({
|
||||
const { mockGetSession, mockGetUserEntityPermissions, mockDbSelect } = vi.hoisted(() => ({
|
||||
mockGetSession: vi.fn(),
|
||||
mockGetUserEntityPermissions: vi.fn(),
|
||||
mockSelectLimit: vi.fn(),
|
||||
mockInsertValues: vi.fn(),
|
||||
mockOnConflictDoUpdate: vi.fn(),
|
||||
mockInsert: vi.fn(),
|
||||
mockUpdate: vi.fn(),
|
||||
mockDelete: vi.fn(),
|
||||
mockTransaction: vi.fn(),
|
||||
mockRandomUUID: vi.fn(),
|
||||
mockGetScheduleTimeValues: vi.fn(),
|
||||
mockGetSubBlockValue: vi.fn(),
|
||||
mockGenerateCronExpression: vi.fn(),
|
||||
mockCalculateNextRunTime: vi.fn(),
|
||||
mockValidateCronExpression: vi.fn(),
|
||||
mockDbSelect: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth', () => ({
|
||||
@@ -50,231 +22,136 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({
|
||||
|
||||
vi.mock('@sim/db', () => ({
|
||||
db: {
|
||||
select: vi.fn().mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: mockSelectLimit,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
insert: mockInsert,
|
||||
update: mockUpdate,
|
||||
delete: mockDelete,
|
||||
select: mockDbSelect,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@sim/db/schema', () => ({
|
||||
workflow: {
|
||||
id: 'workflow_id',
|
||||
userId: 'user_id',
|
||||
workspaceId: 'workspace_id',
|
||||
},
|
||||
workflowSchedule: {
|
||||
id: 'schedule_id',
|
||||
workflowId: 'workflow_id',
|
||||
blockId: 'block_id',
|
||||
cronExpression: 'cron_expression',
|
||||
nextRunAt: 'next_run_at',
|
||||
status: 'status',
|
||||
},
|
||||
workflow: { id: 'id', userId: 'userId', workspaceId: 'workspaceId' },
|
||||
workflowSchedule: { workflowId: 'workflowId', blockId: 'blockId' },
|
||||
}))
|
||||
|
||||
vi.mock('drizzle-orm', () => ({
|
||||
eq: vi.fn((...args) => ({ type: 'eq', args })),
|
||||
and: vi.fn((...args) => ({ type: 'and', args })),
|
||||
}))
|
||||
|
||||
vi.mock('crypto', () => ({
|
||||
randomUUID: mockRandomUUID,
|
||||
default: {
|
||||
randomUUID: mockRandomUUID,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workflows/schedules/utils', () => ({
|
||||
getScheduleTimeValues: mockGetScheduleTimeValues,
|
||||
getSubBlockValue: mockGetSubBlockValue,
|
||||
generateCronExpression: mockGenerateCronExpression,
|
||||
calculateNextRunTime: mockCalculateNextRunTime,
|
||||
validateCronExpression: mockValidateCronExpression,
|
||||
BlockState: {},
|
||||
eq: vi.fn(),
|
||||
and: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/utils/request', () => ({
|
||||
generateRequestId: vi.fn(() => 'test-request-id'),
|
||||
generateRequestId: () => 'test-request-id',
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/logs/console/logger', () => ({
|
||||
createLogger: vi.fn(() => ({
|
||||
createLogger: () => ({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
})),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/telemetry', () => ({
|
||||
trackPlatformEvent: vi.fn(),
|
||||
}))
|
||||
import { GET } from '@/app/api/schedules/route'
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { POST } from '@/app/api/schedules/route'
|
||||
function createRequest(url: string): NextRequest {
|
||||
return new NextRequest(new URL(url), { method: 'GET' })
|
||||
}
|
||||
|
||||
describe('Schedule Configuration API Route', () => {
|
||||
function mockDbChain(results: any[]) {
|
||||
let callIndex = 0
|
||||
mockDbSelect.mockImplementation(() => ({
|
||||
from: () => ({
|
||||
where: () => ({
|
||||
limit: () => results[callIndex++] || [],
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
}
|
||||
|
||||
describe('Schedule GET API', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
;(db as any).transaction = mockTransaction
|
||||
|
||||
mockExecutionDependencies()
|
||||
|
||||
mockGetSession.mockResolvedValue({
|
||||
user: {
|
||||
id: 'user-id',
|
||||
email: 'test@example.com',
|
||||
},
|
||||
})
|
||||
|
||||
mockGetUserEntityPermissions.mockResolvedValue('admin')
|
||||
|
||||
mockSelectLimit.mockReturnValue([
|
||||
{
|
||||
id: 'workflow-id',
|
||||
userId: 'user-id',
|
||||
workspaceId: null,
|
||||
},
|
||||
])
|
||||
|
||||
mockInsertValues.mockImplementation(() => ({
|
||||
onConflictDoUpdate: mockOnConflictDoUpdate,
|
||||
}))
|
||||
mockOnConflictDoUpdate.mockResolvedValue({})
|
||||
|
||||
mockInsert.mockReturnValue({
|
||||
values: mockInsertValues,
|
||||
})
|
||||
|
||||
mockUpdate.mockImplementation(() => ({
|
||||
set: vi.fn().mockImplementation(() => ({
|
||||
where: vi.fn().mockResolvedValue([]),
|
||||
})),
|
||||
}))
|
||||
|
||||
mockDelete.mockImplementation(() => ({
|
||||
where: vi.fn().mockResolvedValue([]),
|
||||
}))
|
||||
|
||||
mockTransaction.mockImplementation(async (callback) => {
|
||||
const tx = {
|
||||
insert: vi.fn().mockReturnValue({
|
||||
values: mockInsertValues,
|
||||
}),
|
||||
}
|
||||
return callback(tx)
|
||||
})
|
||||
|
||||
mockRandomUUID.mockReturnValue('test-uuid')
|
||||
|
||||
mockGetScheduleTimeValues.mockReturnValue({
|
||||
scheduleTime: '09:30',
|
||||
minutesInterval: 15,
|
||||
hourlyMinute: 0,
|
||||
dailyTime: [9, 30],
|
||||
weeklyDay: 1,
|
||||
weeklyTime: [9, 30],
|
||||
monthlyDay: 1,
|
||||
monthlyTime: [9, 30],
|
||||
})
|
||||
|
||||
mockGetSubBlockValue.mockImplementation((block: any, id: string) => {
|
||||
const subBlocks = {
|
||||
startWorkflow: 'schedule',
|
||||
scheduleType: 'daily',
|
||||
scheduleTime: '09:30',
|
||||
dailyTime: '09:30',
|
||||
}
|
||||
return subBlocks[id as keyof typeof subBlocks] || ''
|
||||
})
|
||||
|
||||
mockGenerateCronExpression.mockReturnValue('0 9 * * *')
|
||||
mockCalculateNextRunTime.mockReturnValue(new Date())
|
||||
mockValidateCronExpression.mockReturnValue({ isValid: true })
|
||||
mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
|
||||
mockGetUserEntityPermissions.mockResolvedValue('read')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should create a new schedule successfully', async () => {
|
||||
const req = createMockRequest('POST', {
|
||||
workflowId: 'workflow-id',
|
||||
state: {
|
||||
blocks: {
|
||||
'starter-id': {
|
||||
type: 'starter',
|
||||
subBlocks: {
|
||||
startWorkflow: { value: 'schedule' },
|
||||
scheduleType: { value: 'daily' },
|
||||
scheduleTime: { value: '09:30' },
|
||||
dailyTime: { value: '09:30' },
|
||||
},
|
||||
},
|
||||
},
|
||||
edges: [],
|
||||
loops: {},
|
||||
},
|
||||
it('returns schedule data for authorized user', async () => {
|
||||
mockDbChain([
|
||||
[{ userId: 'user-1', workspaceId: null }],
|
||||
[{ id: 'sched-1', cronExpression: '0 9 * * *', status: 'active', failedCount: 0 }],
|
||||
])
|
||||
|
||||
const res = await GET(createRequest('http://test/api/schedules?workflowId=wf-1'))
|
||||
const data = await res.json()
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(data.schedule.cronExpression).toBe('0 9 * * *')
|
||||
expect(data.isDisabled).toBe(false)
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
it('returns null when no schedule exists', async () => {
|
||||
mockDbChain([[{ userId: 'user-1', workspaceId: null }], []])
|
||||
|
||||
expect(response).toBeDefined()
|
||||
expect(response.status).toBe(200)
|
||||
const res = await GET(createRequest('http://test/api/schedules?workflowId=wf-1'))
|
||||
const data = await res.json()
|
||||
|
||||
const responseData = await response.json()
|
||||
expect(responseData).toHaveProperty('message', 'Schedule updated')
|
||||
expect(responseData).toHaveProperty('cronExpression', '0 9 * * *')
|
||||
expect(responseData).toHaveProperty('nextRunAt')
|
||||
expect(res.status).toBe(200)
|
||||
expect(data.schedule).toBeNull()
|
||||
})
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
mockSelectLimit.mockReturnValue([])
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
workflowId: 'workflow-id',
|
||||
state: { blocks: {}, edges: [], loops: {} },
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBeGreaterThanOrEqual(400)
|
||||
const data = await response.json()
|
||||
expect(data).toHaveProperty('error')
|
||||
})
|
||||
|
||||
it('should require authentication', async () => {
|
||||
it('requires authentication', async () => {
|
||||
mockGetSession.mockResolvedValue(null)
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
workflowId: 'workflow-id',
|
||||
state: { blocks: {}, edges: [], loops: {} },
|
||||
const res = await GET(createRequest('http://test/api/schedules?workflowId=wf-1'))
|
||||
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
it('requires workflowId parameter', async () => {
|
||||
const res = await GET(createRequest('http://test/api/schedules'))
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
const data = await response.json()
|
||||
expect(data).toHaveProperty('error', 'Unauthorized')
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('should validate input data', async () => {
|
||||
const req = createMockRequest('POST', {
|
||||
workflowId: 'workflow-id',
|
||||
it('returns 404 for non-existent workflow', async () => {
|
||||
mockDbChain([[]])
|
||||
|
||||
const res = await GET(createRequest('http://test/api/schedules?workflowId=wf-1'))
|
||||
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
it('denies access for unauthorized user', async () => {
|
||||
mockDbChain([[{ userId: 'other-user', workspaceId: null }]])
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
const data = await response.json()
|
||||
expect(data).toHaveProperty('error', 'Invalid request data')
|
||||
const res = await GET(createRequest('http://test/api/schedules?workflowId=wf-1'))
|
||||
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it('allows workspace members to view', async () => {
|
||||
mockDbChain([
|
||||
[{ userId: 'other-user', workspaceId: 'ws-1' }],
|
||||
[{ id: 'sched-1', status: 'active', failedCount: 0 }],
|
||||
])
|
||||
|
||||
const res = await GET(createRequest('http://test/api/schedules?workflowId=wf-1'))
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
|
||||
it('indicates disabled schedule with failures', async () => {
|
||||
mockDbChain([
|
||||
[{ userId: 'user-1', workspaceId: null }],
|
||||
[{ id: 'sched-1', status: 'disabled', failedCount: 10 }],
|
||||
])
|
||||
|
||||
const res = await GET(createRequest('http://test/api/schedules?workflowId=wf-1'))
|
||||
const data = await res.json()
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(data.isDisabled).toBe(true)
|
||||
expect(data.hasFailures).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<typeof getScheduleTimeValues>,
|
||||
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<string, { count: number; resetAt: number }>()
|
||||
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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,12 @@ import { and, desc, eq } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { deployWorkflow } from '@/lib/workflows/persistence/utils'
|
||||
import { deployWorkflow, loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||
import {
|
||||
createSchedulesForDeploy,
|
||||
deleteSchedulesForWorkflow,
|
||||
validateWorkflowSchedules,
|
||||
} from '@/lib/workflows/schedules'
|
||||
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
|
||||
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
|
||||
|
||||
@@ -98,13 +103,25 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
return createErrorResponse(error.message, error.status)
|
||||
}
|
||||
|
||||
// Attribution: this route is UI-only; require session user as actor
|
||||
const actorUserId: string | null = session?.user?.id ?? null
|
||||
if (!actorUserId) {
|
||||
logger.warn(`[${requestId}] Unable to resolve actor user for workflow deployment: ${id}`)
|
||||
return createErrorResponse('Unable to determine deploying user', 400)
|
||||
}
|
||||
|
||||
const normalizedData = await loadWorkflowFromNormalizedTables(id)
|
||||
if (!normalizedData) {
|
||||
return createErrorResponse('Failed to load workflow state', 500)
|
||||
}
|
||||
|
||||
const scheduleValidation = validateWorkflowSchedules(normalizedData.blocks)
|
||||
if (!scheduleValidation.isValid) {
|
||||
logger.warn(
|
||||
`[${requestId}] Schedule validation failed for workflow ${id}: ${scheduleValidation.error}`
|
||||
)
|
||||
return createErrorResponse(`Invalid schedule configuration: ${scheduleValidation.error}`, 400)
|
||||
}
|
||||
|
||||
const deployResult = await deployWorkflow({
|
||||
workflowId: id,
|
||||
deployedBy: actorUserId,
|
||||
@@ -117,6 +134,23 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
|
||||
const deployedAt = deployResult.deployedAt!
|
||||
|
||||
let scheduleInfo: { scheduleId?: string; cronExpression?: string; nextRunAt?: Date } = {}
|
||||
const scheduleResult = await createSchedulesForDeploy(id, normalizedData.blocks, db)
|
||||
if (!scheduleResult.success) {
|
||||
logger.error(
|
||||
`[${requestId}] Failed to create schedule for workflow ${id}: ${scheduleResult.error}`
|
||||
)
|
||||
} else if (scheduleResult.scheduleId) {
|
||||
scheduleInfo = {
|
||||
scheduleId: scheduleResult.scheduleId,
|
||||
cronExpression: scheduleResult.cronExpression,
|
||||
nextRunAt: scheduleResult.nextRunAt,
|
||||
}
|
||||
logger.info(
|
||||
`[${requestId}] Schedule created for workflow ${id}: ${scheduleResult.scheduleId}`
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Workflow deployed successfully: ${id}`)
|
||||
|
||||
const responseApiKeyInfo = workflowData!.workspaceId
|
||||
@@ -127,6 +161,13 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
apiKey: responseApiKeyInfo,
|
||||
isDeployed: true,
|
||||
deployedAt,
|
||||
schedule: scheduleInfo.scheduleId
|
||||
? {
|
||||
id: scheduleInfo.scheduleId,
|
||||
cronExpression: scheduleInfo.cronExpression,
|
||||
nextRunAt: scheduleInfo.nextRunAt,
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Error deploying workflow: ${id}`, {
|
||||
@@ -156,6 +197,8 @@ export async function DELETE(
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await deleteSchedulesForWorkflow(id, tx)
|
||||
|
||||
await tx
|
||||
.update(workflowDeploymentVersion)
|
||||
.set({ isActive: false })
|
||||
@@ -169,7 +212,6 @@ export async function DELETE(
|
||||
|
||||
logger.info(`[${requestId}] Workflow undeployed successfully: ${id}`)
|
||||
|
||||
// Track workflow undeployment
|
||||
try {
|
||||
const { trackPlatformEvent } = await import('@/lib/core/telemetry')
|
||||
trackPlatformEvent('platform.workflow.undeployed', {
|
||||
|
||||
@@ -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<typeof getScheduleTimeValues>[0]
|
||||
|
||||
async function syncWorkflowSchedules(
|
||||
workflowId: string,
|
||||
blocks: Record<string, any>
|
||||
): Promise<void> {
|
||||
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<string>(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<void> {
|
||||
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<T> {
|
||||
resourceName: string
|
||||
subBlockId: string
|
||||
@@ -573,27 +441,3 @@ async function syncBlockResources<T>(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -464,6 +464,8 @@ export function DeployModal({
|
||||
setDeploymentInfo((prev) => (prev ? { ...prev, needsRedeployment: false } : prev))
|
||||
} catch (error: unknown) {
|
||||
logger.error('Error redeploying workflow:', { error })
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to redeploy workflow'
|
||||
setApiDeployError(errorMessage)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
|
||||
@@ -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,15 +24,40 @@ 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) {
|
||||
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,
|
||||
})
|
||||
return { success: false, shouldOpenModal: false }
|
||||
}
|
||||
return { success: true, shouldOpenModal: true }
|
||||
}
|
||||
|
||||
setIsDeploying(true)
|
||||
try {
|
||||
const response = await fetch(`/api/workflows/${workflowId}/deploy`, {
|
||||
@@ -47,27 +76,42 @@ export function useDeployment({
|
||||
const deployedAtTime = responseData.deployedAt
|
||||
? new Date(responseData.deployedAt)
|
||||
: undefined
|
||||
setDeploymentStatus(
|
||||
workflowId,
|
||||
isDeployedStatus,
|
||||
deployedAtTime,
|
||||
responseData.apiKey || ''
|
||||
)
|
||||
setDeploymentStatus(workflowId, isDeployedStatus, deployedAtTime, responseData.apiKey || '')
|
||||
await refetchDeployedState()
|
||||
return { success: true, shouldOpenModal: true }
|
||||
}
|
||||
return { success: false, 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)
|
||||
return { success: false, shouldOpenModal: true }
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// If already deployed, just signal to open modal
|
||||
return { success: true, shouldOpenModal: true }
|
||||
}, [workflowId, isDeployed, refetchDeployedState, setDeploymentStatus])
|
||||
}, [
|
||||
workflowId,
|
||||
isDeployed,
|
||||
blocks,
|
||||
edges,
|
||||
loops,
|
||||
parallels,
|
||||
refetchDeployedState,
|
||||
setDeploymentStatus,
|
||||
addNotification,
|
||||
])
|
||||
|
||||
return {
|
||||
isDeploying,
|
||||
|
||||
@@ -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<string, BlockState>
|
||||
edges: Edge[]
|
||||
loops: Record<string, Loop>
|
||||
parallels: Record<string, Parallel>
|
||||
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 }
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { parseCronToHumanReadable } from '@/lib/workflows/schedules/utils'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
|
||||
const logger = createLogger('ScheduleStatus')
|
||||
|
||||
interface ScheduleInfoProps {
|
||||
blockId: string
|
||||
isPreview?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule status display component.
|
||||
* Shows the current schedule status, next run time, and last run time.
|
||||
* Schedule creation/deletion is handled during workflow deploy/undeploy.
|
||||
*/
|
||||
export function ScheduleInfo({ blockId, isPreview = false }: ScheduleInfoProps) {
|
||||
const params = useParams()
|
||||
const workflowId = params.workflowId as string
|
||||
const [scheduleStatus, setScheduleStatus] = useState<'active' | 'disabled' | null>(null)
|
||||
const [nextRunAt, setNextRunAt] = useState<Date | null>(null)
|
||||
const [lastRanAt, setLastRanAt] = useState<Date | null>(null)
|
||||
const [failedCount, setFailedCount] = useState<number>(0)
|
||||
const [isLoadingStatus, setIsLoadingStatus] = useState(true)
|
||||
const [savedCronExpression, setSavedCronExpression] = useState<string | null>(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 (
|
||||
<div className='mt-2'>
|
||||
{isLoadingStatus ? (
|
||||
<div className='flex items-center gap-2 text-muted-foreground text-sm'>
|
||||
<div className='h-4 w-4 animate-spin rounded-full border-[1.5px] border-current border-t-transparent' />
|
||||
Loading schedule status...
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-1'>
|
||||
{/* Failure badge with redeploy action */}
|
||||
{failedCount >= 10 && scheduleStatus === 'disabled' && (
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleRedeploy}
|
||||
disabled={isRedeploying}
|
||||
className='flex w-full cursor-pointer items-center gap-2 rounded-md bg-destructive/10 px-3 py-2 text-left text-destructive text-sm transition-colors hover:bg-destructive/20 disabled:cursor-not-allowed disabled:opacity-50'
|
||||
>
|
||||
{isRedeploying ? (
|
||||
<div className='h-4 w-4 animate-spin rounded-full border-[1.5px] border-current border-t-transparent' />
|
||||
) : (
|
||||
<AlertTriangle className='h-4 w-4 flex-shrink-0' />
|
||||
)}
|
||||
<span>
|
||||
{isRedeploying
|
||||
? 'Redeploying...'
|
||||
: `Schedule disabled after ${failedCount} failures - Click to redeploy`}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Show warning for failed runs under threshold */}
|
||||
{failedCount > 0 && failedCount < 10 && (
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='text-destructive text-sm'>
|
||||
⚠️ {failedCount} failed run{failedCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cron expression human-readable description */}
|
||||
{savedCronExpression && (
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
Runs{' '}
|
||||
{parseCronToHumanReadable(
|
||||
savedCronExpression,
|
||||
scheduleTimezone || 'UTC'
|
||||
).toLowerCase()}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Next run time */}
|
||||
{nextRunAt && (
|
||||
<p className='text-sm'>
|
||||
<span className='font-medium'>Next run:</span>{' '}
|
||||
{nextRunAt.toLocaleString('en-US', {
|
||||
timeZone: scheduleTimezone || 'UTC',
|
||||
year: 'numeric',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
})}{' '}
|
||||
{scheduleTimezone || 'UTC'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Last ran time */}
|
||||
{lastRanAt && (
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
<span className='font-medium'>Last ran:</span>{' '}
|
||||
{lastRanAt.toLocaleString('en-US', {
|
||||
timeZone: scheduleTimezone || 'UTC',
|
||||
year: 'numeric',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
})}{' '}
|
||||
{scheduleTimezone || 'UTC'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<SaveStatus>('idle')
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(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<Date | null>(null)
|
||||
const [lastRanAt, setLastRanAt] = useState<Date | null>(null)
|
||||
const [failedCount, setFailedCount] = useState<number>(0)
|
||||
const [isLoadingStatus, setIsLoadingStatus] = useState(false)
|
||||
const [savedCronExpression, setSavedCronExpression] = useState<string | null>(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<string, any> = {}
|
||||
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<Record<string, any>>({})
|
||||
const validationTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (saveStatus !== 'error') {
|
||||
previousValuesRef.current = subscribedSubBlockValues
|
||||
return
|
||||
}
|
||||
|
||||
const hasChanges = Object.keys(subscribedSubBlockValues).some(
|
||||
(key) =>
|
||||
previousValuesRef.current[key] !== (subscribedSubBlockValues as Record<string, any>)[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 (
|
||||
<div className='mt-2'>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
variant='default'
|
||||
onClick={handleSave}
|
||||
disabled={disabled || isPreview || isSaving || saveStatus === 'saving' || isLoadingStatus}
|
||||
className={cn(
|
||||
'h-9 flex-1 rounded-[8px] transition-all duration-200',
|
||||
saveStatus === 'saved' && 'bg-green-600 hover:bg-green-700',
|
||||
saveStatus === 'error' && 'bg-red-600 hover:bg-red-700'
|
||||
)}
|
||||
>
|
||||
{saveStatus === 'saving' && (
|
||||
<>
|
||||
<div className='mr-2 h-4 w-4 animate-spin rounded-full border-[1.5px] border-current border-t-transparent' />
|
||||
Saving...
|
||||
</>
|
||||
)}
|
||||
{saveStatus === 'saved' && 'Saved'}
|
||||
{saveStatus === 'idle' && (scheduleId ? 'Update Schedule' : 'Save Schedule')}
|
||||
{saveStatus === 'error' && 'Error'}
|
||||
</Button>
|
||||
|
||||
{scheduleId && (
|
||||
<Button
|
||||
variant='default'
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
disabled={disabled || isPreview || deleteStatus === 'deleting' || isSaving}
|
||||
className='h-9 rounded-[8px] px-3'
|
||||
>
|
||||
{deleteStatus === 'deleting' ? (
|
||||
<div className='h-4 w-4 animate-spin rounded-full border-[1.5px] border-current border-t-transparent' />
|
||||
) : (
|
||||
<Trash className='h-[14px] w-[14px]' />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{errorMessage && (
|
||||
<Alert variant='destructive' className='mt-2'>
|
||||
<AlertDescription>{errorMessage}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{scheduleId && (scheduleStatus || isLoadingStatus || nextRunAt) && (
|
||||
<div className='mt-2 space-y-1'>
|
||||
{isLoadingStatus ? (
|
||||
<div className='flex items-center gap-2 text-muted-foreground text-sm'>
|
||||
<div className='h-4 w-4 animate-spin rounded-full border-[1.5px] border-current border-t-transparent' />
|
||||
Loading schedule status...
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{failedCount > 0 && (
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='text-destructive text-sm'>
|
||||
⚠️ {failedCount} failed run{failedCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{savedCronExpression && (
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
Runs{' '}
|
||||
{parseCronToHumanReadable(
|
||||
savedCronExpression,
|
||||
scheduleTimezone || 'UTC'
|
||||
).toLowerCase()}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{nextRunAt && (
|
||||
<p className='text-sm'>
|
||||
<span className='font-medium'>Next run:</span>{' '}
|
||||
{nextRunAt.toLocaleString('en-US', {
|
||||
timeZone: scheduleTimezone || 'UTC',
|
||||
year: 'numeric',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
})}{' '}
|
||||
{scheduleTimezone || 'UTC'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{lastRanAt && (
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
<span className='font-medium'>Last ran:</span>{' '}
|
||||
{lastRanAt.toLocaleString('en-US', {
|
||||
timeZone: scheduleTimezone || 'UTC',
|
||||
year: 'numeric',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
})}{' '}
|
||||
{scheduleTimezone || 'UTC'}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Delete Schedule</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||
Are you sure you want to delete this schedule configuration? This will stop the
|
||||
workflow from running automatically.{' '}
|
||||
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
|
||||
</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant='active' onClick={() => setShowDeleteDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='primary'
|
||||
onClick={handleDeleteConfirm}
|
||||
className='!bg-[var(--text-error)] !text-white hover:!bg-[var(--text-error)]/90'
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
MessagesInput,
|
||||
ProjectSelectorInput,
|
||||
ResponseFormat,
|
||||
ScheduleSave,
|
||||
ScheduleInfo,
|
||||
ShortInput,
|
||||
SlackSelectorInput,
|
||||
SliderInput,
|
||||
@@ -592,8 +592,8 @@ function SubBlockComponent({
|
||||
/>
|
||||
)
|
||||
|
||||
case 'schedule-save':
|
||||
return <ScheduleSave blockId={blockId} isPreview={isPreview} disabled={disabled} />
|
||||
case 'schedule-info':
|
||||
return <ScheduleInfo blockId={blockId} isPreview={isPreview} />
|
||||
|
||||
case 'oauth-input':
|
||||
return (
|
||||
|
||||
@@ -15,17 +15,15 @@ export interface UseScheduleInfoReturn {
|
||||
isLoading: boolean
|
||||
/** Function to reactivate a disabled schedule */
|
||||
reactivateSchedule: (scheduleId: string) => Promise<void>
|
||||
/** Function to disable an active schedule */
|
||||
disableSchedule: (scheduleId: string) => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,5 +24,6 @@ export interface ScheduleInfo {
|
||||
timezone: string
|
||||
status?: string
|
||||
isDisabled?: boolean
|
||||
failedCount?: number
|
||||
id?: string
|
||||
}
|
||||
|
||||
@@ -564,7 +564,6 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
scheduleInfo,
|
||||
isLoading: isLoadingScheduleInfo,
|
||||
reactivateSchedule,
|
||||
disableSchedule,
|
||||
} = useScheduleInfo(id, type, currentWorkflowId)
|
||||
|
||||
const { childWorkflowId, childIsDeployed, childNeedsRedeploy, refetchDeployment } =
|
||||
|
||||
@@ -565,6 +565,7 @@ export async function executeScheduleJob(payload: ScheduleExecutionPayload) {
|
||||
updatedAt: now,
|
||||
nextRunAt,
|
||||
failedCount: 0,
|
||||
lastQueuedAt: null,
|
||||
},
|
||||
requestId,
|
||||
`Error updating schedule ${payload.scheduleId} after success`,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<SaveConfigResult>
|
||||
deleteConfig: () => Promise<boolean>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<SaveConfigResult> => {
|
||||
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<boolean> => {
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
786
apps/sim/lib/workflows/schedules/deploy.test.ts
Normal file
786
apps/sim/lib/workflows/schedules/deploy.test.ts
Normal file
@@ -0,0 +1,786 @@
|
||||
/**
|
||||
* Tests for schedule deploy utilities
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
mockInsert,
|
||||
mockDelete,
|
||||
mockOnConflictDoUpdate,
|
||||
mockValues,
|
||||
mockWhere,
|
||||
mockGenerateCronExpression,
|
||||
mockCalculateNextRunTime,
|
||||
mockValidateCronExpression,
|
||||
mockGetScheduleTimeValues,
|
||||
mockRandomUUID,
|
||||
} = vi.hoisted(() => ({
|
||||
mockInsert: vi.fn(),
|
||||
mockDelete: vi.fn(),
|
||||
mockOnConflictDoUpdate: vi.fn(),
|
||||
mockValues: vi.fn(),
|
||||
mockWhere: vi.fn(),
|
||||
mockGenerateCronExpression: vi.fn(),
|
||||
mockCalculateNextRunTime: vi.fn(),
|
||||
mockValidateCronExpression: vi.fn(),
|
||||
mockGetScheduleTimeValues: vi.fn(),
|
||||
mockRandomUUID: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@sim/db', () => ({
|
||||
db: {},
|
||||
workflowSchedule: {
|
||||
workflowId: 'workflow_id',
|
||||
blockId: 'block_id',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('drizzle-orm', () => ({
|
||||
eq: vi.fn((...args) => ({ type: 'eq', args })),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/logs/console/logger', () => ({
|
||||
createLogger: vi.fn(() => ({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('./utils', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('./utils')>()
|
||||
return {
|
||||
...original,
|
||||
generateCronExpression: mockGenerateCronExpression,
|
||||
calculateNextRunTime: mockCalculateNextRunTime,
|
||||
validateCronExpression: mockValidateCronExpression,
|
||||
getScheduleTimeValues: mockGetScheduleTimeValues,
|
||||
}
|
||||
})
|
||||
|
||||
vi.stubGlobal('crypto', {
|
||||
randomUUID: mockRandomUUID,
|
||||
})
|
||||
|
||||
import { createSchedulesForDeploy, deleteSchedulesForWorkflow } from './deploy'
|
||||
import type { BlockState } from './utils'
|
||||
import { findScheduleBlocks, validateScheduleBlock, validateWorkflowSchedules } from './validation'
|
||||
|
||||
describe('Schedule Deploy Utilities', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
mockRandomUUID.mockReturnValue('test-uuid')
|
||||
mockGenerateCronExpression.mockReturnValue('0 9 * * *')
|
||||
mockCalculateNextRunTime.mockReturnValue(new Date('2025-04-15T09:00:00Z'))
|
||||
mockValidateCronExpression.mockReturnValue({ isValid: true, nextRun: new Date() })
|
||||
mockGetScheduleTimeValues.mockReturnValue({
|
||||
scheduleTime: '09:00',
|
||||
scheduleStartAt: '',
|
||||
timezone: 'UTC',
|
||||
minutesInterval: 15,
|
||||
hourlyMinute: 0,
|
||||
dailyTime: [9, 0],
|
||||
weeklyDay: 1,
|
||||
weeklyTime: [9, 0],
|
||||
monthlyDay: 1,
|
||||
monthlyTime: [9, 0],
|
||||
cronExpression: null,
|
||||
})
|
||||
|
||||
// Setup mock chain for insert
|
||||
mockOnConflictDoUpdate.mockResolvedValue({})
|
||||
mockValues.mockReturnValue({ onConflictDoUpdate: mockOnConflictDoUpdate })
|
||||
mockInsert.mockReturnValue({ values: mockValues })
|
||||
|
||||
// Setup mock chain for delete
|
||||
mockWhere.mockResolvedValue({})
|
||||
mockDelete.mockReturnValue({ where: mockWhere })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('findScheduleBlocks', () => {
|
||||
it('should find schedule blocks in a workflow', () => {
|
||||
const blocks: Record<string, BlockState> = {
|
||||
'block-1': { id: 'block-1', type: 'schedule', subBlocks: {} } as BlockState,
|
||||
'block-2': { id: 'block-2', type: 'agent', subBlocks: {} } as BlockState,
|
||||
'block-3': { id: 'block-3', type: 'schedule', subBlocks: {} } as BlockState,
|
||||
}
|
||||
|
||||
const result = findScheduleBlocks(blocks)
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result.map((b) => b.id)).toEqual(['block-1', 'block-3'])
|
||||
})
|
||||
|
||||
it('should return empty array when no schedule blocks exist', () => {
|
||||
const blocks: Record<string, BlockState> = {
|
||||
'block-1': { id: 'block-1', type: 'agent', subBlocks: {} } as BlockState,
|
||||
'block-2': { id: 'block-2', type: 'starter', subBlocks: {} } as BlockState,
|
||||
}
|
||||
|
||||
const result = findScheduleBlocks(blocks)
|
||||
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should handle empty blocks object', () => {
|
||||
const result = findScheduleBlocks({})
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateScheduleBlock', () => {
|
||||
describe('schedule type validation', () => {
|
||||
it('should fail when schedule type is missing', () => {
|
||||
const block: BlockState = {
|
||||
id: 'block-1',
|
||||
type: 'schedule',
|
||||
subBlocks: {},
|
||||
} as BlockState
|
||||
|
||||
const result = validateScheduleBlock(block)
|
||||
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toBe('Schedule type is required')
|
||||
})
|
||||
|
||||
it('should fail with empty schedule type', () => {
|
||||
const block: BlockState = {
|
||||
id: 'block-1',
|
||||
type: 'schedule',
|
||||
subBlocks: {
|
||||
scheduleType: { value: '' },
|
||||
},
|
||||
} as BlockState
|
||||
|
||||
const result = validateScheduleBlock(block)
|
||||
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toBe('Schedule type is required')
|
||||
})
|
||||
})
|
||||
|
||||
describe('minutes schedule validation', () => {
|
||||
it('should validate valid minutes interval', () => {
|
||||
const block: BlockState = {
|
||||
id: 'block-1',
|
||||
type: 'schedule',
|
||||
subBlocks: {
|
||||
scheduleType: { value: 'minutes' },
|
||||
minutesInterval: { value: '15' },
|
||||
timezone: { value: 'UTC' },
|
||||
},
|
||||
} as BlockState
|
||||
|
||||
const result = validateScheduleBlock(block)
|
||||
|
||||
expect(result.isValid).toBe(true)
|
||||
expect(result.cronExpression).toBeDefined()
|
||||
})
|
||||
|
||||
it('should fail with empty minutes interval', () => {
|
||||
const block: BlockState = {
|
||||
id: 'block-1',
|
||||
type: 'schedule',
|
||||
subBlocks: {
|
||||
scheduleType: { value: 'minutes' },
|
||||
minutesInterval: { value: '' },
|
||||
},
|
||||
} as BlockState
|
||||
|
||||
const result = validateScheduleBlock(block)
|
||||
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toBe('Minutes interval is required for minute-based schedules')
|
||||
})
|
||||
|
||||
it('should fail with invalid minutes interval (out of range)', () => {
|
||||
const block: BlockState = {
|
||||
id: 'block-1',
|
||||
type: 'schedule',
|
||||
subBlocks: {
|
||||
scheduleType: { value: 'minutes' },
|
||||
minutesInterval: { value: '0' },
|
||||
},
|
||||
} as BlockState
|
||||
|
||||
const result = validateScheduleBlock(block)
|
||||
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toBe('Minutes interval is required for minute-based schedules')
|
||||
})
|
||||
|
||||
it('should fail with minutes interval > 1440', () => {
|
||||
const block: BlockState = {
|
||||
id: 'block-1',
|
||||
type: 'schedule',
|
||||
subBlocks: {
|
||||
scheduleType: { value: 'minutes' },
|
||||
minutesInterval: { value: '1441' },
|
||||
},
|
||||
} as BlockState
|
||||
|
||||
const result = validateScheduleBlock(block)
|
||||
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toBe('Minutes interval is required for minute-based schedules')
|
||||
})
|
||||
})
|
||||
|
||||
describe('hourly schedule validation', () => {
|
||||
it('should validate valid hourly minute', () => {
|
||||
const block: BlockState = {
|
||||
id: 'block-1',
|
||||
type: 'schedule',
|
||||
subBlocks: {
|
||||
scheduleType: { value: 'hourly' },
|
||||
hourlyMinute: { value: '30' },
|
||||
timezone: { value: 'UTC' },
|
||||
},
|
||||
} as BlockState
|
||||
|
||||
const result = validateScheduleBlock(block)
|
||||
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
|
||||
it('should validate hourly minute of 0', () => {
|
||||
const block: BlockState = {
|
||||
id: 'block-1',
|
||||
type: 'schedule',
|
||||
subBlocks: {
|
||||
scheduleType: { value: 'hourly' },
|
||||
hourlyMinute: { value: '0' },
|
||||
timezone: { value: 'UTC' },
|
||||
},
|
||||
} as BlockState
|
||||
|
||||
const result = validateScheduleBlock(block)
|
||||
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
|
||||
it('should fail with empty hourly minute', () => {
|
||||
const block: BlockState = {
|
||||
id: 'block-1',
|
||||
type: 'schedule',
|
||||
subBlocks: {
|
||||
scheduleType: { value: 'hourly' },
|
||||
hourlyMinute: { value: '' },
|
||||
},
|
||||
} as BlockState
|
||||
|
||||
const result = validateScheduleBlock(block)
|
||||
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toBe('Minute value is required for hourly schedules')
|
||||
})
|
||||
|
||||
it('should fail with hourly minute > 59', () => {
|
||||
const block: BlockState = {
|
||||
id: 'block-1',
|
||||
type: 'schedule',
|
||||
subBlocks: {
|
||||
scheduleType: { value: 'hourly' },
|
||||
hourlyMinute: { value: '60' },
|
||||
},
|
||||
} as BlockState
|
||||
|
||||
const result = validateScheduleBlock(block)
|
||||
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toBe('Minute value is required for hourly schedules')
|
||||
})
|
||||
})
|
||||
|
||||
describe('daily schedule validation', () => {
|
||||
it('should validate valid daily time', () => {
|
||||
const block: BlockState = {
|
||||
id: 'block-1',
|
||||
type: 'schedule',
|
||||
subBlocks: {
|
||||
scheduleType: { value: 'daily' },
|
||||
dailyTime: { value: '09:30' },
|
||||
timezone: { value: 'America/New_York' },
|
||||
},
|
||||
} as BlockState
|
||||
|
||||
const result = validateScheduleBlock(block)
|
||||
|
||||
expect(result.isValid).toBe(true)
|
||||
expect(result.timezone).toBe('America/New_York')
|
||||
})
|
||||
|
||||
it('should fail with empty daily time', () => {
|
||||
const block: BlockState = {
|
||||
id: 'block-1',
|
||||
type: 'schedule',
|
||||
subBlocks: {
|
||||
scheduleType: { value: 'daily' },
|
||||
dailyTime: { value: '' },
|
||||
},
|
||||
} as BlockState
|
||||
|
||||
const result = validateScheduleBlock(block)
|
||||
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toBe('Time is required for daily schedules')
|
||||
})
|
||||
|
||||
it('should fail with invalid time format (no colon)', () => {
|
||||
const block: BlockState = {
|
||||
id: 'block-1',
|
||||
type: 'schedule',
|
||||
subBlocks: {
|
||||
scheduleType: { value: 'daily' },
|
||||
dailyTime: { value: '0930' },
|
||||
},
|
||||
} as BlockState
|
||||
|
||||
const result = validateScheduleBlock(block)
|
||||
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toBe('Time is required for daily schedules')
|
||||
})
|
||||
})
|
||||
|
||||
describe('weekly schedule validation', () => {
|
||||
it('should validate valid weekly configuration', () => {
|
||||
const block: BlockState = {
|
||||
id: 'block-1',
|
||||
type: 'schedule',
|
||||
subBlocks: {
|
||||
scheduleType: { value: 'weekly' },
|
||||
weeklyDay: { value: 'MON' },
|
||||
weeklyDayTime: { value: '10:00' },
|
||||
timezone: { value: 'UTC' },
|
||||
},
|
||||
} as BlockState
|
||||
|
||||
const result = validateScheduleBlock(block)
|
||||
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
|
||||
it('should fail with missing day', () => {
|
||||
const block: BlockState = {
|
||||
id: 'block-1',
|
||||
type: 'schedule',
|
||||
subBlocks: {
|
||||
scheduleType: { value: 'weekly' },
|
||||
weeklyDay: { value: '' },
|
||||
weeklyDayTime: { value: '10:00' },
|
||||
},
|
||||
} as BlockState
|
||||
|
||||
const result = validateScheduleBlock(block)
|
||||
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toBe('Day and time are required for weekly schedules')
|
||||
})
|
||||
|
||||
it('should fail with missing time', () => {
|
||||
const block: BlockState = {
|
||||
id: 'block-1',
|
||||
type: 'schedule',
|
||||
subBlocks: {
|
||||
scheduleType: { value: 'weekly' },
|
||||
weeklyDay: { value: 'MON' },
|
||||
weeklyDayTime: { value: '' },
|
||||
},
|
||||
} as BlockState
|
||||
|
||||
const result = validateScheduleBlock(block)
|
||||
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toBe('Day and time are required for weekly schedules')
|
||||
})
|
||||
})
|
||||
|
||||
describe('monthly schedule validation', () => {
|
||||
it('should validate valid monthly configuration', () => {
|
||||
const block: BlockState = {
|
||||
id: 'block-1',
|
||||
type: 'schedule',
|
||||
subBlocks: {
|
||||
scheduleType: { value: 'monthly' },
|
||||
monthlyDay: { value: '15' },
|
||||
monthlyTime: { value: '14:30' },
|
||||
timezone: { value: 'UTC' },
|
||||
},
|
||||
} as BlockState
|
||||
|
||||
const result = validateScheduleBlock(block)
|
||||
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
|
||||
it('should fail with day out of range (0)', () => {
|
||||
const block: BlockState = {
|
||||
id: 'block-1',
|
||||
type: 'schedule',
|
||||
subBlocks: {
|
||||
scheduleType: { value: 'monthly' },
|
||||
monthlyDay: { value: '0' },
|
||||
monthlyTime: { value: '14:30' },
|
||||
},
|
||||
} as BlockState
|
||||
|
||||
const result = validateScheduleBlock(block)
|
||||
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toBe('Day and time are required for monthly schedules')
|
||||
})
|
||||
|
||||
it('should fail with day out of range (32)', () => {
|
||||
const block: BlockState = {
|
||||
id: 'block-1',
|
||||
type: 'schedule',
|
||||
subBlocks: {
|
||||
scheduleType: { value: 'monthly' },
|
||||
monthlyDay: { value: '32' },
|
||||
monthlyTime: { value: '14:30' },
|
||||
},
|
||||
} as BlockState
|
||||
|
||||
const result = validateScheduleBlock(block)
|
||||
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toBe('Day and time are required for monthly schedules')
|
||||
})
|
||||
|
||||
it('should fail with missing time', () => {
|
||||
const block: BlockState = {
|
||||
id: 'block-1',
|
||||
type: 'schedule',
|
||||
subBlocks: {
|
||||
scheduleType: { value: 'monthly' },
|
||||
monthlyDay: { value: '15' },
|
||||
monthlyTime: { value: '' },
|
||||
},
|
||||
} as BlockState
|
||||
|
||||
const result = validateScheduleBlock(block)
|
||||
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toBe('Day and time are required for monthly schedules')
|
||||
})
|
||||
})
|
||||
|
||||
describe('custom cron schedule validation', () => {
|
||||
it('should validate valid custom cron expression', () => {
|
||||
mockGetScheduleTimeValues.mockReturnValue({
|
||||
scheduleTime: '',
|
||||
scheduleStartAt: '',
|
||||
timezone: 'UTC',
|
||||
minutesInterval: 15,
|
||||
hourlyMinute: 0,
|
||||
dailyTime: [9, 0],
|
||||
weeklyDay: 1,
|
||||
weeklyTime: [9, 0],
|
||||
monthlyDay: 1,
|
||||
monthlyTime: [9, 0],
|
||||
cronExpression: '*/5 * * * *',
|
||||
})
|
||||
|
||||
const block: BlockState = {
|
||||
id: 'block-1',
|
||||
type: 'schedule',
|
||||
subBlocks: {
|
||||
scheduleType: { value: 'custom' },
|
||||
cronExpression: { value: '*/5 * * * *' },
|
||||
timezone: { value: 'UTC' },
|
||||
},
|
||||
} as BlockState
|
||||
|
||||
const result = validateScheduleBlock(block)
|
||||
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
|
||||
it('should fail with empty cron expression', () => {
|
||||
const block: BlockState = {
|
||||
id: 'block-1',
|
||||
type: 'schedule',
|
||||
subBlocks: {
|
||||
scheduleType: { value: 'custom' },
|
||||
cronExpression: { value: '' },
|
||||
},
|
||||
} as BlockState
|
||||
|
||||
const result = validateScheduleBlock(block)
|
||||
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toBe('Cron expression is required for custom schedules')
|
||||
})
|
||||
})
|
||||
|
||||
describe('invalid cron expression handling', () => {
|
||||
it('should fail when generated cron is invalid', () => {
|
||||
mockValidateCronExpression.mockReturnValue({
|
||||
isValid: false,
|
||||
error: 'Invalid minute value',
|
||||
})
|
||||
|
||||
const block: BlockState = {
|
||||
id: 'block-1',
|
||||
type: 'schedule',
|
||||
subBlocks: {
|
||||
scheduleType: { value: 'daily' },
|
||||
dailyTime: { value: '09:00' },
|
||||
timezone: { value: 'UTC' },
|
||||
},
|
||||
} as BlockState
|
||||
|
||||
const result = validateScheduleBlock(block)
|
||||
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('Invalid cron expression')
|
||||
})
|
||||
|
||||
it('should handle exceptions during cron generation', () => {
|
||||
mockGenerateCronExpression.mockImplementation(() => {
|
||||
throw new Error('Failed to parse schedule type')
|
||||
})
|
||||
|
||||
const block: BlockState = {
|
||||
id: 'block-1',
|
||||
type: 'schedule',
|
||||
subBlocks: {
|
||||
scheduleType: { value: 'daily' },
|
||||
dailyTime: { value: '09:00' },
|
||||
timezone: { value: 'UTC' },
|
||||
},
|
||||
} as BlockState
|
||||
|
||||
const result = validateScheduleBlock(block)
|
||||
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toBe('Failed to parse schedule type')
|
||||
})
|
||||
})
|
||||
|
||||
describe('timezone handling', () => {
|
||||
it('should use UTC as default timezone', () => {
|
||||
const block: BlockState = {
|
||||
id: 'block-1',
|
||||
type: 'schedule',
|
||||
subBlocks: {
|
||||
scheduleType: { value: 'daily' },
|
||||
dailyTime: { value: '09:00' },
|
||||
},
|
||||
} as BlockState
|
||||
|
||||
const result = validateScheduleBlock(block)
|
||||
|
||||
expect(result.isValid).toBe(true)
|
||||
expect(result.timezone).toBe('UTC')
|
||||
})
|
||||
|
||||
it('should use specified timezone', () => {
|
||||
const block: BlockState = {
|
||||
id: 'block-1',
|
||||
type: 'schedule',
|
||||
subBlocks: {
|
||||
scheduleType: { value: 'daily' },
|
||||
dailyTime: { value: '09:00' },
|
||||
timezone: { value: 'Asia/Tokyo' },
|
||||
},
|
||||
} as BlockState
|
||||
|
||||
const result = validateScheduleBlock(block)
|
||||
|
||||
expect(result.isValid).toBe(true)
|
||||
expect(result.timezone).toBe('Asia/Tokyo')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateWorkflowSchedules', () => {
|
||||
it('should return valid for workflows without schedule blocks', () => {
|
||||
const blocks: Record<string, BlockState> = {
|
||||
'block-1': { id: 'block-1', type: 'agent', subBlocks: {} } as BlockState,
|
||||
}
|
||||
|
||||
const result = validateWorkflowSchedules(blocks)
|
||||
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
|
||||
it('should validate all schedule blocks', () => {
|
||||
const blocks: Record<string, BlockState> = {
|
||||
'block-1': {
|
||||
id: 'block-1',
|
||||
type: 'schedule',
|
||||
subBlocks: {
|
||||
scheduleType: { value: 'daily' },
|
||||
dailyTime: { value: '09:00' },
|
||||
timezone: { value: 'UTC' },
|
||||
},
|
||||
} as BlockState,
|
||||
'block-2': {
|
||||
id: 'block-2',
|
||||
type: 'schedule',
|
||||
subBlocks: {
|
||||
scheduleType: { value: 'hourly' },
|
||||
hourlyMinute: { value: '30' },
|
||||
timezone: { value: 'UTC' },
|
||||
},
|
||||
} as BlockState,
|
||||
}
|
||||
|
||||
const result = validateWorkflowSchedules(blocks)
|
||||
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
|
||||
it('should return first validation error found', () => {
|
||||
const blocks: Record<string, BlockState> = {
|
||||
'block-1': {
|
||||
id: 'block-1',
|
||||
type: 'schedule',
|
||||
subBlocks: {
|
||||
scheduleType: { value: 'daily' },
|
||||
dailyTime: { value: '09:00' },
|
||||
timezone: { value: 'UTC' },
|
||||
},
|
||||
} as BlockState,
|
||||
'block-2': {
|
||||
id: 'block-2',
|
||||
type: 'schedule',
|
||||
subBlocks: {
|
||||
scheduleType: { value: 'daily' },
|
||||
dailyTime: { value: '' }, // Invalid - missing time
|
||||
},
|
||||
} as BlockState,
|
||||
}
|
||||
|
||||
const result = validateWorkflowSchedules(blocks)
|
||||
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toBe('Time is required for daily schedules')
|
||||
})
|
||||
})
|
||||
|
||||
describe('createSchedulesForDeploy', () => {
|
||||
it('should return success with no schedule blocks', async () => {
|
||||
const blocks: Record<string, BlockState> = {
|
||||
'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<string, BlockState> = {
|
||||
'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<string, BlockState> = {
|
||||
'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<string, BlockState> = {
|
||||
'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()
|
||||
})
|
||||
})
|
||||
})
|
||||
131
apps/sim/lib/workflows/schedules/deploy.ts
Normal file
131
apps/sim/lib/workflows/schedules/deploy.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { type db, workflowSchedule } from '@sim/db'
|
||||
import type * as schema from '@sim/db/schema'
|
||||
import type { ExtractTablesWithRelations } from 'drizzle-orm'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import type { PgTransaction } from 'drizzle-orm/pg-core'
|
||||
import type { PostgresJsQueryResultHKT } from 'drizzle-orm/postgres-js'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import type { BlockState } from '@/lib/workflows/schedules/utils'
|
||||
import { findScheduleBlocks, validateScheduleBlock } from '@/lib/workflows/schedules/validation'
|
||||
|
||||
const logger = createLogger('ScheduleDeployUtils')
|
||||
|
||||
/**
|
||||
* Type for database or transaction context
|
||||
* This allows the functions to work with either the db instance or a transaction
|
||||
*/
|
||||
type DbOrTx =
|
||||
| typeof db
|
||||
| PgTransaction<
|
||||
PostgresJsQueryResultHKT,
|
||||
typeof schema,
|
||||
ExtractTablesWithRelations<typeof schema>
|
||||
>
|
||||
|
||||
/**
|
||||
* Result of schedule creation during deploy
|
||||
*/
|
||||
export interface ScheduleDeployResult {
|
||||
success: boolean
|
||||
error?: string
|
||||
scheduleId?: string
|
||||
cronExpression?: string
|
||||
nextRunAt?: Date
|
||||
timezone?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update schedule records for a workflow during deployment
|
||||
* This should be called within a database transaction
|
||||
*/
|
||||
export async function createSchedulesForDeploy(
|
||||
workflowId: string,
|
||||
blocks: Record<string, BlockState>,
|
||||
tx: DbOrTx
|
||||
): Promise<ScheduleDeployResult> {
|
||||
const scheduleBlocks = findScheduleBlocks(blocks)
|
||||
|
||||
if (scheduleBlocks.length === 0) {
|
||||
logger.info(`No schedule blocks found in workflow ${workflowId}`)
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
let lastScheduleInfo: {
|
||||
scheduleId: string
|
||||
cronExpression?: string
|
||||
nextRunAt?: Date
|
||||
timezone?: string
|
||||
} | null = null
|
||||
|
||||
for (const block of scheduleBlocks) {
|
||||
const blockId = block.id as string
|
||||
|
||||
const validation = validateScheduleBlock(block)
|
||||
if (!validation.isValid) {
|
||||
return {
|
||||
success: false,
|
||||
error: validation.error,
|
||||
}
|
||||
}
|
||||
|
||||
const { cronExpression, nextRunAt, timezone } = validation
|
||||
|
||||
const scheduleId = crypto.randomUUID()
|
||||
const now = new Date()
|
||||
|
||||
const values = {
|
||||
id: scheduleId,
|
||||
workflowId,
|
||||
blockId,
|
||||
cronExpression: cronExpression!,
|
||||
triggerType: 'schedule',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
nextRunAt: nextRunAt!,
|
||||
timezone: timezone!,
|
||||
status: 'active',
|
||||
failedCount: 0,
|
||||
}
|
||||
|
||||
const setValues = {
|
||||
blockId,
|
||||
cronExpression: cronExpression!,
|
||||
updatedAt: now,
|
||||
nextRunAt: nextRunAt!,
|
||||
timezone: timezone!,
|
||||
status: 'active',
|
||||
failedCount: 0,
|
||||
}
|
||||
|
||||
await tx
|
||||
.insert(workflowSchedule)
|
||||
.values(values)
|
||||
.onConflictDoUpdate({
|
||||
target: [workflowSchedule.workflowId, workflowSchedule.blockId],
|
||||
set: setValues,
|
||||
})
|
||||
|
||||
logger.info(`Schedule created/updated for workflow ${workflowId}, block ${blockId}`, {
|
||||
scheduleId: values.id,
|
||||
cronExpression,
|
||||
nextRunAt: nextRunAt?.toISOString(),
|
||||
})
|
||||
|
||||
lastScheduleInfo = { scheduleId: values.id, cronExpression, nextRunAt, timezone }
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
...lastScheduleInfo,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all schedules for a workflow
|
||||
* This should be called within a database transaction during undeploy
|
||||
*/
|
||||
export async function deleteSchedulesForWorkflow(workflowId: string, tx: DbOrTx): Promise<void> {
|
||||
await tx.delete(workflowSchedule).where(eq(workflowSchedule.workflowId, workflowId))
|
||||
|
||||
logger.info(`Deleted all schedules for workflow ${workflowId}`)
|
||||
}
|
||||
23
apps/sim/lib/workflows/schedules/index.ts
Normal file
23
apps/sim/lib/workflows/schedules/index.ts
Normal file
@@ -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'
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
|
||||
184
apps/sim/lib/workflows/schedules/validation.ts
Normal file
184
apps/sim/lib/workflows/schedules/validation.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Client-safe schedule validation functions
|
||||
* These can be used in both client and server contexts
|
||||
*/
|
||||
import {
|
||||
type BlockState,
|
||||
calculateNextRunTime,
|
||||
generateCronExpression,
|
||||
getScheduleTimeValues,
|
||||
getSubBlockValue,
|
||||
validateCronExpression,
|
||||
} from '@/lib/workflows/schedules/utils'
|
||||
|
||||
/**
|
||||
* Result of schedule validation
|
||||
*/
|
||||
export interface ScheduleValidationResult {
|
||||
isValid: boolean
|
||||
error?: string
|
||||
scheduleType?: string
|
||||
cronExpression?: string
|
||||
nextRunAt?: Date
|
||||
timezone?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a time value is valid (not empty/null/undefined)
|
||||
*/
|
||||
function isValidTimeValue(value: string | null | undefined): boolean {
|
||||
return !!value && value.trim() !== '' && value.includes(':')
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a schedule type has valid configuration
|
||||
* Uses raw block values to avoid false positives from default parsing
|
||||
*/
|
||||
function hasValidScheduleConfig(scheduleType: string | undefined, block: BlockState): boolean {
|
||||
switch (scheduleType) {
|
||||
case 'minutes': {
|
||||
const rawValue = getSubBlockValue(block, 'minutesInterval')
|
||||
const numValue = Number(rawValue)
|
||||
return !!rawValue && !Number.isNaN(numValue) && numValue >= 1 && numValue <= 1440
|
||||
}
|
||||
case 'hourly': {
|
||||
const rawValue = getSubBlockValue(block, 'hourlyMinute')
|
||||
const numValue = Number(rawValue)
|
||||
return rawValue !== '' && !Number.isNaN(numValue) && numValue >= 0 && numValue <= 59
|
||||
}
|
||||
case 'daily': {
|
||||
const rawTime = getSubBlockValue(block, 'dailyTime')
|
||||
return isValidTimeValue(rawTime)
|
||||
}
|
||||
case 'weekly': {
|
||||
const rawDay = getSubBlockValue(block, 'weeklyDay')
|
||||
const rawTime = getSubBlockValue(block, 'weeklyDayTime')
|
||||
return !!rawDay && isValidTimeValue(rawTime)
|
||||
}
|
||||
case 'monthly': {
|
||||
const rawDay = getSubBlockValue(block, 'monthlyDay')
|
||||
const rawTime = getSubBlockValue(block, 'monthlyTime')
|
||||
const dayNum = Number(rawDay)
|
||||
return (
|
||||
!!rawDay &&
|
||||
!Number.isNaN(dayNum) &&
|
||||
dayNum >= 1 &&
|
||||
dayNum <= 31 &&
|
||||
isValidTimeValue(rawTime)
|
||||
)
|
||||
}
|
||||
case 'custom':
|
||||
return !!getSubBlockValue(block, 'cronExpression')
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable error message for missing schedule configuration
|
||||
*/
|
||||
function getMissingConfigError(scheduleType: string): string {
|
||||
switch (scheduleType) {
|
||||
case 'minutes':
|
||||
return 'Minutes interval is required for minute-based schedules'
|
||||
case 'hourly':
|
||||
return 'Minute value is required for hourly schedules'
|
||||
case 'daily':
|
||||
return 'Time is required for daily schedules'
|
||||
case 'weekly':
|
||||
return 'Day and time are required for weekly schedules'
|
||||
case 'monthly':
|
||||
return 'Day and time are required for monthly schedules'
|
||||
case 'custom':
|
||||
return 'Cron expression is required for custom schedules'
|
||||
default:
|
||||
return 'Schedule type is required'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find schedule blocks in a workflow's blocks
|
||||
*/
|
||||
export function findScheduleBlocks(blocks: Record<string, BlockState>): 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<string, BlockState>
|
||||
): ScheduleValidationResult {
|
||||
const scheduleBlocks = findScheduleBlocks(blocks)
|
||||
|
||||
for (const block of scheduleBlocks) {
|
||||
const result = validateScheduleBlock(block)
|
||||
if (!result.isValid) {
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
return { isValid: true }
|
||||
}
|
||||
@@ -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`,
|
||||
|
||||
@@ -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<WorkflowRegistry>()(
|
||||
}
|
||||
|
||||
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({
|
||||
|
||||
@@ -27,8 +27,6 @@ export const useSubBlockStore = create<SubBlockStore>()(
|
||||
workflowValues: {},
|
||||
loadingWebhooks: new Set<string>(),
|
||||
checkedWebhooks: new Set<string>(),
|
||||
loadingSchedules: new Set<string>(),
|
||||
checkedSchedules: new Set<string>(),
|
||||
|
||||
setValue: (blockId: string, subBlockId: string, value: any) => {
|
||||
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
|
||||
|
||||
@@ -2,8 +2,6 @@ export interface SubBlockState {
|
||||
workflowValues: Record<string, Record<string, Record<string, any>>> // Store values per workflow ID
|
||||
loadingWebhooks: Set<string> // Track which blockIds are currently loading webhooks
|
||||
checkedWebhooks: Set<string> // Track which blockIds have been checked for webhooks
|
||||
loadingSchedules: Set<string> // Track which blockIds are currently loading schedules
|
||||
checkedSchedules: Set<string> // Track which blockIds have been checked for schedules
|
||||
}
|
||||
|
||||
export interface SubBlockStore extends SubBlockState {
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
16
bun.lock
16
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=="],
|
||||
|
||||
|
||||
@@ -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}": [
|
||||
|
||||
Reference in New Issue
Block a user