Feature/execution (#87)

* feat(executor): split executor into specialized components

* fix(executor): if there is a dependency on a block that is not along the selected path, ignore it; if we are at max iterations for a loop, stop

* feat(exector): cleanup inline comments in executor

* fix(executor): fix issue in removeDownstreamBlocks when we are breaking out of a loop to prevent infinite recursion

* feat(executor/tests): setup initial testing directory

* feat(executor): make the path selection for routing/conditional blocks independent of context, instead of deactivating paths we just activate others
This commit is contained in:
waleedlatif1
2025-02-26 02:09:56 -08:00
committed by GitHub
parent f1a2f399ce
commit 8218a88ce6
21 changed files with 6484 additions and 1250 deletions

508
executor/handlers.ts Normal file
View File

@@ -0,0 +1,508 @@
import { getAllBlocks } from '@/blocks'
import { generateRouterPrompt } from '@/blocks/blocks/router'
import { BlockOutput } from '@/blocks/types'
import { executeProviderRequest } from '@/providers/service'
import { getProviderFromModel } from '@/providers/utils'
import { SerializedBlock } from '@/serializer/types'
import { executeTool, getTool } from '@/tools'
import { PathTracker } from './path'
import { ExecutionContext } from './types'
/**
* Interface for block handlers that execute specific block types.
* Each handler is responsible for executing a particular type of block.
*/
export interface BlockHandler {
/**
* Determines if this handler can process the given block.
*
* @param block - Block to check
* @returns True if this handler can process the block
*/
canHandle(block: SerializedBlock): boolean
/**
* Executes the block with the given inputs and context.
*
* @param block - Block to execute
* @param inputs - Resolved input parameters
* @param context - Current execution context
* @returns Block execution output
*/
execute(
block: SerializedBlock,
inputs: Record<string, any>,
context: ExecutionContext
): Promise<BlockOutput>
}
/**
* Handler for Agent blocks that process LLM requests with optional tools.
*/
export class AgentBlockHandler implements BlockHandler {
canHandle(block: SerializedBlock): boolean {
return block.metadata?.id === 'agent'
}
async execute(
block: SerializedBlock,
inputs: Record<string, any>,
context: ExecutionContext
): Promise<BlockOutput> {
// Parse response format if provided
let responseFormat: any = undefined
if (inputs.responseFormat) {
try {
responseFormat =
typeof inputs.responseFormat === 'string'
? JSON.parse(inputs.responseFormat)
: inputs.responseFormat
} catch (error: any) {
throw new Error(`Invalid response format: ${error.message}`)
}
}
const model = inputs.model || 'gpt-4o'
const providerId = getProviderFromModel(model)
// Format tools for provider API
const formattedTools = Array.isArray(inputs.tools)
? inputs.tools
.map((tool: any) => {
const blockFound = getAllBlocks().find((b) => b.type === tool.type)
const toolId = blockFound?.tools.access[0]
if (!toolId) return null
const toolConfig = getTool(toolId)
if (!toolConfig) return null
return {
id: toolConfig.id,
name: toolConfig.name,
description: toolConfig.description,
params: tool.params || {},
parameters: {
type: 'object',
properties: Object.entries(toolConfig.params).reduce(
(acc, [key, config]) => ({
...acc,
[key]: {
type: config.type === 'json' ? 'object' : config.type,
description: config.description || '',
...(key in tool.params && { default: tool.params[key] }),
},
}),
{}
),
required: Object.entries(toolConfig.params)
.filter(([_, config]) => config.required)
.map(([key]) => key),
},
}
})
.filter((t): t is NonNullable<typeof t> => t !== null)
: []
const response = await executeProviderRequest(providerId, {
model,
systemPrompt: inputs.systemPrompt,
context: Array.isArray(inputs.context)
? JSON.stringify(inputs.context, null, 2)
: inputs.context,
tools: formattedTools.length > 0 ? formattedTools : undefined,
temperature: inputs.temperature,
maxTokens: inputs.maxTokens,
apiKey: inputs.apiKey,
responseFormat,
})
// Return structured or standard response based on responseFormat
return responseFormat
? {
...JSON.parse(response.content),
tokens: response.tokens || {
prompt: 0,
completion: 0,
total: 0,
},
toolCalls: response.toolCalls
? {
list: response.toolCalls,
count: response.toolCalls.length,
}
: undefined,
}
: {
response: {
content: response.content,
model: response.model,
tokens: response.tokens || {
prompt: 0,
completion: 0,
total: 0,
},
toolCalls: {
list: response.toolCalls || [],
count: response.toolCalls?.length || 0,
},
},
}
}
}
/**
* Handler for Router blocks that dynamically select execution paths.
*/
export class RouterBlockHandler implements BlockHandler {
/**
* @param pathTracker - Utility for tracking execution paths
*/
constructor(private pathTracker: PathTracker) {}
canHandle(block: SerializedBlock): boolean {
return block.metadata?.id === 'router'
}
async execute(
block: SerializedBlock,
inputs: Record<string, any>,
context: ExecutionContext
): Promise<BlockOutput> {
const targetBlocks = this.getTargetBlocks(block, context)
const routerConfig = {
prompt: inputs.prompt,
model: inputs.model || 'gpt-4o',
apiKey: inputs.apiKey,
temperature: inputs.temperature || 0,
}
const providerId = getProviderFromModel(routerConfig.model)
const response = await executeProviderRequest(providerId, {
model: routerConfig.model,
systemPrompt: generateRouterPrompt(routerConfig.prompt, targetBlocks),
messages: [{ role: 'user', content: routerConfig.prompt }],
temperature: routerConfig.temperature,
apiKey: routerConfig.apiKey,
})
const chosenBlockId = response.content.trim().toLowerCase()
const chosenBlock = targetBlocks?.find((b) => b.id === chosenBlockId)
if (!chosenBlock) {
throw new Error(`Invalid routing decision: ${chosenBlockId}`)
}
const tokens = response.tokens || { prompt: 0, completion: 0, total: 0 }
return {
response: {
content: inputs.prompt,
model: response.model,
tokens: {
prompt: tokens.prompt || 0,
completion: tokens.completion || 0,
total: tokens.total || 0,
},
selectedPath: {
blockId: chosenBlock.id,
blockType: chosenBlock.type || 'unknown',
blockTitle: chosenBlock.title || 'Untitled Block',
},
},
}
}
/**
* Gets all potential target blocks for this router.
*
* @param block - Router block
* @param context - Current execution context
* @returns Array of potential target blocks with metadata
* @throws Error if target block not found
*/
private getTargetBlocks(block: SerializedBlock, context: ExecutionContext) {
return context.workflow?.connections
.filter((conn) => conn.source === block.id)
.map((conn) => {
const targetBlock = context.workflow?.blocks.find((b) => b.id === conn.target)
if (!targetBlock) {
throw new Error(`Target block ${conn.target} not found`)
}
return {
id: targetBlock.id,
type: targetBlock.metadata?.id,
title: targetBlock.metadata?.name,
description: targetBlock.metadata?.description,
subBlocks: targetBlock.config.params,
currentState: context.blockStates.get(targetBlock.id)?.output,
}
})
}
}
/**
* Handler for Condition blocks that evaluate expressions to determine execution paths.
*/
export class ConditionBlockHandler implements BlockHandler {
/**
* @param pathTracker - Utility for tracking execution paths
*/
constructor(private pathTracker: PathTracker) {}
canHandle(block: SerializedBlock): boolean {
return block.metadata?.id === 'condition'
}
async execute(
block: SerializedBlock,
inputs: Record<string, any>,
context: ExecutionContext
): Promise<BlockOutput> {
const conditions = Array.isArray(inputs.conditions)
? inputs.conditions
: JSON.parse(inputs.conditions || '[]')
// Find source block for the condition
const sourceBlockId = context.workflow?.connections.find(
(conn) => conn.target === block.id
)?.source
if (!sourceBlockId) {
throw new Error(`No source block found for condition block ${block.id}`)
}
const sourceOutput = context.blockStates.get(sourceBlockId)?.output
if (!sourceOutput) {
throw new Error(`No output found for source block ${sourceBlockId}`)
}
// Get source block to derive a dynamic key
const sourceBlock = context.workflow?.blocks.find((b) => b.id === sourceBlockId)
if (!sourceBlock) {
throw new Error(`Source block ${sourceBlockId} not found`)
}
const sourceKey = sourceBlock.metadata?.name
? this.normalizeBlockName(sourceBlock.metadata.name)
: 'source'
// Get outgoing connections
const outgoingConnections = context.workflow?.connections.filter(
(conn) => conn.source === block.id
)
// Build evaluation context with source block output
const evalContext = {
...(typeof sourceOutput === 'object' && sourceOutput !== null ? sourceOutput : {}),
[sourceKey]: sourceOutput,
}
// Evaluate conditions in order (if, else if, else)
let selectedConnection: { target: string; sourceHandle?: string } | null = null
let selectedCondition: { id: string; title: string; value: string } | null = null
for (const condition of conditions) {
try {
// Evaluate the condition based on the resolved condition string
const conditionMet = new Function('context', `with(context) { return ${condition.value} }`)(
evalContext
)
// Find connection for this condition
const connection = outgoingConnections?.find(
(conn) => conn.sourceHandle === `condition-${condition.id}`
) as { target: string; sourceHandle?: string } | undefined
if (connection) {
// For if/else-if, require conditionMet to be true
// For else, always select it
if ((condition.title === 'if' || condition.title === 'else if') && conditionMet) {
selectedConnection = connection
selectedCondition = condition
break
} else if (condition.title === 'else') {
selectedConnection = connection
selectedCondition = condition
break
}
}
} catch (error: any) {
console.error(`Failed to evaluate condition: ${error.message}`, {
condition,
error,
})
throw new Error(`Failed to evaluate condition: ${error.message}`)
}
}
if (!selectedConnection || !selectedCondition) {
throw new Error(`No matching path found for condition block ${block.id}`)
}
// Find target block
const targetBlock = context.workflow?.blocks.find((b) => b.id === selectedConnection!.target)
if (!targetBlock) {
throw new Error(`Target block ${selectedConnection!.target} not found`)
}
return {
response: {
...((sourceOutput as any)?.response || {}),
conditionResult: true,
selectedPath: {
blockId: targetBlock.id,
blockType: targetBlock.metadata?.id || '',
blockTitle: targetBlock.metadata?.name || '',
},
selectedConditionId: selectedCondition.id,
},
}
}
/**
* Normalizes a block name for consistent lookups.
*
* @param name - Block name to normalize
* @returns Normalized block name (lowercase, no spaces)
*/
private normalizeBlockName(name: string): string {
return name.toLowerCase().replace(/\s+/g, '')
}
}
/**
* Handler for Evaluator blocks that assess content against criteria.
*/
export class EvaluatorBlockHandler implements BlockHandler {
canHandle(block: SerializedBlock): boolean {
return block.metadata?.id === 'evaluator'
}
async execute(
block: SerializedBlock,
inputs: Record<string, any>,
context: ExecutionContext
): Promise<BlockOutput> {
const model = inputs.model || 'gpt-4o'
const providerId = getProviderFromModel(model)
// Parse system prompt object
const systemPromptObj =
typeof inputs.systemPrompt === 'string'
? JSON.parse(inputs.systemPrompt)
: inputs.systemPrompt
// Execute the evaluator prompt with structured output format
const response = await executeProviderRequest(providerId, {
model: inputs.model,
systemPrompt: systemPromptObj?.systemPrompt,
responseFormat: systemPromptObj?.responseFormat,
messages: [{ role: 'user', content: inputs.content }],
temperature: inputs.temperature || 0,
apiKey: inputs.apiKey,
})
// Parse response content
const parsedContent = JSON.parse(response.content)
// Create result with metrics as direct fields for easy access
return {
response: {
content: inputs.content,
model: response.model,
tokens: {
prompt: response.tokens?.prompt || 0,
completion: response.tokens?.completion || 0,
total: response.tokens?.total || 0,
},
...Object.fromEntries(
Object.entries(parsedContent).map(([key, value]) => [key.toLowerCase(), value])
),
},
}
}
}
/**
* Handler for API blocks that make external HTTP requests.
*/
export class ApiBlockHandler implements BlockHandler {
canHandle(block: SerializedBlock): boolean {
return block.metadata?.id === 'api'
}
async execute(
block: SerializedBlock,
inputs: Record<string, any>,
context: ExecutionContext
): Promise<BlockOutput> {
const tool = getTool(block.config.tool)
if (!tool) {
throw new Error(`Tool not found: ${block.config.tool}`)
}
const result = await executeTool(block.config.tool, inputs)
if (!result.success) {
throw new Error(result.error || `API request failed with no error message`)
}
return { response: result.output }
}
}
/**
* Handler for Function blocks that execute custom code.
*/
export class FunctionBlockHandler implements BlockHandler {
canHandle(block: SerializedBlock): boolean {
return block.metadata?.id === 'function'
}
async execute(
block: SerializedBlock,
inputs: Record<string, any>,
context: ExecutionContext
): Promise<BlockOutput> {
const tool = getTool(block.config.tool)
if (!tool) {
throw new Error(`Tool not found: ${block.config.tool}`)
}
const result = await executeTool(block.config.tool, inputs)
if (!result.success) {
throw new Error(result.error || `Function execution failed with no error message`)
}
return { response: result.output }
}
}
/**
* Generic handler for any block types not covered by specialized handlers.
* Acts as a fallback for custom or future block types.
*/
export class GenericBlockHandler implements BlockHandler {
canHandle(block: SerializedBlock): boolean {
return true
}
async execute(
block: SerializedBlock,
inputs: Record<string, any>,
context: ExecutionContext
): Promise<BlockOutput> {
const tool = getTool(block.config.tool)
if (!tool) {
throw new Error(`Tool not found: ${block.config.tool}`)
}
const result = await executeTool(block.config.tool, inputs)
if (!result.success) {
throw new Error(result.error || `Block execution failed with no error message`)
}
return { response: result.output }
}
}

File diff suppressed because it is too large Load Diff

178
executor/loops.ts Normal file
View File

@@ -0,0 +1,178 @@
import { SerializedBlock, SerializedConnection, SerializedLoop } from '@/serializer/types'
import { ExecutionContext } from './types'
/**
* Manages loop detection, iteration limits, and state resets.
*/
export class LoopManager {
constructor(
private loops: Record<string, SerializedLoop>,
private defaultMaxIterations: number = 5
) {}
/**
* Processes all loops and checks if any need to be iterated.
* Resets blocks in loops that should iterate again.
*
* @param context - Current execution context
* @returns Whether any loop has reached its maximum iterations
*/
async processLoopIterations(context: ExecutionContext): Promise<boolean> {
let hasLoopReachedMaxIterations = false
// Nothing to do if no loops
if (Object.keys(this.loops).length === 0) return hasLoopReachedMaxIterations
// Check each loop to see if it should iterate
for (const [loopId, loop] of Object.entries(this.loops)) {
// Get current iteration count
const currentIteration = context.loopIterations.get(loopId) || 0
// If we've hit the max iterations, skip this loop and mark flag
if (currentIteration >= loop.maxIterations) {
hasLoopReachedMaxIterations = true
continue
}
// Check if loop should iterate again
const shouldIterate = this.shouldIterateLoop(loopId, context)
if (shouldIterate) {
// Increment iteration counter
context.loopIterations.set(loopId, currentIteration + 1)
// Check if we've now reached max iterations after incrementing
if (currentIteration + 1 >= loop.maxIterations) {
hasLoopReachedMaxIterations = true
}
// Reset ALL blocks in the loop, not just blocks after the entry
for (const nodeId of loop.nodes) {
// Remove from executed blocks
context.executedBlocks.delete(nodeId)
// Make sure it's in the active execution path
context.activeExecutionPath.add(nodeId)
}
// Important: Make sure the first block in the loop is marked as executable
if (loop.nodes.length > 0) {
// Find the first block in the loop (typically the one with fewest incoming connections)
const firstBlockId = this.findEntryBlock(loop.nodes, context)
if (firstBlockId) {
// Make sure it's in the active path
context.activeExecutionPath.add(firstBlockId)
}
}
}
}
return hasLoopReachedMaxIterations
}
/**
* Finds the entry block for a loop (the one that should be executed first).
* Typically the block with the fewest incoming connections.
*
* @param nodeIds - IDs of nodes in the loop
* @param context - Current execution context
* @returns ID of the entry block
*/
private findEntryBlock(nodeIds: string[], context: ExecutionContext): string | undefined {
const blockConnectionCounts = new Map<string, number>()
for (const nodeId of nodeIds) {
const incomingCount = context.workflow!.connections.filter(
(conn) => conn.target === nodeId
).length
blockConnectionCounts.set(nodeId, incomingCount)
}
const sortedBlocks = [...nodeIds].sort(
(a, b) => (blockConnectionCounts.get(a) || 0) - (blockConnectionCounts.get(b) || 0)
)
return sortedBlocks[0]
}
/**
* Checks if a loop should iterate again.
* A loop should iterate if:
* 1. All blocks in the loop have been executed
* 2. At least one feedback path exists
* 3. We haven't hit the max iterations
*
* @param loopId - ID of the loop to check
* @param context - Current execution context
* @returns Whether the loop should iterate again
*/
private shouldIterateLoop(loopId: string, context: ExecutionContext): boolean {
const loop = this.loops[loopId]
if (!loop) return false
const allBlocksExecuted = loop.nodes.every((nodeId) => context.executedBlocks.has(nodeId))
if (!allBlocksExecuted) return false
const currentIteration = context.loopIterations.get(loopId) || 0
const maxIterations = loop.maxIterations || this.defaultMaxIterations
if (currentIteration >= maxIterations) return false
const conditionBlocks = loop.nodes.filter((nodeId) => {
const block = context.blockStates.get(nodeId)
return block?.output?.response?.selectedConditionId !== undefined
})
for (const conditionId of conditionBlocks) {
const conditionState = context.blockStates.get(conditionId)
if (!conditionState) continue
const selectedPath = conditionState.output?.response?.selectedPath
if (!selectedPath) continue
const targetIndex = loop.nodes.indexOf(selectedPath.blockId)
const sourceIndex = loop.nodes.indexOf(conditionId)
if (targetIndex !== -1 && targetIndex < sourceIndex) {
return true
}
}
return false
}
/**
* Checks if a connection forms a feedback path in a loop.
* A feedback path points to an earlier block in the loop.
*
* @param connection - Connection to check
* @param blocks - All blocks in the workflow
* @returns Whether the connection forms a feedback path
*/
isFeedbackPath(connection: SerializedConnection, blocks: SerializedBlock[]): boolean {
for (const [loopId, loop] of Object.entries(this.loops)) {
if (loop.nodes.includes(connection.source) && loop.nodes.includes(connection.target)) {
const sourceIndex = loop.nodes.indexOf(connection.source)
const targetIndex = loop.nodes.indexOf(connection.target)
if (targetIndex < sourceIndex) {
const sourceBlock = blocks.find((b) => b.id === connection.source)
const isCondition = sourceBlock?.metadata?.id === 'condition'
return isCondition && connection.sourceHandle?.startsWith('condition-') === true
}
}
}
return false
}
/**
* Gets the maximum iterations for a loop.
*
* @param loopId - ID of the loop
* @returns Maximum iterations for the loop
*/
getMaxIterations(loopId: string): number {
return this.loops[loopId]?.maxIterations || this.defaultMaxIterations
}
}

110
executor/path.ts Normal file
View File

@@ -0,0 +1,110 @@
import { SerializedWorkflow } from '@/serializer/types'
import { ExecutionContext } from './types'
/**
* Manages the active execution paths in the workflow.
* Tracks which blocks should be executed based on routing decisions.
*/
export class PathTracker {
constructor(private workflow: SerializedWorkflow) {}
/**
* Checks if a block is in the active execution path.
* Considers router and condition block decisions.
*
* @param blockId - ID of the block to check
* @param context - Current execution context
* @returns Whether the block is in the active execution path
*/
isInActivePath(blockId: string, context: ExecutionContext): boolean {
// If the block is already in the active path set, it's valid
if (context.activeExecutionPath.has(blockId)) {
return true
}
// Get all incoming connections to this block
const incomingConnections = this.workflow.connections.filter((conn) => conn.target === blockId)
// A block is in the active path if at least one of its incoming connections
// is from an active and executed block
return incomingConnections.some((conn) => {
const sourceBlock = this.workflow.blocks.find((b) => b.id === conn.source)
// For router blocks, check if this is the selected target
if (sourceBlock?.metadata?.id === 'router') {
const selectedTarget = context.decisions.router.get(conn.source)
// This path is active if the router selected this target
if (context.executedBlocks.has(conn.source) && selectedTarget === blockId) {
return true
}
return false
}
// For condition blocks, check if this is the selected condition
if (sourceBlock?.metadata?.id === 'condition') {
if (conn.sourceHandle?.startsWith('condition-')) {
const conditionId = conn.sourceHandle.replace('condition-', '')
const selectedCondition = context.decisions.condition.get(conn.source)
// This path is active if the condition selected this path
if (context.executedBlocks.has(conn.source) && conditionId === selectedCondition) {
return true
}
return false
}
}
// For regular blocks, check if the source is in the active path and executed
return context.activeExecutionPath.has(conn.source) && context.executedBlocks.has(conn.source)
})
}
/**
* Updates execution paths based on newly executed blocks.
* Handles router and condition block decisions to activate paths without deactivating others.
*
* @param executedBlockIds - IDs of blocks that were just executed
* @param context - Current execution context
*/
updateExecutionPaths(executedBlockIds: string[], context: ExecutionContext): void {
for (const blockId of executedBlockIds) {
const block = this.workflow.blocks.find((b) => b.id === blockId)
if (block?.metadata?.id === 'router') {
const routerOutput = context.blockStates.get(blockId)?.output
const selectedPath = routerOutput?.response?.selectedPath?.blockId
if (selectedPath) {
// Record the decision but don't deactivate other paths
context.decisions.router.set(blockId, selectedPath)
context.activeExecutionPath.add(selectedPath)
}
} else if (block?.metadata?.id === 'condition') {
const conditionOutput = context.blockStates.get(blockId)?.output
const selectedConditionId = conditionOutput?.response?.selectedConditionId
if (selectedConditionId) {
// Record the decision but don't deactivate other paths
context.decisions.condition.set(blockId, selectedConditionId)
const targetConnection = this.workflow.connections.find(
(conn) =>
conn.source === blockId && conn.sourceHandle === `condition-${selectedConditionId}`
)
if (targetConnection) {
context.activeExecutionPath.add(targetConnection.target)
}
}
} else {
// For regular blocks, activate all outgoing connections
const outgoingConnections = this.workflow.connections.filter(
(conn) => conn.source === blockId
)
for (const conn of outgoingConnections) {
context.activeExecutionPath.add(conn.target)
}
}
}
}
}

302
executor/resolver.ts Normal file
View File

@@ -0,0 +1,302 @@
import { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
import { ExecutionContext } from './types'
/**
* Resolves input values for blocks by handling references and variable substitution.
*/
export class InputResolver {
private blockById: Map<string, SerializedBlock>
private blockByNormalizedName: Map<string, SerializedBlock>
constructor(
private workflow: SerializedWorkflow,
private environmentVariables: Record<string, string>
) {
// Create maps for efficient lookups
this.blockById = new Map(workflow.blocks.map((block) => [block.id, block]))
this.blockByNormalizedName = new Map(
workflow.blocks.map((block) => [
block.metadata?.name ? this.normalizeBlockName(block.metadata.name) : block.id,
block,
])
)
}
/**
* Resolves all inputs for a block based on current context.
* Handles block references, environment variables, and JSON parsing.
*
* @param block - Block to resolve inputs for
* @param context - Current execution context
* @returns Resolved input parameters
*/
resolveInputs(block: SerializedBlock, context: ExecutionContext): Record<string, any> {
const inputs = { ...block.config.params }
const result: Record<string, any> = {}
// Process each input parameter
for (const [key, value] of Object.entries(inputs)) {
// Skip null or undefined values
if (value === null || value === undefined) {
result[key] = value
continue
}
// Handle string values that may contain references
if (typeof value === 'string') {
// Resolve block references
let resolvedValue = this.resolveBlockReferences(value, context, block)
// Resolve environment variables
resolvedValue = this.resolveEnvVariables(resolvedValue)
// Convert JSON strings to objects if possible
try {
if (resolvedValue.startsWith('{') || resolvedValue.startsWith('[')) {
result[key] = JSON.parse(resolvedValue)
} else {
result[key] = resolvedValue
}
} catch {
// If it's not valid JSON, keep it as a string
result[key] = resolvedValue
}
}
// Handle objects and arrays recursively
else if (typeof value === 'object') {
if (Array.isArray(value)) {
result[key] = value.map((item) =>
typeof item === 'string'
? this.resolveEnvVariables(this.resolveBlockReferences(item, context, block))
: item
)
} else {
result[key] = this.resolveObjectReferences(value, context, block)
}
}
// Pass through other value types
else {
result[key] = value
}
}
return result
}
/**
* Resolves block references in a string (<blockId.property> or <blockName.property>).
* Handles inactive paths, missing blocks, and formats values appropriately.
*
* @param value - String containing block references
* @param context - Current execution context
* @param currentBlock - Block that contains the references
* @returns String with resolved references
* @throws Error if referenced block is not found or disabled
*/
resolveBlockReferences(
value: string,
context: ExecutionContext,
currentBlock: SerializedBlock
): string {
const blockMatches = value.match(/<([^>]+)>/g)
if (!blockMatches) return value
let resolvedValue = value
for (const match of blockMatches) {
const path = match.slice(1, -1)
const [blockRef, ...pathParts] = path.split('.')
let sourceBlock = this.blockById.get(blockRef)
if (!sourceBlock) {
const normalizedRef = this.normalizeBlockName(blockRef)
sourceBlock = this.blockByNormalizedName.get(normalizedRef)
}
if (!sourceBlock) {
throw new Error(`Block reference "${blockRef}" was not found.`)
}
if (sourceBlock.enabled === false) {
throw new Error(
`Block "${sourceBlock.metadata?.name || sourceBlock.id}" is disabled, and block "${currentBlock.metadata?.name || currentBlock.id}" depends on it.`
)
}
const isInActivePath = context.activeExecutionPath.has(sourceBlock.id)
if (!isInActivePath) {
resolvedValue = resolvedValue.replace(match, '')
continue
}
const blockState = context.blockStates.get(sourceBlock.id)
if (!blockState) {
// If the block is in a loop, return empty string
const isInLoop = Object.values(this.workflow.loops || {}).some((loop) =>
loop.nodes.includes(sourceBlock.id)
)
if (isInLoop) {
resolvedValue = resolvedValue.replace(match, '')
continue
}
// If the block hasn't been executed and isn't in the active path,
// it means it's in an inactive branch - return empty string
if (!context.activeExecutionPath.has(sourceBlock.id)) {
resolvedValue = resolvedValue.replace(match, '')
continue
}
throw new Error(
`No state found for block "${sourceBlock.metadata?.name || sourceBlock.id}" (ID: ${sourceBlock.id}).`
)
}
let replacementValue: any = blockState.output
for (const part of pathParts) {
if (!replacementValue || typeof replacementValue !== 'object') {
throw new Error(
`Invalid path "${part}" in "${path}" for block "${currentBlock.metadata?.name || currentBlock.id}".`
)
}
replacementValue = replacementValue[part]
if (replacementValue === undefined) {
throw new Error(
`No value found at path "${path}" in block "${sourceBlock.metadata?.name || sourceBlock.id}".`
)
}
}
let formattedValue: string
if (currentBlock.metadata?.id === 'condition') {
formattedValue = this.stringifyForCondition(replacementValue)
} else {
formattedValue =
typeof replacementValue === 'object'
? JSON.stringify(replacementValue)
: String(replacementValue)
}
resolvedValue = resolvedValue.replace(match, formattedValue)
}
return resolvedValue
}
/**
* Resolves environment variables in any value ({{ENV_VAR}}).
*
* @param value - Value that may contain environment variable references
* @returns Value with environment variables resolved
* @throws Error if referenced environment variable is not found
*/
resolveEnvVariables(value: any): any {
if (typeof value === 'string') {
const envMatches = value.match(/\{\{([^}]+)\}\}/g)
if (envMatches) {
let resolvedValue = value
for (const match of envMatches) {
const envKey = match.slice(2, -2)
const envValue = this.environmentVariables[envKey]
if (envValue === undefined) {
throw new Error(`Environment variable "${envKey}" was not found.`)
}
resolvedValue = resolvedValue.replace(match, envValue)
}
return resolvedValue
}
return value
}
if (Array.isArray(value)) {
return value.map((item) => this.resolveEnvVariables(item))
}
if (value && typeof value === 'object') {
return Object.entries(value).reduce(
(acc, [k, v]) => ({ ...acc, [k]: this.resolveEnvVariables(v) }),
{}
)
}
return value
}
/**
* Resolves block references in an object or array.
* Recursively processes nested objects and arrays.
*
* @param obj - Object containing block references
* @param context - Current execution context
* @param currentBlock - Block that contains the references
* @returns Object with resolved references
*/
private resolveObjectReferences(
obj: Record<string, any>,
context: ExecutionContext,
currentBlock: SerializedBlock
): Record<string, any> {
const result: Record<string, any> = {}
for (const [key, value] of Object.entries(obj)) {
if (typeof value === 'string') {
result[key] = this.resolveBlockReferences(value, context, currentBlock)
result[key] = this.resolveEnvVariables(result[key])
} else if (Array.isArray(value)) {
result[key] = value.map((item) =>
typeof item === 'string'
? this.resolveEnvVariables(this.resolveBlockReferences(item, context, currentBlock))
: typeof item === 'object'
? this.resolveObjectReferences(item, context, currentBlock)
: item
)
} else if (value && typeof value === 'object') {
result[key] = this.resolveObjectReferences(value, context, currentBlock)
} else {
result[key] = value
}
}
return result
}
/**
* Formats a value for use in condition blocks.
* Handles strings, null, undefined, and objects appropriately.
*
* @param value - Value to format
* @returns Formatted string representation
*/
private stringifyForCondition(value: any): string {
if (typeof value === 'string') {
return `"${value.replace(/"/g, '\\"').replace(/\n/g, '\\n')}"`
} else if (value === null) {
return 'null'
} else if (typeof value === 'undefined') {
return 'undefined'
} else if (typeof value === 'object') {
return JSON.stringify(value)
}
return String(value)
}
/**
* Normalizes block name for consistent lookups.
* Converts to lowercase and removes whitespace.
*
* @param name - Block name to normalize
* @returns Normalized block name
*/
private normalizeBlockName(name: string): string {
return name.toLowerCase().replace(/\s+/g, '')
}
}

View File

@@ -1,86 +1,183 @@
import { BlockOutput } from '@/blocks/types'
import { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
/**
* Describes a single block's logs, including timing and success/failure state.
* Standardized block output format that ensures compatibility with the execution engine.
*/
export interface NormalizedBlockOutput {
/** Primary response data from the block execution */
response: {
[key: string]: any
content?: string // Text content from LLM responses
model?: string // Model identifier used for generation
tokens?: {
prompt?: number
completion?: number
total?: number
}
toolCalls?: {
list: any[]
count: number
}
selectedPath?: {
blockId: string
blockType?: string
blockTitle?: string
}
selectedConditionId?: string // ID of selected condition
conditionResult?: boolean // Whether condition evaluated to true
result?: any // Generic result value
stdout?: string // Standard output from function execution
executionTime?: number // Time taken to execute
data?: any // Response data from API calls
status?: number // HTTP status code
headers?: Record<string, string> // HTTP headers
}
[key: string]: any // Additional properties
}
/**
* Execution log entry for a single block.
*/
export interface BlockLog {
blockId: string
blockName?: string
blockType?: string
startedAt: string
endedAt: string
durationMs: number
success: boolean
output?: any
error?: string
blockId: string // Unique identifier of the executed block
blockName?: string // Display name of the block
blockType?: string // Type of the block (agent, router, etc.)
startedAt: string // ISO timestamp when execution started
endedAt: string // ISO timestamp when execution completed
durationMs: number // Duration of execution in milliseconds
success: boolean // Whether execution completed successfully
output?: any // Output data from successful execution
error?: string // Error message if execution failed
}
/**
* Describes the runtime context for executing a workflow,
* including all block outputs (blockStates), metadata for timing, and block logs.
* Timing metadata for workflow execution.
*/
export interface ExecutionMetadata {
startTime?: string
endTime?: string
}
export interface ExecutionContext {
workflowId: string
blockStates: Map<string, BlockOutput>
blockLogs: BlockLog[]
metadata: ExecutionMetadata
environmentVariables?: Record<string, string>
startTime?: string // ISO timestamp when workflow execution started
endTime?: string // ISO timestamp when workflow execution completed
}
/**
* The complete result from executing the workflow. Includes success/fail,
* the "last block" output, optional error, timing metadata, and logs of each block's run.
* Current state of a block during workflow execution.
*/
export interface BlockState {
output: NormalizedBlockOutput // Current output data from the block
executed: boolean // Whether the block has been executed
executionTime?: number // Time taken to execute in milliseconds
}
/**
* Runtime context for workflow execution.
*/
export interface ExecutionContext {
workflowId: string // Unique identifier for this workflow execution
blockStates: Map<string, BlockState> // Map of block states indexed by block ID
blockLogs: BlockLog[] // Chronological log of block executions
metadata: ExecutionMetadata // Timing metadata for the execution
environmentVariables: Record<string, string> // Environment variables available during execution
// Routing decisions for path determination
decisions: {
router: Map<string, string> // Router block ID -> Target block ID
condition: Map<string, string> // Condition block ID -> Selected condition ID
}
loopIterations: Map<string, number> // Tracks current iteration count for each loop
// Execution tracking
executedBlocks: Set<string> // Set of block IDs that have been executed
activeExecutionPath: Set<string> // Set of block IDs in the current execution path
workflow?: SerializedWorkflow // Reference to the workflow being executed
}
/**
* Complete result from executing a workflow.
*/
export interface ExecutionResult {
success: boolean
output: BlockOutput
error?: string
logs?: BlockLog[]
success: boolean // Whether the workflow executed successfully
output: NormalizedBlockOutput // Final output data from the workflow
error?: string // Error message if execution failed
logs?: BlockLog[] // Execution logs for all blocks
metadata?: {
duration: number
startTime: string
endTime: string
duration: number // Total execution time in milliseconds
startTime: string // ISO timestamp when execution started
endTime: string // ISO timestamp when execution completed
}
}
/**
* Defines how a particular tool is invoked (URLs, headers, etc.), how it transforms responses
* and handles errors. Used by blocks that reference a particular tool ID.
* Configuration options for workflow execution.
*/
export interface ExecutionOptions {
maxLoopIterations?: number // Maximum iterations for any loop (default: 5)
continueOnError?: boolean // Whether to continue execution after errors
timeoutMs?: number // Maximum execution time in milliseconds before timeout
}
/**
* Interface for a block executor component.
*/
export interface BlockExecutor {
/**
* Determines if this executor can process the given block.
*/
canExecute(block: SerializedBlock): boolean
/**
* Executes the block with the given inputs and context.
*/
execute(
block: SerializedBlock,
inputs: Record<string, any>,
context: ExecutionContext
): Promise<BlockOutput>
}
/**
* Definition of a tool that can be invoked by blocks.
*
* @template P - Parameter type for the tool
* @template O - Output type from the tool
*/
export interface Tool<P = any, O = Record<string, any>> {
id: string
name: string
description: string
version: string
id: string // Unique identifier for the tool
name: string // Display name of the tool
description: string // Description of what the tool does
version: string // Version string for the tool
// Parameter definitions for the tool
params: {
[key: string]: {
type: string
required?: boolean
description?: string
default?: any
type: string // Data type of the parameter
required?: boolean // Whether the parameter is required
description?: string // Description of the parameter
default?: any // Default value if not provided
}
}
// HTTP request configuration for API tools
request?: {
url?: string | ((params: P) => string)
method?: string
headers?: (params: P) => Record<string, string>
body?: (params: P) => Record<string, any>
url?: string | ((params: P) => string) // URL or function to generate URL
method?: string // HTTP method to use
headers?: (params: P) => Record<string, string> // Function to generate request headers
body?: (params: P) => Record<string, any> // Function to generate request body
}
// Function to transform API response to tool output
transformResponse?: (response: any) => Promise<{
success: boolean
output: O
error?: string
}>
transformError?: (error: any) => string
transformError?: (error: any) => string // Function to format error messages
}
/**
* A registry of Tools, keyed by their IDs or names.
* Registry of available tools indexed by ID.
*/
export interface ToolRegistry {
[key: string]: Tool

View File

@@ -1,144 +0,0 @@
function stringifyValue(value: any): string {
if (typeof value === 'string') {
return `"${value.replace(/"/g, '\\"').replace(/\n/g, '\\n')}"`
} else if (value === null) {
return 'null'
} else if (typeof value === 'undefined') {
return 'undefined'
} else if (typeof value === 'object') {
return JSON.stringify(value)
}
return String(value)
}
export function resolveEnvVariables(value: any, environmentVariables: Record<string, string>): any {
if (typeof value === 'string') {
const envMatches = value.match(/\{\{([^}]+)\}\}/g)
if (envMatches) {
let resolvedValue = value
for (const match of envMatches) {
const envKey = match.slice(2, -2)
const envValue = environmentVariables[envKey]
if (envValue === undefined) {
throw new Error(`Environment variable "${envKey}" was not found.`)
}
resolvedValue = resolvedValue.replace(match, envValue)
}
return resolvedValue
}
} else if (Array.isArray(value)) {
return value.map((item) => resolveEnvVariables(item, environmentVariables))
} else if (value && typeof value === 'object') {
return Object.entries(value).reduce(
(acc, [k, v]) => ({ ...acc, [k]: resolveEnvVariables(v, environmentVariables) }),
{}
)
}
return value
}
export function resolveBlockReferences(
value: string,
blockById: Map<string, any>,
blockByName: Map<string, any>,
blockStates: Map<string, any>,
blockName: string,
blockType: string,
workflowLoops?: Record<string, { nodes: string[] }>
): string {
const blockMatches = value.match(/<([^>]+)>/g)
let resolvedValue = value
if (blockMatches) {
for (const match of blockMatches) {
const path = match.slice(1, -1)
const [blockRef, ...pathParts] = path.split('.')
let sourceBlock = blockById.get(blockRef)
if (!sourceBlock) {
const normalized = blockRef.toLowerCase().replace(/\s+/g, '')
sourceBlock = blockByName.get(normalized)
}
if (!sourceBlock) {
throw new Error(`Block reference "${blockRef}" was not found.`)
}
if (sourceBlock.enabled === false) {
throw new Error(
`Block "${sourceBlock.metadata?.title || sourceBlock.name}" is disabled, and block "${blockName}" depends on it.`
)
}
let sourceState = blockStates.get(sourceBlock.id)
let defaulted = false
if (!sourceState) {
if (workflowLoops) {
for (const loopKey in workflowLoops) {
const loop = workflowLoops[loopKey]
if (loop.nodes.includes(sourceBlock.id)) {
defaulted = true
sourceState = {} // default to empty object
break
}
}
}
if (!sourceState) {
throw new Error(
`No state found for block "${sourceBlock.metadata?.title || sourceBlock.name}" (ID: ${sourceBlock.id}).`
)
}
}
// Drill into the property path.
let replacementValue: any = sourceState
for (const part of pathParts) {
if (!replacementValue || typeof replacementValue !== 'object') {
if (defaulted) {
replacementValue = ''
break
} else {
throw new Error(`Invalid path "${part}" in "${path}" for block "${blockName}".`)
}
}
replacementValue = replacementValue[part]
}
if (replacementValue === undefined && defaulted) {
replacementValue = ''
} else if (replacementValue === undefined) {
throw new Error(
`No value found at path "${path}" in block "${sourceBlock.metadata?.title || sourceBlock.name}".`
)
}
if (replacementValue !== undefined) {
// For condition blocks, we need to properly stringify the value
if (blockType === 'condition') {
resolvedValue = resolvedValue.replace(match, stringifyValue(replacementValue))
} else {
resolvedValue = resolvedValue.replace(
match,
typeof replacementValue === 'object'
? JSON.stringify(replacementValue)
: String(replacementValue)
)
}
}
}
}
if (typeof resolvedValue === 'undefined') {
const refRegex = /<([^>]+)>/
const match = value.match(refRegex)
const ref = match ? match[1] : ''
const refParts = ref.split('.')
const refBlockId = refParts[0]
if (workflowLoops) {
for (const loopKey in workflowLoops) {
const loop = workflowLoops[loopKey]
if (loop.nodes.includes(refBlockId)) {
// Block exists in a loop, so return empty string instead of error
return ''
}
}
}
throw new Error(`No state found for block "${refBlockId}" (ID: ${refBlockId})`)
}
return resolvedValue
}

17
jest.config.js Normal file
View File

@@ -0,0 +1,17 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
// Test paths
testMatch: ['**/tests/**/*.test.{ts,tsx,js,jsx}', '**/__tests__/**/*.test.{ts,tsx,js,jsx}'],
testPathIgnorePatterns: ['/node_modules/', '/dist/'],
// Module resolution
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/$1',
},
// Setup files
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
}

