Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
7c1e727
feat(timeouts): execution timeout limits
icecrasher321 Feb 3, 2026
501b44e
fix type issues
icecrasher321 Feb 3, 2026
d7259e3
add to docs
icecrasher321 Feb 4, 2026
b53ed5d
update stale exec cleanup route
icecrasher321 Feb 4, 2026
eac163c
update more callsites
icecrasher321 Feb 4, 2026
f104659
update tests
icecrasher321 Feb 4, 2026
bbf5c66
address bugbot comments
icecrasher321 Feb 4, 2026
d2e4afd
remove import expression
icecrasher321 Feb 4, 2026
c332efd
support streaming and async paths'
icecrasher321 Feb 4, 2026
066850b
fix streaming path
icecrasher321 Feb 4, 2026
424b6e6
add hitl and workflow handler
icecrasher321 Feb 4, 2026
39d7589
make sync path match
icecrasher321 Feb 4, 2026
ee06ee3
consolidate
icecrasher321 Feb 4, 2026
fe27adf
Merge remote-tracking branch 'origin/staging' into feat/timeout-lims
icecrasher321 Feb 4, 2026
06ddd80
timeout errors
icecrasher321 Feb 4, 2026
5565677
validation errors typed
icecrasher321 Feb 4, 2026
593bda7
import order
icecrasher321 Feb 4, 2026
32a571a
Merge remote-tracking branch 'origin/staging' into feat/timeout-lims
icecrasher321 Feb 4, 2026
17f02f8
Merge staging into feat/timeout-lims
icecrasher321 Feb 4, 2026
c519034
make run from block consistent
icecrasher321 Feb 4, 2026
b890120
revert console update change
icecrasher321 Feb 4, 2026
7d28f62
fix subflow errors
icecrasher321 Feb 4, 2026
7b75f15
clean up base 64 cache correctly
icecrasher321 Feb 4, 2026
7f23b90
update docs
icecrasher321 Feb 4, 2026
cb63e98
consolidate workflow execution and run from block hook code
icecrasher321 Feb 4, 2026
e63a642
remove unused constant
icecrasher321 Feb 4, 2026
eff4fc9
fix cleanup base64 sse
icecrasher321 Feb 4, 2026
fc2f985
fix run from block tracespan
icecrasher321 Feb 4, 2026
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
19 changes: 19 additions & 0 deletions apps/docs/content/docs/en/execution/costs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,25 @@ Different subscription plans have different usage limits:
| **Team** | $40/seat (pooled, adjustable) | 300 sync, 2,500 async |
| **Enterprise** | Custom | Custom |

## Execution Time Limits

Workflows have maximum execution time limits based on your subscription plan:

| Plan | Sync Execution | Async Execution |
|------|----------------|-----------------|
| **Free** | 5 minutes | 10 minutes |
| **Pro** | 60 minutes | 90 minutes |
| **Team** | 60 minutes | 90 minutes |
| **Enterprise** | 60 minutes | 90 minutes |

**Sync executions** run immediately and return results directly. These are triggered via the API with `async: false` (default) or through the UI.
**Async executions** (triggered via API with `async: true`, webhooks, or schedules) run in the background. Async time limits are up to 2x the sync limit, capped at 90 minutes.


<Callout type="info">
If a workflow exceeds its time limit, it will be terminated and marked as failed with a timeout error. Design long-running workflows to use async execution or break them into smaller workflows.
</Callout>

## Billing Model

