fix(subflow): fixed subflow execution regardless of path decision (#707)

* fix typo in docs file

* fix(subflows): fixed subflows executing irrespective of active path

* added routing strategy

* reorganized executor

* brought folder renaming inline

* cleanup
This commit is contained in:
Waleed Latif
2025-07-16 14:21:32 -07:00
committed by GitHub
parent 4c6c7272c5
commit 92fe353f44
44 changed files with 2201 additions and 392 deletions

View File

@@ -24,8 +24,8 @@ interface FolderContextMenuProps {
folderId: string
folderName: string
onCreateWorkflow: (folderId: string) => void
onRename?: (folderId: string, newName: string) => void
onDelete?: (folderId: string) => void
onStartEdit?: () => void
level: number
}
@@ -33,23 +33,20 @@ export function FolderContextMenu({
folderId,
folderName,
onCreateWorkflow,
onRename,
onDelete,
onStartEdit,
level,
}: FolderContextMenuProps) {
const [showSubfolderDialog, setShowSubfolderDialog] = useState(false)
const [showRenameDialog, setShowRenameDialog] = useState(false)
const [subfolderName, setSubfolderName] = useState('')
const [renameName, setRenameName] = useState(folderName)
const [isCreating, setIsCreating] = useState(false)
const [isRenaming, setIsRenaming] = useState(false)
const params = useParams()
const workspaceId = params.workspaceId as string
// Get user permissions for the workspace
const userPermissions = useUserPermissionsContext()
const { createFolder, updateFolder, deleteFolder } = useFolderStore()
const { createFolder, deleteFolder } = useFolderStore()
const handleCreateWorkflow = () => {
onCreateWorkflow(folderId)
@@ -60,8 +57,9 @@ export function FolderContextMenu({
}
const handleRename = () => {
setRenameName(folderName)
setShowRenameDialog(true)
if (onStartEdit) {
onStartEdit()
}
}
const handleDelete = async () => {
@@ -98,31 +96,9 @@ export function FolderContextMenu({
}
}
const handleRenameSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!renameName.trim()) return
setIsRenaming(true)
try {
if (onRename) {
onRename(folderId, renameName.trim())
} else {
// Default rename behavior
await updateFolder(folderId, { name: renameName.trim() })
}
setShowRenameDialog(false)
} catch (error) {
console.error('Failed to rename folder:', error)
} finally {
setIsRenaming(false)
}
}
const handleCancel = () => {
setSubfolderName('')
setShowSubfolderDialog(false)
setRenameName(folderName)
setShowRenameDialog(false)
}
return (
@@ -230,37 +206,6 @@ export function FolderContextMenu({
</form>
</DialogContent>
</Dialog>
{/* Rename dialog */}
<Dialog open={showRenameDialog} onOpenChange={setShowRenameDialog}>
<DialogContent className='sm:max-w-[425px]' onClick={(e) => e.stopPropagation()}>
<DialogHeader>
<DialogTitle>Rename Folder</DialogTitle>
</DialogHeader>
<form onSubmit={handleRenameSubmit} className='space-y-4'>
<div className='space-y-2'>
<Label htmlFor='rename-folder'>Folder Name</Label>
<Input
id='rename-folder'
value={renameName}
onChange={(e) => setRenameName(e.target.value)}
placeholder='Enter folder name...'
maxLength={50}
autoFocus
required
/>
</div>
<div className='flex justify-end space-x-2'>
<Button type='button' variant='outline' onClick={handleCancel}>
Cancel
</Button>
<Button type='submit' disabled={!renameName.trim() || isRenaming}>
{isRenaming ? 'Renaming...' : 'Rename'}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</>
)
}

View File

@@ -14,6 +14,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Input } from '@/components/ui/input'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { createLogger } from '@/lib/logs/console-logger'
import { type FolderTreeNode, useFolderStore } from '@/stores/folders/store'
@@ -48,14 +49,33 @@ export function FolderItem({
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [isDragging, setIsDragging] = useState(false)
const [isEditing, setIsEditing] = useState(false)
const [editValue, setEditValue] = useState(folder.name)
const [isRenaming, setIsRenaming] = useState(false)
const dragStartedRef = useRef(false)
const inputRef = useRef<HTMLInputElement>(null)
const params = useParams()
const workspaceId = params.workspaceId as string
const isExpanded = expandedFolders.has(folder.id)
const updateTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
const pendingStateRef = useRef<boolean | null>(null)
// Update editValue when folder name changes
useEffect(() => {
setEditValue(folder.name)
}, [folder.name])
// Focus input when entering edit mode
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus()
inputRef.current.select()
}
}, [isEditing])
const handleToggleExpanded = useCallback(() => {
if (isEditing) return // Don't toggle when editing
const newExpandedState = !isExpanded
toggleExpanded(folder.id)
pendingStateRef.current = newExpandedState
@@ -73,9 +93,11 @@ export function FolderItem({
})
}
}, 300)
}, [folder.id, isExpanded, toggleExpanded, updateFolderAPI])
}, [folder.id, isExpanded, toggleExpanded, updateFolderAPI, isEditing])
const handleDragStart = (e: React.DragEvent) => {
if (isEditing) return
dragStartedRef.current = true
setIsDragging(true)
@@ -101,7 +123,7 @@ export function FolderItem({
}
const handleClick = (e: React.MouseEvent) => {
if (dragStartedRef.current) {
if (dragStartedRef.current || isEditing) {
e.preventDefault()
return
}
@@ -116,15 +138,57 @@ export function FolderItem({
}
}, [])
const handleRename = async (folderId: string, newName: string) => {
const handleStartEdit = () => {
setIsEditing(true)
setEditValue(folder.name)
}
const handleSaveEdit = async () => {
if (!editValue.trim() || editValue.trim() === folder.name) {
setIsEditing(false)
setEditValue(folder.name)
return
}
setIsRenaming(true)
try {
await updateFolderAPI(folderId, { name: newName })
await updateFolderAPI(folder.id, { name: editValue.trim() })
logger.info(`Successfully renamed folder from "${folder.name}" to "${editValue.trim()}"`)
setIsEditing(false)
} catch (error) {
logger.error('Failed to rename folder:', { error })
logger.error('Failed to rename folder:', {
error,
folderId: folder.id,
oldName: folder.name,
newName: editValue.trim(),
})
// Reset to original name on error
setEditValue(folder.name)
} finally {
setIsRenaming(false)
}
}
const handleDelete = async (folderId: string) => {
const handleCancelEdit = () => {
setIsEditing(false)
setEditValue(folder.name)
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
handleSaveEdit()
} else if (e.key === 'Escape') {
e.preventDefault()
handleCancelEdit()
}
}
const handleInputBlur = () => {
handleSaveEdit()
}
const handleDelete = async () => {
setShowDeleteDialog(true)
}
@@ -154,7 +218,7 @@ export function FolderItem({
onDragLeave={onDragLeave}
onDrop={onDrop}
onClick={handleClick}
draggable={true}
draggable={!isEditing}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
@@ -222,7 +286,7 @@ export function FolderItem({
maxWidth: isFirstItem ? `${164 - level * 20}px` : `${206 - level * 20}px`,
}}
onClick={handleClick}
draggable={true}
draggable={!isEditing}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
@@ -234,18 +298,34 @@ export function FolderItem({
)}
</div>
<span className='flex-1 select-none truncate text-muted-foreground'>{folder.name}</span>
<div className='flex items-center justify-center' onClick={(e) => e.stopPropagation()}>
<FolderContextMenu
folderId={folder.id}
folderName={folder.name}
onCreateWorkflow={onCreateWorkflow}
onRename={handleRename}
onDelete={handleDelete}
level={level}
{isEditing ? (
<Input
ref={inputRef}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleInputBlur}
className='h-6 flex-1 border-0 bg-transparent p-0 text-muted-foreground text-sm outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0'
maxLength={50}
disabled={isRenaming}
onClick={(e) => e.stopPropagation()} // Prevent folder toggle when clicking input
/>
</div>
) : (
<span className='flex-1 select-none truncate text-muted-foreground'>{folder.name}</span>
)}
{!isEditing && (
<div className='flex items-center justify-center' onClick={(e) => e.stopPropagation()}>
<FolderContextMenu
folderId={folder.id}
folderName={folder.name}
onCreateWorkflow={onCreateWorkflow}
onDelete={handleDelete}
onStartEdit={handleStartEdit}
level={level}
/>
</div>
)}
</div>
</div>

View File

@@ -21,7 +21,7 @@ export const GoogleCalendarBlock: BlockConfig<GoogleCalendarResponse> = {
description: 'Manage Google Calendar events',
longDescription:
"Integrate Google Calendar functionality to create, read, update, and list calendar events within your workflow. Automate scheduling, check availability, and manage events using OAuth authentication. Email invitations are sent asynchronously and delivery depends on recipients' Google Calendar settings.",
docsLink: 'https://docs.simstudio.ai/tools/google-calendar',
docsLink: 'https://docs.simstudio.ai/tools/google_calendar',
category: 'tools',
bgColor: '#E0E0E0',
icon: GoogleCalendarIcon,

View File

@@ -0,0 +1,29 @@
/**
* Enum defining all supported block types in the executor.
* This centralizes block type definitions and eliminates magic strings.
*/
export enum BlockType {
PARALLEL = 'parallel',
LOOP = 'loop',
ROUTER = 'router',
CONDITION = 'condition',
FUNCTION = 'function',
AGENT = 'agent',
API = 'api',
EVALUATOR = 'evaluator',
RESPONSE = 'response',
WORKFLOW = 'workflow',
STARTER = 'starter',
}
/**
* Array of all block types for iteration and validation
*/
export const ALL_BLOCK_TYPES = Object.values(BlockType) as string[]
/**
* Type guard to check if a string is a valid block type
*/
export function isValidBlockType(type: string): type is BlockType {
return ALL_BLOCK_TYPES.includes(type)
}

View File

@@ -1,12 +1,13 @@
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
import { isHosted } from '@/lib/environment'
import { getAllBlocks } from '@/blocks'
import { BlockType } from '@/executor/consts'
import { AgentBlockHandler } from '@/executor/handlers/agent/agent-handler'
import type { ExecutionContext, StreamingExecution } from '@/executor/types'
import { executeProviderRequest } from '@/providers'
import { getProviderFromModel, transformBlockTool } from '@/providers/utils'
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
import { executeTool } from '@/tools'
import type { ExecutionContext, StreamingExecution } from '../../types'
import { AgentBlockHandler } from './agent-handler'
process.env.NEXT_PUBLIC_APP_URL = 'http://localhost:3000'
@@ -88,8 +89,8 @@ describe('AgentBlockHandler', () => {
mockBlock = {
id: 'test-agent-block',
metadata: { id: 'agent', name: 'Test Agent' },
type: 'agent',
metadata: { id: BlockType.AGENT, name: 'Test Agent' },
type: BlockType.AGENT,
position: { x: 0, y: 0 },
config: {
tool: 'mock-tool',
@@ -908,7 +909,7 @@ describe('AgentBlockHandler', () => {
logs: [
{
blockId: 'some-id',
blockType: 'agent',
blockType: BlockType.AGENT,
startedAt: new Date().toISOString(),
endedAt: new Date().toISOString(),
durationMs: 100,
@@ -959,7 +960,7 @@ describe('AgentBlockHandler', () => {
const logs = (result as StreamingExecution).execution.logs
expect(logs?.length).toBe(1)
if (logs && logs.length > 0 && logs[0]) {
expect(logs[0].blockType).toBe('agent')
expect(logs[0].blockType).toBe(BlockType.AGENT)
}
})
@@ -1069,7 +1070,7 @@ describe('AgentBlockHandler', () => {
memories: [
{
key: 'conversation-1',
type: 'agent',
type: BlockType.AGENT,
data: [
{ role: 'user', content: 'Hi there!' },
{ role: 'assistant', content: 'Hello! How can I help you?' },

View File

@@ -2,13 +2,19 @@ import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console-logger'
import { getAllBlocks } from '@/blocks'
import type { BlockOutput } from '@/blocks/types'
import { BlockType } from '@/executor/consts'
import type {
AgentInputs,
Message,
StreamingConfig,
ToolInput,
} from '@/executor/handlers/agent/types'
import type { BlockHandler, ExecutionContext, StreamingExecution } from '@/executor/types'
import { executeProviderRequest } from '@/providers'
import { getApiKey, getProviderFromModel, transformBlockTool } from '@/providers/utils'
import type { SerializedBlock } from '@/serializer/types'
import { executeTool } from '@/tools'
import { getTool, getToolAsync } from '@/tools/utils'
import type { BlockHandler, ExecutionContext, StreamingExecution } from '../../types'
import type { AgentInputs, Message, StreamingConfig, ToolInput } from './types'
const logger = createLogger('AgentBlockHandler')
@@ -22,7 +28,7 @@ const CUSTOM_TOOL_PREFIX = 'custom_'
*/
export class AgentBlockHandler implements BlockHandler {
canHandle(block: SerializedBlock): boolean {
return block.metadata?.id === 'agent'
return block.metadata?.id === BlockType.AGENT
}
async execute(

View File

@@ -1,12 +1,13 @@
import '../../__test-utils__/mock-dependencies'
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
import { BlockType } from '@/executor/consts'
import { ApiBlockHandler } from '@/executor/handlers/api/api-handler'
import type { ExecutionContext } from '@/executor/types'
import type { SerializedBlock } from '@/serializer/types'
import { executeTool } from '@/tools'
import type { ToolConfig } from '@/tools/types'
import { getTool } from '@/tools/utils'
import type { ExecutionContext } from '../../types'
import { ApiBlockHandler } from './api-handler'
const mockGetTool = vi.mocked(getTool)
const mockExecuteTool = executeTool as Mock
@@ -21,7 +22,7 @@ describe('ApiBlockHandler', () => {
handler = new ApiBlockHandler()
mockBlock = {
id: 'api-block-1',
metadata: { id: 'api', name: 'Test API Block' },
metadata: { id: BlockType.API, name: 'Test API Block' },
position: { x: 10, y: 10 },
config: { tool: 'http_request', params: {} },
inputs: {},

View File

@@ -1,8 +1,9 @@
import { createLogger } from '@/lib/logs/console-logger'
import { BlockType } from '@/executor/consts'
import type { BlockHandler, ExecutionContext } from '@/executor/types'
import type { SerializedBlock } from '@/serializer/types'
import { executeTool } from '@/tools'
import { getTool } from '@/tools/utils'
import type { BlockHandler, ExecutionContext } from '../../types'
const logger = createLogger('ApiBlockHandler')
@@ -11,7 +12,7 @@ const logger = createLogger('ApiBlockHandler')
*/
export class ApiBlockHandler implements BlockHandler {
canHandle(block: SerializedBlock): boolean {
return block.metadata?.id === 'api'
return block.metadata?.id === BlockType.API
}
async execute(

View File

@@ -1,11 +1,12 @@
import '../../__test-utils__/mock-dependencies'
import { beforeEach, describe, expect, it, type Mocked, type MockedClass, vi } from 'vitest'
import { BlockType } from '@/executor/consts'
import { ConditionBlockHandler } from '@/executor/handlers/condition/condition-handler'
import { PathTracker } from '@/executor/path/path'
import { InputResolver } from '@/executor/resolver/resolver'
import type { BlockState, ExecutionContext } from '@/executor/types'
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
import { PathTracker } from '../../path'
import { InputResolver } from '../../resolver'
import type { BlockState, ExecutionContext } from '../../types'
import { ConditionBlockHandler } from './condition-handler'
const MockPathTracker = PathTracker as MockedClass<typeof PathTracker>
const MockInputResolver = InputResolver as MockedClass<typeof InputResolver>
@@ -34,9 +35,9 @@ describe('ConditionBlockHandler', () => {
}
mockBlock = {
id: 'cond-block-1',
metadata: { id: 'condition', name: 'Test Condition' },
metadata: { id: BlockType.CONDITION, name: 'Test Condition' },
position: { x: 50, y: 50 },
config: { tool: 'condition', params: {} },
config: { tool: BlockType.CONDITION, params: {} },
inputs: { conditions: 'json' }, // Corrected based on previous step
outputs: {},
enabled: true,

View File

@@ -1,9 +1,10 @@
import { createLogger } from '@/lib/logs/console-logger'
import type { BlockOutput } from '@/blocks/types'
import { BlockType } from '@/executor/consts'
import type { PathTracker } from '@/executor/path/path'
import type { InputResolver } from '@/executor/resolver/resolver'
import type { BlockHandler, ExecutionContext } from '@/executor/types'
import type { SerializedBlock } from '@/serializer/types'
import type { PathTracker } from '../../path'
import type { InputResolver } from '../../resolver'
import type { BlockHandler, ExecutionContext } from '../../types'
const logger = createLogger('ConditionBlockHandler')
@@ -21,7 +22,7 @@ export class ConditionBlockHandler implements BlockHandler {
) {}
canHandle(block: SerializedBlock): boolean {
return block.metadata?.id === 'condition'
return block.metadata?.id === BlockType.CONDITION
}
async execute(

View File

@@ -1,10 +1,11 @@
import '../../__test-utils__/mock-dependencies'
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
import { BlockType } from '@/executor/consts'
import { EvaluatorBlockHandler } from '@/executor/handlers/evaluator/evaluator-handler'
import type { ExecutionContext } from '@/executor/types'
import { getProviderFromModel } from '@/providers/utils'
import type { SerializedBlock } from '@/serializer/types'
import type { ExecutionContext } from '../../types'
import { EvaluatorBlockHandler } from './evaluator-handler'
const mockGetProviderFromModel = getProviderFromModel as Mock
const mockFetch = global.fetch as unknown as Mock
@@ -19,9 +20,9 @@ describe('EvaluatorBlockHandler', () => {
mockBlock = {
id: 'eval-block-1',
metadata: { id: 'evaluator', name: 'Test Evaluator' },
metadata: { id: BlockType.EVALUATOR, name: 'Test Evaluator' },
position: { x: 20, y: 20 },
config: { tool: 'evaluator', params: {} },
config: { tool: BlockType.EVALUATOR, params: {} },
inputs: {
content: 'string',
metrics: 'json',

View File

@@ -1,9 +1,10 @@
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console-logger'
import type { BlockOutput } from '@/blocks/types'
import { BlockType } from '@/executor/consts'
import type { BlockHandler, ExecutionContext } from '@/executor/types'
import { calculateCost, getProviderFromModel } from '@/providers/utils'
import type { SerializedBlock } from '@/serializer/types'
import type { BlockHandler, ExecutionContext } from '../../types'
const logger = createLogger('EvaluatorBlockHandler')
@@ -12,7 +13,7 @@ const logger = createLogger('EvaluatorBlockHandler')
*/
export class EvaluatorBlockHandler implements BlockHandler {
canHandle(block: SerializedBlock): boolean {
return block.metadata?.id === 'evaluator'
return block.metadata?.id === BlockType.EVALUATOR
}
async execute(

View File

@@ -1,8 +1,9 @@
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
import { BlockType } from '@/executor/consts'
import { FunctionBlockHandler } from '@/executor/handlers/function/function-handler'
import type { ExecutionContext } from '@/executor/types'
import type { SerializedBlock } from '@/serializer/types'
import { executeTool } from '@/tools'
import type { ExecutionContext } from '../../types'
import { FunctionBlockHandler } from './function-handler'
vi.mock('@/lib/logs/console-logger', () => ({
createLogger: vi.fn(() => ({
@@ -29,9 +30,9 @@ describe('FunctionBlockHandler', () => {
mockBlock = {
id: 'func-block-1',
metadata: { id: 'function', name: 'Test Function' },
metadata: { id: BlockType.FUNCTION, name: 'Test Function' },
position: { x: 30, y: 30 },
config: { tool: 'function', params: {} },
config: { tool: BlockType.FUNCTION, params: {} },
inputs: { code: 'string', timeout: 'number' }, // Using ParamType strings
outputs: {},
enabled: true,

View File

@@ -1,7 +1,8 @@
import { createLogger } from '@/lib/logs/console-logger'
import { BlockType } from '@/executor/consts'
import type { BlockHandler, ExecutionContext } from '@/executor/types'
import type { SerializedBlock } from '@/serializer/types'
import { executeTool } from '@/tools'
import type { BlockHandler, ExecutionContext } from '../../types'
const logger = createLogger('FunctionBlockHandler')
@@ -10,7 +11,7 @@ const logger = createLogger('FunctionBlockHandler')
*/
export class FunctionBlockHandler implements BlockHandler {
canHandle(block: SerializedBlock): boolean {
return block.metadata?.id === 'function'
return block.metadata?.id === BlockType.FUNCTION
}
async execute(

View File

@@ -1,12 +1,13 @@
import '../../__test-utils__/mock-dependencies'
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
import { BlockType } from '@/executor/consts'
import { GenericBlockHandler } from '@/executor/handlers/generic/generic-handler'
import type { ExecutionContext } from '@/executor/types'
import type { SerializedBlock } from '@/serializer/types'
import { executeTool } from '@/tools'
import type { ToolConfig } from '@/tools/types'
import { getTool } from '@/tools/utils'
import type { ExecutionContext } from '../../types'
import { GenericBlockHandler } from './generic-handler'
const mockGetTool = vi.mocked(getTool)
const mockExecuteTool = executeTool as Mock
@@ -74,7 +75,7 @@ describe('GenericBlockHandler', () => {
})
it.concurrent('should always handle any block type', () => {
const agentBlock: SerializedBlock = { ...mockBlock, metadata: { id: 'agent' } }
const agentBlock: SerializedBlock = { ...mockBlock, metadata: { id: BlockType.AGENT } }
expect(handler.canHandle(agentBlock)).toBe(true)
expect(handler.canHandle(mockBlock)).toBe(true)
const noMetaIdBlock: SerializedBlock = { ...mockBlock, metadata: undefined }

View File

@@ -1,8 +1,8 @@
import { createLogger } from '@/lib/logs/console-logger'
import type { BlockHandler, ExecutionContext } from '@/executor/types'
import type { SerializedBlock } from '@/serializer/types'
import { executeTool } from '@/tools'
import { getTool } from '@/tools/utils'
import type { BlockHandler, ExecutionContext } from '../../types'
const logger = createLogger('GenericBlockHandler')

View File

@@ -1,14 +1,14 @@
import { AgentBlockHandler } from './agent/agent-handler'
import { ApiBlockHandler } from './api/api-handler'
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 { ResponseBlockHandler } from './response/response-handler'
import { RouterBlockHandler } from './router/router-handler'
import { WorkflowBlockHandler } from './workflow/workflow-handler'
import { AgentBlockHandler } from '@/executor/handlers/agent/agent-handler'
import { ApiBlockHandler } from '@/executor/handlers/api/api-handler'
import { ConditionBlockHandler } from '@/executor/handlers/condition/condition-handler'
import { EvaluatorBlockHandler } from '@/executor/handlers/evaluator/evaluator-handler'
import { FunctionBlockHandler } from '@/executor/handlers/function/function-handler'
import { GenericBlockHandler } from '@/executor/handlers/generic/generic-handler'
import { LoopBlockHandler } from '@/executor/handlers/loop/loop-handler'
import { ParallelBlockHandler } from '@/executor/handlers/parallel/parallel-handler'
import { ResponseBlockHandler } from '@/executor/handlers/response/response-handler'
import { RouterBlockHandler } from '@/executor/handlers/router/router-handler'
import { WorkflowBlockHandler } from '@/executor/handlers/workflow/workflow-handler'
export {
AgentBlockHandler,

View File

@@ -1,22 +1,28 @@
import { vi } from 'vitest'
import { BlockType } from '@/executor/consts'
import { LoopBlockHandler } from '@/executor/handlers/loop/loop-handler'
import type { ExecutionContext } from '@/executor/types'
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
const mockPathTracker = {
isInActivePath: vi.fn(),
}
beforeEach(() => {
handler = new LoopBlockHandler()
mockBlock = {
id: 'loop-1',
position: { x: 0, y: 0 },
config: { tool: 'loop', params: {} },
config: { tool: BlockType.LOOP, params: {} },
inputs: {},
outputs: {},
metadata: { id: 'loop', name: 'Test Loop' },
metadata: { id: BlockType.LOOP, name: 'Test Loop' },
enabled: true,
}
@@ -66,7 +72,7 @@ describe('LoopBlockHandler', () => {
it('should not handle non-loop blocks', () => {
if (mockBlock.metadata) {
mockBlock.metadata.id = 'function'
mockBlock.metadata.id = BlockType.FUNCTION
}
expect(handler.canHandle(mockBlock)).toBe(false)
})
@@ -212,4 +218,46 @@ describe('LoopBlockHandler', () => {
)
})
})
describe('PathTracker integration', () => {
it('should activate children when in active path', async () => {
const handlerWithPathTracker = new LoopBlockHandler(undefined, mockPathTracker as any)
// Mock PathTracker to return true (block is in active path)
mockPathTracker.isInActivePath.mockReturnValue(true)
await handlerWithPathTracker.execute(mockBlock, {}, mockContext)
// Should activate children when in active path
expect(mockContext.activeExecutionPath.has('inner-block')).toBe(true)
expect(mockPathTracker.isInActivePath).toHaveBeenCalledWith('loop-1', mockContext)
})
it('should not activate children when not in active path', async () => {
const handlerWithPathTracker = new LoopBlockHandler(undefined, mockPathTracker as any)
// Mock PathTracker to return false (block is not in active path)
mockPathTracker.isInActivePath.mockReturnValue(false)
await handlerWithPathTracker.execute(mockBlock, {}, mockContext)
// Should not activate children when not in active path
expect(mockContext.activeExecutionPath.has('inner-block')).toBe(false)
expect(mockPathTracker.isInActivePath).toHaveBeenCalledWith('loop-1', mockContext)
})
it('should handle PathTracker errors gracefully', async () => {
const handlerWithPathTracker = new LoopBlockHandler(undefined, mockPathTracker as any)
// Mock PathTracker to throw error
mockPathTracker.isInActivePath.mockImplementation(() => {
throw new Error('PathTracker error')
})
await handlerWithPathTracker.execute(mockBlock, {}, mockContext)
// Should default to activating children when PathTracker fails
expect(mockContext.activeExecutionPath.has('inner-block')).toBe(true)
})
})
})

View File

@@ -1,8 +1,11 @@
import { createLogger } from '@/lib/logs/console-logger'
import type { BlockOutput } from '@/blocks/types'
import { BlockType } from '@/executor/consts'
import type { PathTracker } from '@/executor/path/path'
import type { InputResolver } from '@/executor/resolver/resolver'
import { Routing } from '@/executor/routing/routing'
import type { BlockHandler, ExecutionContext } from '@/executor/types'
import type { SerializedBlock } from '@/serializer/types'
import type { InputResolver } from '../../resolver'
import type { BlockHandler, ExecutionContext } from '../../types'
const logger = createLogger('LoopBlockHandler')
@@ -13,10 +16,13 @@ const DEFAULT_MAX_ITERATIONS = 5
* Loop blocks don't execute logic themselves but control the flow of blocks within them.
*/
export class LoopBlockHandler implements BlockHandler {
constructor(private resolver?: InputResolver) {}
constructor(
private resolver?: InputResolver,
private pathTracker?: PathTracker
) {}
canHandle(block: SerializedBlock): boolean {
return block.metadata?.id === 'loop'
return block.metadata?.id === BlockType.LOOP
}
async execute(
@@ -126,15 +132,31 @@ export class LoopBlockHandler implements BlockHandler {
`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'
) || []
// Use routing strategy to determine if this block requires active path checking
const blockType = block.metadata?.id
if (Routing.requiresActivePathCheck(blockType || '')) {
let isInActivePath = true
if (this.pathTracker) {
try {
isInActivePath = this.pathTracker.isInActivePath(block.id, context)
} catch (error) {
logger.warn(`PathTracker check failed for ${blockType} block ${block.id}:`, error)
// Default to true to maintain existing behavior if PathTracker fails
isInActivePath = true
}
}
for (const conn of loopStartConnections) {
context.activeExecutionPath.add(conn.target)
logger.info(`Activated loop start path to ${conn.target} for iteration ${currentIteration}`)
// Only activate child nodes if this block is in the active execution path
if (isInActivePath) {
this.activateChildNodes(block, context, currentIteration)
} else {
logger.info(
`${blockType} block ${block.id} is not in active execution path, skipping child activation`
)
}
} else {
// Regular blocks always activate their children
this.activateChildNodes(block, context, currentIteration)
}
return {
@@ -147,6 +169,26 @@ export class LoopBlockHandler implements BlockHandler {
} as Record<string, any>
}
/**
* Activate child nodes for loop execution
*/
private activateChildNodes(
block: SerializedBlock,
context: ExecutionContext,
currentIteration: number
): void {
// 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}`)
}
}
/**
* Evaluates forEach items expression or value
*/

View File

@@ -1,21 +1,26 @@
import { describe, expect, it, vi } from 'vitest'
import { createParallelExecutionState } from '@/executor/__test-utils__/executor-mocks'
import { BlockType } from '@/executor/consts'
import { ParallelBlockHandler } from '@/executor/handlers/parallel/parallel-handler'
import type { ExecutionContext } from '@/executor/types'
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 mockPathTracker = {
isInActivePath: vi.fn(),
}
const createMockBlock = (id: string): SerializedBlock => ({
id,
position: { x: 0, y: 0 },
config: { tool: '', params: {} },
inputs: {},
outputs: {},
metadata: { id: 'parallel', name: 'Test Parallel' },
metadata: { id: BlockType.PARALLEL, name: 'Test Parallel' },
enabled: true,
})
@@ -46,7 +51,7 @@ describe('ParallelBlockHandler', () => {
expect(handler.canHandle(block)).toBe(true)
const nonParallelBlock = { ...block, metadata: { id: 'agent' } }
const nonParallelBlock = { ...block, metadata: { id: BlockType.AGENT } }
expect(handler.canHandle(nonParallelBlock)).toBe(false)
})
@@ -394,24 +399,24 @@ describe('ParallelBlockHandler', () => {
{
id: 'agent-1',
position: { x: 0, y: 0 },
config: { tool: 'agent', params: {} },
config: { tool: BlockType.AGENT, params: {} },
inputs: {},
outputs: {},
metadata: { id: 'agent', name: 'Agent 1' },
metadata: { id: BlockType.AGENT, name: 'Agent 1' },
enabled: true,
},
{
id: 'function-1',
position: { x: 0, y: 0 },
config: {
tool: 'function',
tool: BlockType.FUNCTION,
params: {
code: 'return <parallel.results>;',
},
},
inputs: {},
outputs: {},
metadata: { id: 'function', name: 'Function 1' },
metadata: { id: BlockType.FUNCTION, name: 'Function 1' },
enabled: true,
},
],
@@ -481,4 +486,91 @@ describe('ParallelBlockHandler', () => {
}).not.toThrow()
})
})
describe('PathTracker integration', () => {
it('should activate children when in active path', async () => {
const handler = new ParallelBlockHandler(mockResolver as any, mockPathTracker as any)
const block = createMockBlock('parallel-1')
const parallel = {
id: 'parallel-1',
nodes: ['agent-1'],
distribution: ['item1', 'item2'],
}
const context = createMockContext(parallel)
context.workflow!.connections = [
{
source: 'parallel-1',
target: 'agent-1',
sourceHandle: 'parallel-start-source',
},
]
// Mock PathTracker to return true (block is in active path)
mockPathTracker.isInActivePath.mockReturnValue(true)
await handler.execute(block, {}, context)
// Should activate children when in active path
expect(context.activeExecutionPath.has('agent-1')).toBe(true)
expect(mockPathTracker.isInActivePath).toHaveBeenCalledWith('parallel-1', context)
})
it('should not activate children when not in active path', async () => {
const handler = new ParallelBlockHandler(mockResolver as any, mockPathTracker as any)
const block = createMockBlock('parallel-1')
const parallel = {
id: 'parallel-1',
nodes: ['agent-1'],
distribution: ['item1', 'item2'],
}
const context = createMockContext(parallel)
context.workflow!.connections = [
{
source: 'parallel-1',
target: 'agent-1',
sourceHandle: 'parallel-start-source',
},
]
// Mock PathTracker to return false (block is not in active path)
mockPathTracker.isInActivePath.mockReturnValue(false)
await handler.execute(block, {}, context)
// Should not activate children when not in active path
expect(context.activeExecutionPath.has('agent-1')).toBe(false)
expect(mockPathTracker.isInActivePath).toHaveBeenCalledWith('parallel-1', context)
})
it('should handle PathTracker errors gracefully', async () => {
const handler = new ParallelBlockHandler(mockResolver as any, mockPathTracker as any)
const block = createMockBlock('parallel-1')
const parallel = {
id: 'parallel-1',
nodes: ['agent-1'],
distribution: ['item1', 'item2'],
}
const context = createMockContext(parallel)
context.workflow!.connections = [
{
source: 'parallel-1',
target: 'agent-1',
sourceHandle: 'parallel-start-source',
},
]
// Mock PathTracker to throw error
mockPathTracker.isInActivePath.mockImplementation(() => {
throw new Error('PathTracker error')
})
await handler.execute(block, {}, context)
// Should default to activating children when PathTracker fails
expect(context.activeExecutionPath.has('agent-1')).toBe(true)
})
})
})

View File

@@ -1,8 +1,11 @@
import { createLogger } from '@/lib/logs/console-logger'
import type { BlockOutput } from '@/blocks/types'
import { BlockType } from '@/executor/consts'
import type { PathTracker } from '@/executor/path/path'
import type { InputResolver } from '@/executor/resolver/resolver'
import { Routing } from '@/executor/routing/routing'
import type { BlockHandler, ExecutionContext, StreamingExecution } from '@/executor/types'
import type { SerializedBlock } from '@/serializer/types'
import type { InputResolver } from '../../resolver'
import type { BlockHandler, ExecutionContext, StreamingExecution } from '../../types'
const logger = createLogger('ParallelBlockHandler')
@@ -12,10 +15,13 @@ const logger = createLogger('ParallelBlockHandler')
* create virtual instances for true parallel execution.
*/
export class ParallelBlockHandler implements BlockHandler {
constructor(private resolver?: InputResolver) {}
constructor(
private resolver?: InputResolver,
private pathTracker?: PathTracker
) {}
canHandle(block: SerializedBlock): boolean {
return block.metadata?.id === 'parallel'
return block.metadata?.id === BlockType.PARALLEL
}
async execute(
@@ -185,15 +191,31 @@ export class ParallelBlockHandler implements BlockHandler {
}
// Note: For simple count-based parallels without distribution, we don't store items
// 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'
) || []
// Use routing strategy to determine if this block requires active path checking
const blockType = block.metadata?.id
if (Routing.requiresActivePathCheck(blockType || '')) {
let isInActivePath = true
if (this.pathTracker) {
try {
isInActivePath = this.pathTracker.isInActivePath(block.id, context)
} catch (error) {
logger.warn(`PathTracker check failed for ${blockType} block ${block.id}:`, error)
// Default to true to maintain existing behavior if PathTracker fails
isInActivePath = true
}
}
for (const conn of parallelStartConnections) {
context.activeExecutionPath.add(conn.target)
logger.info(`Activated parallel path to ${conn.target}`)
// Only activate child nodes if this block is in the active execution path
if (isInActivePath) {
this.activateChildNodes(block, context)
} else {
logger.info(
`${blockType} block ${block.id} is not in active execution path, skipping child activation`
)
}
} else {
// Regular blocks always activate their children
this.activateChildNodes(block, context)
}
return {
@@ -291,6 +313,22 @@ export class ParallelBlockHandler implements BlockHandler {
} as Record<string, any>
}
/**
* Activate child nodes for parallel execution
*/
private activateChildNodes(block: SerializedBlock, context: ExecutionContext): void {
// 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}`)
}
}
/**
* Checks if all iterations of a parallel block have completed
*/

View File

@@ -1,7 +1,8 @@
import { createLogger } from '@/lib/logs/console-logger'
import type { BlockOutput } from '@/blocks/types'
import { BlockType } from '@/executor/consts'
import type { BlockHandler } from '@/executor/types'
import type { SerializedBlock } from '@/serializer/types'
import type { BlockHandler } from '../../types'
const logger = createLogger('ResponseBlockHandler')
@@ -15,7 +16,7 @@ interface JSONProperty {
export class ResponseBlockHandler implements BlockHandler {
canHandle(block: SerializedBlock): boolean {
return block.metadata?.id === 'response'
return block.metadata?.id === BlockType.RESPONSE
}
async execute(block: SerializedBlock, inputs: Record<string, any>): Promise<BlockOutput> {

View File

@@ -11,11 +11,12 @@ import {
vi,
} from 'vitest'
import { generateRouterPrompt } from '@/blocks/blocks/router'
import { BlockType } from '@/executor/consts'
import { RouterBlockHandler } from '@/executor/handlers/router/router-handler'
import { PathTracker } from '@/executor/path/path'
import type { ExecutionContext } from '@/executor/types'
import { getProviderFromModel } from '@/providers/utils'
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
import { PathTracker } from '../../path'
import type { ExecutionContext } from '../../types'
import { RouterBlockHandler } from './router-handler'
const mockGenerateRouterPrompt = generateRouterPrompt as Mock
const mockGetProviderFromModel = getProviderFromModel as Mock
@@ -52,9 +53,9 @@ describe('RouterBlockHandler', () => {
}
mockBlock = {
id: 'router-block-1',
metadata: { id: 'router', name: 'Test Router' },
metadata: { id: BlockType.ROUTER, name: 'Test Router' },
position: { x: 50, y: 50 },
config: { tool: 'router', params: {} },
config: { tool: BlockType.ROUTER, params: {} },
inputs: { prompt: 'string', model: 'string' }, // Using ParamType strings
outputs: {},
enabled: true,

View File

@@ -2,10 +2,11 @@ import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console-logger'
import { generateRouterPrompt } from '@/blocks/blocks/router'
import type { BlockOutput } from '@/blocks/types'
import { BlockType } from '@/executor/consts'
import type { PathTracker } from '@/executor/path/path'
import type { BlockHandler, ExecutionContext } from '@/executor/types'
import { calculateCost, getProviderFromModel } from '@/providers/utils'
import type { SerializedBlock } from '@/serializer/types'
import type { PathTracker } from '../../path'
import type { BlockHandler, ExecutionContext } from '../../types'
const logger = createLogger('RouterBlockHandler')
@@ -19,7 +20,7 @@ export class RouterBlockHandler implements BlockHandler {
constructor(private pathTracker: PathTracker) {}
canHandle(block: SerializedBlock): boolean {
return block.metadata?.id === 'router'
return block.metadata?.id === BlockType.ROUTER
}
async execute(
@@ -144,7 +145,7 @@ export class RouterBlockHandler implements BlockHandler {
// Extract system prompt for agent blocks
let systemPrompt = ''
if (targetBlock.metadata?.id === 'agent') {
if (targetBlock.metadata?.id === BlockType.AGENT) {
// Try to get system prompt from different possible locations
systemPrompt =
targetBlock.config?.params?.systemPrompt || targetBlock.inputs?.systemPrompt || ''

View File

@@ -1,7 +1,8 @@
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
import { BlockType } from '@/executor/consts'
import { WorkflowBlockHandler } from '@/executor/handlers/workflow/workflow-handler'
import type { ExecutionContext } from '@/executor/types'
import type { SerializedBlock } from '@/serializer/types'
import type { ExecutionContext } from '../../types'
import { WorkflowBlockHandler } from './workflow-handler'
// Mock fetch globally
global.fetch = vi.fn()
@@ -18,9 +19,9 @@ describe('WorkflowBlockHandler', () => {
mockBlock = {
id: 'workflow-block-1',
metadata: { id: 'workflow', name: 'Test Workflow Block' },
metadata: { id: BlockType.WORKFLOW, name: 'Test Workflow Block' },
position: { x: 0, y: 0 },
config: { tool: 'workflow', params: {} },
config: { tool: BlockType.WORKFLOW, params: {} },
inputs: { workflowId: 'string' },
outputs: {},
enabled: true,
@@ -64,9 +65,9 @@ describe('WorkflowBlockHandler', () => {
blocks: [
{
id: 'starter',
metadata: { id: 'starter', name: 'Starter' },
metadata: { id: BlockType.STARTER, name: 'Starter' },
position: { x: 0, y: 0 },
config: { tool: 'starter', params: {} },
config: { tool: BlockType.STARTER, params: {} },
inputs: {},
outputs: {},
enabled: true,
@@ -87,7 +88,7 @@ describe('WorkflowBlockHandler', () => {
})
it('should not handle non-workflow blocks', () => {
const nonWorkflowBlock = { ...mockBlock, metadata: { id: 'function' } }
const nonWorkflowBlock = { ...mockBlock, metadata: { id: BlockType.FUNCTION } }
expect(handler.canHandle(nonWorkflowBlock)).toBe(false)
})
})

View File

@@ -2,11 +2,12 @@ import { generateInternalToken } from '@/lib/auth/internal'
import { createLogger } from '@/lib/logs/console-logger'
import { getBaseUrl } from '@/lib/urls/utils'
import type { BlockOutput } from '@/blocks/types'
import { Executor } from '@/executor'
import { BlockType } from '@/executor/consts'
import type { BlockHandler, ExecutionContext, StreamingExecution } from '@/executor/types'
import { Serializer } from '@/serializer'
import type { SerializedBlock } from '@/serializer/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { Executor } from '../../index'
import type { BlockHandler, ExecutionContext, StreamingExecution } from '../../types'
const logger = createLogger('WorkflowBlockHandler')
@@ -22,7 +23,7 @@ export class WorkflowBlockHandler implements BlockHandler {
private static executionStack = new Set<string>()
canHandle(block: SerializedBlock): boolean {
return block.metadata?.id === 'workflow'
return block.metadata?.id === BlockType.WORKFLOW
}
async execute(

View File

@@ -8,6 +8,7 @@
* resolving inputs and dependencies, and managing errors.
*/
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
import { Executor } from '@/executor'
import {
createMinimalWorkflow,
createMockContext,
@@ -15,8 +16,8 @@ import {
createWorkflowWithErrorPath,
createWorkflowWithLoop,
setupAllMocks,
} from './__test-utils__/executor-mocks'
import { Executor } from './index'
} from '@/executor/__test-utils__/executor-mocks'
import { BlockType } from '@/executor/consts'
vi.mock('@/stores/execution/store', () => ({
useExecutionStore: {
@@ -155,14 +156,14 @@ describe('Executor', () => {
test('should throw error for workflow without starter block', () => {
const workflow = createMinimalWorkflow()
workflow.blocks = workflow.blocks.filter((block) => block.metadata?.id !== 'starter')
workflow.blocks = workflow.blocks.filter((block) => block.metadata?.id !== BlockType.STARTER)
expect(() => new Executor(workflow)).toThrow('Workflow must have an enabled starter block')
})
test('should throw error for workflow with disabled starter block', () => {
const workflow = createMinimalWorkflow()
workflow.blocks.find((block) => block.metadata?.id === 'starter')!.enabled = false
workflow.blocks.find((block) => block.metadata?.id === BlockType.STARTER)!.enabled = false
expect(() => new Executor(workflow)).toThrow('Workflow must have an enabled starter block')
})
@@ -459,7 +460,7 @@ describe('Executor', () => {
inputs: {},
outputs: {},
enabled: true,
metadata: { id: 'condition', name: 'Condition Block' },
metadata: { id: BlockType.CONDITION, name: 'Condition Block' },
})
// Mock context
@@ -641,7 +642,7 @@ describe('Executor', () => {
{
id: 'start',
position: { x: 0, y: 0 },
metadata: { id: 'starter', name: 'Start' },
metadata: { id: BlockType.STARTER, name: 'Start' },
config: { tool: 'test-tool', params: {} },
inputs: {},
outputs: {},
@@ -650,7 +651,7 @@ describe('Executor', () => {
{
id: 'router',
position: { x: 100, y: 0 },
metadata: { id: 'router', name: 'Router' },
metadata: { id: BlockType.ROUTER, name: 'Router' },
config: { tool: 'test-tool', params: { prompt: 'test', model: 'gpt-4' } },
inputs: {},
outputs: {},
@@ -659,7 +660,7 @@ describe('Executor', () => {
{
id: 'api1',
position: { x: 200, y: -50 },
metadata: { id: 'api', name: 'API 1' },
metadata: { id: BlockType.API, name: 'API 1' },
config: { tool: 'test-tool', params: { url: 'http://api1.com', method: 'GET' } },
inputs: {},
outputs: {},
@@ -668,7 +669,7 @@ describe('Executor', () => {
{
id: 'api2',
position: { x: 200, y: 50 },
metadata: { id: 'api', name: 'API 2' },
metadata: { id: BlockType.API, name: 'API 2' },
config: { tool: 'test-tool', params: { url: 'http://api2.com', method: 'GET' } },
inputs: {},
outputs: {},
@@ -677,7 +678,7 @@ describe('Executor', () => {
{
id: 'agent',
position: { x: 300, y: 0 },
metadata: { id: 'agent', name: 'Agent' },
metadata: { id: BlockType.AGENT, name: 'Agent' },
config: { tool: 'test-tool', params: { model: 'gpt-4', userPrompt: 'test' } },
inputs: {},
outputs: {},
@@ -771,7 +772,7 @@ describe('Executor', () => {
workflow.blocks.push({
id: 'router1',
position: { x: 200, y: 0 },
metadata: { id: 'router', name: 'Router' },
metadata: { id: BlockType.ROUTER, name: 'Router' },
config: { tool: 'test-tool', params: {} },
inputs: {},
outputs: {},

View File

@@ -1,10 +1,7 @@
import { BlockPathCalculator } from '@/lib/block-path-calculator'
import { createLogger } from '@/lib/logs/console-logger'
import type { BlockOutput } from '@/blocks/types'
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
import { useExecutionStore } from '@/stores/execution/store'
import { useConsoleStore } from '@/stores/panel/console/store'
import { useGeneralStore } from '@/stores/settings/general/store'
import { BlockType } from '@/executor/consts'
import {
AgentBlockHandler,
ApiBlockHandler,
@@ -17,11 +14,11 @@ import {
ResponseBlockHandler,
RouterBlockHandler,
WorkflowBlockHandler,
} from './handlers/index'
import { LoopManager } from './loops'
import { ParallelManager } from './parallels'
import { PathTracker } from './path'
import { InputResolver } from './resolver'
} from '@/executor/handlers'
import { LoopManager } from '@/executor/loops/loops'
import { ParallelManager } from '@/executor/parallels/parallels'
import { PathTracker } from '@/executor/path/path'
import { InputResolver } from '@/executor/resolver/resolver'
import type {
BlockHandler,
BlockLog,
@@ -29,8 +26,12 @@ import type {
ExecutionResult,
NormalizedBlockOutput,
StreamingExecution,
} from './types'
import { streamingResponseFormatProcessor } from './utils'
} from '@/executor/types'
import { streamingResponseFormatProcessor } from '@/executor/utils'
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
import { useExecutionStore } from '@/stores/execution/store'
import { useConsoleStore } from '@/stores/panel/console/store'
import { useGeneralStore } from '@/stores/settings/general/store'
const logger = createLogger('Executor')
@@ -152,8 +153,8 @@ export class Executor {
new EvaluatorBlockHandler(),
new FunctionBlockHandler(),
new ApiBlockHandler(),
new LoopBlockHandler(this.resolver),
new ParallelBlockHandler(this.resolver),
new LoopBlockHandler(this.resolver, this.pathTracker),
new ParallelBlockHandler(this.resolver, this.pathTracker),
new ResponseBlockHandler(),
new WorkflowBlockHandler(),
new GenericBlockHandler(),
@@ -548,7 +549,7 @@ export class Executor {
*/
private validateWorkflow(): void {
const starterBlock = this.actualWorkflow.blocks.find(
(block) => block.metadata?.id === 'starter'
(block) => block.metadata?.id === BlockType.STARTER
)
if (!starterBlock || !starterBlock.enabled) {
throw new Error('Workflow must have an enabled starter block')
@@ -652,7 +653,7 @@ export class Executor {
}
const starterBlock = this.actualWorkflow.blocks.find(
(block) => block.metadata?.id === 'starter'
(block) => block.metadata?.id === BlockType.STARTER
)
if (starterBlock) {
// Initialize the starter block with the workflow input
@@ -998,7 +999,7 @@ export class Executor {
// 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'
return sourceBlock?.metadata?.id === BlockType.LOOP
})
if (isLoopBlock) {
@@ -1081,7 +1082,7 @@ export class Executor {
// 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') {
if (sourceBlock?.metadata?.id === BlockType.CONDITION) {
const conditionId = conn.sourceHandle.replace('condition-', '')
const selectedCondition = context.decisions.condition.get(conn.source)
@@ -1096,7 +1097,7 @@ export class Executor {
}
// For router blocks, check if this is the selected target
if (sourceBlock?.metadata?.id === 'router') {
if (sourceBlock?.metadata?.id === BlockType.ROUTER) {
const selectedTarget = context.decisions.router.get(conn.source)
// If source is executed and this is not the selected target, consider it met
@@ -1219,7 +1220,7 @@ export class Executor {
// 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') {
if (block.metadata?.id === BlockType.STARTER) {
const starterState = context.blockStates.get(actualBlockId)
if (starterState) {
return starterState.output as NormalizedBlockOutput
@@ -1243,7 +1244,9 @@ export class Executor {
// Check if this block needs the starter block's output
// This is especially relevant for API, function, and conditions that might reference <start.input>
const starterBlock = this.actualWorkflow.blocks.find((b) => b.metadata?.id === 'starter')
const starterBlock = this.actualWorkflow.blocks.find(
(b) => b.metadata?.id === BlockType.STARTER
)
if (starterBlock) {
const starterState = context.blockStates.get(starterBlock.id)
if (!starterState) {
@@ -1345,7 +1348,7 @@ export class Executor {
// Skip console logging for infrastructure blocks like loops and parallels
// For streaming blocks, we'll add the console entry after stream processing
if (block.metadata?.id !== 'loop' && block.metadata?.id !== 'parallel') {
if (block.metadata?.id !== BlockType.LOOP && block.metadata?.id !== BlockType.PARALLEL) {
addConsole({
input: blockLog.input,
output: blockLog.output,
@@ -1415,7 +1418,7 @@ export class Executor {
context.blockLogs.push(blockLog)
// Skip console logging for infrastructure blocks like loops and parallels
if (block.metadata?.id !== 'loop' && block.metadata?.id !== 'parallel') {
if (block.metadata?.id !== BlockType.LOOP && block.metadata?.id !== BlockType.PARALLEL) {
addConsole({
input: blockLog.input,
output: blockLog.output,
@@ -1483,7 +1486,7 @@ export class Executor {
context.blockLogs.push(blockLog)
// Skip console logging for infrastructure blocks like loops and parallels
if (block.metadata?.id !== 'loop' && block.metadata?.id !== 'parallel') {
if (block.metadata?.id !== BlockType.LOOP && block.metadata?.id !== BlockType.PARALLEL) {
addConsole({
input: blockLog.input,
output: {},
@@ -1575,10 +1578,10 @@ export class Executor {
// 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' ||
block?.metadata?.id === 'loop' ||
block?.metadata?.id === 'parallel'
block?.metadata?.id === BlockType.STARTER ||
block?.metadata?.id === BlockType.CONDITION ||
block?.metadata?.id === BlockType.LOOP ||
block?.metadata?.id === BlockType.PARALLEL
) {
return false
}

View File

@@ -1,8 +1,9 @@
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { createMockContext } from '@/executor/__test-utils__/executor-mocks'
import { BlockType } from '@/executor/consts'
import { LoopManager } from '@/executor/loops/loops'
import type { ExecutionContext } from '@/executor/types'
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: () => ({
@@ -40,8 +41,8 @@ describe('LoopManager', () => {
{
id: 'starter',
position: { x: 0, y: 0 },
metadata: { id: 'starter', name: 'Start' },
config: { tool: 'starter', params: {} },
metadata: { id: BlockType.STARTER, name: 'Start' },
config: { tool: BlockType.STARTER, params: {} },
inputs: {},
outputs: {},
enabled: true,
@@ -49,8 +50,8 @@ describe('LoopManager', () => {
{
id: 'loop-1',
position: { x: 100, y: 0 },
metadata: { id: 'loop', name: 'Test Loop' },
config: { tool: 'loop', params: {} },
metadata: { id: BlockType.LOOP, name: 'Test Loop' },
config: { tool: BlockType.LOOP, params: {} },
inputs: {},
outputs: {},
enabled: true,
@@ -58,8 +59,8 @@ describe('LoopManager', () => {
{
id: 'block-1',
position: { x: 200, y: 0 },
metadata: { id: 'function', name: 'Block 1' },
config: { tool: 'function', params: {} },
metadata: { id: BlockType.FUNCTION, name: 'Block 1' },
config: { tool: BlockType.FUNCTION, params: {} },
inputs: {},
outputs: {},
enabled: true,
@@ -67,8 +68,8 @@ describe('LoopManager', () => {
{
id: 'block-2',
position: { x: 300, y: 0 },
metadata: { id: 'function', name: 'Block 2' },
config: { tool: 'function', params: {} },
metadata: { id: BlockType.FUNCTION, name: 'Block 2' },
config: { tool: BlockType.FUNCTION, params: {} },
inputs: {},
outputs: {},
enabled: true,
@@ -76,8 +77,8 @@ describe('LoopManager', () => {
{
id: 'after-loop',
position: { x: 400, y: 0 },
metadata: { id: 'function', name: 'After Loop' },
config: { tool: 'function', params: {} },
metadata: { id: BlockType.FUNCTION, name: 'After Loop' },
config: { tool: BlockType.FUNCTION, params: {} },
inputs: {},
outputs: {},
enabled: true,
@@ -409,7 +410,7 @@ describe('LoopManager', () => {
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.blocks[2].metadata!.id = BlockType.ROUTER // Make block-1 a router
workflow.connections = [
{ source: 'starter', target: 'loop-1' },
{ source: 'loop-1', target: 'block-1', sourceHandle: 'loop-start-source' },
@@ -435,7 +436,7 @@ describe('LoopManager', () => {
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.blocks[2].metadata!.id = BlockType.CONDITION // Make block-1 a condition
workflow.connections = [
{ source: 'starter', target: 'loop-1' },
{ source: 'loop-1', target: 'block-1', sourceHandle: 'loop-start-source' },
@@ -496,8 +497,8 @@ describe('LoopManager', () => {
workflow.blocks.push({
id: 'error-handler',
position: { x: 350, y: 100 },
metadata: { id: 'function', name: 'Error Handler' },
config: { tool: 'function', params: {} },
metadata: { id: BlockType.FUNCTION, name: 'Error Handler' },
config: { tool: BlockType.FUNCTION, params: {} },
inputs: {},
outputs: {},
enabled: true,

View File

@@ -1,6 +1,7 @@
import { createLogger } from '@/lib/logs/console-logger'
import { BlockType } from '@/executor/consts'
import type { ExecutionContext } from '@/executor/types'
import type { SerializedBlock, SerializedConnection, SerializedLoop } from '@/serializer/types'
import type { ExecutionContext } from './types'
const logger = createLogger('LoopManager')
@@ -366,13 +367,13 @@ export class LoopManager {
const outgoing = blockOutgoingConnections.get(currentBlockId) || []
// Handle routing blocks specially
if (block.metadata?.id === 'router') {
if (block.metadata?.id === BlockType.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') {
} else if (block.metadata?.id === BlockType.CONDITION) {
// For condition blocks, only follow the selected condition path
const selectedConditionId = context.decisions.condition.get(currentBlockId)
if (selectedConditionId) {

View File

@@ -1,8 +1,9 @@
import { describe, expect, test, vi } from 'vitest'
import { createParallelExecutionState } from '@/executor/__test-utils__/executor-mocks'
import { BlockType } from '@/executor/consts'
import { ParallelManager } from '@/executor/parallels/parallels'
import type { ExecutionContext } from '@/executor/types'
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: () => ({
@@ -154,7 +155,7 @@ describe('ParallelManager', () => {
const block = {
id: 'func-1',
position: { x: 0, y: 0 },
config: { tool: 'function', params: {} },
config: { tool: BlockType.FUNCTION, params: {} },
inputs: {},
outputs: {},
enabled: true,
@@ -185,7 +186,7 @@ describe('ParallelManager', () => {
const block = {
id: 'func-1',
position: { x: 0, y: 0 },
config: { tool: 'function', params: {} },
config: { tool: BlockType.FUNCTION, params: {} },
inputs: {},
outputs: {},
enabled: true,

View File

@@ -1,6 +1,6 @@
import { createLogger } from '@/lib/logs/console-logger'
import type { ExecutionContext, NormalizedBlockOutput } from '@/executor/types'
import type { SerializedBlock, SerializedParallel, SerializedWorkflow } from '@/serializer/types'
import type { ExecutionContext, NormalizedBlockOutput } from './types'
const logger = createLogger('ParallelManager')

View File

@@ -1,7 +1,9 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { BlockType } from '@/executor/consts'
import { PathTracker } from '@/executor/path/path'
import { Routing } from '@/executor/routing/routing'
import type { BlockState, ExecutionContext } from '@/executor/types'
import type { SerializedWorkflow } from '@/serializer/types'
import { PathTracker } from './path'
import type { BlockState, ExecutionContext } from './types'
describe('PathTracker', () => {
let pathTracker: PathTracker
@@ -32,27 +34,27 @@ describe('PathTracker', () => {
},
{
id: 'router1',
metadata: { id: 'router' },
metadata: { id: BlockType.ROUTER },
position: { x: 0, y: 0 },
config: { tool: 'router', params: {} },
config: { tool: BlockType.ROUTER, params: {} },
inputs: {},
outputs: {},
enabled: true,
},
{
id: 'condition1',
metadata: { id: 'condition' },
metadata: { id: BlockType.CONDITION },
position: { x: 0, y: 0 },
config: { tool: 'condition', params: {} },
config: { tool: BlockType.CONDITION, params: {} },
inputs: {},
outputs: {},
enabled: true,
},
{
id: 'loop1',
metadata: { id: 'loop' },
metadata: { id: BlockType.LOOP },
position: { x: 0, y: 0 },
config: { tool: 'loop', params: {} },
config: { tool: BlockType.LOOP, params: {} },
inputs: {},
outputs: {},
enabled: true,
@@ -75,6 +77,7 @@ describe('PathTracker', () => {
loopType: 'for',
},
},
parallels: {},
}
mockContext = {
@@ -417,36 +420,36 @@ describe('PathTracker', () => {
blocks: [
{
id: 'router1',
metadata: { id: 'router', name: 'Router' },
metadata: { id: BlockType.ROUTER, name: 'Router' },
position: { x: 0, y: 0 },
config: { tool: 'router', params: {} },
config: { tool: BlockType.ROUTER, params: {} },
inputs: {},
outputs: {},
enabled: true,
},
{
id: 'api1',
metadata: { id: 'api', name: 'API 1' },
metadata: { id: BlockType.API, name: 'API 1' },
position: { x: 0, y: 0 },
config: { tool: 'api', params: {} },
config: { tool: BlockType.API, params: {} },
inputs: {},
outputs: {},
enabled: true,
},
{
id: 'api2',
metadata: { id: 'api', name: 'API 2' },
metadata: { id: BlockType.API, name: 'API 2' },
position: { x: 0, y: 0 },
config: { tool: 'api', params: {} },
config: { tool: BlockType.API, params: {} },
inputs: {},
outputs: {},
enabled: true,
},
{
id: 'agent1',
metadata: { id: 'agent', name: 'Agent' },
metadata: { id: BlockType.AGENT, name: 'Agent' },
position: { x: 0, y: 0 },
config: { tool: 'agent', params: {} },
config: { tool: BlockType.AGENT, params: {} },
inputs: {},
outputs: {},
enabled: true,
@@ -485,7 +488,7 @@ describe('PathTracker', () => {
output: {
selectedPath: {
blockId: 'api1',
blockType: 'api',
blockType: BlockType.API,
blockTitle: 'API 1',
},
},
@@ -508,9 +511,9 @@ describe('PathTracker', () => {
// Add another level to test deep activation
mockWorkflow.blocks.push({
id: 'finalStep',
metadata: { id: 'api', name: 'Final Step' },
metadata: { id: BlockType.API, name: 'Final Step' },
position: { x: 0, y: 0 },
config: { tool: 'api', params: {} },
config: { tool: BlockType.API, params: {} },
inputs: {},
outputs: {},
enabled: true,
@@ -524,7 +527,7 @@ describe('PathTracker', () => {
output: {
selectedPath: {
blockId: 'api1',
blockType: 'api',
blockType: BlockType.API,
blockTitle: 'API 1',
},
},
@@ -552,7 +555,7 @@ describe('PathTracker', () => {
output: {
selectedPath: {
blockId: 'api1',
blockType: 'api',
blockType: BlockType.API,
blockTitle: 'API 1',
},
},
@@ -586,7 +589,7 @@ describe('PathTracker', () => {
output: {
selectedPath: {
blockId: 'api1',
blockType: 'api',
blockType: BlockType.API,
blockTitle: 'API 1',
},
},
@@ -602,4 +605,158 @@ describe('PathTracker', () => {
expect(mockContext.activeExecutionPath.has('agent1')).toBe(false)
})
})
describe('RoutingStrategy integration', () => {
beforeEach(() => {
// Add more block types to test the new routing strategy
mockWorkflow.blocks.push(
{
id: 'parallel1',
metadata: { id: BlockType.PARALLEL },
position: { x: 0, y: 0 },
config: { tool: BlockType.PARALLEL, params: {} },
inputs: {},
outputs: {},
enabled: true,
},
{
id: 'function1',
metadata: { id: BlockType.FUNCTION },
position: { x: 0, y: 0 },
config: { tool: BlockType.FUNCTION, params: {} },
inputs: {},
outputs: {},
enabled: true,
},
{
id: 'agent1',
metadata: { id: BlockType.AGENT },
position: { x: 0, y: 0 },
config: { tool: BlockType.AGENT, params: {} },
inputs: {},
outputs: {},
enabled: true,
}
)
mockWorkflow.connections.push(
{ source: 'parallel1', target: 'function1', sourceHandle: 'parallel-start-source' },
{ source: 'parallel1', target: 'agent1', sourceHandle: 'parallel-end-source' }
)
mockWorkflow.parallels = {
parallel1: {
id: 'parallel1',
nodes: ['function1'],
distribution: ['item1', 'item2'],
},
}
pathTracker = new PathTracker(mockWorkflow)
})
it('should correctly categorize different block types', () => {
// Test that our refactored code properly uses RoutingStrategy
expect(Routing.getCategory(BlockType.ROUTER)).toBe('routing')
expect(Routing.getCategory(BlockType.CONDITION)).toBe('routing')
expect(Routing.getCategory(BlockType.PARALLEL)).toBe('flow-control')
expect(Routing.getCategory(BlockType.LOOP)).toBe('flow-control')
expect(Routing.getCategory(BlockType.FUNCTION)).toBe('regular')
expect(Routing.getCategory(BlockType.AGENT)).toBe('regular')
})
it('should handle flow control blocks correctly in path checking', () => {
// Test that parallel blocks are handled correctly
mockContext.executedBlocks.add('parallel1')
mockContext.activeExecutionPath.add('parallel1')
// Function1 should be reachable from parallel1 via parallel-start-source
expect(pathTracker.isInActivePath('function1', mockContext)).toBe(true)
// Agent1 should be reachable from parallel1 via parallel-end-source
expect(pathTracker.isInActivePath('agent1', mockContext)).toBe(true)
})
it('should handle router selecting routing blocks correctly', () => {
// Test the refactored logic where router selects another routing block
const blockState: BlockState = {
output: { selectedPath: { blockId: 'condition1' } },
executed: true,
executionTime: 100,
}
mockContext.blockStates.set('router1', blockState)
pathTracker.updateExecutionPaths(['router1'], mockContext)
// Condition1 should be activated but not its downstream paths
// (since routing blocks make their own decisions)
expect(mockContext.activeExecutionPath.has('condition1')).toBe(true)
expect(mockContext.decisions.router.get('router1')).toBe('condition1')
})
it('should handle router selecting flow control blocks correctly', () => {
// Test the refactored logic where router selects a flow control block
const blockState: BlockState = {
output: { selectedPath: { blockId: 'parallel1' } },
executed: true,
executionTime: 100,
}
mockContext.blockStates.set('router1', blockState)
pathTracker.updateExecutionPaths(['router1'], mockContext)
// Parallel1 should be activated but not its downstream paths
// (since flow control blocks don't activate downstream automatically)
expect(mockContext.activeExecutionPath.has('parallel1')).toBe(true)
expect(mockContext.decisions.router.get('router1')).toBe('parallel1')
// Children should NOT be activated automatically
expect(mockContext.activeExecutionPath.has('function1')).toBe(false)
expect(mockContext.activeExecutionPath.has('agent1')).toBe(false)
})
it('should handle router selecting regular blocks correctly', () => {
// Test that regular blocks still activate downstream paths
const blockState: BlockState = {
output: { selectedPath: { blockId: 'function1' } },
executed: true,
executionTime: 100,
}
mockContext.blockStates.set('router1', blockState)
pathTracker.updateExecutionPaths(['router1'], mockContext)
// Function1 should be activated and can activate downstream paths
expect(mockContext.activeExecutionPath.has('function1')).toBe(true)
expect(mockContext.decisions.router.get('router1')).toBe('function1')
})
it('should use category-based logic for updatePathForBlock', () => {
// Test that the refactored switch statement works correctly
// Test routing block (condition)
const conditionState: BlockState = {
output: { selectedConditionId: 'if' },
executed: true,
executionTime: 100,
}
mockContext.blockStates.set('condition1', conditionState)
pathTracker.updateExecutionPaths(['condition1'], mockContext)
expect(mockContext.decisions.condition.get('condition1')).toBe('if')
// Test flow control block (loop)
pathTracker.updateExecutionPaths(['loop1'], mockContext)
expect(mockContext.activeExecutionPath.has('block1')).toBe(true) // loop-start-source
// Test regular block
const functionState: BlockState = {
output: { result: 'success' },
executed: true,
executionTime: 100,
}
mockContext.blockStates.set('function1', functionState)
mockContext.executedBlocks.add('function1')
pathTracker.updateExecutionPaths(['function1'], mockContext)
// Should activate downstream connections (handled by regular block logic)
})
})
})

View File

@@ -1,6 +1,8 @@
import { createLogger } from '@/lib/logs/console-logger'
import { BlockType } from '@/executor/consts'
import { Routing } from '@/executor/routing/routing'
import type { BlockState, ExecutionContext } from '@/executor/types'
import type { SerializedBlock, SerializedConnection, SerializedWorkflow } from '@/serializer/types'
import type { BlockState, ExecutionContext } from './types'
const logger = createLogger('PathTracker')
@@ -79,14 +81,15 @@ export class PathTracker {
const sourceBlock = this.getBlock(connection.source)
if (!sourceBlock) return false
const blockType = sourceBlock.metadata?.id
const blockType = sourceBlock.metadata?.id || ''
const category = Routing.getCategory(blockType)
// Use strategy pattern for different block types
switch (blockType) {
case 'router':
return this.isRouterConnectionActive(connection, context)
case 'condition':
return this.isConditionConnectionActive(connection, context)
// Use routing strategy to determine connection checking method
switch (category) {
case 'routing':
return blockType === BlockType.ROUTER
? this.isRouterConnectionActive(connection, context)
: this.isConditionConnectionActive(connection, context)
default:
return this.isRegularConnectionActive(connection, context)
}
@@ -137,17 +140,24 @@ export class PathTracker {
* Update paths for a specific block based on its type
*/
private updatePathForBlock(block: SerializedBlock, context: ExecutionContext): void {
const blockType = block.metadata?.id
const blockType = block.metadata?.id || ''
const category = Routing.getCategory(blockType)
switch (blockType) {
case 'router':
this.updateRouterPaths(block, context)
switch (category) {
case 'routing':
if (blockType === BlockType.ROUTER) {
this.updateRouterPaths(block, context)
} else {
this.updateConditionPaths(block, context)
}
break
case 'condition':
this.updateConditionPaths(block, context)
break
case 'loop':
this.updateLoopPaths(block, context)
case 'flow-control':
if (blockType === BlockType.LOOP) {
this.updateLoopPaths(block, context)
} else {
// For parallel blocks, they're handled by their own handler
this.updateRegularBlockPaths(block, context)
}
break
default:
this.updateRegularBlockPaths(block, context)
@@ -166,23 +176,43 @@ export class PathTracker {
context.decisions.router.set(block.id, selectedPath)
context.activeExecutionPath.add(selectedPath)
this.activateDownstreamPaths(selectedPath, context)
// Check if the selected target should activate downstream paths
const selectedBlock = this.getBlock(selectedPath)
const selectedBlockType = selectedBlock?.metadata?.id || ''
const selectedCategory = Routing.getCategory(selectedBlockType)
// Only activate downstream paths for regular blocks
// Routing blocks make their own routing decisions when they execute
// Flow control blocks manage their own path activation
if (selectedCategory === 'regular') {
this.activateDownstreamPathsSelectively(selectedPath, context)
}
logger.info(`Router ${block.id} selected path: ${selectedPath}`)
}
}
/**
* Recursively activate downstream paths from a block
* Selectively activate downstream paths, respecting block routing behavior
* This prevents flow control blocks from being activated when they should be controlled by routing
*/
private activateDownstreamPaths(blockId: string, context: ExecutionContext): void {
private activateDownstreamPathsSelectively(blockId: string, context: ExecutionContext): void {
const outgoingConnections = this.getOutgoingConnections(blockId)
for (const conn of outgoingConnections) {
if (!context.activeExecutionPath.has(conn.target)) {
context.activeExecutionPath.add(conn.target)
const targetBlock = this.getBlock(conn.target)
const targetBlockType = targetBlock?.metadata?.id
this.activateDownstreamPaths(conn.target, context)
// Use routing strategy to determine if this connection should be activated
if (!Routing.shouldSkipConnection(conn.sourceHandle, targetBlockType || '')) {
context.activeExecutionPath.add(conn.target)
// Recursively activate downstream paths if the target block should activate downstream
if (Routing.shouldActivateDownstream(targetBlockType || '')) {
this.activateDownstreamPathsSelectively(conn.target, context)
}
}
}
}
}
@@ -238,6 +268,14 @@ export class PathTracker {
for (const conn of outgoingConnections) {
if (this.shouldActivateConnection(conn, hasError, isPartOfLoop, blockLoops, context)) {
const targetBlock = this.getBlock(conn.target)
const targetBlockType = targetBlock?.metadata?.id
// Use routing strategy to determine if this connection should be activated
if (Routing.shouldSkipConnection(conn.sourceHandle, targetBlockType || '')) {
continue
}
context.activeExecutionPath.add(conn.target)
}
}

View File

@@ -1,7 +1,8 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { BlockType } from '@/executor/consts'
import { InputResolver } from '@/executor/resolver/resolver'
import type { ExecutionContext } from '@/executor/types'
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
import { InputResolver } from './resolver'
import type { ExecutionContext } from './types'
// Mock logger
vi.mock('@/lib/logs/console-logger', () => ({
@@ -27,36 +28,36 @@ describe('InputResolver', () => {
blocks: [
{
id: 'starter-block',
metadata: { id: 'starter', name: 'Start' },
metadata: { id: BlockType.STARTER, name: 'Start' },
position: { x: 100, y: 100 },
config: { tool: 'starter', params: {} },
config: { tool: BlockType.STARTER, params: {} },
inputs: {},
outputs: {},
enabled: true,
},
{
id: 'function-block',
metadata: { id: 'function', name: 'Function' },
metadata: { id: BlockType.FUNCTION, name: 'Function' },
position: { x: 300, y: 100 },
config: { tool: 'function', params: {} },
config: { tool: BlockType.FUNCTION, params: {} },
inputs: {},
outputs: {},
enabled: true,
},
{
id: 'condition-block',
metadata: { id: 'condition', name: 'Condition' },
metadata: { id: BlockType.CONDITION, name: 'Condition' },
position: { x: 500, y: 100 },
config: { tool: 'condition', params: {} },
config: { tool: BlockType.CONDITION, params: {} },
inputs: {},
outputs: {},
enabled: true,
},
{
id: 'api-block',
metadata: { id: 'api', name: 'API' },
metadata: { id: BlockType.API, name: 'API' },
position: { x: 700, y: 100 },
config: { tool: 'api', params: {} },
config: { tool: BlockType.API, params: {} },
inputs: {},
outputs: {},
enabled: true,
@@ -429,7 +430,7 @@ describe('InputResolver', () => {
it('should resolve environment variables in API key contexts', () => {
const block: SerializedBlock = {
id: 'test-block',
metadata: { id: 'api', name: 'Test API Block' }, // API block type
metadata: { id: BlockType.API, name: 'Test API Block' }, // API block type
position: { x: 0, y: 0 },
config: {
tool: 'api',
@@ -626,10 +627,10 @@ describe('InputResolver', () => {
it('should handle code input for function blocks', () => {
const block: SerializedBlock = {
id: 'code-block',
metadata: { id: 'function', name: 'Code Block' },
metadata: { id: BlockType.FUNCTION, name: 'Code Block' },
position: { x: 0, y: 0 },
config: {
tool: 'function',
tool: BlockType.FUNCTION,
params: {
code: 'const name = "<variable.stringVar>";\nconst num = <variable.numberVar>;\nreturn { name, num };',
},
@@ -652,7 +653,7 @@ describe('InputResolver', () => {
it('should handle body input for API blocks', () => {
const block: SerializedBlock = {
id: 'api-block',
metadata: { id: 'api', name: 'API Block' },
metadata: { id: BlockType.API, name: 'API Block' },
position: { x: 0, y: 0 },
config: {
tool: 'api',
@@ -679,7 +680,7 @@ describe('InputResolver', () => {
it('should handle conditions parameter for condition blocks', () => {
const block: SerializedBlock = {
id: 'condition-block',
metadata: { id: 'condition', name: 'Condition Block' },
metadata: { id: BlockType.CONDITION, name: 'Condition Block' },
position: { x: 0, y: 0 },
config: {
tool: 'condition',
@@ -734,10 +735,10 @@ describe('InputResolver', () => {
const loopBlock: SerializedBlock = {
id: 'loop-1',
position: { x: 0, y: 0 },
config: { tool: 'loop', params: {} },
config: { tool: BlockType.LOOP, params: {} },
inputs: {},
outputs: {},
metadata: { id: 'loop', name: 'Test Loop' },
metadata: { id: BlockType.LOOP, name: 'Test Loop' },
enabled: true,
}
@@ -745,14 +746,14 @@ describe('InputResolver', () => {
id: 'function-1',
position: { x: 0, y: 0 },
config: {
tool: 'function',
tool: BlockType.FUNCTION,
params: {
item: '<loop.currentItem>', // Direct reference, not wrapped in quotes
},
},
inputs: {},
outputs: {},
metadata: { id: 'function', name: 'Process Item' },
metadata: { id: BlockType.FUNCTION, name: 'Process Item' },
enabled: true,
}
@@ -796,10 +797,10 @@ describe('InputResolver', () => {
const loopBlock: SerializedBlock = {
id: 'loop-1',
position: { x: 0, y: 0 },
config: { tool: 'loop', params: {} },
config: { tool: BlockType.LOOP, params: {} },
inputs: {},
outputs: {},
metadata: { id: 'loop', name: 'Test Loop' },
metadata: { id: BlockType.LOOP, name: 'Test Loop' },
enabled: true,
}
@@ -807,14 +808,14 @@ describe('InputResolver', () => {
id: 'function-1',
position: { x: 0, y: 0 },
config: {
tool: 'function',
tool: BlockType.FUNCTION,
params: {
index: '<loop.index>', // Direct reference, not wrapped in quotes
},
},
inputs: {},
outputs: {},
metadata: { id: 'function', name: 'Process Index' },
metadata: { id: BlockType.FUNCTION, name: 'Process Index' },
enabled: true,
}
@@ -857,10 +858,10 @@ describe('InputResolver', () => {
const loopBlock: SerializedBlock = {
id: 'loop-1',
position: { x: 0, y: 0 },
config: { tool: 'loop', params: {} },
config: { tool: BlockType.LOOP, params: {} },
inputs: {},
outputs: {},
metadata: { id: 'loop', name: 'Test Loop' },
metadata: { id: BlockType.LOOP, name: 'Test Loop' },
enabled: true,
}
@@ -868,14 +869,14 @@ describe('InputResolver', () => {
id: 'function-1',
position: { x: 0, y: 0 },
config: {
tool: 'function',
tool: BlockType.FUNCTION,
params: {
allItems: '<loop.items>', // Direct reference to all items
},
},
inputs: {},
outputs: {},
metadata: { id: 'function', name: 'Process All Items' },
metadata: { id: BlockType.FUNCTION, name: 'Process All Items' },
enabled: true,
}
@@ -924,10 +925,10 @@ describe('InputResolver', () => {
const loopBlock: SerializedBlock = {
id: 'loop-1',
position: { x: 0, y: 0 },
config: { tool: 'loop', params: {} },
config: { tool: BlockType.LOOP, params: {} },
inputs: {},
outputs: {},
metadata: { id: 'loop', name: 'Test Loop' },
metadata: { id: BlockType.LOOP, name: 'Test Loop' },
enabled: true,
}
@@ -935,14 +936,14 @@ describe('InputResolver', () => {
id: 'function-1',
position: { x: 0, y: 0 },
config: {
tool: 'function',
tool: BlockType.FUNCTION,
params: {
allItems: '<loop.items>', // Direct reference to all items
},
},
inputs: {},
outputs: {},
metadata: { id: 'function', name: 'Process All Items' },
metadata: { id: BlockType.FUNCTION, name: 'Process All Items' },
enabled: true,
}
@@ -997,19 +998,19 @@ describe('InputResolver', () => {
{
id: 'parallel-1',
position: { x: 0, y: 0 },
config: { tool: 'parallel', params: {} },
config: { tool: BlockType.PARALLEL, params: {} },
inputs: {},
outputs: {},
metadata: { id: 'parallel', name: 'Parallel 1' },
metadata: { id: BlockType.PARALLEL, name: 'Parallel 1' },
enabled: true,
},
{
id: 'function-1',
position: { x: 0, y: 0 },
config: { tool: 'function', params: { code: '<parallel.currentItem>' } },
config: { tool: BlockType.FUNCTION, params: { code: '<parallel.currentItem>' } },
inputs: {},
outputs: {},
metadata: { id: 'function', name: 'Function 1' },
metadata: { id: BlockType.FUNCTION, name: 'Function 1' },
enabled: true,
},
],
@@ -1053,28 +1054,28 @@ describe('InputResolver', () => {
{
id: 'parallel-1',
position: { x: 0, y: 0 },
config: { tool: 'parallel', params: {} },
config: { tool: BlockType.PARALLEL, params: {} },
inputs: {},
outputs: {},
metadata: { id: 'parallel', name: 'Parallel 1' },
metadata: { id: BlockType.PARALLEL, name: 'Parallel 1' },
enabled: true,
},
{
id: 'parallel-2',
position: { x: 0, y: 0 },
config: { tool: 'parallel', params: {} },
config: { tool: BlockType.PARALLEL, params: {} },
inputs: {},
outputs: {},
metadata: { id: 'parallel', name: 'Parallel 2' },
metadata: { id: BlockType.PARALLEL, name: 'Parallel 2' },
enabled: true,
},
{
id: 'function-1',
position: { x: 0, y: 0 },
config: { tool: 'function', params: { code: '<Parallel1.results>' } },
config: { tool: BlockType.FUNCTION, params: { code: '<Parallel1.results>' } },
inputs: {},
outputs: {},
metadata: { id: 'function', name: 'Function 1' },
metadata: { id: BlockType.FUNCTION, name: 'Function 1' },
enabled: true,
},
],
@@ -1156,19 +1157,19 @@ describe('InputResolver', () => {
{
id: 'parallel-1',
position: { x: 0, y: 0 },
config: { tool: 'parallel', params: {} },
config: { tool: BlockType.PARALLEL, params: {} },
inputs: {},
outputs: {},
metadata: { id: 'parallel', name: 'Parallel 1' },
metadata: { id: BlockType.PARALLEL, name: 'Parallel 1' },
enabled: true,
},
{
id: 'function-1',
position: { x: 0, y: 0 },
config: { tool: 'function', params: { code: '<parallel-1.results>' } },
config: { tool: BlockType.FUNCTION, params: { code: '<parallel-1.results>' } },
inputs: {},
outputs: {},
metadata: { id: 'function', name: 'Function 1' },
metadata: { id: BlockType.FUNCTION, name: 'Function 1' },
enabled: true,
},
],
@@ -1244,36 +1245,36 @@ describe('InputResolver', () => {
blocks: [
{
id: 'starter-1',
metadata: { id: 'starter', name: 'Start' },
metadata: { id: BlockType.STARTER, name: 'Start' },
position: { x: 0, y: 0 },
config: { tool: 'starter', params: {} },
config: { tool: BlockType.STARTER, params: {} },
inputs: {},
outputs: {},
enabled: true,
},
{
id: 'agent-1',
metadata: { id: 'agent', name: 'Agent Block' },
metadata: { id: BlockType.AGENT, name: 'Agent Block' },
position: { x: 100, y: 100 },
config: { tool: 'agent', params: {} },
config: { tool: BlockType.AGENT, params: {} },
inputs: {},
outputs: {},
enabled: true,
},
{
id: 'function-1',
metadata: { id: 'function', name: 'Function Block' },
metadata: { id: BlockType.FUNCTION, name: 'Function Block' },
position: { x: 200, y: 200 },
config: { tool: 'function', params: {} },
config: { tool: BlockType.FUNCTION, params: {} },
inputs: {},
outputs: {},
enabled: true,
},
{
id: 'isolated-block',
metadata: { id: 'agent', name: 'Isolated Block' },
metadata: { id: BlockType.AGENT, name: 'Isolated Block' },
position: { x: 300, y: 300 },
config: { tool: 'agent', params: {} },
config: { tool: BlockType.AGENT, params: {} },
inputs: {},
outputs: {},
enabled: true,
@@ -1301,7 +1302,7 @@ describe('InputResolver', () => {
})
// Always allow starter block access
const starterBlock = workflowWithConnections.blocks.find(
(b) => b.metadata?.id === 'starter'
(b) => b.metadata?.id === BlockType.STARTER
)
if (starterBlock) {
accessibleBlocks.add(starterBlock.id)
@@ -1323,7 +1324,7 @@ describe('InputResolver', () => {
})
// Always allow starter block access
const starterBlock = workflowWithConnections.blocks.find(
(b) => b.metadata?.id === 'starter'
(b) => b.metadata?.id === BlockType.STARTER
)
if (starterBlock) {
accessibleBlocks.add(starterBlock.id)
@@ -1370,7 +1371,7 @@ describe('InputResolver', () => {
const testBlock: SerializedBlock = {
...functionBlock,
config: {
tool: 'function',
tool: BlockType.FUNCTION,
params: {
code: 'return <agent-1.content>', // function-1 can reference agent-1 (connected)
},
@@ -1385,9 +1386,9 @@ describe('InputResolver', () => {
// Create a new block that is added to the workflow but not connected to isolated-block
workflowWithConnections.blocks.push({
id: 'test-block',
metadata: { id: 'function', name: 'Test Block' },
metadata: { id: BlockType.FUNCTION, name: 'Test Block' },
position: { x: 500, y: 500 },
config: { tool: 'function', params: {} },
config: { tool: BlockType.FUNCTION, params: {} },
inputs: {},
outputs: {},
enabled: true,
@@ -1404,7 +1405,9 @@ describe('InputResolver', () => {
}
})
// Always allow starter block access
const starterBlock = workflowWithConnections.blocks.find((b) => b.metadata?.id === 'starter')
const starterBlock = workflowWithConnections.blocks.find(
(b) => b.metadata?.id === BlockType.STARTER
)
if (starterBlock) {
testBlockAccessible.add(starterBlock.id)
}
@@ -1412,10 +1415,10 @@ describe('InputResolver', () => {
const testBlock: SerializedBlock = {
id: 'test-block',
metadata: { id: 'function', name: 'Test Block' },
metadata: { id: BlockType.FUNCTION, name: 'Test Block' },
position: { x: 500, y: 500 },
config: {
tool: 'function',
tool: BlockType.FUNCTION,
params: {
code: 'return <isolated-block.content>', // test-block cannot reference isolated-block (not connected)
},
@@ -1435,7 +1438,7 @@ describe('InputResolver', () => {
const testBlock: SerializedBlock = {
...functionBlock,
config: {
tool: 'function',
tool: BlockType.FUNCTION,
params: {
code: 'return <start.input>', // Any block can reference start
},
@@ -1450,9 +1453,9 @@ describe('InputResolver', () => {
// Create a test block in the workflow first
workflowWithConnections.blocks.push({
id: 'test-block-2',
metadata: { id: 'function', name: 'Test Block 2' },
metadata: { id: BlockType.FUNCTION, name: 'Test Block 2' },
position: { x: 600, y: 600 },
config: { tool: 'function', params: {} },
config: { tool: BlockType.FUNCTION, params: {} },
inputs: {},
outputs: {},
enabled: true,
@@ -1469,7 +1472,9 @@ describe('InputResolver', () => {
}
})
// Always allow starter block access
const starterBlock = workflowWithConnections.blocks.find((b) => b.metadata?.id === 'starter')
const starterBlock = workflowWithConnections.blocks.find(
(b) => b.metadata?.id === BlockType.STARTER
)
if (starterBlock) {
testBlock2Accessible.add(starterBlock.id)
}
@@ -1477,10 +1482,10 @@ describe('InputResolver', () => {
const testBlock: SerializedBlock = {
id: 'test-block-2',
metadata: { id: 'function', name: 'Test Block 2' },
metadata: { id: BlockType.FUNCTION, name: 'Test Block 2' },
position: { x: 600, y: 600 },
config: {
tool: 'function',
tool: BlockType.FUNCTION,
params: {
code: 'return <nonexistent.value>',
},
@@ -1500,7 +1505,7 @@ describe('InputResolver', () => {
const testBlock: SerializedBlock = {
...functionBlock,
config: {
tool: 'function',
tool: BlockType.FUNCTION,
params: {
nameRef: '<Agent Block.content>', // Reference by actual name
normalizedRef: '<agentblock.content>', // Reference by normalized name
@@ -1523,9 +1528,9 @@ describe('InputResolver', () => {
...workflowWithConnections.blocks,
{
id: 'response-1',
metadata: { id: 'response', name: 'Response Block' },
metadata: { id: BlockType.RESPONSE, name: 'Response Block' },
position: { x: 400, y: 400 },
config: { tool: 'response', params: {} },
config: { tool: BlockType.RESPONSE, params: {} },
inputs: {},
outputs: {},
enabled: true,
@@ -1555,7 +1560,9 @@ describe('InputResolver', () => {
}
})
// Always allow starter block access
const starterBlock = extendedWorkflow.blocks.find((b) => b.metadata?.id === 'starter')
const starterBlock = extendedWorkflow.blocks.find(
(b) => b.metadata?.id === BlockType.STARTER
)
if (starterBlock) {
accessibleBlocks.add(starterBlock.id)
}
@@ -1572,7 +1579,9 @@ describe('InputResolver', () => {
}
})
// Always allow starter block access
const starterBlock = extendedWorkflow.blocks.find((b) => b.metadata?.id === 'starter')
const starterBlock = extendedWorkflow.blocks.find(
(b) => b.metadata?.id === BlockType.STARTER
)
if (starterBlock) {
accessibleBlocks.add(starterBlock.id)
}
@@ -1590,7 +1599,7 @@ describe('InputResolver', () => {
const testBlock: SerializedBlock = {
...responseBlock,
config: {
tool: 'response',
tool: BlockType.RESPONSE,
params: {
canReferenceFunction: '<function-1.result>', // Can reference directly connected function-1
cannotReferenceAgent: '<agent-1.content>', // Cannot reference agent-1 (not directly connected)
@@ -1614,7 +1623,7 @@ describe('InputResolver', () => {
expect(() => {
const block1 = {
...testBlock,
config: { tool: 'response', params: { test: '<function-1.result>' } },
config: { tool: BlockType.RESPONSE, params: { test: '<function-1.result>' } },
}
extendedResolver.resolveInputs(block1, extendedContext)
}).not.toThrow()
@@ -1624,9 +1633,9 @@ describe('InputResolver', () => {
// Add the response block to the workflow so it can be validated properly
extendedWorkflow.blocks.push({
id: 'test-response-block',
metadata: { id: 'response', name: 'Test Response Block' },
metadata: { id: BlockType.RESPONSE, name: 'Test Response Block' },
position: { x: 500, y: 500 },
config: { tool: 'response', params: {} },
config: { tool: BlockType.RESPONSE, params: {} },
inputs: {},
outputs: {},
enabled: true,
@@ -1635,9 +1644,9 @@ describe('InputResolver', () => {
const block2 = {
id: 'test-response-block',
metadata: { id: 'response', name: 'Test Response Block' },
metadata: { id: BlockType.RESPONSE, name: 'Test Response Block' },
position: { x: 500, y: 500 },
config: { tool: 'response', params: { test: '<agent-1.content>' } },
config: { tool: BlockType.RESPONSE, params: { test: '<agent-1.content>' } },
inputs: {},
outputs: {},
enabled: true,
@@ -1652,16 +1661,16 @@ describe('InputResolver', () => {
blocks: [
{
id: 'starter-1',
metadata: { id: 'starter', name: 'Start' },
metadata: { id: BlockType.STARTER, name: 'Start' },
position: { x: 0, y: 0 },
config: { tool: 'starter', params: {} },
config: { tool: BlockType.STARTER, params: {} },
inputs: {},
outputs: {},
enabled: true,
},
{
id: 'loop-1',
metadata: { id: 'loop', name: 'Loop' },
metadata: { id: BlockType.LOOP, name: 'Loop' },
position: { x: 100, y: 100 },
config: { tool: '', params: {} },
inputs: {},
@@ -1670,18 +1679,18 @@ describe('InputResolver', () => {
},
{
id: 'function-1',
metadata: { id: 'function', name: 'Function 1' },
metadata: { id: BlockType.FUNCTION, name: 'Function 1' },
position: { x: 200, y: 200 },
config: { tool: 'function', params: {} },
config: { tool: BlockType.FUNCTION, params: {} },
inputs: {},
outputs: {},
enabled: true,
},
{
id: 'function-2',
metadata: { id: 'function', name: 'Function 2' },
metadata: { id: BlockType.FUNCTION, name: 'Function 2' },
position: { x: 300, y: 300 },
config: { tool: 'function', params: {} },
config: { tool: BlockType.FUNCTION, params: {} },
inputs: {},
outputs: {},
enabled: true,
@@ -1711,7 +1720,7 @@ describe('InputResolver', () => {
}
})
// Always allow starter block access
const starterBlock = loopWorkflow.blocks.find((b) => b.metadata?.id === 'starter')
const starterBlock = loopWorkflow.blocks.find((b) => b.metadata?.id === BlockType.STARTER)
if (starterBlock) {
accessibleBlocks.add(starterBlock.id)
}
@@ -1735,7 +1744,7 @@ describe('InputResolver', () => {
}
})
// Always allow starter block access
const starterBlock = loopWorkflow.blocks.find((b) => b.metadata?.id === 'starter')
const starterBlock = loopWorkflow.blocks.find((b) => b.metadata?.id === BlockType.STARTER)
if (starterBlock) {
accessibleBlocks.add(starterBlock.id)
}
@@ -1746,7 +1755,7 @@ describe('InputResolver', () => {
const testBlock: SerializedBlock = {
...loopWorkflow.blocks[2],
config: {
tool: 'function',
tool: BlockType.FUNCTION,
params: {
code: 'return <function-2.result>', // function-1 can reference function-2 (same loop)
},

View File

@@ -1,9 +1,9 @@
import { BlockPathCalculator } from '@/lib/block-path-calculator'
import { createLogger } from '@/lib/logs/console-logger'
import { VariableManager } from '@/lib/variables/variable-manager'
import type { LoopManager } from '@/executor/loops/loops'
import type { ExecutionContext } from '@/executor/types'
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
import type { LoopManager } from './loops'
import type { ExecutionContext } from './types'
const logger = createLogger('InputResolver')

View File

@@ -0,0 +1,145 @@
import { describe, expect, it } from 'vitest'
import { BlockType } from '@/executor/consts'
import { BlockCategory, Routing } from '@/executor/routing/routing'
describe('Routing', () => {
describe('getCategory', () => {
it.concurrent('should categorize flow control blocks correctly', () => {
expect(Routing.getCategory(BlockType.PARALLEL)).toBe(BlockCategory.FLOW_CONTROL)
expect(Routing.getCategory(BlockType.LOOP)).toBe(BlockCategory.FLOW_CONTROL)
})
it.concurrent('should categorize routing blocks correctly', () => {
expect(Routing.getCategory(BlockType.ROUTER)).toBe(BlockCategory.ROUTING_BLOCK)
expect(Routing.getCategory(BlockType.CONDITION)).toBe(BlockCategory.ROUTING_BLOCK)
})
it.concurrent('should categorize regular blocks correctly', () => {
expect(Routing.getCategory(BlockType.FUNCTION)).toBe(BlockCategory.REGULAR_BLOCK)
expect(Routing.getCategory(BlockType.AGENT)).toBe(BlockCategory.REGULAR_BLOCK)
expect(Routing.getCategory(BlockType.API)).toBe(BlockCategory.REGULAR_BLOCK)
expect(Routing.getCategory(BlockType.STARTER)).toBe(BlockCategory.REGULAR_BLOCK)
})
it.concurrent('should default to regular block for unknown types', () => {
expect(Routing.getCategory('unknown')).toBe(BlockCategory.REGULAR_BLOCK)
expect(Routing.getCategory('')).toBe(BlockCategory.REGULAR_BLOCK)
})
})
describe('shouldActivateDownstream', () => {
it.concurrent('should return true for routing blocks', () => {
expect(Routing.shouldActivateDownstream(BlockType.ROUTER)).toBe(true)
expect(Routing.shouldActivateDownstream(BlockType.CONDITION)).toBe(true)
})
it.concurrent('should return false for flow control blocks', () => {
expect(Routing.shouldActivateDownstream(BlockType.PARALLEL)).toBe(false)
expect(Routing.shouldActivateDownstream(BlockType.LOOP)).toBe(false)
})
it.concurrent('should return true for regular blocks', () => {
expect(Routing.shouldActivateDownstream(BlockType.FUNCTION)).toBe(true)
expect(Routing.shouldActivateDownstream(BlockType.AGENT)).toBe(true)
})
it.concurrent('should handle empty/undefined block types', () => {
expect(Routing.shouldActivateDownstream('')).toBe(true)
expect(Routing.shouldActivateDownstream(undefined as any)).toBe(true)
})
})
describe('requiresActivePathCheck', () => {
it.concurrent('should return true for flow control blocks', () => {
expect(Routing.requiresActivePathCheck(BlockType.PARALLEL)).toBe(true)
expect(Routing.requiresActivePathCheck(BlockType.LOOP)).toBe(true)
})
it.concurrent('should return false for routing blocks', () => {
expect(Routing.requiresActivePathCheck(BlockType.ROUTER)).toBe(false)
expect(Routing.requiresActivePathCheck(BlockType.CONDITION)).toBe(false)
})
it.concurrent('should return false for regular blocks', () => {
expect(Routing.requiresActivePathCheck(BlockType.FUNCTION)).toBe(false)
expect(Routing.requiresActivePathCheck(BlockType.AGENT)).toBe(false)
})
it.concurrent('should handle empty/undefined block types', () => {
expect(Routing.requiresActivePathCheck('')).toBe(false)
expect(Routing.requiresActivePathCheck(undefined as any)).toBe(false)
})
})
describe('shouldSkipInSelectiveActivation', () => {
it.concurrent('should return true for flow control blocks', () => {
expect(Routing.shouldSkipInSelectiveActivation(BlockType.PARALLEL)).toBe(true)
expect(Routing.shouldSkipInSelectiveActivation(BlockType.LOOP)).toBe(true)
})
it.concurrent('should return false for routing blocks', () => {
expect(Routing.shouldSkipInSelectiveActivation(BlockType.ROUTER)).toBe(false)
expect(Routing.shouldSkipInSelectiveActivation(BlockType.CONDITION)).toBe(false)
})
it.concurrent('should return false for regular blocks', () => {
expect(Routing.shouldSkipInSelectiveActivation(BlockType.FUNCTION)).toBe(false)
expect(Routing.shouldSkipInSelectiveActivation(BlockType.AGENT)).toBe(false)
})
})
describe('shouldSkipConnection', () => {
it.concurrent('should skip flow control blocks', () => {
expect(Routing.shouldSkipConnection(undefined, BlockType.PARALLEL)).toBe(true)
expect(Routing.shouldSkipConnection('source', BlockType.LOOP)).toBe(true)
})
it.concurrent('should skip flow control specific connections', () => {
expect(Routing.shouldSkipConnection('parallel-start-source', BlockType.FUNCTION)).toBe(true)
expect(Routing.shouldSkipConnection('parallel-end-source', BlockType.AGENT)).toBe(true)
expect(Routing.shouldSkipConnection('loop-start-source', BlockType.API)).toBe(true)
expect(Routing.shouldSkipConnection('loop-end-source', BlockType.EVALUATOR)).toBe(true)
})
it.concurrent('should not skip regular connections to regular blocks', () => {
expect(Routing.shouldSkipConnection('source', BlockType.FUNCTION)).toBe(false)
expect(Routing.shouldSkipConnection('source', BlockType.AGENT)).toBe(false)
expect(Routing.shouldSkipConnection(undefined, BlockType.API)).toBe(false)
})
it.concurrent('should not skip routing connections', () => {
expect(Routing.shouldSkipConnection('condition-test-if', BlockType.FUNCTION)).toBe(false)
expect(Routing.shouldSkipConnection('condition-test-else', BlockType.AGENT)).toBe(false)
})
it.concurrent('should handle empty/undefined types', () => {
expect(Routing.shouldSkipConnection('', '')).toBe(false)
expect(Routing.shouldSkipConnection(undefined, '')).toBe(false)
})
})
describe('getBehavior', () => {
it.concurrent('should return correct behavior for each category', () => {
const flowControlBehavior = Routing.getBehavior(BlockType.PARALLEL)
expect(flowControlBehavior).toEqual({
shouldActivateDownstream: false,
requiresActivePathCheck: true,
skipInSelectiveActivation: true,
})
const routingBehavior = Routing.getBehavior(BlockType.ROUTER)
expect(routingBehavior).toEqual({
shouldActivateDownstream: true,
requiresActivePathCheck: false,
skipInSelectiveActivation: false,
})
const regularBehavior = Routing.getBehavior(BlockType.FUNCTION)
expect(regularBehavior).toEqual({
shouldActivateDownstream: true,
requiresActivePathCheck: false,
skipInSelectiveActivation: false,
})
})
})
})

View File

@@ -0,0 +1,97 @@
import { BlockType } from '@/executor/consts'
export enum BlockCategory {
ROUTING_BLOCK = 'routing', // router, condition - make routing decisions
FLOW_CONTROL = 'flow-control', // parallel, loop - control execution flow
REGULAR_BLOCK = 'regular', // function, agent, etc. - regular execution
}
export interface RoutingBehavior {
shouldActivateDownstream: boolean
requiresActivePathCheck: boolean
skipInSelectiveActivation: boolean
}
/**
* Centralized routing strategy that defines how different block types
* should behave in the execution path system.
*/
export class Routing {
private static readonly BEHAVIOR_MAP: Record<BlockCategory, RoutingBehavior> = {
[BlockCategory.ROUTING_BLOCK]: {
shouldActivateDownstream: true,
requiresActivePathCheck: false,
skipInSelectiveActivation: false,
},
[BlockCategory.FLOW_CONTROL]: {
shouldActivateDownstream: false,
requiresActivePathCheck: true,
skipInSelectiveActivation: true,
},
[BlockCategory.REGULAR_BLOCK]: {
shouldActivateDownstream: true,
requiresActivePathCheck: false,
skipInSelectiveActivation: false,
},
}
private static readonly BLOCK_TYPE_TO_CATEGORY: Record<string, BlockCategory> = {
// Flow control blocks
[BlockType.PARALLEL]: BlockCategory.FLOW_CONTROL,
[BlockType.LOOP]: BlockCategory.FLOW_CONTROL,
// Routing blocks
[BlockType.ROUTER]: BlockCategory.ROUTING_BLOCK,
[BlockType.CONDITION]: BlockCategory.ROUTING_BLOCK,
// Regular blocks (default category)
[BlockType.FUNCTION]: BlockCategory.REGULAR_BLOCK,
[BlockType.AGENT]: BlockCategory.REGULAR_BLOCK,
[BlockType.API]: BlockCategory.REGULAR_BLOCK,
[BlockType.EVALUATOR]: BlockCategory.REGULAR_BLOCK,
[BlockType.RESPONSE]: BlockCategory.REGULAR_BLOCK,
[BlockType.WORKFLOW]: BlockCategory.REGULAR_BLOCK,
[BlockType.STARTER]: BlockCategory.REGULAR_BLOCK,
}
static getCategory(blockType: string): BlockCategory {
return Routing.BLOCK_TYPE_TO_CATEGORY[blockType] || BlockCategory.REGULAR_BLOCK
}
static getBehavior(blockType: string): RoutingBehavior {
const category = Routing.getCategory(blockType)
return Routing.BEHAVIOR_MAP[category]
}
static shouldActivateDownstream(blockType: string): boolean {
return Routing.getBehavior(blockType).shouldActivateDownstream
}
static requiresActivePathCheck(blockType: string): boolean {
return Routing.getBehavior(blockType).requiresActivePathCheck
}
static shouldSkipInSelectiveActivation(blockType: string): boolean {
return Routing.getBehavior(blockType).skipInSelectiveActivation
}
/**
* Checks if a connection should be skipped during selective activation
*/
static shouldSkipConnection(sourceHandle: string | undefined, targetBlockType: string): boolean {
// Skip flow control blocks
if (Routing.shouldSkipInSelectiveActivation(targetBlockType)) {
return true
}
// Skip flow control specific connections
const flowControlHandles = [
'parallel-start-source',
'parallel-end-source',
'loop-start-source',
'loop-end-source',
]
return flowControlHandles.includes(sourceHandle || '')
}
}

View File

@@ -0,0 +1,227 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { Executor } from '@/executor'
import { BlockType } from '@/executor/consts'
import type { SerializedWorkflow } from '@/serializer/types'
describe('Full Executor Test', () => {
let workflow: SerializedWorkflow
let executor: Executor
beforeEach(() => {
workflow = {
version: '2.0',
blocks: [
{
id: 'bd9f4f7d-8aed-4860-a3be-8bebd1931b19',
position: { x: 0, y: 0 },
metadata: { id: BlockType.STARTER, name: 'Start' },
config: { tool: BlockType.STARTER, params: {} },
inputs: {},
outputs: {},
enabled: true,
},
{
id: 'f29a40b7-125a-45a7-a670-af14a1498f94',
position: { x: 100, y: 0 },
metadata: { id: BlockType.ROUTER, name: 'Router 1' },
config: {
tool: BlockType.ROUTER,
params: {
prompt: 'if x then function 1\nif y then parallel\n\ninput: x',
model: 'gpt-4o',
},
},
inputs: {},
outputs: {},
enabled: true,
},
{
id: 'd09b0a90-2c59-4a2c-af15-c30321e36d9b',
position: { x: 200, y: -50 },
metadata: { id: BlockType.FUNCTION, name: 'Function 1' },
config: { tool: BlockType.FUNCTION, params: { code: "return 'one'" } },
inputs: {},
outputs: {},
enabled: true,
},
{
id: 'a62902db-fd8d-4851-aa88-acd5e7667497',
position: { x: 200, y: 50 },
metadata: { id: BlockType.PARALLEL, name: 'Parallel 1' },
config: { tool: BlockType.PARALLEL, params: {} },
inputs: {},
outputs: {},
enabled: true,
},
{
id: '0494cf56-2520-4e29-98ad-313ea55cf142',
position: { x: 300, y: -50 },
metadata: { id: 'condition', name: 'Condition 1' },
config: { tool: 'condition', params: {} },
inputs: {},
outputs: {},
enabled: true,
},
{
id: '033ea142-3002-4a68-9e12-092b10b8c9c8',
position: { x: 400, y: -100 },
metadata: { id: BlockType.FUNCTION, name: 'Function 2' },
config: { tool: BlockType.FUNCTION, params: { code: "return 'two'" } },
inputs: {},
outputs: {},
enabled: true,
},
{
id: '037140a8-fda3-44e2-896c-6adea53ea30f',
position: { x: 400, y: 0 },
metadata: { id: BlockType.PARALLEL, name: 'Parallel 2' },
config: { tool: BlockType.PARALLEL, params: {} },
inputs: {},
outputs: {},
enabled: true,
},
{
id: 'a91e3a02-b884-4823-8197-30ae498ac94c',
position: { x: 300, y: 100 },
metadata: { id: 'agent', name: 'Agent 1' },
config: { tool: 'agent', params: {} },
inputs: {},
outputs: {},
enabled: true,
},
{
id: '97974a42-cdf4-4810-9caa-b5e339f42ab0',
position: { x: 500, y: 0 },
metadata: { id: 'agent', name: 'Agent 2' },
config: { tool: 'agent', params: {} },
inputs: {},
outputs: {},
enabled: true,
},
],
connections: [
{
source: 'bd9f4f7d-8aed-4860-a3be-8bebd1931b19',
target: 'f29a40b7-125a-45a7-a670-af14a1498f94',
},
{
source: 'f29a40b7-125a-45a7-a670-af14a1498f94',
target: 'd09b0a90-2c59-4a2c-af15-c30321e36d9b',
},
{
source: 'f29a40b7-125a-45a7-a670-af14a1498f94',
target: 'a62902db-fd8d-4851-aa88-acd5e7667497',
},
{
source: 'd09b0a90-2c59-4a2c-af15-c30321e36d9b',
target: '0494cf56-2520-4e29-98ad-313ea55cf142',
},
{
source: '0494cf56-2520-4e29-98ad-313ea55cf142',
target: '033ea142-3002-4a68-9e12-092b10b8c9c8',
sourceHandle: 'condition-0494cf56-2520-4e29-98ad-313ea55cf142-if',
},
{
source: '0494cf56-2520-4e29-98ad-313ea55cf142',
target: '037140a8-fda3-44e2-896c-6adea53ea30f',
sourceHandle: 'condition-0494cf56-2520-4e29-98ad-313ea55cf142-else',
},
{
source: 'a62902db-fd8d-4851-aa88-acd5e7667497',
target: 'a91e3a02-b884-4823-8197-30ae498ac94c',
sourceHandle: 'parallel-start-source',
},
{
source: '037140a8-fda3-44e2-896c-6adea53ea30f',
target: '97974a42-cdf4-4810-9caa-b5e339f42ab0',
sourceHandle: 'parallel-start-source',
},
],
loops: {},
parallels: {
'a62902db-fd8d-4851-aa88-acd5e7667497': {
id: 'a62902db-fd8d-4851-aa88-acd5e7667497',
nodes: ['a91e3a02-b884-4823-8197-30ae498ac94c'],
distribution: ['item1', 'item2'],
},
'037140a8-fda3-44e2-896c-6adea53ea30f': {
id: '037140a8-fda3-44e2-896c-6adea53ea30f',
nodes: ['97974a42-cdf4-4810-9caa-b5e339f42ab0'],
distribution: ['item1', 'item2'],
},
},
}
executor = new Executor(workflow)
})
it('should test the full executor flow and see what happens', async () => {
// Mock the necessary functions to avoid actual API calls
const mockInput = {}
try {
// Execute the workflow
const result = await executor.execute('test-workflow-id')
// Check if it's an ExecutionResult (not StreamingExecution)
if ('success' in result) {
// Check if there are any logs that might indicate what happened
if (result.logs) {
}
// The test itself doesn't need to assert anything specific
// We just want to see what the executor does
expect(result.success).toBeDefined()
} else {
expect(result).toBeDefined()
}
} catch (error) {
console.error('Execution error:', error)
// Log the error but don't fail the test - we want to see what happens
}
})
it('should test the executor getNextExecutionLayer method directly', async () => {
// Create a mock context in the exact state after the condition executes
const context = (executor as any).createExecutionContext('test-workflow', new Date())
// Set up the state as it would be after the condition executes
context.executedBlocks.add('bd9f4f7d-8aed-4860-a3be-8bebd1931b19') // Start
context.executedBlocks.add('f29a40b7-125a-45a7-a670-af14a1498f94') // Router 1
context.executedBlocks.add('d09b0a90-2c59-4a2c-af15-c30321e36d9b') // Function 1
context.executedBlocks.add('0494cf56-2520-4e29-98ad-313ea55cf142') // Condition 1
context.executedBlocks.add('033ea142-3002-4a68-9e12-092b10b8c9c8') // Function 2
// Set router decision
context.decisions.router.set(
'f29a40b7-125a-45a7-a670-af14a1498f94',
'd09b0a90-2c59-4a2c-af15-c30321e36d9b'
)
// Set condition decision to if path (Function 2)
context.decisions.condition.set(
'0494cf56-2520-4e29-98ad-313ea55cf142',
'0494cf56-2520-4e29-98ad-313ea55cf142-if'
)
// Set up active execution path as it should be after condition
context.activeExecutionPath.add('bd9f4f7d-8aed-4860-a3be-8bebd1931b19')
context.activeExecutionPath.add('f29a40b7-125a-45a7-a670-af14a1498f94')
context.activeExecutionPath.add('d09b0a90-2c59-4a2c-af15-c30321e36d9b')
context.activeExecutionPath.add('0494cf56-2520-4e29-98ad-313ea55cf142')
context.activeExecutionPath.add('033ea142-3002-4a68-9e12-092b10b8c9c8')
// Get the next execution layer
const nextLayer = (executor as any).getNextExecutionLayer(context)
// Check if Parallel 2 is in the next execution layer
const hasParallel2 = nextLayer.includes('037140a8-fda3-44e2-896c-6adea53ea30f')
// Check if Agent 2 is in the next execution layer
const hasAgent2 = nextLayer.includes('97974a42-cdf4-4810-9caa-b5e339f42ab0')
// The key test: Parallel 2 should NOT be in the next execution layer
expect(nextLayer).not.toContain('037140a8-fda3-44e2-896c-6adea53ea30f')
expect(nextLayer).not.toContain('97974a42-cdf4-4810-9caa-b5e339f42ab0')
})
})

View File

@@ -0,0 +1,307 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { BlockType } from '@/executor/consts'
import { PathTracker } from '@/executor/path/path'
import type { ExecutionContext } from '@/executor/types'
import type { SerializedWorkflow } from '@/serializer/types'
describe('Nested Routing Fix - Router → Condition → Target', () => {
let workflow: SerializedWorkflow
let pathTracker: PathTracker
let mockContext: ExecutionContext
beforeEach(() => {
// Create a workflow similar to the screenshot: Router → Condition → Function/Parallel
workflow = {
version: '2.0',
blocks: [
{
id: 'starter',
position: { x: 0, y: 0 },
metadata: { id: BlockType.STARTER, name: 'Start' },
config: { tool: BlockType.STARTER, params: {} },
inputs: {},
outputs: {},
enabled: true,
},
{
id: 'router-1',
position: { x: 100, y: 0 },
metadata: { id: BlockType.ROUTER, name: 'Router 1' },
config: { tool: BlockType.ROUTER, params: {} },
inputs: {},
outputs: {},
enabled: true,
},
{
id: 'function-2',
position: { x: 200, y: -100 },
metadata: { id: BlockType.FUNCTION, name: 'Function 2' },
config: { tool: BlockType.FUNCTION, params: {} },
inputs: {},
outputs: {},
enabled: true,
},
{
id: 'condition-1',
position: { x: 200, y: 100 },
metadata: { id: BlockType.CONDITION, name: 'Condition 1' },
config: { tool: BlockType.CONDITION, params: {} },
inputs: {},
outputs: {},
enabled: true,
},
{
id: 'function-4',
position: { x: 350, y: 50 },
metadata: { id: BlockType.FUNCTION, name: 'Function 4' },
config: { tool: BlockType.FUNCTION, params: {} },
inputs: {},
outputs: {},
enabled: true,
},
{
id: 'parallel-block',
position: { x: 350, y: 150 },
metadata: { id: BlockType.PARALLEL, name: 'Parallel Block' },
config: { tool: BlockType.PARALLEL, params: {} },
inputs: {},
outputs: {},
enabled: true,
},
{
id: 'agent-inside-parallel',
position: { x: 450, y: 150 },
metadata: { id: BlockType.AGENT, name: 'Agent Inside Parallel' },
config: { tool: BlockType.AGENT, params: {} },
inputs: {},
outputs: {},
enabled: true,
},
],
connections: [
{ source: 'starter', target: 'router-1' },
{ source: 'router-1', target: 'function-2' },
{ source: 'router-1', target: 'condition-1' },
{
source: 'condition-1',
target: 'function-4',
sourceHandle: 'condition-b8f0a33c-a57f-4a36-ac7a-dc9f2b5e6c07-if',
},
{
source: 'condition-1',
target: 'parallel-block',
sourceHandle: 'condition-b8f0a33c-a57f-4a36-ac7a-dc9f2b5e6c07-else',
},
{
source: 'parallel-block',
target: 'agent-inside-parallel',
sourceHandle: 'parallel-start-source',
},
],
loops: {},
parallels: {
'parallel-block': {
id: 'parallel-block',
nodes: ['agent-inside-parallel'],
distribution: ['item1', 'item2'],
},
},
}
pathTracker = new PathTracker(workflow)
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,
}
// Initialize starter as executed and in active path
mockContext.executedBlocks.add('starter')
mockContext.activeExecutionPath.add('starter')
mockContext.activeExecutionPath.add('router-1')
})
it('should handle nested routing: router selects condition, condition selects function', () => {
// Step 1: Router selects the condition path (not function-2)
mockContext.blockStates.set('router-1', {
output: {
selectedPath: {
blockId: 'condition-1',
blockType: BlockType.CONDITION,
blockTitle: 'Condition 1',
},
},
executed: true,
executionTime: 0,
})
mockContext.executedBlocks.add('router-1')
// Update paths after router execution
pathTracker.updateExecutionPaths(['router-1'], mockContext)
// Verify router decision
expect(mockContext.decisions.router.get('router-1')).toBe('condition-1')
// After router execution, condition should be active but not function-2
expect(mockContext.activeExecutionPath.has('condition-1')).toBe(true)
expect(mockContext.activeExecutionPath.has('function-2')).toBe(false)
// CRITICAL: Parallel block should NOT be activated yet
expect(mockContext.activeExecutionPath.has('parallel-block')).toBe(false)
expect(mockContext.activeExecutionPath.has('agent-inside-parallel')).toBe(false)
// Step 2: Condition executes and selects function-4 (not parallel)
mockContext.blockStates.set('condition-1', {
output: {
result: 'two',
stdout: '',
conditionResult: true,
selectedPath: {
blockId: 'function-4',
blockType: BlockType.FUNCTION,
blockTitle: 'Function 4',
},
selectedConditionId: 'b8f0a33c-a57f-4a36-ac7a-dc9f2b5e6c07-if',
},
executed: true,
executionTime: 0,
})
mockContext.executedBlocks.add('condition-1')
// Update paths after condition execution
pathTracker.updateExecutionPaths(['condition-1'], mockContext)
// Verify condition decision
expect(mockContext.decisions.condition.get('condition-1')).toBe(
'b8f0a33c-a57f-4a36-ac7a-dc9f2b5e6c07-if'
)
// After condition execution, function-4 should be active
expect(mockContext.activeExecutionPath.has('function-4')).toBe(true)
// CRITICAL: Parallel block should still NOT be activated
expect(mockContext.activeExecutionPath.has('parallel-block')).toBe(false)
expect(mockContext.activeExecutionPath.has('agent-inside-parallel')).toBe(false)
})
it('should handle nested routing: router selects condition, condition selects parallel', () => {
// Step 1: Router selects the condition path
mockContext.blockStates.set('router-1', {
output: {
selectedPath: {
blockId: 'condition-1',
blockType: BlockType.CONDITION,
blockTitle: 'Condition 1',
},
},
executed: true,
executionTime: 0,
})
mockContext.executedBlocks.add('router-1')
pathTracker.updateExecutionPaths(['router-1'], mockContext)
// Step 2: Condition executes and selects parallel-block (not function-4)
mockContext.blockStates.set('condition-1', {
output: {
result: 'else',
stdout: '',
conditionResult: false,
selectedPath: {
blockId: 'parallel-block',
blockType: BlockType.PARALLEL,
blockTitle: 'Parallel Block',
},
selectedConditionId: 'b8f0a33c-a57f-4a36-ac7a-dc9f2b5e6c07-else',
},
executed: true,
executionTime: 0,
})
mockContext.executedBlocks.add('condition-1')
pathTracker.updateExecutionPaths(['condition-1'], mockContext)
// Verify condition decision
expect(mockContext.decisions.condition.get('condition-1')).toBe(
'b8f0a33c-a57f-4a36-ac7a-dc9f2b5e6c07-else'
)
// After condition execution, parallel-block should be active
expect(mockContext.activeExecutionPath.has('parallel-block')).toBe(true)
// Function-4 should NOT be activated
expect(mockContext.activeExecutionPath.has('function-4')).toBe(false)
// The agent inside parallel should NOT be automatically activated
// It should only be activated when the parallel block executes
expect(mockContext.activeExecutionPath.has('agent-inside-parallel')).toBe(false)
})
it('should prevent parallel blocks from executing when not selected by nested routing', () => {
// This test simulates the exact scenario from the bug report
// Step 1: Router selects condition path
mockContext.blockStates.set('router-1', {
output: {
selectedPath: {
blockId: 'condition-1',
blockType: BlockType.CONDITION,
blockTitle: 'Condition 1',
},
},
executed: true,
executionTime: 0,
})
mockContext.executedBlocks.add('router-1')
pathTracker.updateExecutionPaths(['router-1'], mockContext)
// Step 2: Condition selects function-4 (NOT parallel)
mockContext.blockStates.set('condition-1', {
output: {
result: 'two',
stdout: '',
conditionResult: true,
selectedPath: {
blockId: 'function-4',
blockType: BlockType.FUNCTION,
blockTitle: 'Function 4',
},
selectedConditionId: 'b8f0a33c-a57f-4a36-ac7a-dc9f2b5e6c07-if',
},
executed: true,
executionTime: 0,
})
mockContext.executedBlocks.add('condition-1')
pathTracker.updateExecutionPaths(['condition-1'], mockContext)
// Step 3: Simulate what the executor's getNextExecutionLayer would do
const blocksToExecute = workflow.blocks.filter(
(block) =>
mockContext.activeExecutionPath.has(block.id) && !mockContext.executedBlocks.has(block.id)
)
const blockIds = blocksToExecute.map((b) => b.id)
// Should only include function-4, NOT parallel-block
expect(blockIds).toContain('function-4')
expect(blockIds).not.toContain('parallel-block')
expect(blockIds).not.toContain('agent-inside-parallel')
// Verify that parallel block is not in active path
expect(mockContext.activeExecutionPath.has('parallel-block')).toBe(false)
// Verify that isInActivePath also returns false for parallel block
const isParallelActive = pathTracker.isInActivePath('parallel-block', mockContext)
expect(isParallelActive).toBe(false)
})
})

View File

@@ -0,0 +1,206 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { BlockType } from '@/executor/consts'
import { ParallelBlockHandler } from '@/executor/handlers/parallel/parallel-handler'
import { PathTracker } from '@/executor/path/path'
import type { ExecutionContext } from '@/executor/types'
import type { SerializedWorkflow } from '@/serializer/types'
describe('Parallel Handler Integration with PathTracker', () => {
let workflow: SerializedWorkflow
let pathTracker: PathTracker
let parallelHandler: ParallelBlockHandler
let mockContext: ExecutionContext
beforeEach(() => {
// Create a simplified workflow with condition → parallel scenario
workflow = {
version: '2.0',
blocks: [
{
id: 'condition-1',
position: { x: 0, y: 0 },
metadata: { id: BlockType.CONDITION, name: 'Condition 1' },
config: { tool: BlockType.CONDITION, params: {} },
inputs: {},
outputs: {},
enabled: true,
},
{
id: 'function-2',
position: { x: 100, y: -50 },
metadata: { id: BlockType.FUNCTION, name: 'Function 2' },
config: { tool: BlockType.FUNCTION, params: {} },
inputs: {},
outputs: {},
enabled: true,
},
{
id: 'parallel-2',
position: { x: 100, y: 50 },
metadata: { id: BlockType.PARALLEL, name: 'Parallel 2' },
config: { tool: BlockType.PARALLEL, params: {} },
inputs: {},
outputs: {},
enabled: true,
},
{
id: 'agent-2',
position: { x: 200, y: 50 },
metadata: { id: BlockType.AGENT, name: 'Agent 2' },
config: { tool: BlockType.AGENT, params: {} },
inputs: {},
outputs: {},
enabled: true,
},
],
connections: [
// Condition → Function 2 (if path)
{
source: 'condition-1',
target: 'function-2',
sourceHandle: 'condition-test-if',
},
// Condition → Parallel 2 (else path)
{
source: 'condition-1',
target: 'parallel-2',
sourceHandle: 'condition-test-else',
},
// Parallel 2 → Agent 2
{
source: 'parallel-2',
target: 'agent-2',
sourceHandle: 'parallel-start-source',
},
],
loops: {},
parallels: {
'parallel-2': {
id: 'parallel-2',
nodes: ['agent-2'],
distribution: ['item1', 'item2'],
},
},
}
pathTracker = new PathTracker(workflow)
parallelHandler = new ParallelBlockHandler(undefined, pathTracker)
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,
}
})
it('should not allow parallel block to execute when not in active path', async () => {
// Set up scenario where condition selected function-2 (if path), not parallel-2 (else path)
mockContext.decisions.condition.set('condition-1', 'test-if')
mockContext.executedBlocks.add('condition-1')
mockContext.activeExecutionPath.add('condition-1')
mockContext.activeExecutionPath.add('function-2') // Only function-2 should be active
// Parallel-2 should NOT be in active path
expect(mockContext.activeExecutionPath.has('parallel-2')).toBe(false)
// Test PathTracker's isInActivePath method
const isParallel2Active = pathTracker.isInActivePath('parallel-2', mockContext)
expect(isParallel2Active).toBe(false)
// Get the parallel block
const parallelBlock = workflow.blocks.find((b) => b.id === 'parallel-2')!
// Try to execute the parallel block
const result = await parallelHandler.execute(parallelBlock, {}, mockContext)
// The parallel block should execute (return started: true) but should NOT activate its children
expect(result).toMatchObject({
parallelId: 'parallel-2',
started: true,
})
// CRITICAL: Agent 2 should NOT be activated because parallel-2 is not in active path
expect(mockContext.activeExecutionPath.has('agent-2')).toBe(false)
})
it('should allow parallel block to execute and activate children when in active path', async () => {
// Set up scenario where condition selected parallel-2 (else path)
mockContext.decisions.condition.set('condition-1', 'test-else')
mockContext.executedBlocks.add('condition-1')
mockContext.activeExecutionPath.add('condition-1')
mockContext.activeExecutionPath.add('parallel-2') // Parallel-2 should be active
// Parallel-2 should be in active path
expect(mockContext.activeExecutionPath.has('parallel-2')).toBe(true)
// Test PathTracker's isInActivePath method
const isParallel2Active = pathTracker.isInActivePath('parallel-2', mockContext)
expect(isParallel2Active).toBe(true)
// Get the parallel block
const parallelBlock = workflow.blocks.find((b) => b.id === 'parallel-2')!
// Try to execute the parallel block
const result = await parallelHandler.execute(parallelBlock, {}, mockContext)
// The parallel block should execute and activate its children
expect(result).toMatchObject({
parallelId: 'parallel-2',
started: true,
})
// Agent 2 should be activated because parallel-2 is in active path
expect(mockContext.activeExecutionPath.has('agent-2')).toBe(true)
})
it('should test the routing failure scenario with parallel block', async () => {
// Step 1: Condition 1 selects Function 2 (if path)
mockContext.blockStates.set('condition-1', {
output: {
result: 'one',
stdout: '',
conditionResult: true,
selectedPath: {
blockId: 'function-2',
blockType: 'function',
blockTitle: 'Function 2',
},
selectedConditionId: 'test-if',
},
executed: true,
executionTime: 0,
})
mockContext.executedBlocks.add('condition-1')
mockContext.activeExecutionPath.add('condition-1')
// Update paths after condition execution
pathTracker.updateExecutionPaths(['condition-1'], mockContext)
// Verify condition selected if path
expect(mockContext.decisions.condition.get('condition-1')).toBe('test-if')
expect(mockContext.activeExecutionPath.has('function-2')).toBe(true)
expect(mockContext.activeExecutionPath.has('parallel-2')).toBe(false)
// Step 2: Try to execute parallel-2 (should not activate children)
const parallelBlock = workflow.blocks.find((b) => b.id === 'parallel-2')!
const result = await parallelHandler.execute(parallelBlock, {}, mockContext)
// Parallel should execute but not activate children
expect(result).toMatchObject({
parallelId: 'parallel-2',
started: true,
})
// CRITICAL: Agent 2 should NOT be activated
expect(mockContext.activeExecutionPath.has('agent-2')).toBe(false)
})
})

View File

@@ -0,0 +1,318 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { BlockType } from '@/executor/consts'
import { PathTracker } from '@/executor/path/path'
import type { ExecutionContext } from '@/executor/types'
import type { SerializedWorkflow } from '@/serializer/types'
describe('Router and Condition Block Path Selection in Complex Workflows', () => {
let workflow: SerializedWorkflow
let pathTracker: PathTracker
let mockContext: ExecutionContext
beforeEach(() => {
workflow = {
version: '2.0',
blocks: [
{
id: 'bd9f4f7d-8aed-4860-a3be-8bebd1931b19',
position: { x: 0, y: 0 },
metadata: { id: BlockType.STARTER, name: 'Start' },
config: { tool: BlockType.STARTER, params: {} },
inputs: {},
outputs: {},
enabled: true,
},
{
id: 'f29a40b7-125a-45a7-a670-af14a1498f94',
position: { x: 100, y: 0 },
metadata: { id: BlockType.ROUTER, name: 'Router 1' },
config: { tool: BlockType.ROUTER, params: {} },
inputs: {},
outputs: {},
enabled: true,
},
{
id: 'd09b0a90-2c59-4a2c-af15-c30321e36d9b',
position: { x: 200, y: -50 },
metadata: { id: BlockType.FUNCTION, name: 'Function 1' },
config: { tool: BlockType.FUNCTION, params: {} },
inputs: {},
outputs: {},
enabled: true,
},
{
id: 'a62902db-fd8d-4851-aa88-acd5e7667497',
position: { x: 200, y: 50 },
metadata: { id: BlockType.PARALLEL, name: 'Parallel 1' },
config: { tool: BlockType.PARALLEL, params: {} },
inputs: {},
outputs: {},
enabled: true,
},
{
id: '0494cf56-2520-4e29-98ad-313ea55cf142',
position: { x: 300, y: -50 },
metadata: { id: BlockType.CONDITION, name: 'Condition 1' },
config: { tool: BlockType.CONDITION, params: {} },
inputs: {},
outputs: {},
enabled: true,
},
{
id: '033ea142-3002-4a68-9e12-092b10b8c9c8',
position: { x: 400, y: -100 },
metadata: { id: BlockType.FUNCTION, name: 'Function 2' },
config: { tool: BlockType.FUNCTION, params: {} },
inputs: {},
outputs: {},
enabled: true,
},
{
id: '037140a8-fda3-44e2-896c-6adea53ea30f',
position: { x: 400, y: 0 },
metadata: { id: BlockType.PARALLEL, name: 'Parallel 2' },
config: { tool: BlockType.PARALLEL, params: {} },
inputs: {},
outputs: {},
enabled: true,
},
{
id: 'a91e3a02-b884-4823-8197-30ae498ac94c',
position: { x: 300, y: 100 },
metadata: { id: BlockType.AGENT, name: 'Agent 1' },
config: { tool: BlockType.AGENT, params: {} },
inputs: {},
outputs: {},
enabled: true,
},
{
id: '97974a42-cdf4-4810-9caa-b5e339f42ab0',
position: { x: 500, y: 0 },
metadata: { id: BlockType.AGENT, name: 'Agent 2' },
config: { tool: BlockType.AGENT, params: {} },
inputs: {},
outputs: {},
enabled: true,
},
],
connections: [
// Start → Router 1
{
source: 'bd9f4f7d-8aed-4860-a3be-8bebd1931b19',
target: 'f29a40b7-125a-45a7-a670-af14a1498f94',
},
// Router 1 → Function 1
{
source: 'f29a40b7-125a-45a7-a670-af14a1498f94',
target: 'd09b0a90-2c59-4a2c-af15-c30321e36d9b',
},
// Router 1 → Parallel 1
{
source: 'f29a40b7-125a-45a7-a670-af14a1498f94',
target: 'a62902db-fd8d-4851-aa88-acd5e7667497',
},
// Function 1 → Condition 1
{
source: 'd09b0a90-2c59-4a2c-af15-c30321e36d9b',
target: '0494cf56-2520-4e29-98ad-313ea55cf142',
},
// Condition 1 → Function 2 (if path)
{
source: '0494cf56-2520-4e29-98ad-313ea55cf142',
target: '033ea142-3002-4a68-9e12-092b10b8c9c8',
sourceHandle: 'condition-0494cf56-2520-4e29-98ad-313ea55cf142-if',
},
// Condition 1 → Parallel 2 (else path)
{
source: '0494cf56-2520-4e29-98ad-313ea55cf142',
target: '037140a8-fda3-44e2-896c-6adea53ea30f',
sourceHandle: 'condition-0494cf56-2520-4e29-98ad-313ea55cf142-else',
},
// Parallel 1 → Agent 1
{
source: 'a62902db-fd8d-4851-aa88-acd5e7667497',
target: 'a91e3a02-b884-4823-8197-30ae498ac94c',
sourceHandle: 'parallel-start-source',
},
// Parallel 2 → Agent 2
{
source: '037140a8-fda3-44e2-896c-6adea53ea30f',
target: '97974a42-cdf4-4810-9caa-b5e339f42ab0',
sourceHandle: 'parallel-start-source',
},
],
loops: {},
parallels: {
'a62902db-fd8d-4851-aa88-acd5e7667497': {
id: 'a62902db-fd8d-4851-aa88-acd5e7667497',
nodes: ['a91e3a02-b884-4823-8197-30ae498ac94c'],
distribution: ['item1', 'item2'],
},
'037140a8-fda3-44e2-896c-6adea53ea30f': {
id: '037140a8-fda3-44e2-896c-6adea53ea30f',
nodes: ['97974a42-cdf4-4810-9caa-b5e339f42ab0'],
distribution: ['item1', 'item2'],
},
},
}
pathTracker = new PathTracker(workflow)
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,
}
// Initialize execution state
mockContext.executedBlocks.add('bd9f4f7d-8aed-4860-a3be-8bebd1931b19') // Start
mockContext.activeExecutionPath.add('bd9f4f7d-8aed-4860-a3be-8bebd1931b19') // Start
mockContext.activeExecutionPath.add('f29a40b7-125a-45a7-a670-af14a1498f94') // Router 1
})
it('should reproduce the exact router and condition block path selection scenario', () => {
// Step 1: Router 1 executes and selects Function 1 (not Parallel 1)
mockContext.blockStates.set('f29a40b7-125a-45a7-a670-af14a1498f94', {
output: {
selectedPath: {
blockId: 'd09b0a90-2c59-4a2c-af15-c30321e36d9b',
blockType: BlockType.FUNCTION,
blockTitle: 'Function 1',
},
},
executed: true,
executionTime: 0,
})
mockContext.executedBlocks.add('f29a40b7-125a-45a7-a670-af14a1498f94')
pathTracker.updateExecutionPaths(['f29a40b7-125a-45a7-a670-af14a1498f94'], mockContext)
// Verify router selected Function 1
expect(mockContext.decisions.router.get('f29a40b7-125a-45a7-a670-af14a1498f94')).toBe(
'd09b0a90-2c59-4a2c-af15-c30321e36d9b'
)
expect(mockContext.activeExecutionPath.has('d09b0a90-2c59-4a2c-af15-c30321e36d9b')).toBe(true) // Function 1
// Parallel 1 should NOT be in active path (not selected by router)
expect(mockContext.activeExecutionPath.has('a62902db-fd8d-4851-aa88-acd5e7667497')).toBe(false) // Parallel 1
expect(mockContext.activeExecutionPath.has('a91e3a02-b884-4823-8197-30ae498ac94c')).toBe(false) // Agent 1
// Step 2: Function 1 executes and returns "one"
mockContext.blockStates.set('d09b0a90-2c59-4a2c-af15-c30321e36d9b', {
output: {
result: 'one',
stdout: '',
},
executed: true,
executionTime: 0,
})
mockContext.executedBlocks.add('d09b0a90-2c59-4a2c-af15-c30321e36d9b')
pathTracker.updateExecutionPaths(['d09b0a90-2c59-4a2c-af15-c30321e36d9b'], mockContext)
// Function 1 should activate Condition 1
expect(mockContext.activeExecutionPath.has('0494cf56-2520-4e29-98ad-313ea55cf142')).toBe(true) // Condition 1
// Parallel 2 should NOT be in active path yet
expect(mockContext.activeExecutionPath.has('037140a8-fda3-44e2-896c-6adea53ea30f')).toBe(false) // Parallel 2
expect(mockContext.activeExecutionPath.has('97974a42-cdf4-4810-9caa-b5e339f42ab0')).toBe(false) // Agent 2
// Step 3: Condition 1 executes and selects Function 2 (if path, not else/parallel path)
mockContext.blockStates.set('0494cf56-2520-4e29-98ad-313ea55cf142', {
output: {
result: 'one',
stdout: '',
conditionResult: true,
selectedPath: {
blockId: '033ea142-3002-4a68-9e12-092b10b8c9c8',
blockType: BlockType.FUNCTION,
blockTitle: 'Function 2',
},
selectedConditionId: '0494cf56-2520-4e29-98ad-313ea55cf142-if',
},
executed: true,
executionTime: 0,
})
mockContext.executedBlocks.add('0494cf56-2520-4e29-98ad-313ea55cf142')
pathTracker.updateExecutionPaths(['0494cf56-2520-4e29-98ad-313ea55cf142'], mockContext)
// Verify condition selected the if path (Function 2)
expect(mockContext.decisions.condition.get('0494cf56-2520-4e29-98ad-313ea55cf142')).toBe(
'0494cf56-2520-4e29-98ad-313ea55cf142-if'
)
expect(mockContext.activeExecutionPath.has('033ea142-3002-4a68-9e12-092b10b8c9c8')).toBe(true) // Function 2
// CRITICAL: Parallel 2 should NOT be in active path (condition selected if, not else)
expect(mockContext.activeExecutionPath.has('037140a8-fda3-44e2-896c-6adea53ea30f')).toBe(false) // Parallel 2
expect(mockContext.activeExecutionPath.has('97974a42-cdf4-4810-9caa-b5e339f42ab0')).toBe(false) // Agent 2
// Step 4: Function 2 executes (this should be the end of the workflow)
mockContext.blockStates.set('033ea142-3002-4a68-9e12-092b10b8c9c8', {
output: {
result: 'two',
stdout: '',
},
executed: true,
executionTime: 0,
})
mockContext.executedBlocks.add('033ea142-3002-4a68-9e12-092b10b8c9c8')
pathTracker.updateExecutionPaths(['033ea142-3002-4a68-9e12-092b10b8c9c8'], mockContext)
// Final verification: Parallel 2 and Agent 2 should NEVER be in active path
expect(mockContext.activeExecutionPath.has('037140a8-fda3-44e2-896c-6adea53ea30f')).toBe(false) // Parallel 2
expect(mockContext.activeExecutionPath.has('97974a42-cdf4-4810-9caa-b5e339f42ab0')).toBe(false) // Agent 2
// Simulate what executor's getNextExecutionLayer would return
const blocksToExecute = workflow.blocks.filter(
(block) =>
mockContext.activeExecutionPath.has(block.id) && !mockContext.executedBlocks.has(block.id)
)
const blockIds = blocksToExecute.map((b) => b.id)
// Should be empty (no more blocks to execute)
expect(blockIds).toHaveLength(0)
// Should NOT include Parallel 2 or Agent 2
expect(blockIds).not.toContain('037140a8-fda3-44e2-896c-6adea53ea30f') // Parallel 2
expect(blockIds).not.toContain('97974a42-cdf4-4810-9caa-b5e339f42ab0') // Agent 2
})
it('should test the isInActivePath method for Parallel 2', () => {
// Set up the same execution state as above
mockContext.executedBlocks.add('f29a40b7-125a-45a7-a670-af14a1498f94') // Router 1
mockContext.executedBlocks.add('d09b0a90-2c59-4a2c-af15-c30321e36d9b') // Function 1
mockContext.executedBlocks.add('0494cf56-2520-4e29-98ad-313ea55cf142') // Condition 1
// Set router decision
mockContext.decisions.router.set(
'f29a40b7-125a-45a7-a670-af14a1498f94',
'd09b0a90-2c59-4a2c-af15-c30321e36d9b'
)
// Set condition decision to if path (not else path)
mockContext.decisions.condition.set(
'0494cf56-2520-4e29-98ad-313ea55cf142',
'0494cf56-2520-4e29-98ad-313ea55cf142-if'
)
// Test isInActivePath for Parallel 2
const isParallel2Active = pathTracker.isInActivePath(
'037140a8-fda3-44e2-896c-6adea53ea30f',
mockContext
)
// Should be false because condition selected if path, not else path
expect(isParallel2Active).toBe(false)
})
})

View File

@@ -1,5 +1,8 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { StreamingResponseFormatProcessor, streamingResponseFormatProcessor } from './utils'
import {
StreamingResponseFormatProcessor,
streamingResponseFormatProcessor,
} from '@/executor/utils'
vi.mock('@/lib/logs/console-logger', () => ({
createLogger: vi.fn().mockReturnValue({

View File

@@ -1,5 +1,5 @@
import { createLogger } from '@/lib/logs/console-logger'
import type { ResponseFormatStreamProcessor } from './types'
import type { ResponseFormatStreamProcessor } from '@/executor/types'
const logger = createLogger('ExecutorUtils')