Files
sim/executor/resolver.ts

359 lines
12 KiB
TypeScript

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]))
// Initialize the normalized name map
this.blockByNormalizedName = new Map(
workflow.blocks.map((block) => [
block.metadata?.name ? this.normalizeBlockName(block.metadata.name) : block.id,
block,
])
)
// Add special handling for the starter block - allow referencing it as "start"
const starterBlock = workflow.blocks.find((block) => block.metadata?.id === 'starter')
if (starterBlock) {
this.blockByNormalizedName.set('start', starterBlock)
// Also add the normalized actual name if it exists
if (starterBlock.metadata?.name) {
this.blockByNormalizedName.set(
this.normalizeBlockName(starterBlock.metadata.name),
starterBlock
)
}
}
}
/**
* 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') {
result[key] = this.resolveNestedStructure(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('.')
// Special case for "start" references
// This allows users to reference the starter block using <start.response.type.input>
// regardless of the actual name of the starter block
if (blockRef.toLowerCase() === 'start') {
// Find the starter block
const starterBlock = this.workflow.blocks.find((block) => block.metadata?.id === 'starter')
if (starterBlock) {
const blockState = context.blockStates.get(starterBlock.id)
if (blockState) {
// Navigate through the path parts
let replacementValue: any = blockState.output
for (const part of pathParts) {
if (!replacementValue || typeof replacementValue !== 'object') {
throw new Error(`Invalid path "${part}" in "${path}" for starter block.`)
}
replacementValue = replacementValue[part]
if (replacementValue === undefined) {
throw new Error(`No value found at path "${path}" in starter block.`)
}
}
// Format the value
const formattedValue =
typeof replacementValue === 'object'
? JSON.stringify(replacementValue)
: String(replacementValue)
resolvedValue = resolvedValue.replace(match, formattedValue)
continue
}
}
}
// Standard block reference resolution
let sourceBlock = this.blockById.get(blockRef)
if (!sourceBlock) {
const normalizedRef = this.normalizeBlockName(blockRef)
sourceBlock = this.blockByNormalizedName.get(normalizedRef)
}
if (!sourceBlock) {
// Provide a more helpful error message with available block names
const availableBlocks = Array.from(this.blockByNormalizedName.keys()).join(', ')
throw new Error(
`Block reference "${blockRef}" was not found. Available blocks: ${availableBlocks}. ` +
`For the starter block, try using "start" or the exact block name.`
)
}
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 if (currentBlock.metadata?.id === 'function' && typeof replacementValue === 'string') {
// For function blocks, we need to properly quote string values to avoid syntax errors
formattedValue = JSON.stringify(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 references and environment variables in any nested structure (object or array).
* This is a more general approach that handles any level of nesting.
*
* @param value - The value to resolve (object, array, or primitive)
* @param context - Current execution context
* @param currentBlock - Block that contains the references
* @returns Resolved value with all references and environment variables processed
*/
private resolveNestedStructure(
value: any,
context: ExecutionContext,
currentBlock: SerializedBlock
): any {
// Handle null or undefined
if (value === null || value === undefined) {
return value
}
// Handle strings
if (typeof value === 'string') {
// First resolve block references
const resolvedReferences = this.resolveBlockReferences(value, context, currentBlock)
// Then resolve environment variables
return this.resolveEnvVariables(resolvedReferences)
}
// Handle arrays
if (Array.isArray(value)) {
return value.map((item) => this.resolveNestedStructure(item, context, currentBlock))
}
// Handle objects
if (typeof value === 'object') {
const result: Record<string, any> = {}
for (const [k, v] of Object.entries(value)) {
result[k] = this.resolveNestedStructure(v, context, currentBlock)
}
return result
}
// Return primitives as is
return value
}
/**
* 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, '')
}
}