mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
co-authored by
Emir Karabeg
parent
ef8ae60afd
commit
b1126e3d6a
@@ -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
|
||||
@@ -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> = {}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+3
@@ -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'
|
||||
+47
@@ -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>
|
||||
)
|
||||
}
|
||||
+48
@@ -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>
|
||||
|
||||
+4
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,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
@@ -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
@@ -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 },
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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' }
|
||||
)
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user