diff --git a/apps/docs/content/docs/en/execution/api.mdx b/apps/docs/content/docs/en/execution/api.mdx index 85e02eb02..860b44571 100644 --- a/apps/docs/content/docs/en/execution/api.mdx +++ b/apps/docs/content/docs/en/execution/api.mdx @@ -27,16 +27,16 @@ All API responses include information about your workflow execution limits and u "limits": { "workflowExecutionRateLimit": { "sync": { - "requestsPerMinute": 60, // Sustained rate limit per minute - "maxBurst": 120, // Maximum burst capacity - "remaining": 118, // Current tokens available (up to maxBurst) - "resetAt": "..." // When tokens next refill + "requestsPerMinute": 150, // Sustained rate limit per minute + "maxBurst": 300, // Maximum burst capacity + "remaining": 298, // Current tokens available (up to maxBurst) + "resetAt": "..." // When tokens next refill }, "async": { - "requestsPerMinute": 200, // Sustained rate limit per minute - "maxBurst": 400, // Maximum burst capacity - "remaining": 398, // Current tokens available - "resetAt": "..." // When tokens next refill + "requestsPerMinute": 1000, // Sustained rate limit per minute + "maxBurst": 2000, // Maximum burst capacity + "remaining": 1998, // Current tokens available + "resetAt": "..." // When tokens next refill } }, "usage": { @@ -107,28 +107,28 @@ Query workflow execution logs with extensive filtering options. } ], "nextCursor": "eyJzIjoiMjAyNS0wMS0wMVQxMjozNDo1Ni43ODlaIiwiaWQiOiJsb2dfYWJjMTIzIn0", - "limits": { - "workflowExecutionRateLimit": { - "sync": { - "requestsPerMinute": 60, - "maxBurst": 120, - "remaining": 118, - "resetAt": "2025-01-01T12:35:56.789Z" +"limits": { + "workflowExecutionRateLimit": { + "sync": { + "requestsPerMinute": 150, + "maxBurst": 300, + "remaining": 298, + "resetAt": "2025-01-01T12:35:56.789Z" + }, + "async": { + "requestsPerMinute": 1000, + "maxBurst": 2000, + "remaining": 1998, + "resetAt": "2025-01-01T12:35:56.789Z" + } }, - "async": { - "requestsPerMinute": 200, - "maxBurst": 400, - "remaining": 398, - "resetAt": "2025-01-01T12:35:56.789Z" + "usage": { + "currentPeriodCost": 1.234, + "limit": 10, + "plan": "pro", + "isExceeded": false } - }, - "usage": { - "currentPeriodCost": 1.234, - "limit": 10, - "plan": "pro", - "isExceeded": false } - } } ``` @@ -188,15 +188,15 @@ Retrieve detailed information about a specific log entry. "limits": { "workflowExecutionRateLimit": { "sync": { - "requestsPerMinute": 60, - "maxBurst": 120, - "remaining": 118, + "requestsPerMinute": 150, + "maxBurst": 300, + "remaining": 298, "resetAt": "2025-01-01T12:35:56.789Z" }, "async": { - "requestsPerMinute": 200, - "maxBurst": 400, - "remaining": 398, + "requestsPerMinute": 1000, + "maxBurst": 2000, + "remaining": 1998, "resetAt": "2025-01-01T12:35:56.789Z" } }, @@ -477,10 +477,10 @@ The API uses a **token bucket algorithm** for rate limiting, providing fair usag | Plan | Requests/Minute | Burst Capacity | |------|-----------------|----------------| -| Free | 10 | 20 | -| Pro | 30 | 60 | -| Team | 60 | 120 | -| Enterprise | 120 | 240 | +| Free | 30 | 60 | +| Pro | 100 | 200 | +| Team | 200 | 400 | +| Enterprise | 500 | 1000 | **How it works:** - Tokens refill at `requestsPerMinute` rate diff --git a/apps/docs/content/docs/en/execution/costs.mdx b/apps/docs/content/docs/en/execution/costs.mdx index 65dcd8a58..5d88091b1 100644 --- a/apps/docs/content/docs/en/execution/costs.mdx +++ b/apps/docs/content/docs/en/execution/costs.mdx @@ -170,16 +170,16 @@ curl -X GET -H "X-API-Key: YOUR_API_KEY" -H "Content-Type: application/json" htt "rateLimit": { "sync": { "isLimited": false, - "requestsPerMinute": 25, - "maxBurst": 50, - "remaining": 50, + "requestsPerMinute": 150, + "maxBurst": 300, + "remaining": 300, "resetAt": "2025-09-08T22:51:55.999Z" }, "async": { "isLimited": false, - "requestsPerMinute": 200, - "maxBurst": 400, - "remaining": 400, + "requestsPerMinute": 1000, + "maxBurst": 2000, + "remaining": 2000, "resetAt": "2025-09-08T22:51:56.155Z" }, "authType": "api" @@ -206,11 +206,11 @@ curl -X GET -H "X-API-Key: YOUR_API_KEY" -H "Content-Type: application/json" htt Different subscription plans have different usage limits: -| Plan | Monthly Usage Limit | Rate Limits (per minute) | -|------|-------------------|-------------------------| -| **Free** | $20 | 5 sync, 10 async | -| **Pro** | $100 | 10 sync, 50 async | -| **Team** | $500 (pooled) | 50 sync, 100 async | +| Plan | Monthly Usage Included | Rate Limits (per minute) | +|------|------------------------|-------------------------| +| **Free** | $20 | 50 sync, 200 async | +| **Pro** | $20 (adjustable) | 150 sync, 1,000 async | +| **Team** | $40/seat (pooled, adjustable) | 300 sync, 2,500 async | | **Enterprise** | Custom | Custom | ## Billing Model diff --git a/apps/sim/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx b/apps/sim/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx index ab198204d..a501f0d23 100644 --- a/apps/sim/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx +++ b/apps/sim/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx @@ -1,10 +1,11 @@ 'use client' import type React from 'react' -import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react' +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { useQueryClient } from '@tanstack/react-query' import { useParams } from 'next/navigation' +import { useSocket } from '@/app/workspace/providers/socket-provider' import { useWorkspacePermissionsQuery, type WorkspacePermissions, @@ -57,14 +58,42 @@ export function WorkspacePermissionsProvider({ children }: WorkspacePermissionsP const [hasShownOfflineNotification, setHasShownOfflineNotification] = useState(false) const hasOperationError = useOperationQueueStore((state) => state.hasOperationError) const addNotification = useNotificationStore((state) => state.addNotification) + const removeNotification = useNotificationStore((state) => state.removeNotification) + const { isReconnecting } = useSocket() + const reconnectingNotificationIdRef = useRef(null) const isOfflineMode = hasOperationError + useEffect(() => { + if (isReconnecting && !reconnectingNotificationIdRef.current && !isOfflineMode) { + const id = addNotification({ + level: 'error', + message: 'Reconnecting...', + }) + reconnectingNotificationIdRef.current = id + } else if (!isReconnecting && reconnectingNotificationIdRef.current) { + removeNotification(reconnectingNotificationIdRef.current) + reconnectingNotificationIdRef.current = null + } + + return () => { + if (reconnectingNotificationIdRef.current) { + removeNotification(reconnectingNotificationIdRef.current) + reconnectingNotificationIdRef.current = null + } + } + }, [isReconnecting, isOfflineMode, addNotification, removeNotification]) + useEffect(() => { if (!isOfflineMode || hasShownOfflineNotification) { return } + if (reconnectingNotificationIdRef.current) { + removeNotification(reconnectingNotificationIdRef.current) + reconnectingNotificationIdRef.current = null + } + try { addNotification({ level: 'error', @@ -78,7 +107,7 @@ export function WorkspacePermissionsProvider({ children }: WorkspacePermissionsP } catch (error) { logger.error('Failed to add offline notification', { error }) } - }, [addNotification, hasShownOfflineNotification, isOfflineMode]) + }, [addNotification, removeNotification, hasShownOfflineNotification, isOfflineMode]) const { data: workspacePermissions, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.test.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.test.ts new file mode 100644 index 000000000..8d2548c13 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.test.ts @@ -0,0 +1,443 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' + +interface StoredTool { + type: string + title?: string + toolId?: string + params?: Record + customToolId?: string + schema?: any + code?: string + operation?: string + usageControl?: 'auto' | 'force' | 'none' +} + +const isMcpToolAlreadySelected = (selectedTools: StoredTool[], mcpToolId: string): boolean => { + return selectedTools.some((tool) => tool.type === 'mcp' && tool.toolId === mcpToolId) +} + +const isCustomToolAlreadySelected = ( + selectedTools: StoredTool[], + customToolId: string +): boolean => { + return selectedTools.some( + (tool) => tool.type === 'custom-tool' && tool.customToolId === customToolId + ) +} + +const isWorkflowAlreadySelected = (selectedTools: StoredTool[], workflowId: string): boolean => { + return selectedTools.some( + (tool) => tool.type === 'workflow_input' && tool.params?.workflowId === workflowId + ) +} + +describe('isMcpToolAlreadySelected', () => { + describe('basic functionality', () => { + it.concurrent('returns false when selectedTools is empty', () => { + expect(isMcpToolAlreadySelected([], 'mcp-tool-123')).toBe(false) + }) + + it.concurrent('returns false when MCP tool is not in selectedTools', () => { + const selectedTools: StoredTool[] = [ + { type: 'mcp', toolId: 'different-mcp-tool', title: 'Different Tool' }, + ] + expect(isMcpToolAlreadySelected(selectedTools, 'mcp-tool-123')).toBe(false) + }) + + it.concurrent('returns true when MCP tool is already selected', () => { + const selectedTools: StoredTool[] = [ + { type: 'mcp', toolId: 'mcp-tool-123', title: 'My MCP Tool' }, + ] + expect(isMcpToolAlreadySelected(selectedTools, 'mcp-tool-123')).toBe(true) + }) + + it.concurrent('returns true when MCP tool is one of many selected tools', () => { + const selectedTools: StoredTool[] = [ + { type: 'custom-tool', customToolId: 'custom-1' }, + { type: 'mcp', toolId: 'mcp-tool-123', title: 'My MCP Tool' }, + { type: 'workflow_input', toolId: 'workflow_executor' }, + ] + expect(isMcpToolAlreadySelected(selectedTools, 'mcp-tool-123')).toBe(true) + }) + }) + + describe('type discrimination', () => { + it.concurrent('does not match non-MCP tools with same toolId', () => { + const selectedTools: StoredTool[] = [{ type: 'http_request', toolId: 'mcp-tool-123' }] + expect(isMcpToolAlreadySelected(selectedTools, 'mcp-tool-123')).toBe(false) + }) + + it.concurrent('does not match custom tools even with toolId set', () => { + const selectedTools: StoredTool[] = [ + { type: 'custom-tool', toolId: 'custom-mcp-tool-123', customToolId: 'db-id' }, + ] + expect(isMcpToolAlreadySelected(selectedTools, 'mcp-tool-123')).toBe(false) + }) + }) + + describe('multiple MCP tools', () => { + it.concurrent('correctly identifies first of multiple MCP tools', () => { + const selectedTools: StoredTool[] = [ + { type: 'mcp', toolId: 'mcp-tool-1', title: 'Tool 1' }, + { type: 'mcp', toolId: 'mcp-tool-2', title: 'Tool 2' }, + { type: 'mcp', toolId: 'mcp-tool-3', title: 'Tool 3' }, + ] + expect(isMcpToolAlreadySelected(selectedTools, 'mcp-tool-1')).toBe(true) + }) + + it.concurrent('correctly identifies middle MCP tool', () => { + const selectedTools: StoredTool[] = [ + { type: 'mcp', toolId: 'mcp-tool-1', title: 'Tool 1' }, + { type: 'mcp', toolId: 'mcp-tool-2', title: 'Tool 2' }, + { type: 'mcp', toolId: 'mcp-tool-3', title: 'Tool 3' }, + ] + expect(isMcpToolAlreadySelected(selectedTools, 'mcp-tool-2')).toBe(true) + }) + + it.concurrent('correctly identifies last MCP tool', () => { + const selectedTools: StoredTool[] = [ + { type: 'mcp', toolId: 'mcp-tool-1', title: 'Tool 1' }, + { type: 'mcp', toolId: 'mcp-tool-2', title: 'Tool 2' }, + { type: 'mcp', toolId: 'mcp-tool-3', title: 'Tool 3' }, + ] + expect(isMcpToolAlreadySelected(selectedTools, 'mcp-tool-3')).toBe(true) + }) + + it.concurrent('returns false for non-existent MCP tool among many', () => { + const selectedTools: StoredTool[] = [ + { type: 'mcp', toolId: 'mcp-tool-1', title: 'Tool 1' }, + { type: 'mcp', toolId: 'mcp-tool-2', title: 'Tool 2' }, + ] + expect(isMcpToolAlreadySelected(selectedTools, 'mcp-tool-999')).toBe(false) + }) + }) +}) + +describe('isCustomToolAlreadySelected', () => { + describe('basic functionality', () => { + it.concurrent('returns false when selectedTools is empty', () => { + expect(isCustomToolAlreadySelected([], 'custom-tool-123')).toBe(false) + }) + + it.concurrent('returns false when custom tool is not in selectedTools', () => { + const selectedTools: StoredTool[] = [ + { type: 'custom-tool', customToolId: 'different-custom-tool' }, + ] + expect(isCustomToolAlreadySelected(selectedTools, 'custom-tool-123')).toBe(false) + }) + + it.concurrent('returns true when custom tool is already selected', () => { + const selectedTools: StoredTool[] = [{ type: 'custom-tool', customToolId: 'custom-tool-123' }] + expect(isCustomToolAlreadySelected(selectedTools, 'custom-tool-123')).toBe(true) + }) + + it.concurrent('returns true when custom tool is one of many selected tools', () => { + const selectedTools: StoredTool[] = [ + { type: 'mcp', toolId: 'mcp-1', title: 'MCP Tool' }, + { type: 'custom-tool', customToolId: 'custom-tool-123' }, + { type: 'http_request', toolId: 'http_request_tool' }, + ] + expect(isCustomToolAlreadySelected(selectedTools, 'custom-tool-123')).toBe(true) + }) + }) + + describe('type discrimination', () => { + it.concurrent('does not match non-custom tools with similar IDs', () => { + const selectedTools: StoredTool[] = [ + { type: 'mcp', toolId: 'custom-tool-123', title: 'MCP with similar ID' }, + ] + expect(isCustomToolAlreadySelected(selectedTools, 'custom-tool-123')).toBe(false) + }) + + it.concurrent('does not match MCP tools even if customToolId happens to match', () => { + const selectedTools: StoredTool[] = [ + { type: 'mcp', toolId: 'mcp-id', customToolId: 'custom-tool-123' } as StoredTool, + ] + expect(isCustomToolAlreadySelected(selectedTools, 'custom-tool-123')).toBe(false) + }) + }) + + describe('legacy inline custom tools', () => { + it.concurrent('does not match legacy inline tools without customToolId', () => { + const selectedTools: StoredTool[] = [ + { + type: 'custom-tool', + title: 'Legacy Tool', + toolId: 'custom-myFunction', + schema: { function: { name: 'myFunction' } }, + code: 'return true', + }, + ] + expect(isCustomToolAlreadySelected(selectedTools, 'custom-tool-123')).toBe(false) + }) + + it.concurrent('does not false-positive on legacy tools when checking for database tool', () => { + const selectedTools: StoredTool[] = [ + { + type: 'custom-tool', + title: 'Legacy Tool', + schema: { function: { name: 'sameName' } }, + code: 'return true', + }, + ] + expect(isCustomToolAlreadySelected(selectedTools, 'db-tool-1')).toBe(false) + }) + }) + + describe('multiple custom tools', () => { + it.concurrent('correctly identifies first of multiple custom tools', () => { + const selectedTools: StoredTool[] = [ + { type: 'custom-tool', customToolId: 'custom-1' }, + { type: 'custom-tool', customToolId: 'custom-2' }, + { type: 'custom-tool', customToolId: 'custom-3' }, + ] + expect(isCustomToolAlreadySelected(selectedTools, 'custom-1')).toBe(true) + }) + + it.concurrent('correctly identifies middle custom tool', () => { + const selectedTools: StoredTool[] = [ + { type: 'custom-tool', customToolId: 'custom-1' }, + { type: 'custom-tool', customToolId: 'custom-2' }, + { type: 'custom-tool', customToolId: 'custom-3' }, + ] + expect(isCustomToolAlreadySelected(selectedTools, 'custom-2')).toBe(true) + }) + + it.concurrent('correctly identifies last custom tool', () => { + const selectedTools: StoredTool[] = [ + { type: 'custom-tool', customToolId: 'custom-1' }, + { type: 'custom-tool', customToolId: 'custom-2' }, + { type: 'custom-tool', customToolId: 'custom-3' }, + ] + expect(isCustomToolAlreadySelected(selectedTools, 'custom-3')).toBe(true) + }) + + it.concurrent('returns false for non-existent custom tool among many', () => { + const selectedTools: StoredTool[] = [ + { type: 'custom-tool', customToolId: 'custom-1' }, + { type: 'custom-tool', customToolId: 'custom-2' }, + ] + expect(isCustomToolAlreadySelected(selectedTools, 'custom-999')).toBe(false) + }) + }) + + describe('mixed tool types', () => { + it.concurrent('correctly identifies custom tool in mixed list', () => { + const selectedTools: StoredTool[] = [ + { type: 'mcp', toolId: 'mcp-tool-1', title: 'MCP Tool' }, + { type: 'custom-tool', customToolId: 'custom-tool-123' }, + { type: 'http_request', toolId: 'http_request' }, + { type: 'workflow_input', toolId: 'workflow_executor' }, + { type: 'custom-tool', title: 'Legacy', schema: {}, code: '' }, + ] + expect(isCustomToolAlreadySelected(selectedTools, 'custom-tool-123')).toBe(true) + }) + + it.concurrent('does not confuse MCP toolId with custom customToolId', () => { + const selectedTools: StoredTool[] = [ + { type: 'mcp', toolId: 'shared-id-123', title: 'MCP Tool' }, + { type: 'custom-tool', customToolId: 'different-id' }, + ] + expect(isCustomToolAlreadySelected(selectedTools, 'shared-id-123')).toBe(false) + }) + }) +}) + +describe('isWorkflowAlreadySelected', () => { + describe('basic functionality', () => { + it.concurrent('returns false when selectedTools is empty', () => { + expect(isWorkflowAlreadySelected([], 'workflow-123')).toBe(false) + }) + + it.concurrent('returns false when workflow is not in selectedTools', () => { + const selectedTools: StoredTool[] = [ + { + type: 'workflow_input', + toolId: 'workflow_executor', + params: { workflowId: 'different-workflow' }, + }, + ] + expect(isWorkflowAlreadySelected(selectedTools, 'workflow-123')).toBe(false) + }) + + it.concurrent('returns true when workflow is already selected', () => { + const selectedTools: StoredTool[] = [ + { + type: 'workflow_input', + toolId: 'workflow_executor', + params: { workflowId: 'workflow-123' }, + }, + ] + expect(isWorkflowAlreadySelected(selectedTools, 'workflow-123')).toBe(true) + }) + + it.concurrent('returns true when workflow is one of many selected tools', () => { + const selectedTools: StoredTool[] = [ + { type: 'mcp', toolId: 'mcp-1', title: 'MCP Tool' }, + { + type: 'workflow_input', + toolId: 'workflow_executor', + params: { workflowId: 'workflow-123' }, + }, + { type: 'custom-tool', customToolId: 'custom-1' }, + ] + expect(isWorkflowAlreadySelected(selectedTools, 'workflow-123')).toBe(true) + }) + }) + + describe('type discrimination', () => { + it.concurrent('does not match non-workflow_input tools', () => { + const selectedTools: StoredTool[] = [ + { type: 'mcp', toolId: 'workflow-123', params: { workflowId: 'workflow-123' } }, + ] + expect(isWorkflowAlreadySelected(selectedTools, 'workflow-123')).toBe(false) + }) + + it.concurrent('does not match workflow_input without params', () => { + const selectedTools: StoredTool[] = [{ type: 'workflow_input', toolId: 'workflow_executor' }] + expect(isWorkflowAlreadySelected(selectedTools, 'workflow-123')).toBe(false) + }) + + it.concurrent('does not match workflow_input with different workflowId in params', () => { + const selectedTools: StoredTool[] = [ + { + type: 'workflow_input', + toolId: 'workflow_executor', + params: { workflowId: 'other-workflow' }, + }, + ] + expect(isWorkflowAlreadySelected(selectedTools, 'workflow-123')).toBe(false) + }) + }) + + describe('multiple workflows', () => { + it.concurrent('allows different workflows to be selected', () => { + const selectedTools: StoredTool[] = [ + { + type: 'workflow_input', + toolId: 'workflow_executor', + params: { workflowId: 'workflow-a' }, + }, + { + type: 'workflow_input', + toolId: 'workflow_executor', + params: { workflowId: 'workflow-b' }, + }, + ] + expect(isWorkflowAlreadySelected(selectedTools, 'workflow-a')).toBe(true) + expect(isWorkflowAlreadySelected(selectedTools, 'workflow-b')).toBe(true) + expect(isWorkflowAlreadySelected(selectedTools, 'workflow-c')).toBe(false) + }) + + it.concurrent('correctly identifies specific workflow among many', () => { + const selectedTools: StoredTool[] = [ + { + type: 'workflow_input', + toolId: 'workflow_executor', + params: { workflowId: 'workflow-1' }, + }, + { + type: 'workflow_input', + toolId: 'workflow_executor', + params: { workflowId: 'workflow-2' }, + }, + { + type: 'workflow_input', + toolId: 'workflow_executor', + params: { workflowId: 'workflow-3' }, + }, + ] + expect(isWorkflowAlreadySelected(selectedTools, 'workflow-2')).toBe(true) + expect(isWorkflowAlreadySelected(selectedTools, 'workflow-999')).toBe(false) + }) + }) +}) + +describe('duplicate prevention integration scenarios', () => { + describe('add then try to re-add', () => { + it.concurrent('prevents re-adding the same MCP tool', () => { + const selectedTools: StoredTool[] = [ + { + type: 'mcp', + toolId: 'planetscale-query', + title: 'PlanetScale Query', + params: { serverId: 'server-1' }, + }, + ] + expect(isMcpToolAlreadySelected(selectedTools, 'planetscale-query')).toBe(true) + }) + + it.concurrent('prevents re-adding the same custom tool', () => { + const selectedTools: StoredTool[] = [ + { + type: 'custom-tool', + customToolId: 'my-custom-tool-uuid', + usageControl: 'auto', + }, + ] + expect(isCustomToolAlreadySelected(selectedTools, 'my-custom-tool-uuid')).toBe(true) + }) + + it.concurrent('prevents re-adding the same workflow', () => { + const selectedTools: StoredTool[] = [ + { + type: 'workflow_input', + toolId: 'workflow_executor', + params: { workflowId: 'my-workflow-uuid' }, + }, + ] + expect(isWorkflowAlreadySelected(selectedTools, 'my-workflow-uuid')).toBe(true) + }) + }) + + describe('remove then re-add', () => { + it.concurrent('allows re-adding MCP tool after removal', () => { + const selectedToolsAfterRemoval: StoredTool[] = [] + expect(isMcpToolAlreadySelected(selectedToolsAfterRemoval, 'planetscale-query')).toBe(false) + }) + + it.concurrent('allows re-adding custom tool after removal', () => { + const selectedToolsAfterRemoval: StoredTool[] = [ + { type: 'mcp', toolId: 'some-other-tool', title: 'Other' }, + ] + expect(isCustomToolAlreadySelected(selectedToolsAfterRemoval, 'my-custom-tool-uuid')).toBe( + false + ) + }) + + it.concurrent('allows re-adding workflow after removal', () => { + const selectedToolsAfterRemoval: StoredTool[] = [ + { type: 'mcp', toolId: 'some-tool', title: 'Other' }, + ] + expect(isWorkflowAlreadySelected(selectedToolsAfterRemoval, 'my-workflow-uuid')).toBe(false) + }) + }) + + describe('different tools with similar names', () => { + it.concurrent('allows adding different MCP tools from same server', () => { + const selectedTools: StoredTool[] = [ + { type: 'mcp', toolId: 'server1-tool-a', title: 'Tool A', params: { serverId: 'server1' } }, + ] + expect(isMcpToolAlreadySelected(selectedTools, 'server1-tool-b')).toBe(false) + }) + + it.concurrent('allows adding different custom tools', () => { + const selectedTools: StoredTool[] = [{ type: 'custom-tool', customToolId: 'custom-a' }] + expect(isCustomToolAlreadySelected(selectedTools, 'custom-b')).toBe(false) + }) + + it.concurrent('allows adding different workflows', () => { + const selectedTools: StoredTool[] = [ + { + type: 'workflow_input', + toolId: 'workflow_executor', + params: { workflowId: 'workflow-a' }, + }, + ] + expect(isWorkflowAlreadySelected(selectedTools, 'workflow-b')).toBe(false) + }) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx index c52e247ff..cd2f342a3 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx @@ -1226,6 +1226,40 @@ export const ToolInput = memo(function ToolInput({ return selectedTools.some((tool) => tool.toolId === toolId) } + /** + * Checks if an MCP tool is already selected. + * + * @param mcpToolId - The MCP tool identifier to check + * @returns `true` if the MCP tool is already selected + */ + const isMcpToolAlreadySelected = (mcpToolId: string): boolean => { + return selectedTools.some((tool) => tool.type === 'mcp' && tool.toolId === mcpToolId) + } + + /** + * Checks if a custom tool is already selected. + * + * @param customToolId - The custom tool identifier to check + * @returns `true` if the custom tool is already selected + */ + const isCustomToolAlreadySelected = (customToolId: string): boolean => { + return selectedTools.some( + (tool) => tool.type === 'custom-tool' && tool.customToolId === customToolId + ) + } + + /** + * Checks if a workflow is already selected. + * + * @param workflowId - The workflow identifier to check + * @returns `true` if the workflow is already selected + */ + const isWorkflowAlreadySelected = (workflowId: string): boolean => { + return selectedTools.some( + (tool) => tool.type === 'workflow_input' && tool.params?.workflowId === workflowId + ) + } + /** * Checks if a block supports multiple operations. * @@ -1745,24 +1779,29 @@ export const ToolInput = memo(function ToolInput({ if (!permissionConfig.disableCustomTools && customTools.length > 0) { groups.push({ section: 'Custom Tools', - items: customTools.map((customTool) => ({ - label: customTool.title, - value: `custom-${customTool.id}`, - iconElement: createToolIcon('#3B82F6', WrenchIcon), - onSelect: () => { - const newTool: StoredTool = { - type: 'custom-tool', - customToolId: customTool.id, - usageControl: 'auto', - isExpanded: true, - } - setStoreValue([ - ...selectedTools.map((tool) => ({ ...tool, isExpanded: false })), - newTool, - ]) - setOpen(false) - }, - })), + items: customTools.map((customTool) => { + const alreadySelected = isCustomToolAlreadySelected(customTool.id) + return { + label: customTool.title, + value: `custom-${customTool.id}`, + iconElement: createToolIcon('#3B82F6', WrenchIcon), + disabled: isPreview || alreadySelected, + onSelect: () => { + if (alreadySelected) return + const newTool: StoredTool = { + type: 'custom-tool', + customToolId: customTool.id, + usageControl: 'auto', + isExpanded: true, + } + setStoreValue([ + ...selectedTools.map((tool) => ({ ...tool, isExpanded: false })), + newTool, + ]) + setOpen(false) + }, + } + }), }) } @@ -1772,11 +1811,13 @@ export const ToolInput = memo(function ToolInput({ section: 'MCP Tools', items: availableMcpTools.map((mcpTool) => { const server = mcpServers.find((s) => s.id === mcpTool.serverId) + const alreadySelected = isMcpToolAlreadySelected(mcpTool.id) return { label: mcpTool.name, value: `mcp-${mcpTool.id}`, iconElement: createToolIcon(mcpTool.bgColor || '#6366F1', mcpTool.icon || McpIcon), onSelect: () => { + if (alreadySelected) return const newTool: StoredTool = { type: 'mcp', title: mcpTool.name, @@ -1796,7 +1837,7 @@ export const ToolInput = memo(function ToolInput({ } handleMcpToolSelect(newTool, true) }, - disabled: isPreview || disabled, + disabled: isPreview || disabled || alreadySelected, } }), }) @@ -1810,12 +1851,17 @@ export const ToolInput = memo(function ToolInput({ if (builtInTools.length > 0) { groups.push({ section: 'Built-in Tools', - items: builtInTools.map((block) => ({ - label: block.name, - value: `builtin-${block.type}`, - iconElement: createToolIcon(block.bgColor, block.icon), - onSelect: () => handleSelectTool(block), - })), + items: builtInTools.map((block) => { + const toolId = getToolIdForOperation(block.type, undefined) + const alreadySelected = toolId ? isToolAlreadySelected(toolId, block.type) : false + return { + label: block.name, + value: `builtin-${block.type}`, + iconElement: createToolIcon(block.bgColor, block.icon), + disabled: isPreview || alreadySelected, + onSelect: () => handleSelectTool(block), + } + }), }) } @@ -1823,12 +1869,17 @@ export const ToolInput = memo(function ToolInput({ if (integrations.length > 0) { groups.push({ section: 'Integrations', - items: integrations.map((block) => ({ - label: block.name, - value: `builtin-${block.type}`, - iconElement: createToolIcon(block.bgColor, block.icon), - onSelect: () => handleSelectTool(block), - })), + items: integrations.map((block) => { + const toolId = getToolIdForOperation(block.type, undefined) + const alreadySelected = toolId ? isToolAlreadySelected(toolId, block.type) : false + return { + label: block.name, + value: `builtin-${block.type}`, + iconElement: createToolIcon(block.bgColor, block.icon), + disabled: isPreview || alreadySelected, + onSelect: () => handleSelectTool(block), + } + }), }) } @@ -1836,29 +1887,33 @@ export const ToolInput = memo(function ToolInput({ if (availableWorkflows.length > 0) { groups.push({ section: 'Workflows', - items: availableWorkflows.map((workflow) => ({ - label: workflow.name, - value: `workflow-${workflow.id}`, - iconElement: createToolIcon('#6366F1', WorkflowIcon), - onSelect: () => { - const newTool: StoredTool = { - type: 'workflow_input', - title: 'Workflow', - toolId: 'workflow_executor', - params: { - workflowId: workflow.id, - }, - isExpanded: true, - usageControl: 'auto', - } - setStoreValue([ - ...selectedTools.map((tool) => ({ ...tool, isExpanded: false })), - newTool, - ]) - setOpen(false) - }, - disabled: isPreview || disabled, - })), + items: availableWorkflows.map((workflow) => { + const alreadySelected = isWorkflowAlreadySelected(workflow.id) + return { + label: workflow.name, + value: `workflow-${workflow.id}`, + iconElement: createToolIcon('#6366F1', WorkflowIcon), + onSelect: () => { + if (alreadySelected) return + const newTool: StoredTool = { + type: 'workflow_input', + title: 'Workflow', + toolId: 'workflow_executor', + params: { + workflowId: workflow.id, + }, + isExpanded: true, + usageControl: 'auto', + } + setStoreValue([ + ...selectedTools.map((tool) => ({ ...tool, isExpanded: false })), + newTool, + ]) + setOpen(false) + }, + disabled: isPreview || disabled || alreadySelected, + } + }), }) } @@ -1877,6 +1932,11 @@ export const ToolInput = memo(function ToolInput({ permissionConfig.disableCustomTools, permissionConfig.disableMcpTools, availableWorkflows, + getToolIdForOperation, + isToolAlreadySelected, + isMcpToolAlreadySelected, + isCustomToolAlreadySelected, + isWorkflowAlreadySelected, ]) const toolRequiresOAuth = (toolId: string): boolean => { diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/plan-configs.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/plan-configs.ts index f604dd76e..3438d72b2 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/plan-configs.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/plan-configs.ts @@ -13,8 +13,8 @@ import { SlackMonoIcon } from '@/components/icons' import type { PlanFeature } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/plan-card' export const PRO_PLAN_FEATURES: PlanFeature[] = [ - { icon: Zap, text: '25 runs per minute (sync)' }, - { icon: Clock, text: '200 runs per minute (async)' }, + { icon: Zap, text: '150 runs per minute (sync)' }, + { icon: Clock, text: '1,000 runs per minute (async)' }, { icon: HardDrive, text: '50GB file storage' }, { icon: Building2, text: 'Unlimited workspaces' }, { icon: Users, text: 'Unlimited invites' }, @@ -22,8 +22,8 @@ export const PRO_PLAN_FEATURES: PlanFeature[] = [ ] export const TEAM_PLAN_FEATURES: PlanFeature[] = [ - { icon: Zap, text: '75 runs per minute (sync)' }, - { icon: Clock, text: '500 runs per minute (async)' }, + { icon: Zap, text: '300 runs per minute (sync)' }, + { icon: Clock, text: '2,500 runs per minute (async)' }, { icon: HardDrive, text: '500GB file storage (pooled)' }, { icon: Building2, text: 'Unlimited workspaces' }, { icon: Users, text: 'Unlimited invites' }, diff --git a/apps/sim/app/workspace/providers/socket-provider.tsx b/apps/sim/app/workspace/providers/socket-provider.tsx index 0b4c5d017..425c64882 100644 --- a/apps/sim/app/workspace/providers/socket-provider.tsx +++ b/apps/sim/app/workspace/providers/socket-provider.tsx @@ -49,6 +49,7 @@ interface SocketContextType { socket: Socket | null isConnected: boolean isConnecting: boolean + isReconnecting: boolean authFailed: boolean currentWorkflowId: string | null currentSocketId: string | null @@ -66,9 +67,16 @@ interface SocketContextType { blockId: string, subblockId: string, value: any, - operationId?: string + operationId: string | undefined, + workflowId: string + ) => void + emitVariableUpdate: ( + variableId: string, + field: string, + value: any, + operationId: string | undefined, + workflowId: string ) => void - emitVariableUpdate: (variableId: string, field: string, value: any, operationId?: string) => void emitCursorUpdate: (cursor: { x: number; y: number } | null) => void emitSelectionUpdate: (selection: { type: 'block' | 'edge' | 'none'; id?: string }) => void @@ -88,6 +96,7 @@ const SocketContext = createContext({ socket: null, isConnected: false, isConnecting: false, + isReconnecting: false, authFailed: false, currentWorkflowId: null, currentSocketId: null, @@ -122,6 +131,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) { const [socket, setSocket] = useState(null) const [isConnected, setIsConnected] = useState(false) const [isConnecting, setIsConnecting] = useState(false) + const [isReconnecting, setIsReconnecting] = useState(false) const [currentWorkflowId, setCurrentWorkflowId] = useState(null) const [currentSocketId, setCurrentSocketId] = useState(null) const [presenceUsers, setPresenceUsers] = useState([]) @@ -236,20 +246,19 @@ export function SocketProvider({ children, user }: SocketProviderProps) { setCurrentWorkflowId(null) setPresenceUsers([]) - logger.info('Socket disconnected', { - reason, - }) + // socket.active indicates if auto-reconnect will happen + if (socketInstance.active) { + setIsReconnecting(true) + logger.info('Socket disconnected, will auto-reconnect', { reason }) + } else { + setIsReconnecting(false) + logger.info('Socket disconnected, no auto-reconnect', { reason }) + } }) - socketInstance.on('connect_error', (error: any) => { + socketInstance.on('connect_error', (error: Error) => { setIsConnecting(false) - logger.error('Socket connection error:', { - message: error.message, - stack: error.stack, - description: error.description, - type: error.type, - transport: error.transport, - }) + logger.error('Socket connection error:', { message: error.message }) // Check if this is an authentication failure const isAuthError = @@ -261,43 +270,41 @@ export function SocketProvider({ children, user }: SocketProviderProps) { logger.warn( 'Authentication failed - stopping reconnection attempts. User may need to refresh/re-login.' ) - // Stop reconnection attempts to prevent infinite loop socketInstance.disconnect() - // Reset state to allow re-initialization when session is restored setSocket(null) setAuthFailed(true) + setIsReconnecting(false) initializedRef.current = false + } else if (socketInstance.active) { + // Temporary failure, will auto-reconnect + setIsReconnecting(true) } }) - socketInstance.on('reconnect', (attemptNumber) => { + // Reconnection events are on the Manager (socket.io), not the socket itself + socketInstance.io.on('reconnect', (attemptNumber) => { setIsConnected(true) + setIsReconnecting(false) setCurrentSocketId(socketInstance.id ?? null) logger.info('Socket reconnected successfully', { attemptNumber, socketId: socketInstance.id, transport: socketInstance.io.engine?.transport?.name, }) - // Note: join-workflow is handled by the useEffect watching isConnected }) - socketInstance.on('reconnect_attempt', (attemptNumber) => { - logger.info('Socket reconnection attempt (fresh token will be generated)', { - attemptNumber, - timestamp: new Date().toISOString(), - }) + socketInstance.io.on('reconnect_attempt', (attemptNumber) => { + setIsReconnecting(true) + logger.info('Socket reconnection attempt', { attemptNumber }) }) - socketInstance.on('reconnect_error', (error: any) => { - logger.error('Socket reconnection error:', { - message: error.message, - attemptNumber: error.attemptNumber, - type: error.type, - }) + socketInstance.io.on('reconnect_error', (error: Error) => { + logger.error('Socket reconnection error:', { message: error.message }) }) - socketInstance.on('reconnect_failed', () => { + socketInstance.io.on('reconnect_failed', () => { logger.error('Socket reconnection failed - all attempts exhausted') + setIsReconnecting(false) setIsConnecting(false) }) @@ -629,6 +636,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) { if (commit) { socket.emit('workflow-operation', { + workflowId: currentWorkflowId, operation, target, payload, @@ -645,6 +653,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) { } pendingPositionUpdates.current.set(blockId, { + workflowId: currentWorkflowId, operation, target, payload, @@ -666,6 +675,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) { } } else { socket.emit('workflow-operation', { + workflowId: currentWorkflowId, operation, target, payload, @@ -678,47 +688,51 @@ export function SocketProvider({ children, user }: SocketProviderProps) { ) const emitSubblockUpdate = useCallback( - (blockId: string, subblockId: string, value: any, operationId?: string) => { - if (socket && currentWorkflowId) { - socket.emit('subblock-update', { - blockId, - subblockId, - value, - timestamp: Date.now(), - operationId, - }) - } else { - logger.warn('Cannot emit subblock update: no socket connection or workflow room', { - hasSocket: !!socket, - currentWorkflowId, - blockId, - subblockId, - }) + ( + blockId: string, + subblockId: string, + value: any, + operationId: string | undefined, + workflowId: string + ) => { + if (!socket) { + logger.warn('Cannot emit subblock update: no socket connection', { workflowId, blockId }) + return } + socket.emit('subblock-update', { + workflowId, + blockId, + subblockId, + value, + timestamp: Date.now(), + operationId, + }) }, - [socket, currentWorkflowId] + [socket] ) const emitVariableUpdate = useCallback( - (variableId: string, field: string, value: any, operationId?: string) => { - if (socket && currentWorkflowId) { - socket.emit('variable-update', { - variableId, - field, - value, - timestamp: Date.now(), - operationId, - }) - } else { - logger.warn('Cannot emit variable update: no socket connection or workflow room', { - hasSocket: !!socket, - currentWorkflowId, - variableId, - field, - }) + ( + variableId: string, + field: string, + value: any, + operationId: string | undefined, + workflowId: string + ) => { + if (!socket) { + logger.warn('Cannot emit variable update: no socket connection', { workflowId, variableId }) + return } + socket.emit('variable-update', { + workflowId, + variableId, + field, + value, + timestamp: Date.now(), + operationId, + }) }, - [socket, currentWorkflowId] + [socket] ) const lastCursorEmit = useRef(0) @@ -794,6 +808,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) { socket, isConnected, isConnecting, + isReconnecting, authFailed, currentWorkflowId, currentSocketId, @@ -820,6 +835,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) { socket, isConnected, isConnecting, + isReconnecting, authFailed, currentWorkflowId, currentSocketId, diff --git a/apps/sim/components/emails/billing/free-tier-upgrade-email.tsx b/apps/sim/components/emails/billing/free-tier-upgrade-email.tsx index 464221d89..9f42559d2 100644 --- a/apps/sim/components/emails/billing/free-tier-upgrade-email.tsx +++ b/apps/sim/components/emails/billing/free-tier-upgrade-email.tsx @@ -13,8 +13,8 @@ interface FreeTierUpgradeEmailProps { const proFeatures = [ { label: '$20/month', desc: 'in credits included' }, - { label: '25 runs/min', desc: 'sync executions' }, - { label: '200 runs/min', desc: 'async executions' }, + { label: '150 runs/min', desc: 'sync executions' }, + { label: '1,000 runs/min', desc: 'async executions' }, { label: '50GB storage', desc: 'for files & assets' }, { label: 'Unlimited', desc: 'workspaces & invites' }, ] diff --git a/apps/sim/hooks/use-collaborative-workflow.ts b/apps/sim/hooks/use-collaborative-workflow.ts index d2a88cc89..f2e7782bf 100644 --- a/apps/sim/hooks/use-collaborative-workflow.ts +++ b/apps/sim/hooks/use-collaborative-workflow.ts @@ -146,10 +146,6 @@ export function useCollaborativeWorkflow() { cancelOperationsForVariable, } = useOperationQueue() - const isInActiveRoom = useCallback(() => { - return !!currentWorkflowId && activeWorkflowId === currentWorkflowId - }, [currentWorkflowId, activeWorkflowId]) - // Register emit functions with operation queue store useEffect(() => { registerEmitFunctions( @@ -162,10 +158,19 @@ export function useCollaborativeWorkflow() { useEffect(() => { const handleWorkflowOperation = (data: any) => { - const { operation, target, payload, userId } = data + const { operation, target, payload, userId, metadata } = data if (isApplyingRemoteChange.current) return + // Filter broadcasts by workflowId to prevent cross-workflow updates + if (metadata?.workflowId && metadata.workflowId !== activeWorkflowId) { + logger.debug('Ignoring workflow operation for different workflow', { + broadcastWorkflowId: metadata.workflowId, + activeWorkflowId, + }) + return + } + logger.info(`Received ${operation} on ${target} from user ${userId}`) // Apply the operation to local state @@ -450,16 +455,24 @@ export function useCollaborativeWorkflow() { } const handleSubblockUpdate = (data: any) => { - const { blockId, subblockId, value, userId } = data + const { workflowId, blockId, subblockId, value, userId } = data if (isApplyingRemoteChange.current) return + // Filter broadcasts by workflowId to prevent cross-workflow updates + if (workflowId && workflowId !== activeWorkflowId) { + logger.debug('Ignoring subblock update for different workflow', { + broadcastWorkflowId: workflowId, + activeWorkflowId, + }) + return + } + logger.info(`Received subblock update from user ${userId}: ${blockId}.${subblockId}`) isApplyingRemoteChange.current = true try { - // The setValue function automatically uses the active workflow ID useSubBlockStore.getState().setValue(blockId, subblockId, value) const blockType = useWorkflowStore.getState().blocks?.[blockId]?.type if (activeWorkflowId && blockType === 'function' && subblockId === 'code') { @@ -473,10 +486,19 @@ export function useCollaborativeWorkflow() { } const handleVariableUpdate = (data: any) => { - const { variableId, field, value, userId } = data + const { workflowId, variableId, field, value, userId } = data if (isApplyingRemoteChange.current) return + // Filter broadcasts by workflowId to prevent cross-workflow updates + if (workflowId && workflowId !== activeWorkflowId) { + logger.debug('Ignoring variable update for different workflow', { + broadcastWorkflowId: workflowId, + activeWorkflowId, + }) + return + } + logger.info(`Received variable update from user ${userId}: ${variableId}.${field}`) isApplyingRemoteChange.current = true @@ -637,13 +659,9 @@ export function useCollaborativeWorkflow() { return } - if (!isInActiveRoom()) { - logger.debug('Skipping operation - not in active workflow', { - currentWorkflowId, - activeWorkflowId, - operation, - target, - }) + // Queue operations if we have an active workflow - queue handles socket readiness + if (!activeWorkflowId) { + logger.debug('Skipping operation - no active workflow', { operation, target }) return } @@ -656,20 +674,13 @@ export function useCollaborativeWorkflow() { target, payload, }, - workflowId: activeWorkflowId || '', + workflowId: activeWorkflowId, userId: session?.user?.id || 'unknown', }) localAction() }, - [ - addToQueue, - session?.user?.id, - isBaselineDiffView, - activeWorkflowId, - isInActiveRoom, - currentWorkflowId, - ] + [addToQueue, session?.user?.id, isBaselineDiffView, activeWorkflowId] ) const collaborativeBatchUpdatePositions = useCallback( @@ -683,8 +694,8 @@ export function useCollaborativeWorkflow() { return } - if (!isInActiveRoom()) { - logger.debug('Skipping batch position update - not in active workflow') + if (!activeWorkflowId) { + logger.debug('Skipping batch position update - no active workflow') return } @@ -728,7 +739,7 @@ export function useCollaborativeWorkflow() { } } }, - [isBaselineDiffView, addToQueue, activeWorkflowId, session?.user?.id, isInActiveRoom, undoRedo] + [isBaselineDiffView, addToQueue, activeWorkflowId, session?.user?.id, undoRedo] ) const collaborativeUpdateBlockName = useCallback( @@ -885,8 +896,8 @@ export function useCollaborativeWorkflow() { return } - if (!isInActiveRoom()) { - logger.debug('Skipping batch update parent - not in active workflow') + if (!activeWorkflowId) { + logger.debug('Skipping batch update parent - no active workflow') return } @@ -955,7 +966,7 @@ export function useCollaborativeWorkflow() { logger.debug('Batch updated parent for blocks', { updateCount: updates.length }) }, - [isBaselineDiffView, isInActiveRoom, undoRedo, addToQueue, activeWorkflowId, session?.user?.id] + [isBaselineDiffView, undoRedo, addToQueue, activeWorkflowId, session?.user?.id] ) const collaborativeToggleBlockAdvancedMode = useCallback( @@ -1099,8 +1110,8 @@ export function useCollaborativeWorkflow() { return false } - if (!isInActiveRoom()) { - logger.debug('Skipping batch add edges - not in active workflow') + if (!activeWorkflowId) { + logger.debug('Skipping batch add edges - no active workflow') return false } @@ -1134,7 +1145,7 @@ export function useCollaborativeWorkflow() { return true }, - [isBaselineDiffView, addToQueue, activeWorkflowId, session?.user?.id, isInActiveRoom, undoRedo] + [isBaselineDiffView, addToQueue, activeWorkflowId, session?.user?.id, undoRedo] ) const collaborativeBatchRemoveEdges = useCallback( @@ -1143,8 +1154,8 @@ export function useCollaborativeWorkflow() { return false } - if (!isInActiveRoom()) { - logger.debug('Skipping batch remove edges - not in active workflow') + if (!activeWorkflowId) { + logger.debug('Skipping batch remove edges - no active workflow') return false } @@ -1192,7 +1203,7 @@ export function useCollaborativeWorkflow() { logger.info('Batch removed edges', { count: validEdgeIds.length }) return true }, - [isBaselineDiffView, isInActiveRoom, addToQueue, activeWorkflowId, session, undoRedo] + [isBaselineDiffView, addToQueue, activeWorkflowId, session, undoRedo] ) const collaborativeSetSubblockValue = useCallback( @@ -1227,11 +1238,9 @@ export function useCollaborativeWorkflow() { // Best-effort; do not block on clearing } - // Only emit to socket if in active room - if (!isInActiveRoom()) { - logger.debug('Local update applied, skipping socket emit - not in active workflow', { - currentWorkflowId, - activeWorkflowId, + // Queue socket operation if we have an active workflow + if (!activeWorkflowId) { + logger.debug('Local update applied, skipping socket queue - no active workflow', { blockId, subblockId, }) @@ -1253,14 +1262,7 @@ export function useCollaborativeWorkflow() { userId: session?.user?.id || 'unknown', }) }, - [ - currentWorkflowId, - activeWorkflowId, - addToQueue, - session?.user?.id, - isBaselineDiffView, - isInActiveRoom, - ] + [activeWorkflowId, addToQueue, session?.user?.id, isBaselineDiffView] ) // Immediate tag selection (uses queue but processes immediately, no debouncing) @@ -1272,13 +1274,8 @@ export function useCollaborativeWorkflow() { return } - if (!isInActiveRoom()) { - logger.debug('Skipping tag selection - not in active workflow', { - currentWorkflowId, - activeWorkflowId, - blockId, - subblockId, - }) + if (!activeWorkflowId) { + logger.debug('Skipping tag selection - no active workflow', { blockId, subblockId }) return } @@ -1299,14 +1296,7 @@ export function useCollaborativeWorkflow() { userId: session?.user?.id || 'unknown', }) }, - [ - isBaselineDiffView, - addToQueue, - currentWorkflowId, - activeWorkflowId, - session?.user?.id, - isInActiveRoom, - ] + [isBaselineDiffView, addToQueue, activeWorkflowId, session?.user?.id] ) const collaborativeUpdateLoopType = useCallback( @@ -1593,8 +1583,8 @@ export function useCollaborativeWorkflow() { subBlockValues: Record> = {}, options?: { skipUndoRedo?: boolean } ) => { - if (!isInActiveRoom()) { - logger.debug('Skipping batch add blocks - not in active workflow') + if (!activeWorkflowId) { + logger.debug('Skipping batch add blocks - no active workflow') return false } @@ -1647,7 +1637,7 @@ export function useCollaborativeWorkflow() { return true }, - [addToQueue, activeWorkflowId, session?.user?.id, isBaselineDiffView, isInActiveRoom, undoRedo] + [addToQueue, activeWorkflowId, session?.user?.id, isBaselineDiffView, undoRedo] ) const collaborativeBatchRemoveBlocks = useCallback( @@ -1656,8 +1646,8 @@ export function useCollaborativeWorkflow() { return false } - if (!isInActiveRoom()) { - logger.debug('Skipping batch remove blocks - not in active workflow') + if (!activeWorkflowId) { + logger.debug('Skipping batch remove blocks - no active workflow') return false } @@ -1741,7 +1731,6 @@ export function useCollaborativeWorkflow() { addToQueue, activeWorkflowId, session?.user?.id, - isInActiveRoom, cancelOperationsForBlock, undoRedo, ] diff --git a/apps/sim/hooks/use-undo-redo.ts b/apps/sim/hooks/use-undo-redo.ts index 7448bb7db..75c72e399 100644 --- a/apps/sim/hooks/use-undo-redo.ts +++ b/apps/sim/hooks/use-undo-redo.ts @@ -30,7 +30,6 @@ import { useUndoRedoStore, } from '@/stores/undo-redo' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' import type { BlockState } from '@/stores/workflows/workflow/types' @@ -535,47 +534,9 @@ export function useUndoRedo() { userId, }) - blocksToAdd.forEach((block) => { - useWorkflowStore - .getState() - .addBlock( - block.id, - block.type, - block.name, - block.position, - block.data, - block.data?.parentId, - block.data?.extent, - { - enabled: block.enabled, - horizontalHandles: block.horizontalHandles, - advancedMode: block.advancedMode, - triggerMode: block.triggerMode, - height: block.height, - } - ) - }) - - if (subBlockValues && Object.keys(subBlockValues).length > 0) { - useSubBlockStore.setState((state) => ({ - workflowValues: { - ...state.workflowValues, - [activeWorkflowId]: { - ...state.workflowValues[activeWorkflowId], - ...subBlockValues, - }, - }, - })) - } - - if (edgeSnapshots && edgeSnapshots.length > 0) { - const edgesToAdd = edgeSnapshots.filter( - (edge) => !useWorkflowStore.getState().edges.find((e) => e.id === edge.id) - ) - if (edgesToAdd.length > 0) { - useWorkflowStore.getState().batchAddEdges(edgesToAdd) - } - } + useWorkflowStore + .getState() + .batchAddBlocks(blocksToAdd, edgeSnapshots || [], subBlockValues || {}) break } case UNDO_REDO_OPERATIONS.BATCH_REMOVE_EDGES: { @@ -1148,47 +1109,9 @@ export function useUndoRedo() { userId, }) - blocksToAdd.forEach((block) => { - useWorkflowStore - .getState() - .addBlock( - block.id, - block.type, - block.name, - block.position, - block.data, - block.data?.parentId, - block.data?.extent, - { - enabled: block.enabled, - horizontalHandles: block.horizontalHandles, - advancedMode: block.advancedMode, - triggerMode: block.triggerMode, - height: block.height, - } - ) - }) - - if (subBlockValues && Object.keys(subBlockValues).length > 0) { - useSubBlockStore.setState((state) => ({ - workflowValues: { - ...state.workflowValues, - [activeWorkflowId]: { - ...state.workflowValues[activeWorkflowId], - ...subBlockValues, - }, - }, - })) - } - - if (edgeSnapshots && edgeSnapshots.length > 0) { - const edgesToAdd = edgeSnapshots.filter( - (edge) => !useWorkflowStore.getState().edges.find((e) => e.id === edge.id) - ) - if (edgesToAdd.length > 0) { - useWorkflowStore.getState().batchAddEdges(edgesToAdd) - } - } + useWorkflowStore + .getState() + .batchAddBlocks(blocksToAdd, edgeSnapshots || [], subBlockValues || {}) break } case UNDO_REDO_OPERATIONS.BATCH_REMOVE_BLOCKS: { diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 86ccbc2c8..6bd9df299 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -161,14 +161,14 @@ export const env = createEnv({ // Rate Limiting Configuration RATE_LIMIT_WINDOW_MS: z.string().optional().default('60000'), // Rate limit window duration in milliseconds (default: 1 minute) MANUAL_EXECUTION_LIMIT: z.string().optional().default('999999'),// Manual execution bypass value (effectively unlimited) - RATE_LIMIT_FREE_SYNC: z.string().optional().default('10'), // Free tier sync API executions per minute - RATE_LIMIT_FREE_ASYNC: z.string().optional().default('50'), // Free tier async API executions per minute - RATE_LIMIT_PRO_SYNC: z.string().optional().default('25'), // Pro tier sync API executions per minute - RATE_LIMIT_PRO_ASYNC: z.string().optional().default('200'), // Pro tier async API executions per minute - RATE_LIMIT_TEAM_SYNC: z.string().optional().default('75'), // Team tier sync API executions per minute - RATE_LIMIT_TEAM_ASYNC: z.string().optional().default('500'), // Team tier async API executions per minute - RATE_LIMIT_ENTERPRISE_SYNC: z.string().optional().default('150'), // Enterprise tier sync API executions per minute - RATE_LIMIT_ENTERPRISE_ASYNC: z.string().optional().default('1000'), // Enterprise tier async API executions per minute + RATE_LIMIT_FREE_SYNC: z.string().optional().default('50'), // Free tier sync API executions per minute + RATE_LIMIT_FREE_ASYNC: z.string().optional().default('200'), // Free tier async API executions per minute + RATE_LIMIT_PRO_SYNC: z.string().optional().default('150'), // Pro tier sync API executions per minute + RATE_LIMIT_PRO_ASYNC: z.string().optional().default('1000'), // Pro tier async API executions per minute + RATE_LIMIT_TEAM_SYNC: z.string().optional().default('300'), // Team tier sync API executions per minute + RATE_LIMIT_TEAM_ASYNC: z.string().optional().default('2500'), // Team tier async API executions per minute + RATE_LIMIT_ENTERPRISE_SYNC: z.string().optional().default('600'), // Enterprise tier sync API executions per minute + RATE_LIMIT_ENTERPRISE_ASYNC: z.string().optional().default('5000'), // Enterprise tier async API executions per minute // Knowledge Base Processing Configuration - Shared across all processing methods KB_CONFIG_MAX_DURATION: z.number().optional().default(600), // Max processing duration in seconds (10 minutes) diff --git a/apps/sim/lib/core/rate-limiter/types.ts b/apps/sim/lib/core/rate-limiter/types.ts index 282ee09e0..9dac4edb4 100644 --- a/apps/sim/lib/core/rate-limiter/types.ts +++ b/apps/sim/lib/core/rate-limiter/types.ts @@ -28,24 +28,24 @@ function createBucketConfig(ratePerMinute: number, burstMultiplier = 2): TokenBu export const RATE_LIMITS: Record = { free: { - sync: createBucketConfig(Number.parseInt(env.RATE_LIMIT_FREE_SYNC) || 10), - async: createBucketConfig(Number.parseInt(env.RATE_LIMIT_FREE_ASYNC) || 50), - apiEndpoint: createBucketConfig(10), - }, - pro: { - sync: createBucketConfig(Number.parseInt(env.RATE_LIMIT_PRO_SYNC) || 25), - async: createBucketConfig(Number.parseInt(env.RATE_LIMIT_PRO_ASYNC) || 200), + sync: createBucketConfig(Number.parseInt(env.RATE_LIMIT_FREE_SYNC) || 50), + async: createBucketConfig(Number.parseInt(env.RATE_LIMIT_FREE_ASYNC) || 200), apiEndpoint: createBucketConfig(30), }, + pro: { + sync: createBucketConfig(Number.parseInt(env.RATE_LIMIT_PRO_SYNC) || 150), + async: createBucketConfig(Number.parseInt(env.RATE_LIMIT_PRO_ASYNC) || 1000), + apiEndpoint: createBucketConfig(100), + }, team: { - sync: createBucketConfig(Number.parseInt(env.RATE_LIMIT_TEAM_SYNC) || 75), - async: createBucketConfig(Number.parseInt(env.RATE_LIMIT_TEAM_ASYNC) || 500), - apiEndpoint: createBucketConfig(60), + sync: createBucketConfig(Number.parseInt(env.RATE_LIMIT_TEAM_SYNC) || 300), + async: createBucketConfig(Number.parseInt(env.RATE_LIMIT_TEAM_ASYNC) || 2500), + apiEndpoint: createBucketConfig(200), }, enterprise: { - sync: createBucketConfig(Number.parseInt(env.RATE_LIMIT_ENTERPRISE_SYNC) || 150), - async: createBucketConfig(Number.parseInt(env.RATE_LIMIT_ENTERPRISE_ASYNC) || 1000), - apiEndpoint: createBucketConfig(120), + sync: createBucketConfig(Number.parseInt(env.RATE_LIMIT_ENTERPRISE_SYNC) || 600), + async: createBucketConfig(Number.parseInt(env.RATE_LIMIT_ENTERPRISE_ASYNC) || 5000), + apiEndpoint: createBucketConfig(500), }, } diff --git a/apps/sim/lib/mcp/client.ts b/apps/sim/lib/mcp/client.ts index 72281a1ec..5fdb4adac 100644 --- a/apps/sim/lib/mcp/client.ts +++ b/apps/sim/lib/mcp/client.ts @@ -199,10 +199,11 @@ export class McpClient { protocolVersion: this.getNegotiatedVersion(), }) - const sdkResult = await this.client.callTool({ - name: toolCall.name, - arguments: toolCall.arguments, - }) + const sdkResult = await this.client.callTool( + { name: toolCall.name, arguments: toolCall.arguments }, + undefined, + { timeout: 600000 } // 10 minutes - override SDK's 60s default + ) return sdkResult as McpToolResult } catch (error) { diff --git a/apps/sim/socket/handlers/subblocks.ts b/apps/sim/socket/handlers/subblocks.ts index a630151dd..23896fed3 100644 --- a/apps/sim/socket/handlers/subblocks.ts +++ b/apps/sim/socket/handlers/subblocks.ts @@ -39,16 +39,23 @@ export function cleanupPendingSubblocksForSocket(socketId: string): void { export function setupSubblocksHandlers(socket: AuthenticatedSocket, roomManager: IRoomManager) { socket.on('subblock-update', async (data) => { - const { blockId, subblockId, value, timestamp, operationId } = data + const { + workflowId: payloadWorkflowId, + blockId, + subblockId, + value, + timestamp, + operationId, + } = data try { - const workflowId = await roomManager.getWorkflowIdForSocket(socket.id) + const sessionWorkflowId = await roomManager.getWorkflowIdForSocket(socket.id) const session = await roomManager.getUserSession(socket.id) - if (!workflowId || !session) { + if (!sessionWorkflowId || !session) { logger.debug(`Ignoring subblock update: socket not connected to any workflow room`, { socketId: socket.id, - hasWorkflowId: !!workflowId, + hasWorkflowId: !!sessionWorkflowId, hasSession: !!session, }) socket.emit('operation-forbidden', { @@ -61,6 +68,24 @@ export function setupSubblocksHandlers(socket: AuthenticatedSocket, roomManager: return } + const workflowId = payloadWorkflowId || sessionWorkflowId + + if (payloadWorkflowId && payloadWorkflowId !== sessionWorkflowId) { + logger.warn('Workflow ID mismatch in subblock update', { + payloadWorkflowId, + sessionWorkflowId, + socketId: socket.id, + }) + if (operationId) { + socket.emit('operation-failed', { + operationId, + error: 'Workflow ID mismatch', + retryable: true, + }) + } + return + } + const hasRoom = await roomManager.hasWorkflowRoom(workflowId) if (!hasRoom) { logger.debug(`Ignoring subblock update: workflow room not found`, { @@ -182,20 +207,17 @@ async function flushSubblockUpdate( if (updateSuccessful) { // Broadcast to room excluding all senders (works cross-pod via Redis adapter) const senderSocketIds = [...pending.opToSocket.values()] + const broadcastPayload = { + workflowId, + blockId, + subblockId, + value, + timestamp, + } if (senderSocketIds.length > 0) { - io.to(workflowId).except(senderSocketIds).emit('subblock-update', { - blockId, - subblockId, - value, - timestamp, - }) + io.to(workflowId).except(senderSocketIds).emit('subblock-update', broadcastPayload) } else { - io.to(workflowId).emit('subblock-update', { - blockId, - subblockId, - value, - timestamp, - }) + io.to(workflowId).emit('subblock-update', broadcastPayload) } // Confirm all coalesced operationIds (io.to(socketId) works cross-pod) diff --git a/apps/sim/socket/handlers/variables.ts b/apps/sim/socket/handlers/variables.ts index 0421b4e60..5b36873a8 100644 --- a/apps/sim/socket/handlers/variables.ts +++ b/apps/sim/socket/handlers/variables.ts @@ -35,16 +35,16 @@ export function cleanupPendingVariablesForSocket(socketId: string): void { export function setupVariablesHandlers(socket: AuthenticatedSocket, roomManager: IRoomManager) { socket.on('variable-update', async (data) => { - const { variableId, field, value, timestamp, operationId } = data + const { workflowId: payloadWorkflowId, variableId, field, value, timestamp, operationId } = data try { - const workflowId = await roomManager.getWorkflowIdForSocket(socket.id) + const sessionWorkflowId = await roomManager.getWorkflowIdForSocket(socket.id) const session = await roomManager.getUserSession(socket.id) - if (!workflowId || !session) { + if (!sessionWorkflowId || !session) { logger.debug(`Ignoring variable update: socket not connected to any workflow room`, { socketId: socket.id, - hasWorkflowId: !!workflowId, + hasWorkflowId: !!sessionWorkflowId, hasSession: !!session, }) socket.emit('operation-forbidden', { @@ -57,6 +57,24 @@ export function setupVariablesHandlers(socket: AuthenticatedSocket, roomManager: return } + const workflowId = payloadWorkflowId || sessionWorkflowId + + if (payloadWorkflowId && payloadWorkflowId !== sessionWorkflowId) { + logger.warn('Workflow ID mismatch in variable update', { + payloadWorkflowId, + sessionWorkflowId, + socketId: socket.id, + }) + if (operationId) { + socket.emit('operation-failed', { + operationId, + error: 'Workflow ID mismatch', + retryable: true, + }) + } + return + } + const hasRoom = await roomManager.hasWorkflowRoom(workflowId) if (!hasRoom) { logger.debug(`Ignoring variable update: workflow room not found`, { @@ -179,20 +197,17 @@ async function flushVariableUpdate( if (updateSuccessful) { // Broadcast to room excluding all senders (works cross-pod via Redis adapter) const senderSocketIds = [...pending.opToSocket.values()] + const broadcastPayload = { + workflowId, + variableId, + field, + value, + timestamp, + } if (senderSocketIds.length > 0) { - io.to(workflowId).except(senderSocketIds).emit('variable-update', { - variableId, - field, - value, - timestamp, - }) + io.to(workflowId).except(senderSocketIds).emit('variable-update', broadcastPayload) } else { - io.to(workflowId).emit('variable-update', { - variableId, - field, - value, - timestamp, - }) + io.to(workflowId).emit('variable-update', broadcastPayload) } // Confirm all coalesced operationIds (io.to(socketId) works cross-pod) diff --git a/apps/sim/stores/operation-queue/store.ts b/apps/sim/stores/operation-queue/store.ts index e9f3deedc..657535c5b 100644 --- a/apps/sim/stores/operation-queue/store.ts +++ b/apps/sim/stores/operation-queue/store.ts @@ -24,16 +24,40 @@ let emitWorkflowOperation: | ((operation: string, target: string, payload: any, operationId?: string) => void) | null = null let emitSubblockUpdate: - | ((blockId: string, subblockId: string, value: any, operationId?: string) => void) + | (( + blockId: string, + subblockId: string, + value: any, + operationId: string | undefined, + workflowId: string + ) => void) | null = null let emitVariableUpdate: - | ((variableId: string, field: string, value: any, operationId?: string) => void) + | (( + variableId: string, + field: string, + value: any, + operationId: string | undefined, + workflowId: string + ) => void) | null = null export function registerEmitFunctions( workflowEmit: (operation: string, target: string, payload: any, operationId?: string) => void, - subblockEmit: (blockId: string, subblockId: string, value: any, operationId?: string) => void, - variableEmit: (variableId: string, field: string, value: any, operationId?: string) => void, + subblockEmit: ( + blockId: string, + subblockId: string, + value: any, + operationId: string | undefined, + workflowId: string + ) => void, + variableEmit: ( + variableId: string, + field: string, + value: any, + operationId: string | undefined, + workflowId: string + ) => void, workflowId: string | null ) { emitWorkflowOperation = workflowEmit @@ -196,14 +220,16 @@ export const useOperationQueueStore = create((set, get) => } if (!retryable) { - logger.debug('Operation marked as non-retryable, removing from queue', { operationId }) + logger.error( + 'Operation failed with non-retryable error - state out of sync, triggering offline mode', + { + operationId, + operation: operation.operation.operation, + target: operation.operation.target, + } + ) - set((state) => ({ - operations: state.operations.filter((op) => op.id !== operationId), - isProcessing: false, - })) - - get().processNextOperation() + get().triggerOfflineMode() return } @@ -305,11 +331,23 @@ export const useOperationQueueStore = create((set, get) => const { operation: op, target, payload } = nextOperation.operation if (op === 'subblock-update' && target === 'subblock') { if (emitSubblockUpdate) { - emitSubblockUpdate(payload.blockId, payload.subblockId, payload.value, nextOperation.id) + emitSubblockUpdate( + payload.blockId, + payload.subblockId, + payload.value, + nextOperation.id, + nextOperation.workflowId + ) } } else if (op === 'variable-update' && target === 'variable') { if (emitVariableUpdate) { - emitVariableUpdate(payload.variableId, payload.field, payload.value, nextOperation.id) + emitVariableUpdate( + payload.variableId, + payload.field, + payload.value, + nextOperation.id, + nextOperation.workflowId + ) } } else { if (emitWorkflowOperation) { diff --git a/apps/sim/stores/workflows/workflow/store.test.ts b/apps/sim/stores/workflows/workflow/store.test.ts index 106dc15a1..3cc98cbd2 100644 --- a/apps/sim/stores/workflows/workflow/store.test.ts +++ b/apps/sim/stores/workflows/workflow/store.test.ts @@ -26,6 +26,49 @@ import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' +/** + * Helper function to add a single block using batchAddBlocks. + * Provides a simpler interface for tests. + */ +function addBlock( + id: string, + type: string, + name: string, + position: { x: number; y: number }, + data?: Record, + parentId?: string, + extent?: 'parent', + blockProperties?: { + enabled?: boolean + horizontalHandles?: boolean + advancedMode?: boolean + triggerMode?: boolean + height?: number + } +) { + const blockData = { + ...data, + ...(parentId && { parentId, extent: extent || 'parent' }), + } + + useWorkflowStore.getState().batchAddBlocks([ + { + id, + type, + name, + position, + subBlocks: {}, + outputs: {}, + enabled: blockProperties?.enabled ?? true, + horizontalHandles: blockProperties?.horizontalHandles ?? true, + advancedMode: blockProperties?.advancedMode ?? false, + triggerMode: blockProperties?.triggerMode ?? false, + height: blockProperties?.height ?? 0, + data: blockData, + }, + ]) +} + describe('workflow store', () => { beforeEach(() => { const localStorageMock = createMockStorage() @@ -39,10 +82,8 @@ describe('workflow store', () => { }) }) - describe('addBlock', () => { + describe('batchAddBlocks (via addBlock helper)', () => { it('should add a block with correct default properties', () => { - const { addBlock } = useWorkflowStore.getState() - addBlock('agent-1', 'agent', 'My Agent', { x: 100, y: 200 }) const { blocks } = useWorkflowStore.getState() @@ -53,8 +94,6 @@ describe('workflow store', () => { }) it('should add a block with parent relationship for containers', () => { - const { addBlock } = useWorkflowStore.getState() - addBlock('loop-1', 'loop', 'My Loop', { x: 0, y: 0 }, { loopType: 'for', count: 3 }) addBlock( 'child-1', @@ -73,8 +112,6 @@ describe('workflow store', () => { }) it('should add multiple blocks correctly', () => { - const { addBlock } = useWorkflowStore.getState() - addBlock('block-1', 'starter', 'Start', { x: 0, y: 0 }) addBlock('block-2', 'agent', 'Agent', { x: 200, y: 0 }) addBlock('block-3', 'function', 'Function', { x: 400, y: 0 }) @@ -87,8 +124,6 @@ describe('workflow store', () => { }) it('should create a block with default properties when no blockProperties provided', () => { - const { addBlock } = useWorkflowStore.getState() - addBlock('agent1', 'agent', 'Test Agent', { x: 100, y: 200 }) const state = useWorkflowStore.getState() @@ -105,8 +140,6 @@ describe('workflow store', () => { }) it('should create a block with custom blockProperties for regular blocks', () => { - const { addBlock } = useWorkflowStore.getState() - addBlock( 'agent1', 'agent', @@ -134,8 +167,6 @@ describe('workflow store', () => { }) it('should create a loop block with custom blockProperties', () => { - const { addBlock } = useWorkflowStore.getState() - addBlock( 'loop1', 'loop', @@ -163,8 +194,6 @@ describe('workflow store', () => { }) it('should create a parallel block with custom blockProperties', () => { - const { addBlock } = useWorkflowStore.getState() - addBlock( 'parallel1', 'parallel', @@ -192,8 +221,6 @@ describe('workflow store', () => { }) it('should handle partial blockProperties (only some properties provided)', () => { - const { addBlock } = useWorkflowStore.getState() - addBlock( 'agent1', 'agent', @@ -216,8 +243,6 @@ describe('workflow store', () => { }) it('should handle blockProperties with parent relationships', () => { - const { addBlock } = useWorkflowStore.getState() - addBlock('loop1', 'loop', 'Parent Loop', { x: 0, y: 0 }) addBlock( @@ -249,7 +274,7 @@ describe('workflow store', () => { describe('batchRemoveBlocks', () => { it('should remove a block', () => { - const { addBlock, batchRemoveBlocks } = useWorkflowStore.getState() + const { batchRemoveBlocks } = useWorkflowStore.getState() addBlock('block-1', 'function', 'Test', { x: 0, y: 0 }) batchRemoveBlocks(['block-1']) @@ -259,7 +284,7 @@ describe('workflow store', () => { }) it('should remove connected edges when block is removed', () => { - const { addBlock, batchAddEdges, batchRemoveBlocks } = useWorkflowStore.getState() + const { batchAddEdges, batchRemoveBlocks } = useWorkflowStore.getState() addBlock('block-1', 'starter', 'Start', { x: 0, y: 0 }) addBlock('block-2', 'function', 'Middle', { x: 200, y: 0 }) @@ -286,7 +311,7 @@ describe('workflow store', () => { describe('batchAddEdges', () => { it('should add an edge between two blocks', () => { - const { addBlock, batchAddEdges } = useWorkflowStore.getState() + const { batchAddEdges } = useWorkflowStore.getState() addBlock('block-1', 'starter', 'Start', { x: 0, y: 0 }) addBlock('block-2', 'function', 'End', { x: 200, y: 0 }) @@ -298,7 +323,7 @@ describe('workflow store', () => { }) it('should not add duplicate connections', () => { - const { addBlock, batchAddEdges } = useWorkflowStore.getState() + const { batchAddEdges } = useWorkflowStore.getState() addBlock('block-1', 'starter', 'Start', { x: 0, y: 0 }) addBlock('block-2', 'function', 'End', { x: 200, y: 0 }) @@ -313,7 +338,7 @@ describe('workflow store', () => { describe('batchRemoveEdges', () => { it('should remove an edge by id', () => { - const { addBlock, batchAddEdges, batchRemoveEdges } = useWorkflowStore.getState() + const { batchAddEdges, batchRemoveEdges } = useWorkflowStore.getState() addBlock('block-1', 'starter', 'Start', { x: 0, y: 0 }) addBlock('block-2', 'function', 'End', { x: 200, y: 0 }) @@ -335,7 +360,7 @@ describe('workflow store', () => { describe('clear', () => { it('should clear all blocks and edges', () => { - const { addBlock, batchAddEdges, clear } = useWorkflowStore.getState() + const { batchAddEdges, clear } = useWorkflowStore.getState() addBlock('block-1', 'starter', 'Start', { x: 0, y: 0 }) addBlock('block-2', 'function', 'End', { x: 200, y: 0 }) @@ -351,7 +376,7 @@ describe('workflow store', () => { describe('batchToggleEnabled', () => { it('should toggle block enabled state', () => { - const { addBlock, batchToggleEnabled } = useWorkflowStore.getState() + const { batchToggleEnabled } = useWorkflowStore.getState() addBlock('block-1', 'function', 'Test', { x: 0, y: 0 }) @@ -367,7 +392,7 @@ describe('workflow store', () => { describe('duplicateBlock', () => { it('should duplicate a block', () => { - const { addBlock, duplicateBlock } = useWorkflowStore.getState() + const { duplicateBlock } = useWorkflowStore.getState() addBlock('original', 'agent', 'Original Agent', { x: 0, y: 0 }) @@ -391,7 +416,7 @@ describe('workflow store', () => { describe('batchUpdatePositions', () => { it('should update block position', () => { - const { addBlock, batchUpdatePositions } = useWorkflowStore.getState() + const { batchUpdatePositions } = useWorkflowStore.getState() addBlock('block-1', 'function', 'Test', { x: 0, y: 0 }) @@ -404,7 +429,7 @@ describe('workflow store', () => { describe('loop management', () => { it('should regenerate loops when updateLoopCount is called', () => { - const { addBlock, updateLoopCount } = useWorkflowStore.getState() + const { updateLoopCount } = useWorkflowStore.getState() addBlock( 'loop1', @@ -428,7 +453,7 @@ describe('workflow store', () => { }) it('should regenerate loops when updateLoopType is called', () => { - const { addBlock, updateLoopType } = useWorkflowStore.getState() + const { updateLoopType } = useWorkflowStore.getState() addBlock( 'loop1', @@ -453,7 +478,7 @@ describe('workflow store', () => { }) it('should regenerate loops when updateLoopCollection is called', () => { - const { addBlock, updateLoopCollection } = useWorkflowStore.getState() + const { updateLoopCollection } = useWorkflowStore.getState() addBlock( 'loop1', @@ -476,7 +501,7 @@ describe('workflow store', () => { }) it('should clamp loop count between 1 and 1000', () => { - const { addBlock, updateLoopCount } = useWorkflowStore.getState() + const { updateLoopCount } = useWorkflowStore.getState() addBlock( 'loop1', @@ -502,7 +527,7 @@ describe('workflow store', () => { describe('parallel management', () => { it('should regenerate parallels when updateParallelCount is called', () => { - const { addBlock, updateParallelCount } = useWorkflowStore.getState() + const { updateParallelCount } = useWorkflowStore.getState() addBlock( 'parallel1', @@ -525,7 +550,7 @@ describe('workflow store', () => { }) it('should regenerate parallels when updateParallelCollection is called', () => { - const { addBlock, updateParallelCollection } = useWorkflowStore.getState() + const { updateParallelCollection } = useWorkflowStore.getState() addBlock( 'parallel1', @@ -552,7 +577,7 @@ describe('workflow store', () => { }) it('should clamp parallel count between 1 and 20', () => { - const { addBlock, updateParallelCount } = useWorkflowStore.getState() + const { updateParallelCount } = useWorkflowStore.getState() addBlock( 'parallel1', @@ -575,7 +600,7 @@ describe('workflow store', () => { }) it('should regenerate parallels when updateParallelType is called', () => { - const { addBlock, updateParallelType } = useWorkflowStore.getState() + const { updateParallelType } = useWorkflowStore.getState() addBlock( 'parallel1', @@ -601,7 +626,7 @@ describe('workflow store', () => { describe('mode switching', () => { it('should toggle advanced mode on a block', () => { - const { addBlock, toggleBlockAdvancedMode } = useWorkflowStore.getState() + const { toggleBlockAdvancedMode } = useWorkflowStore.getState() addBlock('agent1', 'agent', 'Test Agent', { x: 0, y: 0 }) @@ -618,7 +643,7 @@ describe('workflow store', () => { }) it('should preserve systemPrompt and userPrompt when switching modes', () => { - const { addBlock, toggleBlockAdvancedMode } = useWorkflowStore.getState() + const { toggleBlockAdvancedMode } = useWorkflowStore.getState() const { setState: setSubBlockState } = useSubBlockStore useWorkflowRegistry.setState({ activeWorkflowId: 'test-workflow' }) addBlock('agent1', 'agent', 'Test Agent', { x: 0, y: 0 }) @@ -651,7 +676,7 @@ describe('workflow store', () => { }) it('should preserve memories when switching from advanced to basic mode', () => { - const { addBlock, toggleBlockAdvancedMode } = useWorkflowStore.getState() + const { toggleBlockAdvancedMode } = useWorkflowStore.getState() const { setState: setSubBlockState } = useSubBlockStore useWorkflowRegistry.setState({ activeWorkflowId: 'test-workflow' }) @@ -691,7 +716,7 @@ describe('workflow store', () => { }) it('should handle mode switching when no subblock values exist', () => { - const { addBlock, toggleBlockAdvancedMode } = useWorkflowStore.getState() + const { toggleBlockAdvancedMode } = useWorkflowStore.getState() useWorkflowRegistry.setState({ activeWorkflowId: 'test-workflow' }) @@ -753,7 +778,7 @@ describe('workflow store', () => { describe('replaceWorkflowState', () => { it('should replace entire workflow state', () => { - const { addBlock, replaceWorkflowState } = useWorkflowStore.getState() + const { replaceWorkflowState } = useWorkflowStore.getState() addBlock('old-1', 'function', 'Old', { x: 0, y: 0 }) @@ -769,7 +794,7 @@ describe('workflow store', () => { describe('getWorkflowState', () => { it('should return current workflow state', () => { - const { addBlock, getWorkflowState } = useWorkflowStore.getState() + const { getWorkflowState } = useWorkflowStore.getState() addBlock('block-1', 'starter', 'Start', { x: 0, y: 0 }) addBlock('block-2', 'function', 'End', { x: 200, y: 0 }) @@ -782,9 +807,346 @@ describe('workflow store', () => { }) }) + describe('loop/parallel regeneration optimization', () => { + it('should NOT regenerate loops when adding a regular block without parentId', () => { + // Add a loop first + addBlock('loop-1', 'loop', 'Loop 1', { x: 0, y: 0 }, { loopType: 'for', count: 5 }) + + const stateAfterLoop = useWorkflowStore.getState() + const loopsAfterLoop = stateAfterLoop.loops + + // Add a regular block (no parentId) + addBlock('agent-1', 'agent', 'Agent 1', { x: 200, y: 0 }) + + const stateAfterAgent = useWorkflowStore.getState() + + // Loops should be unchanged (same content) + expect(Object.keys(stateAfterAgent.loops)).toEqual(Object.keys(loopsAfterLoop)) + expect(stateAfterAgent.loops['loop-1'].nodes).toEqual(loopsAfterLoop['loop-1'].nodes) + }) + + it('should regenerate loops when adding a child to a loop', () => { + // Add a loop + addBlock('loop-1', 'loop', 'Loop 1', { x: 0, y: 0 }, { loopType: 'for', count: 5 }) + + const stateAfterLoop = useWorkflowStore.getState() + expect(stateAfterLoop.loops['loop-1'].nodes).toEqual([]) + + // Add a child block to the loop + addBlock( + 'child-1', + 'function', + 'Child 1', + { x: 50, y: 50 }, + { parentId: 'loop-1' }, + 'loop-1', + 'parent' + ) + + const stateAfterChild = useWorkflowStore.getState() + + // Loop should now include the child + expect(stateAfterChild.loops['loop-1'].nodes).toContain('child-1') + }) + + it('should NOT regenerate parallels when adding a child to a loop', () => { + // Add both a loop and a parallel + addBlock('loop-1', 'loop', 'Loop 1', { x: 0, y: 0 }, { loopType: 'for', count: 5 }) + addBlock('parallel-1', 'parallel', 'Parallel 1', { x: 300, y: 0 }, { count: 3 }) + + const stateAfterContainers = useWorkflowStore.getState() + const parallelsAfterContainers = stateAfterContainers.parallels + + // Add a child to the loop (not the parallel) + addBlock( + 'child-1', + 'function', + 'Child 1', + { x: 50, y: 50 }, + { parentId: 'loop-1' }, + 'loop-1', + 'parent' + ) + + const stateAfterChild = useWorkflowStore.getState() + + // Parallels should be unchanged + expect(stateAfterChild.parallels['parallel-1'].nodes).toEqual( + parallelsAfterContainers['parallel-1'].nodes + ) + }) + + it('should regenerate parallels when adding a child to a parallel', () => { + // Add a parallel + addBlock('parallel-1', 'parallel', 'Parallel 1', { x: 0, y: 0 }, { count: 3 }) + + const stateAfterParallel = useWorkflowStore.getState() + expect(stateAfterParallel.parallels['parallel-1'].nodes).toEqual([]) + + // Add a child block to the parallel + addBlock( + 'child-1', + 'function', + 'Child 1', + { x: 50, y: 50 }, + { parentId: 'parallel-1' }, + 'parallel-1', + 'parent' + ) + + const stateAfterChild = useWorkflowStore.getState() + + // Parallel should now include the child + expect(stateAfterChild.parallels['parallel-1'].nodes).toContain('child-1') + }) + + it('should handle adding blocks in any order and produce correct final state', () => { + // Add child BEFORE the loop (simulating undo-redo edge case) + // Note: The child's parentId points to a loop that doesn't exist yet + addBlock( + 'child-1', + 'function', + 'Child 1', + { x: 50, y: 50 }, + { parentId: 'loop-1' }, + 'loop-1', + 'parent' + ) + + // At this point, the child exists but loop doesn't + const stateAfterChild = useWorkflowStore.getState() + expect(stateAfterChild.blocks['child-1']).toBeDefined() + expect(stateAfterChild.loops['loop-1']).toBeUndefined() + + // Now add the loop + addBlock('loop-1', 'loop', 'Loop 1', { x: 0, y: 0 }, { loopType: 'for', count: 5 }) + + // Final state should be correct - loop should include the child + const finalState = useWorkflowStore.getState() + expect(finalState.loops['loop-1']).toBeDefined() + expect(finalState.loops['loop-1'].nodes).toContain('child-1') + }) + }) + + describe('batchAddBlocks optimization', () => { + it('should NOT regenerate loops/parallels when adding regular blocks', () => { + const { batchAddBlocks } = useWorkflowStore.getState() + + // Set up initial state with a loop + useWorkflowStore.setState({ + blocks: { + 'loop-1': { + id: 'loop-1', + type: 'loop', + name: 'Loop 1', + position: { x: 0, y: 0 }, + subBlocks: {}, + outputs: {}, + enabled: true, + horizontalHandles: true, + advancedMode: false, + triggerMode: false, + height: 0, + data: { loopType: 'for', count: 5 }, + }, + }, + edges: [], + loops: { + 'loop-1': { + id: 'loop-1', + nodes: [], + iterations: 5, + loopType: 'for', + enabled: true, + }, + }, + parallels: {}, + }) + + const stateBefore = useWorkflowStore.getState() + + // Add regular blocks (no parentId, not loop/parallel type) + batchAddBlocks([ + { + id: 'agent-1', + type: 'agent', + name: 'Agent 1', + position: { x: 200, y: 0 }, + subBlocks: {}, + outputs: {}, + enabled: true, + }, + { + id: 'function-1', + type: 'function', + name: 'Function 1', + position: { x: 400, y: 0 }, + subBlocks: {}, + outputs: {}, + enabled: true, + }, + ]) + + const stateAfter = useWorkflowStore.getState() + + // Loops should be unchanged + expect(stateAfter.loops['loop-1'].nodes).toEqual(stateBefore.loops['loop-1'].nodes) + }) + + it('should regenerate loops when batch adding a loop block', () => { + const { batchAddBlocks } = useWorkflowStore.getState() + + batchAddBlocks([ + { + id: 'loop-1', + type: 'loop', + name: 'Loop 1', + position: { x: 0, y: 0 }, + subBlocks: {}, + outputs: {}, + enabled: true, + data: { loopType: 'for', count: 5 }, + }, + ]) + + const state = useWorkflowStore.getState() + expect(state.loops['loop-1']).toBeDefined() + expect(state.loops['loop-1'].iterations).toBe(5) + }) + + it('should regenerate loops when batch adding a child of a loop', () => { + const { batchAddBlocks } = useWorkflowStore.getState() + + // First add a loop + batchAddBlocks([ + { + id: 'loop-1', + type: 'loop', + name: 'Loop 1', + position: { x: 0, y: 0 }, + subBlocks: {}, + outputs: {}, + enabled: true, + data: { loopType: 'for', count: 5 }, + }, + ]) + + // Then add a child + batchAddBlocks([ + { + id: 'child-1', + type: 'function', + name: 'Child 1', + position: { x: 50, y: 50 }, + subBlocks: {}, + outputs: {}, + enabled: true, + data: { parentId: 'loop-1' }, + }, + ]) + + const state = useWorkflowStore.getState() + expect(state.loops['loop-1'].nodes).toContain('child-1') + }) + + it('should correctly handle batch adding loop and its children together', () => { + const { batchAddBlocks } = useWorkflowStore.getState() + + // Add loop and child in same batch + batchAddBlocks([ + { + id: 'loop-1', + type: 'loop', + name: 'Loop 1', + position: { x: 0, y: 0 }, + subBlocks: {}, + outputs: {}, + enabled: true, + data: { loopType: 'for', count: 5 }, + }, + { + id: 'child-1', + type: 'function', + name: 'Child 1', + position: { x: 50, y: 50 }, + subBlocks: {}, + outputs: {}, + enabled: true, + data: { parentId: 'loop-1' }, + }, + ]) + + const state = useWorkflowStore.getState() + expect(state.loops['loop-1']).toBeDefined() + expect(state.loops['loop-1'].nodes).toContain('child-1') + }) + }) + + describe('edge operations should not affect loops/parallels', () => { + it('should preserve loops when adding edges', () => { + const { batchAddEdges } = useWorkflowStore.getState() + + // Create a loop with a child + addBlock('loop-1', 'loop', 'Loop 1', { x: 0, y: 0 }, { loopType: 'for', count: 5 }) + addBlock( + 'child-1', + 'function', + 'Child 1', + { x: 50, y: 50 }, + { parentId: 'loop-1' }, + 'loop-1', + 'parent' + ) + addBlock('external-1', 'function', 'External', { x: 300, y: 0 }) + + const stateBeforeEdge = useWorkflowStore.getState() + const loopsBeforeEdge = stateBeforeEdge.loops + + // Add an edge (should not affect loops) + batchAddEdges([{ id: 'e1', source: 'loop-1', target: 'external-1' }]) + + const stateAfterEdge = useWorkflowStore.getState() + + // Loops should be unchanged + expect(stateAfterEdge.loops['loop-1'].nodes).toEqual(loopsBeforeEdge['loop-1'].nodes) + expect(stateAfterEdge.loops['loop-1'].iterations).toEqual( + loopsBeforeEdge['loop-1'].iterations + ) + }) + + it('should preserve loops when removing edges', () => { + const { batchAddEdges, batchRemoveEdges } = useWorkflowStore.getState() + + // Create a loop with a child and an edge + addBlock('loop-1', 'loop', 'Loop 1', { x: 0, y: 0 }, { loopType: 'for', count: 5 }) + addBlock( + 'child-1', + 'function', + 'Child 1', + { x: 50, y: 50 }, + { parentId: 'loop-1' }, + 'loop-1', + 'parent' + ) + addBlock('external-1', 'function', 'External', { x: 300, y: 0 }) + batchAddEdges([{ id: 'e1', source: 'loop-1', target: 'external-1' }]) + + const stateBeforeRemove = useWorkflowStore.getState() + const loopsBeforeRemove = stateBeforeRemove.loops + + // Remove the edge + batchRemoveEdges(['e1']) + + const stateAfterRemove = useWorkflowStore.getState() + + // Loops should be unchanged + expect(stateAfterRemove.loops['loop-1'].nodes).toEqual(loopsBeforeRemove['loop-1'].nodes) + }) + }) + describe('batchToggleLocked', () => { it('should toggle block locked state', () => { - const { addBlock, batchToggleLocked } = useWorkflowStore.getState() + const { batchToggleLocked } = useWorkflowStore.getState() addBlock('block-1', 'function', 'Test', { x: 0, y: 0 }) @@ -799,7 +1161,7 @@ describe('workflow store', () => { }) it('should cascade lock to children when locking a loop', () => { - const { addBlock, batchToggleLocked } = useWorkflowStore.getState() + const { batchToggleLocked } = useWorkflowStore.getState() addBlock('loop-1', 'loop', 'My Loop', { x: 0, y: 0 }, { loopType: 'for', count: 3 }) addBlock( @@ -820,7 +1182,7 @@ describe('workflow store', () => { }) it('should cascade unlock to children when unlocking a parallel', () => { - const { addBlock, batchToggleLocked } = useWorkflowStore.getState() + const { batchToggleLocked } = useWorkflowStore.getState() addBlock('parallel-1', 'parallel', 'My Parallel', { x: 0, y: 0 }, { count: 3 }) addBlock( @@ -846,7 +1208,7 @@ describe('workflow store', () => { }) it('should toggle multiple blocks at once', () => { - const { addBlock, batchToggleLocked } = useWorkflowStore.getState() + const { batchToggleLocked } = useWorkflowStore.getState() addBlock('block-1', 'function', 'Test 1', { x: 0, y: 0 }) addBlock('block-2', 'function', 'Test 2', { x: 100, y: 0 }) @@ -861,7 +1223,7 @@ describe('workflow store', () => { describe('setBlockLocked', () => { it('should set block locked state', () => { - const { addBlock, setBlockLocked } = useWorkflowStore.getState() + const { setBlockLocked } = useWorkflowStore.getState() addBlock('block-1', 'function', 'Test', { x: 0, y: 0 }) @@ -873,7 +1235,7 @@ describe('workflow store', () => { }) it('should not update if locked state is already the target value', () => { - const { addBlock, setBlockLocked } = useWorkflowStore.getState() + const { setBlockLocked } = useWorkflowStore.getState() addBlock('block-1', 'function', 'Test', { x: 0, y: 0 }) @@ -889,7 +1251,7 @@ describe('workflow store', () => { describe('duplicateBlock with locked', () => { it('should unlock duplicate when duplicating a locked block', () => { - const { addBlock, setBlockLocked, duplicateBlock } = useWorkflowStore.getState() + const { setBlockLocked, duplicateBlock } = useWorkflowStore.getState() addBlock('original', 'agent', 'Original Agent', { x: 0, y: 0 }) setBlockLocked('original', true) @@ -915,7 +1277,7 @@ describe('workflow store', () => { }) it('should create unlocked duplicate when duplicating an unlocked block', () => { - const { addBlock, duplicateBlock } = useWorkflowStore.getState() + const { duplicateBlock } = useWorkflowStore.getState() addBlock('original', 'agent', 'Original Agent', { x: 0, y: 0 }) @@ -940,8 +1302,6 @@ describe('workflow store', () => { parallels: {}, }) - const { addBlock } = useWorkflowStore.getState() - addBlock('block1', 'agent', 'Column AD', { x: 0, y: 0 }) addBlock('block2', 'function', 'Employee Length', { x: 100, y: 0 }) addBlock('block3', 'starter', 'Start', { x: 200, y: 0 }) diff --git a/apps/sim/stores/workflows/workflow/store.ts b/apps/sim/stores/workflows/workflow/store.ts index 3d11dc3f0..45f8d23f2 100644 --- a/apps/sim/stores/workflows/workflow/store.ts +++ b/apps/sim/stores/workflows/workflow/store.ts @@ -3,8 +3,6 @@ import type { Edge } from 'reactflow' import { create } from 'zustand' import { devtools } from 'zustand/middleware' import { DEFAULT_DUPLICATE_OFFSET } from '@/lib/workflows/autolayout/constants' -import { getBlockOutputs } from '@/lib/workflows/blocks/block-outputs' -import { getBlock } from '@/blocks' import type { SubBlockConfig } from '@/blocks/types' import { normalizeName, RESERVED_BLOCK_NAMES } from '@/executor/constants' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' @@ -114,138 +112,6 @@ export const useWorkflowStore = create()( set({ needsRedeployment }) }, - addBlock: ( - id: string, - type: string, - name: string, - position: Position, - data?: Record, - parentId?: string, - extent?: 'parent', - blockProperties?: { - enabled?: boolean - horizontalHandles?: boolean - advancedMode?: boolean - triggerMode?: boolean - height?: number - locked?: boolean - } - ) => { - const blockConfig = getBlock(type) - // For custom nodes like loop and parallel that don't use BlockConfig - if (!blockConfig && (type === 'loop' || type === 'parallel')) { - // Merge parentId and extent into data if provided - const nodeData = { - ...data, - ...(parentId && { parentId, extent: extent || 'parent' }), - } - - const newState = { - blocks: { - ...get().blocks, - [id]: { - id, - type, - name, - position, - subBlocks: {}, - outputs: {}, - enabled: blockProperties?.enabled ?? true, - horizontalHandles: blockProperties?.horizontalHandles ?? true, - advancedMode: blockProperties?.advancedMode ?? false, - triggerMode: blockProperties?.triggerMode ?? false, - height: blockProperties?.height ?? 0, - data: nodeData, - locked: blockProperties?.locked ?? false, - }, - }, - edges: [...get().edges], - loops: get().generateLoopBlocks(), - parallels: get().generateParallelBlocks(), - } - - set(newState) - get().updateLastSaved() - return - } - - if (!blockConfig) return - - // Merge parentId and extent into data for regular blocks - const nodeData = { - ...data, - ...(parentId && { parentId, extent: extent || 'parent' }), - } - - const subBlocks: Record = {} - const subBlockStore = useSubBlockStore.getState() - const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId - - blockConfig.subBlocks.forEach((subBlock) => { - const subBlockId = subBlock.id - const initialValue = resolveInitialSubblockValue(subBlock) - const normalizedValue = - initialValue !== undefined && initialValue !== null ? initialValue : null - - subBlocks[subBlockId] = { - id: subBlockId, - type: subBlock.type, - value: normalizedValue as SubBlockState['value'], - } - - if (activeWorkflowId) { - try { - const valueToStore = - initialValue !== undefined ? cloneInitialSubblockValue(initialValue) : null - subBlockStore.setValue(id, subBlockId, valueToStore) - } catch (error) { - logger.warn('Failed to seed sub-block store value during block creation', { - blockId: id, - subBlockId, - error: error instanceof Error ? error.message : String(error), - }) - } - } else { - logger.warn('Cannot seed sub-block store value: activeWorkflowId not available', { - blockId: id, - subBlockId, - }) - } - }) - - // Get outputs based on trigger mode - const triggerMode = blockProperties?.triggerMode ?? false - const outputs = getBlockOutputs(type, subBlocks, triggerMode) - - const newState = { - blocks: { - ...get().blocks, - [id]: { - id, - type, - name, - position, - subBlocks, - outputs, - enabled: blockProperties?.enabled ?? true, - horizontalHandles: blockProperties?.horizontalHandles ?? true, - advancedMode: blockProperties?.advancedMode ?? false, - triggerMode: triggerMode, - height: blockProperties?.height ?? 0, - layout: {}, - data: nodeData, - locked: blockProperties?.locked ?? false, - }, - }, - edges: [...get().edges], - loops: get().generateLoopBlocks(), - parallels: get().generateParallelBlocks(), - } - - set(newState) - get().updateLastSaved() - }, - updateNodeDimensions: (id: string, dimensions: { width: number; height: number }) => { set((state) => { const block = state.blocks[id] @@ -391,11 +257,27 @@ export const useWorkflowStore = create()( } } + // Only regenerate loops/parallels if we're adding blocks that affect them: + // - Adding a loop/parallel container block + // - Adding a block as a child of a loop/parallel (has parentId pointing to one) + const needsLoopRegeneration = blocks.some( + (block) => + block.type === 'loop' || + (block.data?.parentId && newBlocks[block.data.parentId]?.type === 'loop') + ) + const needsParallelRegeneration = blocks.some( + (block) => + block.type === 'parallel' || + (block.data?.parentId && newBlocks[block.data.parentId]?.type === 'parallel') + ) + set({ blocks: newBlocks, edges: newEdges, - loops: generateLoopBlocks(newBlocks), - parallels: generateParallelBlocks(newBlocks), + loops: needsLoopRegeneration ? generateLoopBlocks(newBlocks) : { ...get().loops }, + parallels: needsParallelRegeneration + ? generateParallelBlocks(newBlocks) + : { ...get().parallels }, }) if (subBlockValues && Object.keys(subBlockValues).length > 0) { @@ -568,8 +450,9 @@ export const useWorkflowStore = create()( set({ blocks: { ...blocks }, edges: newEdges, - loops: generateLoopBlocks(blocks), - parallels: generateParallelBlocks(blocks), + // Edges don't affect loop/parallel structure (determined by parentId), skip regeneration + loops: { ...get().loops }, + parallels: { ...get().parallels }, }) get().updateLastSaved() @@ -583,8 +466,9 @@ export const useWorkflowStore = create()( set({ blocks: { ...blocks }, edges: newEdges, - loops: generateLoopBlocks(blocks), - parallels: generateParallelBlocks(blocks), + // Edges don't affect loop/parallel structure (determined by parentId), skip regeneration + loops: { ...get().loops }, + parallels: { ...get().parallels }, }) get().updateLastSaved() diff --git a/apps/sim/stores/workflows/workflow/types.ts b/apps/sim/stores/workflows/workflow/types.ts index fde78aae4..ebf7734da 100644 --- a/apps/sim/stores/workflows/workflow/types.ts +++ b/apps/sim/stores/workflows/workflow/types.ts @@ -178,23 +178,6 @@ export interface WorkflowState { } export interface WorkflowActions { - addBlock: ( - id: string, - type: string, - name: string, - position: Position, - data?: Record, - parentId?: string, - extent?: 'parent', - blockProperties?: { - enabled?: boolean - horizontalHandles?: boolean - advancedMode?: boolean - triggerMode?: boolean - height?: number - locked?: boolean - } - ) => void updateNodeDimensions: (id: string, dimensions: { width: number; height: number }) => void batchUpdateBlocksWithParent: ( updates: Array<{ diff --git a/helm/sim/values.yaml b/helm/sim/values.yaml index e78e0f917..aa227c987 100644 --- a/helm/sim/values.yaml +++ b/helm/sim/values.yaml @@ -125,8 +125,8 @@ app: # Rate Limiting Configuration (per minute) RATE_LIMIT_WINDOW_MS: "60000" # Rate limit window duration (1 minute) - RATE_LIMIT_FREE_SYNC: "10" # Sync API executions per minute - RATE_LIMIT_FREE_ASYNC: "50" # Async API executions per minute + RATE_LIMIT_FREE_SYNC: "50" # Sync API executions per minute + RATE_LIMIT_FREE_ASYNC: "200" # Async API executions per minute # UI Branding & Whitelabeling Configuration NEXT_PUBLIC_BRAND_NAME: "Sim" # Custom brand name