mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-09 15:07:55 -05:00
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:
508
executor/handlers.ts
Normal file
508
executor/handlers.ts
Normal 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 }
|
||||
}
|
||||
}
|
||||
1315
executor/index.ts
1315
executor/index.ts
File diff suppressed because it is too large
Load Diff
178
executor/loops.ts
Normal file
178
executor/loops.ts
Normal 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
110
executor/path.ts
Normal 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
302
executor/resolver.ts
Normal 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, '')
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
17
jest.config.js
Normal 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
54
jest.setup.js
Normal 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
4611
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@@ -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": {
|
||||
|
||||
3
tests/__mocks__/blocks/types.ts
Normal file
3
tests/__mocks__/blocks/types.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export interface BlockOutput {
|
||||
[key: string]: any
|
||||
}
|
||||
12
tests/__mocks__/executor/handlers.ts
Normal file
12
tests/__mocks__/executor/handlers.ts
Normal 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)
|
||||
4
tests/__mocks__/executor/loops.ts
Normal file
4
tests/__mocks__/executor/loops.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export const LoopManager = jest.fn().mockImplementation(() => ({
|
||||
processLoopIterations: jest.fn().mockResolvedValue(false),
|
||||
getMaxIterations: jest.fn().mockReturnValue(5),
|
||||
}))
|
||||
4
tests/__mocks__/executor/path.ts
Normal file
4
tests/__mocks__/executor/path.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export const PathTracker = jest.fn().mockImplementation(() => ({
|
||||
isInActivePath: jest.fn().mockReturnValue(true),
|
||||
updateExecutionPaths: jest.fn(),
|
||||
}))
|
||||
5
tests/__mocks__/executor/resolver.ts
Normal file
5
tests/__mocks__/executor/resolver.ts
Normal 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),
|
||||
}))
|
||||
6
tests/__mocks__/serializer/types.ts
Normal file
6
tests/__mocks__/serializer/types.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
const mockSerializer = {
|
||||
serialize: jest.fn(),
|
||||
deserialize: jest.fn(),
|
||||
}
|
||||
|
||||
export default mockSerializer
|
||||
5
tests/__mocks__/stores/console/store.ts
Normal file
5
tests/__mocks__/stores/console/store.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const useConsoleStore = {
|
||||
getState: jest.fn().mockReturnValue({
|
||||
addConsole: jest.fn(),
|
||||
}),
|
||||
}
|
||||
7
tests/__mocks__/stores/execution/store.ts
Normal file
7
tests/__mocks__/stores/execution/store.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export const useExecutionStore = {
|
||||
getState: jest.fn().mockReturnValue({
|
||||
setIsExecuting: jest.fn(),
|
||||
reset: jest.fn(),
|
||||
setActiveBlocks: jest.fn(),
|
||||
}),
|
||||
}
|
||||
145
tests/executor/fixtures/workflows.ts
Normal file
145
tests/executor/fixtures/workflows.ts
Normal 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: {},
|
||||
})
|
||||
101
tests/executor/index.test.ts
Normal file
101
tests/executor/index.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user