Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/docs/content/docs/en/triggers/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ Use the Start block for everything originating from the editor, deploy-to-API, o
| Trigger | Start condition |
|---------|-----------------|
| **Start** | Editor runs, deploy-to-API requests, or chat messages |
| **Schedule** | Timer managed in schedule modal |
| **Schedule** | Timer managed in schedule block |
| **Webhook** | On inbound HTTP request |

> The Start block always exposes `input`, `conversationId`, and `files` fields. Add custom fields to the input format for additional structured data.
Expand Down
80 changes: 40 additions & 40 deletions apps/sim/app/api/schedules/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,6 @@ const ScheduleRequestSchema = z.object({
}),
})

// Track recent requests to reduce redundant logging
const recentRequests = new Map<string, number>()
const LOGGING_THROTTLE_MS = 5000 // 5 seconds between logging for the same workflow

function hasValidScheduleConfig(
scheduleType: string | undefined,
scheduleValues: ReturnType<typeof getScheduleTimeValues>,
Expand Down Expand Up @@ -86,7 +82,6 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ error: 'Missing workflowId parameter' }, { status: 400 })
}

// Check if user has permission to view this workflow
const [workflowRecord] = await db
.select({ userId: workflow.userId, workspaceId: workflow.workspaceId })
.from(workflow)
Expand All @@ -97,10 +92,8 @@ export async function GET(req: NextRequest) {
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,
Expand All @@ -114,16 +107,8 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ error: 'Not authorized to view this workflow' }, { status: 403 })
}

const now = Date.now()
const lastLog = recentRequests.get(workflowId) || 0
const shouldLog = now - lastLog > LOGGING_THROTTLE_MS

if (shouldLog) {
logger.info(`[${requestId}] Getting schedule for workflow ${workflowId}`)
recentRequests.set(workflowId, now)
}
logger.info(`[${requestId}] Getting schedule for workflow ${workflowId}`)

// Build query conditions
const conditions = [eq(workflowSchedule.workflowId, workflowId)]
if (blockId) {
conditions.push(eq(workflowSchedule.blockId, blockId))
Expand All @@ -136,7 +121,7 @@ export async function GET(req: NextRequest) {
.limit(1)

const headers = new Headers()
headers.set('Cache-Control', 'max-age=30') // Cache for 30 seconds
headers.set('Cache-Control', 'max-age=30')

if (schedule.length === 0) {
return NextResponse.json({ schedule: null }, { headers })
Expand All @@ -161,6 +146,11 @@ export async function GET(req: NextRequest) {
}
}

// Simple in-memory rate limiting
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
*/
Expand All @@ -174,12 +164,28 @@ export async function POST(req: NextRequest) {
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}`)

// Check if user has permission to modify this workflow
const [workflowRecord] = await db
.select({ userId: workflow.userId, workspaceId: workflow.workspaceId })
.from(workflow)
Expand All @@ -191,10 +197,8 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
}

// 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,
Expand All @@ -211,15 +215,12 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'Not authorized to modify this workflow' }, { status: 403 })
}

// Find the target block - prioritize the specific blockId if provided
let targetBlock: BlockState | undefined
if (blockId) {
// If blockId is provided, find that specific block
targetBlock = Object.values(state.blocks).find((block: any) => block.id === blockId) as
| BlockState
| undefined
} else {
// Fallback: find either starter block or schedule trigger block
targetBlock = Object.values(state.blocks).find(
(block: any) => block.type === 'starter' || block.type === 'schedule'
) as BlockState | undefined
Expand All @@ -240,12 +241,9 @@ export async function POST(req: NextRequest) {

const hasScheduleConfig = hasValidScheduleConfig(scheduleType, scheduleValues, targetBlock)

// For schedule trigger blocks, we always have valid configuration
// For starter blocks, check if schedule is selected and has valid config
const isScheduleBlock = targetBlock.type === 'schedule'
const hasValidConfig = isScheduleBlock || (startWorkflow === 'schedule' && hasScheduleConfig)

// Debug logging to understand why validation fails
logger.info(`[${requestId}] Schedule validation debug:`, {
workflowId,
blockId,
Expand All @@ -266,7 +264,6 @@ export async function POST(req: NextRequest) {
logger.info(
`[${requestId}] Removing schedule for workflow ${workflowId} - no valid configuration found`
)
// Build delete conditions
const deleteConditions = [eq(workflowSchedule.workflowId, workflowId)]
if (blockId) {
deleteConditions.push(eq(workflowSchedule.blockId, blockId))
Expand Down Expand Up @@ -307,14 +304,15 @@ export async function POST(req: NextRequest) {

cronExpression = generateCronExpression(defaultScheduleType, scheduleValues)

// Additional validation for custom cron expressions
if (defaultScheduleType === 'custom' && cronExpression) {
// Validate with timezone for accurate validation
if (cronExpression) {
const validation = validateCronExpression(cronExpression, timezone)
if (!validation.isValid) {
logger.error(`[${requestId}] Invalid cron expression: ${validation.error}`)
logger.error(`[${requestId}] Invalid cron expression: ${validation.error}`, {
scheduleType: defaultScheduleType,
cronExpression,
})
return NextResponse.json(
{ error: `Invalid cron expression: ${validation.error}` },
{ error: `Invalid schedule configuration: ${validation.error}` },
{ status: 400 }
)
}
Expand Down Expand Up @@ -354,20 +352,21 @@ export async function POST(req: NextRequest) {
failedCount: 0, // Reset failure count on reconfiguration
}

await db
.insert(workflowSchedule)
.values(values)
.onConflictDoUpdate({
target: [workflowSchedule.workflowId, workflowSchedule.blockId],
set: setValues,
})
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,
})

// Track schedule creation/update
try {
const { trackPlatformEvent } = await import('@/lib/telemetry/tracer')
trackPlatformEvent('platform.schedule.created', {
Expand All @@ -382,6 +381,7 @@ export async function POST(req: NextRequest) {

return NextResponse.json({
message: 'Schedule updated',
schedule: { id: values.id },
nextRunAt,
cronExpression,
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ export { McpServerSelector } from './mcp-server-modal/mcp-server-selector'
export { McpToolSelector } from './mcp-server-modal/mcp-tool-selector'
export { ProjectSelectorInput } from './project-selector/project-selector-input'
export { ResponseFormat } from './response/response-format'
export { ScheduleConfig } from './schedule/schedule-config'
export { ShortInput } from './short-input'
export { SliderInput } from './slider-input'
export { InputFormat } from './starter/input-format'
Expand Down
Loading
Loading