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:
Waleed
2025-12-23 18:53:40 -08:00
committed by GitHub
parent 8c89507247
commit 810d2089cf
38 changed files with 2736 additions and 1923 deletions

View File

@@ -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

View 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')
})
})
})

View File

@@ -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 })

View File

@@ -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')
})
})

View File

@@ -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 })
}
}

View File

@@ -1,43 +1,15 @@
/**
* Integration tests for schedule configuration API route
* Tests for schedule GET API route
*
* @vitest-environment node
*/
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createMockRequest, mockExecutionDependencies } from '@/app/api/__test-utils__/utils'
const {
mockGetSession,
mockGetUserEntityPermissions,
mockSelectLimit,
mockInsertValues,
mockOnConflictDoUpdate,
mockInsert,
mockUpdate,
mockDelete,
mockTransaction,
mockRandomUUID,
mockGetScheduleTimeValues,
mockGetSubBlockValue,
mockGenerateCronExpression,
mockCalculateNextRunTime,
mockValidateCronExpression,
} = vi.hoisted(() => ({
const { mockGetSession, mockGetUserEntityPermissions, mockDbSelect } = vi.hoisted(() => ({
mockGetSession: vi.fn(),
mockGetUserEntityPermissions: vi.fn(),
mockSelectLimit: vi.fn(),
mockInsertValues: vi.fn(),
mockOnConflictDoUpdate: vi.fn(),
mockInsert: vi.fn(),
mockUpdate: vi.fn(),
mockDelete: vi.fn(),
mockTransaction: vi.fn(),
mockRandomUUID: vi.fn(),
mockGetScheduleTimeValues: vi.fn(),
mockGetSubBlockValue: vi.fn(),
mockGenerateCronExpression: vi.fn(),
mockCalculateNextRunTime: vi.fn(),
mockValidateCronExpression: vi.fn(),
mockDbSelect: vi.fn(),
}))
vi.mock('@/lib/auth', () => ({
@@ -50,231 +22,136 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({
vi.mock('@sim/db', () => ({
db: {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: mockSelectLimit,
}),
}),
}),
insert: mockInsert,
update: mockUpdate,
delete: mockDelete,
select: mockDbSelect,
},
}))
vi.mock('@sim/db/schema', () => ({
workflow: {
id: 'workflow_id',
userId: 'user_id',
workspaceId: 'workspace_id',
},
workflowSchedule: {
id: 'schedule_id',
workflowId: 'workflow_id',
blockId: 'block_id',
cronExpression: 'cron_expression',
nextRunAt: 'next_run_at',
status: 'status',
},
workflow: { id: 'id', userId: 'userId', workspaceId: 'workspaceId' },
workflowSchedule: { workflowId: 'workflowId', blockId: 'blockId' },
}))
vi.mock('drizzle-orm', () => ({
eq: vi.fn((...args) => ({ type: 'eq', args })),
and: vi.fn((...args) => ({ type: 'and', args })),
}))
vi.mock('crypto', () => ({
randomUUID: mockRandomUUID,
default: {
randomUUID: mockRandomUUID,
},
}))
vi.mock('@/lib/workflows/schedules/utils', () => ({
getScheduleTimeValues: mockGetScheduleTimeValues,
getSubBlockValue: mockGetSubBlockValue,
generateCronExpression: mockGenerateCronExpression,
calculateNextRunTime: mockCalculateNextRunTime,
validateCronExpression: mockValidateCronExpression,
BlockState: {},
eq: vi.fn(),
and: vi.fn(),
}))
vi.mock('@/lib/core/utils/request', () => ({
generateRequestId: vi.fn(() => 'test-request-id'),
generateRequestId: () => 'test-request-id',
}))
vi.mock('@/lib/logs/console/logger', () => ({
createLogger: vi.fn(() => ({
createLogger: () => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
})),
}),
}))
vi.mock('@/lib/core/telemetry', () => ({
trackPlatformEvent: vi.fn(),
import { GET } from '@/app/api/schedules/route'
function createRequest(url: string): NextRequest {
return new NextRequest(new URL(url), { method: 'GET' })
}
function mockDbChain(results: any[]) {
let callIndex = 0
mockDbSelect.mockImplementation(() => ({
from: () => ({
where: () => ({
limit: () => results[callIndex++] || [],
}),
}),
}))
}
import { db } from '@sim/db'
import { POST } from '@/app/api/schedules/route'
describe('Schedule Configuration API Route', () => {
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)
})
})

View File

@@ -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 })
}
}

View File

@@ -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', {

View File

@@ -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)
}
}

View File

@@ -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)
}

View File

@@ -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,

View File

@@ -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 }
}

View File

@@ -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'

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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 (

View File

@@ -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,
}
}

View File

@@ -24,5 +24,6 @@ export interface ScheduleInfo {
timezone: string
status?: string
isDisabled?: boolean
failedCount?: number
id?: string
}

View File

@@ -564,7 +564,6 @@ export const WorkflowBlock = memo(function WorkflowBlock({
scheduleInfo,
isLoading: isLoadingScheduleInfo,
reactivateSchedule,
disableSchedule,
} = useScheduleInfo(id, type, currentWorkflowId)
const { childWorkflowId, childIsDeployed, childNeedsRedeploy, refetchDeployment } =

View File

@@ -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`,

View File

@@ -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: {

View File

@@ -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.

View File

@@ -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,
}
}

View File

@@ -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
}

View 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()
})
})
})

View 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}`)
}

View 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'

View File

@@ -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)
})
})
})
})

View File

@@ -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(

View 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 }
}

View File

@@ -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`,

View File

@@ -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({

View File

@@ -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

View File

@@ -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 {

View File

@@ -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',
]

View File

@@ -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=="],

View File

@@ -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}": [