Compare commits

..

7 Commits

80 changed files with 313 additions and 653 deletions

View File

@@ -213,25 +213,6 @@ 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 Limit |
|------|---------------------|
| **Free** | 5 minutes |
| **Pro** | 60 minutes |
| **Team** | 60 minutes |
| **Enterprise** | 60 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 with a 90-minute time limit for all plans.
<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:

View File

@@ -1,6 +1,6 @@
import { redirect } from 'next/navigation'
import { getEnv, isTruthy } from '@/lib/core/config/env'
import SSOForm from '@/app/(auth)/sso/sso-form'
import SSOForm from '@/ee/sso/components/sso-form'
export const dynamic = 'force-dynamic'

View File

@@ -11,7 +11,7 @@ import {
Database,
DollarSign,
HardDrive,
Timer,
Workflow,
} from 'lucide-react'
import { useRouter } from 'next/navigation'
import { cn } from '@/lib/core/utils/cn'
@@ -44,7 +44,7 @@ interface PricingTier {
const FREE_PLAN_FEATURES: PricingFeature[] = [
{ icon: DollarSign, text: '$20 usage limit' },
{ icon: HardDrive, text: '5GB file storage' },
{ icon: Timer, text: '5 min execution limit' },
{ icon: Workflow, text: 'Public template access' },
{ icon: Database, text: 'Limited log retention' },
{ icon: Code2, text: 'CLI/SDK Access' },
]

View File

@@ -4,12 +4,10 @@ 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_MS = getMaxExecutionTimeout() + 5 * 60 * 1000
const STALE_THRESHOLD_MINUTES = Math.ceil(STALE_THRESHOLD_MS / 60000)
const STALE_THRESHOLD_MINUTES = 30
const MAX_INT32 = 2_147_483_647
export async function GET(request: NextRequest) {

View File

@@ -21,7 +21,6 @@ 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')
@@ -265,7 +264,7 @@ async function handleToolsCall(
method: 'POST',
headers,
body: JSON.stringify({ input: params.arguments || {}, triggerType: 'mcp' }),
signal: AbortSignal.timeout(getMaxExecutionTimeout()),
signal: AbortSignal.timeout(600000), // 10 minute timeout
})
const executeResult = await response.json()

View File

@@ -1,8 +1,5 @@
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'
@@ -10,6 +7,7 @@ import {
categorizeError,
createMcpErrorResponse,
createMcpSuccessResponse,
MCP_CONSTANTS,
validateStringParam,
} from '@/lib/mcp/utils'
@@ -173,16 +171,13 @@ 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')), executionTimeout)
setTimeout(
() => reject(new Error('Tool execution timeout')),
MCP_CONSTANTS.EXECUTION_TIMEOUT
)
),
])

View File

@@ -29,7 +29,7 @@ import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils'
import {
InvitationsNotAllowedError,
validateInvitationsAllowed,
} from '@/executor/utils/permission-check'
} from '@/ee/access-control/utils/permission-check'
const logger = createLogger('OrganizationInvitations')

View File

