diff --git a/apps/sim/app/(auth)/sso/page.tsx b/apps/sim/app/(auth)/sso/page.tsx
index 18ff14f90..49bf30f1c 100644
--- a/apps/sim/app/(auth)/sso/page.tsx
+++ b/apps/sim/app/(auth)/sso/page.tsx
@@ -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'
diff --git a/apps/sim/app/api/organizations/[id]/invitations/route.ts b/apps/sim/app/api/organizations/[id]/invitations/route.ts
index 124d70957..905628696 100644
--- a/apps/sim/app/api/organizations/[id]/invitations/route.ts
+++ b/apps/sim/app/api/organizations/[id]/invitations/route.ts
@@ -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')
diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts
index 68b2fa842..a4448e016 100644
--- a/apps/sim/app/api/workflows/[id]/execute/route.ts
+++ b/apps/sim/app/api/workflows/[id]/execute/route.ts
@@ -612,6 +612,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
blockId: string,
blockName: string,
blockType: string,
+ executionOrder: number,
iterationContext?: IterationContext
) => {
logger.info(`[${requestId}] 🔷 onBlockStart called:`, { blockId, blockName, blockType })
@@ -624,6 +625,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
blockId,
blockName,
blockType,
+ executionOrder,
...(iterationContext && {
iterationCurrent: iterationContext.iterationCurrent,
iterationTotal: iterationContext.iterationTotal,
@@ -662,6 +664,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
error: callbackData.output.error,
durationMs: callbackData.executionTime || 0,
startedAt: callbackData.startedAt,
+ executionOrder: callbackData.executionOrder,
endedAt: callbackData.endedAt,
...(iterationContext && {
iterationCurrent: iterationContext.iterationCurrent,
@@ -689,6 +692,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
output: callbackData.output,
durationMs: callbackData.executionTime || 0,
startedAt: callbackData.startedAt,
+ executionOrder: callbackData.executionOrder,
endedAt: callbackData.endedAt,
...(iterationContext && {
iterationCurrent: iterationContext.iterationCurrent,
diff --git a/apps/sim/app/api/workspaces/invitations/route.test.ts b/apps/sim/app/api/workspaces/invitations/route.test.ts
index 202559142..ac3545885 100644
--- a/apps/sim/app/api/workspaces/invitations/route.test.ts
+++ b/apps/sim/app/api/workspaces/invitations/route.test.ts
@@ -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() {
diff --git a/apps/sim/app/api/workspaces/invitations/route.ts b/apps/sim/app/api/workspaces/invitations/route.ts
index bd70b9dc9..e6116d840 100644
--- a/apps/sim/app/api/workspaces/invitations/route.ts
+++ b/apps/sim/app/api/workspaces/invitations/route.ts
@@ -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)
diff --git a/apps/sim/app/chat/[identifier]/chat.tsx b/apps/sim/app/chat/[identifier]/chat.tsx
index 94082ffec..549e450d4 100644
--- a/apps/sim/app/chat/[identifier]/chat.tsx
+++ b/apps/sim/app/chat/[identifier]/chat.tsx
@@ -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')
diff --git a/apps/sim/app/chat/components/index.ts b/apps/sim/app/chat/components/index.ts
index 4be7ea2f1..eef5a82c4 100644
--- a/apps/sim/app/chat/components/index.ts
+++ b/apps/sim/app/chat/components/index.ts
@@ -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'
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/page.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/page.tsx
index a5c1eadeb..a449539d5 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/page.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/page.tsx
@@ -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}`)
diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/file-download/file-download.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/file-download/file-download.tsx
index 74397b9bb..3dd05f8d8 100644
--- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/file-download/file-download.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/file-download/file-download.tsx
@@ -104,14 +104,12 @@ function FileCard({ file, isExecutionFile = false, workspaceId }: FileCardProps)
}
return (
-
-
-
-
- {file.name}
-
-
-
+
+
+
+ {file.name}
+
+
{formatFileSize(file.size)}
@@ -142,20 +140,18 @@ export function FileCards({ files, isExecutionFile = false, workspaceId }: FileC
}
return (
-
+
Files ({files.length})
-
- {files.map((file, index) => (
-
- ))}
-
+ {files.map((file, index) => (
+
+ ))}
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx
index b0f79805a..43aa334e4 100644
--- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx
@@ -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
- {log.duration || '—'}
+ {formatDuration(log.duration, { precision: 2 }) || '—'}
diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-list/logs-list.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-list/logs-list.tsx
index 5a073a04b..c7ae2bf61 100644
--- a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-list/logs-list.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-list/logs-list.tsx
@@ -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(
- {formatDuration(log.duration) || '—'}
+ {formatDuration(log.duration, { precision: 2 }) || '—'}
diff --git a/apps/sim/app/workspace/[workspaceId]/logs/utils.ts b/apps/sim/app/workspace/[workspaceId]/logs/utils.ts
index d21561b97..570262d10 100644
--- a/apps/sim/app/workspace/[workspaceId]/logs/utils.ts
+++ b/apps/sim/app/workspace/[workspaceId]/logs/utils.ts
@@ -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) => {
diff --git a/apps/sim/app/workspace/[workspaceId]/templates/page.tsx b/apps/sim/app/workspace/[workspaceId]/templates/page.tsx
index 9955c2433..8e5194cee 100644
--- a/apps/sim/app/workspace/[workspaceId]/templates/page.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/templates/page.tsx
@@ -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<{
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block/thinking-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block/thinking-block.tsx
index de632ca5f..3c95d83d4 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block/thinking-block.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block/thinking-block.tsx
@@ -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'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx
index d22542375..f6ee0679a 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx
@@ -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 = () => (
<>
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx
index d8c905560..79087c7c4 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx
@@ -45,7 +45,7 @@ export function CredentialSelector({
previewValue,
}: CredentialSelectorProps) {
const [showOAuthModal, setShowOAuthModal] = useState(false)
- const [inputValue, setInputValue] = useState('')
+ const [editingValue, setEditingValue] = useState('')
const [isEditing, setIsEditing] = useState(false)
const { activeWorkflowId } = useWorkflowRegistry()
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id)
@@ -128,11 +128,7 @@ export function CredentialSelector({
return ''
}, [selectedCredentialSet, isForeignCredentialSet, selectedCredential, isForeign])
- useEffect(() => {
- if (!isEditing) {
- setInputValue(resolvedLabel)
- }
- }, [resolvedLabel, isEditing])
+ const displayValue = isEditing ? editingValue : resolvedLabel
const invalidSelection =
!isPreview &&
@@ -295,7 +291,7 @@ export function CredentialSelector({
const selectedCredentialProvider = selectedCredential?.provider ?? provider
const overlayContent = useMemo(() => {
- if (!inputValue) return null
+ if (!displayValue) return null
if (isCredentialSetSelected && selectedCredentialSet) {
return (
@@ -303,7 +299,7 @@ export function CredentialSelector({
- {inputValue}
+ {displayValue}
)
}
@@ -313,12 +309,12 @@ export function CredentialSelector({
{getProviderIcon(selectedCredentialProvider)}
-
{inputValue}
+
{displayValue}
)
}, [
getProviderIcon,
- inputValue,
+ displayValue,
selectedCredentialProvider,
isCredentialSetSelected,
selectedCredentialSet,
@@ -335,7 +331,6 @@ export function CredentialSelector({
const credentialSetId = value.slice(CREDENTIAL_SET.PREFIX.length)
const matchedSet = credentialSets.find((cs) => cs.id === credentialSetId)
if (matchedSet) {
- setInputValue(matchedSet.name)
handleCredentialSetSelect(credentialSetId)
return
}
@@ -343,13 +338,12 @@ export function CredentialSelector({
const matchedCred = credentials.find((c) => c.id === value)
if (matchedCred) {
- setInputValue(matchedCred.name)
handleSelect(value)
return
}
setIsEditing(true)
- setInputValue(value)
+ setEditingValue(value)
},
[credentials, credentialSets, handleAddCredential, handleSelect, handleCredentialSetSelect]
)
@@ -359,7 +353,7 @@ export function CredentialSelector({
{
- const { isInFolder, closeFolder, colorScheme, size } = usePopoverContext()
+const TagDropdownBackButton: React.FC<{ setSelectedIndex: (index: number) => void }> = ({
+ setSelectedIndex,
+}) => {
+ const { isInFolder, closeFolder, size, isKeyboardNav, setKeyboardNav } = usePopoverContext()
const nestedNav = useNestedNavigation()
if (!isInFolder) return null
@@ -922,28 +924,31 @@ const TagDropdownBackButton: React.FC = () => {
closeFolder()
}
+ const handleMouseEnter = () => {
+ if (isKeyboardNav) return
+ setKeyboardNav(false)
+ setSelectedIndex(-1)
+ }
+
return (
- {
+ e.preventDefault()
+ e.stopPropagation()
+ handleBackClick(e)
+ }}
+ onMouseEnter={handleMouseEnter}
>
-
Back
-
+ Back
+
)
}
@@ -1961,8 +1966,8 @@ export const TagDropdown: React.FC = ({
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
>
-
+
{flatTagList.length === 0 ? (
No matching tags found
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-terminal-filters.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-terminal-filters.ts
index 1807828f4..c712864cf 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-terminal-filters.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-terminal-filters.ts
@@ -105,11 +105,9 @@ export function useTerminalFilters() {
})
}
- // Apply sorting by timestamp
+ // Sort by executionOrder (monotonically increasing integer from server)
result = [...result].sort((a, b) => {
- const timeA = new Date(a.timestamp).getTime()
- const timeB = new Date(b.timestamp).getTime()
- const comparison = timeA - timeB
+ const comparison = a.executionOrder - b.executionOrder
return sortConfig.direction === 'asc' ? comparison : -comparison
})
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx
index abe60c6a4..540f97bba 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx
@@ -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({
@@ -201,7 +201,7 @@ const IterationNodeRow = memo(function IterationNodeRow({
@@ -314,7 +314,7 @@ const SubflowNodeRow = memo(function SubflowNodeRow({
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils.ts
index 6dbc15770..d54ccbfdf 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils.ts
@@ -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
*/
@@ -195,13 +184,9 @@ function buildEntryTree(entries: ConsoleEntry[]): EntryNode[] {
group.blocks.push(entry)
}
- // Sort blocks within each iteration by start time ascending (oldest first, top-down)
+ // Sort blocks within each iteration by executionOrder ascending (oldest first, top-down)
for (const group of iterationGroupsMap.values()) {
- group.blocks.sort((a, b) => {
- const aStart = new Date(a.startedAt || a.timestamp).getTime()
- const bStart = new Date(b.startedAt || b.timestamp).getTime()
- return aStart - bStart
- })
+ group.blocks.sort((a, b) => a.executionOrder - b.executionOrder)
}
// Group iterations by iterationType to create subflow parents
@@ -236,6 +221,8 @@ function buildEntryTree(entries: ConsoleEntry[]): EntryNode[] {
const totalDuration = allBlocks.reduce((sum, b) => sum + (b.durationMs || 0), 0)
// Create synthetic subflow parent entry
+ // Use the minimum executionOrder from all child blocks for proper ordering
+ const subflowExecutionOrder = Math.min(...allBlocks.map((b) => b.executionOrder))
const syntheticSubflow: ConsoleEntry = {
id: `subflow-${iterationType}-${firstIteration.blocks[0]?.executionId || 'unknown'}`,
timestamp: new Date(subflowStartMs).toISOString(),
@@ -245,6 +232,7 @@ function buildEntryTree(entries: ConsoleEntry[]): EntryNode[] {
blockType: iterationType,
executionId: firstIteration.blocks[0]?.executionId,
startedAt: new Date(subflowStartMs).toISOString(),
+ executionOrder: subflowExecutionOrder,
endedAt: new Date(subflowEndMs).toISOString(),
durationMs: totalDuration,
success: !allBlocks.some((b) => b.error),
@@ -262,6 +250,8 @@ function buildEntryTree(entries: ConsoleEntry[]): EntryNode[] {
)
const iterDuration = iterBlocks.reduce((sum, b) => sum + (b.durationMs || 0), 0)
+ // Use the minimum executionOrder from blocks in this iteration
+ const iterExecutionOrder = Math.min(...iterBlocks.map((b) => b.executionOrder))
const syntheticIteration: ConsoleEntry = {
id: `iteration-${iterationType}-${iterGroup.iterationCurrent}-${iterBlocks[0]?.executionId || 'unknown'}`,
timestamp: new Date(iterStartMs).toISOString(),
@@ -271,6 +261,7 @@ function buildEntryTree(entries: ConsoleEntry[]): EntryNode[] {
blockType: iterationType,
executionId: iterBlocks[0]?.executionId,
startedAt: new Date(iterStartMs).toISOString(),
+ executionOrder: iterExecutionOrder,
endedAt: new Date(iterEndMs).toISOString(),
durationMs: iterDuration,
success: !iterBlocks.some((b) => b.error),
@@ -311,14 +302,9 @@ function buildEntryTree(entries: ConsoleEntry[]): EntryNode[] {
nodeType: 'block' as const,
}))
- // Combine all nodes and sort by start time ascending (oldest first, top-down)
+ // Combine all nodes and sort by executionOrder ascending (oldest first, top-down)
const allNodes = [...subflowNodes, ...regularNodes]
- allNodes.sort((a, b) => {
- const aStart = new Date(a.entry.startedAt || a.entry.timestamp).getTime()
- const bStart = new Date(b.entry.startedAt || b.entry.timestamp).getTime()
- return aStart - bStart
- })
-
+ allNodes.sort((a, b) => a.entry.executionOrder - b.entry.executionOrder)
return allNodes
}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/training-modal/training-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/training-modal/training-modal.tsx
index cbdac251f..3003d4acd 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/training-modal/training-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/training-modal/training-modal.tsx
@@ -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() {
Duration:{' '}
{dataset.metadata?.duration
- ? `${(dataset.metadata.duration / 1000).toFixed(1)}s`
+ ? formatDuration(dataset.metadata.duration, {
+ precision: 1,
+ })
: 'N/A'}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts
index 74b3df7ad..747425247 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts
@@ -926,6 +926,7 @@ export function useWorkflowExecution() {
})
// Add entry to terminal immediately with isRunning=true
+ // Use server-provided executionOrder to ensure correct sort order
const startedAt = new Date().toISOString()
addConsole({
input: {},
@@ -933,6 +934,7 @@ export function useWorkflowExecution() {
success: undefined,
durationMs: undefined,
startedAt,
+ executionOrder: data.executionOrder,
endedAt: undefined,
workflowId: activeWorkflowId,
blockId: data.blockId,
@@ -948,8 +950,6 @@ export function useWorkflowExecution() {
},
onBlockCompleted: (data) => {
- logger.info('onBlockCompleted received:', { data })
-
activeBlocksSet.delete(data.blockId)
setActiveBlocks(new Set(activeBlocksSet))
setBlockRunStatus(data.blockId, 'success')
@@ -976,6 +976,7 @@ export function useWorkflowExecution() {
success: true,
durationMs: data.durationMs,
startedAt,
+ executionOrder: data.executionOrder,
endedAt,
})
@@ -987,6 +988,7 @@ export function useWorkflowExecution() {
replaceOutput: data.output,
success: true,
durationMs: data.durationMs,
+ startedAt,
endedAt,
isRunning: false,
// Pass through iteration context for subflow grouping
@@ -1027,6 +1029,7 @@ export function useWorkflowExecution() {
error: data.error,
durationMs: data.durationMs,
startedAt,
+ executionOrder: data.executionOrder,
endedAt,
})
@@ -1039,6 +1042,7 @@ export function useWorkflowExecution() {
success: false,
error: data.error,
durationMs: data.durationMs,
+ startedAt,
endedAt,
isRunning: false,
// Pass through iteration context for subflow grouping
@@ -1157,20 +1161,25 @@ export function useWorkflowExecution() {
cancelRunningEntries(activeWorkflowId)
}
- 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',
- })
+ if (accumulatedBlockLogs.length === 0) {
+ // No blocks executed yet - this is a pre-execution error
+ // Use 0 for executionOrder so validation errors appear first
+ addConsole({
+ input: {},
+ output: {},
+ success: false,
+ error: data.error,
+ durationMs: data.duration || 0,
+ startedAt: new Date(Date.now() - (data.duration || 0)).toISOString(),
+ executionOrder: 0,
+ endedAt: new Date().toISOString(),
+ workflowId: activeWorkflowId,
+ blockId: 'validation',
+ executionId,
+ blockName: 'Workflow Validation',
+ blockType: 'validation',
+ })
+ }
},
onExecutionCancelled: () => {
@@ -1236,6 +1245,7 @@ export function useWorkflowExecution() {
blockType = error.blockType || blockType
}
+ // Use MAX_SAFE_INTEGER so execution errors appear at the end of the log
useTerminalConsoleStore.getState().addConsole({
input: {},
output: {},
@@ -1243,6 +1253,7 @@ export function useWorkflowExecution() {
error: normalizedMessage,
durationMs: 0,
startedAt: new Date().toISOString(),
+ executionOrder: Number.MAX_SAFE_INTEGER,
endedAt: new Date().toISOString(),
workflowId: activeWorkflowId || '',
blockId,
@@ -1614,6 +1625,7 @@ export function useWorkflowExecution() {
success: true,
durationMs: data.durationMs,
startedAt,
+ executionOrder: data.executionOrder,
endedAt,
})
@@ -1623,6 +1635,7 @@ export function useWorkflowExecution() {
success: true,
durationMs: data.durationMs,
startedAt,
+ executionOrder: data.executionOrder,
endedAt,
workflowId,
blockId: data.blockId,
@@ -1652,6 +1665,7 @@ export function useWorkflowExecution() {
output: {},
success: false,
error: data.error,
+ executionOrder: data.executionOrder,
durationMs: data.durationMs,
startedAt,
endedAt,
@@ -1664,6 +1678,7 @@ export function useWorkflowExecution() {
error: data.error,
durationMs: data.durationMs,
startedAt,
+ executionOrder: data.executionOrder,
endedAt,
workflowId,
blockId: data.blockId,
@@ -1728,6 +1743,7 @@ export function useWorkflowExecution() {
error: data.error,
durationMs: data.duration || 0,
startedAt: new Date(Date.now() - (data.duration || 0)).toISOString(),
+ executionOrder: Number.MAX_SAFE_INTEGER,
endedAt: new Date().toISOString(),
workflowId,
blockId: 'workflow-error',
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts
index c69670f8d..0d0597f9a 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts
@@ -111,6 +111,7 @@ export async function executeWorkflowWithFullLogging(
success: true,
durationMs: event.data.durationMs,
startedAt: new Date(Date.now() - event.data.durationMs).toISOString(),
+ executionOrder: event.data.executionOrder,
endedAt: new Date().toISOString(),
workflowId: activeWorkflowId,
blockId: event.data.blockId,
@@ -140,6 +141,7 @@ export async function executeWorkflowWithFullLogging(
error: event.data.error,
durationMs: event.data.durationMs,
startedAt: new Date(Date.now() - event.data.durationMs).toISOString(),
+ executionOrder: event.data.executionOrder,
endedAt: new Date().toISOString(),
workflowId: activeWorkflowId,
blockId: event.data.blockId,
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credential-sets/credential-sets.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credential-sets/credential-sets.tsx
index a1fae5b1a..ce6154939 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credential-sets/credential-sets.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credential-sets/credential-sets.tsx
@@ -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
}
- // 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() {
- {/* Group Info */}
@@ -471,7 +467,6 @@ export function CredentialSets() {
- {/* Invite Section - Email Tags Input */}
{emailError}}
- {/* Members List - styled like team members */}
Members
@@ -519,7 +513,6 @@ export function CredentialSets() {
) : (
- {/* 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() {
- {/* Footer Actions */}
- {/* Create Polling Group Modal */}
Create Polling Group
@@ -895,7 +885,6 @@ export function CredentialSets() {
- {/* Leave Confirmation Modal */}
setLeavingMembership(null)}>
Leave Polling Group
@@ -923,7 +912,6 @@ export function CredentialSets() {
- {/* Delete Confirmation Modal */}
setDeletingSet(null)}>
Delete Polling Group
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/index.ts
index e2241137f..db87eaf39 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/index.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/index.ts
@@ -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'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/mcp.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/mcp.tsx
index d4103702b..d25865a74 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/mcp.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/mcp.tsx
@@ -407,14 +407,12 @@ export function MCP({ initialServerId }: MCPProps) {
const [urlScrollLeft, setUrlScrollLeft] = useState(0)
const [headerScrollLeft, setHeaderScrollLeft] = useState>({})
- // 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)
@@ -675,6 +673,7 @@ export function MCP({ initialServerId }: MCPProps) {
/**
* Opens the detail view for a specific server.
+ * Note: Tool refresh is handled by the useEffect that watches selectedServerId
*/
const handleViewDetails = useCallback((serverId: string) => {
setSelectedServerId(serverId)
@@ -717,7 +716,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`)
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx
index d2a72a998..b9a3dc5be 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx
@@ -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,16 +52,18 @@ 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 { useSuperUserStatus } from '@/hooks/queries/user-profile'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useSettingsModalStore } from '@/stores/modals/settings/store'
@@ -204,13 +205,13 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
const [activeSection, setActiveSection] = useState('general')
const { initialSection, mcpServerId, clearInitialState } = useSettingsModalStore()
const [pendingMcpServerId, setPendingMcpServerId] = useState(null)
- const [isSuperUser, setIsSuperUser] = useState(false)
const { data: session } = useSession()
const queryClient = useQueryClient()
const { data: organizationsData } = useOrganizations()
const { data: generalSettings } = useGeneralSettings()
const { data: subscriptionData } = useSubscriptionData({ enabled: isBillingEnabled })
const { data: ssoProvidersData, isLoading: isLoadingSSO } = useSSOProviders()
+ const { data: superUserData } = useSuperUserStatus(session?.user?.id)
const activeOrganization = organizationsData?.activeOrganization
const { config: permissionConfig } = usePermissionConfig()
@@ -229,22 +230,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
const hasEnterprisePlan = subscriptionStatus.isEnterprise
const hasOrganization = !!activeOrganization?.id
- // Fetch superuser status
- useEffect(() => {
- const fetchSuperUserStatus = async () => {
- if (!userId) return
- try {
- const response = await fetch('/api/user/super-user')
- if (response.ok) {
- const data = await response.json()
- setIsSuperUser(data.isSuperUser)
- }
- } catch {
- setIsSuperUser(false)
- }
- }
- fetchSuperUserStatus()
- }, [userId])
+ const isSuperUser = superUserData?.isSuperUser ?? false
// Memoize SSO provider ownership check
const isSSOProviderOwner = useMemo(() => {
@@ -328,7 +314,13 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
generalSettings?.superUserModeEnabled,
])
- // Memoized callbacks to prevent infinite loops in child components
+ const effectiveActiveSection = useMemo(() => {
+ if (!isBillingEnabled && (activeSection === 'subscription' || activeSection === 'team')) {
+ return 'general'
+ }
+ return activeSection
+ }, [activeSection])
+
const registerEnvironmentBeforeLeaveHandler = useCallback(
(handler: (onProceed: () => void) => void) => {
environmentBeforeLeaveHandler.current = handler
@@ -342,19 +334,18 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
const handleSectionChange = useCallback(
(sectionId: SettingsSection) => {
- if (sectionId === activeSection) return
+ if (sectionId === effectiveActiveSection) return
- if (activeSection === 'environment' && environmentBeforeLeaveHandler.current) {
+ if (effectiveActiveSection === 'environment' && environmentBeforeLeaveHandler.current) {
environmentBeforeLeaveHandler.current(() => setActiveSection(sectionId))
return
}
setActiveSection(sectionId)
},
- [activeSection]
+ [effectiveActiveSection]
)
- // Apply initial section from store when modal opens
useEffect(() => {
if (open && initialSection) {
setActiveSection(initialSection)
@@ -365,7 +356,6 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
}
}, [open, initialSection, mcpServerId, clearInitialState])
- // Clear pending server ID when section changes away from MCP
useEffect(() => {
if (activeSection !== 'mcp') {
setPendingMcpServerId(null)
@@ -391,14 +381,6 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
}
}, [onOpenChange])
- // Redirect away from billing tabs if billing is disabled
- useEffect(() => {
- if (!isBillingEnabled && (activeSection === 'subscription' || activeSection === 'team')) {
- setActiveSection('general')
- }
- }, [activeSection])
-
- // Prefetch functions for React Query
const prefetchGeneral = () => {
queryClient.prefetchQuery({
queryKey: generalSettingsKeys.settings(),
@@ -489,9 +471,17 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
// Handle dialog close - delegate to environment component if it's active
const handleDialogOpenChange = (newOpen: boolean) => {
- if (!newOpen && activeSection === 'environment' && environmentBeforeLeaveHandler.current) {
+ if (
+ !newOpen &&
+ effectiveActiveSection === 'environment' &&
+ environmentBeforeLeaveHandler.current
+ ) {
environmentBeforeLeaveHandler.current(() => onOpenChange(false))
- } else if (!newOpen && activeSection === 'integrations' && integrationsCloseHandler.current) {
+ } else if (
+ !newOpen &&
+ effectiveActiveSection === 'integrations' &&
+ integrationsCloseHandler.current
+ ) {
integrationsCloseHandler.current(newOpen)
} else {
onOpenChange(newOpen)
@@ -522,7 +512,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
{sectionItems.map((item) => (
}
onMouseEnter={() => handlePrefetch(item.id)}
onClick={() => handleSectionChange(item.id)}
@@ -538,35 +528,36 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
- {navigationItems.find((item) => item.id === activeSection)?.label || activeSection}
+ {navigationItems.find((item) => item.id === effectiveActiveSection)?.label ||
+ effectiveActiveSection}
- {activeSection === 'general' && }
- {activeSection === 'environment' && (
+ {effectiveActiveSection === 'general' && }
+ {effectiveActiveSection === 'environment' && (
)}
- {activeSection === 'template-profile' && }
- {activeSection === 'integrations' && (
+ {effectiveActiveSection === 'template-profile' && }
+ {effectiveActiveSection === 'integrations' && (
)}
- {activeSection === 'credential-sets' && }
- {activeSection === 'access-control' && }
- {activeSection === 'apikeys' && }
- {activeSection === 'files' && }
- {isBillingEnabled && activeSection === 'subscription' && }
- {isBillingEnabled && activeSection === 'team' && }
- {activeSection === 'sso' && }
- {activeSection === 'byok' && }
- {activeSection === 'copilot' && }
- {activeSection === 'mcp' && }
- {activeSection === 'custom-tools' && }
- {activeSection === 'workflow-mcp-servers' && }
- {activeSection === 'debug' && }
+ {effectiveActiveSection === 'credential-sets' && }
+ {effectiveActiveSection === 'access-control' && }
+ {effectiveActiveSection === 'apikeys' && }
+ {effectiveActiveSection === 'files' && }
+ {isBillingEnabled && effectiveActiveSection === 'subscription' && }
+ {isBillingEnabled && effectiveActiveSection === 'team' && }
+ {effectiveActiveSection === 'sso' && }
+ {effectiveActiveSection === 'byok' && }
+ {effectiveActiveSection === 'copilot' && }
+ {effectiveActiveSection === 'mcp' && }
+ {effectiveActiveSection === 'custom-tools' && }
+ {effectiveActiveSection === 'workflow-mcp-servers' && }
+ {effectiveActiveSection === 'debug' && }
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx
index 7cca37364..989532c28 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx
@@ -231,6 +231,8 @@ export function FolderItem({
const isFolderSelected = store.selectedFolders.has(folder.id)
if (!isFolderSelected) {
+ // Replace selection with just this folder (Finder/Explorer pattern)
+ store.clearAllSelection()
store.selectFolder(folder.id)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx
index 3c099da60..6963464d4 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx
@@ -189,6 +189,9 @@ export function WorkflowItem({
const isCurrentlySelected = store.selectedWorkflows.has(workflow.id)
if (!isCurrentlySelected) {
+ // Replace selection with just this item (Finder/Explorer pattern)
+ // This clears both workflow and folder selections
+ store.clearAllSelection()
store.selectWorkflow(workflow.id)
}
diff --git a/apps/sim/background/workspace-notification-delivery.ts b/apps/sim/background/workspace-notification-delivery.ts
index d5dbf3a92..0d9a8254b 100644
--- a/apps/sim/background/workspace-notification-delivery.ts
+++ b/apps/sim/background/workspace-notification-delivery.ts
@@ -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 {
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)}` },
],
},
diff --git a/apps/sim/components/emcn/components/popover/popover.tsx b/apps/sim/components/emcn/components/popover/popover.tsx
index d84200d21..e925b3d19 100644
--- a/apps/sim/components/emcn/components/popover/popover.tsx
+++ b/apps/sim/components/emcn/components/popover/popover.tsx
@@ -260,6 +260,9 @@ const Popover: React.FC = ({
setIsKeyboardNav(false)
setSelectedIndex(-1)
registeredItemsRef.current = []
+ } else {
+ // Reset hover state when opening to prevent stale submenu from previous menu
+ setLastHoveredItem(null)
}
}, [open])
diff --git a/apps/sim/components/ui/tool-call.tsx b/apps/sim/components/ui/tool-call.tsx
index b6d76ca7e..0d7d2ece2 100644
--- a/apps/sim/components/ui/tool-call.tsx
+++ b/apps/sim/components/ui/tool-call.tsx
@@ -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 (
- {formatDuration(toolCall.duration)}
+ {toolCall.duration ? formatDuration(toolCall.duration, { precision: 1 }) : ''}
)}
diff --git a/apps/sim/ee/LICENSE b/apps/sim/ee/LICENSE
new file mode 100644
index 000000000..ba5405dbf
--- /dev/null
+++ b/apps/sim/ee/LICENSE
@@ -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
diff --git a/apps/sim/ee/README.md b/apps/sim/ee/README.md
new file mode 100644
index 000000000..d9e91afaf
--- /dev/null
+++ b/apps/sim/ee/README.md
@@ -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`).
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/access-control/access-control.tsx b/apps/sim/ee/access-control/components/access-control.tsx
similarity index 99%
rename from apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/access-control/access-control.tsx
rename to apps/sim/ee/access-control/components/access-control.tsx
index af7db3fcc..83f2f28dc 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/access-control/access-control.tsx
+++ b/apps/sim/ee/access-control/components/access-control.tsx
@@ -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
diff --git a/apps/sim/hooks/queries/permission-groups.ts b/apps/sim/ee/access-control/hooks/permission-groups.ts
similarity index 99%
rename from apps/sim/hooks/queries/permission-groups.ts
rename to apps/sim/ee/access-control/hooks/permission-groups.ts
index 6832d5188..91f838ced 100644
--- a/apps/sim/hooks/queries/permission-groups.ts
+++ b/apps/sim/ee/access-control/hooks/permission-groups.ts
@@ -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'
diff --git a/apps/sim/executor/utils/permission-check.ts b/apps/sim/ee/access-control/utils/permission-check.ts
similarity index 100%
rename from apps/sim/executor/utils/permission-check.ts
rename to apps/sim/ee/access-control/utils/permission-check.ts
diff --git a/apps/sim/app/chat/components/auth/sso/sso-auth.tsx b/apps/sim/ee/sso/components/sso-auth.tsx
similarity index 100%
rename from apps/sim/app/chat/components/auth/sso/sso-auth.tsx
rename to apps/sim/ee/sso/components/sso-auth.tsx
diff --git a/apps/sim/app/(auth)/sso/sso-form.tsx b/apps/sim/ee/sso/components/sso-form.tsx
similarity index 100%
rename from apps/sim/app/(auth)/sso/sso-form.tsx
rename to apps/sim/ee/sso/components/sso-form.tsx
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/sso/sso.tsx b/apps/sim/ee/sso/components/sso-settings.tsx
similarity index 97%
rename from apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/sso/sso.tsx
rename to apps/sim/ee/sso/components/sso-settings.tsx
index 2657c8204..a43e15ff3 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/sso/sso.tsx
+++ b/apps/sim/ee/sso/components/sso-settings.tsx
@@ -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() {
handleInputChange('providerId', value)}
- options={TRUSTED_SSO_PROVIDERS.map((id) => ({
+ options={SSO_TRUSTED_PROVIDERS.map((id) => ({
label: id,
value: id,
}))}
diff --git a/apps/sim/lib/auth/sso/constants.ts b/apps/sim/ee/sso/constants.ts
similarity index 85%
rename from apps/sim/lib/auth/sso/constants.ts
rename to apps/sim/ee/sso/constants.ts
index ca246f8cf..67cfee94f 100644
--- a/apps/sim/lib/auth/sso/constants.ts
+++ b/apps/sim/ee/sso/constants.ts
@@ -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',
diff --git a/apps/sim/hooks/queries/sso.ts b/apps/sim/ee/sso/hooks/sso.ts
similarity index 69%
rename from apps/sim/hooks/queries/sso.ts
rename to apps/sim/ee/sso/hooks/sso.ts
index 7c5c769ab..2dfa1592e 100644
--- a/apps/sim/hooks/queries/sso.ts
+++ b/apps/sim/ee/sso/hooks/sso.ts
@@ -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),
- })
- }
- },
- })
-}
diff --git a/apps/sim/executor/execution/block-executor.ts b/apps/sim/executor/execution/block-executor.ts
index d17da0e7c..6c97dd1a1 100644
--- a/apps/sim/executor/execution/block-executor.ts
+++ b/apps/sim/executor/execution/block-executor.ts
@@ -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,
@@ -20,18 +21,18 @@ import {
generatePauseContextId,
mapNodeMetadataToPauseScopes,
} from '@/executor/human-in-the-loop/utils.ts'
-import type {
- BlockHandler,
- BlockLog,
- BlockState,
- ExecutionContext,
- NormalizedBlockOutput,
+import {
+ type BlockHandler,
+ type BlockLog,
+ type BlockState,
+ type ExecutionContext,
+ getNextExecutionOrder,
+ type NormalizedBlockOutput,
} from '@/executor/types'
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'
@@ -68,7 +69,7 @@ export class BlockExecutor {
if (!isSentinel) {
blockLog = this.createBlockLog(ctx, node.id, block, node)
ctx.blockLogs.push(blockLog)
- this.callOnBlockStart(ctx, node, block)
+ this.callOnBlockStart(ctx, node, block, blockLog.executionOrder)
}
const startTime = performance.now()
@@ -159,7 +160,7 @@ export class BlockExecutor {
this.state.setBlockOutput(node.id, normalizedOutput, duration)
- if (!isSentinel) {
+ if (!isSentinel && blockLog) {
const displayOutput = filterOutputForLog(block.metadata?.id || '', normalizedOutput, {
block,
})
@@ -170,8 +171,9 @@ export class BlockExecutor {
this.sanitizeInputsForLog(resolvedInputs),
displayOutput,
duration,
- blockLog!.startedAt,
- blockLog!.endedAt
+ blockLog.startedAt,
+ blockLog.executionOrder,
+ blockLog.endedAt
)
}
@@ -268,7 +270,7 @@ export class BlockExecutor {
}
)
- if (!isSentinel) {
+ if (!isSentinel && blockLog) {
const displayOutput = filterOutputForLog(block.metadata?.id || '', errorOutput, { block })
this.callOnBlockComplete(
ctx,
@@ -277,8 +279,9 @@ export class BlockExecutor {
this.sanitizeInputsForLog(input),
displayOutput,
duration,
- blockLog!.startedAt,
- blockLog!.endedAt
+ blockLog.startedAt,
+ blockLog.executionOrder,
+ blockLog.endedAt
)
}
@@ -346,6 +349,7 @@ export class BlockExecutor {
blockName,
blockType: block.metadata?.id ?? DEFAULTS.BLOCK_TYPE,
startedAt: new Date().toISOString(),
+ executionOrder: getNextExecutionOrder(ctx),
endedAt: '',
durationMs: 0,
success: false,
@@ -409,7 +413,12 @@ export class BlockExecutor {
return result
}
- private callOnBlockStart(ctx: ExecutionContext, node: DAGNode, block: SerializedBlock): void {
+ private callOnBlockStart(
+ ctx: ExecutionContext,
+ node: DAGNode,
+ block: SerializedBlock,
+ executionOrder: number
+ ): void {
const blockId = node.id
const blockName = block.metadata?.name ?? blockId
const blockType = block.metadata?.id ?? DEFAULTS.BLOCK_TYPE
@@ -417,7 +426,13 @@ export class BlockExecutor {
const iterationContext = this.getIterationContext(ctx, node)
if (this.contextExtensions.onBlockStart) {
- this.contextExtensions.onBlockStart(blockId, blockName, blockType, iterationContext)
+ this.contextExtensions.onBlockStart(
+ blockId,
+ blockName,
+ blockType,
+ executionOrder,
+ iterationContext
+ )
}
}
@@ -429,6 +444,7 @@ export class BlockExecutor {
output: NormalizedBlockOutput,
duration: number,
startedAt: string,
+ executionOrder: number,
endedAt: string
): void {
const blockId = node.id
@@ -447,6 +463,7 @@ export class BlockExecutor {
output,
executionTime: duration,
startedAt,
+ executionOrder,
endedAt,
},
iterationContext
diff --git a/apps/sim/executor/execution/types.ts b/apps/sim/executor/execution/types.ts
index e770989b6..91dfe2c6a 100644
--- a/apps/sim/executor/execution/types.ts
+++ b/apps/sim/executor/execution/types.ts
@@ -55,7 +55,13 @@ export interface IterationContext {
export interface ExecutionCallbacks {
onStream?: (streamingExec: any) => Promise
- onBlockStart?: (blockId: string, blockName: string, blockType: string) => Promise
+ onBlockStart?: (
+ blockId: string,
+ blockName: string,
+ blockType: string,
+ executionOrder: number,
+ iterationContext?: IterationContext
+ ) => Promise
onBlockComplete?: (
blockId: string,
blockName: string,
@@ -97,6 +103,7 @@ export interface ContextExtensions {
blockId: string,
blockName: string,
blockType: string,
+ executionOrder: number,
iterationContext?: IterationContext
) => Promise
onBlockComplete?: (
@@ -108,6 +115,7 @@ export interface ContextExtensions {
output: NormalizedBlockOutput
executionTime: number
startedAt: string
+ executionOrder: number
endedAt: string
},
iterationContext?: IterationContext
diff --git a/apps/sim/executor/handlers/agent/agent-handler.ts b/apps/sim/executor/handlers/agent/agent-handler.ts
index 007833d9c..40c7b9ba8 100644
--- a/apps/sim/executor/handlers/agent/agent-handler.ts
+++ b/apps/sim/executor/handlers/agent/agent-handler.ts
@@ -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'
diff --git a/apps/sim/executor/handlers/evaluator/evaluator-handler.ts b/apps/sim/executor/handlers/evaluator/evaluator-handler.ts
index b383bdce0..3e95b2f85 100644
--- a/apps/sim/executor/handlers/evaluator/evaluator-handler.ts
+++ b/apps/sim/executor/handlers/evaluator/evaluator-handler.ts
@@ -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'
diff --git a/apps/sim/executor/handlers/router/router-handler.ts b/apps/sim/executor/handlers/router/router-handler.ts
index 12acf6c4c..766a4aac6 100644
--- a/apps/sim/executor/handlers/router/router-handler.ts
+++ b/apps/sim/executor/handlers/router/router-handler.ts
@@ -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'
diff --git a/apps/sim/executor/orchestrators/loop.ts b/apps/sim/executor/orchestrators/loop.ts
index bd72b8498..8bdf8edd2 100644
--- a/apps/sim/executor/orchestrators/loop.ts
+++ b/apps/sim/executor/orchestrators/loop.ts
@@ -7,7 +7,11 @@ import type { DAG } from '@/executor/dag/builder'
import type { EdgeManager } from '@/executor/execution/edge-manager'
import type { LoopScope } from '@/executor/execution/state'
import type { BlockStateController, ContextExtensions } from '@/executor/execution/types'
-import type { ExecutionContext, NormalizedBlockOutput } from '@/executor/types'
+import {
+ type ExecutionContext,
+ getNextExecutionOrder,
+ type NormalizedBlockOutput,
+} from '@/executor/types'
import type { LoopConfigWithNodes } from '@/executor/types/loop'
import { replaceValidReferences } from '@/executor/utils/reference-validation'
import {
@@ -286,6 +290,7 @@ export class LoopOrchestrator {
output,
executionTime: DEFAULTS.EXECUTION_TIME,
startedAt: now,
+ executionOrder: getNextExecutionOrder(ctx),
endedAt: now,
})
}
diff --git a/apps/sim/executor/orchestrators/parallel.ts b/apps/sim/executor/orchestrators/parallel.ts
index 88942b8cb..6d7ea2dfe 100644
--- a/apps/sim/executor/orchestrators/parallel.ts
+++ b/apps/sim/executor/orchestrators/parallel.ts
@@ -3,7 +3,11 @@ import { DEFAULTS } from '@/executor/constants'
import type { DAG } from '@/executor/dag/builder'
import type { ParallelScope } from '@/executor/execution/state'
import type { BlockStateWriter, ContextExtensions } from '@/executor/execution/types'
-import type { ExecutionContext, NormalizedBlockOutput } from '@/executor/types'
+import {
+ type ExecutionContext,
+ getNextExecutionOrder,
+ type NormalizedBlockOutput,
+} from '@/executor/types'
import type { ParallelConfigWithNodes } from '@/executor/types/parallel'
import { ParallelExpander } from '@/executor/utils/parallel-expansion'
import {
@@ -270,6 +274,7 @@ export class ParallelOrchestrator {
output,
executionTime: 0,
startedAt: now,
+ executionOrder: getNextExecutionOrder(ctx),
endedAt: now,
})
}
diff --git a/apps/sim/executor/types.ts b/apps/sim/executor/types.ts
index 6c87eed25..10c1996b3 100644
--- a/apps/sim/executor/types.ts
+++ b/apps/sim/executor/types.ts
@@ -114,6 +114,11 @@ export interface BlockLog {
loopId?: string
parallelId?: string
iterationIndex?: number
+ /**
+ * Monotonically increasing integer (1, 2, 3, ...) for accurate block ordering.
+ * Generated via getNextExecutionOrder() to ensure deterministic sorting.
+ */
+ executionOrder: number
/**
* Child workflow trace spans for nested workflow execution.
* Stored separately from output to keep output clean for display
@@ -227,7 +232,12 @@ export interface ExecutionContext {
edges?: Array<{ source: string; target: string }>
onStream?: (streamingExecution: StreamingExecution) => Promise
- onBlockStart?: (blockId: string, blockName: string, blockType: string) => Promise
+ onBlockStart?: (
+ blockId: string,
+ blockName: string,
+ blockType: string,
+ executionOrder: number
+ ) => Promise
onBlockComplete?: (
blockId: string,
blockName: string,
@@ -268,6 +278,23 @@ export interface ExecutionContext {
* Stop execution after this block completes. Used for "run until block" feature.
*/
stopAfterBlockId?: string
+
+ /**
+ * Counter for generating monotonically increasing execution order values.
+ * Starts at 0 and increments for each block. Use getNextExecutionOrder() to access.
+ */
+ executionOrderCounter?: { value: number }
+}
+
+/**
+ * Gets the next execution order value for a block.
+ * Returns a simple incrementing integer (1, 2, 3, ...) for clear ordering.
+ */
+export function getNextExecutionOrder(ctx: ExecutionContext): number {
+ if (!ctx.executionOrderCounter) {
+ ctx.executionOrderCounter = { value: 0 }
+ }
+ return ++ctx.executionOrderCounter.value
}
export interface ExecutionResult {
diff --git a/apps/sim/executor/utils/subflow-utils.ts b/apps/sim/executor/utils/subflow-utils.ts
index 5ef3a51b5..c4eb23f38 100644
--- a/apps/sim/executor/utils/subflow-utils.ts
+++ b/apps/sim/executor/utils/subflow-utils.ts
@@ -1,7 +1,7 @@
import { createLogger } from '@sim/logger'
import { LOOP, PARALLEL, PARSING, REFERENCE } from '@/executor/constants'
import type { ContextExtensions } from '@/executor/execution/types'
-import type { BlockLog, ExecutionContext } from '@/executor/types'
+import { type BlockLog, type ExecutionContext, getNextExecutionOrder } from '@/executor/types'
import type { VariableResolver } from '@/executor/variables/resolver'
const logger = createLogger('SubflowUtils')
@@ -208,6 +208,7 @@ export function addSubflowErrorLog(
contextExtensions: ContextExtensions | null
): void {
const now = new Date().toISOString()
+ const execOrder = getNextExecutionOrder(ctx)
const block = ctx.workflow?.blocks?.find((b) => b.id === blockId)
const blockName = block?.metadata?.name || (blockType === 'loop' ? 'Loop' : 'Parallel')
@@ -217,6 +218,7 @@ export function addSubflowErrorLog(
blockName,
blockType,
startedAt: now,
+ executionOrder: execOrder,
endedAt: now,
durationMs: 0,
success: false,
@@ -233,6 +235,7 @@ export function addSubflowErrorLog(
output: { error: errorMessage },
executionTime: 0,
startedAt: now,
+ executionOrder: execOrder,
endedAt: now,
})
}
diff --git a/apps/sim/hooks/queries/credential-sets.ts b/apps/sim/hooks/queries/credential-sets.ts
index 33da082f7..a18374b75 100644
--- a/apps/sim/hooks/queries/credential-sets.ts
+++ b/apps/sim/hooks/queries/credential-sets.ts
@@ -1,3 +1,5 @@
+'use client'
+
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { fetchJson } from '@/hooks/selectors/helpers'
diff --git a/apps/sim/hooks/queries/user-profile.ts b/apps/sim/hooks/queries/user-profile.ts
index f01cbe585..0b3e048b8 100644
--- a/apps/sim/hooks/queries/user-profile.ts
+++ b/apps/sim/hooks/queries/user-profile.ts
@@ -9,6 +9,7 @@ const logger = createLogger('UserProfileQuery')
export const userProfileKeys = {
all: ['userProfile'] as const,
profile: () => [...userProfileKeys.all, 'profile'] as const,
+ superUser: (userId?: string) => [...userProfileKeys.all, 'superUser', userId ?? ''] as const,
}
/**
@@ -109,3 +110,37 @@ export function useUpdateUserProfile() {
},
})
}
+
+/**
+ * Superuser status response type
+ */
+interface SuperUserStatus {
+ isSuperUser: boolean
+}
+
+/**
+ * Fetch superuser status from API
+ */
+async function fetchSuperUserStatus(): Promise {
+ const response = await fetch('/api/user/super-user')
+
+ if (!response.ok) {
+ return { isSuperUser: false }
+ }
+
+ const data = await response.json()
+ return { isSuperUser: data.isSuperUser ?? false }
+}
+
+/**
+ * Hook to fetch superuser status
+ * @param userId - User ID for cache isolation (required for proper per-user caching)
+ */
+export function useSuperUserStatus(userId?: string) {
+ return useQuery({
+ queryKey: userProfileKeys.superUser(userId),
+ queryFn: fetchSuperUserStatus,
+ enabled: Boolean(userId),
+ staleTime: 5 * 60 * 1000, // 5 minutes - superuser status rarely changes
+ })
+}
diff --git a/apps/sim/hooks/use-permission-config.ts b/apps/sim/hooks/use-permission-config.ts
index 994656fdc..3c536caf5 100644
--- a/apps/sim/hooks/use-permission-config.ts
+++ b/apps/sim/hooks/use-permission-config.ts
@@ -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
diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts
index 9241eaf09..d5ac1a8c2 100644
--- a/apps/sim/lib/auth/auth.ts
+++ b/apps/sim/lib/auth/auth.ts
@@ -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')
diff --git a/apps/sim/lib/copilot/process-contents.ts b/apps/sim/lib/copilot/process-contents.ts
index ff1dbf497..13a0015f0 100644
--- a/apps/sim/lib/copilot/process-contents.ts
+++ b/apps/sim/lib/copilot/process-contents.ts
@@ -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 =
diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-block-config.ts b/apps/sim/lib/copilot/tools/server/blocks/get-block-config.ts
index 3d6ebba17..cd95577d7 100644
--- a/apps/sim/lib/copilot/tools/server/blocks/get-block-config.ts
+++ b/apps/sim/lib/copilot/tools/server/blocks/get-block-config.ts
@@ -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'
diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-block-options.ts b/apps/sim/lib/copilot/tools/server/blocks/get-block-options.ts
index b5e5b2373..177482fc3 100644
--- a/apps/sim/lib/copilot/tools/server/blocks/get-block-options.ts
+++ b/apps/sim/lib/copilot/tools/server/blocks/get-block-options.ts
@@ -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<
diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-and-tools.ts b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-and-tools.ts
index 222288aab..9413dc278 100644
--- a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-and-tools.ts
+++ b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-and-tools.ts
@@ -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,
diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts
index dc4615777..7b945d6b0 100644
--- a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts
+++ b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts
@@ -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'
diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-trigger-blocks.ts b/apps/sim/lib/copilot/tools/server/blocks/get-trigger-blocks.ts
index c5f3b75b4..5f5820e20 100644
--- a/apps/sim/lib/copilot/tools/server/blocks/get-trigger-blocks.ts
+++ b/apps/sim/lib/copilot/tools/server/blocks/get-trigger-blocks.ts
@@ -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({
diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts
index 61866dbd9..f484ea5d8 100644
--- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts
+++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts
@@ -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'
diff --git a/apps/sim/lib/core/utils/formatting.ts b/apps/sim/lib/core/utils/formatting.ts
index abd0f8805..a7051df03 100644
--- a/apps/sim/lib/core/utils/formatting.ts
+++ b/apps/sim/lib/core/utils/formatting.ts
@@ -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)
diff --git a/apps/sim/lib/workflows/executor/execution-events.ts b/apps/sim/lib/workflows/executor/execution-events.ts
index ee6e75b56..bc5c316c2 100644
--- a/apps/sim/lib/workflows/executor/execution-events.ts
+++ b/apps/sim/lib/workflows/executor/execution-events.ts
@@ -1,7 +1,3 @@
-/**
- * SSE Event types for workflow execution
- */
-
import type { SubflowType } from '@/stores/workflows/workflow/types'
export type ExecutionEventType =
@@ -80,7 +76,7 @@ export interface BlockStartedEvent extends BaseExecutionEvent {
blockId: string
blockName: string
blockType: string
- // Iteration context for loops and parallels
+ executionOrder: number
iterationCurrent?: number
iterationTotal?: number
iterationType?: SubflowType
@@ -101,8 +97,8 @@ export interface BlockCompletedEvent extends BaseExecutionEvent {
output: any
durationMs: number
startedAt: string
+ executionOrder: number
endedAt: string
- // Iteration context for loops and parallels
iterationCurrent?: number
iterationTotal?: number
iterationType?: SubflowType
@@ -123,8 +119,8 @@ export interface BlockErrorEvent extends BaseExecutionEvent {
error: string
durationMs: number
startedAt: string
+ executionOrder: number
endedAt: string
- // Iteration context for loops and parallels
iterationCurrent?: number
iterationTotal?: number
iterationType?: SubflowType
@@ -222,6 +218,7 @@ export function createSSECallbacks(options: SSECallbackOptions) {
blockId: string,
blockName: string,
blockType: string,
+ executionOrder: number,
iterationContext?: { iterationCurrent: number; iterationTotal: number; iterationType: string }
) => {
sendEvent({
@@ -233,6 +230,7 @@ export function createSSECallbacks(options: SSECallbackOptions) {
blockId,
blockName,
blockType,
+ executionOrder,
...(iterationContext && {
iterationCurrent: iterationContext.iterationCurrent,
iterationTotal: iterationContext.iterationTotal,
@@ -251,6 +249,7 @@ export function createSSECallbacks(options: SSECallbackOptions) {
output: any
executionTime: number
startedAt: string
+ executionOrder: number
endedAt: string
},
iterationContext?: { iterationCurrent: number; iterationTotal: number; iterationType: string }
@@ -278,6 +277,7 @@ export function createSSECallbacks(options: SSECallbackOptions) {
error: callbackData.output.error,
durationMs: callbackData.executionTime || 0,
startedAt: callbackData.startedAt,
+ executionOrder: callbackData.executionOrder,
endedAt: callbackData.endedAt,
...iterationData,
},
@@ -296,6 +296,7 @@ export function createSSECallbacks(options: SSECallbackOptions) {
output: callbackData.output,
durationMs: callbackData.executionTime || 0,
startedAt: callbackData.startedAt,
+ executionOrder: callbackData.executionOrder,
endedAt: callbackData.endedAt,
...iterationData,
},
diff --git a/apps/sim/stores/terminal/console/store.ts b/apps/sim/stores/terminal/console/store.ts
index 15298c625..9b1386da1 100644
--- a/apps/sim/stores/terminal/console/store.ts
+++ b/apps/sim/stores/terminal/console/store.ts
@@ -287,6 +287,14 @@ export const useTerminalConsoleStore = create()(
return entry
}
+ if (
+ typeof update === 'object' &&
+ update.iterationCurrent !== undefined &&
+ entry.iterationCurrent !== update.iterationCurrent
+ ) {
+ return entry
+ }
+
if (typeof update === 'string') {
const newOutput = updateBlockOutput(entry.output, update)
return { ...entry, output: newOutput }
@@ -324,6 +332,10 @@ export const useTerminalConsoleStore = create()(
updatedEntry.success = update.success
}
+ if (update.startedAt !== undefined) {
+ updatedEntry.startedAt = update.startedAt
+ }
+
if (update.endedAt !== undefined) {
updatedEntry.endedAt = update.endedAt
}
@@ -397,9 +409,15 @@ export const useTerminalConsoleStore = create()(
},
merge: (persistedState, currentState) => {
const persisted = persistedState as Partial | undefined
+ const entries = (persisted?.entries ?? currentState.entries).map((entry, index) => {
+ if (entry.executionOrder === undefined) {
+ return { ...entry, executionOrder: index + 1 }
+ }
+ return entry
+ })
return {
...currentState,
- entries: persisted?.entries ?? currentState.entries,
+ entries,
isOpen: persisted?.isOpen ?? currentState.isOpen,
}
},
diff --git a/apps/sim/stores/terminal/console/types.ts b/apps/sim/stores/terminal/console/types.ts
index ca31112eb..3ddb4b424 100644
--- a/apps/sim/stores/terminal/console/types.ts
+++ b/apps/sim/stores/terminal/console/types.ts
@@ -10,6 +10,7 @@ export interface ConsoleEntry {
blockType: string
executionId?: string
startedAt?: string
+ executionOrder: number
endedAt?: string
durationMs?: number
success?: boolean
@@ -20,9 +21,7 @@ export interface ConsoleEntry {
iterationCurrent?: number
iterationTotal?: number
iterationType?: SubflowType
- /** Whether this block is currently running */
isRunning?: boolean
- /** Whether this block execution was canceled */
isCanceled?: boolean
}
@@ -33,14 +32,12 @@ export interface ConsoleUpdate {
error?: string | Error | null
warning?: string
success?: boolean
+ startedAt?: string
endedAt?: string
durationMs?: number
input?: any
- /** Whether this block is currently running */
isRunning?: boolean
- /** Whether this block execution was canceled */
isCanceled?: boolean
- /** Iteration context for subflow blocks */
iterationCurrent?: number
iterationTotal?: number
iterationType?: SubflowType
diff --git a/apps/sim/tools/http/request.test.ts b/apps/sim/tools/http/request.test.ts
index d338a030c..88c2e9086 100644
--- a/apps/sim/tools/http/request.test.ts
+++ b/apps/sim/tools/http/request.test.ts
@@ -286,6 +286,30 @@ describe('HTTP Request Tool', () => {
)
})
+ it('should handle nested objects and arrays in URL-encoded form data', async () => {
+ tester.setup({ result: 'success' })
+
+ const body = {
+ name: 'test',
+ data: { nested: 'value' },
+ items: [1, 2, 3],
+ }
+
+ await tester.execute({
+ url: 'https://api.example.com/submit',
+ method: 'POST',
+ body,
+ headers: [{ cells: { Key: 'Content-Type', Value: 'application/x-www-form-urlencoded' } }],
+ })
+
+ const fetchCall = (global.fetch as any).mock.calls[0]
+ const bodyStr = fetchCall[1].body
+
+ expect(bodyStr).toContain('name=test')
+ expect(bodyStr).toContain('data=%7B%22nested%22%3A%22value%22%7D')
+ expect(bodyStr).toContain('items=%5B1%2C2%2C3%5D')
+ })
+
it('should handle OAuth client credentials requests', async () => {
tester.setup({ access_token: 'token123', token_type: 'Bearer' })
diff --git a/apps/sim/tools/http/request.ts b/apps/sim/tools/http/request.ts
index dbc74df4d..687ccb46f 100644
--- a/apps/sim/tools/http/request.ts
+++ b/apps/sim/tools/http/request.ts
@@ -105,7 +105,10 @@ export const requestTool: ToolConfig = {
const urlencoded = new URLSearchParams()
Object.entries(params.body as Record).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
- urlencoded.append(key, String(value))
+ urlencoded.append(
+ key,
+ typeof value === 'object' ? JSON.stringify(value) : String(value)
+ )
}
})
return urlencoded.toString()