54
jest.setup.js Normal file
View File

@@ -0,0 +1,54 @@
require('@testing-library/jest-dom')
// Mock global fetch
global.fetch = jest.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({}),
})
)
// Mock stores
jest.mock('@/stores/console/store', () => ({
useConsoleStore: {
getState: jest.fn().mockReturnValue({
addConsole: jest.fn(),
}),
},
}))
jest.mock('@/stores/execution/store', () => ({
useExecutionStore: {
getState: jest.fn().mockReturnValue({
setIsExecuting: jest.fn(),
reset: jest.fn(),
setActiveBlocks: jest.fn(),
}),
},
}))
// Reset mocks before each test
beforeEach(() => {
jest.clearAllMocks()
})
// Silence specific console errors during tests
const originalConsoleError = console.error
console.error = (...args) => {
// Filter out expected errors from test output
if (args[0] === 'Workflow execution failed:' && args[1]?.message === 'Test error') {
return
}
originalConsoleError(...args)
}
// Global setup
beforeAll(() => {
// Add any global setup here
})
// Global teardown
afterAll(() => {
// Restore console.error
console.error = originalConsoleError
})

4611
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,10 @@
"format:check": "prettier --check .",
"prepare": "husky",
"db:push": "drizzle-kit push:pg",
"db:studio": "drizzle-kit studio"
"db:studio": "drizzle-kit studio",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
},
"dependencies": {
"@radix-ui/react-alert-dialog": "^1.1.5",
@@ -54,7 +57,11 @@
"zod": "^3.24.1"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@testing-library/user-event": "^14.6.1",
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
"@types/jest": "^29.5.14",
"@types/node": "^20",
"@types/prismjs": "^1.26.5",
"@types/react": "^19",
@@ -62,11 +69,14 @@
"dotenv": "^16.4.7",
"drizzle-kit": "^0.30.4",
"husky": "^9.1.7",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"lint-staged": "^15.4.3",
"postcss": "^8",
"prettier": "^3.4.2",
"prettier-plugin-tailwindcss": "^0.6.11",
"tailwindcss": "^3.4.1",
"ts-jest": "^29.2.6",
"typescript": "^5.7.3"
},
"lint-staged": {

View File

@@ -0,0 +1,3 @@
export interface BlockOutput {
[key: string]: any
}

View File

@@ -0,0 +1,12 @@
const mockHandler = {
canHandle: jest.fn().mockReturnValue(true),
execute: jest.fn().mockResolvedValue({ response: { result: 'success' } }),
}
export const AgentBlockHandler = jest.fn().mockImplementation(() => mockHandler)
export const RouterBlockHandler = jest.fn().mockImplementation(() => mockHandler)
export const ConditionBlockHandler = jest.fn().mockImplementation(() => mockHandler)
export const EvaluatorBlockHandler = jest.fn().mockImplementation(() => mockHandler)
export const FunctionBlockHandler = jest.fn().mockImplementation(() => mockHandler)
export const ApiBlockHandler = jest.fn().mockImplementation(() => mockHandler)
export const GenericBlockHandler = jest.fn().mockImplementation(() => mockHandler)

View File

@@ -0,0 +1,4 @@
export const LoopManager = jest.fn().mockImplementation(() => ({
processLoopIterations: jest.fn().mockResolvedValue(false),
getMaxIterations: jest.fn().mockReturnValue(5),
}))

View File

@@ -0,0 +1,4 @@
export const PathTracker = jest.fn().mockImplementation(() => ({
isInActivePath: jest.fn().mockReturnValue(true),
updateExecutionPaths: jest.fn(),
}))

View File

@@ -0,0 +1,5 @@
export const InputResolver = jest.fn().mockImplementation(() => ({
resolveInputs: jest.fn().mockReturnValue({}),
resolveBlockReferences: jest.fn().mockImplementation((val) => val),
resolveEnvVariables: jest.fn().mockImplementation((val) => val),
}))

View File

@@ -0,0 +1,6 @@
const mockSerializer = {
serialize: jest.fn(),
deserialize: jest.fn(),
}
export default mockSerializer

View File

@@ -0,0 +1,5 @@
export const useConsoleStore = {
getState: jest.fn().mockReturnValue({
addConsole: jest.fn(),
}),
}

View File

@@ -0,0 +1,7 @@
export const useExecutionStore = {
getState: jest.fn().mockReturnValue({
setIsExecuting: jest.fn(),
reset: jest.fn(),
setActiveBlocks: jest.fn(),
}),
}

View File

@@ -0,0 +1,145 @@
import { SerializedWorkflow } from '../../../serializer/types'
export const createMinimalWorkflow = (): SerializedWorkflow => ({
version: '1.0',
blocks: [
{
id: 'starter',
position: { x: 0, y: 0 },
config: { tool: 'test-tool', params: {} },
inputs: {},
outputs: {},
enabled: true,
metadata: { id: 'starter', name: 'Starter Block' },
},
{
id: 'block1',
position: { x: 100, y: 0 },
config: { tool: 'test-tool', params: {} },
inputs: {},
outputs: {},
enabled: true,
metadata: { id: 'test', name: 'Test Block' },
},
],
connections: [
{
source: 'starter',
target: 'block1',
},
],
loops: {},
})
export const createWorkflowWithLoop = (): SerializedWorkflow => ({
version: '1.0',
blocks: [
{
id: 'starter',
position: { x: 0, y: 0 },
config: { tool: 'test-tool', params: {} },
inputs: {},
outputs: {},
enabled: true,
metadata: { id: 'starter', name: 'Starter Block' },
},
{
id: 'block1',
position: { x: 100, y: 0 },
config: { tool: 'test-tool', params: {} },
inputs: {},
outputs: {},
enabled: true,
metadata: { id: 'test', name: 'Loop Block 1' },
},
{
id: 'block2',
position: { x: 200, y: 0 },
config: { tool: 'test-tool', params: {} },
inputs: {},
outputs: {},
enabled: true,
metadata: { id: 'test', name: 'Loop Block 2' },
},
],
connections: [
{
source: 'starter',
target: 'block1',
},
{
source: 'block1',
target: 'block2',
},
{
source: 'block2',
target: 'block1',
},
],
loops: {
loop1: {
id: 'loop1',
nodes: ['block1', 'block2'],
maxIterations: 5,
},
},
})
export const createWorkflowWithCondition = (): SerializedWorkflow => ({
version: '1.0',
blocks: [
{
id: 'starter',
position: { x: 0, y: 0 },
config: { tool: 'test-tool', params: {} },
inputs: {},
outputs: {},
enabled: true,
metadata: { id: 'starter', name: 'Starter Block' },
},
{
id: 'condition1',
position: { x: 100, y: 0 },
config: { tool: 'test-tool', params: {} },
inputs: {},
outputs: {},
enabled: true,
metadata: { id: 'condition', name: 'Condition Block' },
},
{
id: 'block1',
position: { x: 200, y: -50 },
config: { tool: 'test-tool', params: {} },
inputs: {},
outputs: {},
enabled: true,
metadata: { id: 'test', name: 'True Path Block' },
},
{
id: 'block2',
position: { x: 200, y: 50 },
config: { tool: 'test-tool', params: {} },
inputs: {},
outputs: {},
enabled: true,
metadata: { id: 'test', name: 'False Path Block' },
},
],
connections: [
{
source: 'starter',
target: 'condition1',
},
{
source: 'condition1',
target: 'block1',
sourceHandle: 'condition-true',
},
{
source: 'condition1',
target: 'block2',
sourceHandle: 'condition-false',
},
],
loops: {},
})

View File

@@ -0,0 +1,101 @@
import { Executor } from '../../executor'
import {
createMinimalWorkflow,
createWorkflowWithCondition,
createWorkflowWithLoop,
} from './fixtures/workflows'
// Use automatic mocking
jest.mock('../../executor/resolver', () => require('../__mocks__/executor/resolver'))
jest.mock('../../executor/loops', () => require('../__mocks__/executor/loops'))
jest.mock('../../executor/path', () => require('../__mocks__/executor/path'))
jest.mock('../../executor/handlers', () => require('../__mocks__/executor/handlers'))
jest.mock('@/stores/console/store')
jest.mock('@/stores/execution/store')
describe('Executor', () => {
beforeEach(() => {
jest.clearAllMocks()
})
test('should initialize correctly', () => {
const workflow = createMinimalWorkflow()
const executor = new Executor(workflow)
expect(executor).toBeDefined()
})
test('should validate workflow on execution', async () => {
const workflow = createMinimalWorkflow()
const executor = new Executor(workflow)
const validateSpy = jest.spyOn(executor as any, 'validateWorkflow')
await executor.execute('test-workflow-id')
expect(validateSpy).toHaveBeenCalled()
})
test('should throw error for workflow without starter block', () => {
const workflow = createMinimalWorkflow()
workflow.blocks = workflow.blocks.filter((block) => block.metadata?.id !== '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
expect(() => new Executor(workflow)).toThrow('Workflow must have an enabled starter block')
})
test('should execute blocks in correct order', async () => {
const workflow = createMinimalWorkflow()
const executor = new Executor(workflow)
const result = await executor.execute('test-workflow-id')
expect(result.success).toBe(true)
// Add more assertions based on expected execution order
})
test('should handle loops correctly', async () => {
const workflow = createWorkflowWithLoop()
const executor = new Executor(workflow)
const result = await executor.execute('test-workflow-id')
expect(result.success).toBe(true)
// Add assertions for loop execution
})
test('should follow conditional paths correctly', async () => {
const workflow = createWorkflowWithCondition()
const executor = new Executor(workflow)
// Mock condition decision
const { useExecutionStore } = require('@/stores/execution/store')
useExecutionStore.getState().decisions = {
condition: new Map([['condition1', 'true']]),
}
const result = await executor.execute('test-workflow-id')
expect(result.success).toBe(true)
// Add assertions for conditional path execution
})
test('should handle errors gracefully', async () => {
const workflow = createMinimalWorkflow()
const executor = new Executor(workflow)
// Mock handler to throw error
const { GenericBlockHandler } = require('../__mocks__/executor/handlers')
const mockHandler = GenericBlockHandler.mock.results[0].value
mockHandler.execute.mockRejectedValueOnce(new Error('Test error'))
const result = await executor.execute('test-workflow-id')
expect(result.success).toBe(false)
expect(result.error).toBe('Test error')
})
})