@@ -3,7 +3,6 @@ import { type NextRequest, NextResponse } from 'next/server'
import { v4 as uuidv4 } from 'uuid'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { getTimeoutErrorMessage, isTimeoutError } from '@/lib/core/execution-limits'
import { generateRequestId } from '@/lib/core/utils/request'
import { SSE_HEADERS } from '@/lib/core/utils/sse'
import { markExecutionCancelled } from '@/lib/execution/cancellation'
@@ -117,16 +116,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
const loggingSession = new LoggingSession(workflowId, executionId, 'manual', requestId)
const abortController = new AbortController()
let isStreamClosed = false
let isTimedOut = false
const syncTimeout = preprocessResult.executionTimeout?.sync
let timeoutId: NodeJS.Timeout | undefined
if (syncTimeout) {
timeoutId = setTimeout(() => {
isTimedOut = true
abortController.abort()
}, syncTimeout)
}
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
@@ -178,33 +167,13 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
})
if (result.status === 'cancelled') {
if (isTimedOut && syncTimeout) {
const timeoutErrorMessage = getTimeoutErrorMessage(null, syncTimeout)
logger.info(`[${requestId}] Run-from-block execution timed out`, {
timeoutMs: syncTimeout,
})
await loggingSession.markAsFailed(timeoutErrorMessage)
sendEvent({
type: 'execution:error',
timestamp: new Date().toISOString(),
executionId,
workflowId,
data: {
error: timeoutErrorMessage,
duration: result.metadata?.duration || 0,
},
})
} else {
sendEvent({
type: 'execution:cancelled',
timestamp: new Date().toISOString(),
executionId,
workflowId,
data: { duration: result.metadata?.duration || 0 },
})
}
sendEvent({
type: 'execution:cancelled',
timestamp: new Date().toISOString(),
executionId,
workflowId,
data: { duration: result.metadata?.duration || 0 },
})
} else {
sendEvent({
type: 'execution:completed',
@@ -221,25 +190,11 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
})
}
} catch (error: unknown) {
const isTimeout = isTimeoutError(error) || isTimedOut
const errorMessage = isTimeout
? getTimeoutErrorMessage(error, syncTimeout)
: error instanceof Error
? error.message
: 'Unknown error'
logger.error(`[${requestId}] Run-from-block execution failed: ${errorMessage}`, {
isTimeout,
})
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
logger.error(`[${requestId}] Run-from-block execution failed: ${errorMessage}`)
const executionResult = hasExecutionResult(error) ? error.executionResult : undefined
await loggingSession.safeCompleteWithError({
totalDurationMs: executionResult?.metadata?.duration,
error: { message: errorMessage },
traceSpans: executionResult?.logs as any,
})
sendEvent({
type: 'execution:error',
timestamp: new Date().toISOString(),
@@ -251,7 +206,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
},
})
} finally {
if (timeoutId) clearTimeout(timeoutId)
if (!isStreamClosed) {
try {
controller.enqueue(new TextEncoder().encode('data: [DONE]\n\n'))
@@ -262,7 +216,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
},
cancel() {
isStreamClosed = true
if (timeoutId) clearTimeout(timeoutId)
abortController.abort()
markExecutionCancelled(executionId).catch(() => {})
},

View File

@@ -5,7 +5,6 @@ import { validate as uuidValidate, v4 as uuidv4 } from 'uuid'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags'
import { getTimeoutErrorMessage, isTimeoutError } from '@/lib/core/execution-limits'
import { generateRequestId } from '@/lib/core/utils/request'
import { SSE_HEADERS } from '@/lib/core/utils/sse'
import { getBaseUrl } from '@/lib/core/utils/urls'
@@ -121,6 +120,10 @@ type AsyncExecutionParams = {
triggerType: CoreTriggerType
}
/**
* Handles async workflow execution by queueing a background job.
* Returns immediately with a 202 Accepted response containing the job ID.
*/
async function handleAsyncExecution(params: AsyncExecutionParams): Promise<NextResponse> {
const { requestId, workflowId, userId, input, triggerType } = params
@@ -402,7 +405,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
if (!enableSSE) {
logger.info(`[${requestId}] Using non-SSE execution (direct JSON response)`)
const syncTimeout = preprocessResult.executionTimeout?.sync
try {
const metadata: ExecutionMetadata = {
requestId,
@@ -436,7 +438,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
includeFileBase64,
base64MaxBytes,
stopAfterBlockId,
abortSignal: syncTimeout ? AbortSignal.timeout(syncTimeout) : undefined,
})
const outputWithBase64 = includeFileBase64
@@ -472,23 +473,11 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
return NextResponse.json(filteredResult)
} catch (error: unknown) {
const isTimeout = isTimeoutError(error)
const errorMessage = isTimeout
? getTimeoutErrorMessage(error, syncTimeout)
: error instanceof Error
? error.message
: 'Unknown error'
logger.error(`[${requestId}] Non-SSE execution failed: ${errorMessage}`, { isTimeout })
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
logger.error(`[${requestId}] Non-SSE execution failed: ${errorMessage}`)
const executionResult = hasExecutionResult(error) ? error.executionResult : undefined
await loggingSession.safeCompleteWithError({
totalDurationMs: executionResult?.metadata?.duration,
error: { message: errorMessage },
traceSpans: executionResult?.logs as any,
})
return NextResponse.json(
{
success: false,
@@ -502,7 +491,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
}
: undefined,
},
{ status: isTimeout ? 408 : 500 }
{ status: 500 }
)
}
}
@@ -548,16 +537,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
const encoder = new TextEncoder()
const abortController = new AbortController()
let isStreamClosed = false
let isTimedOut = false
const syncTimeout = preprocessResult.executionTimeout?.sync
let timeoutId: NodeJS.Timeout | undefined
if (syncTimeout) {
timeoutId = setTimeout(() => {
isTimedOut = true
abortController.abort()
}, syncTimeout)
}
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
@@ -784,35 +763,16 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
}
if (result.status === 'cancelled') {
if (isTimedOut && syncTimeout) {
const timeoutErrorMessage = getTimeoutErrorMessage(null, syncTimeout)
logger.info(`[${requestId}] Workflow execution timed out`, { timeoutMs: syncTimeout })
await loggingSession.markAsFailed(timeoutErrorMessage)
sendEvent({
type: 'execution:error',
timestamp: new Date().toISOString(),
executionId,
workflowId,
data: {
error: timeoutErrorMessage,
duration: result.metadata?.duration || 0,
},
})
} else {
logger.info(`[${requestId}] Workflow execution was cancelled`)
sendEvent({
type: 'execution:cancelled',
timestamp: new Date().toISOString(),
executionId,
workflowId,
data: {
duration: result.metadata?.duration || 0,
},
})
}
logger.info(`[${requestId}] Workflow execution was cancelled`)
sendEvent({
type: 'execution:cancelled',
timestamp: new Date().toISOString(),
executionId,
workflowId,
data: {
duration: result.metadata?.duration || 0,
},
})
return
}
@@ -839,23 +799,11 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
// Cleanup base64 cache for this execution
await cleanupExecutionBase64Cache(executionId)
} catch (error: unknown) {
const isTimeout = isTimeoutError(error) || isTimedOut
const errorMessage = isTimeout
? getTimeoutErrorMessage(error, syncTimeout)
: error instanceof Error
? error.message
: 'Unknown error'
logger.error(`[${requestId}] SSE execution failed: ${errorMessage}`, { isTimeout })
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
logger.error(`[${requestId}] SSE execution failed: ${errorMessage}`)
const executionResult = hasExecutionResult(error) ? error.executionResult : undefined
await loggingSession.safeCompleteWithError({
totalDurationMs: executionResult?.metadata?.duration,
error: { message: errorMessage },
traceSpans: executionResult?.logs as any,
})
sendEvent({
type: 'execution:error',
timestamp: new Date().toISOString(),
@@ -867,18 +815,18 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
},
})
} finally {
if (timeoutId) clearTimeout(timeoutId)
if (!isStreamClosed) {
try {
controller.enqueue(encoder.encode('data: [DONE]\n\n'))
controller.close()
} catch {}
} catch {
// Stream already closed - nothing to do
}
}
}
},
cancel() {
isStreamClosed = true
if (timeoutId) clearTimeout(timeoutId)
logger.info(`[${requestId}] Client aborted SSE stream, signalling cancellation`)
abortController.abort()
markExecutionCancelled(executionId).catch(() => {})

View File

@@ -102,7 +102,7 @@ describe('Workspace Invitations API Route', () => {
inArray: vi.fn().mockImplementation((field, values) => ({ type: 'inArray', field, values })),
}))
vi.doMock('@/executor/utils/permission-check', () => ({
vi.doMock('@/ee/access-control/utils/permission-check', () => ({
validateInvitationsAllowed: vi.fn().mockResolvedValue(undefined),
InvitationsNotAllowedError: class InvitationsNotAllowedError extends Error {
constructor() {

View File

@@ -21,7 +21,7 @@ import { getFromEmailAddress } from '@/lib/messaging/email/utils'
import {
InvitationsNotAllowedError,
validateInvitationsAllowed,
} from '@/executor/utils/permission-check'
} from '@/ee/access-control/utils/permission-check'
export const dynamic = 'force-dynamic'
@@ -38,7 +38,6 @@ export async function GET(req: NextRequest) {
}
try {
// Get all workspaces where the user has permissions
const userWorkspaces = await db
.select({ id: workspace.id })
.from(workspace)
@@ -55,10 +54,8 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ invitations: [] })
}
// Get all workspaceIds where the user is a member
const workspaceIds = userWorkspaces.map((w) => w.id)
// Find all invitations for those workspaces
const invitations = await db
.select()
.from(workspaceInvitation)

View File

@@ -14,11 +14,11 @@ import {
ChatMessageContainer,
EmailAuth,
PasswordAuth,
SSOAuth,
VoiceInterface,
} from '@/app/chat/components'
import { CHAT_ERROR_MESSAGES, CHAT_REQUEST_TIMEOUT_MS } from '@/app/chat/constants'
import { useAudioStreaming, useChatStreaming } from '@/app/chat/hooks'
import SSOAuth from '@/ee/sso/components/sso-auth'
const logger = createLogger('ChatClient')

View File

@@ -1,6 +1,5 @@
export { default as EmailAuth } from './auth/email/email-auth'
export { default as PasswordAuth } from './auth/password/password-auth'
export { default as SSOAuth } from './auth/sso/sso-auth'
export { ChatErrorState } from './error-state/error-state'
export { ChatHeader } from './header/header'
export { ChatInput } from './input/input'

View File

@@ -1,7 +1,7 @@
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
import { Knowledge } from './knowledge'
interface KnowledgePageProps {
@@ -23,7 +23,6 @@ export default async function KnowledgePage({ params }: KnowledgePageProps) {
redirect('/')
}
// Check permission group restrictions
const permissionConfig = await getUserPermissionConfig(session.user.id)
if (permissionConfig?.hideKnowledgeBaseTab) {
redirect(`/workspace/${workspaceId}`)

View File

@@ -18,6 +18,7 @@ import {
import { ScrollArea } from '@/components/ui/scroll-area'
import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants'
import { cn } from '@/lib/core/utils/cn'
import { formatDuration } from '@/lib/core/utils/formatting'
import { filterHiddenOutputKeys } from '@/lib/logs/execution/trace-spans/trace-spans'
import {
ExecutionSnapshot,
@@ -453,7 +454,7 @@ export const LogDetails = memo(function LogDetails({
Duration
</span>
<span className='font-medium text-[13px] text-[var(--text-secondary)]'>
{log.duration || '—'}
{formatDuration(log.duration, { precision: 2 }) || '—'}
</span>
</div>

View File

@@ -6,11 +6,11 @@ import Link from 'next/link'
import { List, type RowComponentProps, useListRef } from 'react-window'
import { Badge, buttonVariants } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { formatDuration } from '@/lib/core/utils/formatting'
import {
DELETED_WORKFLOW_COLOR,
DELETED_WORKFLOW_LABEL,
formatDate,
formatDuration,
getDisplayStatus,
LOG_COLUMNS,
StatusBadge,
@@ -113,7 +113,7 @@ const LogRow = memo(
<div className={`${LOG_COLUMNS.duration.width} ${LOG_COLUMNS.duration.minWidth}`}>
<Badge variant='default' className='rounded-[6px] px-[9px] py-[2px] text-[12px]'>
{formatDuration(log.duration) || '—'}
{formatDuration(log.duration, { precision: 2 }) || '—'}
</Badge>
</div>
</div>

View File

@@ -1,6 +1,7 @@
import React from 'react'
import { format } from 'date-fns'
import { Badge } from '@/components/emcn'
import { formatDuration } from '@/lib/core/utils/formatting'
import { getIntegrationMetadata } from '@/lib/logs/get-trigger-options'
import { getBlock } from '@/blocks/registry'
import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types'
@@ -362,47 +363,14 @@ export function mapToExecutionLogAlt(log: RawLogResponse): ExecutionLog {
}
}
/**
* Format duration for display in logs UI
* If duration is under 1 second, displays as milliseconds (e.g., "500ms")
* If duration is 1 second or more, displays as seconds (e.g., "1.23s")
* @param duration - Duration string (e.g., "500ms") or null
* @returns Formatted duration string or null
*/
export function formatDuration(duration: string | null): string | null {
if (!duration) return null
// Extract numeric value from duration string (e.g., "500ms" -> 500)
const ms = Number.parseInt(duration.replace(/[^0-9]/g, ''), 10)
if (!Number.isFinite(ms)) return duration
if (ms < 1000) {
return `${ms}ms`
}
// Convert to seconds with up to 2 decimal places
const seconds = ms / 1000
return `${seconds.toFixed(2).replace(/\.?0+$/, '')}s`
}
/**
* Format latency value for display in dashboard UI
* If latency is under 1 second, displays as milliseconds (e.g., "500ms")
* If latency is 1 second or more, displays as seconds (e.g., "1.23s")
* @param ms - Latency in milliseconds (number)
* @returns Formatted latency string
*/
export function formatLatency(ms: number): string {
if (!Number.isFinite(ms) || ms <= 0) return '—'
if (ms < 1000) {
return `${Math.round(ms)}ms`
}
// Convert to seconds with up to 2 decimal places
const seconds = ms / 1000
return `${seconds.toFixed(2).replace(/\.?0+$/, '')}s`
return formatDuration(ms, { precision: 2 }) ?? '—'
}
export const formatDate = (dateString: string) => {

View File

@@ -6,7 +6,7 @@ import { getSession } from '@/lib/auth'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
import type { Template as WorkspaceTemplate } from '@/app/workspace/[workspaceId]/templates/templates'
import Templates from '@/app/workspace/[workspaceId]/templates/templates'
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
interface TemplatesPageProps {
params: Promise<{

View File

@@ -3,6 +3,7 @@
import { memo, useEffect, useMemo, useRef, useState } from 'react'
import clsx from 'clsx'
import { ChevronUp } from 'lucide-react'
import { formatDuration } from '@/lib/core/utils/formatting'
import { CopilotMarkdownRenderer } from '../markdown-renderer'
/** Removes thinking tags (raw or escaped) and special tags from streamed content */
@@ -241,15 +242,11 @@ export function ThinkingBlock({
return () => window.clearInterval(intervalId)
}, [isStreaming, isExpanded, userHasScrolledAway])
/** Formats duration in milliseconds to seconds (minimum 1s) */
const formatDuration = (ms: number) => {
const seconds = Math.max(1, Math.round(ms / 1000))
return `${seconds}s`
}
const hasContent = cleanContent.length > 0
const isThinkingDone = !isStreaming || hasFollowingContent || hasSpecialTags
const durationText = `${label} for ${formatDuration(duration)}`
// Round to nearest second (minimum 1s) to match original behavior
const roundedMs = Math.max(1000, Math.round(duration / 1000) * 1000)
const durationText = `${label} for ${formatDuration(roundedMs)}`
const getStreamingLabel = (lbl: string) => {
if (lbl === 'Thought') return 'Thinking'

View File

@@ -15,6 +15,7 @@ import {
hasInterrupt as hasInterruptFromConfig,
isSpecialTool as isSpecialToolFromConfig,
} from '@/lib/copilot/tools/client/ui-config'
import { formatDuration } from '@/lib/core/utils/formatting'
import { CopilotMarkdownRenderer } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer'
import { SmoothStreamingText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/smooth-streaming'
import { ThinkingBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block'
@@ -848,13 +849,10 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({
(allParsed.options && Object.keys(allParsed.options).length > 0)
)
const formatDuration = (ms: number) => {
const seconds = Math.max(1, Math.round(ms / 1000))
return `${seconds}s`
}
const outerLabel = getSubagentCompletionLabel(toolCall.name)
const durationText = `${outerLabel} for ${formatDuration(duration)}`
// Round to nearest second (minimum 1s) to match original behavior
const roundedMs = Math.max(1000, Math.round(duration / 1000) * 1000)
const durationText = `${outerLabel} for ${formatDuration(roundedMs)}`
const renderCollapsibleContent = () => (
<>

View File

@@ -24,6 +24,7 @@ import {
Tooltip,
} from '@/components/emcn'
import { getEnv, isTruthy } from '@/lib/core/config/env'
import { formatDuration } from '@/lib/core/utils/formatting'
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils'
import {
@@ -43,7 +44,6 @@ import {
type EntryNode,
type ExecutionGroup,
flattenBlockEntriesOnly,
formatDuration,
getBlockColor,
getBlockIcon,
groupEntriesByExecution,
@@ -128,7 +128,7 @@ const BlockRow = memo(function BlockRow({
<StatusDisplay
isRunning={isRunning}
isCanceled={isCanceled}
formattedDuration={formatDuration(entry.durationMs)}
formattedDuration={formatDuration(entry.durationMs, { precision: 2 }) ?? '-'}
/>
</span>
</div>
@@ -201,7 +201,7 @@ const IterationNodeRow = memo(function IterationNodeRow({
<StatusDisplay
isRunning={hasRunningChild}
isCanceled={hasCanceledChild}
formattedDuration={formatDuration(entry.durationMs)}
formattedDuration={formatDuration(entry.durationMs, { precision: 2 }) ?? '-'}
/>
</span>
</div>
@@ -314,7 +314,7 @@ const SubflowNodeRow = memo(function SubflowNodeRow({
<StatusDisplay
isRunning={hasRunningDescendant}
isCanceled={hasCanceledDescendant}
formattedDuration={formatDuration(entry.durationMs)}
formattedDuration={formatDuration(entry.durationMs, { precision: 2 }) ?? '-'}
/>
</span>
</div>

View File

@@ -53,17 +53,6 @@ export function getBlockColor(blockType: string): string {
return '#6b7280'
}
/**
* Formats duration from milliseconds to readable format
*/
export function formatDuration(ms?: number): string {
if (ms === undefined || ms === null) return '-'
if (ms < 1000) {
return `${Math.round(ms)}ms`
}
return `${(ms / 1000).toFixed(2)}s`
}
/**
* Determines if a keyboard event originated from a text-editable element
*/

View File

@@ -30,6 +30,7 @@ import {
Textarea,
} from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { formatDuration } from '@/lib/core/utils/formatting'
import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer'
import { formatEditSequence } from '@/lib/workflows/training/compute-edit-sequence'
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow'
@@ -575,7 +576,9 @@ export function TrainingModal() {
<span className='text-[var(--text-muted)]'>Duration:</span>{' '}
<span className='text-[var(--text-secondary)]'>
{dataset.metadata?.duration
? `${(dataset.metadata.duration / 1000).toFixed(1)}s`
? formatDuration(dataset.metadata.duration, {
precision: 1,
})
: 'N/A'}
</span>
</div>

View File

@@ -27,7 +27,7 @@ import { useExecutionStore } from '@/stores/execution'
import { useNotificationStore } from '@/stores/notifications'
import { useVariablesStore } from '@/stores/panel'
import { useEnvironmentStore } from '@/stores/settings/environment'
import { useTerminalConsoleStore } from '@/stores/terminal'
import { type ConsoleEntry, useTerminalConsoleStore } from '@/stores/terminal'
import { useWorkflowDiffStore } from '@/stores/workflow-diff'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { mergeSubblockState } from '@/stores/workflows/utils'
@@ -1153,29 +1153,30 @@ export function useWorkflowExecution() {
logs: accumulatedBlockLogs,
}
if (activeWorkflowId) {
cancelRunningEntries(activeWorkflowId)
}
// Only add workflow-level error if no blocks have executed yet
// This catches pre-execution errors (validation, serialization, etc.)
// Block execution errors are already logged via onBlockError callback
const { entries } = useTerminalConsoleStore.getState()
const existingLogs = entries.filter(
(log: ConsoleEntry) => log.executionId === executionId
)
addConsole({
input: {},
output: {},
success: false,
error: data.error,
durationMs: data.duration || 0,
startedAt: new Date(Date.now() - (data.duration || 0)).toISOString(),
endedAt: new Date().toISOString(),
workflowId: activeWorkflowId,
blockId: 'workflow-error',
executionId,
blockName: 'Workflow Error',
blockType: 'error',
})
},
onExecutionCancelled: () => {
if (activeWorkflowId) {
cancelRunningEntries(activeWorkflowId)
if (existingLogs.length === 0) {
// No blocks executed yet - this is a pre-execution error
addConsole({
input: {},
output: {},
success: false,
error: data.error,
durationMs: data.duration || 0,
startedAt: new Date(Date.now() - (data.duration || 0)).toISOString(),
endedAt: new Date().toISOString(),
workflowId: activeWorkflowId,
blockId: 'validation',
executionId,
blockName: 'Workflow Validation',
blockType: 'validation',
})
}
},
},
@@ -1717,28 +1718,13 @@ export function useWorkflowExecution() {
'Workflow was modified. Run the workflow again to enable running from block.',
workflowId,
})
} else {
addNotification({
level: 'error',
message: data.error || 'Run from block failed',
workflowId,
})
}
cancelRunningEntries(workflowId)
addConsole({
input: {},
output: {},
success: false,
error: data.error,
durationMs: data.duration || 0,
startedAt: new Date(Date.now() - (data.duration || 0)).toISOString(),
endedAt: new Date().toISOString(),
workflowId,
blockId: 'workflow-error',
executionId,
blockName: 'Workflow Error',
blockType: 'error',
})
},
onExecutionCancelled: () => {
cancelRunningEntries(workflowId)
},
},
})

View File

@@ -246,7 +246,6 @@ export function CredentialSets() {
setNewSetDescription('')
setNewSetProvider('google-email')
// Open detail view for the newly created group
if (result?.credentialSet) {
setViewingSet(result.credentialSet)
}
@@ -336,7 +335,6 @@ export function CredentialSets() {
email,
})
// Start 60s cooldown
setResendCooldowns((prev) => ({ ...prev, [invitationId]: 60 }))
const interval = setInterval(() => {
setResendCooldowns((prev) => {
@@ -393,7 +391,6 @@ export function CredentialSets() {
return <GmailIcon className='h-4 w-4' />
}
// All hooks must be called before any early returns
const activeMemberships = useMemo(
() => memberships.filter((m) => m.status === 'active'),
[memberships]
@@ -447,7 +444,6 @@ export function CredentialSets() {
<div className='flex h-full flex-col gap-[16px]'>
<div className='min-h-0 flex-1 overflow-y-auto'>
<div className='flex flex-col gap-[16px]'>
{/* Group Info */}
<div className='flex items-center gap-[16px]'>
<div className='flex items-center gap-[8px]'>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
@@ -471,7 +467,6 @@ export function CredentialSets() {
</div>
</div>
{/* Invite Section - Email Tags Input */}
<div className='flex flex-col gap-[4px]'>
<div className='flex items-center gap-[8px]'>
<TagInput
@@ -495,7 +490,6 @@ export function CredentialSets() {
{emailError && <p className='text-[12px] text-[var(--text-error)]'>{emailError}</p>}
</div>
{/* Members List - styled like team members */}
<div className='flex flex-col gap-[16px]'>
<h4 className='font-medium text-[14px] text-[var(--text-primary)]'>Members</h4>
@@ -519,7 +513,6 @@ export function CredentialSets() {
</p>
) : (
<div className='flex flex-col gap-[16px]'>
{/* Active Members */}
{activeMembers.map((member) => {
const name = member.userName || 'Unknown'
const avatarInitial = name.charAt(0).toUpperCase()
@@ -572,7 +565,6 @@ export function CredentialSets() {
)
})}
{/* Pending Invitations */}
{pendingInvitations.map((invitation) => {
const email = invitation.email || 'Unknown'
const emailPrefix = email.split('@')[0]
@@ -641,7 +633,6 @@ export function CredentialSets() {
</div>
</div>
{/* Footer Actions */}
<div className='mt-auto flex items-center justify-end'>
<Button onClick={handleBackToList} variant='tertiary'>
Back
@@ -822,7 +813,6 @@ export function CredentialSets() {
</div>
</div>
{/* Create Polling Group Modal */}
<Modal open={showCreateModal} onOpenChange={handleCloseCreateModal}>
<ModalContent size='sm'>
<ModalHeader>Create Polling Group</ModalHeader>
@@ -895,7 +885,6 @@ export function CredentialSets() {
</ModalContent>
</Modal>
{/* Leave Confirmation Modal */}
<Modal open={!!leavingMembership} onOpenChange={() => setLeavingMembership(null)}>
<ModalContent size='sm'>
<ModalHeader>Leave Polling Group</ModalHeader>
@@ -923,7 +912,6 @@ export function CredentialSets() {
</ModalContent>
</Modal>
{/* Delete Confirmation Modal */}
<Modal open={!!deletingSet} onOpenChange={() => setDeletingSet(null)}>
<ModalContent size='sm'>
<ModalHeader>Delete Polling Group</ModalHeader>

View File

@@ -1,4 +1,3 @@
export { AccessControl } from './access-control/access-control'
export { ApiKeys } from './api-keys/api-keys'
export { BYOK } from './byok/byok'
export { Copilot } from './copilot/copilot'
@@ -10,7 +9,6 @@ export { Files as FileUploads } from './files/files'
export { General } from './general/general'
export { Integrations } from './integrations/integrations'
export { MCP } from './mcp/mcp'
export { SSO } from './sso/sso'
export { Subscription } from './subscription/subscription'
export { TeamManagement } from './team-management/team-management'
export { WorkflowMcpServers } from './workflow-mcp-servers/workflow-mcp-servers'

View File

@@ -407,14 +407,12 @@ export function MCP({ initialServerId }: MCPProps) {
const [urlScrollLeft, setUrlScrollLeft] = useState(0)
const [headerScrollLeft, setHeaderScrollLeft] = useState<Record<string, number>>({})
// Auto-select server when initialServerId is provided
useEffect(() => {
if (initialServerId && servers.some((s) => s.id === initialServerId)) {
setSelectedServerId(initialServerId)
}
}, [initialServerId, servers])
// Force refresh tools when entering server detail view to detect stale schemas
useEffect(() => {
if (selectedServerId) {
forceRefreshTools(workspaceId)
@@ -717,7 +715,6 @@ export function MCP({ initialServerId }: MCPProps) {
`Refreshed MCP server: ${serverId}, workflows updated: ${result.workflowsUpdated}`
)
// If the active workflow was updated, reload its subblock values from DB
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
if (activeWorkflowId && result.updatedWorkflowIds?.includes(activeWorkflowId)) {
logger.info(`Active workflow ${activeWorkflowId} was updated, reloading subblock values`)

View File

@@ -1,11 +1,11 @@
import {
Building2,
Clock,
Database,
HardDrive,
HeadphonesIcon,
Server,
ShieldCheck,
Timer,
Users,
Zap,
} from 'lucide-react'
@@ -15,8 +15,8 @@ import type { PlanFeature } from '@/app/workspace/[workspaceId]/w/components/sid
export const PRO_PLAN_FEATURES: PlanFeature[] = [
{ icon: Zap, text: '150 runs per minute (sync)' },
{ icon: Clock, text: '1,000 runs per minute (async)' },
{ icon: Timer, text: '60 min sync execution limit' },
{ icon: HardDrive, text: '50GB file storage' },
{ icon: Building2, text: 'Unlimited workspaces' },
{ icon: Users, text: 'Unlimited invites' },
{ icon: Database, text: 'Unlimited log retention' },
]
@@ -24,8 +24,8 @@ export const PRO_PLAN_FEATURES: PlanFeature[] = [
export const TEAM_PLAN_FEATURES: PlanFeature[] = [
{ icon: Zap, text: '300 runs per minute (sync)' },
{ icon: Clock, text: '2,500 runs per minute (async)' },
{ icon: Timer, text: '60 min sync execution limit' },
{ icon: HardDrive, text: '500GB file storage (pooled)' },
{ icon: Building2, text: 'Unlimited workspaces' },
{ icon: Users, text: 'Unlimited invites' },
{ icon: Database, text: 'Unlimited log retention' },
{ icon: SlackMonoIcon, text: 'Dedicated Slack channel' },

View File

@@ -41,7 +41,6 @@ import { getEnv, isTruthy } from '@/lib/core/config/env'
import { isHosted } from '@/lib/core/config/feature-flags'
import { getUserRole } from '@/lib/workspaces/organization'
import {
AccessControl,
ApiKeys,
BYOK,
Copilot,
@@ -53,15 +52,16 @@ import {
General,
Integrations,
MCP,
SSO,
Subscription,
TeamManagement,
WorkflowMcpServers,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components'
import { TemplateProfile } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/template-profile/template-profile'
import { AccessControl } from '@/ee/access-control/components/access-control'
import { SSO } from '@/ee/sso/components/sso-settings'
import { ssoKeys, useSSOProviders } from '@/ee/sso/hooks/sso'
import { generalSettingsKeys, useGeneralSettings } from '@/hooks/queries/general-settings'
import { organizationKeys, useOrganizations } from '@/hooks/queries/organization'
import { ssoKeys, useSSOProviders } from '@/hooks/queries/sso'
import { subscriptionKeys, useSubscriptionData } from '@/hooks/queries/subscription'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useSettingsModalStore } from '@/stores/modals/settings/store'

View File

@@ -19,6 +19,7 @@ import { checkUsageStatus } from '@/lib/billing/calculations/usage-monitor'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { RateLimiter } from '@/lib/core/rate-limiter'
import { decryptSecret } from '@/lib/core/security/encryption'
import { formatDuration } from '@/lib/core/utils/formatting'
import { getBaseUrl } from '@/lib/core/utils/urls'
import type { TraceSpan, WorkflowExecutionLog } from '@/lib/logs/types'
import { sendEmail } from '@/lib/messaging/email/mailer'
@@ -227,12 +228,6 @@ async function deliverWebhook(
}
}
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`
return `${(ms / 60000).toFixed(1)}m`
}
function formatCost(cost?: Record<string, unknown>): string {
if (!cost?.total) return 'N/A'
const total = cost.total as number
@@ -302,7 +297,7 @@ async function deliverEmail(
workflowName: payload.data.workflowName || 'Unknown Workflow',
status: payload.data.status,
trigger: payload.data.trigger,
duration: formatDuration(payload.data.totalDurationMs),
duration: formatDuration(payload.data.totalDurationMs, { precision: 1 }) ?? '-',
cost: formatCost(payload.data.cost),
logUrl,
alertReason,
@@ -315,7 +310,7 @@ async function deliverEmail(
to: subscription.emailRecipients,
subject,
html,
text: `${subject}\n${alertReason ? `\nReason: ${alertReason}\n` : ''}\nWorkflow: ${payload.data.workflowName}\nStatus: ${statusText}\nTrigger: ${payload.data.trigger}\nDuration: ${formatDuration(payload.data.totalDurationMs)}\nCost: ${formatCost(payload.data.cost)}\n\nView Log: ${logUrl}${includedDataText}`,
text: `${subject}\n${alertReason ? `\nReason: ${alertReason}\n` : ''}\nWorkflow: ${payload.data.workflowName}\nStatus: ${statusText}\nTrigger: ${payload.data.trigger}\nDuration: ${formatDuration(payload.data.totalDurationMs, { precision: 1 }) ?? '-'}\nCost: ${formatCost(payload.data.cost)}\n\nView Log: ${logUrl}${includedDataText}`,
emailType: 'notifications',
})
@@ -373,7 +368,10 @@ async function deliverSlack(
fields: [
{ type: 'mrkdwn', text: `*Status:*\n${payload.data.status}` },
{ type: 'mrkdwn', text: `*Trigger:*\n${payload.data.trigger}` },
{ type: 'mrkdwn', text: `*Duration:*\n${formatDuration(payload.data.totalDurationMs)}` },
{
type: 'mrkdwn',
text: `*Duration:*\n${formatDuration(payload.data.totalDurationMs, { precision: 1 }) ?? '-'}`,
},
{ type: 'mrkdwn', text: `*Cost:*\n${formatCost(payload.data.cost)}` },
],
},

View File

@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import type { ToolCallGroup, ToolCallState } from '@/lib/copilot/types'
import { cn } from '@/lib/core/utils/cn'
import { formatDuration } from '@/lib/core/utils/formatting'
interface ToolCallProps {
toolCall: ToolCallState
@@ -225,11 +226,6 @@ export function ToolCallCompletion({ toolCall, isCompact = false }: ToolCallProp
const isError = toolCall.state === 'error'
const isAborted = toolCall.state === 'aborted'
const formatDuration = (duration?: number) => {
if (!duration) return ''
return duration < 1000 ? `${duration}ms` : `${(duration / 1000).toFixed(1)}s`
}
return (
<div
className={cn(
@@ -279,7 +275,7 @@ export function ToolCallCompletion({ toolCall, isCompact = false }: ToolCallProp
)}
style={{ fontSize: '0.625rem' }}
>
{formatDuration(toolCall.duration)}
{toolCall.duration ? formatDuration(toolCall.duration, { precision: 1 }) : ''}
</Badge>
)}
</div>

43
apps/sim/ee/LICENSE Normal file
View File

@@ -0,0 +1,43 @@
Sim Enterprise License
Copyright (c) 2025-present Sim Studio, Inc.
This software and associated documentation files (the "Software") are licensed
under the following terms:
1. LICENSE GRANT
Subject to the terms of this license, Sim Studio, Inc. grants you a limited,
non-exclusive, non-transferable license to use the Software for:
- Development, testing, and evaluation purposes
- Internal non-production use
Production use of the Software requires a valid Sim Enterprise subscription.
2. RESTRICTIONS
You may not:
- Use the Software in production without a valid Enterprise subscription
- Modify, adapt, or create derivative works of the Software
- Redistribute, sublicense, or transfer the Software
- Remove or alter any proprietary notices in the Software
3. ENTERPRISE SUBSCRIPTION
Production deployment of enterprise features requires an active Sim Enterprise
subscription. Contact sales@simstudio.ai for licensing information.
4. DISCLAIMER
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
5. LIMITATION OF LIABILITY
IN NO EVENT SHALL SIM STUDIO, INC. BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY ARISING FROM THE USE OF THE SOFTWARE.
For questions about enterprise licensing, contact: sales@simstudio.ai

21
apps/sim/ee/README.md Normal file
View File

@@ -0,0 +1,21 @@
# Sim Enterprise Edition
This directory contains enterprise features that require a Sim Enterprise subscription
for production use.
## Features
- **SSO (Single Sign-On)**: OIDC and SAML authentication integration
- **Access Control**: Permission groups for fine-grained user access management
- **Credential Sets**: Shared credential pools for email polling workflows
## Licensing
See [LICENSE](./LICENSE) for terms. Development and testing use is permitted.
Production deployment requires an active Enterprise subscription.
## Architecture
Enterprise features are imported directly throughout the codebase. The `ee/` directory
is required at build time. Feature visibility is controlled at runtime via environment
variables (e.g., `NEXT_PUBLIC_ACCESS_CONTROL_ENABLED`, `NEXT_PUBLIC_SSO_ENABLED`).

View File

@@ -29,7 +29,6 @@ import type { PermissionGroupConfig } from '@/lib/permission-groups/types'
import { getUserColor } from '@/lib/workspaces/colors'
import { getUserRole } from '@/lib/workspaces/organization'
import { getAllBlocks } from '@/blocks'
import { useOrganization, useOrganizations } from '@/hooks/queries/organization'
import {
type PermissionGroup,
useBulkAddPermissionGroupMembers,
@@ -39,7 +38,8 @@ import {
usePermissionGroups,
useRemovePermissionGroupMember,
useUpdatePermissionGroup,
} from '@/hooks/queries/permission-groups'
} from '@/ee/access-control/hooks/permission-groups'
import { useOrganization, useOrganizations } from '@/hooks/queries/organization'
import { useSubscriptionData } from '@/hooks/queries/subscription'
import { PROVIDER_DEFINITIONS } from '@/providers/models'
import { getAllProviderIds } from '@/providers/utils'
@@ -255,7 +255,6 @@ export function AccessControl() {
queryEnabled
)
// Show loading while dependencies load, or while permission groups query is pending
const isLoading = orgsLoading || subLoading || (queryEnabled && groupsLoading)
const { data: organization } = useOrganization(activeOrganization?.id || '')
@@ -410,10 +409,8 @@ export function AccessControl() {
}, [viewingGroup, editingConfig])
const allBlocks = useMemo(() => {
// Filter out hidden blocks and start_trigger (which should never be disabled)
const blocks = getAllBlocks().filter((b) => !b.hideFromToolbar && b.type !== 'start_trigger')
return blocks.sort((a, b) => {
// Group by category: triggers first, then blocks, then tools
const categoryOrder = { triggers: 0, blocks: 1, tools: 2 }
const catA = categoryOrder[a.category] ?? 3
const catB = categoryOrder[b.category] ?? 3
@@ -555,10 +552,9 @@ export function AccessControl() {
}, [viewingGroup, editingConfig, activeOrganization?.id, updatePermissionGroup])
const handleOpenAddMembersModal = useCallback(() => {
const existingMemberUserIds = new Set(members.map((m) => m.userId))
setSelectedMemberIds(new Set())
setShowAddMembersModal(true)
}, [members])
}, [])
const handleAddSelectedMembers = useCallback(async () => {
if (!viewingGroup || selectedMemberIds.size === 0) return
@@ -891,7 +887,6 @@ export function AccessControl() {
prev
? {
...prev,
// When deselecting all, keep start_trigger allowed (it should never be disabled)
allowedIntegrations: allAllowed ? ['start_trigger'] : null,
}
: prev

View File

@@ -1,3 +1,5 @@
'use client'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import type { PermissionGroupConfig } from '@/lib/permission-groups/types'
import { fetchJson } from '@/hooks/selectors/helpers'

View File

@@ -11,55 +11,13 @@ import { isBillingEnabled } from '@/lib/core/config/feature-flags'
import { cn } from '@/lib/core/utils/cn'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { getUserRole } from '@/lib/workspaces/organization/utils'
import { SSO_TRUSTED_PROVIDERS } from '@/ee/sso/constants'
import { useConfigureSSO, useSSOProviders } from '@/ee/sso/hooks/sso'
import { useOrganizations } from '@/hooks/queries/organization'
import { useConfigureSSO, useSSOProviders } from '@/hooks/queries/sso'
import { useSubscriptionData } from '@/hooks/queries/subscription'
const logger = createLogger('SSO')
const TRUSTED_SSO_PROVIDERS = [
'okta',
'okta-saml',
'okta-prod',
'okta-dev',
'okta-staging',
'okta-test',
'azure-ad',
'azure-active-directory',
'azure-corp',
'azure-enterprise',
'adfs',
'adfs-company',
'adfs-corp',
'adfs-enterprise',
'auth0',
'auth0-prod',
'auth0-dev',
'auth0-staging',
'onelogin',
'onelogin-prod',
'onelogin-corp',
'jumpcloud',
'jumpcloud-prod',
'jumpcloud-corp',
'ping-identity',
'ping-federate',
'pingone',
'shibboleth',
'shibboleth-idp',
'google-workspace',
'google-sso',
'saml',
'saml2',
'saml-sso',
'oidc',
'oidc-sso',
'openid-connect',
'custom-sso',
'enterprise-sso',
'company-sso',
]
interface SSOProvider {
id: string
providerId: string
@@ -565,7 +523,7 @@ export function SSO() {
<Combobox
value={formData.providerId}
onChange={(value: string) => handleInputChange('providerId', value)}
options={TRUSTED_SSO_PROVIDERS.map((id) => ({
options={SSO_TRUSTED_PROVIDERS.map((id) => ({
label: id,
value: id,
}))}

View File

@@ -1,3 +1,7 @@
/**
* List of trusted SSO provider identifiers.
* Used for validation and autocomplete in SSO configuration.
*/
export const SSO_TRUSTED_PROVIDERS = [
'okta',
'okta-saml',

View File

@@ -1,3 +1,5 @@
'use client'
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { organizationKeys } from '@/hooks/queries/organization'
@@ -75,39 +77,3 @@ export function useConfigureSSO() {
},
})
}
/**
* Delete SSO provider mutation
*/
interface DeleteSSOParams {
providerId: string
orgId?: string
}
export function useDeleteSSO() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ providerId }: DeleteSSOParams) => {
const response = await fetch(`/api/auth/sso/providers/${providerId}`, {
method: 'DELETE',
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.message || 'Failed to delete SSO provider')
}
return response.json()
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ssoKeys.providers() })
if (variables.orgId) {
queryClient.invalidateQueries({
queryKey: organizationKeys.detail(variables.orgId),
})
}
},
})
}

View File

@@ -185,16 +185,10 @@ export const HTTP = {
},
} as const
import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
export const AGENT = {
DEFAULT_MODEL: 'claude-sonnet-4-5',
get DEFAULT_FUNCTION_TIMEOUT() {
return getMaxExecutionTimeout()
},
get REQUEST_TIMEOUT() {
return getMaxExecutionTimeout()
},
DEFAULT_FUNCTION_TIMEOUT: 600000,
REQUEST_TIMEOUT: 600000,
CUSTOM_TOOL_PREFIX: 'custom_',
} as const

View File

@@ -5,6 +5,7 @@ import {
hydrateUserFilesWithBase64,
} from '@/lib/uploads/utils/user-file-base64.server'
import { sanitizeInputFormat, sanitizeTools } from '@/lib/workflows/comparison/normalize'
import { validateBlockType } from '@/ee/access-control/utils/permission-check'
import {
BlockType,
buildResumeApiUrl,
@@ -31,7 +32,6 @@ import { streamingResponseFormatProcessor } from '@/executor/utils'
import { buildBlockExecutionError, normalizeError } from '@/executor/utils/errors'
import { isJSONString } from '@/executor/utils/json'
import { filterOutputForLog } from '@/executor/utils/output-filter'
import { validateBlockType } from '@/executor/utils/permission-check'
import type { VariableResolver } from '@/executor/variables/resolver'
import type { SerializedBlock } from '@/serializer/types'
import type { SubflowType } from '@/stores/workflows/workflow/types'

View File

@@ -6,6 +6,12 @@ import { createMcpToolId } from '@/lib/mcp/utils'
import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { getAllBlocks } from '@/blocks'
import type { BlockOutput } from '@/blocks/types'
import {
validateBlockType,
validateCustomToolsAllowed,
validateMcpToolsAllowed,
validateModelProvider,
} from '@/ee/access-control/utils/permission-check'
import { AGENT, BlockType, DEFAULTS, REFERENCE, stripCustomToolPrefix } from '@/executor/constants'
import { memoryService } from '@/executor/handlers/agent/memory'
import type {
@@ -18,12 +24,6 @@ import type { BlockHandler, ExecutionContext, StreamingExecution } from '@/execu
import { collectBlockData } from '@/executor/utils/block-data'
import { buildAPIUrl, buildAuthHeaders } from '@/executor/utils/http'
import { stringifyJSON } from '@/executor/utils/json'
import {
validateBlockType,
validateCustomToolsAllowed,
validateMcpToolsAllowed,
validateModelProvider,
} from '@/executor/utils/permission-check'
import { executeProviderRequest } from '@/providers'
import { getProviderFromModel, transformBlockTool } from '@/providers/utils'
import type { SerializedBlock } from '@/serializer/types'

View File

@@ -4,11 +4,11 @@ import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import type { BlockOutput } from '@/blocks/types'
import { validateModelProvider } from '@/ee/access-control/utils/permission-check'
import { BlockType, DEFAULTS, EVALUATOR } from '@/executor/constants'
import type { BlockHandler, ExecutionContext } from '@/executor/types'
import { buildAPIUrl, buildAuthHeaders, extractAPIErrorMessage } from '@/executor/utils/http'
import { isJSONString, parseJSON, stringifyJSON } from '@/executor/utils/json'
import { validateModelProvider } from '@/executor/utils/permission-check'
import { calculateCost, getProviderFromModel } from '@/providers/utils'
import type { SerializedBlock } from '@/serializer/types'

View File

@@ -6,6 +6,7 @@ import { getBaseUrl } from '@/lib/core/utils/urls'
import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { generateRouterPrompt, generateRouterV2Prompt } from '@/blocks/blocks/router'
import type { BlockOutput } from '@/blocks/types'
import { validateModelProvider } from '@/ee/access-control/utils/permission-check'
import {
BlockType,
DEFAULTS,
@@ -15,7 +16,6 @@ import {
} from '@/executor/constants'
import type { BlockHandler, ExecutionContext } from '@/executor/types'
import { buildAuthHeaders } from '@/executor/utils/http'
import { validateModelProvider } from '@/executor/utils/permission-check'
import { calculateCost, getProviderFromModel } from '@/providers/utils'
import type { SerializedBlock } from '@/serializer/types'

View File

@@ -14,7 +14,7 @@ const sleep = async (ms: number, options: SleepOptions = {}): Promise<boolean> =
const { signal, executionId } = options
const useRedis = isRedisCancellationEnabled() && !!executionId
if (signal?.aborted) {
if (!useRedis && signal?.aborted) {
return false
}
@@ -27,7 +27,7 @@ const sleep = async (ms: number, options: SleepOptions = {}): Promise<boolean> =
const cleanup = () => {
if (mainTimeoutId) clearTimeout(mainTimeoutId)
if (checkIntervalId) clearInterval(checkIntervalId)
if (signal) signal.removeEventListener('abort', onAbort)
if (!useRedis && signal) signal.removeEventListener('abort', onAbort)
}
const onAbort = () => {
@@ -37,10 +37,6 @@ const sleep = async (ms: number, options: SleepOptions = {}): Promise<boolean> =
resolve(false)
}
if (signal) {
signal.addEventListener('abort', onAbort, { once: true })
}
if (useRedis) {
checkIntervalId = setInterval(async () => {
if (resolved) return
@@ -53,6 +49,8 @@ const sleep = async (ms: number, options: SleepOptions = {}): Promise<boolean> =
}
} catch {}
}, CANCELLATION_CHECK_INTERVAL_MS)
} else if (signal) {
signal.addEventListener('abort', onAbort, { once: true })
}
mainTimeoutId = setTimeout(() => {

View File

@@ -1,3 +1,5 @@
'use client'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { fetchJson } from '@/hooks/selectors/helpers'

View File

@@ -1,3 +1,5 @@
'use client'
import { useMemo } from 'react'
import { getEnv, isTruthy } from '@/lib/core/config/env'
import { isAccessControlEnabled, isHosted } from '@/lib/core/config/feature-flags'
@@ -5,8 +7,8 @@ import {
DEFAULT_PERMISSION_GROUP_CONFIG,
type PermissionGroupConfig,
} from '@/lib/permission-groups/types'
import { useUserPermissionConfig } from '@/ee/access-control/hooks/permission-groups'
import { useOrganizations } from '@/hooks/queries/organization'
import { useUserPermissionConfig } from '@/hooks/queries/permission-groups'
export interface PermissionConfigResult {
config: PermissionGroupConfig

View File

@@ -1,10 +1,7 @@
export { AGENT_CARD_PATH } from '@a2a-js/sdk'
import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/core/execution-limits'
export const A2A_PROTOCOL_VERSION = '0.3.0'
export const A2A_DEFAULT_TIMEOUT = DEFAULT_EXECUTION_TIMEOUT_MS
export const A2A_DEFAULT_TIMEOUT = 300000
/**
* Maximum number of messages stored per task in the database.

View File

@@ -59,8 +59,8 @@ import { sendEmail } from '@/lib/messaging/email/mailer'
import { getFromEmailAddress, getPersonalEmailFrom } from '@/lib/messaging/email/utils'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'
import { SSO_TRUSTED_PROVIDERS } from '@/ee/sso/constants'
import { createAnonymousSession, ensureAnonymousUserExists } from './anonymous'
import { SSO_TRUSTED_PROVIDERS } from './sso/constants'
const logger = createLogger('Auth')

View File

@@ -5,8 +5,8 @@ import { and, eq, isNull } from 'drizzle-orm'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer'
import { isHiddenFromDisplay } from '@/blocks/types'
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
import { escapeRegExp } from '@/executor/constants'
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
import type { ChatContext } from '@/stores/panel/copilot/types'
export type AgentContextType =

View File

@@ -5,9 +5,11 @@ import type { ToolUIConfig } from './ui-config'
const baseToolLogger = createLogger('BaseClientTool')
const DEFAULT_TOOL_TIMEOUT_MS = 5 * 60 * 1000
/** Default timeout for tool execution (5 minutes) */
const DEFAULT_TOOL_TIMEOUT_MS = 2 * 60 * 1000
export const WORKFLOW_EXECUTION_TIMEOUT_MS = 5 * 60 * 1000
/** Timeout for tools that run workflows (10 minutes) */
export const WORKFLOW_EXECUTION_TIMEOUT_MS = 10 * 60 * 1000
// Client tool call states used by the new runtime
export enum ClientToolCallState {

View File

@@ -7,7 +7,7 @@ import {
} from '@/lib/copilot/tools/shared/schemas'
import { registry as blockRegistry, getLatestBlock } from '@/blocks/registry'
import { isHiddenFromDisplay, type SubBlockConfig } from '@/blocks/types'
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
import { PROVIDER_DEFINITIONS } from '@/providers/models'
import { tools as toolsRegistry } from '@/tools/registry'
import { getTrigger, isTriggerValid } from '@/triggers'

View File

@@ -6,7 +6,7 @@ import {
type GetBlockOptionsResultType,
} from '@/lib/copilot/tools/shared/schemas'
import { registry as blockRegistry, getLatestBlock } from '@/blocks/registry'
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
import { tools as toolsRegistry } from '@/tools/registry'
export const getBlockOptionsServerTool: BaseServerTool<

View File

@@ -6,7 +6,7 @@ import {
} from '@/lib/copilot/tools/shared/schemas'
import { registry as blockRegistry } from '@/blocks/registry'
import type { BlockConfig } from '@/blocks/types'
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
export const getBlocksAndToolsServerTool: BaseServerTool<
ReturnType<typeof GetBlocksAndToolsInput.parse>,

View File

@@ -8,7 +8,7 @@ import {
} from '@/lib/copilot/tools/shared/schemas'
import { registry as blockRegistry } from '@/blocks/registry'
import { AuthMode, type BlockConfig, isHiddenFromDisplay } from '@/blocks/types'
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
import { PROVIDER_DEFINITIONS } from '@/providers/models'
import { tools as toolsRegistry } from '@/tools/registry'
import { getTrigger, isTriggerValid } from '@/triggers'

View File

@@ -3,7 +3,7 @@ import { z } from 'zod'
import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool'
import { registry as blockRegistry } from '@/blocks/registry'
import type { BlockConfig } from '@/blocks/types'
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
export const GetTriggerBlocksInput = z.object({})
export const GetTriggerBlocksResult = z.object({

View File

@@ -15,8 +15,8 @@ import { buildCanonicalIndex, isCanonicalPair } from '@/lib/workflows/subblocks/
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
import { getAllBlocks, getBlock } from '@/blocks/registry'
import type { BlockConfig, SubBlockConfig } from '@/blocks/types'
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
import { EDGE, normalizeName, RESERVED_BLOCK_NAMES } from '@/executor/constants'
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
import { TRIGGER_RUNTIME_SUBBLOCK_IDS } from '@/triggers/constants'

View File

@@ -170,11 +170,6 @@ export const env = createEnv({
RATE_LIMIT_ENTERPRISE_SYNC: z.string().optional().default('600'), // Enterprise tier sync API executions per minute
RATE_LIMIT_ENTERPRISE_ASYNC: z.string().optional().default('5000'), // Enterprise tier async API executions per minute
EXECUTION_TIMEOUT_FREE: z.string().optional().default('300'),
EXECUTION_TIMEOUT_PRO: z.string().optional().default('3600'),
EXECUTION_TIMEOUT_TEAM: z.string().optional().default('3600'),
EXECUTION_TIMEOUT_ENTERPRISE: z.string().optional().default('3600'),
// Knowledge Base Processing Configuration - Shared across all processing methods
KB_CONFIG_MAX_DURATION: z.number().optional().default(600), // Max processing duration in seconds (10 minutes)
KB_CONFIG_MAX_ATTEMPTS: z.number().optional().default(3), // Max retry attempts

View File

@@ -1 +0,0 @@
export * from './types'

View File

@@ -1,122 +0,0 @@
import { env } from '@/lib/core/config/env'
import type { SubscriptionPlan } from '@/lib/core/rate-limiter/types'
export interface ExecutionTimeoutConfig {
sync: number
async: number
}
const DEFAULT_SYNC_TIMEOUTS = {
free: 300,
pro: 3600,
team: 3600,
enterprise: 3600,
} as const
const ASYNC_TIMEOUT_SECONDS = 5400
function getSyncTimeoutForPlan(plan: SubscriptionPlan): number {
const envVarMap: Record<SubscriptionPlan, string | undefined> = {
free: env.EXECUTION_TIMEOUT_FREE,
pro: env.EXECUTION_TIMEOUT_PRO,
team: env.EXECUTION_TIMEOUT_TEAM,
enterprise: env.EXECUTION_TIMEOUT_ENTERPRISE,
}
return (Number.parseInt(envVarMap[plan] || '') || DEFAULT_SYNC_TIMEOUTS[plan]) * 1000
}
export const EXECUTION_TIMEOUTS: Record<SubscriptionPlan, ExecutionTimeoutConfig> = {
free: {
sync: getSyncTimeoutForPlan('free'),
async: ASYNC_TIMEOUT_SECONDS * 1000,
},
pro: {
sync: getSyncTimeoutForPlan('pro'),
async: ASYNC_TIMEOUT_SECONDS * 1000,
},
team: {
sync: getSyncTimeoutForPlan('team'),
async: ASYNC_TIMEOUT_SECONDS * 1000,
},
enterprise: {
sync: getSyncTimeoutForPlan('enterprise'),
async: ASYNC_TIMEOUT_SECONDS * 1000,
},
}
export function getExecutionTimeout(
plan: SubscriptionPlan | undefined,
type: 'sync' | 'async' = 'sync'
): number {
return EXECUTION_TIMEOUTS[plan || 'free'][type]
}
export function getExecutionTimeoutSeconds(
plan: SubscriptionPlan | undefined,
type: 'sync' | 'async' = 'sync'
): number {
return Math.floor(getExecutionTimeout(plan, type) / 1000)
}
export function getMaxExecutionTimeout(): number {
return EXECUTION_TIMEOUTS.enterprise.async
}
export const DEFAULT_EXECUTION_TIMEOUT_MS = EXECUTION_TIMEOUTS.free.sync
export class ExecutionTimeoutError extends Error {
constructor(
public readonly timeoutMs: number,
public readonly plan?: SubscriptionPlan
) {
const timeoutSeconds = Math.floor(timeoutMs / 1000)
const timeoutMinutes = Math.floor(timeoutSeconds / 60)
const displayTime =
timeoutMinutes > 0
? `${timeoutMinutes} minute${timeoutMinutes > 1 ? 's' : ''}`
: `${timeoutSeconds} seconds`
super(`Execution timed out after ${displayTime}`)
this.name = 'ExecutionTimeoutError'
}
}
export function isTimeoutError(error: unknown): boolean {
if (error instanceof ExecutionTimeoutError) return true
if (!(error instanceof Error)) return false
const name = error.name.toLowerCase()
const message = error.message.toLowerCase()
return (
name === 'timeouterror' ||
name === 'aborterror' ||
message.includes('timeout') ||
message.includes('timed out') ||
message.includes('aborted')
)
}
export function createTimeoutError(
timeoutMs: number,
plan?: SubscriptionPlan
): ExecutionTimeoutError {
return new ExecutionTimeoutError(timeoutMs, plan)
}
export function getTimeoutErrorMessage(error: unknown, timeoutMs?: number): string {
if (error instanceof ExecutionTimeoutError) {
return error.message
}
if (timeoutMs) {
const timeoutSeconds = Math.floor(timeoutMs / 1000)
const timeoutMinutes = Math.floor(timeoutSeconds / 60)
const displayTime =
timeoutMinutes > 0
? `${timeoutMinutes} minute${timeoutMinutes > 1 ? 's' : ''}`
: `${timeoutSeconds} seconds`
return `Execution timed out after ${displayTime}`
}
return 'Execution timed out'
}

View File

@@ -153,22 +153,50 @@ export function formatCompactTimestamp(iso: string): string {
}
/**
* Format a duration in milliseconds to a human-readable format
* @param durationMs - The duration in milliseconds
* Format a duration to a human-readable format
* @param duration - Duration in milliseconds (number) or as string (e.g., "500ms")
* @param options - Optional formatting options
* @param options.precision - Number of decimal places for seconds (default: 0)
* @returns A formatted duration string
* @param options.precision - Number of decimal places for seconds (default: 0), trailing zeros are stripped
* @returns A formatted duration string, or null if input is null/undefined
*/
export function formatDuration(durationMs: number, options?: { precision?: number }): string {
const precision = options?.precision ?? 0
if (durationMs < 1000) {
return `${durationMs}ms`
export function formatDuration(
duration: number | string | undefined | null,
options?: { precision?: number }
): string | null {
if (duration === undefined || duration === null) {
return null
}
const seconds = durationMs / 1000
// Parse string durations (e.g., "500ms", "0.44ms", "1234")
let ms: number
if (typeof duration === 'string') {
ms = Number.parseFloat(duration.replace(/[^0-9.-]/g, ''))
if (!Number.isFinite(ms)) {
return duration
}
} else {
ms = duration
}
const precision = options?.precision ?? 0
if (ms < 1) {
// Sub-millisecond: show with 2 decimal places
return `${ms.toFixed(2)}ms`
}
if (ms < 1000) {
// Milliseconds: round to integer
return `${Math.round(ms)}ms`
}
const seconds = ms / 1000
if (seconds < 60) {
return precision > 0 ? `${seconds.toFixed(precision)}s` : `${Math.floor(seconds)}s`
if (precision > 0) {
// Strip trailing zeros (e.g., "5.00s" -> "5s", "5.10s" -> "5.1s")
return `${seconds.toFixed(precision).replace(/\.?0+$/, '')}s`
}
return `${Math.floor(seconds)}s`
}
const minutes = Math.floor(seconds / 60)

View File

@@ -1,3 +1,7 @@
import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/core/execution-limits'
/**
* Execution timeout constants
*
* DEFAULT_EXECUTION_TIMEOUT_MS: The default timeout for executing user code (10 minutes)
*/
export { DEFAULT_EXECUTION_TIMEOUT_MS }
export const DEFAULT_EXECUTION_TIMEOUT_MS = 600000 // 10 minutes (600 seconds)

View File

@@ -4,9 +4,7 @@ import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { checkServerSideUsageLimits } from '@/lib/billing/calculations/usage-monitor'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { getExecutionTimeout } from '@/lib/core/execution-limits'
import { RateLimiter } from '@/lib/core/rate-limiter/rate-limiter'
import type { SubscriptionPlan } from '@/lib/core/rate-limiter/types'
import { LoggingSession } from '@/lib/logs/execution/logging-session'
import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils'
import type { CoreTriggerType } from '@/stores/logs/filters/types'
@@ -135,10 +133,10 @@ export interface PreprocessExecutionResult {
success: boolean
error?: {
message: string
statusCode: number
logCreated: boolean
statusCode: number // HTTP status code (401, 402, 403, 404, 429, 500)
logCreated: boolean // Whether error was logged to execution_logs
}
actorUserId?: string
actorUserId?: string // The user ID that will be billed
workflowRecord?: WorkflowRecord
userSubscription?: SubscriptionInfo | null
rateLimitInfo?: {
@@ -146,10 +144,6 @@ export interface PreprocessExecutionResult {
remaining: number
resetAt: Date
}
executionTimeout?: {
sync: number
async: number
}
}
type WorkflowRecord = typeof workflow.$inferSelect
@@ -490,17 +484,12 @@ export async function preprocessExecution(
triggerType,
})
const plan = userSubscription?.plan as SubscriptionPlan | undefined
return {
success: true,
actorUserId,
workflowRecord,
userSubscription,
rateLimitInfo,
executionTimeout: {
sync: getExecutionTimeout(plan, 'sync'),
async: getExecutionTimeout(plan, 'async'),
},
}
}

View File

@@ -776,16 +776,11 @@ export class LoggingSession {
await db
.update(workflowExecutionLogs)
.set({
level: 'error',
status: 'failed',
executionData: sql`jsonb_set(
jsonb_set(
COALESCE(execution_data, '{}'::jsonb),
ARRAY['error'],
to_jsonb(${message}::text)
),
ARRAY['finalOutput'],
jsonb_build_object('error', ${message}::text)
COALESCE(execution_data, '{}'::jsonb),
ARRAY['error'],
to_jsonb(${message}::text)
)`,
})
.where(eq(workflowExecutionLogs.executionId, executionId))

View File

@@ -12,7 +12,6 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
import type { ListToolsResult, Tool } from '@modelcontextprotocol/sdk/types.js'
import { createLogger } from '@sim/logger'
import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
import {
McpConnectionError,
type McpConnectionStatus,
@@ -203,7 +202,7 @@ export class McpClient {
const sdkResult = await this.client.callTool(
{ name: toolCall.name, arguments: toolCall.arguments },
undefined,
{ timeout: getMaxExecutionTimeout() }
{ timeout: 600000 } // 10 minutes - override SDK's 60s default
)
return sdkResult as McpToolResult

View File

@@ -34,7 +34,7 @@ export function sanitizeHeaders(
* Client-safe MCP constants
*/
export const MCP_CLIENT_CONSTANTS = {
CLIENT_TIMEOUT: 5 * 60 * 1000,
CLIENT_TIMEOUT: 600000,
MAX_RETRIES: 3,
RECONNECT_DELAY: 1000,
} as const

View File

@@ -81,8 +81,8 @@ describe('generateMcpServerId', () => {
})
describe('MCP_CONSTANTS', () => {
it.concurrent('has correct execution timeout (5 minutes)', () => {
expect(MCP_CONSTANTS.EXECUTION_TIMEOUT).toBe(300000)
it.concurrent('has correct execution timeout (10 minutes)', () => {
expect(MCP_CONSTANTS.EXECUTION_TIMEOUT).toBe(600000)
})
it.concurrent('has correct cache timeout (5 minutes)', () => {
@@ -107,8 +107,8 @@ describe('MCP_CONSTANTS', () => {
})
describe('MCP_CLIENT_CONSTANTS', () => {
it.concurrent('has correct client timeout (5 minutes)', () => {
expect(MCP_CLIENT_CONSTANTS.CLIENT_TIMEOUT).toBe(300000)
it.concurrent('has correct client timeout (10 minutes)', () => {
expect(MCP_CLIENT_CONSTANTS.CLIENT_TIMEOUT).toBe(600000)
})
it.concurrent('has correct auto refresh interval (5 minutes)', () => {

View File

@@ -1,11 +1,12 @@
import { NextResponse } from 'next/server'
import { DEFAULT_EXECUTION_TIMEOUT_MS, getExecutionTimeout } from '@/lib/core/execution-limits'
import type { SubscriptionPlan } from '@/lib/core/rate-limiter/types'
import type { McpApiResponse } from '@/lib/mcp/types'
import { isMcpTool, MCP } from '@/executor/constants'
/**
* MCP-specific constants
*/
export const MCP_CONSTANTS = {
EXECUTION_TIMEOUT: DEFAULT_EXECUTION_TIMEOUT_MS,
EXECUTION_TIMEOUT: 600000,
CACHE_TIMEOUT: 5 * 60 * 1000,
DEFAULT_RETRIES: 3,
DEFAULT_CONNECTION_TIMEOUT: 30000,
@@ -13,10 +14,6 @@ export const MCP_CONSTANTS = {
MAX_CONSECUTIVE_FAILURES: 3,
} as const
export function getMcpExecutionTimeout(plan?: SubscriptionPlan): number {
return getExecutionTimeout(plan, 'sync')
}
/**
* Core MCP tool parameter keys that are metadata, not user-entered test values.
* These should be preserved when cleaning up params during schema updates.
@@ -48,8 +45,11 @@ export function sanitizeHeaders(
)
}
/**
* Client-safe MCP constants
*/
export const MCP_CLIENT_CONSTANTS = {
CLIENT_TIMEOUT: DEFAULT_EXECUTION_TIMEOUT_MS,
CLIENT_TIMEOUT: 600000,
AUTO_REFRESH_INTERVAL: 5 * 60 * 1000,
} as const

View File

@@ -62,6 +62,9 @@ export interface ExecutionErrorEvent extends BaseExecutionEvent {
}
}
/**
* Execution cancelled event
*/
export interface ExecutionCancelledEvent extends BaseExecutionEvent {
type: 'execution:cancelled'
workflowId: string
@@ -168,6 +171,9 @@ export type ExecutionEvent =
| StreamChunkEvent
| StreamDoneEvent
/**
* Extracted data types for use in callbacks
*/
export type ExecutionStartedData = ExecutionStartedEvent['data']
export type ExecutionCompletedData = ExecutionCompletedEvent['data']
export type ExecutionErrorData = ExecutionErrorEvent['data']

View File

@@ -1,9 +1,8 @@
import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/core/execution-limits'
import type { RunActorParams, RunActorResult } from '@/tools/apify/types'
import type { ToolConfig } from '@/tools/types'
const POLL_INTERVAL_MS = 5000
const MAX_POLL_TIME_MS = DEFAULT_EXECUTION_TIMEOUT_MS
const POLL_INTERVAL_MS = 5000 // 5 seconds between polls
const MAX_POLL_TIME_MS = 300000 // 5 minutes maximum polling time
export const apifyRunActorAsyncTool: ToolConfig<RunActorParams, RunActorResult> = {
id: 'apify_run_actor_async',

View File

@@ -1,12 +1,11 @@
import { createLogger } from '@sim/logger'
import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/core/execution-limits'
import type { BrowserUseRunTaskParams, BrowserUseRunTaskResponse } from '@/tools/browser_use/types'
import type { ToolConfig, ToolResponse } from '@/tools/types'
const logger = createLogger('BrowserUseTool')
const POLL_INTERVAL_MS = 5000
const MAX_POLL_TIME_MS = DEFAULT_EXECUTION_TIMEOUT_MS
const MAX_POLL_TIME_MS = 600000 // 10 minutes
const MAX_CONSECUTIVE_ERRORS = 3
async function createSessionWithProfile(

View File

@@ -1,12 +1,11 @@
import { createLogger } from '@sim/logger'
import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/core/execution-limits'
import type { ExaResearchParams, ExaResearchResponse } from '@/tools/exa/types'
import type { ToolConfig } from '@/tools/types'
const logger = createLogger('ExaResearchTool')
const POLL_INTERVAL_MS = 5000
const MAX_POLL_TIME_MS = DEFAULT_EXECUTION_TIMEOUT_MS
const POLL_INTERVAL_MS = 5000 // 5 seconds between polls
const MAX_POLL_TIME_MS = 300000 // 5 minutes maximum polling time
export const researchTool: ToolConfig<ExaResearchParams, ExaResearchResponse> = {
id: 'exa_research',

View File

@@ -1,12 +1,11 @@
import { createLogger } from '@sim/logger'
import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/core/execution-limits'
import type { AgentParams, AgentResponse } from '@/tools/firecrawl/types'
import type { ToolConfig } from '@/tools/types'
const logger = createLogger('FirecrawlAgentTool')
const POLL_INTERVAL_MS = 5000
const MAX_POLL_TIME_MS = DEFAULT_EXECUTION_TIMEOUT_MS
const POLL_INTERVAL_MS = 5000 // 5 seconds between polls
const MAX_POLL_TIME_MS = 300000 // 5 minutes maximum polling time
export const agentTool: ToolConfig<AgentParams, AgentResponse> = {
id: 'firecrawl_agent',

View File

@@ -1,13 +1,12 @@
import { createLogger } from '@sim/logger'
import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/core/execution-limits'
import type { FirecrawlCrawlParams, FirecrawlCrawlResponse } from '@/tools/firecrawl/types'
import { CRAWLED_PAGE_OUTPUT_PROPERTIES } from '@/tools/firecrawl/types'
import type { ToolConfig } from '@/tools/types'
const logger = createLogger('FirecrawlCrawlTool')
const POLL_INTERVAL_MS = 5000
const MAX_POLL_TIME_MS = DEFAULT_EXECUTION_TIMEOUT_MS
const POLL_INTERVAL_MS = 5000 // 5 seconds between polls
const MAX_POLL_TIME_MS = 300000 // 5 minutes maximum polling time
export const crawlTool: ToolConfig<FirecrawlCrawlParams, FirecrawlCrawlResponse> = {
id: 'firecrawl_crawl',

View File

@@ -1,12 +1,11 @@
import { createLogger } from '@sim/logger'
import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/core/execution-limits'
import type { ExtractParams, ExtractResponse } from '@/tools/firecrawl/types'
import type { ToolConfig } from '@/tools/types'
const logger = createLogger('FirecrawlExtractTool')
const POLL_INTERVAL_MS = 5000
const MAX_POLL_TIME_MS = DEFAULT_EXECUTION_TIMEOUT_MS
const POLL_INTERVAL_MS = 5000 // 5 seconds between polls
const MAX_POLL_TIME_MS = 300000 // 5 minutes maximum polling time
export const extractTool: ToolConfig<ExtractParams, ExtractResponse> = {
id: 'firecrawl_extract',

View File

@@ -1,6 +1,5 @@
import { createLogger } from '@sim/logger'
import { generateInternalToken } from '@/lib/auth/internal'
import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/core/execution-limits'
import { secureFetchWithPinnedIP, validateUrlWithDNS } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { getBaseUrl } from '@/lib/core/utils/urls'
@@ -626,8 +625,9 @@ async function executeToolRequest(
let response: Response
if (isInternalRoute) {
// Set up AbortController for timeout support on internal routes
const controller = new AbortController()
const timeout = requestParams.timeout || DEFAULT_EXECUTION_TIMEOUT_MS
const timeout = requestParams.timeout || 300000
const timeoutId = setTimeout(() => controller.abort(), timeout)
try {

View File

@@ -1,5 +1,4 @@
import { createLogger } from '@sim/logger'
import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { AGENT, isCustomTool } from '@/executor/constants'
import { getCustomTool } from '@/hooks/queries/custom-tools'
@@ -124,7 +123,9 @@ export function formatRequestParams(tool: ToolConfig, params: Record<string, any
}
}
const MAX_TIMEOUT_MS = getMaxExecutionTimeout()
// Get timeout from params (if specified) and validate
// Must be a finite positive number, max 600000ms (10 minutes) as documented
const MAX_TIMEOUT_MS = 600000
const rawTimeout = params.timeout
const timeout = rawTimeout != null ? Number(rawTimeout) : undefined
const validTimeout =

View File

@@ -6,7 +6,7 @@ export default defineConfig({
project: env.TRIGGER_PROJECT_ID!,
runtime: 'node',
logLevel: 'log',
maxDuration: 5400,
maxDuration: 600,
retries: {
enabledInDev: false,
default: {