feat(loop): added loop block & parallel block logic (#411)

* feat(subflows): implement parallel and loop nodes

* fixed linter errors

* added loop block logic

* added parallel executioon

* added input resolution for collections for parallel & loop blocks

* refactored tests, cleaned up some unnecessary logic

* prevent self connections now that we have loop blocks

* refactored path tracker

* lint

* acknowledged PR comments, added tests

* added tests for loops

---------

Co-authored-by: Emir Karabeg <emirkarabeg@berkeley.edu>
This commit is contained in:
Waleed Latif
2025-05-25 14:39:56 -07:00
committed by GitHub
co-authored by Emir Karabeg
parent ef8ae60afd
commit b1126e3d6a
60 changed files with 9786 additions and 2217 deletions
-35
View File
@@ -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
+8 -3
View File
@@ -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<string, string> = {}
+7 -2
View File
@@ -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,
@@ -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,
+2 -1
View File
@@ -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
+6 -1
View File
@@ -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 {
@@ -26,6 +26,7 @@ interface DeployedWorkflowModalProps {
blocks: Record<string, any>
edges: Array<any>
loops: Record<string, any>
parallels: Record<string, any>
}
}
@@ -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()
@@ -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)
})
})
@@ -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<string, any>
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<HTMLTextAreaElement | null>(null)
const editorContainerRef = useRef<HTMLDivElement>(null)
// Get store methods
const updateNodeData = useCallback(
(updates: Partial<LoopNodeData>) => {
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<HTMLInputElement>) => {
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 (
<div className='-top-9 absolute right-0 left-0 z-10 flex justify-between'>
{/* Loop Type Badge */}
<Popover open={typePopoverOpen} onOpenChange={setTypePopoverOpen}>
<PopoverTrigger asChild onClick={(e) => e.stopPropagation()}>
<Badge
variant='outline'
className={cn(
'border-border bg-background/80 py-0.5 pr-1.5 pl-2.5 font-medium text-foreground text-sm backdrop-blur-sm',
'cursor-pointer transition-colors duration-150 hover:bg-accent/50',
'flex items-center gap-1'
)}
>
{loopType === 'for' ? 'For Loop' : 'For Each'}
<ChevronDown className='h-3 w-3 text-muted-foreground' />
</Badge>
</PopoverTrigger>
<PopoverContent className='w-48 p-3' align='center' onClick={(e) => e.stopPropagation()}>
<div className='space-y-2'>
<div className='font-medium text-muted-foreground text-xs'>Loop Type</div>
<div className='space-y-1'>
<div
className={cn(
'flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5',
loopType === 'for' ? 'bg-accent' : 'hover:bg-accent/50'
)}
onClick={() => handleLoopTypeChange('for')}
>
<span className='text-sm'>For Loop</span>
</div>
<div
className={cn(
'flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5',
loopType === 'forEach' ? 'bg-accent' : 'hover:bg-accent/50'
)}
onClick={() => handleLoopTypeChange('forEach')}
>
<span className='text-sm'>For Each</span>
</div>
</div>
</div>
</PopoverContent>
</Popover>
{/* Iterations/Collection Badge */}
<Popover open={configPopoverOpen} onOpenChange={setConfigPopoverOpen}>
<PopoverTrigger asChild onClick={(e) => e.stopPropagation()}>
<Badge
variant='outline'
className={cn(
'border-border bg-background/80 py-0.5 pr-1.5 pl-2.5 font-medium text-foreground text-sm backdrop-blur-sm',
'cursor-pointer transition-colors duration-150 hover:bg-accent/50',
'flex items-center gap-1'
)}
>
{loopType === 'for' ? `Iterations: ${iterations}` : 'Items'}
<ChevronDown className='h-3 w-3 text-muted-foreground' />
</Badge>
</PopoverTrigger>
<PopoverContent
className={cn('p-3', loopType !== 'for' ? 'w-72' : 'w-48')}
align='center'
onClick={(e) => e.stopPropagation()}
>
<div className='space-y-2'>
<div className='font-medium text-muted-foreground text-xs'>
{loopType === 'for' ? 'Loop Iterations' : 'Collection Items'}
</div>
{loopType === 'for' ? (
// Number input for 'for' loops
<div className='flex items-center gap-2'>
<Input
type='text'
value={inputValue}
onChange={handleIterationsChange}
onBlur={handleIterationsSave}
onKeyDown={(e) => e.key === 'Enter' && handleIterationsSave()}
className='h-8 text-sm'
autoFocus
/>
</div>
) : (
// Code editor for 'forEach' loops
<div ref={editorContainerRef} className='relative'>
<div className='relative min-h-[80px] rounded-md border border-input bg-background px-3 pt-2 pb-3 font-mono text-sm'>
{editorValue === '' && (
<div className='pointer-events-none absolute top-[8.5px] left-3 select-none text-muted-foreground/50'>
["item1", "item2", "item3"]
</div>
)}
<Editor
value={editorValue}
onValueChange={handleEditorChange}
highlight={(code) => 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'
/>
</div>
<div className='mt-2 text-[10px] text-muted-foreground'>
Array or object to iterate over. Type "{'<'}" to reference other blocks.
</div>
{showTagDropdown && (
<TagDropdown
visible={showTagDropdown}
onSelect={handleTagSelect}
blockId={nodeId}
activeSourceBlockId={null}
inputValue={editorValue}
cursorPosition={cursorPosition}
onClose={() => setShowTagDropdown(false)}
/>
)}
</div>
)}
{loopType === 'for' && (
<div className='text-[10px] text-muted-foreground'>
Enter a number between 1 and 100
</div>
)}
</div>
</PopoverContent>
</Popover>
</div>
)
}
@@ -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,
}
@@ -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 (
<style jsx global>{`
@keyframes loop-node-pulse {
0% { box-shadow: 0 0 0 0 rgba(64, 224, 208, 0.3); }
70% { box-shadow: 0 0 0 6px rgba(64, 224, 208, 0); }
100% { box-shadow: 0 0 0 0 rgba(64, 224, 208, 0); }
}
.loop-node-drag-over {
animation: loop-node-pulse 1.2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
border-style: solid !important;
background-color: rgba(47, 179, 255, 0.08) !important;
box-shadow: 0 0 0 8px rgba(47, 179, 255, 0.1);
}
/* Make resizer handles more visible */
.react-flow__resize-control {
z-index: 10;
pointer-events: all !important;
}
/* Ensure parent borders are visible when hovering over resize controls */
.react-flow__node-group:hover,
.hover-highlight {
border-color: #1e293b !important;
}
/* Ensure hover effects work well */
.group-node-container:hover .react-flow__resize-control.bottom-right {
opacity: 1 !important;
visibility: visible !important;
}
/* React Flow position transitions within loops */
.react-flow__node[data-parent-node-id] {
transition: transform 0.05s ease;
pointer-events: all;
}
/* Prevent jumpy drag behavior */
.loop-drop-container .react-flow__node {
transform-origin: center;
position: absolute;
}
/* Remove default border from React Flow group nodes */
.react-flow__node-group {
border: none;
background-color: transparent;
outline: none;
box-shadow: none;
}
/* Ensure child nodes stay within parent bounds */
.react-flow__node[data-parent-node-id] .react-flow__handle {
z-index: 30;
}
/* Enhanced drag detection */
.react-flow__node-group.dragging-over {
background-color: rgba(34,197,94,0.05);
transition: all 0.2s ease-in-out;
}
`}</style>
)
}
export const LoopNodeComponent = memo(({ data, selected, id }: NodeProps) => {
const { getNodes } = useReactFlow()
const removeBlock = useWorkflowStore((state) => state.removeBlock)
const blockRef = useRef<HTMLDivElement>(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<string, string> = {
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 (
<>
<LoopNodeStyles />
<div className='group relative'>
<Card
ref={blockRef}
className={cn(
' relative cursor-default select-none',
'transition-block-bg transition-ring',
'z-[20]',
data?.state === 'valid' && 'bg-[rgba(34,197,94,0.05)] ring-2 ring-[#2FB3FF]',
nestingLevel > 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 */}
<div
className='workflow-drag-handle absolute top-0 right-0 left-0 z-10 h-10 cursor-move'
style={{ pointerEvents: 'auto' }}
/>
{/* Custom visible resize handle */}
<div
className='absolute right-2 bottom-2 z-20 flex h-8 w-8 cursor-se-resize items-center justify-center text-muted-foreground'
style={{ pointerEvents: 'auto' }}
/>
{/* Child nodes container - Enable pointer events to allow dragging of children */}
<div
className='h-[calc(100%-10px)] p-4'
data-dragarea='true'
style={{
position: 'relative',
minHeight: '100%',
pointerEvents: 'auto',
}}
>
{/* Delete button - styled like in action-bar.tsx */}
<Button
variant='ghost'
size='sm'
onClick={(e) => {
e.stopPropagation()
removeBlock(id)
}}
className='absolute top-2 right-2 z-20 text-gray-500 opacity-0 transition-opacity duration-200 hover:text-red-600 group-hover:opacity-100'
style={{ pointerEvents: 'auto' }}
>
<Trash2 className='h-4 w-4' />
</Button>
{/* Loop Start Block */}
<div
className='-translate-y-1/2 absolute top-1/2 left-8 flex h-10 w-10 transform items-center justify-center rounded-md bg-[#2FB3FF] p-2'
style={{ pointerEvents: 'auto' }}
data-parent-id={id}
data-node-role='loop-start'
data-extent='parent'
>
<StartIcon className='h-6 w-6 text-white' />
<Handle
type='source'
position={Position.Right}
id='loop-start-source'
className='!w-[6px] !h-4 !bg-slate-300 dark:!bg-slate-500 !rounded-[2px] !border-none !z-[30] hover:!w-[10px] hover:!right-[-10px] hover:!rounded-r-full hover:!rounded-l-none !cursor-crosshair transition-[colors] duration-150'
style={{
right: '-6px',
top: '50%',
transform: 'translateY(-50%)',
pointerEvents: 'auto',
}}
data-parent-id={id}
/>
</div>
</div>
{/* Input handle on left middle */}
<Handle
type='target'
position={Position.Left}
className='!w-[7px] !h-5 !bg-slate-300 dark:!bg-slate-500 !rounded-[2px] !border-none !z-[30] hover:!w-[10px] hover:!left-[-10px] hover:!rounded-l-full hover:!rounded-r-none !cursor-crosshair transition-[colors] duration-150'
style={{
left: '-7px',
top: '50%',
transform: 'translateY(-50%)',
pointerEvents: 'auto',
}}
/>
{/* Output handle on right middle */}
<Handle
type='source'
position={Position.Right}
className='!w-[7px] !h-5 !bg-slate-300 dark:!bg-slate-500 !rounded-[2px] !border-none !z-[30] hover:!w-[10px] hover:!right-[-10px] hover:!rounded-r-full hover:!rounded-l-none !cursor-crosshair transition-[colors] duration-150'
style={{
right: '-7px',
top: '50%',
transform: 'translateY(-50%)',
pointerEvents: 'auto',
}}
id='loop-end-source'
/>
{/* Loop Configuration Badges */}
<LoopBadges nodeId={id} data={data} />
</Card>
</div>
</>
)
})
LoopNodeComponent.displayName = 'LoopNodeComponent'
@@ -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<string, any>
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<HTMLDivElement>(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<ParallelNodeData>) => {
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<HTMLInputElement>) => {
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 (
<div className='-top-9 absolute right-0 left-0 z-10 flex justify-between'>
{/* Parallel Type Badge */}
<Popover open={typePopoverOpen} onOpenChange={setTypePopoverOpen}>
<PopoverTrigger asChild onClick={(e) => e.stopPropagation()}>
<Badge
variant='outline'
className={cn(
'border-border bg-background/80 py-0.5 pr-1.5 pl-2.5 font-medium text-foreground text-sm backdrop-blur-sm',
'cursor-pointer transition-colors duration-150 hover:bg-accent/50',
'flex items-center gap-1'
)}
>
{parallelType === 'count' ? 'Parallel Count' : 'Parallel Each'}
<ChevronDown className='h-3 w-3 text-muted-foreground' />
</Badge>
</PopoverTrigger>
<PopoverContent className='w-48 p-3' align='center' onClick={(e) => e.stopPropagation()}>
<div className='space-y-2'>
<div className='font-medium text-muted-foreground text-xs'>Parallel Type</div>
<div className='space-y-1'>
<div
className={cn(
'flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5',
parallelType === 'count' ? 'bg-accent' : 'hover:bg-accent/50'
)}
onClick={() => handleParallelTypeChange('count')}
>
<span className='text-sm'>Parallel Count</span>
</div>
<div
className={cn(
'flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5',
parallelType === 'collection' ? 'bg-accent' : 'hover:bg-accent/50'
)}
onClick={() => handleParallelTypeChange('collection')}
>
<span className='text-sm'>Parallel Each</span>
</div>
</div>
</div>
</PopoverContent>
</Popover>
{/* Iterations/Collection Badge */}
<Popover open={configPopoverOpen} onOpenChange={setConfigPopoverOpen}>
<PopoverTrigger asChild onClick={(e) => e.stopPropagation()}>
<Badge
variant='outline'
className={cn(
'border-border bg-background/80 py-0.5 pr-1.5 pl-2.5 font-medium text-foreground text-sm backdrop-blur-sm',
'cursor-pointer transition-colors duration-150 hover:bg-accent/50',
'flex items-center gap-1'
)}
>
{parallelType === 'count' ? `Iterations: ${iterations}` : 'Items'}
<ChevronDown className='h-3 w-3 text-muted-foreground' />
</Badge>
</PopoverTrigger>
<PopoverContent
className={cn('p-3', parallelType !== 'count' ? 'w-72' : 'w-48')}
align='center'
onClick={(e) => e.stopPropagation()}
onKeyDown={handleKeyDown}
>
<div className='space-y-2'>
<div className='font-medium text-muted-foreground text-xs'>
{parallelType === 'count' ? 'Parallel Iterations' : 'Parallel Items'}
</div>
{parallelType === 'count' ? (
// Number input for count-based parallel
<div className='flex items-center gap-2'>
<Input
type='text'
value={inputValue}
onChange={handleIterationsChange}
onBlur={handleIterationsSave}
onKeyDown={(e) => e.key === 'Enter' && handleIterationsSave()}
className='h-8 text-sm'
autoFocus
/>
</div>
) : (
// Code editor for collection-based parallel
<div className='relative'>
<div
ref={editorRef}
className='relative min-h-[80px] rounded-md border border-input bg-background px-3 pt-2 pb-3 font-mono text-sm'
>
{editorValue === '' && (
<div className='pointer-events-none absolute top-[8.5px] left-3 select-none text-muted-foreground/50'>
['item1', 'item2', 'item3']
</div>
)}
<Editor
value={editorValue}
onValueChange={handleEditorChange}
highlight={(code) => 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 && (
<TagDropdown
visible={showTags}
onSelect={handleTagSelect}
blockId={nodeId}
activeSourceBlockId={nodeId}
inputValue={editorValue}
cursorPosition={cursorPosition}
onClose={() => setShowTags(false)}
/>
)}
</div>
<div className='mt-2 text-[10px] text-muted-foreground'>
Array or object to use for parallel execution. Type "{'<'}" to reference other
blocks.
</div>
</div>
)}
{parallelType === 'count' && (
<div className='text-[10px] text-muted-foreground'>
Enter a number between 1 and 20
</div>
)}
</div>
</PopoverContent>
</Popover>
</div>
)
}
@@ -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,
}
@@ -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 (
<style jsx global>{`
@keyframes parallel-node-pulse {
0% { box-shadow: 0 0 0 0 rgba(254, 225, 43, 0.3); }
70% { box-shadow: 0 0 0 6px rgba(254, 225, 43, 0); }
100% { box-shadow: 0 0 0 0 rgba(254, 225, 43, 0); }
}
.parallel-node-drag-over {
animation: parallel-node-pulse 1.2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
border-style: solid !important;
background-color: rgba(254, 225, 43, 0.08) !important;
box-shadow: 0 0 0 8px rgba(254, 225, 43, 0.1);
}
/* Make resizer handles more visible */
.react-flow__resize-control {
z-index: 10;
pointer-events: all !important;
}
/* Ensure parent borders are visible when hovering over resize controls */
.react-flow__node-group:hover,
.hover-highlight {
border-color: #1e293b !important;
}
/* Ensure hover effects work well */
.group-node-container:hover .react-flow__resize-control.bottom-right {
opacity: 1 !important;
visibility: visible !important;
}
/* React Flow position transitions within parallel blocks */
.react-flow__node[data-parent-node-id] {
transition: transform 0.05s ease;
pointer-events: all;
}
/* Prevent jumpy drag behavior */
.parallel-drop-container .react-flow__node {
transform-origin: center;
position: absolute;
}
/* Remove default border from React Flow group nodes */
.react-flow__node-group {
border: none;
background-color: transparent;
outline: none;
box-shadow: none;
}
/* Ensure child nodes stay within parent bounds */
.react-flow__node[data-parent-node-id] .react-flow__handle {
z-index: 30;
}
/* Enhanced drag detection */
.react-flow__node-group.dragging-over {
background-color: rgba(254,225,43,0.05);
transition: all 0.2s ease-in-out;
}
`}</style>
)
}
export const ParallelNodeComponent = memo(({ data, selected, id }: NodeProps) => {
const { getNodes } = useReactFlow()
const blockRef = useRef<HTMLDivElement>(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<string, string> = {
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 (
<>
<ParallelNodeStyles />
<div className='group relative'>
<Card
ref={blockRef}
className={cn(
'relative cursor-default select-none',
'transition-block-bg transition-ring',
'z-[20]',
data?.state === 'valid' && 'bg-[rgba(254,225,43,0.05)] ring-2 ring-[#FEE12B]',
nestingLevel > 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 */}
<div
className='workflow-drag-handle absolute top-0 right-0 left-0 z-10 h-10 cursor-move'
style={{ pointerEvents: 'auto' }}
/>
{/* Custom visible resize handle */}
<div
className='absolute right-2 bottom-2 z-20 flex h-8 w-8 cursor-se-resize items-center justify-center text-muted-foreground'
style={{ pointerEvents: 'auto' }}
/>
{/* Child nodes container - Set pointerEvents to allow dragging of children */}
<div
className='h-[calc(100%-10px)] p-4'
data-dragarea='true'
style={{
position: 'relative',
minHeight: '100%',
pointerEvents: 'auto',
}}
>
{/* Delete button - styled like in action-bar.tsx */}
<Button
variant='ghost'
size='sm'
onClick={(e) => {
e.stopPropagation()
useWorkflowStore.getState().removeBlock(id)
}}
className='absolute top-2 right-2 z-20 text-gray-500 opacity-0 transition-opacity duration-200 hover:text-red-600 group-hover:opacity-100'
style={{ pointerEvents: 'auto' }}
>
<Trash2 className='h-4 w-4' />
</Button>
{/* Parallel Start Block */}
<div
className='-translate-y-1/2 absolute top-1/2 left-8 flex h-10 w-10 transform items-center justify-center rounded-md bg-[#FEE12B] p-2'
style={{ pointerEvents: 'auto' }}
data-parent-id={id}
data-node-role='parallel-start'
data-extent='parent'
>
<StartIcon className='h-6 w-6 text-white' />
<Handle
type='source'
position={Position.Right}
id='parallel-start-source'
className='!w-[6px] !h-4 !bg-slate-300 dark:!bg-slate-500 !rounded-[2px] !border-none !z-[30] hover:!w-[10px] hover:!right-[-10px] hover:!rounded-r-full hover:!rounded-l-none !cursor-crosshair transition-[colors] duration-150'
style={{
right: '-6px',
top: '50%',
transform: 'translateY(-50%)',
pointerEvents: 'auto',
}}
data-parent-id={id}
/>
</div>
</div>
{/* Input handle on left middle */}
<Handle
type='target'
position={Position.Left}
className='!w-[7px] !h-5 !bg-slate-300 dark:!bg-slate-500 !rounded-[2px] !border-none !z-[30] hover:!w-[10px] hover:!left-[-10px] hover:!rounded-l-full hover:!rounded-r-none !cursor-crosshair transition-[colors] duration-150'
style={{
left: '-7px',
top: '50%',
transform: 'translateY(-50%)',
pointerEvents: 'auto',
}}
/>
{/* Output handle on right middle */}
<Handle
type='source'
position={Position.Right}
className='!w-[7px] !h-5 !bg-slate-300 dark:!bg-slate-500 !rounded-[2px] !border-none !z-[30] hover:!w-[10px] hover:!right-[-10px] hover:!rounded-r-full hover:!rounded-l-none !cursor-crosshair transition-[colors] duration-150'
style={{
right: '-7px',
top: '50%',
transform: 'translateY(-50%)',
pointerEvents: 'auto',
}}
id='parallel-end-source'
/>
{/* Parallel Configuration Badges */}
<ParallelBadges nodeId={id} data={data} />
</Card>
</div>
</>
)
})
ParallelNodeComponent.displayName = 'ParallelNodeComponent'
@@ -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 (
<div
draggable
onDragStart={handleDragStart}
onClick={handleClick}
className='group flex cursor-pointer items-center gap-3 rounded-lg border bg-card p-3.5 shadow-sm transition-colors hover:bg-accent/50 active:cursor-grabbing'
>
<div
className='relative flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-lg'
style={{ backgroundColor: LoopTool.bgColor }}
>
<LoopTool.icon className='h-[22px] w-[22px] text-white transition-transform duration-200 group-hover:scale-110' />
</div>
<div className='mb-[-2px] flex flex-col gap-1'>
<h3 className='font-medium leading-none'>{LoopTool.name}</h3>
<p className='text-muted-foreground text-sm leading-snug'>{LoopTool.description}</p>
</div>
</div>
)
}
@@ -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 (
<div
draggable
onDragStart={handleDragStart}
onClick={handleClick}
className='group flex cursor-pointer items-center gap-3 rounded-lg border bg-card p-3.5 shadow-sm transition-colors hover:bg-accent/50 active:cursor-grabbing'
>
<div
className='relative flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-lg'
style={{ backgroundColor: ParallelTool.bgColor }}
>
<ParallelTool.icon className='h-[22px] w-[22px] text-white transition-transform duration-200 group-hover:scale-110' />
</div>
<div className='mb-[-2px] flex flex-col gap-1'>
<h3 className='font-medium leading-none'>{ParallelTool.name}</h3>
<p className='text-muted-foreground text-sm leading-snug'>{ParallelTool.description}</p>
</div>
</div>
)
}
@@ -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) => (
<ToolbarBlock key={block.type} config={block} />
))}
{activeTab === 'blocks' && !searchQuery && (
<>
<LoopToolbarItem />
<ParallelToolbarItem />
</>
)}
</div>
</div>
</ScrollArea>
@@ -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
@@ -421,7 +421,7 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
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<WorkflowBlockProps>) {
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<WorkflowBlockProps>) {
data-handleid='error'
isConnectableStart={true}
isConnectableEnd={false}
isValidConnection={(connection) => true}
isValidConnection={(connection) => connection.target !== id}
/>
)}
</>
@@ -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 (
<>
<BaseEdge
path={edgePath}
data-testid='workflow-edge'
style={{
strokeWidth: 2,
stroke: isSelected ? '#475569' : '#94a3b8',
strokeDasharray: '5,5',
zIndex: -10,
}}
interactionWidth={20}
style={edgeStyle}
interactionWidth={30}
data-edge-id={id}
data-parent-loop-id={parentLoopId}
data-is-selected={isSelected ? 'true' : 'false'}
data-is-inside-loop={isInsideLoop ? 'true' : 'false'}
/>
<animate
attributeName='stroke-dashoffset'
@@ -50,16 +61,18 @@ export const WorkflowEdge = ({
{isSelected && (
<EdgeLabelRenderer>
<div
className='nodrag nopan flex h-6 w-6 cursor-pointer items-center justify-center rounded-full bg-[#FAFBFC]'
className='nodrag nopan flex h-6 w-6 cursor-pointer items-center justify-center rounded-full bg-[#FAFBFC] shadow-sm'
style={{
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
pointerEvents: 'all',
zIndex: 1000,
zIndex: 22,
}}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
if (data?.onDelete) {
// Pass this specific edge's ID to the delete function
data.onDelete(id)
}
}}
@@ -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<HTMLDivElement | null>(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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild onClick={(e) => e.stopPropagation()}>
<Badge
variant='outline'
className={cn(
'border-border bg-background py-0.5 pr-1.5 pl-2.5 font-medium text-foreground text-sm',
'cursor-pointer transition-colors duration-150 hover:bg-accent/50',
'flex items-center gap-1'
)}
>
{getLabel()}
<ChevronDown className='h-3 w-3 text-muted-foreground' />
</Badge>
</PopoverTrigger>
<PopoverContent
className={cn('p-3', loopType !== 'for' ? 'w-72' : 'w-48')}
align='start'
onClick={(e) => e.stopPropagation()}
>
<div className='space-y-2'>
<div className='flex items-center justify-between'>
<div className='font-medium text-muted-foreground text-xs'>
{loopType === 'for' ? 'Loop Iterations' : 'Collection Items'}
</div>
</div>
{loopType === 'for' ? (
// Number input for 'for' loops
<div className='flex items-center gap-2'>
<Input
type='text'
value={inputValue}
onChange={handleChange}
onBlur={handleSave}
onKeyDown={handleKeyDown}
className='h-8 text-sm'
/>
</div>
) : (
// Code editor for 'forEach' loops
<div
className='relative min-h-[80px] rounded-md border border-input bg-background px-3 pt-2 pb-3 font-mono text-sm'
ref={editorRef}
>
{editorValue === '' && (
<div className='pointer-events-none absolute top-[8.5px] left-3 select-none text-muted-foreground/50'>
{getPlaceholder()}
</div>
)}
<Editor
value={editorValue}
onValueChange={handleEditorChange}
highlight={(code) => 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'
/>
</div>
)}
<div className='text-[10px] text-muted-foreground'>
{loopType === 'for'
? 'Enter a number between 1 and 50'
: 'Array or object to iterate over'}
</div>
</div>
</PopoverContent>
</Popover>
)
}
@@ -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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild onClick={(e) => e.stopPropagation()}>
<Badge
variant='outline'
className={cn(
'border-border bg-background py-0.5 pr-1.5 pl-2.5 font-medium text-foreground text-sm',
'cursor-pointer transition-colors duration-150 hover:bg-accent/50',
'flex items-center gap-1'
)}
>
{getLoopLabel()}
<ChevronDown className='h-3 w-3 text-muted-foreground' />
</Badge>
</PopoverTrigger>
<PopoverContent className='w-36 p-1' align='start' onClick={(e) => e.stopPropagation()}>
<div className='text-sm'>
<div
className={cn(
'flex cursor-pointer items-center rounded-md px-2 py-1.5 transition-colors duration-150 hover:bg-accent/50',
loopType === 'for' && 'bg-accent'
)}
onClick={() => handleLoopTypeChange('for')}
>
<span>For loop</span>
</div>
<div
className={cn(
'flex cursor-pointer items-center rounded-md px-2 py-1.5 transition-colors duration-150 hover:bg-accent/50',
loopType === 'forEach' && 'bg-accent'
)}
onClick={() => handleLoopTypeChange('forEach')}
>
<span>For each</span>
</div>
</div>
</PopoverContent>
</Popover>
)
}
@@ -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<string, any>
}
// 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<string, any>) {
// 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<typeof block> =>
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,
}
}
@@ -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,
+351
View File
@@ -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<string>()
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)
}
})
}
File diff suppressed because it is too large Load Diff
@@ -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<any>) {
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<any>) {
{name}
</span>
</div>
{type === 'loop' && (
<div className='text-muted-foreground text-xs'>
{blockState?.data?.loopType === 'forEach' ? 'For Each' : 'For'}
{blockState?.data?.count && ` (${blockState.data.count}x)`}
</div>
)}
</div>
{/* Block Content */}
@@ -461,7 +476,9 @@ function PreviewWorkflowBlock({ id, data }: NodeProps<any>) {
</div>
))
) : (
<div className='py-2 text-muted-foreground text-xs'>No configured items</div>
<div className='py-2 text-muted-foreground text-xs'>
{type === 'loop' ? 'Loop configuration' : 'No configured items'}
</div>
)}
</div>
)}
@@ -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<string, any> = {}
const topLevelBlocks: Record<string, any> = {}
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(() => {
@@ -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<string, BlockState> = {
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<string, BlockState> = {
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)
})
})
+168 -8
View File
@@ -81,9 +81,10 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
// 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<TagDropdownProps> = ({
}
}
// 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<TagDropdownProps> = ({
}
}
// 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<TagDropdownProps> = ({
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<TagDropdownProps> = ({
}, [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<TagDropdownProps> = ({
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<TagDropdownProps> = ({
</>
)}
{parallelTags.length > 0 && (
<>
{loopTags.length > 0 && <div className='my-0' />}
<div className='px-2 pt-2.5 pb-0.5 font-medium text-muted-foreground text-xs'>
Parallel
</div>
<div className='-mx-1 -px-1'>
{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 (
<button
key={tag}
className={cn(
'flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm',
'hover:bg-accent hover:text-accent-foreground',
'focus:bg-accent focus:text-accent-foreground focus:outline-none',
tagIndex === selectedIndex && 'bg-accent text-accent-foreground'
)}
onMouseEnter={() => setSelectedIndex(tagIndex)}
onMouseDown={(e) => {
e.preventDefault() // Prevent input blur
handleTagSelect(tag)
}}
>
<div
className='flex h-5 w-5 items-center justify-center rounded'
style={{ backgroundColor: bgColor }}
>
<span className='h-3 w-3 font-bold text-white text-xs'>{tagIcon}</span>
</div>
<span className='flex-1 truncate'>{tag}</span>
<span className='ml-auto text-muted-foreground text-xs'>
{tagDescription}
</span>
</button>
)
})}
</div>
</>
)}
{blockTags.length > 0 && (
<>
{(variableTags.length > 0 || loopTags.length > 0) && <div className='my-0' />}
{(variableTags.length > 0 || loopTags.length > 0 || parallelTags.length > 0) && (
<div className='my-0' />
)}
<div className='px-2 pt-2.5 pb-0.5 font-medium text-muted-foreground text-xs'>
Blocks
</div>
@@ -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<typeof vi.fn>
consoleUpdateFn?: ReturnType<typeof vi.fn>
}) => {
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: <parallel.currentItem>, index: <parallel.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<string, number>
loopItems?: Map<string, any>
executedBlocks?: Set<string>
activeExecutionPath?: Set<string>
completedLoops?: Set<string>
parallelExecutions?: Map<string, any>
parallelBlockMapping?: Map<string, any>
currentVirtualBlockId?: string
workflow?: SerializedWorkflow
blockStates?: Map<string, any>
}
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<string>(),
activeExecutionPath: options.activeExecutionPath || new Set<string>(),
workflow,
completedLoops: options.completedLoops || new Set<string>(),
parallelExecutions: options.parallelExecutions,
parallelBlockMapping: options.parallelBlockMapping,
currentVirtualBlockId: options.currentVirtualBlockId,
}
}
/**
* Mock implementations for testing loops
*/
export const createLoopManagerMock = (options?: {
processLoopIterationsImpl?: (context: any) => Promise<boolean>
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<string, any> | null
completedExecutions?: number
executionResults?: Map<string, any>
activeIterations?: Set<number>
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<string, any>(),
activeIterations: options?.activeIterations ?? new Set<number>(),
currentIteration: options?.currentIteration ?? 1,
parallelType: options?.parallelType,
})
/**
* Mock implementations for testing parallels
*/
export const createParallelManagerMock = (options?: {
maxChecks?: number
processParallelIterationsImpl?: (context: any) => Promise<void>
}) => ({
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: <parallel.currentItem>, index: <parallel.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<string, any> = { 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: <parallel.currentItem.key>, value: <parallel.currentItem.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<typeof vi.fn>
consoleUpdateFn?: ReturnType<typeof vi.fn>
}) => {
setupHandlerMocks()
const storeMocks = setupStoreMocks(options)
setupExecutorCoreMocks()
return storeMocks
}
+4
View File
@@ -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,
}
@@ -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'
)
})
})
})
@@ -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<string, any>,
context: ExecutionContext
): Promise<BlockOutput> {
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<string, any> | 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 <loop.items>
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<any[] | Record<string, any> | 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
}
}
@@ -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)
})
})
@@ -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<string, any>,
context: ExecutionContext
): Promise<BlockOutput | StreamingExecution> {
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<string, any> | 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<string, any>(),
activeIterations: new Set<number>(),
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<any[] | Record<string, any> | 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
}
}
+276 -429
View File
@@ -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<string>(['block1', 'block2']),
activeExecutionPath: new Set<string>(['block1', 'block2']),
workflow,
completedLoops: new Set<string>(),
}
})
// 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<string>(['block1']),
activeExecutionPath: new Set<string>(['block1', 'block2']),
workflow,
completedLoops: new Set<string>(),
}
})
// 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<string>(),
activeExecutionPath: new Set<string>(['block1', 'block2']),
const context = createMockContext({
workflow,
completedLoops: new Set<string>(),
}
loopIterations: new Map([['loop1', 0]]),
activeExecutionPath: new Set<string>(['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)
})
})
})
+409 -116
View File
@@ -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<string>()
// Check if we have any active parallel executions
const activeParallels = new Map<string, any>()
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<string>,
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<NormalizedBlockOutput> {
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 },
}
+692
View File
@@ -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>): SerializedLoop => ({
id: 'loop-1',
nodes: ['block-1', 'block-2'],
iterations: 3,
loopType: 'for',
...overrides,
})
const createForEachLoop = (items: any, overrides?: Partial<SerializedLoop>): 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)
})
})
})
+344 -495
View File
@@ -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<string, any>)
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 <loop.currentItem>
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<string, any> = {}
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<string>()
}
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<string>()
// 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<any[] | Record<string, any> | 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<string, any>
}
// 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<string, typeof loopConnections>()
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<string>()
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)
}
}
}
+356
View File
@@ -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<string>()
const activeExecutionPath = new Set<string>() // 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<number>(),
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<number>(),
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<number>(),
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()
})
})
})
+201
View File
@@ -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<string, any> | null
completedExecutions: number
executionResults: Map<string, any>
activeIterations: Set<number>
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<string, any>
): 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<string>,
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<void> {
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<string>,
activeExecutionPath: Set<string>
): 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,
})
}
}
}
+403
View File
@@ -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()
})
})
})
+227 -113
View File
@@ -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
}
}
+261
View File
@@ -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: '<loop.currentItem>', // 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: '<loop.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: '<loop.items>', // 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<string, any>()
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: '<loop.items>', // 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<string, any>()
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)
})
})
})
+571 -233
View File
@@ -12,6 +12,7 @@ const logger = createLogger('InputResolver')
export class InputResolver {
private blockById: Map<string, SerializedBlock>
private blockByNormalizedName: Map<string, SerializedBlock>
private loopsByBlockId: Map<string, string> // 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(/^<variable\.([^>]+)>$/)
// Check for direct variable reference first
// Check for direct variable reference pattern: <variable.name>
const directVariableMatch = trimmedValue.match(/^<variable\.([^>]+)>$/)
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 <variable.${variableName}> 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: <loop.property>
const directLoopMatch = trimmedValue.match(/^<loop\.([^>]+)>$/)
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 <loop.${directLoopMatch[1]}> 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: <parallel.property>
const directParallelMatch = trimmedValue.match(/^<parallel\.([^>]+)>$/)
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 <parallel.${directParallelMatch[1]}> 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: <variable.name>
const directVariableMatch = trimmedValue.match(/^<variable\.([^>]+)>$/)
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 <variable.${variableName}> 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<string, any>
),
}))
} 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) || /<[^>]*}}\$/.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) || /<[^>]*}\$/.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 (<loop.property>).
* 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., <loop.currentItem.key>)
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 (<parallel.property>).
* 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., <parallel.currentItem.key>)
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<string, any> | 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(/^<variable\.([^>]+)>$/)
if (directVariableMatch) {
const variableName = directVariableMatch[1]
const variable = this.findVariableByName(variableName)
if (variable) {
acc[cellKey] = this.getTypedVariableValue(variable)
} else {
logger.warn(
`Variable reference <variable.${variableName}> 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<string, any>
),
}))
}
// 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
}
}
+39
View File
@@ -96,6 +96,45 @@ export interface ExecutionContext {
loopItems: Map<string, any> // Tracks current item for forEach loops
completedLoops: Set<string> // Tracks which loops have completed all iterations
// Parallel execution tracking
parallelExecutions?: Map<
string,
{
parallelCount: number
distributionItems: any[] | Record<string, any> | null
completedExecutions: number
executionResults: Map<string, any>
activeIterations: Set<number>
currentIteration: number
parallelType?: 'count' | 'collection'
}
>
// Loop execution tracking
loopExecutions?: Map<
string,
{
maxIterations: number
loopType: 'for' | 'forEach'
forEachItems?: any[] | Record<string, any> | null
executionResults: Map<string, any> // 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<string> // Set of block IDs that have been executed
activeExecutionPath: Set<string> // Set of block IDs in the current execution path
+9 -3
View File
@@ -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 {
+45 -2
View File
@@ -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<string, BlockState>,
edges: Edge[],
loops: Record<string, Loop>
loops: Record<string, Loop>,
parallels?: Record<string, Parallel>
): 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<string, any> {
// 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}`)
+7
View File
@@ -6,6 +6,7 @@ export interface SerializedWorkflow {
blocks: SerializedBlock[]
connections: SerializedConnection[]
loops: Record<string, SerializedLoop>
parallels?: Record<string, SerializedParallel>
}
export interface SerializedConnection {
@@ -46,3 +47,9 @@ export interface SerializedLoop {
loopType?: 'for' | 'forEach' | 'while'
forEachItems?: any[] | Record<string, any> | string // Items to iterate over or expression to evaluate
}
export interface SerializedParallel {
id: string
nodes: string[]
distribution?: any[] | Record<string, any> | string // Items to distribute or expression to evaluate
}
+1
View File
@@ -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(),
+4
View File
@@ -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,
+15 -4
View File
@@ -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
+7 -1
View File
@@ -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(),
+22 -2
View File
@@ -128,6 +128,7 @@ function resetWorkflowStores() {
blocks: {},
edges: [],
loops: {},
parallels: {},
isDeployed: false,
deployedAt: undefined,
},
@@ -497,6 +498,7 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
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<WorkflowRegistry>()(
const {
blocks,
edges,
parallels,
history,
loops,
isDeployed,
@@ -545,6 +548,7 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
blocks,
edges,
loops,
parallels,
isDeployed: workflowIsDeployed !== undefined ? workflowIsDeployed : false,
deployedAt: workflowDeployedAt ? new Date(workflowDeployedAt) : undefined,
needsRedeployment:
@@ -557,7 +561,8 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
state: {
blocks,
edges,
loops: {},
loops,
parallels,
isDeployed: workflowIsDeployed !== undefined ? workflowIsDeployed : false,
deployedAt: workflowDeployedAt,
},
@@ -599,6 +604,7 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
blocks: {},
edges: [],
loops: {},
parallels: {},
isDeployed: false,
deployedAt: undefined,
deploymentStatuses: {},
@@ -610,6 +616,7 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
blocks: {},
edges: [],
loops: {},
parallels: {},
isDeployed: false,
deployedAt: undefined,
},
@@ -664,6 +671,7 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
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<WorkflowRegistry>()(
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<WorkflowRegistry>()(
},
edges: [],
loops: {},
parallels: {},
isDeployed: false,
deployedAt: undefined,
deploymentStatuses: {}, // Initialize empty deployment statuses map
@@ -801,6 +811,7 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
},
edges: [],
loops: {},
parallels: {},
isDeployed: false,
deployedAt: undefined,
workspaceId, // Include workspace ID in history
@@ -887,6 +898,7 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
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<WorkflowRegistry>()(
blocks: state.blocks || {},
edges: state.edges || [],
loops: state.loops || {},
parallels: state.parallels || {},
isDeployed: false,
deployedAt: undefined,
},
@@ -982,6 +995,7 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
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<WorkflowRegistry>()(
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<WorkflowRegistry>()(
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<WorkflowRegistry>()(
blocks,
edges,
loops,
parallels,
isDeployed: isDeployed || false,
deployedAt,
},
@@ -1117,6 +1135,7 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
blocks: {},
edges: [],
loops: {},
parallels: {},
isDeployed: false,
deployedAt: undefined,
hasActiveSchedule: false,
@@ -1127,6 +1146,7 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
blocks: {},
edges: [],
loops: {},
parallels: {},
isDeployed: false,
deployedAt: undefined,
},
+1
View File
@@ -264,6 +264,7 @@ export async function fetchWorkflowsFromDB(): Promise<void> {
blocks: state.blocks || {},
edges: state.edges || [],
loops: state.loops || {},
parallels: state.parallels || {},
isDeployed: isDeployed || false,
deployedAt: deployedAt ? new Date(deployedAt) : undefined,
apiKey,
@@ -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)
})
})
})
+345 -171
View File
@@ -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<WorkflowStoreWithHistory>()(
set({ needsRedeployment })
},
addBlock: (id: string, type: string, name: string, position: Position) => {
addBlock: (
id: string,
type: string,
name: string,
position: Position,
data?: Record<string, any>,
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<string, SubBlockState> = {}
blockConfig.subBlocks.forEach((subBlock) => {
const subBlockId = subBlock.id
@@ -117,10 +173,12 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
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<WorkflowStoreWithHistory>()(
// 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<WorkflowStoreWithHistory>()(
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<WorkflowStoreWithHistory>()(
}))
}
// 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<WorkflowStoreWithHistory>()(
const newEdges = [...get().edges, newEdge]
// Recalculate all loops after adding the edge
const newLoops: Record<string, Loop> = {}
const processedPaths = new Set<string>()
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<WorkflowStoreWithHistory>()(
},
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<string, Loop> = {}
const processedPaths = new Set<string>()
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<WorkflowStoreWithHistory>()(
blocks: {},
edges: [],
loops: {},
parallels: {},
isDeployed: false,
isPublished: false,
},
@@ -383,10 +473,11 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
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<WorkflowStoreWithHistory>()(
},
},
edges: [...get().edges],
loops: { ...get().loops },
}
set(newState)
@@ -463,7 +555,8 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
},
},
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<WorkflowStoreWithHistory>()(
},
},
edges: [...get().edges],
loops: { ...get().loops },
}
set(newState)
@@ -522,6 +616,7 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
},
edges: [...get().edges],
loops: { ...get().loops },
parallels: { ...get().parallels },
}
// Update references in subblock store
@@ -603,7 +698,7 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
},
},
edges: [...state.edges],
loops: { ...get().loops },
loops: { ...state.loops },
}))
get().updateLastSaved()
get().sync.markDirty()
@@ -620,69 +715,84 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
},
},
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<WorkflowStoreWithHistory>()(
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<WorkflowStoreWithHistory>()(
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' }
)
+55 -4
View File
@@ -18,6 +18,7 @@ export interface BlockState {
isWide?: boolean
height?: number
advancedMode?: boolean
data?: Record<string, any>
}
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, any> | string // Items or expression
}
export interface Parallel {
id: string
nodes: string[]
distribution?: any[] | Record<string, any> | string // Items or expression
}
export interface DeploymentStatus {
isDeployed: boolean
deployedAt?: Date
@@ -46,6 +81,7 @@ export interface WorkflowState {
edges: Edge[]
lastSaved?: number
loops: Record<string, Loop>
parallels: Record<string, Parallel>
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<string, any>,
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<string, Loop>
generateParallelBlocks: () => Record<string, Parallel>
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
@@ -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<string, BlockState> = {
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<string, BlockState> = {
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<string, BlockState> = {
loop1: {
id: 'loop1',
type: 'loop',
name: 'Test Loop',
position: { x: 0, y: 0 },
subBlocks: {},
outputs: {},
enabled: true,
data: {
loopType: 'forEach',
count: 5,
collection: '<blockName.response.items>',
},
},
}
const result = convertLoopBlockToLoop('loop1', blocks)
expect(result).toBeDefined()
expect(result?.forEachItems).toBe('<blockName.response.items>')
})
test('should handle empty collection', () => {
const blocks: Record<string, BlockState> = {
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<string, BlockState> = {
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'])
})
})
+148 -47
View File
@@ -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<string>()
const recursionStack = new Set<string>()
const allCycles: string[][] = []
const currentPath: string[] = []
export function convertLoopBlockToLoop(
loopBlockId: string,
blocks: Record<string, BlockState>
): 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<string, BlockState>
): 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, BlockState>): 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, BlockState>
): 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<string, BlockState>): Record<string, Loop> {
const loops: Record<string, Loop> = {}
// 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<string, BlockState>
): Record<string, Parallel> {
const parallels: Record<string, Parallel> = {}
// 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
}