From 9c3fd1f7af7b20779d94fc783e06a58517b9e276 Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 2 Feb 2026 23:40:18 -0800 Subject: [PATCH 1/6] feat(ee): add enterprise modules (#3121) --- apps/sim/app/(auth)/sso/page.tsx | 2 +- .../organizations/[id]/invitations/route.ts | 2 +- .../api/workspaces/invitations/route.test.ts | 2 +- .../app/api/workspaces/invitations/route.ts | 5 +- apps/sim/app/chat/[identifier]/chat.tsx | 2 +- apps/sim/app/chat/components/index.ts | 1 - .../[workspaceId]/knowledge/page.tsx | 3 +- .../[workspaceId]/templates/page.tsx | 2 +- .../credential-sets/credential-sets.tsx | 12 ----- .../settings-modal/components/index.ts | 2 - .../settings-modal/components/mcp/mcp.tsx | 3 -- .../settings-modal/settings-modal.tsx | 6 +-- apps/sim/ee/LICENSE | 43 +++++++++++++++++ apps/sim/ee/README.md | 21 ++++++++ .../components}/access-control.tsx | 11 ++--- .../hooks}/permission-groups.ts | 2 + .../access-control}/utils/permission-check.ts | 0 .../sso => ee/sso/components}/sso-auth.tsx | 0 .../sso => ee/sso/components}/sso-form.tsx | 0 .../sso/components/sso-settings.tsx} | 48 ++----------------- apps/sim/{lib/auth => ee}/sso/constants.ts | 4 ++ .../{hooks/queries => ee/sso/hooks}/sso.ts | 38 +-------------- apps/sim/executor/execution/block-executor.ts | 2 +- .../executor/handlers/agent/agent-handler.ts | 12 ++--- .../handlers/evaluator/evaluator-handler.ts | 2 +- .../handlers/router/router-handler.ts | 2 +- apps/sim/hooks/queries/credential-sets.ts | 2 + apps/sim/hooks/use-permission-config.ts | 4 +- apps/sim/lib/auth/auth.ts | 2 +- apps/sim/lib/copilot/process-contents.ts | 2 +- .../tools/server/blocks/get-block-config.ts | 2 +- .../tools/server/blocks/get-block-options.ts | 2 +- .../server/blocks/get-blocks-and-tools.ts | 2 +- .../server/blocks/get-blocks-metadata-tool.ts | 2 +- .../tools/server/blocks/get-trigger-blocks.ts | 2 +- .../tools/server/workflow/edit-workflow.ts | 2 +- 36 files changed, 110 insertions(+), 139 deletions(-) create mode 100644 apps/sim/ee/LICENSE create mode 100644 apps/sim/ee/README.md rename apps/sim/{app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/access-control => ee/access-control/components}/access-control.tsx (99%) rename apps/sim/{hooks/queries => ee/access-control/hooks}/permission-groups.ts (99%) rename apps/sim/{executor => ee/access-control}/utils/permission-check.ts (100%) rename apps/sim/{app/chat/components/auth/sso => ee/sso/components}/sso-auth.tsx (100%) rename apps/sim/{app/(auth)/sso => ee/sso/components}/sso-form.tsx (100%) rename apps/sim/{app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/sso/sso.tsx => ee/sso/components/sso-settings.tsx} (97%) rename apps/sim/{lib/auth => ee}/sso/constants.ts (85%) rename apps/sim/{hooks/queries => ee/sso/hooks}/sso.ts (69%) 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/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]/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/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..89dc83172 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) @@ -717,7 +715,6 @@ export function MCP({ initialServerId }: MCPProps) { `Refreshed MCP server: ${serverId}, workflows updated: ${result.workflowsUpdated}` ) - // If the active workflow was updated, reload its subblock values from DB const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId if (activeWorkflowId && result.updatedWorkflowIds?.includes(activeWorkflowId)) { logger.info(`Active workflow ${activeWorkflowId} was updated, reloading subblock values`) 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..f0b749f68 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,15 +52,16 @@ import { General, Integrations, MCP, - SSO, Subscription, TeamManagement, WorkflowMcpServers, } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components' import { TemplateProfile } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/template-profile/template-profile' +import { AccessControl } from '@/ee/access-control/components/access-control' +import { SSO } from '@/ee/sso/components/sso-settings' +import { ssoKeys, useSSOProviders } from '@/ee/sso/hooks/sso' import { generalSettingsKeys, useGeneralSettings } from '@/hooks/queries/general-settings' import { organizationKeys, useOrganizations } from '@/hooks/queries/organization' -import { ssoKeys, useSSOProviders } from '@/hooks/queries/sso' import { subscriptionKeys, useSubscriptionData } from '@/hooks/queries/subscription' import { usePermissionConfig } from '@/hooks/use-permission-config' import { useSettingsModalStore } from '@/stores/modals/settings/store' 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..59b08e4a9 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, @@ -31,7 +32,6 @@ import { streamingResponseFormatProcessor } from '@/executor/utils' import { buildBlockExecutionError, normalizeError } from '@/executor/utils/errors' import { isJSONString } from '@/executor/utils/json' import { filterOutputForLog } from '@/executor/utils/output-filter' -import { validateBlockType } from '@/executor/utils/permission-check' import type { VariableResolver } from '@/executor/variables/resolver' import type { SerializedBlock } from '@/serializer/types' import type { SubflowType } from '@/stores/workflows/workflow/types' 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/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/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' From f21fe2309c02eb1a7ed64f9c48f99f6291311b4a Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 2 Feb 2026 23:57:08 -0800 Subject: [PATCH 2/6] fix(formatting): consolidate duration formatting into shared utility (#3118) * fix(formatting): consolidate duration formatting into shared utility * fix(formatting): preserve original precision and rounding behavior * fix(logs): add precision to logs list duration formatting * fix(formatting): use parseFloat to preserve fractional milliseconds * feat(ee): add enterprise modules (#3121) * fix(formatting): return null for missing values, strip trailing zeros --- .../logs/components/logs-list/logs-list.tsx | 4 +- .../app/workspace/[workspaceId]/logs/utils.ts | 36 +------------ .../thinking-block/thinking-block.tsx | 11 ++-- .../components/tool-call/tool-call.tsx | 10 ++-- .../components/terminal/terminal.tsx | 8 +-- .../[workflowId]/components/terminal/utils.ts | 11 ---- .../training-modal/training-modal.tsx | 5 +- .../workspace-notification-delivery.ts | 16 +++--- apps/sim/components/ui/tool-call.tsx | 8 +-- apps/sim/lib/core/utils/formatting.ts | 50 +++++++++++++++---- 10 files changed, 68 insertions(+), 91 deletions(-) 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]/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/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..18b8cfef6 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 */ 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/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/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/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) From 710bf75bcac2a6d8aa095692e613474d2548e5b9 Mon Sep 17 00:00:00 2001 From: Waleed Date: Tue, 3 Feb 2026 10:09:03 -0800 Subject: [PATCH 3/6] fix(sidebar): right-click replaces selection, reset popover hover state (#3123) * fix(sidebar): right-click replaces selection, reset popover hover state * fix(queries): add userId to superuser query key for cache isolation --- .../credential-selector.tsx | 22 ++--- .../settings-modal/components/mcp/mcp.tsx | 1 + .../settings-modal/settings-modal.tsx | 95 +++++++++---------- .../components/folder-item/folder-item.tsx | 2 + .../workflow-item/workflow-item.tsx | 3 + .../emcn/components/popover/popover.tsx | 3 + apps/sim/hooks/queries/user-profile.ts | 35 +++++++ 7 files changed, 95 insertions(+), 66 deletions(-) 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({ { setSelectedServerId(serverId) 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 f0b749f68..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 @@ -63,6 +63,7 @@ import { ssoKeys, useSSOProviders } from '@/ee/sso/hooks/sso' import { generalSettingsKeys, useGeneralSettings } from '@/hooks/queries/general-settings' import { organizationKeys, useOrganizations } from '@/hooks/queries/organization' 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/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/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 + }) +} From 4ca00810b2ba3b2eaa5b344d03c7bffef386d755 Mon Sep 17 00:00:00 2001 From: Waleed Date: Tue, 3 Feb 2026 10:58:01 -0800 Subject: [PATCH 4/6] fix(http): serialize nested objects in form-urlencoded body (#3124) --- apps/sim/tools/http/request.test.ts | 24 ++++++++++++++++++++++++ apps/sim/tools/http/request.ts | 5 ++++- 2 files changed, 28 insertions(+), 1 deletion(-) 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() From c51f266ad78d5e657916e71c99642d17fb1d2db5 Mon Sep 17 00:00:00 2001 From: Waleed Date: Tue, 3 Feb 2026 15:26:09 -0800 Subject: [PATCH 5/6] fix(logs): use formatDuration utility and align file cards styling (#3125) --- .../file-download/file-download.tsx | 34 ++++++++----------- .../components/log-details/log-details.tsx | 3 +- 2 files changed, 17 insertions(+), 20 deletions(-) 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 }) || '—'}
From 4ba22527b66b85acbbd469811d37021777ca7703 Mon Sep 17 00:00:00 2001 From: Waleed Date: Tue, 3 Feb 2026 18:32:40 -0800 Subject: [PATCH 6/6] improvement(tag-dropdown): removed custom styling on tag dropdown popover, fixed execution ordering in terminal and loops entries (#3126) * improvement(tag-dropdown): removeed custom styling on tag dropdown popover, fixed execution ordering in terminal and loops entries * ack pr comments * handle old records --- .../app/api/workflows/[id]/execute/route.ts | 4 ++ .../components/tag-dropdown/tag-dropdown.tsx | 37 ++++++++------- .../terminal/hooks/use-terminal-filters.ts | 6 +-- .../[workflowId]/components/terminal/utils.ts | 23 ++++----- .../hooks/use-workflow-execution.ts | 16 ++++++- .../utils/workflow-execution-utils.ts | 2 + apps/sim/executor/execution/block-executor.ts | 47 +++++++++++++------ apps/sim/executor/execution/types.ts | 10 +++- apps/sim/executor/orchestrators/loop.ts | 7 ++- apps/sim/executor/orchestrators/parallel.ts | 7 ++- apps/sim/executor/types.ts | 29 +++++++++++- apps/sim/executor/utils/subflow-utils.ts | 5 +- .../workflows/executor/execution-events.ts | 15 +++--- apps/sim/stores/terminal/console/store.ts | 20 +++++++- apps/sim/stores/terminal/console/types.ts | 7 +-- 15 files changed, 167 insertions(+), 68 deletions(-) diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index 53161e42a..2ef6b0270 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -567,6 +567,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 }) @@ -579,6 +580,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: blockId, blockName, blockType, + executionOrder, ...(iterationContext && { iterationCurrent: iterationContext.iterationCurrent, iterationTotal: iterationContext.iterationTotal, @@ -617,6 +619,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, @@ -644,6 +647,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/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx index bc982daec..f233fe025 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx @@ -908,8 +908,10 @@ const PopoverContextCapture: React.FC<{ * When in nested folders, goes back one level at a time. * At the root folder level, closes the folder. */ -const TagDropdownBackButton: React.FC = () => { - 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/utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils.ts index 18b8cfef6..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 @@ -184,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 @@ -225,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(), @@ -234,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), @@ -251,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(), @@ -260,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), @@ -300,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]/hooks/use-workflow-execution.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts index 6dcad6c17..2b021b3c5 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 @@ -1163,6 +1167,7 @@ export function useWorkflowExecution() { if (existingLogs.length === 0) { // No blocks executed yet - this is a pre-execution error + // Use 0 for executionOrder so validation errors appear first addConsole({ input: {}, output: {}, @@ -1170,6 +1175,7 @@ export function useWorkflowExecution() { 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', @@ -1237,6 +1243,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: {}, @@ -1244,6 +1251,7 @@ export function useWorkflowExecution() { error: normalizedMessage, durationMs: 0, startedAt: new Date().toISOString(), + executionOrder: Number.MAX_SAFE_INTEGER, endedAt: new Date().toISOString(), workflowId: activeWorkflowId || '', blockId, @@ -1615,6 +1623,7 @@ export function useWorkflowExecution() { success: true, durationMs: data.durationMs, startedAt, + executionOrder: data.executionOrder, endedAt, }) @@ -1624,6 +1633,7 @@ export function useWorkflowExecution() { success: true, durationMs: data.durationMs, startedAt, + executionOrder: data.executionOrder, endedAt, workflowId, blockId: data.blockId, @@ -1653,6 +1663,7 @@ export function useWorkflowExecution() { output: {}, success: false, error: data.error, + executionOrder: data.executionOrder, durationMs: data.durationMs, startedAt, endedAt, @@ -1665,6 +1676,7 @@ export function useWorkflowExecution() { error: data.error, durationMs: data.durationMs, startedAt, + executionOrder: data.executionOrder, endedAt, workflowId, blockId: data.blockId, 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/executor/execution/block-executor.ts b/apps/sim/executor/execution/block-executor.ts index 59b08e4a9..6c97dd1a1 100644 --- a/apps/sim/executor/execution/block-executor.ts +++ b/apps/sim/executor/execution/block-executor.ts @@ -21,12 +21,13 @@ 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' @@ -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/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/lib/workflows/executor/execution-events.ts b/apps/sim/lib/workflows/executor/execution-events.ts index 6c3998e23..ba36f9787 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 = @@ -83,7 +79,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 @@ -104,8 +100,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 @@ -126,8 +122,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 @@ -228,6 +224,7 @@ export function createSSECallbacks(options: SSECallbackOptions) { blockId: string, blockName: string, blockType: string, + executionOrder: number, iterationContext?: { iterationCurrent: number; iterationTotal: number; iterationType: string } ) => { sendEvent({ @@ -239,6 +236,7 @@ export function createSSECallbacks(options: SSECallbackOptions) { blockId, blockName, blockType, + executionOrder, ...(iterationContext && { iterationCurrent: iterationContext.iterationCurrent, iterationTotal: iterationContext.iterationTotal, @@ -257,6 +255,7 @@ export function createSSECallbacks(options: SSECallbackOptions) { output: any executionTime: number startedAt: string + executionOrder: number endedAt: string }, iterationContext?: { iterationCurrent: number; iterationTotal: number; iterationType: string } @@ -284,6 +283,7 @@ export function createSSECallbacks(options: SSECallbackOptions) { error: callbackData.output.error, durationMs: callbackData.executionTime || 0, startedAt: callbackData.startedAt, + executionOrder: callbackData.executionOrder, endedAt: callbackData.endedAt, ...iterationData, }, @@ -302,6 +302,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