Sim uses a **base subscription + overage** billing model:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
Database,
DollarSign,
HardDrive,
Workflow,
Timer,
} from 'lucide-react'
import { useRouter } from 'next/navigation'
import { cn } from '@/lib/core/utils/cn'
Expand Down Expand Up @@ -44,7 +44,7 @@ interface PricingTier {
const FREE_PLAN_FEATURES: PricingFeature[] = [
{ icon: DollarSign, text: '$20 usage limit' },
{ icon: HardDrive, text: '5GB file storage' },
{ icon: Workflow, text: 'Public template access' },
{ icon: Timer, text: '5 min execution limit' },
{ icon: Database, text: 'Limited log retention' },
{ icon: Code2, text: 'CLI/SDK Access' },
]
Expand Down
4 changes: 3 additions & 1 deletion apps/sim/app/api/cron/cleanup-stale-executions/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ import { createLogger } from '@sim/logger'
import { and, eq, lt, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { verifyCronAuth } from '@/lib/auth/internal'
import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'

const logger = createLogger('CleanupStaleExecutions')

const STALE_THRESHOLD_MINUTES = 30
const STALE_THRESHOLD_MS = getMaxExecutionTimeout() + 5 * 60 * 1000
const STALE_THRESHOLD_MINUTES = Math.ceil(STALE_THRESHOLD_MS / 60000)
const MAX_INT32 = 2_147_483_647

export async function GET(request: NextRequest) {
Expand Down
3 changes: 2 additions & 1 deletion apps/sim/app/api/mcp/serve/[serverId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateInternalToken } from '@/lib/auth/internal'
import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
import { getBaseUrl } from '@/lib/core/utils/urls'

const logger = createLogger('WorkflowMcpServeAPI')
Expand Down Expand Up @@ -264,7 +265,7 @@ async function handleToolsCall(
method: 'POST',
headers,
body: JSON.stringify({ input: params.arguments || {}, triggerType: 'mcp' }),
signal: AbortSignal.timeout(600000), // 10 minute timeout
signal: AbortSignal.timeout(getMaxExecutionTimeout()),
})

const executeResult = await response.json()
Expand Down
15 changes: 10 additions & 5 deletions apps/sim/app/api/mcp/tools/execute/route.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { createLogger } from '@sim/logger'
import type { NextRequest } from 'next/server'
import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
import { getExecutionTimeout } from '@/lib/core/execution-limits'
import type { SubscriptionPlan } from '@/lib/core/rate-limiter/types'
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
import { mcpService } from '@/lib/mcp/service'
import type { McpTool, McpToolCall, McpToolResult } from '@/lib/mcp/types'
import {
categorizeError,
createMcpErrorResponse,
createMcpSuccessResponse,
MCP_CONSTANTS,
validateStringParam,
} from '@/lib/mcp/utils'

Expand Down Expand Up @@ -171,13 +173,16 @@ export const POST = withMcpAuth('read')(
arguments: args,
}

const userSubscription = await getHighestPrioritySubscription(userId)
const executionTimeout = getExecutionTimeout(
userSubscription?.plan as SubscriptionPlan | undefined,
'sync'
)

const result = await Promise.race([
mcpService.executeTool(userId, serverId, toolCall, workspaceId),
new Promise<never>((_, reject) =>
setTimeout(
() => reject(new Error('Tool execution timeout')),
MCP_CONSTANTS.EXECUTION_TIMEOUT
)
setTimeout(() => reject(new Error('Tool execution timeout')), executionTimeout)
),
])

Expand Down
11 changes: 1 addition & 10 deletions apps/sim/app/api/tools/dropbox/upload/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { httpHeaderSafeJson } from '@/lib/core/utils/validation'
import { FileInputSchema } from '@/lib/uploads/utils/file-schemas'
import { processFilesToUserFiles, type RawFileInput } from '@/lib/uploads/utils/file-utils'
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
Expand All @@ -11,16 +12,6 @@ export const dynamic = 'force-dynamic'

const logger = createLogger('DropboxUploadAPI')

/**
* Escapes non-ASCII characters in JSON string for HTTP header safety.
* Dropbox API requires characters 0x7F and all non-ASCII to be escaped as \uXXXX.
*/
function httpHeaderSafeJson(value: object): string {
return JSON.stringify(value).replace(/[\u007f-\uffff]/g, (c) => {
return '\\u' + ('0000' + c.charCodeAt(0).toString(16)).slice(-4)
})
}

const DropboxUploadSchema = z.object({
accessToken: z.string().min(1, 'Access token is required'),
path: z.string().min(1, 'Destination path is required'),
Expand Down
4 changes: 3 additions & 1 deletion apps/sim/app/api/tools/stt/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { extractAudioFromVideo, isVideoFile } from '@/lib/audio/extractor'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/core/execution-limits'
import {
secureFetchWithPinnedIP,
validateUrlWithDNS,
Expand Down Expand Up @@ -636,7 +637,8 @@ async function transcribeWithAssemblyAI(

let transcript: any
let attempts = 0
const maxAttempts = 60 // 5 minutes with 5-second intervals
const pollIntervalMs = 5000
const maxAttempts = Math.ceil(DEFAULT_EXECUTION_TIMEOUT_MS / pollIntervalMs)

while (attempts < maxAttempts) {
const statusResponse = await fetch(`https://api.assemblyai.com/v2/transcript/${id}`, {
Expand Down
5 changes: 3 additions & 2 deletions apps/sim/app/api/tools/textract/parse/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/core/execution-limits'
import { validateAwsRegion, validateS3BucketName } from '@/lib/core/security/input-validation'
import {
secureFetchWithPinnedIP,
Expand Down Expand Up @@ -226,8 +227,8 @@ async function pollForJobCompletion(
useAnalyzeDocument: boolean,
requestId: string
): Promise<Record<string, unknown>> {
const pollIntervalMs = 5000 // 5 seconds between polls
const maxPollTimeMs = 180000 // 3 minutes maximum polling time
const pollIntervalMs = 5000
const maxPollTimeMs = DEFAULT_EXECUTION_TIMEOUT_MS
const maxAttempts = Math.ceil(maxPollTimeMs / pollIntervalMs)

const getTarget = useAnalyzeDocument
Expand Down
3 changes: 2 additions & 1 deletion apps/sim/app/api/tools/tts/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger'
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/core/execution-limits'
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { StorageService } from '@/lib/uploads'
Expand Down Expand Up @@ -60,7 +61,7 @@ export async function POST(request: NextRequest) {
text,
model_id: modelId,
}),
signal: AbortSignal.timeout(60000),
signal: AbortSignal.timeout(DEFAULT_EXECUTION_TIMEOUT_MS),
})

if (!response.ok) {
Expand Down
38 changes: 21 additions & 17 deletions apps/sim/app/api/tools/video/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
import type { UserFile } from '@/executor/types'
import type { VideoRequestBody } from '@/tools/video/types'
Expand Down Expand Up @@ -326,11 +327,12 @@ async function generateWithRunway(

logger.info(`[${requestId}] Runway task created: ${taskId}`)

const maxAttempts = 120 // 10 minutes with 5-second intervals
const pollIntervalMs = 5000
const maxAttempts = Math.ceil(getMaxExecutionTimeout() / pollIntervalMs)
let attempts = 0

while (attempts < maxAttempts) {
await sleep(5000) // Poll every 5 seconds
await sleep(pollIntervalMs)

const statusResponse = await fetch(`https://api.dev.runwayml.com/v1/tasks/${taskId}`, {
headers: {
Expand Down Expand Up @@ -370,7 +372,7 @@ async function generateWithRunway(
attempts++
}

throw new Error('Runway generation timed out after 10 minutes')
throw new Error('Runway generation timed out')
}

async function generateWithVeo(
Expand Down Expand Up @@ -429,11 +431,12 @@ async function generateWithVeo(

logger.info(`[${requestId}] Veo operation created: ${operationName}`)

const maxAttempts = 60 // 5 minutes with 5-second intervals
const pollIntervalMs = 5000
const maxAttempts = Math.ceil(getMaxExecutionTimeout() / pollIntervalMs)
let attempts = 0

while (attempts < maxAttempts) {
await sleep(5000)
await sleep(pollIntervalMs)

const statusResponse = await fetch(
`https://generativelanguage.googleapis.com/v1beta/${operationName}`,
Expand Down Expand Up @@ -485,7 +488,7 @@ async function generateWithVeo(
attempts++
}

throw new Error('Veo generation timed out after 5 minutes')
throw new Error('Veo generation timed out')
}

async function generateWithLuma(
Expand Down Expand Up @@ -541,11 +544,12 @@ async function generateWithLuma(

logger.info(`[${requestId}] Luma generation created: ${generationId}`)

const maxAttempts = 120 // 10 minutes
const pollIntervalMs = 5000
const maxAttempts = Math.ceil(getMaxExecutionTimeout() / pollIntervalMs)
let attempts = 0

while (attempts < maxAttempts) {
await sleep(5000)
await sleep(pollIntervalMs)

const statusResponse = await fetch(
`https://api.lumalabs.ai/dream-machine/v1/generations/${generationId}`,
Expand Down Expand Up @@ -592,7 +596,7 @@ async function generateWithLuma(
attempts++
}

throw new Error('Luma generation timed out after 10 minutes')
throw new Error('Luma generation timed out')
}

async function generateWithMiniMax(
Expand Down Expand Up @@ -658,14 +662,13 @@ async function generateWithMiniMax(

logger.info(`[${requestId}] MiniMax task created: ${taskId}`)

// Poll for completion (6-10 minutes typical)
const maxAttempts = 120 // 10 minutes with 5-second intervals
const pollIntervalMs = 5000
const maxAttempts = Math.ceil(getMaxExecutionTimeout() / pollIntervalMs)
let attempts = 0

while (attempts < maxAttempts) {
await sleep(5000)
await sleep(pollIntervalMs)

// Query task status
const statusResponse = await fetch(
`https://api.minimax.io/v1/query/video_generation?task_id=${taskId}`,
{
Expand Down Expand Up @@ -743,7 +746,7 @@ async function generateWithMiniMax(
attempts++
}

throw new Error('MiniMax generation timed out after 10 minutes')
throw new Error('MiniMax generation timed out')
}

// Helper function to strip subpaths from Fal.ai model IDs for status/result endpoints
Expand Down Expand Up @@ -861,11 +864,12 @@ async function generateWithFalAI(
// Get base model ID (without subpath) for status and result endpoints
const baseModelId = getBaseModelId(falModelId)

const maxAttempts = 96 // 8 minutes with 5-second intervals
const pollIntervalMs = 5000
const maxAttempts = Math.ceil(getMaxExecutionTimeout() / pollIntervalMs)
let attempts = 0

while (attempts < maxAttempts) {
await sleep(5000)
await sleep(pollIntervalMs)

const statusResponse = await fetch(
`https://queue.fal.run/${baseModelId}/requests/${requestIdFal}/status`,
Expand Down Expand Up @@ -938,7 +942,7 @@ async function generateWithFalAI(
attempts++
}

throw new Error('Fal.ai generation timed out after 8 minutes')
throw new Error('Fal.ai generation timed out')
}

function getVideoDimensions(
Expand Down
Loading