diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index 60df871994..0000000000 --- a/.prettierignore +++ /dev/null @@ -1,35 +0,0 @@ -# Build output -.next -out -dist -build - -# Dependencies -node_modules -.bun - -# Cache -.cache - -# Misc -.DS_Store -*.pem - -# Debug -bun-debug.log* - -# Local env files -.env*.local -.env - -# Vercel -.vercel - -# Coverage -coverage - -# PWA files -**/public/sw.js -**/public/workbox-*.js -**/public/worker-*.js -**/public/fallback-*.js \ No newline at end of file diff --git a/apps/sim/app/api/chat/utils.ts b/apps/sim/app/api/chat/utils.ts index cfd35ee386..a0e8512a7d 100644 --- a/apps/sim/app/api/chat/utils.ts +++ b/apps/sim/app/api/chat/utils.ts @@ -335,8 +335,8 @@ export async function executeWorkflowForChat( } // Use deployed state for execution - const state = (workflowResult[0].deployedState || workflowResult[0].state) as WorkflowState - const { blocks, edges, loops } = state + const state = workflowResult[0].deployedState || workflowResult[0].state + const { blocks, edges, loops, parallels } = state as WorkflowState // Prepare for execution, similar to use-workflow-execution.ts const mergedStates = mergeSubblockState(blocks) @@ -386,7 +386,12 @@ export async function executeWorkflowForChat( } // Create serialized workflow - const serializedWorkflow = new Serializer().serializeWorkflow(mergedStates, edges, loops) + const serializedWorkflow = new Serializer().serializeWorkflow( + mergedStates, + edges, + loops, + parallels + ) // Decrypt environment variables const decryptedEnvVars: Record = {} diff --git a/apps/sim/app/api/schedules/execute/route.ts b/apps/sim/app/api/schedules/execute/route.ts index 4a116de49e..e347aa27dc 100644 --- a/apps/sim/app/api/schedules/execute/route.ts +++ b/apps/sim/app/api/schedules/execute/route.ts @@ -150,7 +150,7 @@ export async function GET(req: NextRequest) { } const state = workflowRecord.state as WorkflowState - const { blocks, edges, loops } = state + const { blocks, edges, loops, parallels } = state const mergedStates = mergeSubblockState(blocks) @@ -224,7 +224,12 @@ export async function GET(req: NextRequest) { } } - const serializedWorkflow = new Serializer().serializeWorkflow(mergedStates, edges, loops) + const serializedWorkflow = new Serializer().serializeWorkflow( + mergedStates, + edges, + loops, + parallels + ) const input = { workflowId: schedule.workflowId, diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index e23b41850d..2e53a314f4 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -97,7 +97,7 @@ async function executeWorkflow(workflow: any, requestId: string, input?: any) { } const state = workflowState as WorkflowState - const { blocks, edges, loops } = state + const { blocks, edges, loops, parallels } = state // Use the same execution flow as in scheduled executions const mergedStates = mergeSubblockState(blocks) @@ -227,7 +227,12 @@ async function executeWorkflow(workflow: any, requestId: string, input?: any) { // Serialize and execute the workflow logger.debug(`[${requestId}] Serializing workflow: ${workflowId}`) - const serializedWorkflow = new Serializer().serializeWorkflow(mergedStates, edges, loops) + const serializedWorkflow = new Serializer().serializeWorkflow( + mergedStates, + edges, + loops, + parallels + ) const executor = new Executor( serializedWorkflow, diff --git a/apps/sim/app/api/workflows/sync/route.ts b/apps/sim/app/api/workflows/sync/route.ts index 86a6bbaa2c..115e29791c 100644 --- a/apps/sim/app/api/workflows/sync/route.ts +++ b/apps/sim/app/api/workflows/sync/route.ts @@ -21,7 +21,8 @@ const MarketplaceDataSchema = z const WorkflowStateSchema = z.object({ blocks: z.record(z.any()), edges: z.array(z.any()), - loops: z.record(z.any()), + loops: z.record(z.any()).default({}), + parallels: z.record(z.any()).default({}), lastSaved: z.number().optional(), isDeployed: z.boolean().optional(), deployedAt: z diff --git a/apps/sim/app/globals.css b/apps/sim/app/globals.css index d6ada31646..6d31b94e67 100644 --- a/apps/sim/app/globals.css +++ b/apps/sim/app/globals.css @@ -8,7 +8,12 @@ } .workflow-container .react-flow__node { - z-index: 20 !important; + z-index: 21 !important; +} + +.workflow-container .react-flow__node-loopNode, +.workflow-container .react-flow__node-parallelNode { + z-index: -1 !important; } .workflow-container .react-flow__handle { diff --git a/apps/sim/app/w/[id]/components/control-bar/components/deployment-controls/components/deployed-workflow-modal.tsx b/apps/sim/app/w/[id]/components/control-bar/components/deployment-controls/components/deployed-workflow-modal.tsx index 7865235e2b..d3cdd81c1d 100644 --- a/apps/sim/app/w/[id]/components/control-bar/components/deployment-controls/components/deployed-workflow-modal.tsx +++ b/apps/sim/app/w/[id]/components/control-bar/components/deployment-controls/components/deployed-workflow-modal.tsx @@ -26,6 +26,7 @@ interface DeployedWorkflowModalProps { blocks: Record edges: Array loops: Record + parallels: Record } } @@ -43,9 +44,11 @@ export function DeployedWorkflowModal({ blocks: activeWorkflowId ? mergeSubblockState(state.blocks, activeWorkflowId) : state.blocks, edges: state.edges, loops: state.loops, + parallels: state.parallels, })) const handleRevert = () => { + // Revert to the deployed state revertToDeployedState(deployedWorkflowState) setShowRevertDialog(false) onClose() diff --git a/apps/sim/app/w/[id]/components/loop-node/components/loop-badges.test.tsx b/apps/sim/app/w/[id]/components/loop-node/components/loop-badges.test.tsx new file mode 100644 index 0000000000..b72d1fb89e --- /dev/null +++ b/apps/sim/app/w/[id]/components/loop-node/components/loop-badges.test.tsx @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useWorkflowStore } from '@/stores/workflows/workflow/store' + +// Mock the store +vi.mock('@/stores/workflows/workflow/store', () => ({ + useWorkflowStore: vi.fn(), +})) + +describe('LoopBadges Store Integration', () => { + const mockUpdateLoopType = vi.fn() + const mockUpdateLoopCount = vi.fn() + const mockUpdateLoopCollection = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + + ;(useWorkflowStore as any).mockImplementation((selector: any) => { + const state = { + updateLoopType: mockUpdateLoopType, + updateLoopCount: mockUpdateLoopCount, + updateLoopCollection: mockUpdateLoopCollection, + } + return selector(state) + }) + }) + + it('should call updateLoopType when changing loop type', () => { + // When we update loop type in the UI, it should call the store method + const nodeId = 'loop1' + const newType = 'forEach' + + // Simulate the handler being called + mockUpdateLoopType(nodeId, newType) + + expect(mockUpdateLoopType).toHaveBeenCalledWith(nodeId, newType) + }) + + it('should call updateLoopCount when changing loop count', () => { + const nodeId = 'loop1' + const newCount = 15 + + // Simulate the handler being called + mockUpdateLoopCount(nodeId, newCount) + + expect(mockUpdateLoopCount).toHaveBeenCalledWith(nodeId, newCount) + }) + + it('should call updateLoopCollection when changing collection', () => { + const nodeId = 'loop1' + const newCollection = '["item1", "item2", "item3"]' + + // Simulate the handler being called + mockUpdateLoopCollection(nodeId, newCollection) + + expect(mockUpdateLoopCollection).toHaveBeenCalledWith(nodeId, newCollection) + }) +}) diff --git a/apps/sim/app/w/[id]/components/loop-node/components/loop-badges.tsx b/apps/sim/app/w/[id]/components/loop-node/components/loop-badges.tsx new file mode 100644 index 0000000000..0bb160a90a --- /dev/null +++ b/apps/sim/app/w/[id]/components/loop-node/components/loop-badges.tsx @@ -0,0 +1,301 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { ChevronDown } from 'lucide-react' +import { highlight, languages } from 'prismjs' +import Editor from 'react-simple-code-editor' +import { Badge } from '@/components/ui/badge' +import { Input } from '@/components/ui/input' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { checkTagTrigger, TagDropdown } from '@/components/ui/tag-dropdown' +import { cn } from '@/lib/utils' +import { useWorkflowStore } from '@/stores/workflows/workflow/store' +import 'prismjs/components/prism-javascript' +import 'prismjs/themes/prism.css' + +interface LoopNodeData { + width?: number + height?: number + parentId?: string + state?: string + type?: string + extent?: 'parent' + loopType?: 'for' | 'forEach' + count?: number + collection?: string | any[] | Record + executionState?: { + currentIteration: number + isExecuting: boolean + startTime: number | null + endTime: number | null + } +} + +interface LoopBadgesProps { + nodeId: string + data: LoopNodeData +} + +export function LoopBadges({ nodeId, data }: LoopBadgesProps) { + // State + const [loopType, setLoopType] = useState(data?.loopType || 'for') + const [iterations, setIterations] = useState(data?.count || 5) + const [inputValue, setInputValue] = useState((data?.count || 5).toString()) + const [editorValue, setEditorValue] = useState('') + const [typePopoverOpen, setTypePopoverOpen] = useState(false) + const [configPopoverOpen, setConfigPopoverOpen] = useState(false) + const [showTagDropdown, setShowTagDropdown] = useState(false) + const [cursorPosition, setCursorPosition] = useState(0) + const textareaRef = useRef(null) + const editorContainerRef = useRef(null) + + // Get store methods + const updateNodeData = useCallback( + (updates: Partial) => { + useWorkflowStore.setState((state) => ({ + blocks: { + ...state.blocks, + [nodeId]: { + ...state.blocks[nodeId], + data: { + ...state.blocks[nodeId].data, + ...updates, + }, + }, + }, + })) + }, + [nodeId] + ) + + const updateLoopType = useWorkflowStore((state) => state.updateLoopType) + const updateLoopCount = useWorkflowStore((state) => state.updateLoopCount) + const updateLoopCollection = useWorkflowStore((state) => state.updateLoopCollection) + + // Initialize editor value from data when it changes + useEffect(() => { + if (data?.loopType && data.loopType !== loopType) { + setLoopType(data.loopType) + } + if (data?.count && data.count !== iterations) { + setIterations(data.count) + setInputValue(data.count.toString()) + } + + if (loopType === 'forEach' && data?.collection) { + if (typeof data.collection === 'string') { + setEditorValue(data.collection) + } else if (Array.isArray(data.collection) || typeof data.collection === 'object') { + setEditorValue(JSON.stringify(data.collection)) + } + } else if (loopType === 'for') { + setEditorValue('') + } + }, [data?.loopType, data?.count, data?.collection, loopType, iterations]) + + // Handle loop type change + const handleLoopTypeChange = useCallback( + (newType: 'for' | 'forEach') => { + setLoopType(newType) + updateLoopType(nodeId, newType) + setTypePopoverOpen(false) + }, + [nodeId, updateLoopType] + ) + + // Handle iterations input change + const handleIterationsChange = useCallback((e: React.ChangeEvent) => { + const sanitizedValue = e.target.value.replace(/[^0-9]/g, '') + const numValue = Number.parseInt(sanitizedValue) + + if (!Number.isNaN(numValue)) { + setInputValue(Math.min(100, numValue).toString()) + } else { + setInputValue(sanitizedValue) + } + }, []) + + // Handle iterations save + const handleIterationsSave = useCallback(() => { + const value = Number.parseInt(inputValue) + + if (!Number.isNaN(value)) { + const newValue = Math.min(100, Math.max(1, value)) + setIterations(newValue) + updateLoopCount(nodeId, newValue) + setInputValue(newValue.toString()) + } else { + setInputValue(iterations.toString()) + } + setConfigPopoverOpen(false) + }, [inputValue, iterations, nodeId, updateLoopCount]) + + // Handle editor change with tag dropdown support + const handleEditorChange = useCallback( + (value: string) => { + setEditorValue(value) + updateLoopCollection(nodeId, value) + + // Get the textarea element from the editor + const textarea = editorContainerRef.current?.querySelector('textarea') + if (textarea) { + textareaRef.current = textarea + const cursorPos = textarea.selectionStart || 0 + setCursorPosition(cursorPos) + + // Check for tag trigger + const triggerCheck = checkTagTrigger(value, cursorPos) + setShowTagDropdown(triggerCheck.show) + } + }, + [nodeId, updateLoopCollection] + ) + + // Handle tag selection + const handleTagSelect = useCallback( + (newValue: string) => { + setEditorValue(newValue) + updateLoopCollection(nodeId, newValue) + setShowTagDropdown(false) + + // Focus back on the editor after a short delay + setTimeout(() => { + const textarea = textareaRef.current + if (textarea) { + textarea.focus() + } + }, 0) + }, + [nodeId, updateLoopCollection] + ) + + return ( +
+ {/* Loop Type Badge */} + + e.stopPropagation()}> + + {loopType === 'for' ? 'For Loop' : 'For Each'} + + + + e.stopPropagation()}> +
+
Loop Type
+
+
handleLoopTypeChange('for')} + > + For Loop +
+
handleLoopTypeChange('forEach')} + > + For Each +
+
+
+
+
+ + {/* Iterations/Collection Badge */} + + e.stopPropagation()}> + + {loopType === 'for' ? `Iterations: ${iterations}` : 'Items'} + + + + e.stopPropagation()} + > +
+
+ {loopType === 'for' ? 'Loop Iterations' : 'Collection Items'} +
+ + {loopType === 'for' ? ( + // Number input for 'for' loops +
+ e.key === 'Enter' && handleIterationsSave()} + className='h-8 text-sm' + autoFocus + /> +
+ ) : ( + // Code editor for 'forEach' loops +
+
+ {editorValue === '' && ( +
+ ["item1", "item2", "item3"] +
+ )} + highlight(code, languages.javascript, 'javascript')} + padding={0} + style={{ + fontFamily: 'monospace', + lineHeight: '21px', + }} + className='w-full focus:outline-none' + textareaClassName='focus:outline-none focus:ring-0 bg-transparent resize-none w-full overflow-hidden whitespace-pre-wrap' + /> +
+
+ Array or object to iterate over. Type "{'<'}" to reference other blocks. +
+ {showTagDropdown && ( + setShowTagDropdown(false)} + /> + )} +
+ )} + + {loopType === 'for' && ( +
+ Enter a number between 1 and 100 +
+ )} +
+
+
+
+ ) +} diff --git a/apps/sim/app/w/[id]/components/loop-node/loop-config.ts b/apps/sim/app/w/[id]/components/loop-node/loop-config.ts new file mode 100644 index 0000000000..2389b1a762 --- /dev/null +++ b/apps/sim/app/w/[id]/components/loop-node/loop-config.ts @@ -0,0 +1,31 @@ +import { RepeatIcon } from 'lucide-react' + +export const LoopTool = { + id: 'loop', + type: 'loop', + name: 'Loop', + description: 'Create a Loop', + icon: RepeatIcon, + bgColor: '#2FB3FF', + data: { + label: 'Loop', + loopType: 'for', + count: 5, + collection: '', + width: 500, + height: 300, + extent: 'parent', + executionState: { + currentIteration: 0, + isExecuting: false, + startTime: null, + endTime: null, + }, + }, + style: { + width: 500, + height: 300, + }, + // Specify that this should be rendered as a ReactFlow group node + isResizable: true, +} diff --git a/apps/sim/app/w/[id]/components/loop-node/loop-node.tsx b/apps/sim/app/w/[id]/components/loop-node/loop-node.tsx new file mode 100644 index 0000000000..f90ce40a50 --- /dev/null +++ b/apps/sim/app/w/[id]/components/loop-node/loop-node.tsx @@ -0,0 +1,245 @@ +import type React from 'react' +import { memo, useMemo, useRef } from 'react' +import { Trash2 } from 'lucide-react' +import { Handle, type NodeProps, Position, useReactFlow } from 'reactflow' +import { StartIcon } from '@/components/icons' +import { Button } from '@/components/ui/button' +import { Card } from '@/components/ui/card' +import { cn } from '@/lib/utils' +import { useWorkflowStore } from '@/stores/workflows/workflow/store' +import { LoopBadges } from './components/loop-badges' + +// Add these styles to your existing global CSS file or create a separate CSS module +const LoopNodeStyles: React.FC = () => { + return ( + + ) +} + +export const LoopNodeComponent = memo(({ data, selected, id }: NodeProps) => { + const { getNodes } = useReactFlow() + const removeBlock = useWorkflowStore((state) => state.removeBlock) + const blockRef = useRef(null) + + // Determine nesting level by counting parents + const nestingLevel = useMemo(() => { + let level = 0 + let currentParentId = data?.parentId + + while (currentParentId) { + level++ + const parentNode = getNodes().find((n) => n.id === currentParentId) + if (!parentNode) break + currentParentId = parentNode.data?.parentId + } + + return level + }, [id, data?.parentId, getNodes]) + + // Generate different background styles based on nesting level + const getNestedStyles = () => { + // Base styles + const styles: Record = { + backgroundColor: data?.state === 'valid' ? 'rgba(34,197,94,0.05)' : 'transparent', + } + + // Apply nested styles + if (nestingLevel > 0) { + // Each nesting level gets a different color + const colors = ['#e2e8f0', '#cbd5e1', '#94a3b8', '#64748b', '#475569'] + const colorIndex = (nestingLevel - 1) % colors.length + + styles.backgroundColor = `${colors[colorIndex]}30` // Slightly more visible background + } + + return styles + } + + const nestedStyles = getNestedStyles() + + return ( + <> + +
+ 0 && + `border border-[0.5px] ${nestingLevel % 2 === 0 ? 'border-slate-300/60' : 'border-slate-400/60'}` + )} + style={{ + width: data.width || 500, + height: data.height || 300, + position: 'relative', + overflow: 'visible', + ...nestedStyles, + pointerEvents: 'all', + }} + data-node-id={id} + data-type='loopNode' + data-nesting-level={nestingLevel} + > + {/* Critical drag handle that controls only the loop node movement */} +
+ + {/* Custom visible resize handle */} +
+ + {/* Child nodes container - Enable pointer events to allow dragging of children */} +
+ {/* Delete button - styled like in action-bar.tsx */} + + + {/* Loop Start Block */} +
+ + + +
+
+ + {/* Input handle on left middle */} + + + {/* Output handle on right middle */} + + + {/* Loop Configuration Badges */} + + +
+ + ) +}) + +LoopNodeComponent.displayName = 'LoopNodeComponent' diff --git a/apps/sim/app/w/[id]/components/parallel-node/components/parallel-badges.tsx b/apps/sim/app/w/[id]/components/parallel-node/components/parallel-badges.tsx new file mode 100644 index 0000000000..6f888c8f0e --- /dev/null +++ b/apps/sim/app/w/[id]/components/parallel-node/components/parallel-badges.tsx @@ -0,0 +1,328 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { ChevronDown } from 'lucide-react' +import { highlight, languages } from 'prismjs' +import Editor from 'react-simple-code-editor' +import { Badge } from '@/components/ui/badge' +import { Input } from '@/components/ui/input' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { checkTagTrigger, TagDropdown } from '@/components/ui/tag-dropdown' +import { cn } from '@/lib/utils' +import { useWorkflowStore } from '@/stores/workflows/workflow/store' +import 'prismjs/components/prism-javascript' +import 'prismjs/themes/prism.css' + +interface ParallelNodeData { + width?: number + height?: number + parentId?: string + state?: string + type?: string + extent?: 'parent' + parallelType?: 'count' | 'collection' + count?: number + collection?: string | any[] | Record + executionState?: { + currentExecution: number + isExecuting: boolean + startTime: number | null + endTime: number | null + } +} + +interface ParallelBadgesProps { + nodeId: string + data: ParallelNodeData +} + +export function ParallelBadges({ nodeId, data }: ParallelBadgesProps) { + // State + const [parallelType, setParallelType] = useState<'count' | 'collection'>( + data?.parallelType || 'collection' + ) + const [iterations, setIterations] = useState(data?.count || 5) + const [inputValue, setInputValue] = useState((data?.count || 5).toString()) + const [editorValue, setEditorValue] = useState('') + const [typePopoverOpen, setTypePopoverOpen] = useState(false) + const [configPopoverOpen, setConfigPopoverOpen] = useState(false) + const [showTags, setShowTags] = useState(false) + const [cursorPosition, setCursorPosition] = useState(0) + const editorRef = useRef(null) + + // Get store methods + const updateParallelCount = useWorkflowStore((state) => state.updateParallelCount) + const updateParallelCollection = useWorkflowStore((state) => state.updateParallelCollection) + + // Update node data to include parallel type + const updateNodeData = useCallback( + (updates: Partial) => { + useWorkflowStore.setState((state) => ({ + blocks: { + ...state.blocks, + [nodeId]: { + ...state.blocks[nodeId], + data: { + ...state.blocks[nodeId].data, + ...updates, + }, + }, + }, + })) + }, + [nodeId] + ) + + // Initialize state from data when it changes + useEffect(() => { + if (data?.parallelType && data.parallelType !== parallelType) { + setParallelType(data.parallelType) + } + if (data?.count && data.count !== iterations) { + setIterations(data.count) + setInputValue(data.count.toString()) + } + + if (data?.collection) { + if (typeof data.collection === 'string') { + setEditorValue(data.collection) + } else if (Array.isArray(data.collection) || typeof data.collection === 'object') { + setEditorValue(JSON.stringify(data.collection)) + } + } + }, [data?.parallelType, data?.count, data?.collection, parallelType, iterations]) + + // Handle parallel type change + const handleParallelTypeChange = useCallback( + (newType: 'count' | 'collection') => { + setParallelType(newType) + updateNodeData({ parallelType: newType }) + + // Reset values based on type + if (newType === 'count') { + updateParallelCollection(nodeId, '') + updateParallelCount(nodeId, iterations) + } else { + updateParallelCount(nodeId, 1) + updateParallelCollection(nodeId, editorValue || '[]') + } + + setTypePopoverOpen(false) + }, + [nodeId, iterations, editorValue, updateNodeData, updateParallelCount, updateParallelCollection] + ) + + // Handle iterations input change + const handleIterationsChange = useCallback((e: React.ChangeEvent) => { + const sanitizedValue = e.target.value.replace(/[^0-9]/g, '') + const numValue = Number.parseInt(sanitizedValue) + + if (!Number.isNaN(numValue)) { + setInputValue(Math.min(20, numValue).toString()) + } else { + setInputValue(sanitizedValue) + } + }, []) + + // Handle iterations save + const handleIterationsSave = useCallback(() => { + const value = Number.parseInt(inputValue) + + if (!Number.isNaN(value)) { + const newValue = Math.min(20, Math.max(1, value)) + setIterations(newValue) + updateParallelCount(nodeId, newValue) + setInputValue(newValue.toString()) + } else { + setInputValue(iterations.toString()) + } + setConfigPopoverOpen(false) + }, [inputValue, iterations, nodeId, updateParallelCount]) + + // Handle editor change and check for tag trigger + const handleEditorChange = useCallback( + (value: string) => { + setEditorValue(value) + updateParallelCollection(nodeId, value) + + // Get the textarea element and cursor position + const textarea = editorRef.current?.querySelector('textarea') + if (textarea) { + const position = textarea.selectionStart || 0 + setCursorPosition(position) + + // Check for tag trigger + const tagTrigger = checkTagTrigger(value, position) + setShowTags(tagTrigger.show) + } + }, + [nodeId, updateParallelCollection] + ) + + // Handle tag selection + const handleTagSelect = useCallback( + (newValue: string) => { + setEditorValue(newValue) + updateParallelCollection(nodeId, newValue) + setShowTags(false) + + // Focus back on the editor after selection + setTimeout(() => { + const textarea = editorRef.current?.querySelector('textarea') + if (textarea) { + textarea.focus() + } + }, 0) + }, + [nodeId, updateParallelCollection] + ) + + // Handle key events + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + setShowTags(false) + } + }, []) + + return ( +
+ {/* Parallel Type Badge */} + + e.stopPropagation()}> + + {parallelType === 'count' ? 'Parallel Count' : 'Parallel Each'} + + + + e.stopPropagation()}> +
+
Parallel Type
+
+
handleParallelTypeChange('count')} + > + Parallel Count +
+
handleParallelTypeChange('collection')} + > + Parallel Each +
+
+
+
+
+ + {/* Iterations/Collection Badge */} + + e.stopPropagation()}> + + {parallelType === 'count' ? `Iterations: ${iterations}` : 'Items'} + + + + e.stopPropagation()} + onKeyDown={handleKeyDown} + > +
+
+ {parallelType === 'count' ? 'Parallel Iterations' : 'Parallel Items'} +
+ + {parallelType === 'count' ? ( + // Number input for count-based parallel +
+ e.key === 'Enter' && handleIterationsSave()} + className='h-8 text-sm' + autoFocus + /> +
+ ) : ( + // Code editor for collection-based parallel +
+
+ {editorValue === '' && ( +
+ ['item1', 'item2', 'item3'] +
+ )} + highlight(code, languages.javascript, 'javascript')} + padding={0} + style={{ + fontFamily: 'monospace', + lineHeight: '21px', + }} + className='w-full focus:outline-none' + textareaClassName='focus:outline-none focus:ring-0 bg-transparent resize-none w-full overflow-hidden whitespace-pre-wrap' + onKeyDown={(e) => { + if (e.key === 'Escape') { + setShowTags(false) + } + }} + /> + + {/* Tag dropdown positioned inside the editor container */} + {showTags && ( + setShowTags(false)} + /> + )} +
+
+ Array or object to use for parallel execution. Type "{'<'}" to reference other + blocks. +
+
+ )} + + {parallelType === 'count' && ( +
+ Enter a number between 1 and 20 +
+ )} +
+
+
+
+ ) +} diff --git a/apps/sim/app/w/[id]/components/parallel-node/parallel-config.ts b/apps/sim/app/w/[id]/components/parallel-node/parallel-config.ts new file mode 100644 index 0000000000..cce237418f --- /dev/null +++ b/apps/sim/app/w/[id]/components/parallel-node/parallel-config.ts @@ -0,0 +1,29 @@ +import { SplitIcon } from 'lucide-react' + +export const ParallelTool = { + id: 'parallel', + type: 'parallel', + name: 'Parallel', + description: 'Parallel Execution', + icon: SplitIcon, + bgColor: '#FEE12B', // Yellow color for parallel execution + data: { + label: 'Parallel', + parallelType: 'collection' as 'collection' | 'count', + count: 5, + collection: '', + extent: 'parent', + executionState: { + currentExecution: 0, + isExecuting: false, + startTime: null, + endTime: null, + }, + }, + style: { + width: 500, + height: 300, + }, + // Specify that this should be rendered as a ReactFlow group node + isResizable: true, +} diff --git a/apps/sim/app/w/[id]/components/parallel-node/parallel-node.tsx b/apps/sim/app/w/[id]/components/parallel-node/parallel-node.tsx new file mode 100644 index 0000000000..85f7e47d52 --- /dev/null +++ b/apps/sim/app/w/[id]/components/parallel-node/parallel-node.tsx @@ -0,0 +1,244 @@ +import type React from 'react' +import { memo, useMemo, useRef } from 'react' +import { Trash2 } from 'lucide-react' +import { Handle, type NodeProps, Position, useReactFlow } from 'reactflow' +import { StartIcon } from '@/components/icons' +import { Button } from '@/components/ui/button' +import { Card } from '@/components/ui/card' +import { cn } from '@/lib/utils' +import { useWorkflowStore } from '@/stores/workflows/workflow/store' +import { ParallelBadges } from './components/parallel-badges' + +const ParallelNodeStyles: React.FC = () => { + return ( + + ) +} + +export const ParallelNodeComponent = memo(({ data, selected, id }: NodeProps) => { + const { getNodes } = useReactFlow() + const blockRef = useRef(null) + + // Determine nesting level by counting parents + const nestingLevel = useMemo(() => { + const maxDepth = 100 // Prevent infinite loops + let level = 0 + let currentParentId = data?.parentId + + while (currentParentId && level < maxDepth) { + level++ + const parentNode = getNodes().find((n) => n.id === currentParentId) + if (!parentNode) break + currentParentId = parentNode.data?.parentId + } + + return level + }, [id, data?.parentId, getNodes]) + + // Generate different background styles based on nesting level + const getNestedStyles = () => { + // Base styles + const styles: Record = { + backgroundColor: data?.state === 'valid' ? 'rgba(254, 225, 43, 0.05)' : 'transparent', + } + + // Apply nested styles + if (nestingLevel > 0) { + // Each nesting level gets a different color + const colors = ['#e2e8f0', '#cbd5e1', '#94a3b8', '#64748b', '#475569'] + const colorIndex = (nestingLevel - 1) % colors.length + + styles.backgroundColor = `${colors[colorIndex]}30` // Slightly more visible background + } + + return styles + } + + const nestedStyles = getNestedStyles() + + return ( + <> + +
+ 0 && + `border border-[0.5px] ${nestingLevel % 2 === 0 ? 'border-slate-300/60' : 'border-slate-400/60'}` + )} + style={{ + width: data.width || 500, + height: data.height || 300, + position: 'relative', + overflow: 'visible', + ...nestedStyles, + pointerEvents: 'all', + }} + data-node-id={id} + data-type='parallelNode' + data-nesting-level={nestingLevel} + > + {/* Critical drag handle that controls only the parallel node movement */} +
+ + {/* Custom visible resize handle */} +
+ + {/* Child nodes container - Set pointerEvents to allow dragging of children */} +
+ {/* Delete button - styled like in action-bar.tsx */} + + + {/* Parallel Start Block */} +
+ + + +
+
+ + {/* Input handle on left middle */} + + + {/* Output handle on right middle */} + + + {/* Parallel Configuration Badges */} + + +
+ + ) +}) + +ParallelNodeComponent.displayName = 'ParallelNodeComponent' diff --git a/apps/sim/app/w/[id]/components/toolbar/components/toolbar-loop-block/toolbar-loop-block.tsx b/apps/sim/app/w/[id]/components/toolbar/components/toolbar-loop-block/toolbar-loop-block.tsx new file mode 100644 index 0000000000..d07ca5e571 --- /dev/null +++ b/apps/sim/app/w/[id]/components/toolbar/components/toolbar-loop-block/toolbar-loop-block.tsx @@ -0,0 +1,47 @@ +import { useCallback } from 'react' +import { LoopTool } from '../../../loop-node/loop-config' + +// Custom component for the Loop Tool +export default function LoopToolbarItem() { + const handleDragStart = (e: React.DragEvent) => { + // Only send the essential data for the loop node + const simplifiedData = { + type: 'loop', + } + e.dataTransfer.setData('application/json', JSON.stringify(simplifiedData)) + e.dataTransfer.effectAllowed = 'move' + } + + // Handle click to add loop block + const handleClick = useCallback((e: React.MouseEvent) => { + // Dispatch a custom event to be caught by the workflow component + const event = new CustomEvent('add-block-from-toolbar', { + detail: { + type: 'loop', + clientX: e.clientX, + clientY: e.clientY, + }, + }) + window.dispatchEvent(event) + }, []) + + return ( +
+
+ +
+
+

{LoopTool.name}

+

{LoopTool.description}

+
+
+ ) +} diff --git a/apps/sim/app/w/[id]/components/toolbar/components/toolbar-parallel-block/toolbar-parallel-block.tsx b/apps/sim/app/w/[id]/components/toolbar/components/toolbar-parallel-block/toolbar-parallel-block.tsx new file mode 100644 index 0000000000..9f277ba67b --- /dev/null +++ b/apps/sim/app/w/[id]/components/toolbar/components/toolbar-parallel-block/toolbar-parallel-block.tsx @@ -0,0 +1,48 @@ +import { useCallback } from 'react' +import { ParallelTool } from '../../../parallel-node/parallel-config' + +// Custom component for the Parallel Tool +export default function ParallelToolbarItem() { + const handleDragStart = (e: React.DragEvent) => { + // Only send the essential data for the parallel node + const simplifiedData = { + type: 'parallel', + } + e.dataTransfer.setData('application/json', JSON.stringify(simplifiedData)) + e.dataTransfer.effectAllowed = 'move' + } + + // Handle click to add parallel block + const handleClick = useCallback((e: React.MouseEvent) => { + // Dispatch a custom event to be caught by the workflow component + const event = new CustomEvent('add-block-from-toolbar', { + detail: { + type: 'parallel', + clientX: e.clientX, + clientY: e.clientY, + }, + bubbles: true, + }) + window.dispatchEvent(event) + }, []) + + return ( +
+
+ +
+
+

{ParallelTool.name}

+

{ParallelTool.description}

+
+
+ ) +} diff --git a/apps/sim/app/w/[id]/components/toolbar/toolbar.tsx b/apps/sim/app/w/[id]/components/toolbar/toolbar.tsx index a75e8ded1c..a5b6d6a174 100644 --- a/apps/sim/app/w/[id]/components/toolbar/toolbar.tsx +++ b/apps/sim/app/w/[id]/components/toolbar/toolbar.tsx @@ -9,6 +9,8 @@ import { getAllBlocks, getBlocksByCategory } from '@/blocks' import type { BlockCategory } from '@/blocks/types' import { useSidebarStore } from '@/stores/sidebar/store' import { ToolbarBlock } from './components/toolbar-block/toolbar-block' +import LoopToolbarItem from './components/toolbar-loop-block/toolbar-loop-block' +import ParallelToolbarItem from './components/toolbar-parallel-block/toolbar-parallel-block' import { ToolbarTabs } from './components/toolbar-tabs/toolbar-tabs' export function Toolbar() { @@ -87,6 +89,12 @@ export function Toolbar() { {blocks.map((block) => ( ))} + {activeTab === 'blocks' && !searchQuery && ( + <> + + + + )}
diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/condition-input.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/condition-input.tsx index 74a3f260bb..f2e070b2ae 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/condition-input.tsx +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/condition-input.tsx @@ -556,6 +556,10 @@ export function ConditionInput({ blockId, subBlockId, isConnecting }: ConditionI isConnectableStart={true} isConnectableEnd={false} isValidConnection={(connection) => { + // Prevent self-connections + if (connection.source === connection.target) return false + + // Existing validation to prevent connections within the same parent node const sourceNodeId = connection.source?.split('-')[0] const targetNodeId = connection.target?.split('-')[0] return sourceNodeId !== targetNodeId diff --git a/apps/sim/app/w/[id]/components/workflow-block/workflow-block.tsx b/apps/sim/app/w/[id]/components/workflow-block/workflow-block.tsx index 752819b609..bad9940317 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/workflow-block.tsx +++ b/apps/sim/app/w/[id]/components/workflow-block/workflow-block.tsx @@ -421,7 +421,7 @@ export function WorkflowBlock({ id, data }: NodeProps) { data-handleid='target' isConnectableStart={false} isConnectableEnd={true} - isValidConnection={(connection) => true} + isValidConnection={(connection) => connection.source !== id} /> )} @@ -707,7 +707,7 @@ export function WorkflowBlock({ id, data }: NodeProps) { data-handleid='source' isConnectableStart={true} isConnectableEnd={false} - isValidConnection={(connection) => true} + isValidConnection={(connection) => connection.target !== id} /> {/* Error Handle - Don't show for starter blocks */} @@ -747,7 +747,7 @@ export function WorkflowBlock({ id, data }: NodeProps) { data-handleid='error' isConnectableStart={true} isConnectableEnd={false} - isValidConnection={(connection) => true} + isValidConnection={(connection) => connection.target !== id} /> )} diff --git a/apps/sim/app/w/[id]/components/workflow-edge/workflow-edge.tsx b/apps/sim/app/w/[id]/components/workflow-edge/workflow-edge.tsx index 100d682c20..0b87808d06 100644 --- a/apps/sim/app/w/[id]/components/workflow-edge/workflow-edge.tsx +++ b/apps/sim/app/w/[id]/components/workflow-edge/workflow-edge.tsx @@ -10,6 +10,7 @@ export const WorkflowEdge = ({ sourcePosition, targetPosition, data, + style, }: EdgeProps) => { const isHorizontal = sourcePosition === 'right' || sourcePosition === 'left' @@ -24,20 +25,30 @@ export const WorkflowEdge = ({ offset: isHorizontal ? 30 : 20, }) - const isSelected = id === data?.selectedEdgeId + // Use the directly provided isSelected flag instead of computing it + const isSelected = data?.isSelected ?? false + const isInsideLoop = data?.isInsideLoop ?? false + const parentLoopId = data?.parentLoopId + + // Merge any style props passed from parent + const edgeStyle = { + strokeWidth: isSelected ? 2.5 : 2, + stroke: isSelected ? '#475569' : '#94a3b8', + strokeDasharray: '5,5', + ...style, + } return ( <>
{ e.preventDefault() e.stopPropagation() + if (data?.onDelete) { + // Pass this specific edge's ID to the delete function data.onDelete(id) } }} diff --git a/apps/sim/app/w/[id]/components/workflow-loop/components/loop-input/loop-input.tsx b/apps/sim/app/w/[id]/components/workflow-loop/components/loop-input/loop-input.tsx deleted file mode 100644 index 0422c5604c..0000000000 --- a/apps/sim/app/w/[id]/components/workflow-loop/components/loop-input/loop-input.tsx +++ /dev/null @@ -1,189 +0,0 @@ -import { useEffect, useRef, useState } from 'react' -import { ChevronDown } from 'lucide-react' -import { highlight, languages } from 'prismjs' -import 'prismjs/components/prism-javascript' -import 'prismjs/themes/prism.css' - -import Editor from 'react-simple-code-editor' -import type { NodeProps } from 'reactflow' -import { Badge } from '@/components/ui/badge' -import { Input } from '@/components/ui/input' -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' -import { cn } from '@/lib/utils' -import { useWorkflowStore } from '@/stores/workflows/workflow/store' - -export function LoopInput({ id }: NodeProps) { - // Extract the loop ID from the node ID - const loopId = id.replace('loop-input-', '') - - // Get the loop data from the store - const loop = useWorkflowStore((state) => state.loops[loopId]) - const iterations = loop?.iterations ?? 5 - const loopType = loop?.loopType || 'for' - const updateLoopIterations = useWorkflowStore((state) => state.updateLoopIterations) - const updateLoopForEachItems = useWorkflowStore((state) => state.updateLoopForEachItems) - - // Local state for input values - const [inputValue, setInputValue] = useState(iterations.toString()) - const [editorValue, setEditorValue] = useState('') - const [open, setOpen] = useState(false) - const editorRef = useRef(null) - - // Initialize editor value from the store - useEffect(() => { - if (loopType === 'forEach' && loop?.forEachItems) { - // Handle different types of forEachItems - if (typeof loop.forEachItems === 'string') { - // Preserve the string exactly as stored - setEditorValue(loop.forEachItems) - } else if (Array.isArray(loop.forEachItems) || typeof loop.forEachItems === 'object') { - // For new objects/arrays from the store, use default formatting - // This only happens for data loaded from DB that wasn't originally user-formatted - setEditorValue(JSON.stringify(loop.forEachItems)) - } - } else if (loopType === 'forEach') { - setEditorValue('') - } - }, [loopType, loop?.forEachItems]) - - const handleChange = (e: React.ChangeEvent) => { - const sanitizedValue = e.target.value.replace(/[^0-9]/g, '') - const numValue = Number.parseInt(sanitizedValue) - - // Only update if it's a valid number and <= 50 - if (!Number.isNaN(numValue)) { - setInputValue(Math.min(50, numValue).toString()) - } else { - setInputValue(sanitizedValue) - } - } - - const handleSave = () => { - const value = Number.parseInt(inputValue) - - if (!Number.isNaN(value)) { - const newValue = Math.min(50, Math.max(1, value)) - updateLoopIterations(loopId, newValue) - // Sync input with store value - setInputValue(newValue.toString()) - } else { - // Reset to current store value if invalid - setInputValue(iterations.toString()) - } - } - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - e.preventDefault() - handleSave() - setOpen(false) - } - } - - const handleEditorChange = (value: string) => { - // Always set the editor value to exactly what the user typed - setEditorValue(value) - - // Save the items to the store for forEach loops - if (loopType === 'forEach') { - // Pass the exact string to preserve formatting - updateLoopForEachItems(loopId, value) - } - } - - // Determine label based on loop type - const getLabel = () => { - switch (loopType) { - case 'for': - return `Iterations: ${iterations}` - case 'forEach': - return 'Items' - default: - return `Iterations: ${iterations}` - } - } - - const getPlaceholder = () => { - switch (loopType) { - case 'forEach': - return "['item1', 'item2', 'item3']" - default: - return '' - } - } - - return ( - - e.stopPropagation()}> - - {getLabel()} - - - - e.stopPropagation()} - > -
-
-
- {loopType === 'for' ? 'Loop Iterations' : 'Collection Items'} -
-
- - {loopType === 'for' ? ( - // Number input for 'for' loops -
- -
- ) : ( - // Code editor for 'forEach' loops -
- {editorValue === '' && ( -
- {getPlaceholder()} -
- )} - highlight(code, languages.javascript, 'javascript')} - padding={0} - style={{ - fontFamily: 'monospace', - lineHeight: '21px', - }} - className='w-full focus:outline-none' - textareaClassName='focus:outline-none focus:ring-0 bg-transparent resize-none w-full overflow-hidden whitespace-pre-wrap' - /> -
- )} - -
- {loopType === 'for' - ? 'Enter a number between 1 and 50' - : 'Array or object to iterate over'} -
-
-
-
- ) -} diff --git a/apps/sim/app/w/[id]/components/workflow-loop/components/loop-label/loop-label.tsx b/apps/sim/app/w/[id]/components/workflow-loop/components/loop-label/loop-label.tsx deleted file mode 100644 index 254206a181..0000000000 --- a/apps/sim/app/w/[id]/components/workflow-loop/components/loop-label/loop-label.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { useState } from 'react' -import { ChevronDown } from 'lucide-react' -import type { NodeProps } from 'reactflow' -import { Badge } from '@/components/ui/badge' -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' -import { cn } from '@/lib/utils' -import { useWorkflowStore } from '@/stores/workflows/workflow/store' - -export function LoopLabel({ id, data }: NodeProps) { - // Extract the loop ID from the node ID - const loopId = id.replace('loop-label-', '') - - // Get the loop type from the store - const loop = useWorkflowStore((state) => state.loops[loopId]) - const updateLoopType = useWorkflowStore((state) => state.updateLoopType) - - // Local state for popover - const [open, setOpen] = useState(false) - - // Default to 'for' if not set - const loopType = loop?.loopType || 'for' - - // Get label based on loop type - const getLoopLabel = () => { - switch (loopType) { - case 'for': - return 'For loop' - case 'forEach': - return 'For each' - default: - return 'Loop' - } - } - - const handleLoopTypeChange = (type: 'for' | 'forEach') => { - updateLoopType(loopId, type) - setOpen(false) - } - - return ( - - e.stopPropagation()}> - - {getLoopLabel()} - - - - e.stopPropagation()}> -
-
handleLoopTypeChange('for')} - > - For loop -
-
handleLoopTypeChange('forEach')} - > - For each -
-
-
-
- ) -} diff --git a/apps/sim/app/w/[id]/components/workflow-loop/workflow-loop.tsx b/apps/sim/app/w/[id]/components/workflow-loop/workflow-loop.tsx deleted file mode 100644 index c6a3c907f3..0000000000 --- a/apps/sim/app/w/[id]/components/workflow-loop/workflow-loop.tsx +++ /dev/null @@ -1,142 +0,0 @@ -import { useWorkflowStore } from '@/stores/workflows/workflow/store' -import type { Loop } from '@/stores/workflows/workflow/types' - -interface WorkflowLoopProps { - loopId: string - loop: Loop - blocks: Record -} - -// Helper function to create loop label node -function createLoopLabelNode(loopId: string, bounds: { x: number; y: number }) { - return { - id: `loop-label-${loopId}`, - type: 'loopLabel', - position: { x: 0, y: -32 }, - parentNode: `loop-${loopId}`, - draggable: false, - data: { - loopId, - label: 'Loop', - }, - } -} - -// Helper function to create loop input node -function createLoopInputNode(loopId: string, bounds: { x: number; width: number }) { - const loop = useWorkflowStore.getState().loops[loopId] - const loopType = loop?.loopType || 'for' - - // Dynamic width based on loop type - let BADGE_WIDTH = 116 // Default for 'for' loop - - if (loopType === 'forEach') { - BADGE_WIDTH = 72 // Adjusted for 'Items' text - } - - return { - id: `loop-input-${loopId}`, - type: 'loopInput', - position: { x: bounds.width - BADGE_WIDTH, y: -32 }, // Position from right edge - parentNode: `loop-${loopId}`, - draggable: false, - data: { - loopId, - }, - } -} - -function calculateLoopBounds(loop: Loop, blocks: Record) { - // Get all blocks in this loop and filter out any undefined blocks - const loopBlocks = loop.nodes - .map((id) => blocks[id]) - .filter( - (block): block is NonNullable => - block !== undefined && block.position !== undefined - ) - - if (!loopBlocks.length) return null - - // Calculate bounds of all blocks in loop - const bound = loopBlocks.reduce( - (acc, block) => { - // Calculate block dimensions - const blockWidth = block.isWide ? 480 : 320 - const blockHeight = block.height || 200 // Fallback height if not set - - // Update bounds - acc.minX = Math.min(acc.minX, block.position.x) - acc.minY = Math.min(acc.minY, block.position.y) - acc.maxX = Math.max(acc.maxX, block.position.x + blockWidth) - acc.maxY = Math.max(acc.maxY, block.position.y + blockHeight) - return acc - }, - { - minX: Number.POSITIVE_INFINITY, - minY: Number.POSITIVE_INFINITY, - maxX: Number.NEGATIVE_INFINITY, - maxY: Number.NEGATIVE_INFINITY, - } - ) - - // Add padding around the group with extra bottom padding - const PADDING = { - TOP: 50, - RIGHT: 50, - BOTTOM: 110, - LEFT: 50, - } - - return { - x: bound.minX - PADDING.LEFT, - y: bound.minY - PADDING.TOP, - width: bound.maxX - bound.minX + PADDING.LEFT + PADDING.RIGHT, - height: bound.maxY - bound.minY + PADDING.TOP + PADDING.BOTTOM, - } -} - -// Update the createLoopNode function -export function createLoopNode({ loopId, loop, blocks }: WorkflowLoopProps) { - const loopBounds = calculateLoopBounds(loop, blocks) - if (!loopBounds) return null - - const loopNode = { - id: `loop-${loopId}`, - type: 'group', - position: { x: loopBounds.x, y: loopBounds.y }, - className: 'bg-[rgb(247,247,248)] dark:bg-[rgb(36,37,45)] dark:bg-opacity-50', - style: { - border: '1px solid rgb(203, 213, 225)', - borderRadius: '12px', - width: loopBounds.width, - height: loopBounds.height, - pointerEvents: 'none', - zIndex: -1, - isolation: 'isolate', - }, - darkModeStyle: { - borderColor: 'rgb(63, 63, 70)', - }, - data: { - label: 'Loop', - }, - } - - // Create both label and input nodes - const labelNode = createLoopLabelNode(loopId, loopBounds) - const inputNode = createLoopInputNode(loopId, loopBounds) - - // Return all three nodes - return [loopNode, labelNode, inputNode] -} - -// Helper function to calculate relative position for child blocks -export function getRelativeLoopPosition( - blockPosition: { x: number; y: number }, - loopBounds: { x: number; y: number } -) { - return { - x: blockPosition.x - loopBounds.x, - y: blockPosition.y - loopBounds.y, - } -} diff --git a/apps/sim/app/w/[id]/hooks/use-workflow-execution.ts b/apps/sim/app/w/[id]/hooks/use-workflow-execution.ts index 5631c18756..95ba2db2b5 100644 --- a/apps/sim/app/w/[id]/hooks/use-workflow-execution.ts +++ b/apps/sim/app/w/[id]/hooks/use-workflow-execution.ts @@ -19,7 +19,7 @@ import { useWorkflowStore } from '@/stores/workflows/workflow/store' const logger = createLogger('useWorkflowExecution') export function useWorkflowExecution() { - const { blocks, edges, loops } = useWorkflowStore() + const { blocks, edges, loops, parallels } = useWorkflowStore() const { activeWorkflowId } = useWorkflowRegistry() const { addNotification } = useNotificationStore() const { toggleConsole } = useConsoleStore() @@ -185,7 +185,7 @@ export function useWorkflowExecution() { ) // Create serialized workflow - const workflow = new Serializer().serializeWorkflow(mergedStates, edges, loops) + const workflow = new Serializer().serializeWorkflow(mergedStates, edges, loops, parallels) // Create executor options with streaming support for chat const executorOptions: any = { @@ -439,6 +439,7 @@ export function useWorkflowExecution() { blocks, edges, loops, + parallels, addNotification, toggleConsole, togglePanel, diff --git a/apps/sim/app/w/[id]/utils.ts b/apps/sim/app/w/[id]/utils.ts new file mode 100644 index 0000000000..28afd6cc6c --- /dev/null +++ b/apps/sim/app/w/[id]/utils.ts @@ -0,0 +1,351 @@ +import { createLogger } from '@/lib/logs/console-logger' + +const logger = createLogger('WorkflowUtils') + +// Default dimensions for loop and parallel container nodes +const DEFAULT_CONTAINER_WIDTH = 500 +const DEFAULT_CONTAINER_HEIGHT = 300 + +/** + * Utility functions for handling node hierarchies and loop operations in the workflow + */ + +/** + * Calculates the depth of a node in the hierarchy tree + * @param nodeId ID of the node to check + * @param getNodes Function to retrieve all nodes from ReactFlow + * @param maxDepth Maximum depth to prevent stack overflow + * @returns Depth level (0 for root nodes, increasing for nested nodes) + */ +export const getNodeDepth = (nodeId: string, getNodes: () => any[], maxDepth = 100): number => { + const node = getNodes().find((n) => n.id === nodeId) + if (!node || !node.parentId) return 0 + if (maxDepth <= 0) return 0 + return 1 + getNodeDepth(node.parentId, getNodes, maxDepth - 1) +} + +/** + * Gets the full hierarchy path of a node (its parent chain) + * @param nodeId ID of the node to check + * @param getNodes Function to retrieve all nodes from ReactFlow + * @returns Array of node IDs representing the hierarchy path + */ +export const getNodeHierarchy = (nodeId: string, getNodes: () => any[]): string[] => { + const node = getNodes().find((n) => n.id === nodeId) + if (!node || !node.parentId) return [nodeId] + return [...getNodeHierarchy(node.parentId, getNodes), nodeId] +} + +/** + * Gets the absolute position of a node (accounting for nested parents) + * @param nodeId ID of the node to check + * @param getNodes Function to retrieve all nodes from ReactFlow + * @returns Absolute position coordinates {x, y} + */ +export const getNodeAbsolutePosition = ( + nodeId: string, + getNodes: () => any[] +): { x: number; y: number } => { + const node = getNodes().find((n) => n.id === nodeId) + if (!node) { + // Handle case where node doesn't exist anymore by returning origin position + // This helps prevent errors during cleanup operations + logger.warn('Attempted to get position of non-existent node', { nodeId }) + return { x: 0, y: 0 } + } + + if (!node.parentId) { + return node.position + } + + // Check if parent exists + const parentNode = getNodes().find((n) => n.id === node.parentId) + if (!parentNode) { + // Parent reference is invalid, return node's current position + logger.warn('Node references non-existent parent', { + nodeId, + invalidParentId: node.parentId, + }) + return node.position + } + + // Check for circular reference to prevent infinite recursion + const visited = new Set() + let current: any = node + while (current?.parentId) { + if (visited.has(current.parentId)) { + // Circular reference detected + logger.error('Circular parent reference detected', { + nodeId, + parentChain: Array.from(visited), + }) + return node.position + } + visited.add(current.id) + current = getNodes().find((n) => n.id === current.parentId) + } + + // Get parent's absolute position + const parentPos = getNodeAbsolutePosition(node.parentId, getNodes) + + // Calculate this node's absolute position + return { + x: parentPos.x + node.position.x, + y: parentPos.y + node.position.y, + } +} + +/** + * Calculates the relative position of a node to a new parent + * @param nodeId ID of the node being repositioned + * @param newParentId ID of the new parent + * @param getNodes Function to retrieve all nodes from ReactFlow + * @returns Relative position coordinates {x, y} + */ +export const calculateRelativePosition = ( + nodeId: string, + newParentId: string, + getNodes: () => any[] +): { x: number; y: number } => { + // Get absolute position of the node + const nodeAbsPos = getNodeAbsolutePosition(nodeId, getNodes) + + // Get absolute position of the new parent + const parentAbsPos = getNodeAbsolutePosition(newParentId, getNodes) + + // Calculate relative position + return { + x: nodeAbsPos.x - parentAbsPos.x, + y: nodeAbsPos.y - parentAbsPos.y, + } +} + +/** + * Updates a node's parent with proper position calculation + * @param nodeId ID of the node being reparented + * @param newParentId ID of the new parent (or null to remove parent) + * @param getNodes Function to retrieve all nodes from ReactFlow + * @param updateBlockPosition Function to update the position of a block + * @param updateParentId Function to update the parent ID of a block + * @param resizeLoopNodes Function to resize loop nodes after parent update + */ +export const updateNodeParent = ( + nodeId: string, + newParentId: string | null, + getNodes: () => any[], + updateBlockPosition: (id: string, position: { x: number; y: number }) => void, + updateParentId: (id: string, parentId: string, extent: 'parent') => void, + resizeLoopNodes: () => void +) => { + // Skip if no change + const node = getNodes().find((n) => n.id === nodeId) + if (!node) return + + const currentParentId = node.parentId || null + if (newParentId === currentParentId) return + + if (newParentId) { + // Moving to a new parent - calculate relative position + const relativePosition = calculateRelativePosition(nodeId, newParentId, getNodes) + + // Update both position and parent + updateBlockPosition(nodeId, relativePosition) + updateParentId(nodeId, newParentId, 'parent') + + logger.info('Updated node parent', { + nodeId, + newParentId, + relativePosition, + }) + } else if (currentParentId) { + // Removing parent - convert to absolute position + const absolutePosition = getNodeAbsolutePosition(nodeId, getNodes) + + // Update position to absolute coordinates and remove parent + updateBlockPosition(nodeId, absolutePosition) + // Note: updateParentId function signature needs to handle null case + + logger.info('Removed node parent', { + nodeId, + previousParentId: currentParentId, + absolutePosition, + }) + } + + // Resize affected loops + resizeLoopNodes() +} + +/** + * Checks if a point is inside a loop or parallel node + * @param position Position coordinates to check + * @param getNodes Function to retrieve all nodes from ReactFlow + * @returns The smallest container node containing the point, or null if none + */ +export const isPointInLoopNode = ( + position: { x: number; y: number }, + getNodes: () => any[] +): { + loopId: string + loopPosition: { x: number; y: number } + dimensions: { width: number; height: number } +} | null => { + // Find loops and parallel nodes that contain this position point + const containingNodes = getNodes() + .filter((n) => n.type === 'loopNode' || n.type === 'parallelNode') + .filter((n) => { + const rect = { + left: n.position.x, + right: n.position.x + (n.data?.width || DEFAULT_CONTAINER_WIDTH), + top: n.position.y, + bottom: n.position.y + (n.data?.height || DEFAULT_CONTAINER_HEIGHT), + } + + return ( + position.x >= rect.left && + position.x <= rect.right && + position.y >= rect.top && + position.y <= rect.bottom + ) + }) + .map((n) => ({ + loopId: n.id, + loopPosition: n.position, + dimensions: { + width: n.data?.width || DEFAULT_CONTAINER_WIDTH, + height: n.data?.height || DEFAULT_CONTAINER_HEIGHT, + }, + })) + + // Sort by area (smallest first) in case of nested containers + if (containingNodes.length > 0) { + return containingNodes.sort((a, b) => { + const aArea = a.dimensions.width * a.dimensions.height + const bArea = b.dimensions.width * b.dimensions.height + return aArea - bArea + })[0] + } + + return null +} + +/** + * Calculates appropriate dimensions for a loop or parallel node based on its children + * @param nodeId ID of the container node + * @param getNodes Function to retrieve all nodes from ReactFlow + * @returns Calculated width and height for the container + */ +export const calculateLoopDimensions = ( + nodeId: string, + getNodes: () => any[] +): { width: number; height: number } => { + // Default minimum dimensions + const minWidth = DEFAULT_CONTAINER_WIDTH + const minHeight = DEFAULT_CONTAINER_HEIGHT + + // Get all child nodes of this container + const childNodes = getNodes().filter((node) => node.parentId === nodeId) + + if (childNodes.length === 0) { + return { width: minWidth, height: minHeight } + } + + // Calculate the bounding box that contains all children + let minX = Number.POSITIVE_INFINITY + let minY = Number.POSITIVE_INFINITY + let maxX = Number.NEGATIVE_INFINITY + let maxY = Number.NEGATIVE_INFINITY + + childNodes.forEach((node) => { + // Get accurate node dimensions based on node type + let nodeWidth + let nodeHeight + + if (node.type === 'loopNode' || node.type === 'parallelNode') { + // For nested containers, don't add excessive padding to the parent + // Use actual dimensions without additional padding to prevent cascading expansion + nodeWidth = node.data?.width || DEFAULT_CONTAINER_WIDTH + nodeHeight = node.data?.height || DEFAULT_CONTAINER_HEIGHT + } else if (node.type === 'workflowBlock') { + // Handle all workflowBlock types appropriately + const blockType = node.data?.type + + switch (blockType) { + case 'agent': + case 'api': + // Tall blocks + nodeWidth = 350 + nodeHeight = 650 + break + case 'condition': + case 'function': + nodeWidth = 250 + nodeHeight = 200 + break + case 'router': + nodeWidth = 250 + nodeHeight = 350 + break + default: + // Default dimensions for other block types + nodeWidth = 200 + nodeHeight = 200 + } + } else { + // Default dimensions for any other node types + nodeWidth = 200 + nodeHeight = 200 + } + + minX = Math.min(minX, node.position.x) + minY = Math.min(minY, node.position.y) + maxX = Math.max(maxX, node.position.x + nodeWidth) + maxY = Math.max(maxY, node.position.y + nodeHeight) + }) + + // Add buffer padding to all sides (20px buffer before edges) + // Add extra padding for nested containers to prevent tight boundaries + const hasNestedContainers = childNodes.some( + (node) => node.type === 'loopNode' || node.type === 'parallelNode' + ) + + // More reasonable padding values, especially for nested containers + // Reduce the excessive padding that was causing parent containers to be too large + const sidePadding = hasNestedContainers ? 150 : 120 // Reduced padding for containers containing other containers + + // Ensure the width and height are never less than the minimums + // Apply padding to all sides (left/right and top/bottom) + const width = Math.max(minWidth, maxX + sidePadding) + const height = Math.max(minHeight, maxY + sidePadding) + + return { width, height } +} + +/** + * Resizes all loop and parallel nodes based on their children + * @param getNodes Function to retrieve all nodes from ReactFlow + * @param updateNodeDimensions Function to update the dimensions of a node + */ +export const resizeLoopNodes = ( + getNodes: () => any[], + updateNodeDimensions: (id: string, dimensions: { width: number; height: number }) => void +) => { + // Find all container nodes and sort by hierarchy depth (parents first) + const containerNodes = getNodes() + .filter((node) => node.type === 'loopNode' || node.type === 'parallelNode') + .map((node) => ({ + ...node, + depth: getNodeDepth(node.id, getNodes), + })) + .sort((a, b) => a.depth - b.depth) + + // Resize each container node based on its children + containerNodes.forEach((node) => { + const dimensions = calculateLoopDimensions(node.id, getNodes) + + // Only update if dimensions have changed (to avoid unnecessary updates) + if (dimensions.width !== node.data?.width || dimensions.height !== node.data?.height) { + updateNodeDimensions(node.id, dimensions) + } + }) +} diff --git a/apps/sim/app/w/[id]/workflow.tsx b/apps/sim/app/w/[id]/workflow.tsx index 26f433f27a..3c4b046db9 100644 --- a/apps/sim/app/w/[id]/workflow.tsx +++ b/apps/sim/app/w/[id]/workflow.tsx @@ -14,7 +14,9 @@ import 'reactflow/dist/style.css' import { LoadingAgent } from '@/components/ui/loading-agent' import { createLogger } from '@/lib/logs/console-logger' +import { LoopNodeComponent } from '@/app/w/[id]/components/loop-node/loop-node' import { NotificationList } from '@/app/w/[id]/components/notifications/notifications' +import { ParallelNodeComponent } from '@/app/w/[id]/components/parallel-node/parallel-node' import { getBlock } from '@/blocks' import { useExecutionStore } from '@/stores/execution/store' import { useNotificationStore } from '@/stores/notifications/store' @@ -31,38 +33,62 @@ import { Panel } from './components/panel/panel' import { Toolbar } from './components/toolbar/toolbar' import { WorkflowBlock } from './components/workflow-block/workflow-block' import { WorkflowEdge } from './components/workflow-edge/workflow-edge' -import { LoopInput } from './components/workflow-loop/components/loop-input/loop-input' -import { LoopLabel } from './components/workflow-loop/components/loop-label/loop-label' -import { createLoopNode, getRelativeLoopPosition } from './components/workflow-loop/workflow-loop' +import { + calculateLoopDimensions, + calculateRelativePosition, + getNodeAbsolutePosition, + getNodeDepth, + getNodeHierarchy, + isPointInLoopNode, + resizeLoopNodes, + updateNodeParent as updateNodeParentUtil, +} from './utils' const logger = createLogger('Workflow') // Define custom node and edge types const nodeTypes: NodeTypes = { workflowBlock: WorkflowBlock, - loopLabel: LoopLabel, - loopInput: LoopInput, + loopNode: LoopNodeComponent, + parallelNode: ParallelNodeComponent, } const edgeTypes: EdgeTypes = { workflowEdge: WorkflowEdge } function WorkflowContent() { // State - const [selectedEdgeId, setSelectedEdgeId] = useState(null) const [isInitialized, setIsInitialized] = useState(false) const { mode, isExpanded } = useSidebarStore() // In hover mode, act as if sidebar is always collapsed for layout purposes const isSidebarCollapsed = mode === 'expanded' ? !isExpanded : mode === 'collapsed' || mode === 'hover' - + // State for tracking node dragging + const [draggedNodeId, setDraggedNodeId] = useState(null) + const [potentialParentId, setPotentialParentId] = useState(null) + // Enhanced edge selection with parent context and unique identifier + const [selectedEdgeInfo, setSelectedEdgeInfo] = useState<{ + id: string + parentLoopId?: string + contextId?: string // Unique identifier combining edge ID and context + } | null>(null) // Hooks const params = useParams() const router = useRouter() - const { project } = useReactFlow() + const { project, getNodes } = useReactFlow() // Store access const { workflows, setActiveWorkflow, createWorkflow } = useWorkflowRegistry() - const { blocks, edges, loops, addBlock, updateBlockPosition, addEdge, removeEdge } = - useWorkflowStore() + //Removed loops from the store + const { + blocks, + edges, + addBlock, + updateNodeDimensions, + updateBlockPosition, + addEdge, + removeEdge, + updateParentId, + removeBlock, + } = useWorkflowStore() const { setValue: setSubBlockValue } = useSubBlockStore() const { markAllAsRead } = useNotificationStore() const { resetLoaded: resetVariablesLoaded } = useVariablesStore() @@ -70,6 +96,73 @@ function WorkflowContent() { // Execution and debug mode state const { activeBlockIds, pendingBlocks } = useExecutionStore() const { isDebugModeEnabled } = useGeneralStore() + const [dragStartParentId, setDragStartParentId] = useState(null) + + // Wrapper functions that use the utilities but provide the getNodes function + const getNodeDepthWrapper = useCallback( + (nodeId: string): number => { + return getNodeDepth(nodeId, getNodes) + }, + [getNodes] + ) + + const getNodeHierarchyWrapper = useCallback( + (nodeId: string): string[] => { + return getNodeHierarchy(nodeId, getNodes) + }, + [getNodes] + ) + + const getNodeAbsolutePositionWrapper = useCallback( + (nodeId: string): { x: number; y: number } => { + return getNodeAbsolutePosition(nodeId, getNodes) + }, + [getNodes] + ) + + const calculateRelativePositionWrapper = useCallback( + (nodeId: string, newParentId: string): { x: number; y: number } => { + return calculateRelativePosition(nodeId, newParentId, getNodes) + }, + [getNodes] + ) + + // Helper function to update a node's parent with proper position calculation + const updateNodeParent = useCallback( + (nodeId: string, newParentId: string | null) => { + return updateNodeParentUtil( + nodeId, + newParentId, + getNodes, + updateBlockPosition, + updateParentId, + resizeLoopNodesWrapper + ) + }, + [getNodes, updateBlockPosition, updateParentId] + ) + + const isPointInLoopNodeWrapper = useCallback( + (position: { x: number; y: number }) => { + return isPointInLoopNode(position, getNodes) + }, + [getNodes] + ) + + const calculateLoopDimensionsWrapper = useCallback( + (loopId: string): { width: number; height: number } => { + return calculateLoopDimensions(loopId, getNodes) + }, + [getNodes] + ) + + // Function to resize all loop nodes with improved hierarchy handling + const resizeLoopNodesWrapper = useCallback(() => { + return resizeLoopNodes(getNodes, updateNodeDimensions) + }, [getNodes, updateNodeDimensions]) + + // Use direct resizing function instead of debounced version for immediate updates + const debouncedResizeLoopNodes = resizeLoopNodesWrapper // Initialize workflow useEffect(() => { @@ -142,6 +235,47 @@ function WorkflowContent() { if (!type) return if (type === 'connectionBlock') return + // Special handling for container nodes (loop or parallel) + if (type === 'loop' || type === 'parallel') { + // Create a unique ID and name for the container + const id = crypto.randomUUID() + const name = type === 'loop' ? 'Loop' : 'Parallel' + + // Calculate the center position of the viewport + const centerPosition = project({ + x: window.innerWidth / 2, + y: window.innerHeight / 2, + }) + + // Add the container node directly to canvas with default dimensions + addBlock(id, type, name, centerPosition, { + width: 500, + height: 300, + type: type === 'loop' ? 'loopNode' : 'parallelNode', + }) + + // Auto-connect logic for container nodes + const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled + if (isAutoConnectEnabled) { + const closestBlock = findClosestOutput(centerPosition) + if (closestBlock) { + // Get appropriate source handle + const sourceHandle = determineSourceHandle(closestBlock) + + addEdge({ + id: crypto.randomUUID(), + source: closestBlock.id, + target: id, + sourceHandle, + targetHandle: 'target', + type: 'workflowEdge', + }) + } + } + + return + } + const blockConfig = getBlock(type) if (!blockConfig) { logger.error('Invalid block type:', { type }) @@ -177,7 +311,7 @@ function WorkflowContent() { target: id, sourceHandle, targetHandle: 'target', - type: 'custom', + type: 'workflowEdge', }) } } @@ -208,42 +342,286 @@ function WorkflowContent() { y: event.clientY - reactFlowBounds.top, }) + // Check if dropping inside a container node (loop or parallel) + const containerInfo = isPointInLoopNodeWrapper(position) + + // Clear any drag-over styling + document + .querySelectorAll('.loop-node-drag-over, .parallel-node-drag-over') + .forEach((el) => { + el.classList.remove('loop-node-drag-over', 'parallel-node-drag-over') + }) + document.body.style.cursor = '' + + // Special handling for container nodes (loop or parallel) + if (data.type === 'loop' || data.type === 'parallel') { + // Create a unique ID and name for the container + const id = crypto.randomUUID() + const name = data.type === 'loop' ? 'Loop' : 'Parallel' + + // Check if we're dropping inside another container + if (containerInfo) { + // Calculate position relative to the parent container + const relativePosition = { + x: position.x - containerInfo.loopPosition.x, + y: position.y - containerInfo.loopPosition.y, + } + + // Add the container as a child of the parent container + addBlock(id, data.type, name, relativePosition, { + width: 500, + height: 300, + type: data.type === 'loop' ? 'loopNode' : 'parallelNode', + parentId: containerInfo.loopId, + extent: 'parent', + }) + + logger.info(`Added nested ${data.type} inside parent container`, { + nodeId: id, + parentId: containerInfo.loopId, + relativePosition, + }) + + // Auto-connect the nested container to nodes inside the parent container + const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled + if (isAutoConnectEnabled) { + // Try to find other nodes in the parent container to connect to + const containerNodes = getNodes().filter((n) => n.parentId === containerInfo.loopId) + + if (containerNodes.length > 0) { + // Connect to the closest node in the container + const closestNode = containerNodes + .map((n) => ({ + id: n.id, + distance: Math.sqrt( + (n.position.x - relativePosition.x) ** 2 + + (n.position.y - relativePosition.y) ** 2 + ), + })) + .sort((a, b) => a.distance - b.distance)[0] + + if (closestNode) { + // Get appropriate source handle + const sourceNode = getNodes().find((n) => n.id === closestNode.id) + const sourceType = sourceNode?.data?.type + + // Default source handle + let sourceHandle = 'source' + + // For condition blocks, use the condition-true handle + if (sourceType === 'condition') { + sourceHandle = 'condition-true' + } + + addEdge({ + id: crypto.randomUUID(), + source: closestNode.id, + target: id, + sourceHandle, + targetHandle: 'target', + type: 'workflowEdge', + }) + } + } + } + + // Resize the parent container to fit the new child container + debouncedResizeLoopNodes() + } else { + // Add the container node directly to canvas with default dimensions + addBlock(id, data.type, name, position, { + width: 500, + height: 300, + type: data.type === 'loop' ? 'loopNode' : 'parallelNode', + }) + + // Auto-connect the container to the closest node on the canvas + const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled + if (isAutoConnectEnabled) { + const closestBlock = findClosestOutput(position) + if (closestBlock) { + const sourceHandle = determineSourceHandle(closestBlock) + + addEdge({ + id: crypto.randomUUID(), + source: closestBlock.id, + target: id, + sourceHandle, + targetHandle: 'target', + type: 'workflowEdge', + }) + } + } + } + + return + } + const blockConfig = getBlock(data.type) - if (!blockConfig) { + if (!blockConfig && data.type !== 'loop' && data.type !== 'parallel') { logger.error('Invalid block type:', { data }) return } + // Generate id and name here so they're available in all code paths const id = crypto.randomUUID() - const name = `${blockConfig.name} ${ - Object.values(blocks).filter((b) => b.type === data.type).length + 1 - }` + const name = + data.type === 'loop' + ? 'Loop' + : data.type === 'parallel' + ? 'Parallel' + : `${blockConfig!.name} ${Object.values(blocks).filter((b) => b.type === data.type).length + 1}` - addBlock(id, data.type, name, position) + if (containerInfo) { + // Calculate position relative to the container node + const relativePosition = { + x: position.x - containerInfo.loopPosition.x, + y: position.y - containerInfo.loopPosition.y, + } - // Auto-connect logic - const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled - if (isAutoConnectEnabled && data.type !== 'starter') { - const closestBlock = findClosestOutput(position) - if (closestBlock) { - // Get appropriate source handle - const sourceHandle = determineSourceHandle(closestBlock) + // Add block with parent info + addBlock(id, data.type, name, relativePosition, { + parentId: containerInfo.loopId, + extent: 'parent', + }) - addEdge({ - id: crypto.randomUUID(), - source: closestBlock.id, - target: id, - sourceHandle, - targetHandle: 'target', - type: 'workflowEdge', - }) + logger.info('Added block inside container', { + blockId: id, + blockType: data.type, + containerId: containerInfo.loopId, + relativePosition, + }) + + // Resize the container node to fit the new block + // Immediate resize without delay + debouncedResizeLoopNodes() + + // Auto-connect logic for blocks inside containers + const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled + if (isAutoConnectEnabled && data.type !== 'starter') { + // Try to find other nodes in the container to connect to + const containerNodes = getNodes().filter((n) => n.parentId === containerInfo.loopId) + + if (containerNodes.length > 0) { + // Connect to the closest node in the container + const closestNode = containerNodes + .map((n) => ({ + id: n.id, + distance: Math.sqrt( + (n.position.x - relativePosition.x) ** 2 + + (n.position.y - relativePosition.y) ** 2 + ), + })) + .sort((a, b) => a.distance - b.distance)[0] + + if (closestNode) { + // Get appropriate source handle + const sourceNode = getNodes().find((n) => n.id === closestNode.id) + const sourceType = sourceNode?.data?.type + + // Default source handle + let sourceHandle = 'source' + + // For condition blocks, use the condition-true handle + if (sourceType === 'condition') { + sourceHandle = 'condition-true' + } + + addEdge({ + id: crypto.randomUUID(), + source: closestNode.id, + target: id, + sourceHandle, + targetHandle: 'target', + type: 'workflowEdge', + }) + } + } + } + } else { + // Regular canvas drop + addBlock(id, data.type, name, position) + + // Regular auto-connect logic + const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled + if (isAutoConnectEnabled && data.type !== 'starter') { + const closestBlock = findClosestOutput(position) + if (closestBlock) { + const sourceHandle = determineSourceHandle(closestBlock) + + addEdge({ + id: crypto.randomUUID(), + source: closestBlock.id, + target: id, + sourceHandle, + targetHandle: 'target', + type: 'workflowEdge', + }) + } } } } catch (err) { logger.error('Error dropping block:', { err }) } }, - [project, blocks, addBlock, addEdge, findClosestOutput, determineSourceHandle] + [ + project, + blocks, + addBlock, + addEdge, + findClosestOutput, + determineSourceHandle, + isPointInLoopNodeWrapper, + getNodes, + ] + ) + + // Handle drag over for ReactFlow canvas + const onDragOver = useCallback( + (event: React.DragEvent) => { + event.preventDefault() + + // Only handle toolbar items + if (!event.dataTransfer?.types.includes('application/json')) return + + try { + const reactFlowBounds = event.currentTarget.getBoundingClientRect() + const position = project({ + x: event.clientX - reactFlowBounds.left, + y: event.clientY - reactFlowBounds.top, + }) + + // Check if hovering over a container node + const containerInfo = isPointInLoopNodeWrapper(position) + + // Clear any previous highlighting + document + .querySelectorAll('.loop-node-drag-over, .parallel-node-drag-over') + .forEach((el) => { + el.classList.remove('loop-node-drag-over', 'parallel-node-drag-over') + }) + + // If hovering over a container node, highlight it + if (containerInfo) { + const containerElement = document.querySelector(`[data-id="${containerInfo.loopId}"]`) + if (containerElement) { + // Determine the type of container node for appropriate styling + const containerNode = getNodes().find((n) => n.id === containerInfo.loopId) + if (containerNode?.type === 'loopNode') { + containerElement.classList.add('loop-node-drag-over') + } else if (containerNode?.type === 'parallelNode') { + containerElement.classList.add('parallel-node-drag-over') + } + document.body.style.cursor = 'copy' + } + } else { + document.body.style.cursor = '' + } + } catch (err) { + logger.error('Error in onDragOver', { err }) + } + }, + [project, isPointInLoopNodeWrapper, getNodes] ) // Init workflow @@ -305,15 +683,6 @@ function WorkflowContent() { const nodes = useMemo(() => { const nodeArray: any[] = [] - // Add loop group nodes and their labels - Object.entries(loops).forEach(([loopId, loop]) => { - const loopNodes = createLoopNode({ loopId, loop, blocks }) - if (loopNodes) { - // Add both the loop node and its label node - nodeArray.push(...loopNodes) - } - }) - // Add block nodes Object.entries(blocks).forEach(([blockId, block]) => { if (!block.type || !block.name) { @@ -321,6 +690,42 @@ function WorkflowContent() { return } + // Handle container nodes differently + if (block.type === 'loop') { + nodeArray.push({ + id: block.id, + type: 'loopNode', + position: block.position, + parentId: block.data?.parentId, + extent: block.data?.extent || undefined, + dragHandle: '.workflow-drag-handle', + data: { + ...block.data, + width: block.data?.width || 500, + height: block.data?.height || 300, + }, + }) + return + } + + // Handle parallel nodes + if (block.type === 'parallel') { + nodeArray.push({ + id: block.id, + type: 'parallelNode', + position: block.position, + parentId: block.data?.parentId, + extent: block.data?.extent || undefined, + dragHandle: '.workflow-drag-handle', + data: { + ...block.data, + width: block.data?.width || 500, + height: block.data?.height || 300, + }, + }) + return + } + const blockConfig = getBlock(block.type) if (!blockConfig) { logger.error(`No configuration found for block type: ${block.type}`, { @@ -329,16 +734,7 @@ function WorkflowContent() { return } - const parentLoop = Object.entries(loops).find(([_, loop]) => loop.nodes.includes(block.id)) - let position = block.position - - if (parentLoop) { - const [loopId] = parentLoop - const loopNode = nodeArray.find((node) => node.id === `loop-${loopId}`) - if (loopNode) { - position = getRelativeLoopPosition(block.position, loopNode.position) - } - } + const position = block.position const isActive = activeBlockIds.has(block.id) const isPending = isDebugModeEnabled && pendingBlocks.includes(block.id) @@ -347,8 +743,8 @@ function WorkflowContent() { id: block.id, type: 'workflowBlock', position, - parentId: parentLoop ? `loop-${parentLoop[0]}` : undefined, - dragHandle: '.workflow-drag-handle', + parentId: block.data?.parentId, + extent: block.data?.extent || undefined, data: { type: block.type, config: blockConfig, @@ -360,7 +756,7 @@ function WorkflowContent() { }) return nodeArray - }, [blocks, loops, activeBlockIds, pendingBlocks, isDebugModeEnabled]) + }, [blocks, activeBlockIds, pendingBlocks, isDebugModeEnabled]) // Update nodes const onNodesChange = useCallback( @@ -369,30 +765,59 @@ function WorkflowContent() { if (change.type === 'position' && change.position) { const node = nodes.find((n) => n.id === change.id) if (!node) return - - if (node.parentId) { - const loopNode = nodes.find((n) => n.id === node.parentId) - if (loopNode) { - const absolutePosition = { - x: change.position.x + loopNode.position.x, - y: change.position.y + loopNode.position.y, - } - updateBlockPosition(change.id, absolutePosition) - } - } else { - updateBlockPosition(change.id, change.position) - } + updateBlockPosition(change.id, change.position) } }) }, [nodes, updateBlockPosition] ) + // Effect to resize loops when nodes change (add/remove/position change) + useEffect(() => { + // Skip during initial render when nodes aren't loaded yet + if (nodes.length === 0) return + + // Resize all loops to fit their children + debouncedResizeLoopNodes() + + // No need for cleanup with direct function + return () => {} + }, [nodes, debouncedResizeLoopNodes]) + + // Special effect to handle cleanup after node deletion + useEffect(() => { + // Create a mapping of node IDs to check for missing parent references + const nodeIds = new Set(Object.keys(blocks)) + + // Check for nodes with invalid parent references + Object.entries(blocks).forEach(([id, block]) => { + const parentId = block.data?.parentId + + // If block has a parent reference but parent no longer exists + if (parentId && !nodeIds.has(parentId)) { + logger.warn('Found orphaned node with invalid parent reference', { + nodeId: id, + missingParentId: parentId, + }) + + // Fix the node by removing its parent reference and calculating absolute position + const absolutePosition = getNodeAbsolutePositionWrapper(id) + + // Update the node to remove parent reference and use absolute position + updateBlockPosition(id, absolutePosition) + updateParentId(id, '', 'parent') + } + }) + }, [blocks, updateBlockPosition, updateParentId, getNodeAbsolutePositionWrapper]) + // Update edges const onEdgesChange = useCallback( (changes: any) => { changes.forEach((change: any) => { if (change.type === 'remove') { + logger.info('Edge removal requested via ReactFlow:', { + edgeId: change.id, + }) removeEdge(change.id) } }) @@ -400,55 +825,441 @@ function WorkflowContent() { [removeEdge] ) - // Handle connections + // Handle connections with improved parent tracking const onConnect = useCallback( (connection: any) => { if (connection.source && connection.target) { + // Prevent self-connections + if (connection.source === connection.target) { + logger.info('Rejected self-connection:', { + nodeId: connection.source, + }) + return + } + + // Check if connecting nodes across container boundaries + const sourceNode = getNodes().find((n) => n.id === connection.source) + const targetNode = getNodes().find((n) => n.id === connection.target) + + if (!sourceNode || !targetNode) return + + // Get parent information (handle container start node case) + const sourceParentId = + sourceNode.parentId || + (connection.sourceHandle === 'loop-start-source' || + connection.sourceHandle === 'parallel-start-source' + ? connection.source + : undefined) + const targetParentId = targetNode.parentId + + // Generate a unique edge ID + const edgeId = crypto.randomUUID() + + // Special case for container start source: Always allow connections to nodes within the same container + if ( + (connection.sourceHandle === 'loop-start-source' || + connection.sourceHandle === 'parallel-start-source') && + targetNode.parentId === sourceNode.id + ) { + // This is a connection from container start to a node inside the container - always allow + logger.info('Creating container start connection:', { + edgeId, + sourceId: connection.source, + targetId: connection.target, + parentId: sourceNode.id, + }) + + addEdge({ + ...connection, + id: edgeId, + type: 'workflowEdge', + // Add metadata about the container context + data: { + parentId: sourceNode.id, + isInsideContainer: true, + }, + }) + return + } + + // Prevent connections across container boundaries + if ( + (sourceParentId && !targetParentId) || + (!sourceParentId && targetParentId) || + (sourceParentId && targetParentId && sourceParentId !== targetParentId) + ) { + logger.info('Rejected cross-boundary connection:', { + sourceId: connection.source, + targetId: connection.target, + sourceParentId, + targetParentId, + }) + return + } + + // Track if this connection is inside a container + const isInsideContainer = Boolean(sourceParentId) || Boolean(targetParentId) + const parentId = sourceParentId || targetParentId + + logger.info('Creating connection:', { + edgeId, + sourceId: connection.source, + targetId: connection.target, + isInsideContainer, + parentId, + }) + + // Add appropriate metadata for container context addEdge({ ...connection, - id: crypto.randomUUID(), + id: edgeId, type: 'workflowEdge', + data: isInsideContainer + ? { + parentId, + isInsideContainer, + } + : undefined, }) } }, - [addEdge] + [addEdge, getNodes] + ) + + // Handle node drag to detect intersections with container nodes + const onNodeDrag = useCallback( + (event: React.MouseEvent, node: any) => { + // Store currently dragged node ID + setDraggedNodeId(node.id) + + // Get the current parent ID of the node being dragged + const currentParentId = blocks[node.id]?.data?.parentId || null + + // Check if this is a starter block - starter blocks should never be in containers + const isStarterBlock = node.data?.type === 'starter' + if (isStarterBlock) { + // If it's a starter block, remove any highlighting and don't allow it to be dragged into containers + if (potentialParentId) { + const prevElement = document.querySelector(`[data-id="${potentialParentId}"]`) + if (prevElement) { + prevElement.classList.remove('loop-node-drag-over', 'parallel-node-drag-over') + } + setPotentialParentId(null) + document.body.style.cursor = '' + } + return // Exit early - don't process any container intersections for starter blocks + } + + // Get the node's absolute position to properly calculate intersections + const nodeAbsolutePos = getNodeAbsolutePositionWrapper(node.id) + + // Find intersections with container nodes using absolute coordinates + const intersectingNodes = getNodes() + .filter((n) => { + // Only consider container nodes that aren't the dragged node + if ((n.type !== 'loopNode' && n.type !== 'parallelNode') || n.id === node.id) return false + + // Skip if this container is already the parent of the node being dragged + if (n.id === currentParentId) return false + + // Skip self-nesting: prevent a container from becoming its own descendant + if (node.type === 'loopNode' || node.type === 'parallelNode') { + // Get the full hierarchy of the potential parent + const hierarchy = getNodeHierarchyWrapper(n.id) + + // If the dragged node is in the hierarchy, this would create a circular reference + if (hierarchy.includes(node.id)) { + return false // Avoid circular nesting + } + } + + // Get the container's absolute position + const containerAbsolutePos = getNodeAbsolutePositionWrapper(n.id) + + // Get dimensions based on node type + const nodeWidth = + node.type === 'loopNode' || node.type === 'parallelNode' + ? node.data?.width || 500 + : node.type === 'condition' + ? 250 + : 350 + + const nodeHeight = + node.type === 'loopNode' || node.type === 'parallelNode' + ? node.data?.height || 300 + : node.type === 'condition' + ? 150 + : 100 + + // Check intersection using absolute coordinates + const nodeRect = { + left: nodeAbsolutePos.x, + right: nodeAbsolutePos.x + nodeWidth, + top: nodeAbsolutePos.y, + bottom: nodeAbsolutePos.y + nodeHeight, + } + + const containerRect = { + left: containerAbsolutePos.x, + right: containerAbsolutePos.x + (n.data?.width || 500), + top: containerAbsolutePos.y, + bottom: containerAbsolutePos.y + (n.data?.height || 300), + } + + // Check intersection with absolute coordinates for accurate detection + return ( + nodeRect.left < containerRect.right && + nodeRect.right > containerRect.left && + nodeRect.top < containerRect.bottom && + nodeRect.bottom > containerRect.top + ) + }) + // Add more information for sorting + .map((n) => ({ + container: n, + depth: getNodeDepthWrapper(n.id), + // Calculate size for secondary sorting + size: (n.data?.width || 500) * (n.data?.height || 300), + })) + + // Update potential parent if there's at least one intersecting container node + if (intersectingNodes.length > 0) { + // Sort by depth first (deepest/most nested containers first), then by size if same depth + const sortedContainers = intersectingNodes.sort((a, b) => { + // First try to compare by hierarchy depth + if (a.depth !== b.depth) { + return b.depth - a.depth // Higher depth (more nested) comes first + } + // If same depth, use size as secondary criterion + return a.size - b.size // Smaller container takes precedence + }) + + // Use the most appropriate container (deepest or smallest at same depth) + const bestContainerMatch = sortedContainers[0] + + // Add a check to see if the bestContainerMatch is a part of the hierarchy of the node being dragged + const hierarchy = getNodeHierarchyWrapper(node.id) + if (hierarchy.includes(bestContainerMatch.container.id)) { + setPotentialParentId(null) + return + } + + setPotentialParentId(bestContainerMatch.container.id) + + // Add highlight class and change cursor + const containerElement = document.querySelector( + `[data-id="${bestContainerMatch.container.id}"]` + ) + if (containerElement) { + // Apply appropriate class based on container type + if (bestContainerMatch.container.type === 'loopNode') { + containerElement.classList.add('loop-node-drag-over') + } else if (bestContainerMatch.container.type === 'parallelNode') { + containerElement.classList.add('parallel-node-drag-over') + } + document.body.style.cursor = 'copy' + } + } else { + // Remove highlighting if no longer over a container + if (potentialParentId) { + const prevElement = document.querySelector(`[data-id="${potentialParentId}"]`) + if (prevElement) { + prevElement.classList.remove('loop-node-drag-over', 'parallel-node-drag-over') + } + setPotentialParentId(null) + document.body.style.cursor = '' + } + } + }, + [ + getNodes, + potentialParentId, + blocks, + getNodeHierarchyWrapper, + getNodeAbsolutePositionWrapper, + getNodeDepthWrapper, + ] + ) + + // Add in a nodeDrag start event to set the dragStartParentId + const onNodeDragStart = useCallback( + (event: React.MouseEvent, node: any) => { + // Store the original parent ID when starting to drag + const currentParentId = node.parentId || blocks[node.id]?.data?.parentId || null + setDragStartParentId(currentParentId) + + logger.info('Node drag started', { + nodeId: node.id, + startParentId: currentParentId, + nodeType: node.type, + }) + }, + [blocks] + ) + + // Handle node drag stop to establish parent-child relationships + const onNodeDragStop = useCallback( + (event: React.MouseEvent, node: any) => { + // Clear UI effects + document.querySelectorAll('.loop-node-drag-over, .parallel-node-drag-over').forEach((el) => { + el.classList.remove('loop-node-drag-over', 'parallel-node-drag-over') + }) + document.body.style.cursor = '' + + // Don't process if the node hasn't actually changed parent or is being moved within same parent + if (potentialParentId === dragStartParentId) return + + logger.info('Node drag stopped', { + nodeId: node.id, + dragStartParentId, + potentialParentId, + nodeType: node.type, + }) + + // Check if this is a starter block - starter blocks should never be in containers + const isStarterBlock = node.data?.type === 'starter' + if (isStarterBlock) { + logger.warn('Prevented starter block from being placed inside a container', { + blockId: node.id, + attemptedParentId: potentialParentId, + }) + // Reset state without updating parent + setDraggedNodeId(null) + setPotentialParentId(null) + return // Exit early - don't allow starter blocks to have parents + } + + // If we're dragging a container node, do additional checks to prevent circular references + if ((node.type === 'loopNode' || node.type === 'parallelNode') && potentialParentId) { + // Get the hierarchy of the potential parent container + const parentHierarchy = getNodeHierarchyWrapper(potentialParentId) + + // If the dragged node is in the parent's hierarchy, it would create a circular reference + if (parentHierarchy.includes(node.id)) { + logger.warn('Prevented circular container nesting', { + draggedNodeId: node.id, + draggedNodeType: node.type, + potentialParentId, + parentHierarchy, + }) + return + } + } + + // Update the node's parent relationship + if (potentialParentId) { + // Moving to a new parent container + updateNodeParent(node.id, potentialParentId) + } + + // Reset state + setDraggedNodeId(null) + setPotentialParentId(null) + }, + [getNodes, dragStartParentId, potentialParentId, updateNodeParent, getNodeHierarchyWrapper] ) // Update onPaneClick to only handle edge selection const onPaneClick = useCallback(() => { - setSelectedEdgeId(null) + setSelectedEdgeInfo(null) }, []) // Edge selection - const onEdgeClick = useCallback((event: React.MouseEvent, edge: any) => { - setSelectedEdgeId(edge.id) - }, []) + const onEdgeClick = useCallback( + (event: React.MouseEvent, edge: any) => { + event.stopPropagation() // Prevent bubbling - // Transform edges to include selection state - const edgesWithSelection = edges.map((edge) => ({ - ...edge, - type: edge.type || 'workflowEdge', - data: { - selectedEdgeId, - onDelete: (edgeId: string) => { - removeEdge(edgeId) - setSelectedEdgeId(null) - }, + // Determine if edge is inside a loop by checking its source/target nodes + const sourceNode = getNodes().find((n) => n.id === edge.source) + const targetNode = getNodes().find((n) => n.id === edge.target) + + // An edge is inside a loop if either source or target has a parent + // If source and target have different parents, prioritize source's parent + const parentLoopId = sourceNode?.parentId || targetNode?.parentId + + // Create a unique identifier that combines edge ID and parent context + const contextId = `${edge.id}${parentLoopId ? `-${parentLoopId}` : ''}` + + logger.info('Edge selected:', { + edgeId: edge.id, + sourceId: edge.source, + targetId: edge.target, + sourceNodeParent: sourceNode?.parentId, + targetNodeParent: targetNode?.parentId, + parentLoopId, + contextId, + }) + + setSelectedEdgeInfo({ + id: edge.id, + parentLoopId, + contextId, + }) }, - })) + [getNodes] + ) - // Handle keyboard shortcuts + // Transform edges to include improved selection state + const edgesWithSelection = edges.map((edge) => { + // Check if this edge connects nodes inside a loop + const sourceNode = getNodes().find((n) => n.id === edge.source) + const targetNode = getNodes().find((n) => n.id === edge.target) + const parentLoopId = sourceNode?.parentId || targetNode?.parentId + const isInsideLoop = Boolean(parentLoopId) + + // Create a unique context ID for this edge + const edgeContextId = `${edge.id}${parentLoopId ? `-${parentLoopId}` : ''}` + + // Determine if this edge is selected using context-aware matching + const isSelected = selectedEdgeInfo?.contextId === edgeContextId + + return { + ...edge, + type: edge.type || 'workflowEdge', + data: { + // Send only necessary data to the edge component + isSelected, + isInsideLoop, + parentLoopId, + onDelete: (edgeId: string) => { + // Log deletion for debugging + logger.info('Deleting edge:', { + edgeId, + fromSelection: selectedEdgeInfo?.id === edgeId, + contextId: edgeContextId, + }) + + // Only delete this specific edge + removeEdge(edgeId) + + // Only clear selection if this was the selected edge + if (selectedEdgeInfo?.id === edgeId) { + setSelectedEdgeInfo(null) + } + }, + }, + } + }) + + // Handle keyboard shortcuts with better edge tracking useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { - if ((event.key === 'Delete' || event.key === 'Backspace') && selectedEdgeId) { - removeEdge(selectedEdgeId) - setSelectedEdgeId(null) + if ((event.key === 'Delete' || event.key === 'Backspace') && selectedEdgeInfo) { + logger.info('Keyboard shortcut edge deletion:', { + edgeId: selectedEdgeInfo.id, + parentLoopId: selectedEdgeInfo.parentLoopId, + contextId: selectedEdgeInfo.contextId, + }) + + // Only delete the specific selected edge + removeEdge(selectedEdgeInfo.id) + setSelectedEdgeInfo(null) } } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) - }, [selectedEdgeId, removeEdge]) + }, [selectedEdgeInfo, removeEdge]) // Handle sub-block value updates from custom events useEffect(() => { @@ -497,7 +1308,7 @@ function WorkflowContent() { nodeTypes={nodeTypes} edgeTypes={edgeTypes} onDrop={onDrop} - onDragOver={(e) => e.preventDefault()} + onDragOver={onDragOver} fitView minZoom={0.1} maxZoom={1.3} @@ -510,9 +1321,9 @@ function WorkflowContent() { strokeDasharray: '5,5', }} connectionLineType={ConnectionLineType.SmoothStep} - onNodeClick={(e) => { + onNodeClick={(e, node) => { + // Allow selecting nodes, but stop propagation to prevent triggering other events e.stopPropagation() - e.preventDefault() }} onPaneClick={onPaneClick} onEdgeClick={onEdgeClick} @@ -525,6 +1336,15 @@ function WorkflowContent() { edgesFocusable={true} edgesUpdatable={true} className='workflow-container h-full' + onNodeDrag={onNodeDrag} + onNodeDragStop={onNodeDragStop} + onNodeDragStart={onNodeDragStart} + snapToGrid={false} + snapGrid={[20, 20]} + elevateEdgesOnSelect={true} + elevateNodesOnSelect={true} + autoPanOnConnect={true} + autoPanOnNodeDrag={true} > diff --git a/apps/sim/app/w/components/workflow-preview/generic-workflow-preview.tsx b/apps/sim/app/w/components/workflow-preview/generic-workflow-preview.tsx index 0b6eb5a0ae..053eaed8d3 100644 --- a/apps/sim/app/w/components/workflow-preview/generic-workflow-preview.tsx +++ b/apps/sim/app/w/components/workflow-preview/generic-workflow-preview.tsx @@ -18,10 +18,11 @@ import 'reactflow/dist/style.css' import { Card } from '@/components/ui/card' import { Label } from '@/components/ui/label' import { cn } from '@/lib/utils' +import { LoopTool } from '@/app/w/[id]/components/loop-node/loop-config' import { WorkflowEdge } from '@/app/w/[id]/components/workflow-edge/workflow-edge' -import { LoopInput } from '@/app/w/[id]/components/workflow-loop/components/loop-input/loop-input' -import { LoopLabel } from '@/app/w/[id]/components/workflow-loop/components/loop-label/loop-label' -import { createLoopNode } from '@/app/w/[id]/components/workflow-loop/workflow-loop' +// import { LoopInput } from '@/app/w/[id]/components/workflow-loop/components/loop-input/loop-input' +// import { LoopLabel } from '@/app/w/[id]/components/workflow-loop/components/loop-label/loop-label' +// import { createLoopNode } from '@/app/w/[id]/components/workflow-loop/workflow-loop' import { getBlock } from '@/blocks' import type { SubBlockConfig } from '@/blocks/types' @@ -57,8 +58,8 @@ interface ExtendedSubBlockConfig extends SubBlockConfig { // Define node types const nodeTypes: NodeTypes = { workflowBlock: PreviewWorkflowBlock, - loopLabel: LoopLabel, - loopInput: LoopInput, + // loopLabel: LoopLabel, + // loopInput: LoopInput, } // Define edge types @@ -407,13 +408,21 @@ function PreviewSubBlock({ config }: { config: ExtendedSubBlockConfig }) { } function PreviewWorkflowBlock({ id, data }: NodeProps) { - const { type, config, name, blockState, showSubBlocks = true } = data + const { type, config, name, blockState, showSubBlocks = true, isLoopBlock } = data + + // Get block configuration - use LoopTool for loop blocks if config is missing + const blockConfig = useMemo(() => { + if (type === 'loop' && !config) { + return LoopTool + } + return config + }, [type, config]) // Only prepare subblocks if they should be shown const preparedSubBlocks = useMemo(() => { if (!showSubBlocks) return [] - return prepareSubBlocks(blockState?.subBlocks, config) - }, [blockState?.subBlocks, config, showSubBlocks]) + return prepareSubBlocks(blockState?.subBlocks, blockConfig) + }, [blockState?.subBlocks, blockConfig, showSubBlocks]) // Group subblocks for layout const subBlockRows = useMemo(() => { @@ -442,6 +451,12 @@ function PreviewWorkflowBlock({ id, data }: NodeProps) { {name}
+ {type === 'loop' && ( +
+ {blockState?.data?.loopType === 'forEach' ? 'For Each' : 'For'} + {blockState?.data?.count && ` (${blockState.data.count}x)`} +
+ )}
{/* Block Content */} @@ -461,7 +476,9 @@ function PreviewWorkflowBlock({ id, data }: NodeProps) {
)) ) : ( -
No configured items
+
+ {type === 'loop' ? 'Loop configuration' : 'No configured items'} +
)} )} @@ -513,27 +530,24 @@ function WorkflowPreviewContent({ const nodes: Node[] = useMemo(() => { const nodeArray: Node[] = [] - // Add loop nodes - Object.entries(workflowState.loops || {}).forEach(([loopId, loop]) => { - const loopNodes = createLoopNode({ - loopId, - loop: loop as any, - blocks: workflowState.blocks, - }) + // First, get all blocks with parent-child relationships + const blocksWithParents: Record = {} + const topLevelBlocks: Record = {} - if (loopNodes) { - if (Array.isArray(loopNodes)) { - nodeArray.push(...(loopNodes as Node[])) - } else { - nodeArray.push(loopNodes) - } + // Categorize blocks as top-level or child blocks + Object.entries(workflowState.blocks).forEach(([blockId, block]) => { + if (block.data?.parentId) { + // This is a child block + blocksWithParents[blockId] = block + } else { + // This is a top-level block + topLevelBlocks[blockId] = block } }) - // Add block nodes - Object.entries(workflowState.blocks).forEach(([blockId, block]) => { + // Process top-level blocks first + Object.entries(topLevelBlocks).forEach(([blockId, block]) => { const blockConfig = getBlock(block.type) - if (!blockConfig) return nodeArray.push({ id: blockId, @@ -541,17 +555,50 @@ function WorkflowPreviewContent({ position: block.position, data: { type: block.type, - config: blockConfig, + config: blockConfig || (block.type === 'loop' ? LoopTool : null), name: block.name, blockState: block, showSubBlocks, }, draggable: false, }) + + // Add children of this block if it's a loop + if (block.type === 'loop') { + // Find all children of this loop + const childBlocks = Object.entries(blocksWithParents).filter( + ([_, childBlock]) => childBlock.data?.parentId === blockId + ) + + // Add all child blocks to the node array + childBlocks.forEach(([childId, childBlock]) => { + const childConfig = getBlock(childBlock.type) + + nodeArray.push({ + id: childId, + type: 'workflowBlock', + // Position child blocks relative to the parent + position: { + x: block.position.x + 50, // Offset children to the right + y: block.position.y + (childBlock.position?.y || 100), // Preserve vertical positioning + }, + data: { + type: childBlock.type, + config: childConfig, + name: childBlock.name, + blockState: childBlock, + showSubBlocks, + isChild: true, + parentId: blockId, + }, + draggable: false, + }) + }) + } }) return nodeArray - }, [workflowState.blocks, workflowState.loops, showSubBlocks]) + }, [workflowState.blocks, showSubBlocks]) // Transform edges const edges: Edge[] = useMemo(() => { diff --git a/apps/sim/components/ui/tag-dropdown.test.tsx b/apps/sim/components/ui/tag-dropdown.test.tsx new file mode 100644 index 0000000000..0cae26beaf --- /dev/null +++ b/apps/sim/components/ui/tag-dropdown.test.tsx @@ -0,0 +1,147 @@ +import { describe, expect, test, vi } from 'vitest' +import type { BlockState } from '@/stores/workflows/workflow/types' +import { generateLoopBlocks } from '@/stores/workflows/workflow/utils' + +vi.mock('@/stores/workflows/workflow/store', () => ({ + useWorkflowStore: vi.fn(() => ({ + blocks: {}, + edges: [], + })), +})) + +vi.mock('@/stores/workflows/registry/store', () => ({ + useWorkflowRegistry: vi.fn(() => ({ + activeWorkflowId: 'test-workflow', + })), +})) + +vi.mock('@/stores/panel/variables/store', () => ({ + useVariablesStore: vi.fn(() => ({ + getVariablesByWorkflowId: vi.fn(() => []), + loadVariables: vi.fn(), + variables: {}, + })), +})) + +describe('TagDropdown Loop Suggestions', () => { + test('should generate correct loop suggestions for forEach loops', () => { + const blocks: Record = { + loop1: { + id: 'loop1', + type: 'loop', + name: 'Test Loop', + position: { x: 0, y: 0 }, + subBlocks: {}, + outputs: {}, + enabled: true, + data: { + loopType: 'forEach', + collection: '["item1", "item2", "item3"]', + }, + }, + function1: { + id: 'function1', + type: 'function', + name: 'Function 1', + position: { x: 0, y: 0 }, + subBlocks: {}, + outputs: {}, + enabled: true, + data: { + parentId: 'loop1', + }, + }, + } + + const loops = generateLoopBlocks(blocks) + + // Verify loop was generated correctly + expect(loops.loop1).toBeDefined() + expect(loops.loop1.loopType).toBe('forEach') + expect(loops.loop1.forEachItems).toEqual(['item1', 'item2', 'item3']) + expect(loops.loop1.nodes).toContain('function1') + + // Simulate the tag generation logic from TagDropdown + const loopTags: string[] = [] + const containingLoop = Object.entries(loops).find(([_, loop]) => + loop.nodes.includes('function1') + ) + + if (containingLoop) { + const [_loopId, loop] = containingLoop + const loopType = loop.loopType || 'for' + + // Add loop.index for all loop types + loopTags.push('loop.index') + + // Add forEach specific properties + if (loopType === 'forEach') { + loopTags.push('loop.currentItem') + loopTags.push('loop.items') + } + } + + // Verify all loop tags are present + expect(loopTags).toContain('loop.index') + expect(loopTags).toContain('loop.currentItem') + expect(loopTags).toContain('loop.items') + expect(loopTags).toHaveLength(3) + }) + + test('should only generate loop.index for regular for loops', () => { + const blocks: Record = { + loop1: { + id: 'loop1', + type: 'loop', + name: 'Test Loop', + position: { x: 0, y: 0 }, + subBlocks: {}, + outputs: {}, + enabled: true, + data: { + loopType: 'for', + count: 5, + collection: '', + }, + }, + function1: { + id: 'function1', + type: 'function', + name: 'Function 1', + position: { x: 0, y: 0 }, + subBlocks: {}, + outputs: {}, + enabled: true, + data: { + parentId: 'loop1', + }, + }, + } + + const loops = generateLoopBlocks(blocks) + + // Simulate the tag generation logic + const loopTags: string[] = [] + const containingLoop = Object.entries(loops).find(([_, loop]) => + loop.nodes.includes('function1') + ) + + if (containingLoop) { + const [_loopId, loop] = containingLoop + const loopType = loop.loopType || 'for' + + loopTags.push('loop.index') + + if (loopType === 'forEach') { + loopTags.push('loop.currentItem') + loopTags.push('loop.items') + } + } + + // For regular loops, should only have loop.index + expect(loopTags).toContain('loop.index') + expect(loopTags).not.toContain('loop.currentItem') + expect(loopTags).not.toContain('loop.items') + expect(loopTags).toHaveLength(1) + }) +}) diff --git a/apps/sim/components/ui/tag-dropdown.tsx b/apps/sim/components/ui/tag-dropdown.tsx index 7aed5971c2..5138c69330 100644 --- a/apps/sim/components/ui/tag-dropdown.tsx +++ b/apps/sim/components/ui/tag-dropdown.tsx @@ -81,9 +81,10 @@ export const TagDropdown: React.FC = ({ // Get available tags from workflow state const blocks = useWorkflowStore((state) => state.blocks) + const loops = useWorkflowStore((state) => state.loops) + const parallels = useWorkflowStore((state) => state.parallels) const _edges = useWorkflowStore((state) => state.edges) const workflowId = useWorkflowRegistry((state) => state.activeWorkflowId) - const loops = useWorkflowStore((state) => state.loops) // Get variables from variables store const getVariablesByWorkflowId = useVariablesStore((state) => state.getVariablesByWorkflowId) @@ -193,6 +194,23 @@ export const TagDropdown: React.FC = ({ } } + // Parallel tags - Add if this block is in a parallel + const parallelTags: string[] = [] + + // Check if the current block is part of a parallel + const containingParallel = Object.entries(parallels || {}).find(([_, parallel]) => + parallel.nodes.includes(blockId) + ) + + if (containingParallel) { + // Add parallel.index for all parallel blocks + parallelTags.push('parallel.index') + + // Add parallel.currentItem and parallel.items + parallelTags.push('parallel.currentItem') + parallelTags.push('parallel.items') + } + // If we have an active source block ID from a drop, use that specific block only if (activeSourceBlockId) { const sourceBlock = blocks[activeSourceBlockId] @@ -256,8 +274,78 @@ export const TagDropdown: React.FC = ({ } } - // Use all incoming connections instead of just direct edges - const sourceTags = incomingConnections.flatMap((connection: ConnectedBlock) => { + // Find parallel and loop blocks connected via end-source handles + const endSourceConnections: ConnectedBlock[] = [] + + // Get all edges that connect to this block + const incomingEdges = useWorkflowStore + .getState() + .edges.filter((edge) => edge.target === blockId) + + for (const edge of incomingEdges) { + const sourceBlock = blocks[edge.source] + if (!sourceBlock) continue + + // Check if this is a parallel-end-source or loop-end-source connection + if (edge.sourceHandle === 'parallel-end-source' && sourceBlock.type === 'parallel') { + const blockName = sourceBlock.name || sourceBlock.type + const normalizedBlockName = blockName.replace(/\s+/g, '').toLowerCase() + + // Add the parallel block as a referenceable block with its aggregated results + endSourceConnections.push({ + id: sourceBlock.id, + type: sourceBlock.type, + outputType: ['response'], + name: blockName, + responseFormat: { + fields: [ + { + name: 'completed', + type: 'boolean', + description: 'Whether all executions completed', + }, + { + name: 'results', + type: 'array', + description: 'Aggregated results from all parallel executions', + }, + { name: 'message', type: 'string', description: 'Status message' }, + ], + }, + }) + } else if (edge.sourceHandle === 'loop-end-source' && sourceBlock.type === 'loop') { + const blockName = sourceBlock.name || sourceBlock.type + const normalizedBlockName = blockName.replace(/\s+/g, '').toLowerCase() + + // Add the loop block as a referenceable block with its aggregated results + endSourceConnections.push({ + id: sourceBlock.id, + type: sourceBlock.type, + outputType: ['response'], + name: blockName, + responseFormat: { + fields: [ + { + name: 'completed', + type: 'boolean', + description: 'Whether all iterations completed', + }, + { + name: 'results', + type: 'array', + description: 'Aggregated results from all loop iterations', + }, + { name: 'message', type: 'string', description: 'Status message' }, + ], + }, + }) + } + } + + // Use all incoming connections plus end-source connections + const allConnections = [...incomingConnections, ...endSourceConnections] + + const sourceTags = allConnections.flatMap((connection: ConnectedBlock) => { const blockName = connection.name || connection.type const normalizedBlockName = blockName.replace(/\s+/g, '').toLowerCase() @@ -294,8 +382,16 @@ export const TagDropdown: React.FC = ({ return outputPaths.map((path) => `${normalizedBlockName}.${path}`) }) - return { tags: [...variableTags, ...loopTags, ...sourceTags], variableInfoMap } - }, [blocks, incomingConnections, blockId, activeSourceBlockId, workflowVariables, loops]) + return { tags: [...variableTags, ...loopTags, ...parallelTags, ...sourceTags], variableInfoMap } + }, [ + blocks, + incomingConnections, + blockId, + activeSourceBlockId, + workflowVariables, + loops, + parallels, + ]) // Filter tags based on search term const filteredTags = useMemo(() => { @@ -304,9 +400,10 @@ export const TagDropdown: React.FC = ({ }, [tags, searchTerm]) // Group tags into variables, loops, and blocks - const { variableTags, loopTags, blockTags } = useMemo(() => { + const { variableTags, loopTags, parallelTags, blockTags } = useMemo(() => { const varTags: string[] = [] const loopTags: string[] = [] + const parTags: string[] = [] const blkTags: string[] = [] filteredTags.forEach((tag) => { @@ -314,12 +411,14 @@ export const TagDropdown: React.FC = ({ varTags.push(tag) } else if (tag.startsWith('loop.')) { loopTags.push(tag) + } else if (tag.startsWith('parallel.')) { + parTags.push(tag) } else { blkTags.push(tag) } }) - return { variableTags: varTags, loopTags: loopTags, blockTags: blkTags } + return { variableTags: varTags, loopTags: loopTags, parallelTags: parTags, blockTags: blkTags } }, [filteredTags]) // Reset selection when filtered results change @@ -515,9 +614,70 @@ export const TagDropdown: React.FC = ({ )} + {parallelTags.length > 0 && ( + <> + {loopTags.length > 0 &&
} +
+ Parallel +
+
+ {parallelTags.map((tag: string, index: number) => { + const tagIndex = filteredTags.indexOf(tag) + const parallelProperty = tag.split('.')[1] + + // Choose appropriate icon/label based on type + let tagIcon = 'P' + let tagDescription = '' + const bgColor = '#FF5757' // Red for parallel variables + + if (parallelProperty === 'currentItem') { + tagIcon = 'i' + tagDescription = 'Current item' + } else if (parallelProperty === 'items') { + tagIcon = 'I' + tagDescription = 'All items' + } else if (parallelProperty === 'index') { + tagIcon = '#' + tagDescription = 'Index' + } + + return ( + + ) + })} +
+ + )} + {blockTags.length > 0 && ( <> - {(variableTags.length > 0 || loopTags.length > 0) &&
} + {(variableTags.length > 0 || loopTags.length > 0 || parallelTags.length > 0) && ( +
+ )}
Blocks
diff --git a/apps/sim/executor/__test-utils__/executor-mocks.ts b/apps/sim/executor/__test-utils__/executor-mocks.ts new file mode 100644 index 0000000000..f6988a5c44 --- /dev/null +++ b/apps/sim/executor/__test-utils__/executor-mocks.ts @@ -0,0 +1,894 @@ +import { vi } from 'vitest' +import type { SerializedWorkflow } from '@/serializer/types' + +/** + * Mock handler factory - creates consistent handler mocks + */ +export const createMockHandler = ( + handlerName: string, + options?: { + canHandleCondition?: (block: any) => boolean + executeResult?: any + } +) => { + const defaultCanHandle = (block: any) => + block.metadata?.id === handlerName || handlerName === 'generic' + + const defaultExecuteResult = { + response: { result: `${handlerName} executed` }, + } + + return vi.fn().mockImplementation(() => ({ + canHandle: options?.canHandleCondition || defaultCanHandle, + execute: vi.fn().mockResolvedValue(options?.executeResult || defaultExecuteResult), + })) +} + +/** + * Setup all handler mocks with default behaviors + */ +export const setupHandlerMocks = () => { + vi.doMock('../handlers', () => ({ + AgentBlockHandler: createMockHandler('agent'), + RouterBlockHandler: createMockHandler('router'), + ConditionBlockHandler: createMockHandler('condition'), + EvaluatorBlockHandler: createMockHandler('evaluator'), + FunctionBlockHandler: createMockHandler('function'), + ApiBlockHandler: createMockHandler('api'), + LoopBlockHandler: createMockHandler('loop'), + ParallelBlockHandler: createMockHandler('parallel'), + GenericBlockHandler: createMockHandler('generic'), + })) +} + +/** + * Setup store mocks with configurable options + */ +export const setupStoreMocks = (options?: { + isDebugModeEnabled?: boolean + consoleAddFn?: ReturnType + consoleUpdateFn?: ReturnType +}) => { + const consoleAddFn = options?.consoleAddFn || vi.fn() + const consoleUpdateFn = options?.consoleUpdateFn || vi.fn() + + vi.doMock('@/stores/settings/general/store', () => ({ + useGeneralStore: { + getState: () => ({ + isDebugModeEnabled: options?.isDebugModeEnabled ?? false, + }), + }, + })) + + vi.doMock('@/stores/execution/store', () => ({ + useExecutionStore: { + getState: () => ({ + setIsExecuting: vi.fn(), + reset: vi.fn(), + setActiveBlocks: vi.fn(), + setPendingBlocks: vi.fn(), + setIsDebugging: vi.fn(), + }), + setState: vi.fn(), + }, + })) + + vi.doMock('@/stores/console/store', () => ({ + useConsoleStore: { + getState: () => ({ + addConsole: consoleAddFn, + }), + }, + })) + + vi.doMock('@/stores/panel/console/store', () => ({ + useConsoleStore: { + getState: () => ({ + addConsole: consoleAddFn, + updateConsole: consoleUpdateFn, + }), + }, + })) + + return { consoleAddFn, consoleUpdateFn } +} + +/** + * Setup core executor mocks (PathTracker, InputResolver, LoopManager, ParallelManager) + */ +export const setupExecutorCoreMocks = () => { + vi.doMock('../path', () => ({ + PathTracker: vi.fn().mockImplementation(() => ({ + updateExecutionPaths: vi.fn(), + isInActivePath: vi.fn().mockReturnValue(true), + })), + })) + + vi.doMock('../resolver', () => ({ + InputResolver: vi.fn().mockImplementation(() => ({ + resolveInputs: vi.fn().mockReturnValue({}), + resolveBlockReferences: vi.fn().mockImplementation((value) => value), + })), + })) + + vi.doMock('../loops', () => ({ + LoopManager: vi.fn().mockImplementation(() => ({ + processLoopIterations: vi.fn().mockResolvedValue(false), + getLoopIndex: vi.fn().mockImplementation((loopId, blockId, context) => { + return context.loopIterations?.get(loopId) || 0 + }), + })), + })) + + vi.doMock('../parallels', () => ({ + ParallelManager: vi.fn().mockImplementation(() => ({ + processParallelIterations: vi.fn().mockResolvedValue(false), + createVirtualBlockInstances: vi.fn().mockReturnValue([]), + setupIterationContext: vi.fn(), + storeIterationResult: vi.fn(), + initializeParallel: vi.fn(), + getIterationItem: vi.fn(), + areAllVirtualBlocksExecuted: vi.fn().mockReturnValue(false), + })), + })) +} + +/** + * Workflow factory functions + */ +export const createMinimalWorkflow = (): SerializedWorkflow => ({ + version: '1.0', + blocks: [ + { + id: 'starter', + position: { x: 0, y: 0 }, + config: { tool: 'test-tool', params: {} }, + inputs: {}, + outputs: {}, + enabled: true, + metadata: { id: 'starter', name: 'Starter Block' }, + }, + { + id: 'block1', + position: { x: 100, y: 0 }, + config: { tool: 'test-tool', params: {} }, + inputs: {}, + outputs: {}, + enabled: true, + metadata: { id: 'test', name: 'Test Block' }, + }, + ], + connections: [ + { + source: 'starter', + target: 'block1', + }, + ], + loops: {}, +}) + +export const createWorkflowWithCondition = (): SerializedWorkflow => ({ + version: '1.0', + blocks: [ + { + id: 'starter', + position: { x: 0, y: 0 }, + config: { tool: 'test-tool', params: {} }, + inputs: {}, + outputs: {}, + enabled: true, + metadata: { id: 'starter', name: 'Starter Block' }, + }, + { + id: 'condition1', + position: { x: 100, y: 0 }, + config: { tool: 'test-tool', params: {} }, + inputs: {}, + outputs: {}, + enabled: true, + metadata: { id: 'condition', name: 'Condition Block' }, + }, + { + id: 'block1', + position: { x: 200, y: -50 }, + config: { tool: 'test-tool', params: {} }, + inputs: {}, + outputs: {}, + enabled: true, + metadata: { id: 'test', name: 'True Path Block' }, + }, + { + id: 'block2', + position: { x: 200, y: 50 }, + config: { tool: 'test-tool', params: {} }, + inputs: {}, + outputs: {}, + enabled: true, + metadata: { id: 'test', name: 'False Path Block' }, + }, + ], + connections: [ + { + source: 'starter', + target: 'condition1', + }, + { + source: 'condition1', + target: 'block1', + sourceHandle: 'condition-true', + }, + { + source: 'condition1', + target: 'block2', + sourceHandle: 'condition-false', + }, + ], + loops: {}, +}) + +export const createWorkflowWithLoop = (): SerializedWorkflow => ({ + version: '1.0', + blocks: [ + { + id: 'starter', + position: { x: 0, y: 0 }, + config: { tool: 'test-tool', params: {} }, + inputs: {}, + outputs: {}, + enabled: true, + metadata: { id: 'starter', name: 'Starter Block' }, + }, + { + id: 'block1', + position: { x: 100, y: 0 }, + config: { tool: 'test-tool', params: {} }, + inputs: {}, + outputs: {}, + enabled: true, + metadata: { id: 'test', name: 'Loop Block 1' }, + }, + { + id: 'block2', + position: { x: 200, y: 0 }, + config: { tool: 'test-tool', params: {} }, + inputs: {}, + outputs: {}, + enabled: true, + metadata: { id: 'test', name: 'Loop Block 2' }, + }, + ], + connections: [ + { + source: 'starter', + target: 'block1', + }, + { + source: 'block1', + target: 'block2', + }, + { + source: 'block2', + target: 'block1', + }, + ], + loops: { + loop1: { + id: 'loop1', + nodes: ['block1', 'block2'], + iterations: 5, + loopType: 'forEach', + forEachItems: [1, 2, 3, 4, 5], + }, + }, +}) + +export const createWorkflowWithErrorPath = (): SerializedWorkflow => ({ + version: '1.0', + blocks: [ + { + id: 'starter', + position: { x: 0, y: 0 }, + config: { tool: 'test-tool', params: {} }, + inputs: {}, + outputs: {}, + enabled: true, + metadata: { id: 'starter', name: 'Starter Block' }, + }, + { + id: 'block1', + position: { x: 100, y: 0 }, + config: { tool: 'test-tool', params: {} }, + inputs: {}, + outputs: {}, + enabled: true, + metadata: { id: 'function', name: 'Function Block' }, + }, + { + id: 'error-handler', + position: { x: 200, y: 50 }, + config: { tool: 'test-tool', params: {} }, + inputs: {}, + outputs: {}, + enabled: true, + metadata: { id: 'test', name: 'Error Handler Block' }, + }, + { + id: 'success-block', + position: { x: 200, y: -50 }, + config: { tool: 'test-tool', params: {} }, + inputs: {}, + outputs: {}, + enabled: true, + metadata: { id: 'test', name: 'Success Block' }, + }, + ], + connections: [ + { + source: 'starter', + target: 'block1', + }, + { + source: 'block1', + target: 'success-block', + sourceHandle: 'source', + }, + { + source: 'block1', + target: 'error-handler', + sourceHandle: 'error', + }, + ], + loops: {}, +}) + +export const createWorkflowWithParallel = (distribution?: any): SerializedWorkflow => ({ + version: '2.0', + blocks: [ + { + id: 'starter', + position: { x: 0, y: 0 }, + metadata: { id: 'starter', name: 'Start' }, + config: { tool: 'starter', params: {} }, + inputs: {}, + outputs: {}, + enabled: true, + }, + { + id: 'parallel-1', + position: { x: 100, y: 0 }, + metadata: { id: 'parallel', name: 'Test Parallel' }, + config: { tool: 'parallel', params: {} }, + inputs: {}, + outputs: {}, + enabled: true, + }, + { + id: 'function-1', + position: { x: 200, y: 0 }, + metadata: { id: 'function', name: 'Process Item' }, + config: { + tool: 'function', + params: { + code: 'return { item: , index: }', + }, + }, + inputs: {}, + outputs: {}, + enabled: true, + }, + { + id: 'endpoint', + position: { x: 300, y: 0 }, + metadata: { id: 'generic', name: 'End' }, + config: { tool: 'generic', params: {} }, + inputs: {}, + outputs: {}, + enabled: true, + }, + ], + connections: [ + { source: 'starter', target: 'parallel-1' }, + { source: 'parallel-1', target: 'function-1', sourceHandle: 'parallel-start-source' }, + { source: 'parallel-1', target: 'endpoint', sourceHandle: 'parallel-end-source' }, + ], + loops: {}, + parallels: { + 'parallel-1': { + id: 'parallel-1', + nodes: ['function-1'], + distribution: distribution || ['apple', 'banana', 'cherry'], + }, + }, +}) + +/** + * Create a mock execution context with customizable options + */ +export interface MockContextOptions { + workflowId?: string + loopIterations?: Map + loopItems?: Map + executedBlocks?: Set + activeExecutionPath?: Set + completedLoops?: Set + parallelExecutions?: Map + parallelBlockMapping?: Map + currentVirtualBlockId?: string + workflow?: SerializedWorkflow + blockStates?: Map +} + +export const createMockContext = (options: MockContextOptions = {}) => { + const workflow = options.workflow || createMinimalWorkflow() + + return { + workflowId: options.workflowId || 'test-workflow-id', + blockStates: options.blockStates || new Map(), + blockLogs: [], + metadata: { startTime: new Date().toISOString(), duration: 0 }, + environmentVariables: {}, + decisions: { router: new Map(), condition: new Map() }, + loopIterations: options.loopIterations || new Map(), + loopItems: options.loopItems || new Map(), + executedBlocks: options.executedBlocks || new Set(), + activeExecutionPath: options.activeExecutionPath || new Set(), + workflow, + completedLoops: options.completedLoops || new Set(), + parallelExecutions: options.parallelExecutions, + parallelBlockMapping: options.parallelBlockMapping, + currentVirtualBlockId: options.currentVirtualBlockId, + } +} + +/** + * Mock implementations for testing loops + */ +export const createLoopManagerMock = (options?: { + processLoopIterationsImpl?: (context: any) => Promise + getLoopIndexImpl?: (loopId: string, blockId: string, context: any) => number +}) => ({ + LoopManager: vi.fn().mockImplementation(() => ({ + processLoopIterations: options?.processLoopIterationsImpl || vi.fn().mockResolvedValue(false), + getLoopIndex: + options?.getLoopIndexImpl || + vi.fn().mockImplementation((loopId, blockId, context) => { + return context.loopIterations.get(loopId) || 0 + }), + })), +}) + +/** + * Create a parallel execution state object for testing + */ +export const createParallelExecutionState = (options?: { + parallelCount?: number + distributionItems?: any[] | Record | null + completedExecutions?: number + executionResults?: Map + activeIterations?: Set + currentIteration?: number + parallelType?: 'count' | 'collection' +}) => ({ + parallelCount: options?.parallelCount ?? 3, + distributionItems: + options?.distributionItems !== undefined ? options.distributionItems : ['a', 'b', 'c'], + completedExecutions: options?.completedExecutions ?? 0, + executionResults: options?.executionResults ?? new Map(), + activeIterations: options?.activeIterations ?? new Set(), + currentIteration: options?.currentIteration ?? 1, + parallelType: options?.parallelType, +}) + +/** + * Mock implementations for testing parallels + */ +export const createParallelManagerMock = (options?: { + maxChecks?: number + processParallelIterationsImpl?: (context: any) => Promise +}) => ({ + ParallelManager: vi.fn().mockImplementation(() => { + const executionCounts = new Map() + const maxChecks = options?.maxChecks || 2 + + return { + processParallelIterations: + options?.processParallelIterationsImpl || + vi.fn().mockImplementation(async (context) => { + for (const [parallelId, parallel] of Object.entries(context.workflow?.parallels || {})) { + if (context.completedLoops.has(parallelId)) { + continue + } + + const parallelState = context.parallelExecutions?.get(parallelId) + if (!parallelState || parallelState.currentIteration === 0) { + continue + } + + const checkCount = executionCounts.get(parallelId) || 0 + executionCounts.set(parallelId, checkCount + 1) + + if (checkCount >= maxChecks) { + context.completedLoops.add(parallelId) + continue + } + + let allVirtualBlocksExecuted = true + const parallelNodes = (parallel as any).nodes || [] + for (const nodeId of parallelNodes) { + for (let i = 0; i < parallelState.parallelCount; i++) { + const virtualBlockId = `${nodeId}_parallel_${parallelId}_iteration_${i}` + if (!context.executedBlocks.has(virtualBlockId)) { + allVirtualBlocksExecuted = false + break + } + } + if (!allVirtualBlocksExecuted) break + } + + if (allVirtualBlocksExecuted && !context.completedLoops.has(parallelId)) { + context.executedBlocks.delete(parallelId) + context.activeExecutionPath.add(parallelId) + + for (const nodeId of parallelNodes) { + context.activeExecutionPath.delete(nodeId) + } + } + } + }), + createVirtualBlockInstances: vi.fn().mockImplementation((block, parallelId, state) => { + const instances = [] + for (let i = 0; i < state.parallelCount; i++) { + instances.push(`${block.id}_parallel_${parallelId}_iteration_${i}`) + } + return instances + }), + setupIterationContext: vi.fn(), + storeIterationResult: vi.fn(), + initializeParallel: vi.fn(), + getIterationItem: vi.fn(), + areAllVirtualBlocksExecuted: vi + .fn() + .mockImplementation((parallelId, parallel, executedBlocks, state) => { + for (const nodeId of parallel.nodes) { + for (let i = 0; i < state.parallelCount; i++) { + const virtualBlockId = `${nodeId}_parallel_${parallelId}_iteration_${i}` + if (!executedBlocks.has(virtualBlockId)) { + return false + } + } + } + return true + }), + } + }), +}) + +/** + * Setup function block handler that executes code + */ +export const createFunctionBlockHandler = vi.fn().mockImplementation(() => ({ + canHandle: (block: any) => block.metadata?.id === 'function', + execute: vi.fn().mockImplementation(async (block, inputs) => { + return { + response: { + result: inputs.code + ? new Function(inputs.code)() + : { key: inputs.key, value: inputs.value }, + stdout: '', + }, + } + }), +})) + +/** + * Create a custom parallel block handler for testing + */ +export const createParallelBlockHandler = vi.fn().mockImplementation(() => { + return { + canHandle: (block: any) => block.metadata?.id === 'parallel', + execute: vi.fn().mockImplementation(async (block, inputs, context) => { + const parallelId = block.id + const parallel = context.workflow?.parallels?.[parallelId] + + if (!parallel) { + throw new Error('Parallel configuration not found') + } + + if (!context.parallelExecutions) { + context.parallelExecutions = new Map() + } + + let parallelState = context.parallelExecutions.get(parallelId) + + if (!parallelState) { + // First execution - initialize + const distributionItems = parallel.distribution || [] + const parallelCount = Array.isArray(distributionItems) + ? distributionItems.length + : typeof distributionItems === 'object' + ? Object.keys(distributionItems).length + : 1 + + parallelState = { + parallelCount, + distributionItems, + completedExecutions: 0, + executionResults: new Map(), + activeIterations: new Set(), + currentIteration: 1, + } + context.parallelExecutions.set(parallelId, parallelState) + + if (distributionItems) { + context.loopItems.set(`${parallelId}_items`, distributionItems) + } + + // Activate child nodes + const connections = + context.workflow?.connections.filter( + (conn: any) => + conn.source === parallelId && conn.sourceHandle === 'parallel-start-source' + ) || [] + + for (const conn of connections) { + context.activeExecutionPath.add(conn.target) + } + + return { + response: { + parallelId, + parallelCount, + distributionType: 'distributed', + started: true, + message: `Initialized ${parallelCount} parallel executions`, + }, + } + } + + // Check completion + const allCompleted = parallel.nodes.every((nodeId: string) => { + for (let i = 0; i < parallelState.parallelCount; i++) { + const virtualBlockId = `${nodeId}_parallel_${parallelId}_iteration_${i}` + if (!context.executedBlocks.has(virtualBlockId)) { + return false + } + } + return true + }) + + if (allCompleted) { + context.completedLoops.add(parallelId) + + // Activate end connections + const endConnections = + context.workflow?.connections.filter( + (conn: any) => conn.source === parallelId && conn.sourceHandle === 'parallel-end-source' + ) || [] + + for (const conn of endConnections) { + context.activeExecutionPath.add(conn.target) + } + + return { + response: { + parallelId, + parallelCount: parallelState.parallelCount, + completed: true, + message: `Completed all ${parallelState.parallelCount} executions`, + }, + } + } + + return { + response: { + parallelId, + parallelCount: parallelState.parallelCount, + waiting: true, + message: 'Waiting for iterations to complete', + }, + } + }), + } +}) + +/** + * Create an input resolver mock that handles parallel references + */ +export const createParallelInputResolver = (distributionData: any) => ({ + InputResolver: vi.fn().mockImplementation(() => ({ + resolveInputs: vi.fn().mockImplementation((block, context) => { + if (block.metadata?.id === 'function') { + const virtualBlockId = context.currentVirtualBlockId + if (virtualBlockId && context.parallelBlockMapping) { + const mapping = context.parallelBlockMapping.get(virtualBlockId) + if (mapping) { + if (Array.isArray(distributionData)) { + const currentItem = distributionData[mapping.iterationIndex] + const currentIndex = mapping.iterationIndex + return { + code: `return { item: "${currentItem}", index: ${currentIndex} }`, + } + } + if (typeof distributionData === 'object') { + const entries = Object.entries(distributionData) + const [key, value] = entries[mapping.iterationIndex] + return { + code: `return { key: "${key}", value: "${value}" }`, + } + } + } + } + } + return {} + }), + })), +}) + +/** + * Create a workflow with parallel blocks for testing + */ +export const createWorkflowWithParallelArray = ( + items: any[] = ['apple', 'banana', 'cherry'] +): SerializedWorkflow => ({ + version: '2.0', + blocks: [ + { + id: 'starter', + position: { x: 0, y: 0 }, + metadata: { id: 'starter', name: 'Start' }, + config: { tool: 'starter', params: {} }, + inputs: {}, + outputs: {}, + enabled: true, + }, + { + id: 'parallel-1', + position: { x: 100, y: 0 }, + metadata: { id: 'parallel', name: 'Test Parallel' }, + config: { tool: 'parallel', params: {} }, + inputs: {}, + outputs: {}, + enabled: true, + }, + { + id: 'function-1', + position: { x: 200, y: 0 }, + metadata: { id: 'function', name: 'Process Item' }, + config: { + tool: 'function', + params: { + code: 'return { item: , index: }', + }, + }, + inputs: {}, + outputs: {}, + enabled: true, + }, + { + id: 'endpoint', + position: { x: 300, y: 0 }, + metadata: { id: 'generic', name: 'End' }, + config: { tool: 'generic', params: {} }, + inputs: {}, + outputs: {}, + enabled: true, + }, + ], + connections: [ + { source: 'starter', target: 'parallel-1' }, + { source: 'parallel-1', target: 'function-1', sourceHandle: 'parallel-start-source' }, + { source: 'parallel-1', target: 'endpoint', sourceHandle: 'parallel-end-source' }, + ], + loops: {}, + parallels: { + 'parallel-1': { + id: 'parallel-1', + nodes: ['function-1'], + distribution: items, + }, + }, +}) + +/** + * Create a workflow with parallel blocks for object distribution + */ +export const createWorkflowWithParallelObject = ( + items: Record = { first: 'alpha', second: 'beta', third: 'gamma' } +): SerializedWorkflow => ({ + version: '2.0', + blocks: [ + { + id: 'starter', + position: { x: 0, y: 0 }, + metadata: { id: 'starter', name: 'Start' }, + config: { tool: 'starter', params: {} }, + inputs: {}, + outputs: {}, + enabled: true, + }, + { + id: 'parallel-1', + position: { x: 100, y: 0 }, + metadata: { id: 'parallel', name: 'Test Parallel' }, + config: { tool: 'parallel', params: {} }, + inputs: {}, + outputs: {}, + enabled: true, + }, + { + id: 'function-1', + position: { x: 200, y: 0 }, + metadata: { id: 'function', name: 'Process Entry' }, + config: { + tool: 'function', + params: { + code: 'return { key: , value: }', + }, + }, + inputs: {}, + outputs: {}, + enabled: true, + }, + { + id: 'endpoint', + position: { x: 300, y: 0 }, + metadata: { id: 'generic', name: 'End' }, + config: { tool: 'generic', params: {} }, + inputs: {}, + outputs: {}, + enabled: true, + }, + ], + connections: [ + { source: 'starter', target: 'parallel-1' }, + { source: 'parallel-1', target: 'function-1', sourceHandle: 'parallel-start-source' }, + { source: 'parallel-1', target: 'endpoint', sourceHandle: 'parallel-end-source' }, + ], + loops: {}, + parallels: { + 'parallel-1': { + id: 'parallel-1', + nodes: ['function-1'], + distribution: items, + }, + }, +}) + +/** + * Mock all modules needed for parallel tests + */ +export const setupParallelTestMocks = (options?: { + distributionData?: any + maxParallelChecks?: number +}) => { + // Setup stores + setupStoreMocks() + + // Setup core executor mocks + setupExecutorCoreMocks() + + // Setup parallel manager with correct relative path + vi.doMock('../parallels', () => + createParallelManagerMock({ + maxChecks: options?.maxParallelChecks, + }) + ) + + // Setup loop manager with correct relative path + vi.doMock('../loops', () => createLoopManagerMock()) +} + +/** + * Sets up all standard mocks for executor tests + */ +export const setupAllMocks = (options?: { + isDebugModeEnabled?: boolean + consoleAddFn?: ReturnType + consoleUpdateFn?: ReturnType +}) => { + setupHandlerMocks() + const storeMocks = setupStoreMocks(options) + setupExecutorCoreMocks() + + return storeMocks +} diff --git a/apps/sim/executor/handlers/index.ts b/apps/sim/executor/handlers/index.ts index d87ccea3fd..c054ad487c 100644 --- a/apps/sim/executor/handlers/index.ts +++ b/apps/sim/executor/handlers/index.ts @@ -4,6 +4,8 @@ import { ConditionBlockHandler } from './condition/condition-handler' import { EvaluatorBlockHandler } from './evaluator/evaluator-handler' import { FunctionBlockHandler } from './function/function-handler' import { GenericBlockHandler } from './generic/generic-handler' +import { LoopBlockHandler } from './loop/loop-handler' +import { ParallelBlockHandler } from './parallel/parallel-handler' import { RouterBlockHandler } from './router/router-handler' export { @@ -13,5 +15,7 @@ export { EvaluatorBlockHandler, FunctionBlockHandler, GenericBlockHandler, + LoopBlockHandler, + ParallelBlockHandler, RouterBlockHandler, } diff --git a/apps/sim/executor/handlers/loop/loop-handler.test.ts b/apps/sim/executor/handlers/loop/loop-handler.test.ts new file mode 100644 index 0000000000..d303b0894f --- /dev/null +++ b/apps/sim/executor/handlers/loop/loop-handler.test.ts @@ -0,0 +1,215 @@ +import type { SerializedBlock } from '@/serializer/types' +import type { ExecutionContext } from '../../types' +import { LoopBlockHandler } from './loop-handler' + +describe('LoopBlockHandler', () => { + let handler: LoopBlockHandler + let mockContext: ExecutionContext + let mockBlock: SerializedBlock + + beforeEach(() => { + handler = new LoopBlockHandler() + + mockBlock = { + id: 'loop-1', + position: { x: 0, y: 0 }, + config: { tool: 'loop', params: {} }, + inputs: {}, + outputs: {}, + metadata: { id: 'loop', name: 'Test Loop' }, + enabled: true, + } + + mockContext = { + workflowId: 'test-workflow', + blockStates: new Map(), + blockLogs: [], + metadata: { duration: 0 }, + environmentVariables: {}, + decisions: { router: new Map(), condition: new Map() }, + loopIterations: new Map(), + loopItems: new Map(), + completedLoops: new Set(), + executedBlocks: new Set(), + activeExecutionPath: new Set(), + workflow: { + version: '1.0', + blocks: [mockBlock], + connections: [ + { + source: 'loop-1', + target: 'inner-block', + sourceHandle: 'loop-start-source', + }, + { + source: 'loop-1', + target: 'after-loop', + sourceHandle: 'loop-end-source', + }, + ], + loops: { + 'loop-1': { + id: 'loop-1', + nodes: ['inner-block'], + iterations: 3, + loopType: 'for', + }, + }, + }, + } + }) + + describe('canHandle', () => { + it('should handle loop blocks', () => { + expect(handler.canHandle(mockBlock)).toBe(true) + }) + + it('should not handle non-loop blocks', () => { + if (mockBlock.metadata) { + mockBlock.metadata.id = 'function' + } + expect(handler.canHandle(mockBlock)).toBe(false) + }) + }) + + describe('execute', () => { + it('should initialize loop on first execution', async () => { + const result = await handler.execute(mockBlock, {}, mockContext) + + // After execution, the counter is incremented for the next iteration + expect(mockContext.loopIterations.get('loop-1')).toBe(1) + expect(mockContext.activeExecutionPath.has('inner-block')).toBe(true) + + // Type guard to check if result has the expected structure + if (typeof result === 'object' && result !== null && 'response' in result) { + const response = result.response as any + expect(response.currentIteration).toBe(0) // Still shows current iteration as 0 + expect(response.maxIterations).toBe(3) + expect(response.completed).toBe(false) + } + }) + + it('should activate loop-end-source when iterations complete', async () => { + // Set to last iteration + mockContext.loopIterations.set('loop-1', 3) + + const result = await handler.execute(mockBlock, {}, mockContext) + + // The loop handler no longer marks loops as completed - that's handled by the loop manager + expect(mockContext.completedLoops.has('loop-1')).toBe(false) + // The loop handler also doesn't activate end connections anymore + expect(mockContext.activeExecutionPath.has('after-loop')).toBe(false) + // But it should not activate the inner block either since we're at max iterations + expect(mockContext.activeExecutionPath.has('inner-block')).toBe(false) + + if (typeof result === 'object' && result !== null && 'response' in result) { + const response = result.response as any + expect(response.completed).toBe(false) // Not completed until all blocks execute + expect(response.message).toContain('Final iteration') + } + }) + + it('should handle forEach loops with array items', async () => { + mockContext.workflow!.loops['loop-1'] = { + id: 'loop-1', + nodes: ['inner-block'], + iterations: 10, + loopType: 'forEach', + forEachItems: ['item1', 'item2', 'item3'], + } + + const result = await handler.execute(mockBlock, {}, mockContext) + + expect(mockContext.loopItems.get('loop-1')).toBe('item1') + + if (typeof result === 'object' && result !== null && 'response' in result) { + const response = result.response as any + expect(response.loopType).toBe('forEach') + expect(response.maxIterations).toBe(3) // Limited by items length + } + }) + + it('should handle forEach loops with object items', async () => { + mockContext.workflow!.loops['loop-1'] = { + id: 'loop-1', + nodes: ['inner-block'], + iterations: 10, + loopType: 'forEach', + forEachItems: { key1: 'value1', key2: 'value2' }, + } + + const result = await handler.execute(mockBlock, {}, mockContext) + + const currentItem = mockContext.loopItems.get('loop-1') + expect(Array.isArray(currentItem)).toBe(true) + expect(currentItem[0]).toBe('key1') + expect(currentItem[1]).toBe('value1') + }) + + it('should limit forEach loops by collection size, not iterations parameter', async () => { + // This tests the fix for the bug where forEach loops were using the iterations count + // instead of the actual collection size + mockContext.workflow!.loops['loop-1'] = { + id: 'loop-1', + nodes: ['inner-block'], + iterations: 10, // High iteration count + loopType: 'forEach', + forEachItems: ['a', 'b'], // Only 2 items + } + + // First execution + let result = await handler.execute(mockBlock, {}, mockContext) + expect(mockContext.loopIterations.get('loop-1')).toBe(1) + expect(mockContext.loopItems.get('loop-1')).toBe('a') + + if (typeof result === 'object' && result !== null && 'response' in result) { + const response = result.response as any + expect(response.maxIterations).toBe(2) // Should be limited to 2, not 10 + expect(response.completed).toBe(false) + } + + // Second execution + result = await handler.execute(mockBlock, {}, mockContext) + expect(mockContext.loopIterations.get('loop-1')).toBe(2) + expect(mockContext.loopItems.get('loop-1')).toBe('b') + + if (typeof result === 'object' && result !== null && 'response' in result) { + const response = result.response as any + expect(response.completed).toBe(false) + } + + // Third execution should complete the loop + result = await handler.execute(mockBlock, {}, mockContext) + // The loop handler no longer marks loops as completed - that's handled by the loop manager + expect(mockContext.completedLoops.has('loop-1')).toBe(false) + }) + + it('should throw error for forEach loops without collection', async () => { + mockContext.workflow!.loops['loop-1'] = { + id: 'loop-1', + nodes: ['inner-block'], + iterations: 5, + loopType: 'forEach', + forEachItems: '', // Empty collection + } + + await expect(handler.execute(mockBlock, {}, mockContext)).rejects.toThrow( + 'forEach loop "loop-1" requires a collection to iterate over' + ) + }) + + it('should throw error for forEach loops with empty collection', async () => { + mockContext.workflow!.loops['loop-1'] = { + id: 'loop-1', + nodes: ['inner-block'], + iterations: 5, + loopType: 'forEach', + forEachItems: [], // Empty array + } + + await expect(handler.execute(mockBlock, {}, mockContext)).rejects.toThrow( + 'forEach loop "loop-1" collection is empty or invalid' + ) + }) + }) +}) diff --git a/apps/sim/executor/handlers/loop/loop-handler.ts b/apps/sim/executor/handlers/loop/loop-handler.ts new file mode 100644 index 0000000000..799209fd17 --- /dev/null +++ b/apps/sim/executor/handlers/loop/loop-handler.ts @@ -0,0 +1,209 @@ +import { createLogger } from '@/lib/logs/console-logger' +import type { BlockOutput } from '@/blocks/types' +import type { SerializedBlock } from '@/serializer/types' +import type { InputResolver } from '../../resolver' +import type { BlockHandler, ExecutionContext } from '../../types' + +const logger = createLogger('LoopBlockHandler') + +const DEFAULT_MAX_ITERATIONS = 5 + +/** + * Handler for loop blocks that manage iteration control and flow. + * Loop blocks don't execute logic themselves but control the flow of blocks within them. + */ +export class LoopBlockHandler implements BlockHandler { + constructor(private resolver?: InputResolver) {} + + canHandle(block: SerializedBlock): boolean { + return block.metadata?.id === 'loop' + } + + async execute( + block: SerializedBlock, + _inputs: Record, + context: ExecutionContext + ): Promise { + logger.info(`Executing loop block: ${block.id}`) + + // Get the loop configuration from the workflow + const loop = context.workflow?.loops?.[block.id] + if (!loop) { + throw new Error(`Loop configuration not found for block ${block.id}`) + } + + // Initialize loop iteration if not already done + if (!context.loopIterations.has(block.id)) { + context.loopIterations.set(block.id, 0) + logger.info(`Initialized loop ${block.id} with 0 iterations`) + } + + const currentIteration = context.loopIterations.get(block.id) || 0 + let maxIterations = loop.iterations || DEFAULT_MAX_ITERATIONS + + // For forEach loops, we need to check the actual items length + let forEachItems: any[] | Record | null = null + if (loop.loopType === 'forEach') { + if ( + !loop.forEachItems || + (typeof loop.forEachItems === 'string' && loop.forEachItems.trim() === '') + ) { + throw new Error( + `forEach loop "${block.id}" requires a collection to iterate over. Please provide an array or object in the collection field.` + ) + } + + forEachItems = await this.evaluateForEachItems(loop.forEachItems, context, block) + logger.info(`Evaluated forEach items for loop ${block.id}:`, forEachItems) + + if ( + !forEachItems || + (Array.isArray(forEachItems) && forEachItems.length === 0) || + (typeof forEachItems === 'object' && Object.keys(forEachItems).length === 0) + ) { + throw new Error( + `forEach loop "${block.id}" collection is empty or invalid. Please provide a non-empty array or object.` + ) + } + + // Adjust max iterations based on actual items + const itemsLength = Array.isArray(forEachItems) + ? forEachItems.length + : Object.keys(forEachItems).length + maxIterations = Math.min(maxIterations, itemsLength) + logger.info( + `Loop ${block.id} max iterations set to ${maxIterations} based on ${itemsLength} items` + ) + } + + logger.info( + `Loop ${block.id} - Current iteration: ${currentIteration}, Max iterations: ${maxIterations}` + ) + + // Check if we've reached the maximum iterations + if (currentIteration >= maxIterations) { + logger.info(`Loop ${block.id} has reached maximum iterations (${maxIterations})`) + + // Don't mark as completed here - let the loop manager handle it after all blocks execute + // Just return that this is the final iteration + return { + response: { + loopId: block.id, + currentIteration: currentIteration - 1, // Report the actual last iteration number + maxIterations, + loopType: loop.loopType || 'for', + completed: false, // Not completed until all blocks in this iteration execute + message: `Final iteration ${currentIteration} of ${maxIterations}`, + }, + } + } + + // For forEach loops, set the current item BEFORE incrementing + if (loop.loopType === 'forEach' && forEachItems) { + // Store the full items array for access via + context.loopItems.set(`${block.id}_items`, forEachItems) + + const currentItem = Array.isArray(forEachItems) + ? forEachItems[currentIteration] + : Object.entries(forEachItems)[currentIteration] + context.loopItems.set(block.id, currentItem) + logger.info( + `Loop ${block.id} - Set current item for iteration ${currentIteration}:`, + currentItem + ) + } + + // Increment the iteration counter for the NEXT iteration + // This happens AFTER we've set up the current iteration's data + context.loopIterations.set(block.id, currentIteration + 1) + logger.info( + `Loop ${block.id} - Incremented counter for next iteration: ${currentIteration + 1}` + ) + + // Loop is still active, activate the loop-start-source connection + const loopStartConnections = + context.workflow?.connections.filter( + (conn) => conn.source === block.id && conn.sourceHandle === 'loop-start-source' + ) || [] + + for (const conn of loopStartConnections) { + context.activeExecutionPath.add(conn.target) + logger.info(`Activated loop start path to ${conn.target} for iteration ${currentIteration}`) + } + + return { + response: { + loopId: block.id, + currentIteration, + maxIterations, + loopType: loop.loopType || 'for', + completed: false, + message: `Starting iteration ${currentIteration + 1} of ${maxIterations}`, + }, + } + } + + /** + * Evaluates forEach items expression or value + */ + private async evaluateForEachItems( + forEachItems: any, + context: ExecutionContext, + block: SerializedBlock + ): Promise | null> { + // If already an array or object, return as-is + if ( + Array.isArray(forEachItems) || + (typeof forEachItems === 'object' && forEachItems !== null) + ) { + return forEachItems + } + + // If it's a string expression, try to evaluate it + if (typeof forEachItems === 'string') { + try { + const trimmed = forEachItems.trim() + if (trimmed.startsWith('//') || trimmed === '') { + return [] + } + + // Try to parse as JSON first + if (trimmed.startsWith('[') || trimmed.startsWith('{')) { + try { + return JSON.parse(trimmed) + } catch { + // Continue to expression evaluation + } + } + + // If we have a resolver, use it to resolve any block references in the expression + if (this.resolver) { + const resolved = this.resolver.resolveBlockReferences(forEachItems, context, block) + + // Try to parse the resolved value + try { + return JSON.parse(resolved) + } catch { + // If it's not valid JSON, try to evaluate as an expression + try { + const result = new Function(`return ${resolved}`)() + if (Array.isArray(result) || (typeof result === 'object' && result !== null)) { + return result + } + } catch (e) { + logger.error(`Error evaluating forEach expression: ${resolved}`, e) + } + } + } + + logger.warn(`forEach expression evaluation not fully implemented: ${forEachItems}`) + return null + } catch (error) { + logger.error(`Error evaluating forEach items:`, error) + return null + } + } + + return null + } +} diff --git a/apps/sim/executor/handlers/parallel/parallel-handler.test.ts b/apps/sim/executor/handlers/parallel/parallel-handler.test.ts new file mode 100644 index 0000000000..a549f7f29c --- /dev/null +++ b/apps/sim/executor/handlers/parallel/parallel-handler.test.ts @@ -0,0 +1,280 @@ +import { describe, expect, it, vi } from 'vitest' +import type { SerializedBlock, SerializedParallel } from '@/serializer/types' +import { createParallelExecutionState } from '../../__test-utils__/executor-mocks' +import type { ExecutionContext } from '../../types' +import { ParallelBlockHandler } from './parallel-handler' + +describe('ParallelBlockHandler', () => { + const mockResolver = { + resolveBlockReferences: vi.fn((expr: string) => expr), + } + + const createMockBlock = (id: string): SerializedBlock => ({ + id, + position: { x: 0, y: 0 }, + config: { tool: '', params: {} }, + inputs: {}, + outputs: {}, + metadata: { id: 'parallel', name: 'Test Parallel' }, + enabled: true, + }) + + const createMockContext = (parallel?: SerializedParallel): ExecutionContext => ({ + workflowId: 'test-workflow', + blockStates: new Map(), + blockLogs: [], + metadata: { duration: 0 }, + environmentVariables: {}, + decisions: { router: new Map(), condition: new Map() }, + loopIterations: new Map(), + loopItems: new Map(), + completedLoops: new Set(), + executedBlocks: new Set(), + activeExecutionPath: new Set(), + workflow: { + version: '1.0', + blocks: [], + connections: [], + loops: {}, + parallels: parallel ? { [parallel.id]: parallel } : {}, + }, + }) + + it('should handle parallel blocks', () => { + const handler = new ParallelBlockHandler(mockResolver as any) + const block = createMockBlock('parallel-1') + + expect(handler.canHandle(block)).toBe(true) + + const nonParallelBlock = { ...block, metadata: { id: 'agent' } } + expect(handler.canHandle(nonParallelBlock)).toBe(false) + }) + + it('should initialize parallel block with distribution', async () => { + const handler = new ParallelBlockHandler(mockResolver as any) + const block = createMockBlock('parallel-1') + const parallel: SerializedParallel = { + id: 'parallel-1', + nodes: ['agent-1', 'api-1'], + distribution: ['item1', 'item2', 'item3'], + } + + const context = createMockContext(parallel) + context.workflow!.connections = [ + { + source: 'parallel-1', + target: 'agent-1', + sourceHandle: 'parallel-start-source', + }, + ] + + // First execution - initialize parallel and set up iterations + const result = await handler.execute(block, {}, context) + + expect(result).toHaveProperty('response') + expect((result as any).response).toMatchObject({ + parallelId: 'parallel-1', + parallelCount: 3, + distributionType: 'distributed', + started: true, + message: 'Initialized 3 parallel executions', + }) + + // Check that items were stored + expect(context.loopItems.get('parallel-1_items')).toEqual(['item1', 'item2', 'item3']) + + // Check that target was activated + expect(context.activeExecutionPath.has('agent-1')).toBe(true) + + // Check parallel state + const parallelState = context.parallelExecutions?.get('parallel-1') + expect(parallelState).toBeDefined() + expect(parallelState?.currentIteration).toBe(1) // Indicates activation + expect(parallelState?.parallelCount).toBe(3) + }) + + it('should handle waiting state when iterations are incomplete', async () => { + const handler = new ParallelBlockHandler(mockResolver as any) + const block = createMockBlock('parallel-1') + const parallel: SerializedParallel = { + id: 'parallel-1', + nodes: ['agent-1'], + distribution: ['item1', 'item2'], + } + + const context = createMockContext(parallel) + context.parallelExecutions = new Map([ + [ + 'parallel-1', + createParallelExecutionState({ + parallelCount: 2, + distributionItems: ['item1', 'item2'], + completedExecutions: 0, + activeIterations: new Set([0, 1]), + currentIteration: 1, + }), + ], + ]) + + context.executedBlocks.add('parallel-1') + context.workflow!.connections = [ + { + source: 'parallel-1', + target: 'agent-1', + sourceHandle: 'parallel-start-source', + }, + ] + + // Second execution - check waiting state + const result = await handler.execute(block, {}, context) + + expect(result).toHaveProperty('response') + expect((result as any).response).toMatchObject({ + parallelId: 'parallel-1', + parallelCount: 2, + completedExecutions: 0, + activeIterations: 2, + waiting: true, + message: '0 of 2 iterations completed', + }) + }) + + it('should handle completion after all iterations', async () => { + const handler = new ParallelBlockHandler(mockResolver as any) + const block = createMockBlock('parallel-1') + const parallel: SerializedParallel = { + id: 'parallel-1', + nodes: ['agent-1'], + distribution: ['item1', 'item2'], + } + + const context = createMockContext(parallel) + context.parallelExecutions = new Map([ + [ + 'parallel-1', + createParallelExecutionState({ + parallelCount: 2, + distributionItems: ['item1', 'item2'], + completedExecutions: 0, + executionResults: new Map([ + ['iteration_0', { 'agent-1': { response: { result: 'result1' } } }], + ['iteration_1', { 'agent-1': { response: { result: 'result2' } } }], + ]), + activeIterations: new Set(), + currentIteration: 1, + }), + ], + ]) + + // Mark virtual blocks as executed + context.executedBlocks.add('parallel-1') + context.executedBlocks.add('agent-1_parallel_parallel-1_iteration_0') + context.executedBlocks.add('agent-1_parallel_parallel-1_iteration_1') + + context.workflow!.connections = [ + { + source: 'parallel-1', + target: 'evaluator-1', + sourceHandle: 'parallel-end-source', + }, + ] + + // Execution after all iterations complete + const result = await handler.execute(block, {}, context) + + expect(result).toHaveProperty('response') + expect((result as any).response).toMatchObject({ + parallelId: 'parallel-1', + parallelCount: 2, + completed: true, + results: [ + { 'agent-1': { response: { result: 'result1' } } }, + { 'agent-1': { response: { result: 'result2' } } }, + ], + message: 'Completed all 2 executions', + }) + + // Check that parallel was marked as completed + expect(context.completedLoops.has('parallel-1')).toBe(true) + + // Check that post-parallel path was activated + expect(context.activeExecutionPath.has('evaluator-1')).toBe(true) + }) + + it('should handle object distribution', async () => { + const handler = new ParallelBlockHandler(mockResolver as any) + const block = createMockBlock('parallel-1') + const parallel: SerializedParallel = { + id: 'parallel-1', + nodes: ['agent-1'], + distribution: { key1: 'value1', key2: 'value2' }, + } + + const context = createMockContext(parallel) + + const result = await handler.execute(block, {}, context) + + expect(result).toHaveProperty('response') + expect((result as any).response).toMatchObject({ + parallelId: 'parallel-1', + parallelCount: 2, + distributionType: 'distributed', + started: true, + }) + + // Check that object entries were stored correctly + expect(context.loopItems.get('parallel-1_items')).toEqual({ key1: 'value1', key2: 'value2' }) + + // Check parallel state + const parallelState = context.parallelExecutions?.get('parallel-1') + expect(parallelState?.distributionItems).toEqual({ key1: 'value1', key2: 'value2' }) + }) + + it('should handle expression evaluation', async () => { + const handler = new ParallelBlockHandler(mockResolver as any) + const block = createMockBlock('parallel-1') + const parallel: SerializedParallel = { + id: 'parallel-1', + nodes: ['agent-1'], + distribution: '["a", "b", "c"]', + } + + const context = createMockContext(parallel) + + const result = await handler.execute(block, {}, context) + + expect(result).toHaveProperty('response') + expect((result as any).response).toMatchObject({ + parallelId: 'parallel-1', + parallelCount: 3, + distributionType: 'distributed', + }) + + expect(context.loopItems.get('parallel-1_items')).toEqual(['a', 'b', 'c']) + }) + + it('should handle parallel without distribution', async () => { + const handler = new ParallelBlockHandler(mockResolver as any) + const block = createMockBlock('parallel-1') + const parallel: SerializedParallel = { + id: 'parallel-1', + nodes: ['agent-1'], + } + + const context = createMockContext(parallel) + + const result = await handler.execute(block, {}, context) + + expect(result).toHaveProperty('response') + expect((result as any).response).toMatchObject({ + parallelId: 'parallel-1', + parallelCount: 1, + distributionType: 'simple', + started: true, + message: 'Initialized 1 parallel executions', + }) + + // Should not have items when no distribution + expect(context.loopItems.has('parallel-1_items')).toBe(false) + }) +}) diff --git a/apps/sim/executor/handlers/parallel/parallel-handler.ts b/apps/sim/executor/handlers/parallel/parallel-handler.ts new file mode 100644 index 0000000000..c63a60848c --- /dev/null +++ b/apps/sim/executor/handlers/parallel/parallel-handler.ts @@ -0,0 +1,309 @@ +import { createLogger } from '@/lib/logs/console-logger' +import type { BlockOutput } from '@/blocks/types' +import type { SerializedBlock } from '@/serializer/types' +import type { InputResolver } from '../../resolver' +import type { BlockHandler, ExecutionContext, StreamingExecution } from '../../types' + +const logger = createLogger('ParallelBlockHandler') + +/** + * Handler for parallel blocks that manage concurrent execution of blocks. + * The parallel block sets up the execution state and lets the executor + * create virtual instances for true parallel execution. + */ +export class ParallelBlockHandler implements BlockHandler { + constructor(private resolver?: InputResolver) {} + + canHandle(block: SerializedBlock): boolean { + return block.metadata?.id === 'parallel' + } + + async execute( + block: SerializedBlock, + _inputs: Record, + context: ExecutionContext + ): Promise { + logger.info(`Executing parallel block: ${block.id}`) + + // Get the parallel configuration from the workflow + const parallel = context.workflow?.parallels?.[block.id] + if (!parallel) { + throw new Error(`Parallel configuration not found for block ${block.id}`) + } + + // Check if we're tracking parallel executions in context + if (!context.parallelExecutions) { + context.parallelExecutions = new Map() + } + + // Get or initialize the parallel state + let parallelState = context.parallelExecutions.get(block.id) + + if (!parallelState) { + logger.info(`Initializing parallel block ${block.id}`) + + // Get the parallel type and count from block data + const parallelType = block.config?.params?.parallelType || 'collection' + const countValue = block.config?.params?.count || 5 + + // Evaluate distribution items if provided and type is collection + let distributionItems: any[] | Record | null = null + if (parallelType === 'collection' && parallel.distribution) { + distributionItems = await this.evaluateDistributionItems( + parallel.distribution, + context, + block + ) + logger.info(`Evaluated distribution items for parallel ${block.id}:`, distributionItems) + } + + // Determine the number of parallel executions + let parallelCount = 1 + if (parallelType === 'count') { + // Use the count value for count-based parallel + parallelCount = Math.min(20, Math.max(1, countValue)) + logger.info(`Parallel ${block.id} will execute ${parallelCount} times based on count`) + } else if (distributionItems) { + // Use distribution items length for collection-based parallel + parallelCount = Array.isArray(distributionItems) + ? distributionItems.length + : Object.keys(distributionItems).length + logger.info( + `Parallel ${block.id} will execute ${parallelCount} times based on distribution items` + ) + } + + // Initialize parallel execution state + parallelState = { + parallelCount, + distributionItems, + completedExecutions: 0, + executionResults: new Map(), + activeIterations: new Set(), + currentIteration: 1, // Start at 1 to indicate activation + parallelType, + } + context.parallelExecutions.set(block.id, parallelState) + + // Store the distribution items for access by child blocks + if (distributionItems) { + context.loopItems.set(`${block.id}_items`, distributionItems) + } else if (parallelType === 'count') { + // For count-based parallel, create an array of indices + const indices = Array.from({ length: parallelCount }, (_, i) => i) + context.loopItems.set(`${block.id}_items`, indices) + } + + // Activate all child nodes (the executor will handle creating virtual instances) + const parallelStartConnections = + context.workflow?.connections.filter( + (conn) => conn.source === block.id && conn.sourceHandle === 'parallel-start-source' + ) || [] + + for (const conn of parallelStartConnections) { + context.activeExecutionPath.add(conn.target) + logger.info(`Activated parallel path to ${conn.target}`) + } + + return { + response: { + parallelId: block.id, + parallelCount, + distributionType: + parallelType === 'count' ? 'count' : distributionItems ? 'distributed' : 'simple', + started: true, + message: `Initialized ${parallelCount} parallel executions`, + }, + } + } + + // Check if all virtual blocks have completed + const allCompleted = this.checkAllIterationsCompleted(block.id, context) + + if (allCompleted) { + logger.info(`All iterations completed for parallel ${block.id}`) + + // Mark this parallel as completed + context.completedLoops.add(block.id) + + // Aggregate results + const results = [] + for (let i = 0; i < parallelState.parallelCount; i++) { + const result = parallelState.executionResults.get(`iteration_${i}`) + if (result) { + results.push(result) + } + } + + // Store the aggregated results in the block state so subsequent blocks can reference them + const aggregatedOutput = { + response: { + parallelId: block.id, + parallelCount: parallelState.parallelCount, + completed: true, + results, + message: `Completed all ${parallelState.parallelCount} executions`, + }, + } + + // Store the aggregated results in context so blocks connected to parallel-end-source can access them + context.blockStates.set(block.id, { + output: aggregatedOutput, + executed: true, + executionTime: 0, // Parallel coordination doesn't have meaningful execution time + }) + + // Activate the parallel-end-source connection to continue workflow + const parallelEndConnections = + context.workflow?.connections.filter( + (conn) => conn.source === block.id && conn.sourceHandle === 'parallel-end-source' + ) || [] + + for (const conn of parallelEndConnections) { + context.activeExecutionPath.add(conn.target) + logger.info(`Activated post-parallel path to ${conn.target}`) + } + + // Clean up iteration data + if (context.loopItems.has(`${block.id}_items`)) { + context.loopItems.delete(`${block.id}_items`) + } + if (context.loopItems.has(block.id)) { + context.loopItems.delete(block.id) + } + if (context.loopIterations.has(block.id)) { + context.loopIterations.delete(block.id) + } + + return aggregatedOutput + } + + // Still waiting for iterations to complete + const completedCount = this.countCompletedIterations(block.id, context) + return { + response: { + parallelId: block.id, + parallelCount: parallelState.parallelCount, + completedExecutions: completedCount, + activeIterations: parallelState.parallelCount - completedCount, + waiting: true, + message: `${completedCount} of ${parallelState.parallelCount} iterations completed`, + }, + } + } + + /** + * Checks if all iterations of a parallel block have completed + */ + private checkAllIterationsCompleted(parallelId: string, context: ExecutionContext): boolean { + const parallel = context.workflow?.parallels?.[parallelId] + const parallelState = context.parallelExecutions?.get(parallelId) + + if (!parallel || !parallelState) return false + + // Check each node in the parallel for all iterations + for (const nodeId of parallel.nodes) { + for (let i = 0; i < parallelState.parallelCount; i++) { + const virtualBlockId = `${nodeId}_parallel_${parallelId}_iteration_${i}` + if (!context.executedBlocks.has(virtualBlockId)) { + return false + } + } + } + + return true + } + + /** + * Counts completed iterations for a parallel block + */ + private countCompletedIterations(parallelId: string, context: ExecutionContext): number { + const parallel = context.workflow?.parallels?.[parallelId] + const parallelState = context.parallelExecutions?.get(parallelId) + + if (!parallel || !parallelState) return 0 + + let completedCount = 0 + + // Count iterations where all nodes have completed + for (let i = 0; i < parallelState.parallelCount; i++) { + let allNodesCompleted = true + for (const nodeId of parallel.nodes) { + const virtualBlockId = `${nodeId}_parallel_${parallelId}_iteration_${i}` + if (!context.executedBlocks.has(virtualBlockId)) { + allNodesCompleted = false + break + } + } + if (allNodesCompleted) { + completedCount++ + } + } + + return completedCount + } + + /** + * Evaluates distribution items expression or value + */ + private async evaluateDistributionItems( + distribution: any, + context: ExecutionContext, + block: SerializedBlock + ): Promise | null> { + // If already an array or object, return as-is + if ( + Array.isArray(distribution) || + (typeof distribution === 'object' && distribution !== null) + ) { + return distribution + } + + // If it's a string expression, try to evaluate it + if (typeof distribution === 'string') { + try { + const trimmed = distribution.trim() + if (trimmed.startsWith('//') || trimmed === '') { + return [] + } + + // Try to parse as JSON first + if (trimmed.startsWith('[') || trimmed.startsWith('{')) { + try { + return JSON.parse(trimmed) + } catch { + // Continue to expression evaluation + } + } + + // If we have a resolver, use it to resolve any block references in the expression + if (this.resolver) { + const resolved = this.resolver.resolveBlockReferences(distribution, context, block) + + // Try to parse the resolved value + try { + return JSON.parse(resolved) + } catch { + // If it's not valid JSON, try to evaluate as an expression + try { + const result = new Function(`return ${resolved}`)() + if (Array.isArray(result) || (typeof result === 'object' && result !== null)) { + return result + } + } catch (e) { + logger.error(`Error evaluating distribution expression: ${resolved}`, e) + } + } + } + + logger.warn(`Distribution expression evaluation not fully implemented: ${distribution}`) + return null + } catch (error) { + logger.error(`Error evaluating distribution items:`, error) + return null + } + } + + return null + } +} diff --git a/apps/sim/executor/index.test.ts b/apps/sim/executor/index.test.ts index b3d21162d1..10dcb7469d 100644 --- a/apps/sim/executor/index.test.ts +++ b/apps/sim/executor/index.test.ts @@ -8,9 +8,21 @@ * resolving inputs and dependencies, and managing errors. */ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' -import type { SerializedWorkflow } from '../serializer/types' +import type { SerializedWorkflow } from '@/serializer/types' +import { + createLoopManagerMock, + createMinimalWorkflow, + createMockContext, + createMockHandler, + createWorkflowWithCondition, + createWorkflowWithErrorPath, + createWorkflowWithLoop, + setupAllMocks, +} from './__test-utils__/executor-mocks' import { Executor } from './index' +import type { BlockLog } from './types' +// Mock the logger vi.mock('@/lib/logs/console-logger', () => ({ createLogger: () => ({ error: vi.fn(), @@ -20,236 +32,16 @@ vi.mock('@/lib/logs/console-logger', () => ({ }), })) -vi.mock('@/stores/console/store', () => ({ - useConsoleStore: { - getState: () => ({ - addConsole: vi.fn(), - }), - }, -})) - -vi.mock('@/stores/execution/store', () => ({ - useExecutionStore: { - getState: () => ({ - setIsExecuting: vi.fn(), - reset: vi.fn(), - setActiveBlocks: vi.fn(), - setPendingBlocks: vi.fn(), - setIsDebugging: vi.fn(), - }), - }, -})) - -vi.mock('@/stores/settings/general/store', () => ({ - useGeneralStore: { - getState: () => ({ - isDebugModeEnabled: true, - }), - }, -})) - -// Mock all handler classes -vi.mock('./handlers', () => { - // Factory function for handler mocks - const createHandler = (handlerName: string) => { - return vi.fn().mockImplementation(() => ({ - canHandle: (block: any) => block.metadata?.id === handlerName || handlerName === 'generic', - execute: vi.fn().mockResolvedValue({ response: { result: `${handlerName} executed` } }), - })) - } - - return { - AgentBlockHandler: createHandler('agent'), - RouterBlockHandler: createHandler('router'), - ConditionBlockHandler: createHandler('condition'), - EvaluatorBlockHandler: createHandler('evaluator'), - FunctionBlockHandler: createHandler('function'), - ApiBlockHandler: createHandler('api'), - GenericBlockHandler: createHandler('generic'), - } -}) - -// Mock the PathTracker -vi.mock('./path', () => ({ - PathTracker: vi.fn().mockImplementation(() => ({ - updateExecutionPaths: vi.fn(), - })), -})) - -// Mock the InputResolver -vi.mock('./resolver', () => ({ - InputResolver: vi.fn().mockImplementation(() => ({ - resolveInputs: vi.fn().mockReturnValue({}), - })), -})) - -// Mock the LoopManager -vi.mock('./loops', () => ({ - LoopManager: vi.fn().mockImplementation(() => ({ - processLoopIterations: vi.fn().mockResolvedValue(false), - })), -})) - -/** - * Test Fixtures - */ - -// Create a minimal workflow -const createMinimalWorkflow = (): SerializedWorkflow => ({ - version: '1.0', - blocks: [ - { - id: 'starter', - position: { x: 0, y: 0 }, - config: { tool: 'test-tool', params: {} }, - inputs: {}, - outputs: {}, - enabled: true, - metadata: { id: 'starter', name: 'Starter Block' }, - }, - { - id: 'block1', - position: { x: 100, y: 0 }, - config: { tool: 'test-tool', params: {} }, - inputs: {}, - outputs: {}, - enabled: true, - metadata: { id: 'test', name: 'Test Block' }, - }, - ], - connections: [ - { - source: 'starter', - target: 'block1', - }, - ], - loops: {}, -}) - -// Create a workflow with a conditional path -const createWorkflowWithCondition = (): SerializedWorkflow => ({ - version: '1.0', - blocks: [ - { - id: 'starter', - position: { x: 0, y: 0 }, - config: { tool: 'test-tool', params: {} }, - inputs: {}, - outputs: {}, - enabled: true, - metadata: { id: 'starter', name: 'Starter Block' }, - }, - { - id: 'condition1', - position: { x: 100, y: 0 }, - config: { tool: 'test-tool', params: {} }, - inputs: {}, - outputs: {}, - enabled: true, - metadata: { id: 'condition', name: 'Condition Block' }, - }, - { - id: 'block1', - position: { x: 200, y: -50 }, - config: { tool: 'test-tool', params: {} }, - inputs: {}, - outputs: {}, - enabled: true, - metadata: { id: 'test', name: 'True Path Block' }, - }, - { - id: 'block2', - position: { x: 200, y: 50 }, - config: { tool: 'test-tool', params: {} }, - inputs: {}, - outputs: {}, - enabled: true, - metadata: { id: 'test', name: 'False Path Block' }, - }, - ], - connections: [ - { - source: 'starter', - target: 'condition1', - }, - { - source: 'condition1', - target: 'block1', - sourceHandle: 'condition-true', - }, - { - source: 'condition1', - target: 'block2', - sourceHandle: 'condition-false', - }, - ], - loops: {}, -}) - -// Create a workflow with a loop -const createWorkflowWithLoop = (): SerializedWorkflow => ({ - version: '1.0', - blocks: [ - { - id: 'starter', - position: { x: 0, y: 0 }, - config: { tool: 'test-tool', params: {} }, - inputs: {}, - outputs: {}, - enabled: true, - metadata: { id: 'starter', name: 'Starter Block' }, - }, - { - id: 'block1', - position: { x: 100, y: 0 }, - config: { tool: 'test-tool', params: {} }, - inputs: {}, - outputs: {}, - enabled: true, - metadata: { id: 'test', name: 'Loop Block 1' }, - }, - { - id: 'block2', - position: { x: 200, y: 0 }, - config: { tool: 'test-tool', params: {} }, - inputs: {}, - outputs: {}, - enabled: true, - metadata: { id: 'test', name: 'Loop Block 2' }, - }, - ], - connections: [ - { - source: 'starter', - target: 'block1', - }, - { - source: 'block1', - target: 'block2', - }, - { - source: 'block2', - target: 'block1', - }, - ], - loops: { - loop1: { - id: 'loop1', - nodes: ['block1', 'block2'], - iterations: 5, - loopType: 'forEach', - forEachItems: [1, 2, 3, 4, 5], - }, - }, -}) - describe('Executor', () => { beforeEach(() => { vi.clearAllMocks() + // Setup all standard mocks by default + setupAllMocks() }) afterEach(() => { vi.resetAllMocks() + vi.resetModules() }) /** @@ -435,14 +227,41 @@ describe('Executor', () => { * Debug mode tests */ describe('debug mode', () => { - // Test that the executor can be put into debug mode - test('should detect debug mode from settings', () => { + test('should detect debug mode from settings', async () => { + // Reset and reconfigure mocks for debug mode + vi.resetModules() + vi.clearAllMocks() + + // Setup mocks with debug mode enabled + setupAllMocks({ isDebugModeEnabled: true }) + + // Import Executor AFTER setting up mocks + const { Executor } = await import('./index') + const workflow = createMinimalWorkflow() const executor = new Executor(workflow) const isDebugging = (executor as any).isDebugging expect(isDebugging).toBe(true) }) + + test('should work with debug mode disabled', async () => { + // Reset and reconfigure mocks for normal mode + vi.resetModules() + vi.clearAllMocks() + + // Setup mocks with debug mode disabled (default) + setupAllMocks({ isDebugModeEnabled: false }) + + // Import Executor AFTER setting up mocks + const { Executor } = await import('./index') + + const workflow = createMinimalWorkflow() + const executor = new Executor(workflow) + const isDebugging = (executor as any).isDebugging + + expect(isDebugging).toBe(false) + }) }) /** @@ -510,66 +329,6 @@ describe('Executor', () => { * Error handling tests */ describe('error handling', () => { - // Create a workflow with an error path - const createWorkflowWithErrorPath = (): SerializedWorkflow => ({ - version: '1.0', - blocks: [ - { - id: 'starter', - position: { x: 0, y: 0 }, - config: { tool: 'test-tool', params: {} }, - inputs: {}, - outputs: {}, - enabled: true, - metadata: { id: 'starter', name: 'Starter Block' }, - }, - { - id: 'block1', - position: { x: 100, y: 0 }, - config: { tool: 'test-tool', params: {} }, - inputs: {}, - outputs: {}, - enabled: true, - metadata: { id: 'function', name: 'Function Block' }, - }, - { - id: 'error-handler', - position: { x: 200, y: 50 }, - config: { tool: 'test-tool', params: {} }, - inputs: {}, - outputs: {}, - enabled: true, - metadata: { id: 'test', name: 'Error Handler Block' }, - }, - { - id: 'success-block', - position: { x: 200, y: -50 }, - config: { tool: 'test-tool', params: {} }, - inputs: {}, - outputs: {}, - enabled: true, - metadata: { id: 'test', name: 'Success Block' }, - }, - ], - connections: [ - { - source: 'starter', - target: 'block1', - }, - { - source: 'block1', - target: 'success-block', - sourceHandle: 'source', - }, - { - source: 'block1', - target: 'error-handler', - sourceHandle: 'error', - }, - ], - loops: {}, - }) - test('should activate error paths when a block has an error', () => { const workflow = createWorkflowWithErrorPath() const executor = new Executor(workflow) @@ -670,11 +429,6 @@ describe('Executor', () => { expect(result).toBe(false) }) - test('should execute error path when a block throws an error', async () => { - // Skip this test for now, as it requires complex mocking - // TODO: Revisit this test with proper mocks for handler execution - }) - test('should create proper error output for a block error', () => { const workflow = createWorkflowWithErrorPath() const executor = new Executor(workflow) @@ -765,43 +519,29 @@ describe('Executor', () => { }) test('should increment loop iterations correctly', async () => { - // Mock the LoopManager - vi.doMock('./loops', () => ({ - LoopManager: vi.fn().mockImplementation(() => ({ - processLoopIterations: vi.fn().mockImplementation(async (context) => { + // Mock the LoopManager with custom implementation + vi.doMock('./loops', () => + createLoopManagerMock({ + processLoopIterationsImpl: async (context) => { // Simulate incrementing iteration counter const currentIteration = context.loopIterations.get('loop1') || 0 context.loopIterations.set('loop1', currentIteration + 1) return false - }), - getLoopIndex: vi.fn().mockImplementation((loopId, blockId, context) => { - return context.loopIterations.get(loopId) || 0 - }), - })), - })) + }, + }) + ) - // Create a minimal workflow with loop const workflow = createWorkflowWithLoop() - - // Import with mocks applied const { LoopManager } = await import('./loops') const loopManager = new LoopManager(workflow.loops) - // Create a mock context - const context = { - workflowId: 'test-workflow-id', - blockStates: new Map(), - blockLogs: [], - metadata: { startTime: new Date().toISOString(), duration: 0 }, - environmentVariables: {}, - decisions: { router: new Map(), condition: new Map() }, + // Create a mock context using the helper + const context = createMockContext({ + workflow, loopIterations: new Map([['loop1', 0]]), - loopItems: new Map(), executedBlocks: new Set(['block1', 'block2']), activeExecutionPath: new Set(['block1', 'block2']), - workflow, - completedLoops: new Set(), - } + }) // Process loop iterations to increment counter await loopManager.processLoopIterations(context) @@ -831,39 +571,23 @@ describe('Executor', () => { })) // Mock the LoopManager - vi.doMock('./loops', () => ({ - LoopManager: vi.fn().mockImplementation(() => ({ - getLoopIndex: vi.fn().mockImplementation((loopId, blockId, context) => { - return context.loopIterations.get(loopId) || 0 - }), - })), - })) + vi.doMock('./loops', () => createLoopManagerMock()) - // Create a minimal workflow with forEach loop const workflow = createWorkflowWithLoop() - - // Import with mocks applied const { Executor } = await import('./index') const executor = new Executor(workflow) const { InputResolver } = await import('./resolver') const resolver = new InputResolver(workflow, {}, {}, (executor as any).loopManager) - // Create a mock context - const context = { - workflowId: 'test-workflow-id', - blockStates: new Map(), - blockLogs: [], - metadata: { startTime: new Date().toISOString(), duration: 0 }, - environmentVariables: {}, - decisions: { router: new Map(), condition: new Map() }, + // Create a mock context with specific loop state + const context = createMockContext({ + workflow, loopIterations: new Map([['loop1', 2]]), // Iteration 2 (3rd item) loopItems: new Map([['loop1', 3]]), // Current item is 3 executedBlocks: new Set(['block1']), activeExecutionPath: new Set(['block1', 'block2']), - workflow, - completedLoops: new Set(), - } + }) // Resolve a loop index reference const resolvedIndex = resolver.resolveBlockReferences( @@ -893,9 +617,9 @@ describe('Executor', () => { const capturedIndices: number[] = [] // Mock the LoopManager implementation - vi.doMock('./loops', () => ({ - LoopManager: vi.fn().mockImplementation(() => ({ - processLoopIterations: vi.fn().mockImplementation(async (context) => { + vi.doMock('./loops', () => + createLoopManagerMock({ + processLoopIterationsImpl: async (context) => { // Simulate 3 loop iterations if (context.executedBlocks.has('block1') && context.executedBlocks.has('block2')) { const currentIteration = context.loopIterations.get('loop1') || 0 @@ -908,32 +632,16 @@ describe('Executor', () => { } } return true - }), - getLoopIndex: vi.fn().mockImplementation((loopId, blockId, context) => { - // Return the current iteration counter - return context.loopIterations.get(loopId) || 0 - }), - })), - })) + }, + }) + ) // Mock the handlers to capture loop indices vi.doMock('./handlers', () => ({ - AgentBlockHandler: vi.fn().mockImplementation(() => ({ - canHandle: () => false, - execute: vi.fn(), - })), - RouterBlockHandler: vi.fn().mockImplementation(() => ({ - canHandle: () => false, - execute: vi.fn(), - })), - ConditionBlockHandler: vi.fn().mockImplementation(() => ({ - canHandle: () => false, - execute: vi.fn(), - })), - EvaluatorBlockHandler: vi.fn().mockImplementation(() => ({ - canHandle: () => false, - execute: vi.fn(), - })), + AgentBlockHandler: createMockHandler('agent'), + RouterBlockHandler: createMockHandler('router'), + ConditionBlockHandler: createMockHandler('condition'), + EvaluatorBlockHandler: createMockHandler('evaluator'), FunctionBlockHandler: vi.fn().mockImplementation(() => ({ canHandle: (block: any) => block.metadata?.id === 'function' || block.id === 'block1' || block.id === 'block2', @@ -944,14 +652,10 @@ describe('Executor', () => { return { response: { result: `Index: ${loopIndex}` } } }), })), - ApiBlockHandler: vi.fn().mockImplementation(() => ({ - canHandle: () => false, - execute: vi.fn(), - })), - GenericBlockHandler: vi.fn().mockImplementation(() => ({ - canHandle: () => true, - execute: vi.fn().mockResolvedValue({ response: { result: 'Executed' } }), - })), + ApiBlockHandler: createMockHandler('api'), + LoopBlockHandler: createMockHandler('loop'), + ParallelBlockHandler: createMockHandler('parallel'), + GenericBlockHandler: createMockHandler('generic', { canHandleCondition: () => true }), })) // Mock PathTracker @@ -991,49 +695,24 @@ describe('Executor', () => { const capturedIndices: { loopId: string; blockId: string; index: number }[] = [] // Mock the LoopManager - vi.doMock('./loops', () => ({ - LoopManager: vi.fn().mockImplementation(() => ({ - processLoopIterations: vi.fn().mockImplementation(async (context) => { - return true - }), - getLoopIndex: vi.fn().mockImplementation((loopId, blockId, context) => { - return context.loopIterations.get(loopId) || 0 - }), - })), - })) + vi.doMock('./loops', () => createLoopManagerMock()) // Mock the handlers to capture loop indices vi.doMock('./handlers', () => ({ - AgentBlockHandler: vi.fn().mockImplementation(() => ({ - canHandle: () => false, - execute: vi.fn(), - })), - RouterBlockHandler: vi.fn().mockImplementation(() => ({ - canHandle: () => false, - execute: vi.fn(), - })), - ConditionBlockHandler: vi.fn().mockImplementation(() => ({ - canHandle: () => false, - execute: vi.fn(), - })), - EvaluatorBlockHandler: vi.fn().mockImplementation(() => ({ - canHandle: () => false, - execute: vi.fn(), - })), + AgentBlockHandler: createMockHandler('agent'), + RouterBlockHandler: createMockHandler('router'), + ConditionBlockHandler: createMockHandler('condition'), + EvaluatorBlockHandler: createMockHandler('evaluator'), FunctionBlockHandler: vi.fn().mockImplementation(() => ({ canHandle: (block: any) => block.id.includes('block'), execute: vi.fn().mockImplementation(async (block, inputs, context) => { return { response: { result: 'Executed' } } }), })), - ApiBlockHandler: vi.fn().mockImplementation(() => ({ - canHandle: () => false, - execute: vi.fn(), - })), - GenericBlockHandler: vi.fn().mockImplementation(() => ({ - canHandle: () => true, - execute: vi.fn().mockResolvedValue({ response: { result: 'Executed' } }), - })), + ApiBlockHandler: createMockHandler('api'), + LoopBlockHandler: createMockHandler('loop'), + ParallelBlockHandler: createMockHandler('parallel'), + GenericBlockHandler: createMockHandler('generic', { canHandleCondition: () => true }), })) // Manually populate the capturedIndices array for testing @@ -1073,10 +752,10 @@ describe('Executor', () => { // Reset modules to ensure clean state vi.resetModules() - // Mock the LoopManager - vi.doMock('./loops', () => ({ - LoopManager: vi.fn().mockImplementation(() => ({ - processLoopIterations: vi.fn().mockImplementation(async (context) => { + // Mock the LoopManager with bug fix implementation + vi.doMock('./loops', () => + createLoopManagerMock({ + processLoopIterationsImpl: async (context) => { // Increment iteration when both blocks executed if (context.executedBlocks.has('block1') && context.executedBlocks.has('block2')) { const currentIteration = context.loopIterations.get('loop1') || 0 @@ -1085,13 +764,9 @@ describe('Executor', () => { context.executedBlocks.delete('block2') } return false - }), - getLoopIndex: vi.fn().mockImplementation((loopId, blockId, context) => { - // Return current iteration counter (not subtracting 1 as in the old buggy version) - return context.loopIterations.get(loopId) || 0 - }), - })), - })) + }, + }) + ) // Import with mocks applied const { LoopManager } = await import('./loops') @@ -1101,20 +776,11 @@ describe('Executor', () => { const loopManager = new LoopManager(workflow.loops) // Create a mock context - const context = { - workflowId: 'test-workflow-id', - blockStates: new Map(), - blockLogs: [], - metadata: { startTime: new Date().toISOString(), duration: 0 }, - environmentVariables: {}, - decisions: { router: new Map(), condition: new Map() }, - loopIterations: new Map([['loop1', 0]]), - loopItems: new Map(), - executedBlocks: new Set(), - activeExecutionPath: new Set(['block1', 'block2']), + const context = createMockContext({ workflow, - completedLoops: new Set(), - } + loopIterations: new Map([['loop1', 0]]), + activeExecutionPath: new Set(['block1', 'block2']), + }) // First iteration - this should give index 0 for both blocks const firstIterationIndex1 = loopManager.getLoopIndex('loop1', 'block1', context) @@ -1163,4 +829,185 @@ describe('Executor', () => { expect(thirdIterationIndex2).toBe(2) }) }) + + describe('parallel management', () => { + beforeEach(() => { + // Reset modules before each test to ensure clean mocks + vi.resetModules() + vi.clearAllMocks() + }) + + it('should execute blocks inside parallel with correct iteration items', async () => { + // Setup basic store mocks + setupAllMocks() + + // Import real implementations + const { Executor } = await import('./index') + + // Create a simple workflow with parallel + const workflow: SerializedWorkflow = { + version: '2.0', + blocks: [ + { + id: 'starter', + position: { x: 0, y: 0 }, + metadata: { id: 'starter', name: 'Start' }, + config: { tool: 'starter', params: {} }, + inputs: {}, + outputs: {}, + enabled: true, + }, + { + id: 'parallel-1', + position: { x: 100, y: 0 }, + metadata: { id: 'parallel', name: 'Test Parallel' }, + config: { tool: 'parallel', params: {} }, + inputs: {}, + outputs: {}, + enabled: true, + }, + { + id: 'function-1', + position: { x: 200, y: 0 }, + metadata: { id: 'function', name: 'Process Item' }, + config: { + tool: 'function', + params: { + code: 'return { item: "test", index: 0 }', + }, + }, + inputs: {}, + outputs: {}, + enabled: true, + }, + { + id: 'endpoint', + position: { x: 300, y: 0 }, + metadata: { id: 'generic', name: 'End' }, + config: { tool: 'generic', params: {} }, + inputs: {}, + outputs: {}, + enabled: true, + }, + ], + connections: [ + { source: 'starter', target: 'parallel-1' }, + { source: 'parallel-1', target: 'function-1', sourceHandle: 'parallel-start-source' }, + { source: 'parallel-1', target: 'endpoint', sourceHandle: 'parallel-end-source' }, + ], + loops: {}, + parallels: { + 'parallel-1': { + id: 'parallel-1', + nodes: ['function-1'], + distribution: ['apple', 'banana', 'cherry'], + }, + }, + } + + const executor = new Executor(workflow) + const result = await executor.execute('test-workflow-id') + + // Type guard to ensure we have ExecutionResult, not StreamingExecution + if ('stream' in result) { + throw new Error('Expected ExecutionResult but got StreamingExecution') + } + + // The test should succeed even if we can't fully mock the parallel execution + // What we're really testing is that the executor can handle parallel blocks + expect(result.success).toBe(true) + expect(result.logs).toBeDefined() + + // Check that at least the parallel block was executed + const parallelLog = result.logs?.find((log: BlockLog) => log.blockType === 'parallel') + expect(parallelLog).toBeDefined() + // Since we're using mocked handlers, we just check that the parallel block was executed + expect(parallelLog?.success).toBe(true) + }) + + it('should handle object distribution in parallel blocks', async () => { + // Setup basic store mocks + setupAllMocks() + + // Import real implementations + const { Executor } = await import('./index') + + // Create a simple workflow with parallel using object distribution + const workflow: SerializedWorkflow = { + version: '2.0', + blocks: [ + { + id: 'starter', + position: { x: 0, y: 0 }, + metadata: { id: 'starter', name: 'Start' }, + config: { tool: 'starter', params: {} }, + inputs: {}, + outputs: {}, + enabled: true, + }, + { + id: 'parallel-1', + position: { x: 100, y: 0 }, + metadata: { id: 'parallel', name: 'Test Parallel' }, + config: { tool: 'parallel', params: {} }, + inputs: {}, + outputs: {}, + enabled: true, + }, + { + id: 'function-1', + position: { x: 200, y: 0 }, + metadata: { id: 'function', name: 'Process Entry' }, + config: { + tool: 'function', + params: { + code: 'return { key: "test", value: "value" }', + }, + }, + inputs: {}, + outputs: {}, + enabled: true, + }, + { + id: 'endpoint', + position: { x: 300, y: 0 }, + metadata: { id: 'generic', name: 'End' }, + config: { tool: 'generic', params: {} }, + inputs: {}, + outputs: {}, + enabled: true, + }, + ], + connections: [ + { source: 'starter', target: 'parallel-1' }, + { source: 'parallel-1', target: 'function-1', sourceHandle: 'parallel-start-source' }, + { source: 'parallel-1', target: 'endpoint', sourceHandle: 'parallel-end-source' }, + ], + loops: {}, + parallels: { + 'parallel-1': { + id: 'parallel-1', + nodes: ['function-1'], + distribution: { first: 'alpha', second: 'beta', third: 'gamma' }, + }, + }, + } + + const executor = new Executor(workflow) + const result = await executor.execute('test-workflow-id') + + if ('stream' in result) { + throw new Error('Expected ExecutionResult but got StreamingExecution') + } + + expect(result.success).toBe(true) + expect(result.logs).toBeDefined() + + // Check that at least the parallel block was executed + const parallelLog = result.logs?.find((log: BlockLog) => log.blockType === 'parallel') + expect(parallelLog).toBeDefined() + // Since we're using mocked handlers, we just check that the parallel block was executed + expect(parallelLog?.success).toBe(true) + }) + }) }) diff --git a/apps/sim/executor/index.ts b/apps/sim/executor/index.ts index 786d08e2e5..4802a1b7d9 100644 --- a/apps/sim/executor/index.ts +++ b/apps/sim/executor/index.ts @@ -11,9 +11,12 @@ import { EvaluatorBlockHandler, FunctionBlockHandler, GenericBlockHandler, + LoopBlockHandler, + ParallelBlockHandler, RouterBlockHandler, } from './handlers/index' import { LoopManager } from './loops' +import { ParallelManager } from './parallels' import { PathTracker } from './path' import { InputResolver } from './resolver' import type { @@ -55,6 +58,7 @@ export class Executor { // Core components are initialized once and remain immutable private resolver: InputResolver private loopManager: LoopManager + private parallelManager: ParallelManager private pathTracker: PathTracker private blockHandlers: BlockHandler[] private workflowInput: any @@ -119,6 +123,7 @@ export class Executor { this.validateWorkflow() this.loopManager = new LoopManager(this.actualWorkflow.loops || {}) + this.parallelManager = new ParallelManager(this.actualWorkflow.parallels || {}) this.resolver = new InputResolver( this.actualWorkflow, this.environmentVariables, @@ -134,6 +139,8 @@ export class Executor { new EvaluatorBlockHandler(), new FunctionBlockHandler(), new ApiBlockHandler(), + new LoopBlockHandler(this.resolver), + new ParallelBlockHandler(this.resolver), new GenericBlockHandler(), ] @@ -334,6 +341,9 @@ export class Executor { // Handle any loop activations, etc. await this.loopManager.processLoopIterations(context) + // Process parallel iterations - similar to loops but conceptually for parallel execution + await this.parallelManager.processParallelIterations(context) + // Fetch the subsequent layer (if any) nextLayer = this.getNextExecutionLayer(context) } @@ -420,6 +430,9 @@ export class Executor { // Process loop iterations - this will activate external paths when loops complete await this.loopManager.processLoopIterations(context) + // Process parallel iterations - similar to loops but conceptually for parallel execution + await this.parallelManager.processParallelIterations(context) + // Continue execution for any newly activated paths // Only stop execution if there are no more blocks to execute const updatedNextLayer = this.getNextExecutionLayer(context) @@ -504,6 +517,7 @@ export class Executor { finalOutput = outputs[outputs.length - 1] } await this.loopManager.processLoopIterations(context) + await this.parallelManager.processParallelIterations(context) const nextLayer = this.getNextExecutionLayer(context) setPendingBlocks(nextLayer) @@ -605,6 +619,15 @@ export class Executor { if (loop.iterations <= 0) { throw new Error(`Loop ${loopId} must have a positive iterations value`) } + + if (loop.loopType === 'forEach') { + if ( + !loop.forEachItems || + (typeof loop.forEachItems === 'string' && loop.forEachItems.trim() === '') + ) { + throw new Error(`forEach loop ${loopId} requires a collection to iterate over`) + } + } } } @@ -813,6 +836,7 @@ export class Executor { /** * Determines the next layer of blocks to execute based on dependencies and execution path. * Handles special cases for blocks in loops, condition blocks, and router blocks. + * For blocks inside parallel executions, creates multiple virtual instances. * * @param context - Current execution context * @returns Array of block IDs that are ready to be executed @@ -821,112 +845,132 @@ export class Executor { const executedBlocks = context.executedBlocks const pendingBlocks = new Set() + // Check if we have any active parallel executions + const activeParallels = new Map() + if (context.parallelExecutions) { + for (const [parallelId, state] of context.parallelExecutions) { + if ( + state.currentIteration > 0 && + state.currentIteration <= state.parallelCount && + !context.completedLoops.has(parallelId) + ) { + activeParallels.set(parallelId, state) + } + } + } + for (const block of this.actualWorkflow.blocks) { if (executedBlocks.has(block.id) || block.enabled === false) { continue } - // Only consider blocks in the active execution path - if (!context.activeExecutionPath.has(block.id)) { - continue + // Check if this block is inside an active parallel + let insideParallel: string | null = null + for (const [parallelId, parallel] of Object.entries(this.actualWorkflow.parallels || {})) { + if (parallel.nodes.includes(block.id)) { + insideParallel = parallelId + break + } } - const incomingConnections = this.actualWorkflow.connections.filter( - (conn) => conn.target === block.id - ) + // If block is inside a parallel, handle multiple instances + if (insideParallel && activeParallels.has(insideParallel)) { + const parallelState = activeParallels.get(insideParallel) - // Find all loops that this block is a part of - const containingLoops = Object.values(this.actualWorkflow.loops || {}).filter((loop) => - loop.nodes.includes(block.id) - ) - - const isInLoop = containingLoops.length > 0 - - if (isInLoop) { - // Check if this block is part of a self-loop (single-node loop) - const isInSelfLoop = containingLoops.some( - (loop) => loop.nodes.length === 1 && loop.nodes[0] === block.id + // Create virtual instances for each unprocessed iteration + const virtualBlockIds = this.parallelManager.createVirtualBlockInstances( + block, + insideParallel, + parallelState, + executedBlocks, + context.activeExecutionPath ) - // Check if there's a direct self-connection - const hasSelfConnection = this.actualWorkflow.connections.some( - (conn) => conn.source === block.id && conn.target === block.id - ) + for (const virtualBlockId of virtualBlockIds) { + // Check dependencies for this virtual instance + const incomingConnections = this.actualWorkflow.connections.filter( + (conn) => conn.target === block.id + ) - if (isInSelfLoop || hasSelfConnection) { - // For self-loops, we only need the node to be in the active execution path - // It will be reset after each iteration by the loop manager - pendingBlocks.add(block.id) + const iterationIndex = Number.parseInt(virtualBlockId.split('_iteration_')[1]) + const allDependenciesMet = this.checkDependencies( + incomingConnections, + executedBlocks, + context, + insideParallel, + iterationIndex + ) + + if (allDependenciesMet) { + pendingBlocks.add(virtualBlockId) + + // Store mapping for virtual block + if (!context.parallelBlockMapping) { + context.parallelBlockMapping = new Map() + } + context.parallelBlockMapping.set(virtualBlockId, { + originalBlockId: block.id, + parallelId: insideParallel, + iterationIndex: iterationIndex, + }) + } + } + } else if (insideParallel) { + // Block is inside a parallel but the parallel is not active + // Check if all virtual instances have been executed + const parallelState = context.parallelExecutions?.get(insideParallel) + if (parallelState) { + let allVirtualInstancesExecuted = true + for (let i = 0; i < parallelState.parallelCount; i++) { + const virtualBlockId = `${block.id}_parallel_${insideParallel}_iteration_${i}` + if (!executedBlocks.has(virtualBlockId)) { + allVirtualInstancesExecuted = false + break + } + } + + // If all virtual instances have been executed, skip this block + // It should not be executed as a regular block + if (allVirtualInstancesExecuted) { + continue + } + } + + // If we reach here, the parallel hasn't been initialized yet + // Allow normal execution flow + if (!context.activeExecutionPath.has(block.id)) { continue } - // For regular multi-node loops - const hasValidPath = incomingConnections.some((conn) => { - return executedBlocks.has(conn.source) - }) + const incomingConnections = this.actualWorkflow.connections.filter( + (conn) => conn.target === block.id + ) - if (hasValidPath) { + const allDependenciesMet = this.checkDependencies( + incomingConnections, + executedBlocks, + context + ) + + if (allDependenciesMet) { pendingBlocks.add(block.id) } } else { - // Regular non-loop block handling (unchanged) - const allDependenciesMet = incomingConnections.every((conn) => { - const sourceExecuted = executedBlocks.has(conn.source) - const sourceBlock = this.actualWorkflow.blocks.find((b) => b.id === conn.source) - const sourceBlockState = context.blockStates.get(conn.source) - const hasSourceError = - sourceBlockState?.output?.error !== undefined || - sourceBlockState?.output?.response?.error !== undefined + // Regular block handling (not inside a parallel) + // Only consider blocks in the active execution path + if (!context.activeExecutionPath.has(block.id)) { + continue + } - // For condition blocks, check if this is the selected path - if (conn.sourceHandle?.startsWith('condition-')) { - const sourceBlock = this.actualWorkflow.blocks.find((b) => b.id === conn.source) - if (sourceBlock?.metadata?.id === 'condition') { - const conditionId = conn.sourceHandle.replace('condition-', '') - const selectedCondition = context.decisions.condition.get(conn.source) + const incomingConnections = this.actualWorkflow.connections.filter( + (conn) => conn.target === block.id + ) - // If source is executed and this is not the selected path, consider it met - if (sourceExecuted && selectedCondition && conditionId !== selectedCondition) { - return true - } - - // Otherwise, this dependency is met only if source is executed and this is the selected path - return sourceExecuted && conditionId === selectedCondition - } - } - - // For router blocks, check if this is the selected target - if (sourceBlock?.metadata?.id === 'router') { - const selectedTarget = context.decisions.router.get(conn.source) - - // If source is executed and this is not the selected target, consider it met - if (sourceExecuted && selectedTarget && conn.target !== selectedTarget) { - return true - } - - // Otherwise, this dependency is met only if source is executed and this is the selected target - return sourceExecuted && conn.target === selectedTarget - } - - // For error connections, check if the source had an error - if (conn.sourceHandle === 'error') { - return sourceExecuted && hasSourceError - } - - // For regular connections, check if the source was executed without error - if (conn.sourceHandle === 'source' || !conn.sourceHandle) { - return sourceExecuted && !hasSourceError - } - - // If source is not in active path, consider this dependency met - // This allows blocks with multiple inputs to execute even if some inputs are from inactive paths - if (!context.activeExecutionPath.has(conn.source)) { - return true - } - - // For regular blocks, dependency is met if source is executed - return sourceExecuted - }) + const allDependenciesMet = this.checkDependencies( + incomingConnections, + executedBlocks, + context + ) if (allDependenciesMet) { pendingBlocks.add(block.id) @@ -937,6 +981,162 @@ export class Executor { return Array.from(pendingBlocks) } + /** + * Checks if all dependencies for a block are met. + * Handles special cases for different connection types. + * + * @param incomingConnections - Connections coming into the block + * @param executedBlocks - Set of executed block IDs + * @param context - Execution context + * @param insideParallel - ID of parallel block if this block is inside one + * @param iterationIndex - Index of the parallel iteration if applicable + * @returns Whether all dependencies are met + */ + private checkDependencies( + incomingConnections: any[], + executedBlocks: Set, + context: ExecutionContext, + insideParallel?: string, + iterationIndex?: number + ): boolean { + // Check if this is a loop block + const isLoopBlock = incomingConnections.some((conn) => { + const sourceBlock = this.actualWorkflow.blocks.find((b) => b.id === conn.source) + return sourceBlock?.metadata?.id === 'loop' + }) + + if (isLoopBlock) { + // Loop blocks are treated as regular blocks with standard dependency checking + return incomingConnections.every((conn) => { + const sourceExecuted = executedBlocks.has(conn.source) + const sourceBlockState = context.blockStates.get(conn.source) + const hasSourceError = + sourceBlockState?.output?.error !== undefined || + sourceBlockState?.output?.response?.error !== undefined + + // For error connections, check if the source had an error + if (conn.sourceHandle === 'error') { + return sourceExecuted && hasSourceError + } + + // For regular connections, check if the source was executed without error + if (conn.sourceHandle === 'source' || !conn.sourceHandle) { + return sourceExecuted && !hasSourceError + } + + // If source is not in active path, consider this dependency met + if (!context.activeExecutionPath.has(conn.source)) { + return true + } + + // For regular blocks, dependency is met if source is executed + return sourceExecuted + }) + } + // Regular non-loop block handling + return incomingConnections.every((conn) => { + // For virtual blocks inside parallels, check the source appropriately + let sourceId = conn.source + if (insideParallel !== undefined && iterationIndex !== undefined) { + // If the source is also inside the same parallel, use virtual ID + const sourceBlock = this.actualWorkflow.blocks.find((b) => b.id === conn.source) + if ( + sourceBlock && + this.actualWorkflow.parallels?.[insideParallel]?.nodes.includes(conn.source) + ) { + sourceId = `${conn.source}_parallel_${insideParallel}_iteration_${iterationIndex}` + } + } + + const sourceExecuted = executedBlocks.has(sourceId) + const sourceBlock = this.actualWorkflow.blocks.find((b) => b.id === conn.source) + const sourceBlockState = + context.blockStates.get(sourceId) || context.blockStates.get(conn.source) + const hasSourceError = + sourceBlockState?.output?.error !== undefined || + sourceBlockState?.output?.response?.error !== undefined + + // Special handling for loop-start-source connections + if (conn.sourceHandle === 'loop-start-source') { + // This block is connected to a loop's start output + // It should be activated when the loop block executes + return sourceExecuted + } + + // Special handling for loop-end-source connections + if (conn.sourceHandle === 'loop-end-source') { + // This block is connected to a loop's end output + // It should only be activated when the loop completes + const loopCompleted = context.completedLoops.has(conn.source) + return loopCompleted + } + + // Special handling for parallel-start-source connections + if (conn.sourceHandle === 'parallel-start-source') { + // This block is connected to a parallel's start output + // It should be activated when the parallel block executes + return executedBlocks.has(conn.source) + } + + // Special handling for parallel-end-source connections + if (conn.sourceHandle === 'parallel-end-source') { + // This block is connected to a parallel's end output + // It should only be activated when the parallel completes + const parallelCompleted = context.completedLoops.has(conn.source) + return parallelCompleted + } + + // For condition blocks, check if this is the selected path + if (conn.sourceHandle?.startsWith('condition-')) { + const sourceBlock = this.actualWorkflow.blocks.find((b) => b.id === conn.source) + if (sourceBlock?.metadata?.id === 'condition') { + const conditionId = conn.sourceHandle.replace('condition-', '') + const selectedCondition = context.decisions.condition.get(conn.source) + + // If source is executed and this is not the selected path, consider it met + if (sourceExecuted && selectedCondition && conditionId !== selectedCondition) { + return true + } + + // Otherwise, this dependency is met only if source is executed and this is the selected path + return sourceExecuted && conditionId === selectedCondition + } + } + + // For router blocks, check if this is the selected target + if (sourceBlock?.metadata?.id === 'router') { + const selectedTarget = context.decisions.router.get(conn.source) + + // If source is executed and this is not the selected target, consider it met + if (sourceExecuted && selectedTarget && conn.target !== selectedTarget) { + return true + } + + // Otherwise, this dependency is met only if source is executed and this is the selected target + return sourceExecuted && conn.target === selectedTarget + } + + // For error connections, check if the source had an error + if (conn.sourceHandle === 'error') { + return sourceExecuted && hasSourceError + } + + // For regular connections, check if the source was executed without error + if (conn.sourceHandle === 'source' || !conn.sourceHandle) { + return sourceExecuted && !hasSourceError + } + + // If source is not in active path, consider this dependency met + // This allows blocks with multiple inputs to execute even if some inputs are from inactive paths + if (!context.activeExecutionPath.has(conn.source)) { + return true + } + + // For regular blocks, dependency is met if source is executed + return sourceExecuted + }) + } + /** * Executes a layer of blocks in parallel. * Updates execution paths based on router and condition decisions. @@ -975,8 +1175,9 @@ export class Executor { /** * Executes a single block with error handling and logging. + * Handles virtual block IDs for parallel iterations. * - * @param blockId - ID of the block to execute + * @param blockId - ID of the block to execute (may be a virtual ID) * @param context - Current execution context * @returns Normalized block output * @throws Error if block execution fails @@ -985,21 +1186,49 @@ export class Executor { blockId: string, context: ExecutionContext ): Promise { - const block = this.actualWorkflow.blocks.find((b) => b.id === blockId) + // Check if this is a virtual block ID for parallel execution + let actualBlockId = blockId + let parallelInfo: + | { originalBlockId: string; parallelId: string; iterationIndex: number } + | undefined + + if (context.parallelBlockMapping?.has(blockId)) { + parallelInfo = context.parallelBlockMapping.get(blockId) + actualBlockId = parallelInfo!.originalBlockId + + // Set the current virtual block ID in context so resolver can access it + context.currentVirtualBlockId = blockId + + // Set up iteration-specific context BEFORE resolving inputs + if (parallelInfo) { + this.parallelManager.setupIterationContext(context, parallelInfo) + } + } else { + // Clear currentVirtualBlockId for non-virtual blocks + context.currentVirtualBlockId = undefined + } + + const block = this.actualWorkflow.blocks.find((b) => b.id === actualBlockId) if (!block) { - throw new Error(`Block ${blockId} not found`) + throw new Error(`Block ${actualBlockId} not found`) } // Special case for starter block - it's already been initialized in createExecutionContext // This ensures we don't re-execute the starter block and just return its existing state if (block.metadata?.id === 'starter') { - const starterState = context.blockStates.get(blockId) + const starterState = context.blockStates.get(actualBlockId) if (starterState) { return starterState.output as NormalizedBlockOutput } } const blockLog = this.createBlockLog(block) + // Use virtual block ID in logs if applicable + if (parallelInfo) { + blockLog.blockId = blockId + blockLog.blockName = `${block.metadata?.name || ''} (iteration ${parallelInfo.iterationIndex + 1})` + } + const addConsole = useConsoleStore.getState().addConsole const { setActiveBlocks } = useExecutionStore.getState() @@ -1015,7 +1244,7 @@ export class Executor { const starterState = context.blockStates.get(starterBlock.id) if (!starterState) { logger.warn( - `Starter block state not found when executing ${block.metadata?.name || blockId}. This may cause reference errors.` + `Starter block state not found when executing ${block.metadata?.name || actualBlockId}. This may cause reference errors.` ) } } @@ -1030,6 +1259,8 @@ export class Executor { trackWorkflowTelemetry('block_execution_start', { workflowId: context.workflowId, blockId: block.id, + virtualBlockId: parallelInfo ? blockId : undefined, + iterationIndex: parallelInfo?.iterationIndex, blockType: block.metadata?.id || 'unknown', blockName: block.metadata?.name || 'Unnamed Block', inputSize: Object.keys(inputs).length, @@ -1059,12 +1290,25 @@ export class Executor { const output = this.normalizeBlockOutput(rawOutput, block) // Update the context with the execution result + // Use virtual block ID for parallel executions context.blockStates.set(blockId, { output, executed: true, executionTime, }) + // Also store under the actual block ID for reference + if (parallelInfo) { + // Store iteration result in parallel state + this.parallelManager.storeIterationResult( + context, + parallelInfo.parallelId, + parallelInfo.iterationIndex, + actualBlockId, + output + ) + } + // Update the execution log blockLog.success = true blockLog.output = output @@ -1072,20 +1316,28 @@ export class Executor { blockLog.endedAt = new Date().toISOString() context.blockLogs.push(blockLog) - addConsole({ - output: blockLog.output, - durationMs: blockLog.durationMs, - startedAt: blockLog.startedAt, - endedAt: blockLog.endedAt, - workflowId: context.workflowId, - blockId: block.id, - blockName: block.metadata?.name || 'Unnamed Block', - blockType: block.metadata?.id || 'unknown', - }) + + // Skip console logging for infrastructure blocks like loops and parallels + if (block.metadata?.id !== 'loop' && block.metadata?.id !== 'parallel') { + addConsole({ + output: blockLog.output, + durationMs: blockLog.durationMs, + startedAt: blockLog.startedAt, + endedAt: blockLog.endedAt, + workflowId: context.workflowId, + blockId: parallelInfo ? blockId : block.id, + blockName: parallelInfo + ? `${block.metadata?.name || 'Unnamed Block'} (iteration ${parallelInfo.iterationIndex + 1})` + : block.metadata?.name || 'Unnamed Block', + blockType: block.metadata?.id || 'unknown', + }) + } trackWorkflowTelemetry('block_execution', { workflowId: context.workflowId, blockId: block.id, + virtualBlockId: parallelInfo ? blockId : undefined, + iterationIndex: parallelInfo?.iterationIndex, blockType: block.metadata?.id || 'unknown', blockName: block.metadata?.name || 'Unnamed Block', durationMs: Math.round(executionTime), @@ -1111,25 +1363,31 @@ export class Executor { // Log the error even if we'll continue execution through error path context.blockLogs.push(blockLog) - addConsole({ - output: {}, - error: - error.message || - `Error executing ${block.metadata?.id || 'unknown'} block: ${String(error)}`, - durationMs: blockLog.durationMs, - startedAt: blockLog.startedAt, - endedAt: blockLog.endedAt, - workflowId: context.workflowId, - blockName: block.metadata?.name || 'Unnamed Block', - blockType: block.metadata?.id || 'unknown', - }) + + // Skip console logging for infrastructure blocks like loops and parallels + if (block.metadata?.id !== 'loop' && block.metadata?.id !== 'parallel') { + addConsole({ + output: {}, + error: + error.message || + `Error executing ${block.metadata?.id || 'unknown'} block: ${String(error)}`, + durationMs: blockLog.durationMs, + startedAt: blockLog.startedAt, + endedAt: blockLog.endedAt, + workflowId: context.workflowId, + blockName: parallelInfo + ? `${block.metadata?.name || 'Unnamed Block'} (iteration ${parallelInfo.iterationIndex + 1})` + : block.metadata?.name || 'Unnamed Block', + blockType: block.metadata?.id || 'unknown', + }) + } // Check for error connections and follow them if they exist - const hasErrorPath = this.activateErrorPath(blockId, context) + const hasErrorPath = this.activateErrorPath(actualBlockId, context) // Log the error for visibility logger.error( - `Error executing block ${block.metadata?.name || blockId}:`, + `Error executing block ${block.metadata?.name || actualBlockId}:`, this.sanitizeError(error) ) @@ -1173,6 +1431,8 @@ export class Executor { trackWorkflowTelemetry('block_execution_error', { workflowId: context.workflowId, blockId: block.id, + virtualBlockId: parallelInfo ? blockId : undefined, + iterationIndex: parallelInfo?.iterationIndex, blockType: block.metadata?.id || 'unknown', blockName: block.metadata?.name || 'Unnamed Block', durationMs: blockLog.durationMs, @@ -1195,7 +1455,12 @@ export class Executor { private activateErrorPath(blockId: string, context: ExecutionContext): boolean { // Skip for starter blocks which don't have error handles const block = this.actualWorkflow.blocks.find((b) => b.id === blockId) - if (block?.metadata?.id === 'starter' || block?.metadata?.id === 'condition') { + if ( + block?.metadata?.id === 'starter' || + block?.metadata?.id === 'condition' || + block?.metadata?.id === 'loop' || + block?.metadata?.id === 'parallel' + ) { return false } @@ -1338,6 +1603,34 @@ export class Executor { return { response: evaluatorResponse } } + if (blockType === 'loop') { + return { + response: { + loopId: output?.loopId || block.id, + currentIteration: output?.currentIteration || 0, + maxIterations: output?.maxIterations || 0, + loopType: output?.loopType || 'for', + completed: output?.completed || false, + results: output?.results || [], + message: output?.message || '', + }, + } + } + + if (blockType === 'parallel') { + return { + response: { + parallelId: output?.parallelId || block.id, + parallelCount: output?.parallelCount || 1, + distributionType: output?.distributionType || 'simple', + completed: output?.completed || false, + completedCount: output?.completedCount || 0, + results: output?.results || [], + message: output?.message || '', + }, + } + } + return { response: { result: output }, } diff --git a/apps/sim/executor/loops.test.ts b/apps/sim/executor/loops.test.ts new file mode 100644 index 0000000000..5e0a5ecb29 --- /dev/null +++ b/apps/sim/executor/loops.test.ts @@ -0,0 +1,692 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest' +import type { SerializedLoop, SerializedWorkflow } from '@/serializer/types' +import { createMockContext } from './__test-utils__/executor-mocks' +import { LoopManager } from './loops' +import type { ExecutionContext } from './types' + +vi.mock('@/lib/logs/console-logger', () => ({ + createLogger: () => ({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + }), +})) + +describe('LoopManager', () => { + let manager: LoopManager + let mockContext: ExecutionContext + + const createBasicLoop = (overrides?: Partial): SerializedLoop => ({ + id: 'loop-1', + nodes: ['block-1', 'block-2'], + iterations: 3, + loopType: 'for', + ...overrides, + }) + + const createForEachLoop = (items: any, overrides?: Partial): SerializedLoop => ({ + id: 'loop-1', + nodes: ['block-1', 'block-2'], + iterations: 5, + loopType: 'forEach', + forEachItems: items, + ...overrides, + }) + + const createWorkflowWithLoop = (loop: SerializedLoop): SerializedWorkflow => ({ + version: '2.0', + blocks: [ + { + id: 'starter', + position: { x: 0, y: 0 }, + metadata: { id: 'starter', name: 'Start' }, + config: { tool: 'starter', params: {} }, + inputs: {}, + outputs: {}, + enabled: true, + }, + { + id: 'loop-1', + position: { x: 100, y: 0 }, + metadata: { id: 'loop', name: 'Test Loop' }, + config: { tool: 'loop', params: {} }, + inputs: {}, + outputs: {}, + enabled: true, + }, + { + id: 'block-1', + position: { x: 200, y: 0 }, + metadata: { id: 'function', name: 'Block 1' }, + config: { tool: 'function', params: {} }, + inputs: {}, + outputs: {}, + enabled: true, + }, + { + id: 'block-2', + position: { x: 300, y: 0 }, + metadata: { id: 'function', name: 'Block 2' }, + config: { tool: 'function', params: {} }, + inputs: {}, + outputs: {}, + enabled: true, + }, + { + id: 'after-loop', + position: { x: 400, y: 0 }, + metadata: { id: 'function', name: 'After Loop' }, + config: { tool: 'function', params: {} }, + inputs: {}, + outputs: {}, + enabled: true, + }, + ], + connections: [ + { source: 'starter', target: 'loop-1' }, + { source: 'loop-1', target: 'block-1', sourceHandle: 'loop-start-source' }, + { source: 'block-1', target: 'block-2' }, + { source: 'block-2', target: 'loop-1' }, + { source: 'loop-1', target: 'after-loop', sourceHandle: 'loop-end-source' }, + ], + loops: { + 'loop-1': loop, + }, + parallels: {}, + }) + + beforeEach(() => { + const loops = { + 'loop-1': createBasicLoop(), + } + manager = new LoopManager(loops) + + mockContext = createMockContext({ + workflow: createWorkflowWithLoop(createBasicLoop()), + loopIterations: new Map([['loop-1', 0]]), + loopItems: new Map(), + executedBlocks: new Set(), + activeExecutionPath: new Set(['starter', 'loop-1']), + completedLoops: new Set(), + }) + }) + + describe('constructor', () => { + test('should initialize with provided loops', () => { + const loops = { + 'loop-1': createBasicLoop(), + 'loop-2': createBasicLoop({ id: 'loop-2', iterations: 5 }), + } + const loopManager = new LoopManager(loops) + + expect(loopManager.getIterations('loop-1')).toBe(3) + expect(loopManager.getIterations('loop-2')).toBe(5) + }) + + test('should use default iterations for unknown loops', () => { + const loopManager = new LoopManager({}) + expect(loopManager.getIterations('unknown-loop')).toBe(5) // default + }) + + test('should accept custom default iterations', () => { + const loopManager = new LoopManager({}, 10) + expect(loopManager.getIterations('unknown-loop')).toBe(10) + }) + }) + + describe('processLoopIterations', () => { + test('should return false when no loops exist', async () => { + const emptyManager = new LoopManager({}) + const result = await emptyManager.processLoopIterations(mockContext) + expect(result).toBe(false) + }) + + test('should skip loops that are already completed', async () => { + mockContext.completedLoops.add('loop-1') + const result = await manager.processLoopIterations(mockContext) + expect(result).toBe(false) + }) + + test('should skip loops where loop block has not been executed', async () => { + // Loop block not in executed blocks + const result = await manager.processLoopIterations(mockContext) + expect(result).toBe(false) + }) + + test('should skip loops where not all blocks have been executed', async () => { + mockContext.executedBlocks.add('loop-1') + mockContext.executedBlocks.add('block-1') + // block-2 not executed yet + + const result = await manager.processLoopIterations(mockContext) + expect(result).toBe(false) + }) + + test('should reset blocks and continue iteration when not at max iterations', async () => { + // Set up as if we've completed one iteration + mockContext.executedBlocks.add('loop-1') + mockContext.executedBlocks.add('block-1') + mockContext.executedBlocks.add('block-2') + mockContext.loopIterations.set('loop-1', 1) // First iteration completed + + // Add some block states to verify they get reset + mockContext.blockStates.set('block-1', { + output: { response: { result: 'test' } }, + executed: true, + executionTime: 100, + }) + mockContext.blockStates.set('block-2', { + output: { response: { result: 'test2' } }, + executed: true, + executionTime: 200, + }) + + const result = await manager.processLoopIterations(mockContext) + + expect(result).toBe(false) // Not at max iterations yet + + // Verify blocks were reset + expect(mockContext.executedBlocks.has('block-1')).toBe(false) + expect(mockContext.executedBlocks.has('block-2')).toBe(false) + expect(mockContext.executedBlocks.has('loop-1')).toBe(false) // Loop block also reset + + // Verify block states were cleared + expect(mockContext.blockStates.has('block-1')).toBe(false) + expect(mockContext.blockStates.has('block-2')).toBe(false) + expect(mockContext.blockStates.has('loop-1')).toBe(false) + + // Verify blocks were removed from active execution path + expect(mockContext.activeExecutionPath.has('block-1')).toBe(false) + expect(mockContext.activeExecutionPath.has('block-2')).toBe(false) + }) + + test('should complete loop and activate end connections when max iterations reached', async () => { + // Set up as if we've completed all iterations + mockContext.executedBlocks.add('loop-1') + mockContext.executedBlocks.add('block-1') + mockContext.executedBlocks.add('block-2') + mockContext.loopIterations.set('loop-1', 3) // Max iterations reached + + // Set up loop execution state with some results + mockContext.loopExecutions = new Map() + mockContext.loopExecutions.set('loop-1', { + maxIterations: 3, + loopType: 'for', + forEachItems: null, + executionResults: new Map([ + ['iteration_0', { iteration: { 'block-1': { response: { result: 'result1' } } } }], + ['iteration_1', { iteration: { 'block-1': { response: { result: 'result2' } } } }], + ['iteration_2', { iteration: { 'block-1': { response: { result: 'result3' } } } }], + ]), + currentIteration: 3, + }) + + const result = await manager.processLoopIterations(mockContext) + + expect(result).toBe(true) // Loop reached max iterations + + // Verify loop was marked as completed + expect(mockContext.completedLoops.has('loop-1')).toBe(true) + + // Verify loop block state was updated with aggregated results + const loopBlockState = mockContext.blockStates.get('loop-1') + expect(loopBlockState).toBeDefined() + expect(loopBlockState?.output.response.completed).toBe(true) + expect(loopBlockState?.output.response.results).toHaveLength(3) + + // Verify end connection was activated + expect(mockContext.activeExecutionPath.has('after-loop')).toBe(true) + }) + + test('should handle forEach loops with array items', async () => { + const forEachLoop = createForEachLoop(['item1', 'item2', 'item3']) + manager = new LoopManager({ 'loop-1': forEachLoop }) + mockContext.workflow!.loops['loop-1'] = forEachLoop + + // Set up as if we've completed all iterations + mockContext.executedBlocks.add('loop-1') + mockContext.executedBlocks.add('block-1') + mockContext.executedBlocks.add('block-2') + mockContext.loopIterations.set('loop-1', 3) // All items processed + + // Store items in context as the loop handler would + mockContext.loopItems.set('loop-1_items', ['item1', 'item2', 'item3']) + + const result = await manager.processLoopIterations(mockContext) + + expect(result).toBe(true) // Loop completed + expect(mockContext.completedLoops.has('loop-1')).toBe(true) + + const loopBlockState = mockContext.blockStates.get('loop-1') + expect(loopBlockState?.output.response.loopType).toBe('forEach') + expect(loopBlockState?.output.response.maxIterations).toBe(3) + }) + + test('should handle forEach loops with object items', async () => { + const items = { key1: 'value1', key2: 'value2' } + const forEachLoop = createForEachLoop(items) + manager = new LoopManager({ 'loop-1': forEachLoop }) + mockContext.workflow!.loops['loop-1'] = forEachLoop + + // Set up as if we've completed all iterations + mockContext.executedBlocks.add('loop-1') + mockContext.executedBlocks.add('block-1') + mockContext.executedBlocks.add('block-2') + mockContext.loopIterations.set('loop-1', 2) // All items processed + + // Store items in context as the loop handler would + mockContext.loopItems.set('loop-1_items', items) + + const result = await manager.processLoopIterations(mockContext) + + expect(result).toBe(true) // Loop completed + expect(mockContext.completedLoops.has('loop-1')).toBe(true) + + const loopBlockState = mockContext.blockStates.get('loop-1') + expect(loopBlockState?.output.response.maxIterations).toBe(2) + }) + + test('should handle forEach loops with string items', async () => { + const forEachLoop = createForEachLoop('["a", "b", "c"]') // JSON string + manager = new LoopManager({ 'loop-1': forEachLoop }) + mockContext.workflow!.loops['loop-1'] = forEachLoop + + // Set up as if we've completed all iterations + mockContext.executedBlocks.add('loop-1') + mockContext.executedBlocks.add('block-1') + mockContext.executedBlocks.add('block-2') + mockContext.loopIterations.set('loop-1', 3) // All items processed + + const result = await manager.processLoopIterations(mockContext) + + expect(result).toBe(true) // Loop completed + expect(mockContext.completedLoops.has('loop-1')).toBe(true) + }) + }) + + describe('storeIterationResult', () => { + test('should create new loop state if none exists', () => { + const output = { response: { result: 'test result' } } + + manager.storeIterationResult(mockContext, 'loop-1', 0, 'block-1', output) + + expect(mockContext.loopExecutions).toBeDefined() + const loopState = mockContext.loopExecutions!.get('loop-1') + expect(loopState).toBeDefined() + expect(loopState?.maxIterations).toBe(3) + expect(loopState?.loopType).toBe('for') + expect(loopState?.executionResults.get('iteration_0')).toEqual({ + 'block-1': output, + }) + }) + + test('should add to existing loop state', () => { + // Initialize loop state + mockContext.loopExecutions = new Map() + mockContext.loopExecutions.set('loop-1', { + maxIterations: 3, + loopType: 'for', + forEachItems: null, + executionResults: new Map(), + currentIteration: 0, + }) + + const output1 = { response: { result: 'result1' } } + const output2 = { response: { result: 'result2' } } + + manager.storeIterationResult(mockContext, 'loop-1', 0, 'block-1', output1) + manager.storeIterationResult(mockContext, 'loop-1', 0, 'block-2', output2) + + const loopState = mockContext.loopExecutions.get('loop-1') + const iterationResults = loopState?.executionResults.get('iteration_0') + + expect(iterationResults).toEqual({ + 'block-1': output1, + 'block-2': output2, + }) + }) + + test('should handle forEach loop state creation', () => { + const forEachLoop = createForEachLoop(['item1', 'item2']) + manager = new LoopManager({ 'loop-1': forEachLoop }) + + const output = { response: { result: 'test result' } } + + manager.storeIterationResult(mockContext, 'loop-1', 0, 'block-1', output) + + const loopState = mockContext.loopExecutions!.get('loop-1') + expect(loopState?.loopType).toBe('forEach') + expect(loopState?.forEachItems).toEqual(['item1', 'item2']) + }) + }) + + describe('getLoopIndex', () => { + test('should return current iteration for existing loop', () => { + mockContext.loopIterations.set('loop-1', 2) + + const index = manager.getLoopIndex('loop-1', 'block-1', mockContext) + + expect(index).toBe(2) + }) + + test('should return 0 for non-existent loop iteration', () => { + const index = manager.getLoopIndex('non-existent', 'block-1', mockContext) + + expect(index).toBe(0) + }) + + test('should return 0 for unknown loop', () => { + const unknownManager = new LoopManager({}) + const index = unknownManager.getLoopIndex('unknown', 'block-1', mockContext) + + expect(index).toBe(0) + }) + }) + + describe('getIterations', () => { + test('should return iterations for existing loop', () => { + expect(manager.getIterations('loop-1')).toBe(3) + }) + + test('should return default iterations for non-existent loop', () => { + expect(manager.getIterations('non-existent')).toBe(5) // default + }) + }) + + describe('getCurrentItem', () => { + test('should return current item for loop', () => { + mockContext.loopItems.set('loop-1', 'current-item') + + const item = manager.getCurrentItem('loop-1', mockContext) + + expect(item).toBe('current-item') + }) + + test('should return undefined for non-existent loop item', () => { + const item = manager.getCurrentItem('non-existent', mockContext) + + expect(item).toBeUndefined() + }) + }) + + describe('allBlocksExecuted (private method testing through processLoopIterations)', () => { + test('should handle router blocks with selected paths', async () => { + // Create a workflow with a router block inside the loop + const workflow = createWorkflowWithLoop(createBasicLoop()) + workflow.blocks[2].metadata!.id = 'router' // Make block-1 a router + workflow.connections = [ + { source: 'starter', target: 'loop-1' }, + { source: 'loop-1', target: 'block-1', sourceHandle: 'loop-start-source' }, + { source: 'block-1', target: 'block-2' }, // Router selects block-2 + { source: 'block-1', target: 'alternative-block' }, // Alternative path + { source: 'block-2', target: 'loop-1' }, + { source: 'loop-1', target: 'after-loop', sourceHandle: 'loop-end-source' }, + ] + + mockContext.workflow = workflow + mockContext.executedBlocks.add('loop-1') + mockContext.executedBlocks.add('block-1') + mockContext.executedBlocks.add('block-2') + mockContext.decisions.router.set('block-1', 'block-2') // Router selected block-2 + mockContext.loopIterations.set('loop-1', 1) + + const result = await manager.processLoopIterations(mockContext) + + // Should process the iteration since all reachable blocks are executed + expect(result).toBe(false) // Not at max iterations yet + }) + + test('should handle condition blocks with selected paths', async () => { + // Create a workflow with a condition block inside the loop + const workflow = createWorkflowWithLoop(createBasicLoop()) + workflow.blocks[2].metadata!.id = 'condition' // Make block-1 a condition + workflow.connections = [ + { source: 'starter', target: 'loop-1' }, + { source: 'loop-1', target: 'block-1', sourceHandle: 'loop-start-source' }, + { source: 'block-1', target: 'block-2', sourceHandle: 'condition-true' }, + { source: 'block-1', target: 'alternative-block', sourceHandle: 'condition-false' }, + { source: 'block-2', target: 'loop-1' }, + { source: 'loop-1', target: 'after-loop', sourceHandle: 'loop-end-source' }, + ] + + mockContext.workflow = workflow + mockContext.executedBlocks.add('loop-1') + mockContext.executedBlocks.add('block-1') + mockContext.executedBlocks.add('block-2') + mockContext.decisions.condition.set('block-1', 'true') // Condition selected true path + mockContext.loopIterations.set('loop-1', 1) + + const result = await manager.processLoopIterations(mockContext) + + // Should process the iteration since all reachable blocks are executed + expect(result).toBe(false) // Not at max iterations yet + }) + + test('should handle error connections properly', async () => { + // Create a workflow with error handling inside the loop + const workflow = createWorkflowWithLoop(createBasicLoop()) + workflow.connections = [ + { source: 'starter', target: 'loop-1' }, + { source: 'loop-1', target: 'block-1', sourceHandle: 'loop-start-source' }, + { source: 'block-1', target: 'block-2', sourceHandle: 'source' }, + { source: 'block-1', target: 'error-handler', sourceHandle: 'error' }, + { source: 'block-2', target: 'loop-1' }, + { source: 'loop-1', target: 'after-loop', sourceHandle: 'loop-end-source' }, + ] + + mockContext.workflow = workflow + mockContext.executedBlocks.add('loop-1') + mockContext.executedBlocks.add('block-1') + mockContext.executedBlocks.add('block-2') + + // Set block-1 to have no error (successful execution) + mockContext.blockStates.set('block-1', { + output: { response: { result: 'success' } }, + executed: true, + executionTime: 100, + }) + + mockContext.loopIterations.set('loop-1', 1) + + const result = await manager.processLoopIterations(mockContext) + + // Should process the iteration since the success path was followed + expect(result).toBe(false) // Not at max iterations yet + }) + + test('should handle blocks with errors following error paths', async () => { + // Create a workflow with error handling inside the loop + const workflow = createWorkflowWithLoop(createBasicLoop()) + workflow.blocks.push({ + id: 'error-handler', + position: { x: 350, y: 100 }, + metadata: { id: 'function', name: 'Error Handler' }, + config: { tool: 'function', params: {} }, + inputs: {}, + outputs: {}, + enabled: true, + }) + workflow.loops['loop-1'].nodes.push('error-handler') + workflow.connections = [ + { source: 'starter', target: 'loop-1' }, + { source: 'loop-1', target: 'block-1', sourceHandle: 'loop-start-source' }, + { source: 'block-1', target: 'block-2', sourceHandle: 'source' }, + { source: 'block-1', target: 'error-handler', sourceHandle: 'error' }, + { source: 'error-handler', target: 'loop-1' }, + { source: 'block-2', target: 'loop-1' }, + { source: 'loop-1', target: 'after-loop', sourceHandle: 'loop-end-source' }, + ] + + mockContext.workflow = workflow + mockContext.executedBlocks.add('loop-1') + mockContext.executedBlocks.add('block-1') + mockContext.executedBlocks.add('error-handler') + + // Set block-1 to have an error + mockContext.blockStates.set('block-1', { + output: { + response: { error: 'Something went wrong' }, + error: 'Something went wrong', + }, + executed: true, + executionTime: 100, + }) + + mockContext.loopIterations.set('loop-1', 1) + + const result = await manager.processLoopIterations(mockContext) + + // Should process the iteration since the error path was followed + expect(result).toBe(false) // Not at max iterations yet + }) + }) + + describe('edge cases and error handling', () => { + test('should handle empty loop nodes array', async () => { + const emptyLoop = createBasicLoop({ nodes: [] }) + manager = new LoopManager({ 'loop-1': emptyLoop }) + mockContext.workflow!.loops['loop-1'] = emptyLoop + + mockContext.executedBlocks.add('loop-1') + mockContext.loopIterations.set('loop-1', 1) + + const result = await manager.processLoopIterations(mockContext) + + // Should complete immediately since there are no blocks to execute + expect(result).toBe(false) + }) + + test('should handle missing workflow in context', async () => { + mockContext.workflow = undefined + + const result = await manager.processLoopIterations(mockContext) + + expect(result).toBe(false) + }) + + test('should handle missing loop configuration', async () => { + // Remove loop from workflow + if (mockContext.workflow) { + mockContext.workflow.loops = {} + } + + mockContext.executedBlocks.add('loop-1') + mockContext.executedBlocks.add('block-1') + mockContext.executedBlocks.add('block-2') + mockContext.loopIterations.set('loop-1', 1) + + const result = await manager.processLoopIterations(mockContext) + + // Should skip processing since loop config is missing + expect(result).toBe(false) + }) + + test('should handle forEach loop with invalid JSON string', async () => { + const forEachLoop = createForEachLoop('invalid json') + manager = new LoopManager({ 'loop-1': forEachLoop }) + mockContext.workflow!.loops['loop-1'] = forEachLoop + + mockContext.executedBlocks.add('loop-1') + mockContext.executedBlocks.add('block-1') + mockContext.executedBlocks.add('block-2') + mockContext.loopIterations.set('loop-1', 1) + + const result = await manager.processLoopIterations(mockContext) + + // Should handle gracefully and use default iterations + expect(result).toBe(false) + }) + + test('should handle forEach loop with null items', async () => { + const forEachLoop = createForEachLoop(null) + manager = new LoopManager({ 'loop-1': forEachLoop }) + mockContext.workflow!.loops['loop-1'] = forEachLoop + + mockContext.executedBlocks.add('loop-1') + mockContext.executedBlocks.add('block-1') + mockContext.executedBlocks.add('block-2') + mockContext.loopIterations.set('loop-1', 1) + + const result = await manager.processLoopIterations(mockContext) + + // Should handle gracefully + expect(result).toBe(false) + }) + }) + + describe('integration scenarios', () => { + test('should handle multiple loops in workflow', async () => { + const loops = { + 'loop-1': createBasicLoop({ iterations: 2 }), + 'loop-2': createBasicLoop({ id: 'loop-2', nodes: ['block-3'], iterations: 3 }), + } + manager = new LoopManager(loops) + + // Set up context for both loops + mockContext.loopIterations.set('loop-1', 2) // loop-1 at max + mockContext.loopIterations.set('loop-2', 1) // loop-2 not at max + + mockContext.executedBlocks.add('loop-1') + mockContext.executedBlocks.add('block-1') + mockContext.executedBlocks.add('block-2') + + // Set up loop execution states + mockContext.loopExecutions = new Map() + mockContext.loopExecutions.set('loop-1', { + maxIterations: 2, + loopType: 'for', + forEachItems: null, + executionResults: new Map([ + ['iteration_0', { iteration: { 'block-1': { response: { result: 'result1' } } } }], + ['iteration_1', { iteration: { 'block-1': { response: { result: 'result2' } } } }], + ]), + currentIteration: 2, + }) + + const result = await manager.processLoopIterations(mockContext) + + expect(result).toBe(true) // loop-1 reached max iterations + expect(mockContext.completedLoops.has('loop-1')).toBe(true) + expect(mockContext.completedLoops.has('loop-2')).toBe(false) + }) + + test('should handle nested loop scenarios (loop inside another loop)', async () => { + // This tests the scenario where a loop block might be inside another loop + const outerLoop = createBasicLoop({ + id: 'outer-loop', + nodes: ['inner-loop', 'block-1'], + iterations: 2, + }) + const innerLoop = createBasicLoop({ + id: 'inner-loop', + nodes: ['block-2'], + iterations: 3, + }) + + const loops = { + 'outer-loop': outerLoop, + 'inner-loop': innerLoop, + } + manager = new LoopManager(loops) + + // Set up context - inner loop completed, outer loop still running + mockContext.loopIterations.set('outer-loop', 1) + mockContext.loopIterations.set('inner-loop', 3) + + mockContext.executedBlocks.add('outer-loop') + mockContext.executedBlocks.add('inner-loop') + mockContext.executedBlocks.add('block-1') + mockContext.executedBlocks.add('block-2') + + mockContext.completedLoops.add('inner-loop') + + const result = await manager.processLoopIterations(mockContext) + + // Should reset outer loop for next iteration + expect(result).toBe(false) + expect(mockContext.executedBlocks.has('inner-loop')).toBe(false) + expect(mockContext.executedBlocks.has('block-1')).toBe(false) + }) + }) +}) diff --git a/apps/sim/executor/loops.ts b/apps/sim/executor/loops.ts index 287f3a0079..ffb02df619 100644 --- a/apps/sim/executor/loops.ts +++ b/apps/sim/executor/loops.ts @@ -6,6 +6,7 @@ const logger = createLogger('LoopManager') /** * Manages loop detection, iteration limits, and state resets. + * With the new loop block approach, this class is significantly simplified. */ export class LoopManager { constructor( @@ -15,7 +16,7 @@ export class LoopManager { /** * Processes all loops and checks if any need to be iterated. - * Resets blocks in loops that should iterate again. + * This is called after each execution layer to handle loop iterations. * * @param context - Current execution context * @returns Whether any loop has reached its maximum iterations @@ -28,144 +29,144 @@ export class LoopManager { // Check each loop to see if it should iterate for (const [loopId, loop] of Object.entries(this.loops)) { - // Get the loop type (default to 'for') - const loopType = loop.loopType || 'for' - const currentIteration = context.loopIterations.get(loopId) || 0 + // Skip if this loop has already been marked as completed + if (context.completedLoops.has(loopId)) { + continue + } - // Handle forEach loop - if (loopType === 'forEach') { - // Get the items to iterate over if we haven't already processed them into an array/object - if ( - !loop.forEachItems || - typeof loop.forEachItems === 'string' || - !(Array.isArray(loop.forEachItems) || typeof loop.forEachItems === 'object') - ) { - // Evaluate the forEach items expression - const items = await this.evalForEachItems(loopId, loop, context) + // Check if the loop block itself has been executed + const loopBlockExecuted = context.executedBlocks.has(loopId) + if (!loopBlockExecuted) { + // Loop block hasn't been executed yet, skip processing + continue + } - // Store the evaluated items for future iterations - if (Array.isArray(items) || (typeof items === 'object' && items !== null)) { - loop.forEachItems = items - } else { - // Default to empty array if we couldn't get any valid items - loop.forEachItems = [] - } - } + // Check if all blocks in the loop have been executed + const allBlocksInLoopExecuted = this.allBlocksExecuted(loop.nodes, context) - // For forEach, convert to array if it's an object - const items = Array.isArray(loop.forEachItems) - ? loop.forEachItems - : Object.entries(loop.forEachItems as Record) + logger.info(`Loop ${loopId} - Processing iteration check`) + logger.info(` Loop block executed: ${loopBlockExecuted}`) + logger.info(` All blocks executed: ${allBlocksInLoopExecuted}`) + logger.info(` Blocks in loop: ${loop.nodes.join(', ')}`) + logger.info(` Current iteration: ${context.loopIterations.get(loopId) || 0}`) - // If we've processed all items or hit max iterations, mark loop as completed - if (currentIteration >= items.length || currentIteration >= loop.iterations) { - if (currentIteration >= items.length) { - hasLoopReachedMaxIterations = true - } else { - hasLoopReachedMaxIterations = true - } - - // Now that the loop is complete, activate only external paths - this.activateExternalPaths(loopId, loop, context) - continue - } - - // Check if all blocks in the loop have been executed - const allExecuted = this.allBlocksExecuted(loop.nodes, context) - - if (allExecuted) { - // Get current item to process in this iteration - const currentItem = items[currentIteration] - - // Store the current item in the context for blocks to access via - context.loopItems.set(loopId, currentItem) - - // IMPORTANT: We're incrementing the iteration counter AFTER storing the current item - // But BEFORE resetting the blocks for next iteration - // This ensures that when blocks execute in the new iteration, they'll get the correct index - context.loopIterations.set(loopId, currentIteration + 1) - - // Check if we've now reached iterations limit after incrementing - if (currentIteration + 1 >= items.length || currentIteration + 1 >= loop.iterations) { - hasLoopReachedMaxIterations = true - - // IMPORTANT: If we've completed all iterations, activate the external paths - // This is different from the previous approach - we activate external paths - // at the end of the last iteration - this.activateExternalPaths(loopId, loop, context) - } else { - // We have more iterations to go, reset the blocks - // Reset ALL blocks in the loop for the next iteration - for (const nodeId of loop.nodes) { - // Remove from executed blocks - context.executedBlocks.delete(nodeId) - - // Make sure it's in the active execution path - context.activeExecutionPath.add(nodeId) - } - - // Make sure the first block in the loop is marked as executable - const entryBlock = this.findEntryBlock(loop.nodes, context) - if (loop.nodes.length > 0 && entryBlock) { - context.activeExecutionPath.add(entryBlock) - } - } - } else { - // Not all blocks in the loop have been executed yet - // We need to activate the next block(s) in the loop sequence - this.activateNextBlocksInLoop(loopId, loop, context) - } - } else { - // Original logic for 'for' loops - // Get current iteration count + if (allBlocksInLoopExecuted) { + // All blocks in the loop have been executed const currentIteration = context.loopIterations.get(loopId) || 0 - // If we've hit the iterations count, skip this loop and mark flag - if (currentIteration >= loop.iterations) { - hasLoopReachedMaxIterations = true - - // Activate external paths from loop blocks when the loop is completed - this.activateExternalPaths(loopId, loop, context) - continue + // Store the results from this iteration before potentially resetting blocks + const iterationResults: Record = {} + for (const nodeId of loop.nodes) { + const blockState = context.blockStates.get(nodeId) + if (blockState?.output) { + iterationResults[nodeId] = blockState.output + } } - // Check if all blocks in the loop have been executed - const allExecuted = this.allBlocksExecuted(loop.nodes, context) + // Store the iteration results + if (Object.keys(iterationResults).length > 0) { + this.storeIterationResult( + context, + loopId, + currentIteration - 1, + 'iteration', + iterationResults + ) + } - if (allExecuted) { - // IMPORTANT: Increment the counter BEFORE resetting blocks for the next iteration - // This ensures the next iteration will show the correct index value - context.loopIterations.set(loopId, currentIteration + 1) + // The loop block will handle incrementing the iteration when it executes next + // We just need to reset the blocks so they can run again - // Check if we've now reached iterations limit after incrementing - if (currentIteration + 1 >= loop.iterations) { - hasLoopReachedMaxIterations = true + // Determine the maximum iterations + let maxIterations = loop.iterations || this.defaultIterations - // IMPORTANT: If we've completed all iterations, activate the external paths - // This is different from the previous approach - we activate external paths - // at the end of the last iteration - this.activateExternalPaths(loopId, loop, context) + // For forEach loops, check the actual items length + if (loop.loopType === 'forEach' && loop.forEachItems) { + // First check if the items have already been evaluated and stored by the loop handler + const storedItems = context.loopItems.get(`${loopId}_items`) + if (storedItems) { + const itemsLength = Array.isArray(storedItems) + ? storedItems.length + : Object.keys(storedItems).length + maxIterations = Math.min(maxIterations, itemsLength) + logger.info( + `Loop ${loopId} using stored items length: ${itemsLength} (max iterations: ${maxIterations})` + ) } else { - // Reset ALL blocks in the loop, not just blocks after the entry - for (const nodeId of loop.nodes) { - // Remove from executed blocks - context.executedBlocks.delete(nodeId) - - // Make sure it's in the active execution path - context.activeExecutionPath.add(nodeId) - } - - // Important: Make sure the first block in the loop is marked as executable - const entryBlock = this.findEntryBlock(loop.nodes, context) - if (loop.nodes.length > 0 && entryBlock) { - // Make sure it's in the active path - context.activeExecutionPath.add(entryBlock) + // Fallback to parsing the forEachItems string if it's not a reference + const itemsLength = this.getItemsLength(loop.forEachItems) + if (itemsLength > 0) { + maxIterations = Math.min(maxIterations, itemsLength) } } + } + + logger.info(`Loop ${loopId} - Current: ${currentIteration}, Max: ${maxIterations}`) + + // Check if we've completed all iterations + // The loop handler increments the counter after setting up each iteration + // So if currentIteration equals maxIterations, we've completed all iterations + if (currentIteration >= maxIterations) { + // This was the last iteration + hasLoopReachedMaxIterations = true + logger.info(`Loop ${loopId} has completed all ${maxIterations} iterations`) + + // Aggregate results from all iterations using stored results + const results = [] + const loopState = context.loopExecutions?.get(loopId) + if (loopState) { + for (let i = 0; i < maxIterations; i++) { + const result = loopState.executionResults.get(`iteration_${i}`) + if (result?.iteration) { + results.push(result.iteration) + } + } + } + + // Store the aggregated results in the loop block's state so subsequent blocks can reference them + const aggregatedOutput = { + response: { + loopId, + currentIteration: maxIterations - 1, // Last iteration index + maxIterations, + loopType: loop.loopType || 'for', + completed: true, + results, + message: `Completed all ${maxIterations} iterations`, + }, + } + + // Store the aggregated results in context so blocks connected to loop-end-source can access them + context.blockStates.set(loopId, { + output: aggregatedOutput, + executed: true, + executionTime: 0, // Loop coordination doesn't have meaningful execution time + }) + + // Mark this loop as completed + context.completedLoops.add(loopId) + + // Activate the loop-end-source connections to continue workflow after loop + const loopEndConnections = + context.workflow?.connections.filter( + (conn) => conn.source === loopId && conn.sourceHandle === 'loop-end-source' + ) || [] + + for (const conn of loopEndConnections) { + context.activeExecutionPath.add(conn.target) + logger.info(`Activated post-loop path from ${loopId} to ${conn.target}`) + } + + logger.info(`Loop ${loopId} - Completed and activated end connections`) } else { - // Not all blocks in the loop have been executed yet - // We need to activate the next block(s) in the loop sequence - this.activateNextBlocksInLoop(loopId, loop, context) + // More iterations to go - reset the blocks inside the loop + this.resetLoopBlocks(loopId, loop, context) + + // Reset the loop block itself so it can execute again + context.executedBlocks.delete(loopId) + context.blockStates.delete(loopId) + + logger.info(`Loop ${loopId} - Reset for iteration ${currentIteration}`) } } } @@ -174,100 +175,111 @@ export class LoopManager { } /** - * Activates only paths that go from loop nodes to nodes outside the loop. - * This is called when a loop completes all iterations. + * Helper to get the length of items for forEach loops + */ + private getItemsLength(forEachItems: any): number { + if (Array.isArray(forEachItems)) { + return forEachItems.length + } + if (typeof forEachItems === 'object' && forEachItems !== null) { + return Object.keys(forEachItems).length + } + if (typeof forEachItems === 'string') { + // Try to parse if it's a JSON string + try { + const parsed = JSON.parse(forEachItems) + if (Array.isArray(parsed)) { + return parsed.length + } + if (typeof parsed === 'object' && parsed !== null) { + return Object.keys(parsed).length + } + } catch { + // Not valid JSON + } + } + return 0 + } + + /** + * Resets all blocks within a loop for the next iteration. * * @param loopId - ID of the loop * @param loop - The loop configuration * @param context - Current execution context */ - private activateExternalPaths( - loopId: string, - loop: SerializedLoop, - context: ExecutionContext - ): void { - if (!context.workflow) return + private resetLoopBlocks(loopId: string, loop: SerializedLoop, context: ExecutionContext): void { + logger.info(`Resetting blocks for loop ${loopId}`) - // Mark this loop as completed - if (!context.completedLoops) { - context.completedLoops = new Set() - } - context.completedLoops.add(loopId) - - // First, identify all paths that lead to outside the loop - // We need to ensure only these are activated on loop completion - const externalTargets = new Set() - - // Build a map of outgoing connections from each node in the loop + // Reset all blocks in the loop for (const nodeId of loop.nodes) { - // Get all outgoing connections from this node - const outgoingConnections = context.workflow.connections.filter( - (conn) => conn.source === nodeId - ) + // Remove from executed blocks + context.executedBlocks.delete(nodeId) - // For each outgoing connection, check if it leads outside the loop - for (const conn of outgoingConnections) { - // If target is not in the loop, it's an external path - if (!loop.nodes.includes(conn.target)) { - externalTargets.add(conn.target) - } - } + // Clear the block state + context.blockStates.delete(nodeId) + + // Remove from active execution path + context.activeExecutionPath.delete(nodeId) + + // Clear any routing decisions for this block + context.decisions.router.delete(nodeId) + context.decisions.condition.delete(nodeId) + + logger.info(`Reset block ${nodeId} in loop ${loopId} for next iteration`) } - // Now, only activate the identified external targets - for (const target of externalTargets) { - // Find all connections leading to this target from nodes in the loop - const incomingConnections = context.workflow.connections.filter( - (conn) => loop.nodes.includes(conn.source) && conn.target === target - ) + logger.info(`After reset - executed blocks: ${Array.from(context.executedBlocks).join(', ')}`) + logger.info(`After reset - active paths: ${Array.from(context.activeExecutionPath).join(', ')}`) + } - for (const conn of incomingConnections) { - const sourceBlockId = conn.source - const blockState = context.blockStates.get(sourceBlockId) - const hasError = - blockState?.output?.error !== undefined || - blockState?.output?.response?.error !== undefined - - // Apply connection type rules, but only activate for the final iteration - if (conn.sourceHandle === 'error') { - // Only activate error paths if there was an error - if (hasError) { - context.activeExecutionPath.add(target) - } - } else if (conn.sourceHandle === 'source' || !conn.sourceHandle) { - // Only activate regular paths if there was no error - if (!hasError) { - context.activeExecutionPath.add(target) - } - } else if (conn.sourceHandle?.startsWith('condition-')) { - // For condition connections, check if this was the selected condition - const conditionId = conn.sourceHandle.replace('condition-', '') - const selectedCondition = context.decisions.condition.get(sourceBlockId) - - if (conditionId === selectedCondition) { - context.activeExecutionPath.add(target) - } - } else if (sourceBlockId === conn.source) { - // For router blocks, check if this was the selected target - const sourceBlock = context.workflow.blocks.find((b) => b.id === sourceBlockId) - if (sourceBlock?.metadata?.id === 'router') { - const selectedTarget = context.decisions.router.get(sourceBlockId) - - if (selectedTarget === target) { - context.activeExecutionPath.add(target) - } - } else { - // For any other connection type - context.activeExecutionPath.add(target) - } - } - } + /** + * Stores the result of a loop iteration. + */ + storeIterationResult( + context: ExecutionContext, + loopId: string, + iterationIndex: number, + blockId: string, + output: any + ): void { + if (!context.loopExecutions) { + context.loopExecutions = new Map() } + + let loopState = context.loopExecutions.get(loopId) + if (!loopState) { + const loop = this.loops[loopId] + const loopType = loop?.loopType === 'forEach' ? 'forEach' : 'for' + const forEachItems = loop?.forEachItems + + loopState = { + maxIterations: loop?.iterations || this.defaultIterations, + loopType, + forEachItems: + Array.isArray(forEachItems) || (typeof forEachItems === 'object' && forEachItems !== null) + ? forEachItems + : null, + executionResults: new Map(), + currentIteration: 0, + } + context.loopExecutions.set(loopId, loopState) + } + + // Get or create the iteration results object + const iterationKey = `iteration_${iterationIndex}` + let iterationResults = loopState.executionResults.get(iterationKey) + if (!iterationResults) { + iterationResults = {} + loopState.executionResults.set(iterationKey, iterationResults) + } + + // Store the block's output for this iteration + iterationResults[blockId] = output } /** * Gets the correct loop index based on the current block being executed. - * Accounts for position within the loop cycle to provide accurate index. * * @param loopId - ID of the loop * @param blockId - ID of the block requesting the index @@ -278,258 +290,8 @@ export class LoopManager { const loop = this.loops[loopId] if (!loop) return 0 - // Get the current iteration counter from context - const iterationCounter = context.loopIterations.get(loopId) || 0 - - // Simply return the current iteration counter - // Since we're updating the iteration counter BEFORE resetting blocks, - // the counter will already be at the correct value for the current iteration - return iterationCounter - } - - /** - * Evaluates the forEach items string or retrieves items for a forEach loop. - * - * @param loopId - ID of the loop - * @param loop - Loop configuration - * @param context - Current execution context - * @returns Items to iterate over (array or object) - */ - private async evalForEachItems( - loopId: string, - loop: SerializedLoop, - context: ExecutionContext - ): Promise | undefined> { - // If forEachItems is not set, return empty array - if (!loop.forEachItems) { - return [] - } - - // If we already have items as an array or object, return them directly - if ( - Array.isArray(loop.forEachItems) || - (typeof loop.forEachItems === 'object' && loop.forEachItems !== null) - ) { - return loop.forEachItems as any[] | Record - } - - // If we have forEachItems as a string, try to evaluate it as an expression - if (typeof loop.forEachItems === 'string') { - try { - // Skip comments or empty expressions - const trimmedExpression = loop.forEachItems.trim() - if (trimmedExpression.startsWith('//') || trimmedExpression === '') { - return [] - } - - // First check if it's valid JSON (array or object) - if (trimmedExpression.startsWith('[') || trimmedExpression.startsWith('{')) { - try { - // Try to parse as JSON first - // Handle both JSON format (double quotes) and JS format (single quotes) - const normalizedExpression = trimmedExpression - .replace(/'/g, '"') // Replace all single quotes with double quotes - .replace(/(\w+):/g, '"$1":') // Convert property names to double-quoted strings - .replace(/,\s*]/g, ']') // Remove trailing commas before closing brackets - .replace(/,\s*}/g, '}') // Remove trailing commas before closing braces - - return JSON.parse(normalizedExpression) - } catch (jsonError) { - logger.debug(`Error parsing JSON for loop ${loopId}:`, jsonError) - // If JSON parsing fails, continue with expression evaluation - } - } - - // If not valid JSON or JSON parsing failed, try to evaluate as an expression - const result = new Function('context', `return ${loop.forEachItems}`)(context) - - // If the result is an array or object, return it - if (Array.isArray(result) || (typeof result === 'object' && result !== null)) { - return result - } - - // If it's a primitive, wrap it in an array - if (result !== undefined) { - return [result] - } - - return [] - } catch (e) { - logger.error(`Error evaluating forEach items for loop ${loopId}:`, e) - return [] - } - } - - // As a fallback, try to find the first non-empty array or object in the context - for (const [_blockId, blockState] of context.blockStates.entries()) { - const output = blockState.output?.response - if (output) { - // Look for arrays or objects in the response that could be iterated over - for (const [_key, value] of Object.entries(output)) { - if (Array.isArray(value) && value.length > 0) { - return value - } - if (typeof value === 'object' && value !== null && Object.keys(value).length > 0) { - return value - } - } - } - } - - return [] - } - - /** - * Finds the entry block for a loop (the one that should be executed first). - * Typically the block with incoming connections from outside the loop. - * - * @param nodeIds - IDs of nodes in the loop - * @param context - Current execution context - * @returns ID of the entry block - */ - private findEntryBlock(nodeIds: string[], context: ExecutionContext): string | undefined { - // If there's only one node in the loop, it's the entry block - if (nodeIds.length === 1) { - return nodeIds[0] - } - - // Check which blocks have connections from outside the loop - const blocksWithExternalConnections = new Map< - string, - { - incomingExternal: number - outgoingExternal: number - incomingInternal: number - } - >() - - for (const nodeId of nodeIds) { - // Count connections coming from outside the loop - const externalIncomingCount = context.workflow?.connections.filter( - (conn) => conn.target === nodeId && !nodeIds.includes(conn.source) - ).length - - // Count connections going to outside the loop - const externalOutgoingCount = context.workflow?.connections.filter( - (conn) => conn.source === nodeId && !nodeIds.includes(conn.target) - ).length - - // Count internal incoming connections that aren't self-connections - const internalIncomingCount = context.workflow?.connections.filter( - (conn) => conn.target === nodeId && conn.source !== nodeId && nodeIds.includes(conn.source) - ).length - - blocksWithExternalConnections.set(nodeId, { - incomingExternal: externalIncomingCount || 0, - outgoingExternal: externalOutgoingCount || 0, - incomingInternal: internalIncomingCount || 0, - }) - } - - // First priority: blocks with incoming connections from outside the loop - const blocksWithExternalIncoming = nodeIds.filter( - (id) => blocksWithExternalConnections.get(id)?.incomingExternal! > 0 - ) - - if (blocksWithExternalIncoming.length > 0) { - // If multiple blocks have external incoming connections, - // prioritize the one with the most external incoming connections - return blocksWithExternalIncoming.sort( - (a, b) => - blocksWithExternalConnections.get(b)?.incomingExternal! - - blocksWithExternalConnections.get(a)?.incomingExternal! - )[0] - } - - // Second priority: look for the likely first node in a self-contained loop - // This is often the node that has the fewest internal incoming connections - // but might have outgoing connections to outside the loop - - // Sort blocks by fewest internal incoming connections - const sortedByInternalIncoming = [...nodeIds].sort( - (a, b) => - (blocksWithExternalConnections.get(a)?.incomingInternal || 0) - - (blocksWithExternalConnections.get(b)?.incomingInternal || 0) - ) - - // Among those with the fewest internal incoming connections, - // prioritize those with external outgoing connections - const candidatesWithFewestIncoming = sortedByInternalIncoming.filter( - (id) => - blocksWithExternalConnections.get(id)?.incomingInternal === - blocksWithExternalConnections.get(sortedByInternalIncoming[0])?.incomingInternal - ) - - if (candidatesWithFewestIncoming.length > 1) { - // Among these candidates, prioritize those with external outgoing connections - const withExternalOutgoing = candidatesWithFewestIncoming.filter( - (id) => blocksWithExternalConnections.get(id)?.outgoingExternal! > 0 - ) - - if (withExternalOutgoing.length > 0) { - return withExternalOutgoing[0] - } - } - - // If no better criteria found, return the first node with fewest internal incoming connections - return sortedByInternalIncoming[0] - } - - /** - * Checks if all blocks in a list have been executed. - * - * @param nodeIds - IDs of nodes to check - * @param context - Current execution context - * @returns Whether all blocks have been executed - */ - private allBlocksExecuted(nodeIds: string[], context: ExecutionContext): boolean { - // For single-node loops, ensure the node has been executed at least once - if (nodeIds.length === 1 && context.executedBlocks.has(nodeIds[0])) { - return true - } - - // For multi-node loops, ensure all nodes have been executed - return nodeIds.every((nodeId) => context.executedBlocks.has(nodeId)) - } - - /** - * Checks if a connection forms a feedback path in a loop. - * A feedback path points to an earlier block in the loop. - * - * @param connection - Connection to check - * @param blocks - All blocks in the workflow - * @returns Whether the connection forms a feedback path - */ - isFeedbackPath(connection: SerializedConnection, blocks: SerializedBlock[]): boolean { - // Self-loops are always feedback paths - if (connection.source === connection.target) { - return true - } - - for (const [_loopId, loop] of Object.entries(this.loops)) { - if (loop.nodes.includes(connection.source) && loop.nodes.includes(connection.target)) { - // For single-node loops, any connection to itself is a feedback path - if ( - loop.nodes.length === 1 && - loop.nodes[0] === connection.source && - connection.source === connection.target - ) { - return true - } - - const sourceIndex = loop.nodes.indexOf(connection.source) - const targetIndex = loop.nodes.indexOf(connection.target) - - if (targetIndex < sourceIndex) { - const sourceBlock = blocks.find((b) => b.id === connection.source) - const isCondition = sourceBlock?.metadata?.id === 'condition' - - return isCondition && connection.sourceHandle?.startsWith('condition-') === true - } - } - } - - return false + // Return the current iteration counter + return context.loopIterations.get(loopId) || 0 } /** @@ -554,57 +316,144 @@ export class LoopManager { } /** - * Activates the next blocks in the loop sequence when not all blocks have been executed. - * This ensures proper flow through the loop when PathTracker is prevented from activating within-loop paths. + * Checks if all blocks in a list have been executed. + * For routing blocks (condition/router), only checks if the selected path has been executed. * - * @param loopId - ID of the loop - * @param loop - The loop configuration + * @param nodeIds - IDs of nodes to check * @param context - Current execution context + * @returns Whether all blocks have been executed */ - private activateNextBlocksInLoop( - loopId: string, - loop: SerializedLoop, - context: ExecutionContext - ): void { - if (!context.workflow) return + private allBlocksExecuted(nodeIds: string[], context: ExecutionContext): boolean { + // Get all connections within the loop + const loopConnections = + context.workflow?.connections.filter( + (conn) => nodeIds.includes(conn.source) && nodeIds.includes(conn.target) + ) || [] - // Find which blocks in the loop have been executed - const executedLoopBlocks = new Set( - loop.nodes.filter((nodeId) => context.executedBlocks.has(nodeId)) - ) - - if (executedLoopBlocks.size === 0) { - // If no blocks have been executed yet, activate the entry block - const entryBlock = this.findEntryBlock(loop.nodes, context) - if (entryBlock) { - context.activeExecutionPath.add(entryBlock) - } - return + // Build a map of blocks to their outgoing connections within the loop + const blockOutgoingConnections = new Map() + for (const nodeId of nodeIds) { + blockOutgoingConnections.set( + nodeId, + loopConnections.filter((conn) => conn.source === nodeId) + ) } - // For each executed block, find and activate its next blocks in the loop - for (const executedBlockId of executedLoopBlocks) { - // Get outgoing connections from this block to other blocks in the loop - const outgoingConnections = context.workflow.connections.filter( - (conn) => - conn.source === executedBlockId && - loop.nodes.includes(conn.target) && - !executedLoopBlocks.has(conn.target) - ) + // Find blocks that have no incoming connections within the loop (entry points) + const entryBlocks = nodeIds.filter((nodeId) => { + const hasIncomingFromLoop = loopConnections.some((conn) => conn.target === nodeId) + return !hasIncomingFromLoop + }) - // Activate each target that hasn't been executed yet - for (const conn of outgoingConnections) { - // Skip error connections unless there was an error - if (conn.sourceHandle === 'error') { - const blockState = context.blockStates.get(executedBlockId) - const hasError = - blockState?.output?.error !== undefined || - blockState?.output?.response?.error !== undefined + // Track which blocks we've visited and determined are reachable + const reachableBlocks = new Set() + const toVisit = [...entryBlocks] - if (!hasError) continue + // Traverse the graph to find all reachable blocks + while (toVisit.length > 0) { + const currentBlockId = toVisit.shift()! + + // Skip if already visited + if (reachableBlocks.has(currentBlockId)) continue + + reachableBlocks.add(currentBlockId) + + // Get the block + const block = context.workflow?.blocks.find((b) => b.id === currentBlockId) + if (!block) continue + + // Get outgoing connections from this block + const outgoing = blockOutgoingConnections.get(currentBlockId) || [] + + // Handle routing blocks specially + if (block.metadata?.id === 'router') { + // For router blocks, only follow the selected path + const selectedTarget = context.decisions.router.get(currentBlockId) + if (selectedTarget && nodeIds.includes(selectedTarget)) { + toVisit.push(selectedTarget) } + } else if (block.metadata?.id === 'condition') { + // For condition blocks, only follow the selected condition path + const selectedConditionId = context.decisions.condition.get(currentBlockId) + if (selectedConditionId) { + const selectedConnection = outgoing.find( + (conn) => conn.sourceHandle === `condition-${selectedConditionId}` + ) + if (selectedConnection?.target) { + toVisit.push(selectedConnection.target) + } + } + } else { + // For regular blocks, use the extracted error handling method + this.handleErrorConnections(currentBlockId, outgoing, context, toVisit) + } + } - context.activeExecutionPath.add(conn.target) + // Now check if all reachable blocks have been executed + for (const reachableBlockId of reachableBlocks) { + if (!context.executedBlocks.has(reachableBlockId)) { + logger.info( + `Loop iteration not complete - block ${reachableBlockId} is reachable but not executed` + ) + return false + } + } + + logger.info( + `All reachable blocks in loop have been executed. Reachable: ${Array.from(reachableBlocks).join(', ')}` + ) + return true + } + + /** + * Checks if a connection forms a feedback path in a loop. + * With loop blocks, feedback paths are now handled by loop-to-inner-block connections. + * + * @param connection - Connection to check + * @param blocks - All blocks in the workflow + * @returns Whether the connection forms a feedback path + */ + isFeedbackPath(connection: SerializedConnection, blocks: SerializedBlock[]): boolean { + // With the new loop block approach, feedback paths are connections from + // blocks inside the loop back to the loop block itself + for (const [loopId, loop] of Object.entries(this.loops)) { + // Use Set for O(1) lookup performance instead of O(n) includes() + const loopNodesSet = new Set(loop.nodes) + + // Check if source is inside the loop and target is the loop block + if (loopNodesSet.has(connection.source) && connection.target === loopId) { + return true + } + } + + return false + } + + /** + * Handles error connections and follows appropriate paths based on error state. + * + * @param blockId - ID of the block to check for error handling + * @param outgoing - Outgoing connections from the block + * @param context - Current execution context + * @param toVisit - Array to add next blocks to visit + */ + private handleErrorConnections( + blockId: string, + outgoing: any[], + context: ExecutionContext, + toVisit: string[] + ): void { + // For regular blocks, check if they had an error + const blockState = context.blockStates.get(blockId) + const hasError = + blockState?.output?.error !== undefined || blockState?.output?.response?.error !== undefined + + // Follow appropriate connections based on error state + for (const conn of outgoing) { + if (conn.sourceHandle === 'error' && hasError) { + toVisit.push(conn.target) + } else if ((conn.sourceHandle === 'source' || !conn.sourceHandle) && !hasError) { + toVisit.push(conn.target) } } } diff --git a/apps/sim/executor/parallels.test.ts b/apps/sim/executor/parallels.test.ts new file mode 100644 index 0000000000..3ba2ea3c56 --- /dev/null +++ b/apps/sim/executor/parallels.test.ts @@ -0,0 +1,356 @@ +import { describe, expect, test, vi } from 'vitest' +import type { SerializedWorkflow } from '@/serializer/types' +import { createParallelExecutionState } from './__test-utils__/executor-mocks' +import { ParallelManager } from './parallels' +import type { ExecutionContext } from './types' + +vi.mock('@/lib/logs/console-logger', () => ({ + createLogger: () => ({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + }), +})) + +describe('ParallelManager', () => { + const createMockContext = (): ExecutionContext => ({ + workflowId: 'test-workflow', + blockStates: new Map(), + blockLogs: [], + metadata: { startTime: new Date().toISOString(), duration: 0 }, + environmentVariables: {}, + decisions: { router: new Map(), condition: new Map() }, + loopIterations: new Map(), + loopItems: new Map(), + completedLoops: new Set(), + executedBlocks: new Set(), + activeExecutionPath: new Set(), + workflow: { blocks: [], connections: [], loops: {}, parallels: {}, version: '2.0' }, + parallelExecutions: new Map(), + }) + + describe('initializeParallel', () => { + test('should initialize parallel state for array distribution', () => { + const manager = new ParallelManager() + const items = ['apple', 'banana', 'cherry'] + + const state = manager.initializeParallel('parallel-1', items) + + expect(state.parallelCount).toBe(3) + expect(state.distributionItems).toEqual(items) + expect(state.completedExecutions).toBe(0) + expect(state.executionResults).toBeInstanceOf(Map) + expect(state.activeIterations).toBeInstanceOf(Set) + expect(state.currentIteration).toBe(1) + }) + + test('should initialize parallel state for object distribution', () => { + const manager = new ParallelManager() + const items = { first: 'alpha', second: 'beta', third: 'gamma' } + + const state = manager.initializeParallel('parallel-1', items) + + expect(state.parallelCount).toBe(3) + expect(state.distributionItems).toEqual(items) + }) + }) + + describe('getIterationItem', () => { + test('should get item from array distribution', () => { + const manager = new ParallelManager() + const state = createParallelExecutionState({ + parallelCount: 3, + distributionItems: ['apple', 'banana', 'cherry'], + }) + + expect(manager.getIterationItem(state, 0)).toBe('apple') + expect(manager.getIterationItem(state, 1)).toBe('banana') + expect(manager.getIterationItem(state, 2)).toBe('cherry') + }) + + test('should get entry from object distribution', () => { + const manager = new ParallelManager() + const state = createParallelExecutionState({ + parallelCount: 3, + distributionItems: { first: 'alpha', second: 'beta', third: 'gamma' }, + }) + + expect(manager.getIterationItem(state, 0)).toEqual(['first', 'alpha']) + expect(manager.getIterationItem(state, 1)).toEqual(['second', 'beta']) + expect(manager.getIterationItem(state, 2)).toEqual(['third', 'gamma']) + }) + + test('should return null for null distribution items', () => { + const manager = new ParallelManager() + const state = createParallelExecutionState({ + parallelCount: 0, + distributionItems: null, + }) + + expect(manager.getIterationItem(state, 0)).toBeNull() + }) + }) + + describe('areAllVirtualBlocksExecuted', () => { + test('should return true when all virtual blocks are executed', () => { + const manager = new ParallelManager() + const executedBlocks = new Set([ + 'func-1_parallel_parallel-1_iteration_0', + 'func-1_parallel_parallel-1_iteration_1', + 'func-1_parallel_parallel-1_iteration_2', + ]) + const parallel = { + id: 'parallel-1', + nodes: ['func-1'], + distribution: ['a', 'b', 'c'], + } + const state = createParallelExecutionState({ + parallelCount: 3, + distributionItems: ['a', 'b', 'c'], + }) + + const result = manager.areAllVirtualBlocksExecuted( + 'parallel-1', + parallel, + executedBlocks, + state + ) + + expect(result).toBe(true) + }) + + test('should return false when some virtual blocks are not executed', () => { + const manager = new ParallelManager() + const executedBlocks = new Set([ + 'func-1_parallel_parallel-1_iteration_0', + 'func-1_parallel_parallel-1_iteration_1', + // Missing iteration_2 + ]) + const parallel = { + id: 'parallel-1', + nodes: ['func-1'], + distribution: ['a', 'b', 'c'], + } + const state = createParallelExecutionState({ + parallelCount: 3, + distributionItems: ['a', 'b', 'c'], + }) + + const result = manager.areAllVirtualBlocksExecuted( + 'parallel-1', + parallel, + executedBlocks, + state + ) + + expect(result).toBe(false) + }) + }) + + describe('createVirtualBlockInstances', () => { + test('should create virtual block instances for unexecuted blocks', () => { + const manager = new ParallelManager() + const block = { + id: 'func-1', + position: { x: 0, y: 0 }, + config: { tool: 'function', params: {} }, + inputs: {}, + outputs: {}, + enabled: true, + } + const executedBlocks = new Set(['func-1_parallel_parallel-1_iteration_0']) + const activeExecutionPath = new Set(['func-1']) + const state = createParallelExecutionState({ + parallelCount: 3, + distributionItems: ['a', 'b', 'c'], + }) + + const virtualIds = manager.createVirtualBlockInstances( + block, + 'parallel-1', + state, + executedBlocks, + activeExecutionPath + ) + + expect(virtualIds).toEqual([ + 'func-1_parallel_parallel-1_iteration_1', + 'func-1_parallel_parallel-1_iteration_2', + ]) + }) + + test('should skip blocks not in active execution path', () => { + const manager = new ParallelManager() + const block = { + id: 'func-1', + position: { x: 0, y: 0 }, + config: { tool: 'function', params: {} }, + inputs: {}, + outputs: {}, + enabled: true, + } + const executedBlocks = new Set() + const activeExecutionPath = new Set() // Block not in active path + const state = createParallelExecutionState({ + parallelCount: 3, + distributionItems: ['a', 'b', 'c'], + }) + + const virtualIds = manager.createVirtualBlockInstances( + block, + 'parallel-1', + state, + executedBlocks, + activeExecutionPath + ) + + expect(virtualIds).toEqual([]) + }) + }) + + describe('setupIterationContext', () => { + test('should set up context for array distribution', () => { + const manager = new ParallelManager() + const context = createMockContext() + + const state = { + parallelCount: 3, + distributionItems: ['apple', 'banana', 'cherry'], + completedExecutions: 0, + executionResults: new Map(), + activeIterations: new Set(), + currentIteration: 1, + } + + context.parallelExecutions?.set('parallel-1', state) + + manager.setupIterationContext(context, { + parallelId: 'parallel-1', + iterationIndex: 1, + }) + + expect(context.loopItems.get('parallel-1_iteration_1')).toBe('banana') + expect(context.loopItems.get('parallel-1')).toBe('banana') + expect(context.loopIterations.get('parallel-1')).toBe(1) + }) + + test('should set up context for object distribution', () => { + const manager = new ParallelManager() + const context = createMockContext() + + const state = createParallelExecutionState({ + parallelCount: 2, + distributionItems: { key1: 'value1', key2: 'value2' }, + }) + + context.parallelExecutions?.set('parallel-1', state) + + manager.setupIterationContext(context, { + parallelId: 'parallel-1', + iterationIndex: 0, + }) + + expect(context.loopItems.get('parallel-1_iteration_0')).toEqual(['key1', 'value1']) + expect(context.loopItems.get('parallel-1')).toEqual(['key1', 'value1']) + expect(context.loopIterations.get('parallel-1')).toBe(0) + }) + }) + + describe('storeIterationResult', () => { + test('should store iteration result in parallel state', () => { + const manager = new ParallelManager() + const context = createMockContext() + + const state = { + parallelCount: 3, + distributionItems: ['a', 'b', 'c'], + completedExecutions: 0, + executionResults: new Map(), + activeIterations: new Set(), + currentIteration: 1, + } + + context.parallelExecutions?.set('parallel-1', state) + + const output = { response: { result: 'test result' } } + + manager.storeIterationResult(context, 'parallel-1', 1, 'func-1', output) + + expect(state.executionResults.get('iteration_1')).toEqual({ + 'func-1': output, + }) + }) + }) + + describe('processParallelIterations', () => { + test('should re-execute parallel block when all virtual blocks are complete', async () => { + const parallels: SerializedWorkflow['parallels'] = { + 'parallel-1': { + id: 'parallel-1', + nodes: ['func-1'], + distribution: ['a', 'b', 'c'], + }, + } + + const manager = new ParallelManager(parallels) + const context = createMockContext() + + // Set up context as if parallel has been executed and all virtual blocks completed + context.executedBlocks.add('parallel-1') + context.executedBlocks.add('func-1_parallel_parallel-1_iteration_0') + context.executedBlocks.add('func-1_parallel_parallel-1_iteration_1') + context.executedBlocks.add('func-1_parallel_parallel-1_iteration_2') + + const state = { + parallelCount: 3, + distributionItems: ['a', 'b', 'c'], + completedExecutions: 0, + executionResults: new Map(), + activeIterations: new Set(), + currentIteration: 1, + } + + context.parallelExecutions?.set('parallel-1', state) + + await manager.processParallelIterations(context) + + // Should remove parallel from executed blocks and add to active path + expect(context.executedBlocks.has('parallel-1')).toBe(false) + expect(context.activeExecutionPath.has('parallel-1')).toBe(true) + + // Should remove child nodes from active path + expect(context.activeExecutionPath.has('func-1')).toBe(false) + }) + + test('should skip completed parallels', async () => { + const parallels: SerializedWorkflow['parallels'] = { + 'parallel-1': { + id: 'parallel-1', + nodes: ['func-1'], + distribution: ['a', 'b', 'c'], + }, + } + + const manager = new ParallelManager(parallels) + const context = createMockContext() + + // Mark parallel as completed + context.completedLoops.add('parallel-1') + + await manager.processParallelIterations(context) + + // Should not modify execution state + expect(context.executedBlocks.size).toBe(0) + expect(context.activeExecutionPath.size).toBe(0) + }) + + test('should handle empty parallels object', async () => { + const manager = new ParallelManager({}) + const context = createMockContext() + + // Should complete without error + await expect(manager.processParallelIterations(context)).resolves.toBeUndefined() + }) + }) +}) diff --git a/apps/sim/executor/parallels.ts b/apps/sim/executor/parallels.ts new file mode 100644 index 0000000000..10adfda3d2 --- /dev/null +++ b/apps/sim/executor/parallels.ts @@ -0,0 +1,201 @@ +import { createLogger } from '@/lib/logs/console-logger' +import type { SerializedBlock, SerializedParallel, SerializedWorkflow } from '@/serializer/types' +import type { ExecutionContext, NormalizedBlockOutput } from './types' + +const logger = createLogger('ParallelManager') + +export interface ParallelState { + parallelCount: number + distributionItems: any[] | Record | null + completedExecutions: number + executionResults: Map + activeIterations: Set + currentIteration: number +} + +/** + * Manages parallel block execution and state. + * Handles distribution of items across parallel executions and tracking completion. + */ +export class ParallelManager { + constructor(private parallels: SerializedWorkflow['parallels'] = {}) {} + + /** + * Initializes a parallel execution state. + */ + initializeParallel( + parallelId: string, + distributionItems: any[] | Record + ): ParallelState { + const parallelCount = Array.isArray(distributionItems) + ? distributionItems.length + : Object.keys(distributionItems).length + + return { + parallelCount, + distributionItems, + completedExecutions: 0, + executionResults: new Map(), + activeIterations: new Set(), + currentIteration: 1, + } + } + + /** + * Gets the current item for a specific parallel iteration. + */ + getIterationItem(parallelState: ParallelState, iterationIndex: number): any { + if (!parallelState.distributionItems) { + return null + } + + if (Array.isArray(parallelState.distributionItems)) { + return parallelState.distributionItems[iterationIndex] + } + return Object.entries(parallelState.distributionItems)[iterationIndex] + } + + /** + * Checks if all virtual blocks for a parallel have been executed. + */ + areAllVirtualBlocksExecuted( + parallelId: string, + parallel: SerializedParallel, + executedBlocks: Set, + parallelState: ParallelState + ): boolean { + for (const nodeId of parallel.nodes) { + for (let i = 0; i < parallelState.parallelCount; i++) { + const virtualBlockId = `${nodeId}_parallel_${parallelId}_iteration_${i}` + if (!executedBlocks.has(virtualBlockId)) { + return false + } + } + } + return true + } + + /** + * Processes parallel iterations to check for completion and trigger re-execution. + */ + async processParallelIterations(context: ExecutionContext): Promise { + if (!this.parallels || Object.keys(this.parallels).length === 0) { + return + } + + for (const [parallelId, parallel] of Object.entries(this.parallels)) { + // Skip if this parallel has already been marked as completed + if (context.completedLoops.has(parallelId)) { + continue + } + + // Check if the parallel block itself has been executed + const parallelBlockExecuted = context.executedBlocks.has(parallelId) + if (!parallelBlockExecuted) { + continue + } + + // Get the parallel state + const parallelState = context.parallelExecutions?.get(parallelId) + if (!parallelState || parallelState.currentIteration === 0) { + continue + } + + // Check if all virtual blocks have been executed + const allVirtualBlocksExecuted = this.areAllVirtualBlocksExecuted( + parallelId, + parallel, + context.executedBlocks, + parallelState + ) + + if (allVirtualBlocksExecuted && !context.completedLoops.has(parallelId)) { + logger.info( + `All virtual blocks completed for parallel ${parallelId}, re-executing to check completion` + ) + + // Re-execute the parallel block to check completion and trigger end connections + context.executedBlocks.delete(parallelId) + context.activeExecutionPath.add(parallelId) + + // IMPORTANT: Remove child nodes from active execution path to prevent re-execution + for (const nodeId of parallel.nodes) { + context.activeExecutionPath.delete(nodeId) + } + } + } + } + + /** + * Creates virtual block instances for parallel execution. + */ + createVirtualBlockInstances( + block: SerializedBlock, + parallelId: string, + parallelState: ParallelState, + executedBlocks: Set, + activeExecutionPath: Set + ): string[] { + const virtualBlockIds: string[] = [] + + for (let i = 0; i < parallelState.parallelCount; i++) { + const virtualBlockId = `${block.id}_parallel_${parallelId}_iteration_${i}` + + // Skip if this virtual instance was already executed + if (executedBlocks.has(virtualBlockId)) { + continue + } + + // Check if this virtual instance is in the active path + if (!activeExecutionPath.has(virtualBlockId) && !activeExecutionPath.has(block.id)) { + continue + } + + virtualBlockIds.push(virtualBlockId) + } + + return virtualBlockIds + } + + /** + * Sets up iteration-specific context for a virtual block. + */ + setupIterationContext( + context: ExecutionContext, + parallelInfo: { parallelId: string; iterationIndex: number } + ): void { + const parallelState = context.parallelExecutions?.get(parallelInfo.parallelId) + if (parallelState?.distributionItems) { + const currentItem = this.getIterationItem(parallelState, parallelInfo.iterationIndex) + + // Store the current item for this specific iteration + const iterationKey = `${parallelInfo.parallelId}_iteration_${parallelInfo.iterationIndex}` + context.loopItems.set(iterationKey, currentItem) + context.loopItems.set(parallelInfo.parallelId, currentItem) // Backward compatibility + context.loopIterations.set(parallelInfo.parallelId, parallelInfo.iterationIndex) + + logger.info(`Set up iteration context for ${iterationKey} with item:`, currentItem) + } + } + + /** + * Stores the result of a parallel iteration. + */ + storeIterationResult( + context: ExecutionContext, + parallelId: string, + iterationIndex: number, + blockId: string, + output: NormalizedBlockOutput + ): void { + const parallelState = context.parallelExecutions?.get(parallelId) + if (parallelState) { + const existingResults = + parallelState.executionResults.get(`iteration_${iterationIndex}`) || {} + parallelState.executionResults.set(`iteration_${iterationIndex}`, { + ...existingResults, + [blockId]: output, + }) + } + } +} diff --git a/apps/sim/executor/path.test.ts b/apps/sim/executor/path.test.ts new file mode 100644 index 0000000000..b2cdda947e --- /dev/null +++ b/apps/sim/executor/path.test.ts @@ -0,0 +1,403 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import type { SerializedWorkflow } from '@/serializer/types' +import { PathTracker } from './path' +import type { BlockState, ExecutionContext } from './types' + +describe('PathTracker', () => { + let pathTracker: PathTracker + let mockWorkflow: SerializedWorkflow + let mockContext: ExecutionContext + + beforeEach(() => { + mockWorkflow = { + version: '1.0', + blocks: [ + { + id: 'block1', + metadata: { id: 'generic' }, + position: { x: 0, y: 0 }, + config: { tool: 'generic', params: {} }, + inputs: {}, + outputs: {}, + enabled: true, + }, + { + id: 'block2', + metadata: { id: 'generic' }, + position: { x: 0, y: 0 }, + config: { tool: 'generic', params: {} }, + inputs: {}, + outputs: {}, + enabled: true, + }, + { + id: 'router1', + metadata: { id: 'router' }, + position: { x: 0, y: 0 }, + config: { tool: 'router', params: {} }, + inputs: {}, + outputs: {}, + enabled: true, + }, + { + id: 'condition1', + metadata: { id: 'condition' }, + position: { x: 0, y: 0 }, + config: { tool: 'condition', params: {} }, + inputs: {}, + outputs: {}, + enabled: true, + }, + { + id: 'loop1', + metadata: { id: 'loop' }, + position: { x: 0, y: 0 }, + config: { tool: 'loop', params: {} }, + inputs: {}, + outputs: {}, + enabled: true, + }, + ], + connections: [ + { source: 'block1', target: 'block2' }, + { source: 'router1', target: 'block1' }, + { source: 'router1', target: 'block2' }, + { source: 'condition1', target: 'block1', sourceHandle: 'condition-if' }, + { source: 'condition1', target: 'block2', sourceHandle: 'condition-else' }, + { source: 'loop1', target: 'block1', sourceHandle: 'loop-start-source' }, + { source: 'loop1', target: 'block2', sourceHandle: 'loop-end-source' }, + ], + loops: { + loop1: { + id: 'loop1', + nodes: ['block1'], + iterations: 3, + loopType: 'for', + }, + }, + } + + mockContext = { + workflowId: 'test-workflow', + blockStates: new Map(), + blockLogs: [], + metadata: { duration: 0 }, + environmentVariables: {}, + decisions: { router: new Map(), condition: new Map() }, + loopIterations: new Map(), + loopItems: new Map(), + completedLoops: new Set(), + executedBlocks: new Set(), + activeExecutionPath: new Set(), + workflow: mockWorkflow, + } + + pathTracker = new PathTracker(mockWorkflow) + }) + + describe('isInActivePath', () => { + it('should return true if block is already in active path', () => { + mockContext.activeExecutionPath.add('block1') + expect(pathTracker.isInActivePath('block1', mockContext)).toBe(true) + }) + + it('should return false if block has no incoming connections and is not in active path', () => { + expect(pathTracker.isInActivePath('router1', mockContext)).toBe(false) + }) + + describe('regular blocks', () => { + it('should return true if source block is in active path and executed', () => { + mockContext.activeExecutionPath.add('block1') + mockContext.executedBlocks.add('block1') + expect(pathTracker.isInActivePath('block2', mockContext)).toBe(true) + }) + + it('should return false if source block is not executed', () => { + mockContext.activeExecutionPath.add('block1') + expect(pathTracker.isInActivePath('block2', mockContext)).toBe(false) + }) + + it('should return false if source block is not in active path', () => { + mockContext.executedBlocks.add('block1') + expect(pathTracker.isInActivePath('block2', mockContext)).toBe(false) + }) + }) + + describe('router blocks', () => { + it('should return true if router selected this target', () => { + mockContext.executedBlocks.add('router1') + mockContext.decisions.router.set('router1', 'block1') + expect(pathTracker.isInActivePath('block1', mockContext)).toBe(true) + }) + + it('should return false if router selected different target', () => { + mockContext.executedBlocks.add('router1') + mockContext.decisions.router.set('router1', 'block2') + expect(pathTracker.isInActivePath('block1', mockContext)).toBe(false) + }) + + it('should return false if router not executed', () => { + mockContext.decisions.router.set('router1', 'block1') + expect(pathTracker.isInActivePath('block1', mockContext)).toBe(false) + }) + }) + + describe('condition blocks', () => { + it('should return true if condition selected this path', () => { + mockContext.executedBlocks.add('condition1') + mockContext.decisions.condition.set('condition1', 'if') + expect(pathTracker.isInActivePath('block1', mockContext)).toBe(true) + }) + + it('should return false if condition selected different path', () => { + mockContext.executedBlocks.add('condition1') + mockContext.decisions.condition.set('condition1', 'else') + expect(pathTracker.isInActivePath('block1', mockContext)).toBe(false) + }) + + it('should return false if connection has no sourceHandle', () => { + // Add a connection without sourceHandle + mockWorkflow.connections.push({ source: 'condition1', target: 'block3' }) + mockContext.executedBlocks.add('condition1') + expect(pathTracker.isInActivePath('block3', mockContext)).toBe(false) + }) + }) + }) + + describe('updateExecutionPaths', () => { + describe('router blocks', () => { + it('should update router decision and activate selected path', () => { + const blockState: BlockState = { + output: { response: { selectedPath: { blockId: 'block1' } } }, + executed: true, + } + mockContext.blockStates.set('router1', blockState) + + pathTracker.updateExecutionPaths(['router1'], mockContext) + + expect(mockContext.decisions.router.get('router1')).toBe('block1') + expect(mockContext.activeExecutionPath.has('block1')).toBe(true) + }) + + it('should not update if no selected path', () => { + const blockState: BlockState = { + output: { response: {} }, + executed: true, + } + mockContext.blockStates.set('router1', blockState) + + pathTracker.updateExecutionPaths(['router1'], mockContext) + + expect(mockContext.decisions.router.has('router1')).toBe(false) + expect(mockContext.activeExecutionPath.has('block1')).toBe(false) + }) + }) + + describe('condition blocks', () => { + it('should update condition decision and activate selected connection', () => { + const blockState: BlockState = { + output: { response: { selectedConditionId: 'if' } }, + executed: true, + } + mockContext.blockStates.set('condition1', blockState) + + pathTracker.updateExecutionPaths(['condition1'], mockContext) + + expect(mockContext.decisions.condition.get('condition1')).toBe('if') + expect(mockContext.activeExecutionPath.has('block1')).toBe(true) + }) + + it('should not activate if no matching connection', () => { + const blockState: BlockState = { + output: { response: { selectedConditionId: 'unknown' } }, + executed: true, + } + mockContext.blockStates.set('condition1', blockState) + + pathTracker.updateExecutionPaths(['condition1'], mockContext) + + expect(mockContext.decisions.condition.get('condition1')).toBe('unknown') + expect(mockContext.activeExecutionPath.has('block1')).toBe(false) + }) + }) + + describe('loop blocks', () => { + it('should only activate loop-start connections', () => { + pathTracker.updateExecutionPaths(['loop1'], mockContext) + + expect(mockContext.activeExecutionPath.has('block1')).toBe(true) + expect(mockContext.activeExecutionPath.has('block2')).toBe(false) + }) + }) + + describe('regular blocks', () => { + it('should activate outgoing connections on success', () => { + const blockState: BlockState = { + output: { response: { data: 'success' } }, + executed: true, + } + mockContext.blockStates.set('block1', blockState) + mockContext.executedBlocks.add('block1') + // Complete the loop so external connections can be activated + mockContext.completedLoops.add('loop1') + + pathTracker.updateExecutionPaths(['block1'], mockContext) + + expect(mockContext.activeExecutionPath.has('block2')).toBe(true) + }) + + it('should activate error connections on error', () => { + // Add error connection + mockWorkflow.connections.push({ + source: 'block1', + target: 'errorHandler', + sourceHandle: 'error', + }) + const blockState: BlockState = { + output: { error: 'Something failed', response: { error: 'Something failed' } }, + executed: true, + } + mockContext.blockStates.set('block1', blockState) + mockContext.executedBlocks.add('block1') + // Complete the loop so external connections can be activated + mockContext.completedLoops.add('loop1') + + pathTracker.updateExecutionPaths(['block1'], mockContext) + + expect(mockContext.activeExecutionPath.has('errorHandler')).toBe(true) + expect(mockContext.activeExecutionPath.has('block2')).toBe(false) + }) + + it('should skip external loop connections if loop not completed', () => { + // Add block3 outside the loop + mockWorkflow.blocks.push({ + id: 'block3', + metadata: { id: 'generic' }, + position: { x: 0, y: 0 }, + config: { tool: 'generic', params: {} }, + inputs: {}, + outputs: {}, + enabled: true, + }) + mockWorkflow.connections.push({ source: 'block1', target: 'block3' }) + mockContext.executedBlocks.add('block1') + + pathTracker.updateExecutionPaths(['block1'], mockContext) + + expect(mockContext.activeExecutionPath.has('block3')).toBe(false) + }) + + it('should activate external loop connections if loop completed', () => { + // Add block3 outside the loop + mockWorkflow.blocks.push({ + id: 'block3', + metadata: { id: 'generic' }, + position: { x: 0, y: 0 }, + config: { tool: 'generic', params: {} }, + inputs: {}, + outputs: {}, + enabled: true, + }) + mockWorkflow.connections.push({ source: 'block1', target: 'block3' }) + mockContext.completedLoops.add('loop1') + mockContext.executedBlocks.add('block1') + + pathTracker.updateExecutionPaths(['block1'], mockContext) + + expect(mockContext.activeExecutionPath.has('block3')).toBe(true) + }) + + it('should activate all other connection types', () => { + // Add custom connection type + mockWorkflow.connections.push({ + source: 'block1', + target: 'customHandler', + sourceHandle: 'custom-handle', + }) + mockContext.executedBlocks.add('block1') + // Complete the loop so external connections can be activated + mockContext.completedLoops.add('loop1') + + pathTracker.updateExecutionPaths(['block1'], mockContext) + + expect(mockContext.activeExecutionPath.has('customHandler')).toBe(true) + }) + }) + + it('should handle multiple blocks in one update', () => { + const blockState1: BlockState = { + output: { response: { data: 'success' } }, + executed: true, + } + const blockState2: BlockState = { + output: { response: { selectedPath: { blockId: 'block1' } } }, + executed: true, + } + mockContext.blockStates.set('block1', blockState1) + mockContext.blockStates.set('router1', blockState2) + mockContext.executedBlocks.add('block1') + mockContext.executedBlocks.add('router1') + // Complete the loop so block1 can activate external connections + mockContext.completedLoops.add('loop1') + + pathTracker.updateExecutionPaths(['block1', 'router1'], mockContext) + + expect(mockContext.activeExecutionPath.has('block2')).toBe(true) + expect(mockContext.activeExecutionPath.has('block1')).toBe(true) + expect(mockContext.decisions.router.get('router1')).toBe('block1') + }) + + it('should skip blocks that do not exist', () => { + // Should not throw + expect(() => { + pathTracker.updateExecutionPaths(['nonexistent'], mockContext) + }).not.toThrow() + }) + }) + + describe('edge cases', () => { + it('should handle blocks with multiple incoming connections', () => { + // Add another connection to block2 + mockWorkflow.connections.push({ source: 'router1', target: 'block2' }) + + // One path is active + mockContext.activeExecutionPath.add('block1') + mockContext.executedBlocks.add('block1') + + expect(pathTracker.isInActivePath('block2', mockContext)).toBe(true) + }) + + it('should handle nested loops', () => { + // Add nested loop + mockWorkflow.loops = mockWorkflow.loops || {} + mockWorkflow.loops.loop2 = { + id: 'loop2', + nodes: ['loop1', 'block1'], + iterations: 2, + loopType: 'for', + } + + // Block1 is in both loops + const loops = Object.entries(mockContext.workflow?.loops || {}) + .filter(([_, loop]) => loop.nodes.includes('block1')) + .map(([id, loop]) => ({ id, loop })) + + expect(loops).toHaveLength(2) + }) + + it('should handle empty workflow', () => { + const emptyWorkflow: SerializedWorkflow = { + version: '1.0', + blocks: [], + connections: [], + loops: {}, + } + const emptyTracker = new PathTracker(emptyWorkflow) + + expect(emptyTracker.isInActivePath('any', mockContext)).toBe(false) + expect(() => { + emptyTracker.updateExecutionPaths(['any'], mockContext) + }).not.toThrow() + }) + }) +}) diff --git a/apps/sim/executor/path.ts b/apps/sim/executor/path.ts index 57d4d0774c..620d926ca9 100644 --- a/apps/sim/executor/path.ts +++ b/apps/sim/executor/path.ts @@ -1,6 +1,6 @@ import { createLogger } from '@/lib/logs/console-logger' -import type { SerializedWorkflow } from '@/serializer/types' -import type { ExecutionContext } from './types' +import type { SerializedBlock, SerializedConnection, SerializedWorkflow } from '@/serializer/types' +import type { BlockState, ExecutionContext } from './types' const logger = createLogger('PathTracker') @@ -20,45 +20,17 @@ export class PathTracker { * @returns Whether the block is in the active execution path */ isInActivePath(blockId: string, context: ExecutionContext): boolean { - // If the block is already in the active path set, it's valid + // Early return if already in active path if (context.activeExecutionPath.has(blockId)) { return true } // Get all incoming connections to this block - const incomingConnections = this.workflow.connections.filter((conn) => conn.target === blockId) + const incomingConnections = this.getIncomingConnections(blockId) // A block is in the active path if at least one of its incoming connections // is from an active and executed block - return incomingConnections.some((conn) => { - const sourceBlock = this.workflow.blocks.find((b) => b.id === conn.source) - - // For router blocks, check if this is the selected target - if (sourceBlock?.metadata?.id === 'router') { - const selectedTarget = context.decisions.router.get(conn.source) - // This path is active if the router selected this target - if (context.executedBlocks.has(conn.source) && selectedTarget === blockId) { - return true - } - return false - } - - // For condition blocks, check if this is the selected condition - if (sourceBlock?.metadata?.id === 'condition') { - if (conn.sourceHandle?.startsWith('condition-')) { - const conditionId = conn.sourceHandle.replace('condition-', '') - const selectedCondition = context.decisions.condition.get(conn.source) - // This path is active if the condition selected this path - if (context.executedBlocks.has(conn.source) && conditionId === selectedCondition) { - return true - } - return false - } - } - - // For regular blocks, check if the source is in the active path and executed - return context.activeExecutionPath.has(conn.source) && context.executedBlocks.has(conn.source) - }) + return incomingConnections.some((conn) => this.isConnectionActive(conn, context)) } /** @@ -72,102 +44,244 @@ export class PathTracker { logger.info(`Updating paths for blocks: ${executedBlockIds.join(', ')}`) for (const blockId of executedBlockIds) { - const block = this.workflow.blocks.find((b) => b.id === blockId) + const block = this.getBlock(blockId) + if (!block) continue - if (block?.metadata?.id === 'router') { - const routerOutput = context.blockStates.get(blockId)?.output - const selectedPath = routerOutput?.response?.selectedPath?.blockId + this.updatePathForBlock(block, context) + } + } - if (selectedPath) { - // Record the decision but don't deactivate other paths - context.decisions.router.set(blockId, selectedPath) - context.activeExecutionPath.add(selectedPath) - logger.info(`Router ${blockId} selected path: ${selectedPath}`) - } - } else if (block?.metadata?.id === 'condition') { - const conditionOutput = context.blockStates.get(blockId)?.output - const selectedConditionId = conditionOutput?.response?.selectedConditionId + /** + * Get all incoming connections to a block + */ + private getIncomingConnections(blockId: string): SerializedConnection[] { + return this.workflow.connections.filter((conn) => conn.target === blockId) + } - if (selectedConditionId) { - // Record the decision but don't deactivate other paths - context.decisions.condition.set(blockId, selectedConditionId) + /** + * Get all outgoing connections from a block + */ + private getOutgoingConnections(blockId: string): SerializedConnection[] { + return this.workflow.connections.filter((conn) => conn.source === blockId) + } - const targetConnection = this.workflow.connections.find( - (conn) => - conn.source === blockId && conn.sourceHandle === `condition-${selectedConditionId}` - ) + /** + * Get a block by ID + */ + private getBlock(blockId: string): SerializedBlock | undefined { + return this.workflow.blocks.find((b) => b.id === blockId) + } - if (targetConnection) { - context.activeExecutionPath.add(targetConnection.target) - logger.debug(`Condition ${blockId} selected: ${selectedConditionId}`) - } - } - } else { - // For regular blocks, activate all outgoing connections based on success or error status - const blockState = context.blockStates.get(blockId) - const hasError = - blockState?.output?.error !== undefined || - blockState?.output?.response?.error !== undefined + /** + * Check if a connection is active based on its source block type and state + */ + private isConnectionActive(connection: SerializedConnection, context: ExecutionContext): boolean { + const sourceBlock = this.getBlock(connection.source) + if (!sourceBlock) return false - // Get all outgoing connections - const outgoingConnections = this.workflow.connections.filter( - (conn) => conn.source === blockId - ) + const blockType = sourceBlock.metadata?.id - // Find out which loops this block belongs to - const blockLoops = Object.entries(context.workflow?.loops || {}) - .filter(([_, loop]) => loop.nodes.includes(blockId)) - .map(([id, loop]) => ({ id, loop })) + // Use strategy pattern for different block types + switch (blockType) { + case 'router': + return this.isRouterConnectionActive(connection, context) + case 'condition': + return this.isConditionConnectionActive(connection, context) + default: + return this.isRegularConnectionActive(connection, context) + } + } - // Check if the block is part of any loops - const isPartOfLoop = blockLoops.length > 0 + /** + * Check if a router connection is active + */ + private isRouterConnectionActive( + connection: SerializedConnection, + context: ExecutionContext + ): boolean { + const selectedTarget = context.decisions.router.get(connection.source) + return context.executedBlocks.has(connection.source) && selectedTarget === connection.target + } - // Process each outgoing connection - for (const conn of outgoingConnections) { - // Check if this connection is internal to any loop the source block belongs to - const isInternalLoopConnection = blockLoops.some(({ loop }) => - // Target is also in the same loop as the source - loop.nodes.includes(conn.target) - ) + /** + * Check if a condition connection is active + */ + private isConditionConnectionActive( + connection: SerializedConnection, + context: ExecutionContext + ): boolean { + if (!connection.sourceHandle?.startsWith('condition-')) { + return false + } - // Check if this is a connection to a block outside any loop that contains the source - const isExternalLoopConnection = isPartOfLoop && !isInternalLoopConnection + const conditionId = connection.sourceHandle.replace('condition-', '') + const selectedCondition = context.decisions.condition.get(connection.source) - // Let the LoopManager handle all connections within loops - if (isInternalLoopConnection) { - continue - } + return context.executedBlocks.has(connection.source) && conditionId === selectedCondition + } - // Check if all loops this block belongs to are completed - const allLoopsCompleted = blockLoops.every(({ id }) => context.completedLoops?.has(id)) + /** + * Check if a regular connection is active + */ + private isRegularConnectionActive( + connection: SerializedConnection, + context: ExecutionContext + ): boolean { + return ( + context.activeExecutionPath.has(connection.source) && + context.executedBlocks.has(connection.source) + ) + } - // Skip external connections from loop blocks UNLESS all loops are completed - if (isExternalLoopConnection && !allLoopsCompleted) { - continue - } + /** + * Update paths for a specific block based on its type + */ + private updatePathForBlock(block: SerializedBlock, context: ExecutionContext): void { + const blockType = block.metadata?.id - // Now we can activate the path if: - // 1. It's not a loop connection, or - // 2. It's an external connection and all loops are completed + switch (blockType) { + case 'router': + this.updateRouterPaths(block, context) + break + case 'condition': + this.updateConditionPaths(block, context) + break + case 'loop': + this.updateLoopPaths(block, context) + break + default: + this.updateRegularBlockPaths(block, context) + break + } + } - // For error connections, only activate them on error - if (conn.sourceHandle === 'error') { - if (hasError) { - context.activeExecutionPath.add(conn.target) - } - } - // For regular (source) connections, only activate them if there's no error - else if (conn.sourceHandle === 'source' || !conn.sourceHandle) { - if (!hasError) { - context.activeExecutionPath.add(conn.target) - } - } - // All other types of connections (e.g., from condition blocks) follow their own rules - else { - context.activeExecutionPath.add(conn.target) - } - } + /** + * Update paths for router blocks + */ + private updateRouterPaths(block: SerializedBlock, context: ExecutionContext): void { + const routerOutput = context.blockStates.get(block.id)?.output + const selectedPath = routerOutput?.response?.selectedPath?.blockId + + if (selectedPath) { + context.decisions.router.set(block.id, selectedPath) + context.activeExecutionPath.add(selectedPath) + logger.info(`Router ${block.id} selected path: ${selectedPath}`) + } + } + + /** + * Update paths for condition blocks + */ + private updateConditionPaths(block: SerializedBlock, context: ExecutionContext): void { + const conditionOutput = context.blockStates.get(block.id)?.output + const selectedConditionId = conditionOutput?.response?.selectedConditionId + + if (!selectedConditionId) return + + context.decisions.condition.set(block.id, selectedConditionId) + + const targetConnection = this.workflow.connections.find( + (conn) => conn.source === block.id && conn.sourceHandle === `condition-${selectedConditionId}` + ) + + if (targetConnection) { + context.activeExecutionPath.add(targetConnection.target) + logger.debug(`Condition ${block.id} selected: ${selectedConditionId}`) + } + } + + /** + * Update paths for loop blocks + */ + private updateLoopPaths(block: SerializedBlock, context: ExecutionContext): void { + const outgoingConnections = this.getOutgoingConnections(block.id) + + for (const conn of outgoingConnections) { + // Only activate loop-start connections + if (conn.sourceHandle === 'loop-start-source') { + context.activeExecutionPath.add(conn.target) + logger.info(`Loop ${block.id} activated start path to: ${conn.target}`) + } + // loop-end-source connections will be activated by the loop manager + } + } + + /** + * Update paths for regular blocks + */ + private updateRegularBlockPaths(block: SerializedBlock, context: ExecutionContext): void { + const blockState = context.blockStates.get(block.id) + const hasError = this.blockHasError(blockState) + const outgoingConnections = this.getOutgoingConnections(block.id) + + // Check if block is part of loops + const blockLoops = this.getBlockLoops(block.id, context) + const isPartOfLoop = blockLoops.length > 0 + + for (const conn of outgoingConnections) { + if ( + this.shouldActivateConnection(conn, block.id, hasError, isPartOfLoop, blockLoops, context) + ) { + context.activeExecutionPath.add(conn.target) } } } + + /** + * Check if a block has an error + */ + private blockHasError(blockState: BlockState | undefined): boolean { + return ( + blockState?.output?.error !== undefined || blockState?.output?.response?.error !== undefined + ) + } + + /** + * Get loops that contain a block + */ + private getBlockLoops( + blockId: string, + context: ExecutionContext + ): Array<{ id: string; loop: any }> { + return Object.entries(context.workflow?.loops || {}) + .filter(([_, loop]) => loop.nodes.includes(blockId)) + .map(([id, loop]) => ({ id, loop })) + } + + /** + * Determine if a connection should be activated + */ + private shouldActivateConnection( + conn: SerializedConnection, + sourceBlockId: string, + hasError: boolean, + isPartOfLoop: boolean, + blockLoops: Array<{ id: string; loop: any }>, + context: ExecutionContext + ): boolean { + // Check if this is an external loop connection + if (isPartOfLoop) { + const isInternalConnection = blockLoops.some(({ loop }) => loop.nodes.includes(conn.target)) + const isExternalConnection = !isInternalConnection + const allLoopsCompleted = blockLoops.every(({ id }) => context.completedLoops?.has(id)) + + // Skip external connections unless all loops are completed + if (isExternalConnection && !allLoopsCompleted) { + return false + } + } + + // Handle error connections + if (conn.sourceHandle === 'error') { + return hasError + } + + // Handle regular connections + if (conn.sourceHandle === 'source' || !conn.sourceHandle) { + return !hasError + } + + // All other connection types are activated + return true + } } diff --git a/apps/sim/executor/resolver.test.ts b/apps/sim/executor/resolver.test.ts index 3177914280..0624065fe5 100644 --- a/apps/sim/executor/resolver.test.ts +++ b/apps/sim/executor/resolver.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types' import { InputResolver } from './resolver' +import type { ExecutionContext } from './types' // Mock logger vi.mock('@/lib/logs/console-logger', () => ({ @@ -683,4 +684,264 @@ describe('InputResolver', () => { expect(result.param2).toBe(42) }) }) + + describe('direct loop references', () => { + it('should resolve direct loop.currentItem reference without quotes', () => { + const loopBlock: SerializedBlock = { + id: 'loop-1', + position: { x: 0, y: 0 }, + config: { tool: 'loop', params: {} }, + inputs: {}, + outputs: {}, + metadata: { id: 'loop', name: 'Test Loop' }, + enabled: true, + } + + const functionBlock: SerializedBlock = { + id: 'function-1', + position: { x: 0, y: 0 }, + config: { + tool: 'function', + params: { + item: '', // Direct reference, not wrapped in quotes + }, + }, + inputs: {}, + outputs: {}, + metadata: { id: 'function', name: 'Process Item' }, + enabled: true, + } + + const workflow: SerializedWorkflow = { + version: '1.0', + blocks: [loopBlock, functionBlock], + connections: [], + loops: { + 'loop-1': { + id: 'loop-1', + nodes: ['function-1'], + iterations: 3, + loopType: 'forEach', + forEachItems: ['item1', 'item2', 'item3'], + }, + }, + } + + const resolver = new InputResolver(workflow, {}) + const context: ExecutionContext = { + workflowId: 'test', + blockStates: new Map(), + blockLogs: [], + metadata: { duration: 0 }, + environmentVariables: {}, + decisions: { router: new Map(), condition: new Map() }, + loopIterations: new Map([['loop-1', 1]]), + loopItems: new Map([['loop-1', 'item1']]), + completedLoops: new Set(), + executedBlocks: new Set(), + activeExecutionPath: new Set(['function-1']), + workflow, + } + + const resolvedInputs = resolver.resolveInputs(functionBlock, context) + + expect(resolvedInputs.item).toBe('item1') // Direct value, not quoted + }) + + it('should resolve direct loop.index reference without quotes', () => { + const loopBlock: SerializedBlock = { + id: 'loop-1', + position: { x: 0, y: 0 }, + config: { tool: 'loop', params: {} }, + inputs: {}, + outputs: {}, + metadata: { id: 'loop', name: 'Test Loop' }, + enabled: true, + } + + const functionBlock: SerializedBlock = { + id: 'function-1', + position: { x: 0, y: 0 }, + config: { + tool: 'function', + params: { + index: '', // Direct reference, not wrapped in quotes + }, + }, + inputs: {}, + outputs: {}, + metadata: { id: 'function', name: 'Process Index' }, + enabled: true, + } + + const workflow: SerializedWorkflow = { + version: '1.0', + blocks: [loopBlock, functionBlock], + connections: [], + loops: { + 'loop-1': { + id: 'loop-1', + nodes: ['function-1'], + iterations: 5, + loopType: 'for', + }, + }, + } + + const resolver = new InputResolver(workflow, {}) + const context: ExecutionContext = { + workflowId: 'test', + blockStates: new Map(), + blockLogs: [], + metadata: { duration: 0 }, + environmentVariables: {}, + decisions: { router: new Map(), condition: new Map() }, + loopIterations: new Map([['loop-1', 3]]), // Iteration 3 (corresponds to 0-based index 2) + loopItems: new Map(), + completedLoops: new Set(), + executedBlocks: new Set(), + activeExecutionPath: new Set(['function-1']), + workflow, + } + + const resolvedInputs = resolver.resolveInputs(functionBlock, context) + + expect(resolvedInputs.index).toBe(2) // Index 2 (adjusted from iteration 3) + }) + + it('should resolve direct loop.items reference for forEach loops', () => { + const loopBlock: SerializedBlock = { + id: 'loop-1', + position: { x: 0, y: 0 }, + config: { tool: 'loop', params: {} }, + inputs: {}, + outputs: {}, + metadata: { id: 'loop', name: 'Test Loop' }, + enabled: true, + } + + const functionBlock: SerializedBlock = { + id: 'function-1', + position: { x: 0, y: 0 }, + config: { + tool: 'function', + params: { + allItems: '', // Direct reference to all items + }, + }, + inputs: {}, + outputs: {}, + metadata: { id: 'function', name: 'Process All Items' }, + enabled: true, + } + + const items = ['item1', 'item2', 'item3'] + const workflow: SerializedWorkflow = { + version: '1.0', + blocks: [loopBlock, functionBlock], + connections: [], + loops: { + 'loop-1': { + id: 'loop-1', + nodes: ['function-1'], + iterations: 3, + loopType: 'forEach', + forEachItems: items, + }, + }, + } + + const resolver = new InputResolver(workflow, {}) + const loopItemsMap = new Map() + loopItemsMap.set('loop-1', 'item1') + loopItemsMap.set('loop-1_items', items) + + const context: ExecutionContext = { + workflowId: 'test', + blockStates: new Map(), + blockLogs: [], + metadata: { duration: 0 }, + environmentVariables: {}, + decisions: { router: new Map(), condition: new Map() }, + loopIterations: new Map([['loop-1', 1]]), + loopItems: loopItemsMap, + completedLoops: new Set(), + executedBlocks: new Set(), + activeExecutionPath: new Set(['function-1']), + workflow, + } + + const resolvedInputs = resolver.resolveInputs(functionBlock, context) + + expect(resolvedInputs.allItems).toEqual(items) // Direct array, not stringified + }) + + it('should handle missing loop-1_items gracefully', () => { + const loopBlock: SerializedBlock = { + id: 'loop-1', + position: { x: 0, y: 0 }, + config: { tool: 'loop', params: {} }, + inputs: {}, + outputs: {}, + metadata: { id: 'loop', name: 'Test Loop' }, + enabled: true, + } + + const functionBlock: SerializedBlock = { + id: 'function-1', + position: { x: 0, y: 0 }, + config: { + tool: 'function', + params: { + allItems: '', // Direct reference to all items + }, + }, + inputs: {}, + outputs: {}, + metadata: { id: 'function', name: 'Process All Items' }, + enabled: true, + } + + const items = ['item1', 'item2', 'item3'] + const workflow: SerializedWorkflow = { + version: '1.0', + blocks: [loopBlock, functionBlock], + connections: [], + loops: { + 'loop-1': { + id: 'loop-1', + nodes: ['function-1'], + iterations: 3, + loopType: 'forEach', + forEachItems: items, + }, + }, + } + + const resolver = new InputResolver(workflow, {}) + const loopItemsMap = new Map() + loopItemsMap.set('loop-1', 'item1') + // Note: loop-1_items is NOT set to test fallback behavior + + const context: ExecutionContext = { + workflowId: 'test', + blockStates: new Map(), + blockLogs: [], + metadata: { duration: 0 }, + environmentVariables: {}, + decisions: { router: new Map(), condition: new Map() }, + loopIterations: new Map([['loop-1', 1]]), + loopItems: loopItemsMap, + completedLoops: new Set(), + executedBlocks: new Set(), + activeExecutionPath: new Set(['function-1']), + workflow, + } + + const resolvedInputs = resolver.resolveInputs(functionBlock, context) + + // Should fall back to the items from the loop configuration + expect(resolvedInputs.allItems).toEqual(items) + }) + }) }) diff --git a/apps/sim/executor/resolver.ts b/apps/sim/executor/resolver.ts index 4c99bbee5a..d5f756170d 100644 --- a/apps/sim/executor/resolver.ts +++ b/apps/sim/executor/resolver.ts @@ -12,6 +12,7 @@ const logger = createLogger('InputResolver') export class InputResolver { private blockById: Map private blockByNormalizedName: Map + private loopsByBlockId: Map // Maps block ID to containing loop ID constructor( private workflow: SerializedWorkflow, @@ -42,6 +43,14 @@ export class InputResolver { ) } } + + // Create efficient loop lookup map + this.loopsByBlockId = new Map() + for (const [loopId, loop] of Object.entries(workflow.loops || {})) { + for (const blockId of loop.nodes) { + this.loopsByBlockId.set(blockId, loopId) + } + } } /** @@ -77,9 +86,9 @@ export class InputResolver { // Handle string values that may contain references if (typeof value === 'string') { const trimmedValue = value.trim() - const directVariableMatch = trimmedValue.match(/^]+)>$/) - // Check for direct variable reference first + // Check for direct variable reference pattern: + const directVariableMatch = trimmedValue.match(/^]+)>$/) if (directVariableMatch) { const variableName = directVariableMatch[1] const variable = this.findVariableByName(variableName) @@ -87,137 +96,95 @@ export class InputResolver { if (variable) { // Return the typed value directly result[key] = this.getTypedVariableValue(variable) - continue // Skip further processing for this direct reference + continue } logger.warn( `Direct variable reference not found. Treating as literal.` ) - result[key] = value // Return original string + result[key] = value continue } - // If not direct reference, proceed with interpolation + other resolutions - // First resolve variable references (interpolation) - const resolvedVars = this.resolveVariableReferences(value, block) + // Check for direct loop reference pattern: + const directLoopMatch = trimmedValue.match(/^]+)>$/) + if (directLoopMatch) { + // Find which loop this block belongs to using efficient lookup + const containingLoopId = this.loopsByBlockId.get(block.id) - // Then resolve block references - // Need to ensure input is string here if resolveVariableReferences returned non-string somehow (shouldn't) - const resolvedReferences = - typeof resolvedVars === 'string' - ? this.resolveBlockReferences(resolvedVars, context, block) - : resolvedVars // Pass non-string through + if (containingLoopId) { + const pathParts = directLoopMatch[1].split('.') + const loopValue = this.resolveLoopReference( + containingLoopId, + pathParts, + context, + block, + false + ) - // Check if this is an API key field - needs original context, less reliable here - // We might need a better way to pass isApiKey context down recursively - const isApiKey = this.isApiKeyField(block, value) // Check original value context - - // Then resolve environment variables - // Need to ensure input is string here - const resolvedEnv = - typeof resolvedReferences === 'string' - ? this.resolveEnvVariables(resolvedReferences, isApiKey) - : resolvedReferences // Pass non-string through - - // Special handling for different block types - const isFunctionBlock = block.metadata?.id === 'function' - const isApiBlock = block.metadata?.id === 'api' - - // For function blocks, we need special handling for code input - if (isFunctionBlock && key === 'code') { - result[key] = resolvedEnv - } - // For API blocks, handle body input specially - else if (isApiBlock && key === 'body') { - // If the final resolved value is a string that looks like JSON, parse it. - // Otherwise, use the value as is (it might already be an object/array from direct ref). - if (typeof resolvedEnv === 'string') { - try { - if (resolvedEnv.trim().startsWith('{') || resolvedEnv.trim().startsWith('[')) { - result[key] = JSON.parse(resolvedEnv) - } else { - result[key] = resolvedEnv // Keep as string if not JSON-like + if (loopValue !== null) { + // Parse the value if it's a JSON string + try { + result[key] = JSON.parse(loopValue) + } catch { + // If it's not valid JSON, use as is + result[key] = loopValue } - } catch { - result[key] = resolvedEnv // Keep as string if JSON parsing fails + continue } - } else { - result[key] = resolvedEnv // Already a non-string type } + + logger.warn(`Direct loop reference could not be resolved.`) + result[key] = value + continue } - // For other inputs, try to convert JSON strings to objects/arrays - else { - // If the final resolved value is a string that looks like JSON, parse it. - if (typeof resolvedEnv === 'string') { - try { - if ( - resolvedEnv.trim().length > 0 && - (resolvedEnv.trim().startsWith('{') || resolvedEnv.trim().startsWith('[')) - ) { - result[key] = JSON.parse(resolvedEnv) - } else { - // If not JSON-like or empty, keep as string - result[key] = resolvedEnv + + // Check for direct parallel reference pattern: + const directParallelMatch = trimmedValue.match(/^]+)>$/) + if (directParallelMatch) { + // Find which parallel this block belongs to + let containingParallelId: string | undefined + for (const [parallelId, parallel] of Object.entries(context.workflow?.parallels || {})) { + if (parallel.nodes.includes(block.id)) { + containingParallelId = parallelId + break + } + } + + if (containingParallelId) { + const pathParts = directParallelMatch[1].split('.') + const parallelValue = this.resolveParallelReference( + containingParallelId, + pathParts, + context, + block, + false + ) + + if (parallelValue !== null) { + // Parse the value if it's a JSON string + try { + result[key] = JSON.parse(parallelValue) + } catch { + // If it's not valid JSON, use as is + result[key] = parallelValue } - } catch { - // If it's not valid JSON, keep it as a string - result[key] = resolvedEnv + continue } - } else { - // If resolvedValue is already not a string (due to direct reference), keep its type - result[key] = resolvedEnv } + + logger.warn( + `Direct parallel reference could not be resolved.` + ) + result[key] = value + continue } + + // Process string with potential interpolations and references + result[key] = this.processStringValue(value, key, context, block) } // Handle objects and arrays recursively else if (typeof value === 'object') { - // Special handling for table-like arrays (e.g., from API params/headers) - if ( - Array.isArray(value) && - value.every((item) => typeof item === 'object' && item !== null && 'cells' in item) - ) { - // Resolve each cell's value within the array - // Cell values are resolved here and will be extracted by tools/utils.ts transformTable function - result[key] = value.map((row) => ({ - ...row, - cells: Object.entries(row.cells).reduce( - (acc, [cellKey, cellValue]) => { - if (typeof cellValue === 'string') { - const trimmedValue = cellValue.trim() - // Check for direct variable reference pattern: - const directVariableMatch = trimmedValue.match(/^]+)>$/) - - if (directVariableMatch) { - // Direct variable reference - handle with clean variable lookup - const variableName = directVariableMatch[1] - const variable = this.findVariableByName(variableName) - - if (variable) { - // Use the variable's typed value directly - acc[cellKey] = this.getTypedVariableValue(variable) - } else { - logger.warn( - `Variable reference not found in table cell` - ) - acc[cellKey] = cellValue // Fall back to original string - } - } else { - // Process interpolated variables, block references, and environment variables - // The resolveNestedStructure handles all types of resolution in a consistent way - acc[cellKey] = this.resolveNestedStructure(cellValue, context, block) - } - } else { - // Handle non-string values (objects, arrays, etc.) - acc[cellKey] = this.resolveNestedStructure(cellValue, context, block) - } - return acc - }, - {} as Record - ), - })) - } else { - // Use general recursive resolution for other objects/arrays - result[key] = this.resolveNestedStructure(value, context, block) - } + result[key] = this.processObjectValue(value, key, context, block) } // Pass through other value types else { @@ -393,7 +360,9 @@ export class InputResolver { // Check if we're in a template literal for function blocks const isInTemplateLiteral = currentBlock.metadata?.id === 'function' && - (/\${[^}]*]*}}\$/.test(value)) + value.includes('${') && + value.includes('}') && + value.includes('`') for (const match of blockMatches) { // Skip variables - they've already been processed @@ -486,125 +455,48 @@ export class InputResolver { // Special case for "loop" references - allows accessing loop properties if (blockRef.toLowerCase() === 'loop') { - // Find which loop this block belongs to - let containingLoopId: string | undefined + // Find which loop this block belongs to using efficient lookup + const containingLoopId = this.loopsByBlockId.get(currentBlock.id) - for (const [loopId, loop] of Object.entries(context.workflow?.loops || {})) { - if (loop.nodes.includes(currentBlock.id)) { - containingLoopId = loopId + if (containingLoopId) { + const formattedValue = this.resolveLoopReference( + containingLoopId, + pathParts, + context, + currentBlock, + isInTemplateLiteral + ) + + if (formattedValue !== null) { + resolvedValue = resolvedValue.replace(match, formattedValue) + continue + } + } + } + + // Special case for "parallel" references - allows accessing parallel properties + if (blockRef.toLowerCase() === 'parallel') { + // Find which parallel this block belongs to + let containingParallelId: string | undefined + + for (const [parallelId, parallel] of Object.entries(context.workflow?.parallels || {})) { + if (parallel.nodes.includes(currentBlock.id)) { + containingParallelId = parallelId break } } - if (containingLoopId) { - const loop = context.workflow?.loops[containingLoopId] - const loopType = loop?.loopType || 'for' + if (containingParallelId) { + const formattedValue = this.resolveParallelReference( + containingParallelId, + pathParts, + context, + currentBlock, + isInTemplateLiteral + ) - // Handle each loop property - if (pathParts[0] === 'currentItem') { - // Get the items to iterate over - const items = this.getLoopItems(loop, context) - - // Get the correct index using the LoopManager - const index = this.loopManager - ? this.loopManager.getLoopIndex(containingLoopId, currentBlock.id, context) - : context.loopIterations.get(containingLoopId) || 0 - - // Get the current item directly from the items array at the current index - if (Array.isArray(items) && index >= 0 && index < items.length) { - const currentItem = items[index] - - // Format the value based on type - if (currentItem !== undefined) { - if (typeof currentItem !== 'object' || currentItem === null) { - // Format primitive values properly for code contexts - resolvedValue = resolvedValue.replace( - match, - this.formatValueForCodeContext(currentItem, currentBlock, isInTemplateLiteral) - ) - } else if ( - Array.isArray(currentItem) && - currentItem.length === 2 && - typeof currentItem[0] === 'string' - ) { - // Handle [key, value] pair from Object.entries() - if (pathParts.length > 1) { - if (pathParts[1] === 'key') { - resolvedValue = resolvedValue.replace( - match, - this.formatValueForCodeContext( - currentItem[0], - currentBlock, - isInTemplateLiteral - ) - ) - } else if (pathParts[1] === 'value') { - resolvedValue = resolvedValue.replace( - match, - this.formatValueForCodeContext( - currentItem[1], - currentBlock, - isInTemplateLiteral - ) - ) - } - } else { - // Default to stringifying the whole item - resolvedValue = resolvedValue.replace(match, JSON.stringify(currentItem)) - } - } else { - // Navigate path if provided for objects - if (pathParts.length > 1) { - let itemValue = currentItem - for (let i = 1; i < pathParts.length; i++) { - if (!itemValue || typeof itemValue !== 'object') { - throw new Error( - `Invalid path "${pathParts[i]}" in loop item reference "${path}"` - ) - } - itemValue = itemValue[pathParts[i]] - if (itemValue === undefined) { - throw new Error(`No value found at path "${path}" in loop item`) - } - } - - // Use the formatter helper method - resolvedValue = resolvedValue.replace( - match, - this.formatValueForCodeContext(itemValue, currentBlock, isInTemplateLiteral) - ) - } else { - // Return the whole item as JSON - resolvedValue = resolvedValue.replace(match, JSON.stringify(currentItem)) - } - } - } - - continue - } - } else if (pathParts[0] === 'items' && loopType === 'forEach') { - // Get all items in the forEach loop - const items = this.getLoopItems(loop, context) - - if (items) { - // Format the items using our helper - resolvedValue = resolvedValue.replace( - match, - this.formatValueForCodeContext(items, currentBlock, isInTemplateLiteral) - ) - continue - } - } else if (pathParts[0] === 'index') { - // Use the LoopManager to get the correct index - const index = this.loopManager - ? this.loopManager.getLoopIndex(containingLoopId, currentBlock.id, context) - : context.loopIterations.get(containingLoopId) || 0 - - // For function blocks, we don't need to quote numbers, but use the formatter for consistency - resolvedValue = resolvedValue.replace( - match, - this.formatValueForCodeContext(index, currentBlock, isInTemplateLiteral) - ) + if (formattedValue !== null) { + resolvedValue = resolvedValue.replace(match, formattedValue) continue } } @@ -642,9 +534,7 @@ export class InputResolver { if (!blockState) { // If the block is in a loop, return empty string - const isInLoop = Object.values(this.workflow.loops || {}).some((loop) => - loop.nodes.includes(sourceBlock.id) - ) + const isInLoop = this.loopsByBlockId.has(sourceBlock.id) if (isInLoop) { resolvedValue = resolvedValue.replace(match, '') @@ -692,7 +582,9 @@ export class InputResolver { // Check if we're in a template literal const isInTemplateLiteral = currentBlock.metadata?.id === 'function' && - (/\${[^}]*]*}\$/.test(value)) + value.includes('${') && + value.includes('}') && + value.includes('`') // For code blocks, use our formatter formattedValue = this.formatValueForCodeContext( @@ -1042,9 +934,9 @@ export class InputResolver { return String(value) } - // Regular (non-template) contexts + // Regular (non-template) contexts - ALL strings need to be quoted for JavaScript if (typeof value === 'string') { - // Quote strings for JavaScript + // Always quote strings for JavaScript code return JSON.stringify(value) } if (typeof value === 'object' && value !== null) { @@ -1126,4 +1018,450 @@ export class InputResolver { return false } + + /** + * Resolves a loop reference (). + * Handles currentItem, items, and index references. + * + * @param loopId - ID of the loop + * @param pathParts - Parts of the reference path after 'loop' + * @param context - Current execution context + * @param currentBlock - Block containing the reference + * @param isInTemplateLiteral - Whether this is inside a template literal + * @returns Formatted value or null if reference is invalid + */ + private resolveLoopReference( + loopId: string, + pathParts: string[], + context: ExecutionContext, + currentBlock: SerializedBlock, + isInTemplateLiteral: boolean + ): string | null { + const loop = context.workflow?.loops[loopId] + if (!loop) return null + + const property = pathParts[0] + + switch (property) { + case 'currentItem': { + // Get the current item from context (set by loop handler) + const currentItem = context.loopItems.get(loopId) + if (currentItem === undefined) { + // If no current item stored, we're probably not in an active iteration + return '' + } + + // Handle nested path access (e.g., ) + if (pathParts.length > 1) { + // Special handling for [key, value] pairs from Object.entries() + if ( + Array.isArray(currentItem) && + currentItem.length === 2 && + typeof currentItem[0] === 'string' + ) { + const subProperty = pathParts[1] + if (subProperty === 'key') { + return this.formatValueForCodeContext( + currentItem[0], + currentBlock, + isInTemplateLiteral + ) + } + if (subProperty === 'value') { + return this.formatValueForCodeContext( + currentItem[1], + currentBlock, + isInTemplateLiteral + ) + } + } + + // Navigate nested path for objects + let value = currentItem + for (let i = 1; i < pathParts.length; i++) { + if (!value || typeof value !== 'object') { + throw new Error(`Invalid path "${pathParts[i]}" in loop item reference`) + } + value = value[pathParts[i]] + if (value === undefined) { + throw new Error(`No value found at path "loop.${pathParts.join('.')}" in loop item`) + } + } + return this.formatValueForCodeContext(value, currentBlock, isInTemplateLiteral) + } + + // Return the whole current item + return this.formatValueForCodeContext(currentItem, currentBlock, isInTemplateLiteral) + } + + case 'items': { + // Only valid for forEach loops + if (loop.loopType !== 'forEach') { + return null + } + + // Get all items - prefer stored items from context + const items = context.loopItems.get(`${loopId}_items`) || this.getLoopItems(loop, context) + if (!items) { + return '[]' + } + + return this.formatValueForCodeContext(items, currentBlock, isInTemplateLiteral) + } + + case 'index': { + // Get the current iteration index + const index = context.loopIterations.get(loopId) || 0 + // Adjust for the fact that the loop handler increments after setting up the iteration + const adjustedIndex = Math.max(0, index - 1) + return this.formatValueForCodeContext(adjustedIndex, currentBlock, isInTemplateLiteral) + } + + default: + return null + } + } + + /** + * Resolves a parallel reference (). + * Handles currentItem, items, and index references for parallel executions. + * + * @param parallelId - ID of the parallel block + * @param pathParts - Parts of the reference path after 'parallel' + * @param context - Current execution context + * @param currentBlock - Block containing the reference + * @param isInTemplateLiteral - Whether this is inside a template literal + * @returns Formatted value or null if reference is invalid + */ + private resolveParallelReference( + parallelId: string, + pathParts: string[], + context: ExecutionContext, + currentBlock: SerializedBlock, + isInTemplateLiteral: boolean + ): string | null { + const parallel = context.workflow?.parallels?.[parallelId] + if (!parallel) return null + + const property = pathParts[0] + + // For parallel blocks, we need to determine which parallel iteration this block is part of + // This is more complex than loops since multiple instances run concurrently + + switch (property) { + case 'currentItem': { + // Try to find the current item for this parallel execution + let currentItem = context.loopItems.get(parallelId) + + // If we have a current virtual block ID, use it to get the exact iteration + if (context.currentVirtualBlockId && context.parallelBlockMapping) { + const mapping = context.parallelBlockMapping.get(context.currentVirtualBlockId) + if (mapping && mapping.parallelId === parallelId) { + const iterationKey = `${parallelId}_iteration_${mapping.iterationIndex}` + const iterationItem = context.loopItems.get(iterationKey) + if (iterationItem !== undefined) { + currentItem = iterationItem + } + } + } else if (parallel.nodes.includes(currentBlock.id)) { + // Fallback: if we're inside a parallel execution but don't have currentVirtualBlockId + // This shouldn't happen in normal execution but provides backward compatibility + for (const [virtualId, mapping] of context.parallelBlockMapping || new Map()) { + if (mapping.originalBlockId === currentBlock.id && mapping.parallelId === parallelId) { + const iterationKey = `${parallelId}_iteration_${mapping.iterationIndex}` + const iterationItem = context.loopItems.get(iterationKey) + if (iterationItem !== undefined) { + currentItem = iterationItem + break + } + } + } + } + + // If not found directly, try to find it with parallel iteration suffix (backward compatibility) + if (currentItem === undefined) { + // Check for parallel-specific keys like "parallelId_parallel_0", "parallelId_parallel_1", etc. + for (let i = 0; i < 100; i++) { + // Reasonable upper limit + const parallelKey = `${parallelId}_parallel_${i}` + if (context.loopItems.has(parallelKey)) { + currentItem = context.loopItems.get(parallelKey) + break + } + } + } + + if (currentItem === undefined) { + return '' + } + + // Handle nested path access (e.g., ) + if (pathParts.length > 1) { + // Special handling for [key, value] pairs from Object.entries() + if ( + Array.isArray(currentItem) && + currentItem.length === 2 && + typeof currentItem[0] === 'string' + ) { + const subProperty = pathParts[1] + if (subProperty === 'key') { + return this.formatValueForCodeContext( + currentItem[0], + currentBlock, + isInTemplateLiteral + ) + } + if (subProperty === 'value') { + return this.formatValueForCodeContext( + currentItem[1], + currentBlock, + isInTemplateLiteral + ) + } + } + + // Navigate nested path for objects + let value = currentItem + for (let i = 1; i < pathParts.length; i++) { + if (!value || typeof value !== 'object') { + throw new Error(`Invalid path "${pathParts[i]}" in parallel item reference`) + } + value = value[pathParts[i]] + if (value === undefined) { + throw new Error( + `No value found at path "parallel.${pathParts.join('.')}" in parallel item` + ) + } + } + return this.formatValueForCodeContext(value, currentBlock, isInTemplateLiteral) + } + + // Return the whole current item + return this.formatValueForCodeContext(currentItem, currentBlock, isInTemplateLiteral) + } + + case 'items': { + // Get all items for the parallel distribution + const items = + context.loopItems.get(`${parallelId}_items`) || + (parallel.distribution && this.getParallelItems(parallel, context)) + if (!items) { + return '[]' + } + + return this.formatValueForCodeContext(items, currentBlock, isInTemplateLiteral) + } + + case 'index': { + // Get the current parallel index + let index = context.loopIterations.get(parallelId) + + // If we have a current virtual block ID, use it to get the exact iteration + if (context.currentVirtualBlockId && context.parallelBlockMapping) { + const mapping = context.parallelBlockMapping.get(context.currentVirtualBlockId) + if (mapping && mapping.parallelId === parallelId) { + index = mapping.iterationIndex + } + } else { + // Fallback: try to find it with parallel iteration suffix + if (index === undefined) { + for (let i = 0; i < 100; i++) { + const parallelKey = `${parallelId}_parallel_${i}` + if (context.loopIterations.has(parallelKey)) { + index = context.loopIterations.get(parallelKey) + break + } + } + } + } + + const adjustedIndex = index !== undefined ? index : 0 + return this.formatValueForCodeContext(adjustedIndex, currentBlock, isInTemplateLiteral) + } + + default: + return null + } + } + + /** + * Gets the items for a parallel distribution. + * Similar to getLoopItems but for parallel blocks. + * + * @param parallel - The parallel configuration + * @param context - Current execution context + * @returns The items to distribute (array or object) + */ + private getParallelItems( + parallel: any, + context: ExecutionContext + ): any[] | Record | null { + if (!parallel || !parallel.distribution) return null + + // If items are already available as an array or object, return them directly + if ( + Array.isArray(parallel.distribution) || + (typeof parallel.distribution === 'object' && parallel.distribution !== null) + ) { + return parallel.distribution + } + + // If it's a string, try to evaluate it (could be an expression or JSON) + if (typeof parallel.distribution === 'string') { + try { + // Check if it's valid JSON + const trimmedExpression = parallel.distribution.trim() + if (trimmedExpression.startsWith('[') || trimmedExpression.startsWith('{')) { + try { + return JSON.parse(trimmedExpression) + } catch { + // Continue with expression evaluation + } + } + + // Try to evaluate as an expression + if (trimmedExpression && !trimmedExpression.startsWith('//')) { + const result = new Function('context', `return ${parallel.distribution}`)(context) + if (Array.isArray(result) || (typeof result === 'object' && result !== null)) { + return result + } + } + } catch (e) { + console.error('Error evaluating parallel distribution items:', e) + } + } + + return [] + } + + /** + * Processes a string value that may contain interpolations and references. + * Handles the full resolution pipeline: variables -> blocks -> environment. + * + * @param value - String value to process + * @param key - The parameter key (for special handling) + * @param context - Current execution context + * @param block - Block containing the value + * @returns Processed value (may be parsed JSON or string) + */ + private processStringValue( + value: string, + key: string, + context: ExecutionContext, + block: SerializedBlock + ): any { + // First resolve variable references (interpolation) + const resolvedVars = this.resolveVariableReferences(value, block) + + // Then resolve block references + const resolvedReferences = this.resolveBlockReferences(resolvedVars, context, block) + + // Check if this is an API key field + const isApiKey = this.isApiKeyField(block, value) + + // Then resolve environment variables + const resolvedEnv = this.resolveEnvVariables(resolvedReferences, isApiKey) + + // Special handling for different block types + const blockType = block.metadata?.id + + // For function blocks, code input doesn't need JSON parsing + if (blockType === 'function' && key === 'code') { + return resolvedEnv + } + + // For API blocks, handle body input specially + if (blockType === 'api' && key === 'body') { + return this.tryParseJSON(resolvedEnv) + } + + // For other inputs, try to convert JSON strings to objects/arrays + return this.tryParseJSON(resolvedEnv) + } + + /** + * Processes object/array values recursively. + * Handles special cases like table-like arrays with cells. + * + * @param value - Object or array to process + * @param key - The parameter key + * @param context - Current execution context + * @param block - Block containing the value + * @returns Processed object/array + */ + private processObjectValue( + value: any, + key: string, + context: ExecutionContext, + block: SerializedBlock + ): any { + // Special handling for table-like arrays (e.g., from API params/headers) + if ( + Array.isArray(value) && + value.every((item) => typeof item === 'object' && item !== null && 'cells' in item) + ) { + // Resolve each cell's value within the array + return value.map((row) => ({ + ...row, + cells: Object.entries(row.cells).reduce( + (acc, [cellKey, cellValue]) => { + if (typeof cellValue === 'string') { + const trimmedValue = cellValue.trim() + // Check for direct variable reference + const directVariableMatch = trimmedValue.match(/^]+)>$/) + + if (directVariableMatch) { + const variableName = directVariableMatch[1] + const variable = this.findVariableByName(variableName) + + if (variable) { + acc[cellKey] = this.getTypedVariableValue(variable) + } else { + logger.warn( + `Variable reference not found in table cell` + ) + acc[cellKey] = cellValue + } + } else { + // Process interpolated variables, block references, and environment variables + acc[cellKey] = this.resolveNestedStructure(cellValue, context, block) + } + } else { + // Handle non-string values + acc[cellKey] = this.resolveNestedStructure(cellValue, context, block) + } + return acc + }, + {} as Record + ), + })) + } + + // Use general recursive resolution for other objects/arrays + return this.resolveNestedStructure(value, context, block) + } + + /** + * Tries to parse a string as JSON if it looks like JSON. + * Returns the original string if parsing fails or it doesn't look like JSON. + * + * @param value - Value to potentially parse + * @returns Parsed JSON or original value + */ + private tryParseJSON(value: any): any { + if (typeof value !== 'string') { + return value + } + + const trimmed = value.trim() + if (trimmed.length > 0 && (trimmed.startsWith('{') || trimmed.startsWith('['))) { + try { + return JSON.parse(trimmed) + } catch { + // Not valid JSON, return as string + } + } + + return value + } } diff --git a/apps/sim/executor/types.ts b/apps/sim/executor/types.ts index 968ab4ab4b..943317850c 100644 --- a/apps/sim/executor/types.ts +++ b/apps/sim/executor/types.ts @@ -96,6 +96,45 @@ export interface ExecutionContext { loopItems: Map // Tracks current item for forEach loops completedLoops: Set // Tracks which loops have completed all iterations + // Parallel execution tracking + parallelExecutions?: Map< + string, + { + parallelCount: number + distributionItems: any[] | Record | null + completedExecutions: number + executionResults: Map + activeIterations: Set + currentIteration: number + parallelType?: 'count' | 'collection' + } + > + + // Loop execution tracking + loopExecutions?: Map< + string, + { + maxIterations: number + loopType: 'for' | 'forEach' + forEachItems?: any[] | Record | null + executionResults: Map // iteration_0, iteration_1, etc. + currentIteration: number + } + > + + // Mapping for virtual parallel block IDs to their original blocks + parallelBlockMapping?: Map< + string, + { + originalBlockId: string + parallelId: string + iterationIndex: number + } + > + + // Current virtual block being executed (for parallel iterations) + currentVirtualBlockId?: string + // Execution tracking executedBlocks: Set // Set of block IDs that have been executed activeExecutionPath: Set // Set of block IDs in the current execution path diff --git a/apps/sim/lib/webhooks/utils.ts b/apps/sim/lib/webhooks/utils.ts index 936607881a..97e71fbe25 100644 --- a/apps/sim/lib/webhooks/utils.ts +++ b/apps/sim/lib/webhooks/utils.ts @@ -13,6 +13,7 @@ import { environment, userStats, webhook } from '@/db/schema' import { Executor } from '@/executor' import { Serializer } from '@/serializer' import { mergeSubblockStateAsync } from '@/stores/workflows/utils' +import type { WorkflowState } from '@/stores/workflows/workflow/types' const logger = createLogger('WebhookUtils') @@ -345,8 +346,8 @@ export async function executeWorkflowFromPayload( }) throw new Error(`Workflow ${foundWorkflow.id} has no state`) } - const state = foundWorkflow.state as any - const { blocks, edges, loops } = state + const state = foundWorkflow.state as WorkflowState + const { blocks, edges, loops, parallels } = state // DEBUG: Log state information logger.debug(`[${requestId}] TRACE: Retrieved workflow state`, { @@ -465,7 +466,12 @@ export async function executeWorkflowFromPayload( // Serialize and get workflow variables const serializeStartTime = Date.now() - const serializedWorkflow = new Serializer().serializeWorkflow(mergedStates as any, edges, loops) + const serializedWorkflow = new Serializer().serializeWorkflow( + mergedStates as any, + edges, + loops, + parallels + ) let workflowVariables = {} if (foundWorkflow.variables) { try { diff --git a/apps/sim/serializer/index.ts b/apps/sim/serializer/index.ts index 141084d081..a410a4d151 100644 --- a/apps/sim/serializer/index.ts +++ b/apps/sim/serializer/index.ts @@ -1,7 +1,7 @@ import type { Edge } from 'reactflow' import { createLogger } from '@/lib/logs/console-logger' import { getBlock } from '@/blocks' -import type { BlockState, Loop } from '@/stores/workflows/workflow/types' +import type { BlockState, Loop, Parallel } from '@/stores/workflows/workflow/types' import type { SerializedBlock, SerializedWorkflow } from './types' const logger = createLogger('Serializer') @@ -10,7 +10,8 @@ export class Serializer { serializeWorkflow( blocks: Record, edges: Edge[], - loops: Record + loops: Record, + parallels?: Record ): SerializedWorkflow { return { version: '1.0', @@ -22,10 +23,33 @@ export class Serializer { targetHandle: edge.targetHandle || undefined, })), loops, + parallels, } } private serializeBlock(block: BlockState): SerializedBlock { + // Special handling for subflow blocks (loops, parallels, etc.) + if (block.type === 'loop' || block.type === 'parallel') { + return { + id: block.id, + position: block.position, + config: { + tool: '', // Loop blocks don't have tools + params: block.data || {}, // Preserve the block data (parallelType, count, etc.) + }, + inputs: {}, + outputs: block.outputs, + metadata: { + id: block.type, + name: block.name, + description: block.type === 'loop' ? 'Loop container' : 'Parallel container', + category: 'subflow', + color: block.type === 'loop' ? '#3b82f6' : '#8b5cf6', + }, + enabled: block.enabled, + } + } + const blockConfig = getBlock(block.type) if (!blockConfig) { throw new Error(`Invalid block type: ${block.type}`) @@ -99,6 +123,11 @@ export class Serializer { } private extractParams(block: BlockState): Record { + // Special handling for subflow blocks (loops, parallels, etc.) + if (block.type === 'loop' || block.type === 'parallel') { + return {} // Loop and parallel blocks don't have traditional params + } + const blockConfig = getBlock(block.type) if (!blockConfig) { throw new Error(`Invalid block type: ${block.type}`) @@ -156,6 +185,20 @@ export class Serializer { throw new Error(`Invalid block type: ${serializedBlock.metadata?.id}`) } + // Special handling for subflow blocks (loops, parallels, etc.) + if (blockType === 'loop' || blockType === 'parallel') { + return { + id: serializedBlock.id, + type: blockType, + name: serializedBlock.metadata?.name || (blockType === 'loop' ? 'Loop' : 'Parallel'), + position: serializedBlock.position, + subBlocks: {}, // Loops and parallels don't have traditional subBlocks + outputs: serializedBlock.outputs, + enabled: serializedBlock.enabled ?? true, + data: serializedBlock.config.params, // Preserve the data (parallelType, count, etc.) + } + } + const blockConfig = getBlock(blockType) if (!blockConfig) { throw new Error(`Invalid block type: ${blockType}`) diff --git a/apps/sim/serializer/types.ts b/apps/sim/serializer/types.ts index fc8fbe5e0b..fcf1d355a9 100644 --- a/apps/sim/serializer/types.ts +++ b/apps/sim/serializer/types.ts @@ -6,6 +6,7 @@ export interface SerializedWorkflow { blocks: SerializedBlock[] connections: SerializedConnection[] loops: Record + parallels?: Record } export interface SerializedConnection { @@ -46,3 +47,9 @@ export interface SerializedLoop { loopType?: 'for' | 'forEach' | 'while' forEachItems?: any[] | Record | string // Items to iterate over or expression to evaluate } + +export interface SerializedParallel { + id: string + nodes: string[] + distribution?: any[] | Record | string // Items to distribute or expression to evaluate +} diff --git a/apps/sim/stores/index.ts b/apps/sim/stores/index.ts index 17e6487b52..d9712570e8 100644 --- a/apps/sim/stores/index.ts +++ b/apps/sim/stores/index.ts @@ -180,6 +180,7 @@ function handleBeforeUnload(event: BeforeUnloadEvent): void { blocks: currentState.blocks, edges: currentState.edges, loops: currentState.loops, + parallels: currentState.parallels, isDeployed: currentState.isDeployed, deployedAt: currentState.deployedAt, lastSaved: Date.now(), diff --git a/apps/sim/stores/workflows/index.ts b/apps/sim/stores/workflows/index.ts index 0d7010c83d..5586b69df8 100644 --- a/apps/sim/stores/workflows/index.ts +++ b/apps/sim/stores/workflows/index.ts @@ -37,6 +37,7 @@ export function getWorkflowWithValues(workflowId: string) { blocks: currentState.blocks, edges: currentState.edges, loops: currentState.loops, + parallels: currentState.parallels, isDeployed: deploymentStatus?.isDeployed || false, deployedAt: deploymentStatus?.deployedAt, lastSaved: currentState.lastSaved, @@ -70,6 +71,7 @@ export function getWorkflowWithValues(workflowId: string) { blocks: mergedBlocks, edges: workflowState.edges, loops: workflowState.loops, + parallels: workflowState.parallels, lastSaved: workflowState.lastSaved, isDeployed: workflowState.isDeployed, deployedAt: workflowState.deployedAt, @@ -124,6 +126,7 @@ export function getAllWorkflowsWithValues() { blocks: currentState.blocks, edges: currentState.edges, loops: currentState.loops, + parallels: currentState.parallels, isDeployed: deploymentStatus?.isDeployed || false, deployedAt: deploymentStatus?.deployedAt, lastSaved: currentState.lastSaved, @@ -162,6 +165,7 @@ export function getAllWorkflowsWithValues() { blocks: mergedBlocks, edges: workflowState.edges, loops: workflowState.loops, + parallels: workflowState.parallels, lastSaved: workflowState.lastSaved, isDeployed: workflowState.isDeployed, deployedAt: workflowState.deployedAt, diff --git a/apps/sim/stores/workflows/middleware.ts b/apps/sim/stores/workflows/middleware.ts index bd46d110d0..9fb98e881c 100644 --- a/apps/sim/stores/workflows/middleware.ts +++ b/apps/sim/stores/workflows/middleware.ts @@ -28,6 +28,14 @@ interface HistoryActions { // MAX for each individual workflow const MAX_HISTORY_LENGTH = 20 +// Default empty state for consistent initialization +const EMPTY_WORKFLOW_STATE = { + blocks: {}, + edges: [] as any[], + loops: {}, + parallels: {}, +} + // Types for workflow store with history management capabilities export interface WorkflowStoreWithHistory extends WorkflowStore, HistoryActions { history: WorkflowHistory @@ -46,6 +54,7 @@ export const withHistory = ( blocks: initialState.blocks, edges: initialState.edges, loops: initialState.loops, + parallels: initialState.parallels, }, timestamp: Date.now(), action: 'Initial state', @@ -110,6 +119,7 @@ export const withHistory = ( blocks: currentState.blocks, edges: currentState.edges, loops: currentState.loops, + parallels: currentState.parallels, history: currentState.history, isDeployed: currentState.isDeployed, deployedAt: currentState.deployedAt, @@ -161,6 +171,7 @@ export const withHistory = ( blocks: currentState.blocks, edges: currentState.edges, loops: currentState.loops, + parallels: currentState.parallels, history: currentState.history, isDeployed: currentState.isDeployed, deployedAt: currentState.deployedAt, @@ -171,13 +182,11 @@ export const withHistory = ( // Reset workflow to empty state clear: () => { const newState = { - blocks: {}, - edges: [], - loops: {}, + ...EMPTY_WORKFLOW_STATE, history: { past: [], present: { - state: { blocks: {}, edges: [], loops: {} }, + state: { ...EMPTY_WORKFLOW_STATE }, timestamp: Date.now(), action: 'Clear workflow', subblockValues: {}, @@ -236,6 +245,7 @@ export const withHistory = ( blocks: currentState.blocks, edges: currentState.edges, loops: currentState.loops, + parallels: currentState.parallels, history: currentState.history, isDeployed: currentState.isDeployed, deployedAt: currentState.deployedAt, @@ -256,6 +266,7 @@ export const createHistoryEntry = (state: WorkflowState, action: string): Histor blocks: { ...state.blocks }, edges: [...state.edges], loops: { ...state.loops }, + parallels: { ...state.parallels }, } // Capture the current subblock values for this workflow diff --git a/apps/sim/stores/workflows/persistence.ts b/apps/sim/stores/workflows/persistence.ts index 6c8a1ae386..a6f71b9f47 100644 --- a/apps/sim/stores/workflows/persistence.ts +++ b/apps/sim/stores/workflows/persistence.ts @@ -158,11 +158,17 @@ export function setupUnloadPersistence(): void { // Save workflow state const currentState = useWorkflowStore.getState() + // Generate loops from the current blocks for consistency + const generatedLoops = currentState.generateLoopBlocks + ? currentState.generateLoopBlocks() + : {} + // Save the complete state including history which is added by middleware saveWorkflowState(currentId, { blocks: currentState.blocks, edges: currentState.edges, - loops: currentState.loops, + loops: generatedLoops, + parallels: currentState.parallels, isDeployed: currentState.isDeployed, deployedAt: currentState.deployedAt, lastSaved: Date.now(), diff --git a/apps/sim/stores/workflows/registry/store.ts b/apps/sim/stores/workflows/registry/store.ts index 7b681f1d49..63863accdd 100644 --- a/apps/sim/stores/workflows/registry/store.ts +++ b/apps/sim/stores/workflows/registry/store.ts @@ -128,6 +128,7 @@ function resetWorkflowStores() { blocks: {}, edges: [], loops: {}, + parallels: {}, isDeployed: false, deployedAt: undefined, }, @@ -497,6 +498,7 @@ export const useWorkflowRegistry = create()( blocks: currentState.blocks, edges: currentState.edges, loops: currentState.loops, + parallels: currentState.parallels, history: currentState.history, isDeployed: currentState.isDeployed, deployedAt: currentState.deployedAt, @@ -517,6 +519,7 @@ export const useWorkflowRegistry = create()( const { blocks, edges, + parallels, history, loops, isDeployed, @@ -545,6 +548,7 @@ export const useWorkflowRegistry = create()( blocks, edges, loops, + parallels, isDeployed: workflowIsDeployed !== undefined ? workflowIsDeployed : false, deployedAt: workflowDeployedAt ? new Date(workflowDeployedAt) : undefined, needsRedeployment: @@ -557,7 +561,8 @@ export const useWorkflowRegistry = create()( state: { blocks, edges, - loops: {}, + loops, + parallels, isDeployed: workflowIsDeployed !== undefined ? workflowIsDeployed : false, deployedAt: workflowDeployedAt, }, @@ -599,6 +604,7 @@ export const useWorkflowRegistry = create()( blocks: {}, edges: [], loops: {}, + parallels: {}, isDeployed: false, deployedAt: undefined, deploymentStatuses: {}, @@ -610,6 +616,7 @@ export const useWorkflowRegistry = create()( blocks: {}, edges: [], loops: {}, + parallels: {}, isDeployed: false, deployedAt: undefined, }, @@ -664,6 +671,7 @@ export const useWorkflowRegistry = create()( blocks: options.marketplaceState.blocks || {}, edges: options.marketplaceState.edges || [], loops: options.marketplaceState.loops || {}, + parallels: options.marketplaceState.parallels || {}, isDeployed: false, deployedAt: undefined, deploymentStatuses: {}, // Initialize empty deployment statuses map @@ -675,6 +683,7 @@ export const useWorkflowRegistry = create()( blocks: options.marketplaceState.blocks || {}, edges: options.marketplaceState.edges || [], loops: options.marketplaceState.loops || {}, + parallels: options.marketplaceState.parallels || {}, isDeployed: false, deployedAt: undefined, workspaceId, // Include workspace ID in history @@ -788,6 +797,7 @@ export const useWorkflowRegistry = create()( }, edges: [], loops: {}, + parallels: {}, isDeployed: false, deployedAt: undefined, deploymentStatuses: {}, // Initialize empty deployment statuses map @@ -801,6 +811,7 @@ export const useWorkflowRegistry = create()( }, edges: [], loops: {}, + parallels: {}, isDeployed: false, deployedAt: undefined, workspaceId, // Include workspace ID in history @@ -887,6 +898,7 @@ export const useWorkflowRegistry = create()( blocks: state.blocks || {}, edges: state.edges || [], loops: state.loops || {}, + parallels: state.parallels || {}, isDeployed: false, deployedAt: undefined, history: { @@ -896,6 +908,7 @@ export const useWorkflowRegistry = create()( blocks: state.blocks || {}, edges: state.edges || [], loops: state.loops || {}, + parallels: state.parallels || {}, isDeployed: false, deployedAt: undefined, }, @@ -982,6 +995,7 @@ export const useWorkflowRegistry = create()( blocks: sourceState.blocks || {}, edges: sourceState.edges || [], loops: sourceState.loops || {}, + parallels: sourceState.parallels || {}, isDeployed: false, // Reset deployment status deployedAt: undefined, // Reset deployment timestamp workspaceId, // Include workspaceId in state @@ -993,6 +1007,7 @@ export const useWorkflowRegistry = create()( blocks: sourceState.blocks || {}, edges: sourceState.edges || [], loops: sourceState.loops || {}, + parallels: sourceState.parallels || {}, isDeployed: false, deployedAt: undefined, workspaceId, // Include workspaceId in history state @@ -1087,11 +1102,13 @@ export const useWorkflowRegistry = create()( newActiveWorkflowId = remainingIds[0] const savedState = loadWorkflowState(newActiveWorkflowId) if (savedState) { - const { blocks, edges, history, loops, isDeployed, deployedAt } = savedState + const { blocks, edges, history, loops, parallels, isDeployed, deployedAt } = + savedState useWorkflowStore.setState({ blocks, edges, loops, + parallels, isDeployed: isDeployed || false, deployedAt: deployedAt ? new Date(deployedAt) : undefined, hasActiveSchedule: false, @@ -1102,6 +1119,7 @@ export const useWorkflowRegistry = create()( blocks, edges, loops, + parallels, isDeployed: isDeployed || false, deployedAt, }, @@ -1117,6 +1135,7 @@ export const useWorkflowRegistry = create()( blocks: {}, edges: [], loops: {}, + parallels: {}, isDeployed: false, deployedAt: undefined, hasActiveSchedule: false, @@ -1127,6 +1146,7 @@ export const useWorkflowRegistry = create()( blocks: {}, edges: [], loops: {}, + parallels: {}, isDeployed: false, deployedAt: undefined, }, diff --git a/apps/sim/stores/workflows/sync.ts b/apps/sim/stores/workflows/sync.ts index 2d89ca9671..c2fbeb412b 100644 --- a/apps/sim/stores/workflows/sync.ts +++ b/apps/sim/stores/workflows/sync.ts @@ -264,6 +264,7 @@ export async function fetchWorkflowsFromDB(): Promise { blocks: state.blocks || {}, edges: state.edges || [], loops: state.loops || {}, + parallels: state.parallels || {}, isDeployed: isDeployed || false, deployedAt: deployedAt ? new Date(deployedAt) : undefined, apiKey, diff --git a/apps/sim/stores/workflows/workflow/store.test.ts b/apps/sim/stores/workflows/workflow/store.test.ts new file mode 100644 index 0000000000..bb82446551 --- /dev/null +++ b/apps/sim/stores/workflows/workflow/store.test.ts @@ -0,0 +1,249 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { useWorkflowStore } from './store' + +describe('workflow store', () => { + beforeEach(() => { + useWorkflowStore.setState({ + blocks: {}, + edges: [], + loops: {}, + parallels: {}, + }) + }) + + describe('loop management', () => { + it('should regenerate loops when updateLoopCount is called', () => { + const { addBlock, updateLoopCount } = useWorkflowStore.getState() + + // Add a loop block + addBlock( + 'loop1', + 'loop', + 'Test Loop', + { x: 0, y: 0 }, + { + loopType: 'for', + count: 5, + collection: '', + } + ) + + // Update loop count + updateLoopCount('loop1', 10) + + const state = useWorkflowStore.getState() + + // Check that block data was updated + expect(state.blocks.loop1?.data?.count).toBe(10) + + // Check that loops were regenerated + expect(state.loops.loop1).toBeDefined() + expect(state.loops.loop1.iterations).toBe(10) + }) + + it('should regenerate loops when updateLoopType is called', () => { + const { addBlock, updateLoopType } = useWorkflowStore.getState() + + // Add a loop block + addBlock( + 'loop1', + 'loop', + 'Test Loop', + { x: 0, y: 0 }, + { + loopType: 'for', + count: 5, + collection: '["a", "b", "c"]', + } + ) + + // Update loop type + updateLoopType('loop1', 'forEach') + + const state = useWorkflowStore.getState() + + // Check that block data was updated + expect(state.blocks.loop1?.data?.loopType).toBe('forEach') + + // Check that loops were regenerated with forEach items + expect(state.loops.loop1).toBeDefined() + expect(state.loops.loop1.loopType).toBe('forEach') + expect(state.loops.loop1.forEachItems).toEqual(['a', 'b', 'c']) + }) + + it('should regenerate loops when updateLoopCollection is called', () => { + const { addBlock, updateLoopCollection } = useWorkflowStore.getState() + + // Add a forEach loop block + addBlock( + 'loop1', + 'loop', + 'Test Loop', + { x: 0, y: 0 }, + { + loopType: 'forEach', + collection: '["item1", "item2"]', + } + ) + + // Update loop collection + updateLoopCollection('loop1', '["item1", "item2", "item3"]') + + const state = useWorkflowStore.getState() + + // Check that block data was updated + expect(state.blocks.loop1?.data?.collection).toBe('["item1", "item2", "item3"]') + + // Check that loops were regenerated with new items + expect(state.loops.loop1).toBeDefined() + expect(state.loops.loop1.forEachItems).toEqual(['item1', 'item2', 'item3']) + }) + + it('should clamp loop count between 1 and 50', () => { + const { addBlock, updateLoopCount } = useWorkflowStore.getState() + + // Add a loop block + addBlock( + 'loop1', + 'loop', + 'Test Loop', + { x: 0, y: 0 }, + { + loopType: 'for', + count: 5, + collection: '', + } + ) + + // Try to set count above max + updateLoopCount('loop1', 100) + let state = useWorkflowStore.getState() + expect(state.blocks.loop1?.data?.count).toBe(50) + + // Try to set count below min + updateLoopCount('loop1', 0) + state = useWorkflowStore.getState() + expect(state.blocks.loop1?.data?.count).toBe(1) + }) + }) + + describe('parallel management', () => { + it('should regenerate parallels when updateParallelCount is called', () => { + const { addBlock, updateParallelCount } = useWorkflowStore.getState() + + // Add a parallel block + addBlock( + 'parallel1', + 'parallel', + 'Test Parallel', + { x: 0, y: 0 }, + { + count: 3, + collection: '', + } + ) + + // Update parallel count + updateParallelCount('parallel1', 5) + + const state = useWorkflowStore.getState() + + // Check that block data was updated + expect(state.blocks.parallel1?.data?.count).toBe(5) + + // Check that parallels were regenerated + expect(state.parallels.parallel1).toBeDefined() + expect(state.parallels.parallel1.distribution).toBe('') + }) + + it('should regenerate parallels when updateParallelCollection is called', () => { + const { addBlock, updateParallelCollection } = useWorkflowStore.getState() + + // Add a parallel block + addBlock( + 'parallel1', + 'parallel', + 'Test Parallel', + { x: 0, y: 0 }, + { + count: 3, + collection: '["item1", "item2"]', + } + ) + + // Update parallel collection + updateParallelCollection('parallel1', '["item1", "item2", "item3"]') + + const state = useWorkflowStore.getState() + + // Check that block data was updated + expect(state.blocks.parallel1?.data?.collection).toBe('["item1", "item2", "item3"]') + + // Check that parallels were regenerated + expect(state.parallels.parallel1).toBeDefined() + expect(state.parallels.parallel1.distribution).toBe('["item1", "item2", "item3"]') + + // Verify that the parallel count matches the collection size + const parsedDistribution = JSON.parse(state.parallels.parallel1.distribution as string) + expect(parsedDistribution).toHaveLength(3) + }) + + it('should clamp parallel count between 1 and 50', () => { + const { addBlock, updateParallelCount } = useWorkflowStore.getState() + + // Add a parallel block + addBlock( + 'parallel1', + 'parallel', + 'Test Parallel', + { x: 0, y: 0 }, + { + count: 5, + collection: '', + } + ) + + // Try to set count above max + updateParallelCount('parallel1', 100) + let state = useWorkflowStore.getState() + expect(state.blocks.parallel1?.data?.count).toBe(50) + + // Try to set count below min + updateParallelCount('parallel1', 0) + state = useWorkflowStore.getState() + expect(state.blocks.parallel1?.data?.count).toBe(1) + }) + + it('should save to history when updating parallel properties', () => { + const { addBlock, updateParallelCollection, updateParallelCount } = + useWorkflowStore.getState() + + // Add a parallel block + addBlock( + 'parallel1', + 'parallel', + 'Test Parallel', + { x: 0, y: 0 }, + { + count: 3, + collection: '', + } + ) + + // Get initial history length + const initialHistoryLength = useWorkflowStore.getState().history.past.length + + // Update collection + updateParallelCollection('parallel1', '["a", "b", "c"]') + + let state = useWorkflowStore.getState() + expect(state.history.past.length).toBe(initialHistoryLength + 1) + + // Update count + updateParallelCount('parallel1', 5) + + state = useWorkflowStore.getState() + expect(state.history.past.length).toBe(initialHistoryLength + 2) + }) + }) +}) diff --git a/apps/sim/stores/workflows/workflow/store.ts b/apps/sim/stores/workflows/workflow/store.ts index dd374b2416..452112503e 100644 --- a/apps/sim/stores/workflows/workflow/store.ts +++ b/apps/sim/stores/workflows/workflow/store.ts @@ -1,7 +1,6 @@ import type { Edge } from 'reactflow' import { create } from 'zustand' import { devtools } from 'zustand/middleware' -import { createLogger } from '@/lib/logs/console-logger' import { getBlock } from '@/blocks' import { resolveOutputType } from '@/blocks/utils' import { pushHistory, type WorkflowStoreWithHistory, withHistory } from '../middleware' @@ -10,15 +9,14 @@ import { useWorkflowRegistry } from '../registry/store' import { useSubBlockStore } from '../subblock/store' import { markWorkflowsDirty, workflowSync } from '../sync' import { mergeSubblockState } from '../utils' -import type { Loop, Position, SubBlockState, SyncControl, WorkflowState } from './types' -import { detectCycle } from './utils' - -const _logger = createLogger('WorkflowStore') +import type { Position, SubBlockState, SyncControl, WorkflowState } from './types' +import { generateLoopBlocks, generateParallelBlocks } from './utils' const initialState = { blocks: {}, edges: [], loops: {}, + parallels: {}, lastSaved: undefined, // Legacy deployment fields (keeping for compatibility but they will be deprecated) isDeployed: false, @@ -31,7 +29,14 @@ const initialState = { history: { past: [], present: { - state: { blocks: {}, edges: [], loops: {}, isDeployed: false, isPublished: false }, + state: { + blocks: {}, + edges: [], + loops: {}, + parallels: {}, + isDeployed: false, + isPublished: false, + }, timestamp: Date.now(), action: 'Initial state', subblockValues: {}, @@ -87,10 +92,61 @@ export const useWorkflowStore = create()( set({ needsRedeployment }) }, - addBlock: (id: string, type: string, name: string, position: Position) => { + addBlock: ( + id: string, + type: string, + name: string, + position: Position, + data?: Record, + parentId?: string, + extent?: 'parent' + ) => { 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: true, + horizontalHandles: true, + isWide: false, + height: 0, + data: nodeData, + }, + }, + edges: [...get().edges], + loops: get().generateLoopBlocks(), + parallels: get().generateParallelBlocks(), + } + + set(newState) + pushHistory(set, get, newState, `Add ${type} node`) + get().updateLastSaved() + workflowSync.sync() + return + } + if (!blockConfig) return + // Merge parentId and extent into data for regular blocks + const nodeData = { + ...data, + ...(parentId && { parentId, extent: extent || 'parent' }), + } + const subBlocks: Record = {} blockConfig.subBlocks.forEach((subBlock) => { const subBlockId = subBlock.id @@ -117,10 +173,12 @@ export const useWorkflowStore = create()( horizontalHandles: true, isWide: false, height: 0, + data: nodeData, }, }, edges: [...get().edges], - loops: { ...get().loops }, + loops: get().generateLoopBlocks(), + parallels: get().generateParallelBlocks(), } set(newState) @@ -146,6 +204,96 @@ export const useWorkflowStore = create()( // No sync here as this is a frequent operation during dragging }, + updateNodeDimensions: (id: string, dimensions: { width: number; height: number }) => { + set((state) => ({ + blocks: { + ...state.blocks, + [id]: { + ...state.blocks[id], + data: { + ...state.blocks[id].data, + width: dimensions.width, + height: dimensions.height, + }, + }, + }, + edges: [...state.edges], + })) + get().updateLastSaved() + workflowSync.sync() + }, + + updateParentId: (id: string, parentId: string, extent: 'parent') => { + const block = get().blocks[id] + if (!block) { + console.warn(`Cannot set parent: Block ${id} not found`) + return + } + + console.log('UpdateParentId called:', { + blockId: id, + blockName: block.name, + blockType: block.type, + newParentId: parentId, + extent, + currentParentId: block.data?.parentId, + }) + + // Skip if the parent ID hasn't changed + if (block.data?.parentId === parentId) { + console.log('Parent ID unchanged, skipping update') + return + } + + // Store current absolute position + const absolutePosition = { ...block.position } + + // Handle empty or null parentId (removing from parent) + const newData = !parentId + ? { ...block.data } // Remove parentId and extent if empty + : { + ...block.data, + parentId, + extent, + } + + // Remove parentId and extent properties for empty parent ID + if (!parentId && newData.parentId) { + newData.parentId = undefined + newData.extent = undefined + } + + const newState = { + blocks: { + ...get().blocks, + [id]: { + ...block, + position: absolutePosition, + data: newData, + }, + }, + edges: [...get().edges], + loops: { ...get().loops }, + parallels: { ...get().parallels }, + } + + console.log('[WorkflowStore/updateParentId] Updated parentId relationship:', { + blockId: id, + newParentId: parentId || 'None (removed parent)', + keepingPosition: absolutePosition, + }) + + set(newState) + pushHistory( + set, + get, + newState, + parentId ? `Set parent for ${block.name}` : `Remove parent for ${block.name}` + ) + get().updateLastSaved() + workflowSync.sync() + }, + removeBlock: (id: string) => { // First, clean up any subblock values for this block const subBlockStore = useSubBlockStore.getState() @@ -155,14 +303,42 @@ export const useWorkflowStore = create()( blocks: { ...get().blocks }, edges: [...get().edges].filter((edge) => edge.source !== id && edge.target !== id), loops: { ...get().loops }, + parallels: { ...get().parallels }, } + // Find and remove all child blocks if this is a parent node + const blocksToRemove = new Set([id]) + + // Recursively find all descendant blocks (children, grandchildren, etc.) + const findAllDescendants = (parentId: string) => { + Object.entries(newState.blocks).forEach(([blockId, block]) => { + if (block.data?.parentId === parentId) { + blocksToRemove.add(blockId) + // Recursively find this block's children + findAllDescendants(blockId) + } + }) + } + + // Start recursive search from the target block + findAllDescendants(id) + + console.log('[WorkflowStore/removeBlock] Found blocks to remove:', { + targetId: id, + totalBlocksToRemove: Array.from(blocksToRemove), + includesHierarchy: blocksToRemove.size > 1, + }) + // Clean up subblock values before removing the block - if (activeWorkflowId) { + if (activeWorkflowId && subBlockStore.workflowValues) { const updatedWorkflowValues = { ...(subBlockStore.workflowValues[activeWorkflowId] || {}), } - delete updatedWorkflowValues[id] + + // Remove values for all blocks being deleted + blocksToRemove.forEach((blockId) => { + delete updatedWorkflowValues[blockId] + }) // Update subblock store useSubBlockStore.setState((state) => ({ @@ -173,26 +349,18 @@ export const useWorkflowStore = create()( })) } - // Clean up loops - Object.entries(newState.loops).forEach(([loopId, loop]) => { - if (loop.nodes.includes(id)) { - // If removing this node would leave the loop empty, delete the loop - if (loop.nodes.length <= 1) { - delete newState.loops[loopId] - } else { - newState.loops[loopId] = { - ...loop, - nodes: loop.nodes.filter((nodeId) => nodeId !== id), - } - } - } + // Remove all edges connected to any of the blocks being removed + newState.edges = newState.edges.filter( + (edge) => !blocksToRemove.has(edge.source) && !blocksToRemove.has(edge.target) + ) + + // Delete all blocks marked for removal + blocksToRemove.forEach((blockId) => { + delete newState.blocks[blockId] }) - // Delete the block last - delete newState.blocks[id] - set(newState) - pushHistory(set, get, newState, 'Remove block') + pushHistory(set, get, newState, 'Remove block and children') get().updateLastSaved() get().sync.markDirty() get().sync.forceSync() @@ -223,55 +391,12 @@ export const useWorkflowStore = create()( const newEdges = [...get().edges, newEdge] - // Recalculate all loops after adding the edge - const newLoops: Record = {} - const processedPaths = new Set() - const existingLoops = get().loops - - // Check for cycles from each node - const nodes = new Set(newEdges.map((e) => e.source)) - nodes.forEach((node) => { - const { paths } = detectCycle(newEdges, node) - paths.forEach((path) => { - // Create a canonical path representation for deduplication - const canonicalPath = [...path].sort().join(',') - if (!processedPaths.has(canonicalPath)) { - processedPaths.add(canonicalPath) - - // Check if this path matches an existing loop - let existingLoop: Loop | undefined - Object.values(existingLoops).forEach((loop) => { - const loopCanonicalPath = [...loop.nodes].sort().join(',') - if (loopCanonicalPath === canonicalPath) { - existingLoop = loop - } - }) - - if (existingLoop) { - // Preserve the existing loop's properties - newLoops[existingLoop.id] = { - ...existingLoop, - nodes: path, // Update nodes in case order changed - } - } else { - // Create a new loop with default settings - const loopId = crypto.randomUUID() - newLoops[loopId] = { - id: loopId, - nodes: path, - iterations: 5, - loopType: 'for', - forEachItems: '', - } - } - } - }) - }) - + // Use the new loop generation approach const newState = { blocks: { ...get().blocks }, edges: newEdges, - loops: newLoops, + loops: generateLoopBlocks(get().blocks), + parallels: get().generateParallelBlocks(), } set(newState) @@ -282,57 +407,21 @@ export const useWorkflowStore = create()( }, removeEdge: (edgeId: string) => { + // Validate the edge exists + const edgeToRemove = get().edges.find((edge) => edge.id === edgeId) + if (!edgeToRemove) { + console.warn(`Attempted to remove non-existent edge: ${edgeId}`) + return + } + const newEdges = get().edges.filter((edge) => edge.id !== edgeId) - // Recalculate all loops after edge removal - const newLoops: Record = {} - const processedPaths = new Set() - const existingLoops = get().loops - - // Check for cycles from each node - const nodes = new Set(newEdges.map((e) => e.source)) - nodes.forEach((node) => { - const { paths } = detectCycle(newEdges, node) - paths.forEach((path) => { - // Create a canonical path representation for deduplication - const canonicalPath = [...path].sort().join(',') - if (!processedPaths.has(canonicalPath)) { - processedPaths.add(canonicalPath) - - // Check if this path matches an existing loop - let existingLoop: Loop | undefined - Object.values(existingLoops).forEach((loop) => { - const loopCanonicalPath = [...loop.nodes].sort().join(',') - if (loopCanonicalPath === canonicalPath) { - existingLoop = loop - } - }) - - if (existingLoop) { - // Preserve the existing loop's properties - newLoops[existingLoop.id] = { - ...existingLoop, - nodes: path, // Update nodes in case order changed - } - } else { - // Create a new loop with default settings - const loopId = crypto.randomUUID() - newLoops[loopId] = { - id: loopId, - nodes: path, - iterations: 5, - loopType: 'for', - forEachItems: '', - } - } - } - }) - }) - + // Use the new loop generation approach instead of cycle detection const newState = { blocks: { ...get().blocks }, edges: newEdges, - loops: newLoops, + loops: generateLoopBlocks(get().blocks), + parallels: get().generateParallelBlocks(), } set(newState) @@ -354,6 +443,7 @@ export const useWorkflowStore = create()( blocks: {}, edges: [], loops: {}, + parallels: {}, isDeployed: false, isPublished: false, }, @@ -383,10 +473,11 @@ export const useWorkflowStore = create()( const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId if (activeWorkflowId) { const currentState = get() + const generatedLoops = currentState.generateLoopBlocks() saveWorkflowState(activeWorkflowId, { blocks: currentState.blocks, edges: currentState.edges, - loops: currentState.loops, + loops: generatedLoops, history: currentState.history, // Include both legacy and new deployment status fields isDeployed: currentState.isDeployed, @@ -412,6 +503,7 @@ export const useWorkflowStore = create()( }, }, edges: [...get().edges], + loops: { ...get().loops }, } set(newState) @@ -463,7 +555,8 @@ export const useWorkflowStore = create()( }, }, edges: [...get().edges], - loops: { ...get().loops }, + loops: get().generateLoopBlocks(), + parallels: get().generateParallelBlocks(), } // Update the subblock store with the duplicated values @@ -499,6 +592,7 @@ export const useWorkflowStore = create()( }, }, edges: [...get().edges], + loops: { ...get().loops }, } set(newState) @@ -522,6 +616,7 @@ export const useWorkflowStore = create()( }, edges: [...get().edges], loops: { ...get().loops }, + parallels: { ...get().parallels }, } // Update references in subblock store @@ -603,7 +698,7 @@ export const useWorkflowStore = create()( }, }, edges: [...state.edges], - loops: { ...get().loops }, + loops: { ...state.loops }, })) get().updateLastSaved() get().sync.markDirty() @@ -620,69 +715,84 @@ export const useWorkflowStore = create()( }, }, edges: [...state.edges], + loops: { ...state.loops }, })) get().updateLastSaved() // No sync needed for height changes, just visual }, - updateLoopIterations: (loopId: string, iterations: number) => { - const newState = { - blocks: { ...get().blocks }, - edges: [...get().edges], - loops: { - ...get().loops, + updateLoopCount: (loopId: string, count: number) => + set((state) => { + const block = state.blocks[loopId] + if (!block || block.type !== 'loop') return state + + const newBlocks = { + ...state.blocks, [loopId]: { - ...get().loops[loopId], - iterations: Math.max(1, Math.min(50, iterations)), // Clamp between 1-50 + ...block, + data: { + ...block.data, + count: Math.max(1, Math.min(50, count)), // Clamp between 1-50 + }, }, - }, - } + } - set(newState) - pushHistory(set, get, newState, 'Update loop iterations') - get().updateLastSaved() - get().sync.markDirty() - get().sync.forceSync() - }, + return { + blocks: newBlocks, + edges: [...state.edges], + loops: generateLoopBlocks(newBlocks), // Regenerate loops + } + }), - updateLoopType: (loopId: string, loopType: Loop['loopType']) => { - const newState = { - blocks: { ...get().blocks }, - edges: [...get().edges], - loops: { - ...get().loops, + updateLoopType: (loopId: string, loopType: 'for' | 'forEach') => + set((state) => { + const block = state.blocks[loopId] + if (!block || block.type !== 'loop') return state + + const newBlocks = { + ...state.blocks, [loopId]: { - ...get().loops[loopId], - loopType, + ...block, + data: { + ...block.data, + loopType, + }, }, - }, - } + } - set(newState) - pushHistory(set, get, newState, 'Update loop type') - get().updateLastSaved() - get().sync.markDirty() - get().sync.forceSync() - }, + return { + blocks: newBlocks, + edges: [...state.edges], + loops: generateLoopBlocks(newBlocks), // Regenerate loops + } + }), - updateLoopForEachItems: (loopId: string, items: string) => { - const newState = { - blocks: { ...get().blocks }, - edges: [...get().edges], - loops: { - ...get().loops, + updateLoopCollection: (loopId: string, collection: string) => + set((state) => { + const block = state.blocks[loopId] + if (!block || block.type !== 'loop') return state + + const newBlocks = { + ...state.blocks, [loopId]: { - ...get().loops[loopId], - forEachItems: items, + ...block, + data: { + ...block.data, + collection, + }, }, - }, - } + } - set(newState) - pushHistory(set, get, newState, 'Update forEach items') - get().updateLastSaved() - get().sync.markDirty() - get().sync.forceSync() + return { + blocks: newBlocks, + edges: [...state.edges], + loops: generateLoopBlocks(newBlocks), // Regenerate loops + } + }), + + // Function to convert UI loop blocks to execution format + generateLoopBlocks: () => { + return generateLoopBlocks(get().blocks) }, triggerUpdate: () => { @@ -726,8 +836,8 @@ export const useWorkflowStore = create()( const newState = { blocks: deployedState.blocks, edges: deployedState.edges, - loops: deployedState.loops, - // Legacy fields for backward compatibility + loops: deployedState.loops || {}, + parallels: deployedState.parallels || {}, isDeployed: true, needsRedeployment: false, hasActiveWebhook: false, // Reset webhook status @@ -832,6 +942,70 @@ export const useWorkflowStore = create()( get().sync.markDirty() get().sync.forceSync() }, + + // Parallel block methods implementation + updateParallelCount: (parallelId: string, count: number) => { + const block = get().blocks[parallelId] + if (!block || block.type !== 'parallel') return + + const newBlocks = { + ...get().blocks, + [parallelId]: { + ...block, + data: { + ...block.data, + count: Math.max(1, Math.min(50, count)), // Clamp between 1-50 + }, + }, + } + + const newState = { + blocks: newBlocks, + edges: [...get().edges], + loops: { ...get().loops }, + parallels: generateParallelBlocks(newBlocks), // Regenerate parallels + } + + set(newState) + pushHistory(set, get, newState, `Update parallel count`) + get().updateLastSaved() + get().sync.markDirty() + get().sync.forceSync() + }, + + updateParallelCollection: (parallelId: string, collection: string) => { + const block = get().blocks[parallelId] + if (!block || block.type !== 'parallel') return + + const newBlocks = { + ...get().blocks, + [parallelId]: { + ...block, + data: { + ...block.data, + collection, + }, + }, + } + + const newState = { + blocks: newBlocks, + edges: [...get().edges], + loops: { ...get().loops }, + parallels: generateParallelBlocks(newBlocks), // Regenerate parallels + } + + set(newState) + pushHistory(set, get, newState, `Update parallel collection`) + get().updateLastSaved() + get().sync.markDirty() + get().sync.forceSync() + }, + + // Function to convert UI parallel blocks to execution format + generateParallelBlocks: () => { + return generateParallelBlocks(get().blocks) + }, })), { name: 'workflow-store' } ) diff --git a/apps/sim/stores/workflows/workflow/types.ts b/apps/sim/stores/workflows/workflow/types.ts index 5c2482605f..ae31883c02 100644 --- a/apps/sim/stores/workflows/workflow/types.ts +++ b/apps/sim/stores/workflows/workflow/types.ts @@ -18,6 +18,7 @@ export interface BlockState { isWide?: boolean height?: number advancedMode?: boolean + data?: Record } export interface SubBlockState { @@ -26,6 +27,34 @@ export interface SubBlockState { value: string | number | string[][] | null } +export interface LoopBlock { + id: string + loopType: 'for' | 'forEach' + count: number + collection: string + width: number + height: number + executionState: { + currentIteration: number + isExecuting: boolean + startTime: null | number + endTime: null | number + } +} + +export interface ParallelBlock { + id: string + collection: string + width: number + height: number + executionState: { + currentExecution: number + isExecuting: boolean + startTime: null | number + endTime: null | number + } +} + export interface Loop { id: string nodes: string[] @@ -34,6 +63,12 @@ export interface Loop { forEachItems?: any[] | Record | string // Items or expression } +export interface Parallel { + id: string + nodes: string[] + distribution?: any[] | Record | string // Items or expression +} + export interface DeploymentStatus { isDeployed: boolean deployedAt?: Date @@ -46,6 +81,7 @@ export interface WorkflowState { edges: Edge[] lastSaved?: number loops: Record + parallels: Record lastUpdate?: number // Legacy deployment fields (keeping for compatibility) isDeployed?: boolean @@ -68,8 +104,18 @@ export interface SyncControl { } export interface WorkflowActions { - addBlock: (id: string, type: string, name: string, position: Position) => void + addBlock: ( + id: string, + type: string, + name: string, + position: Position, + data?: Record, + parentId?: string, + extent?: 'parent' + ) => void updateBlockPosition: (id: string, position: Position) => void + updateNodeDimensions: (id: string, dimensions: { width: number; height: number }) => void + updateParentId: (id: string, parentId: string, extent: 'parent') => void removeBlock: (id: string) => void addEdge: (edge: Edge) => void removeEdge: (edgeId: string) => void @@ -82,12 +128,17 @@ export interface WorkflowActions { toggleBlockWide: (id: string) => void updateBlockHeight: (id: string, height: number) => void triggerUpdate: () => void - updateLoopIterations: (loopId: string, iterations: number) => void - updateLoopType: (loopId: string, loopType: Loop['loopType']) => void - updateLoopForEachItems: (loopId: string, items: string) => void + updateLoopCount: (loopId: string, count: number) => void + updateLoopType: (loopId: string, loopType: 'for' | 'forEach') => void + updateLoopCollection: (loopId: string, collection: string) => void + updateParallelCount: (parallelId: string, count: number) => void + updateParallelCollection: (parallelId: string, collection: string) => void + generateLoopBlocks: () => Record + generateParallelBlocks: () => Record setNeedsRedeploymentFlag: (needsRedeployment: boolean) => void setScheduleStatus: (hasActiveSchedule: boolean) => void setWebhookStatus: (hasActiveWebhook: boolean) => void + revertToDeployedState: (deployedState: WorkflowState) => void toggleBlockAdvancedMode: (id: string) => void // Add the sync control methods to the WorkflowActions interface diff --git a/apps/sim/stores/workflows/workflow/utils.test.ts b/apps/sim/stores/workflows/workflow/utils.test.ts new file mode 100644 index 0000000000..faaa1fb703 --- /dev/null +++ b/apps/sim/stores/workflows/workflow/utils.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, test } from 'vitest' +import type { BlockState } from './types' +import { convertLoopBlockToLoop } from './utils' + +describe('convertLoopBlockToLoop', () => { + test('should parse JSON array string for forEach loops', () => { + const blocks: Record = { + loop1: { + id: 'loop1', + type: 'loop', + name: 'Test Loop', + position: { x: 0, y: 0 }, + subBlocks: {}, + outputs: {}, + enabled: true, + data: { + loopType: 'forEach', + count: 10, + collection: '["item1", "item2", "item3"]', + }, + }, + } + + const result = convertLoopBlockToLoop('loop1', blocks) + + expect(result).toBeDefined() + expect(result?.loopType).toBe('forEach') + expect(result?.forEachItems).toEqual(['item1', 'item2', 'item3']) + expect(result?.iterations).toBe(10) + }) + + test('should parse JSON object string for forEach loops', () => { + const blocks: Record = { + loop1: { + id: 'loop1', + type: 'loop', + name: 'Test Loop', + position: { x: 0, y: 0 }, + subBlocks: {}, + outputs: {}, + enabled: true, + data: { + loopType: 'forEach', + count: 5, + collection: '{"key1": "value1", "key2": "value2"}', + }, + }, + } + + const result = convertLoopBlockToLoop('loop1', blocks) + + expect(result).toBeDefined() + expect(result?.loopType).toBe('forEach') + expect(result?.forEachItems).toEqual({ key1: 'value1', key2: 'value2' }) + }) + + test('should keep string as-is if not valid JSON', () => { + const blocks: Record = { + loop1: { + id: 'loop1', + type: 'loop', + name: 'Test Loop', + position: { x: 0, y: 0 }, + subBlocks: {}, + outputs: {}, + enabled: true, + data: { + loopType: 'forEach', + count: 5, + collection: '', + }, + }, + } + + const result = convertLoopBlockToLoop('loop1', blocks) + + expect(result).toBeDefined() + expect(result?.forEachItems).toBe('') + }) + + test('should handle empty collection', () => { + const blocks: Record = { + loop1: { + id: 'loop1', + type: 'loop', + name: 'Test Loop', + position: { x: 0, y: 0 }, + subBlocks: {}, + outputs: {}, + enabled: true, + data: { + loopType: 'forEach', + count: 5, + collection: '', + }, + }, + } + + const result = convertLoopBlockToLoop('loop1', blocks) + + expect(result).toBeDefined() + expect(result?.forEachItems).toBe('') + }) + + test('should handle for loops without collection parsing', () => { + const blocks: Record = { + loop1: { + id: 'loop1', + type: 'loop', + name: 'Test Loop', + position: { x: 0, y: 0 }, + subBlocks: {}, + outputs: {}, + enabled: true, + data: { + loopType: 'for', + count: 5, + collection: '["should", "not", "matter"]', + }, + }, + } + + const result = convertLoopBlockToLoop('loop1', blocks) + + expect(result).toBeDefined() + expect(result?.loopType).toBe('for') + expect(result?.iterations).toBe(5) + // For 'for' loops, the collection is still parsed in case it's later changed to forEach + expect(result?.forEachItems).toEqual(['should', 'not', 'matter']) + }) +}) diff --git a/apps/sim/stores/workflows/workflow/utils.ts b/apps/sim/stores/workflows/workflow/utils.ts index 3bd4bbecb4..3af55ed435 100644 --- a/apps/sim/stores/workflows/workflow/utils.ts +++ b/apps/sim/stores/workflows/workflow/utils.ts @@ -1,58 +1,159 @@ -import type { Edge } from 'reactflow' +import type { BlockState, Loop, Parallel } from './types' + +const DEFAULT_LOOP_ITERATIONS = 5 /** - * Performs a depth-first search to detect all cycles in the graph - * @param edges - List of all edges in the graph - * @param startNode - Starting node for cycle detection - * @returns Array of all unique cycles found in the graph + * Convert UI loop block to executor Loop format + * + * @param loopBlockId - ID of the loop block to convert + * @param blocks - Record of all blocks in the workflow + * @returns Loop object for execution engine or undefined if not a valid loop */ -export function detectCycle( - edges: Edge[], - startNode: string -): { hasCycle: boolean; paths: string[][] } { - const visited = new Set() - const recursionStack = new Set() - const allCycles: string[][] = [] - const currentPath: string[] = [] +export function convertLoopBlockToLoop( + loopBlockId: string, + blocks: Record +): Loop | undefined { + const loopBlock = blocks[loopBlockId] + if (!loopBlock || loopBlock.type !== 'loop') return undefined - function dfs(node: string) { - visited.add(node) - recursionStack.add(node) - currentPath.push(node) - - // Get all neighbors of current node - const neighbors = edges.filter((edge) => edge.source === node).map((edge) => edge.target) - - for (const neighbor of neighbors) { - // Check for self-loops (node connecting to itself) - if (neighbor === node) { - allCycles.push([node]) - continue - } - - if (!recursionStack.has(neighbor)) { - if (!visited.has(neighbor)) { - dfs(neighbor) - } - } else { - // Found a cycle - const cycleStartIndex = currentPath.indexOf(neighbor) - if (cycleStartIndex !== -1) { - const cycle = currentPath.slice(cycleStartIndex) - // Include all cycles, even single-node ones - allCycles.push([...cycle]) - } + // Parse collection if it's a string representation of an array/object + let forEachItems: any = loopBlock.data?.collection || '' + if (typeof forEachItems === 'string' && forEachItems.trim()) { + const trimmed = forEachItems.trim() + // Try to parse if it looks like JSON + if (trimmed.startsWith('[') || trimmed.startsWith('{')) { + try { + forEachItems = JSON.parse(trimmed) + } catch { + // Keep as string if parsing fails - will be evaluated at runtime } } - - currentPath.pop() - recursionStack.delete(node) } - dfs(startNode) - return { - hasCycle: allCycles.length > 0, - paths: allCycles, + id: loopBlockId, + nodes: findChildNodes(loopBlockId, blocks), + iterations: loopBlock.data?.count || DEFAULT_LOOP_ITERATIONS, + loopType: loopBlock.data?.loopType || 'for', + forEachItems, } } + +/** + * Convert UI parallel block to executor Parallel format + * + * @param parallelBlockId - ID of the parallel block to convert + * @param blocks - Record of all blocks in the workflow + * @returns Parallel object for execution engine or undefined if not a valid parallel block + */ +export function convertParallelBlockToParallel( + parallelBlockId: string, + blocks: Record +): Parallel | undefined { + const parallelBlock = blocks[parallelBlockId] + if (!parallelBlock || parallelBlock.type !== 'parallel') return undefined + + // Only set distribution if it's a collection-based parallel + const parallelType = parallelBlock.data?.parallelType || 'collection' + + // Validate parallelType against allowed values + const validParallelTypes = ['collection', 'count'] as const + const validatedParallelType = validParallelTypes.includes(parallelType as any) + ? parallelType + : 'collection' + + const distribution = + validatedParallelType === 'collection' ? parallelBlock.data?.collection || '' : '' + + return { + id: parallelBlockId, + nodes: findChildNodes(parallelBlockId, blocks), + distribution, + } +} + +/** + * Find all nodes that are children of this container (loop or parallel) + * + * @param containerId - ID of the container to find children for + * @param blocks - Record of all blocks in the workflow + * @returns Array of node IDs that are direct children of this container + */ +export function findChildNodes(containerId: string, blocks: Record): string[] { + return Object.values(blocks) + .filter((block) => block.data?.parentId === containerId) + .map((block) => block.id) +} + +/** + * Find all descendant nodes, including children, grandchildren, etc. + * + * @param containerId - ID of the container to find descendants for + * @param blocks - Record of all blocks in the workflow + * @returns Array of node IDs that are descendants of this container + */ +export function findAllDescendantNodes( + containerId: string, + blocks: Record +): string[] { + const descendants: string[] = [] + const findDescendants = (parentId: string) => { + const children = Object.values(blocks) + .filter((block) => block.data?.parentId === parentId) + .map((block) => block.id) + + children.forEach((childId) => { + descendants.push(childId) + findDescendants(childId) + }) + } + + findDescendants(containerId) + return descendants +} + +/** + * Builds a complete collection of loops from the UI blocks + * + * @param blocks - Record of all blocks in the workflow + * @returns Record of Loop objects for execution engine + */ +export function generateLoopBlocks(blocks: Record): Record { + const loops: Record = {} + + // Find all loop nodes + Object.entries(blocks) + .filter(([_, block]) => block.type === 'loop') + .forEach(([id, block]) => { + const loop = convertLoopBlockToLoop(id, blocks) + if (loop) { + loops[id] = loop + } + }) + + return loops +} + +/** + * Builds a complete collection of parallel blocks from the UI blocks + * + * @param blocks - Record of all blocks in the workflow + * @returns Record of Parallel objects for execution engine + */ +export function generateParallelBlocks( + blocks: Record +): Record { + const parallels: Record = {} + + // Find all parallel nodes + Object.entries(blocks) + .filter(([_, block]) => block.type === 'parallel') + .forEach(([id, block]) => { + const parallel = convertParallelBlockToParallel(id, blocks) + if (parallel) { + parallels[id] = parallel + } + }) + + return parallels +}