/** * Utility functions for the Ion workflow engine. * * Provides variable substitution, condition evaluation, error classification, * and safe messaging helpers used by the DAG executor and top-level executor. */ import type { NodeOutput } from '../schema/index.js'; // --------------------------------------------------------------------------- // Variable substitution // --------------------------------------------------------------------------- /** * Substitute workflow-level variables in a string. * * Replaces `${VAR_NAME}` patterns with values from the provided variables map. * Supports nested dot-notation access (e.g. `${env.API_KEY}`). */ export function substituteWorkflowVariables( template: string, variables: Record, ): string { return template.replace(/\$\{([^}]+)\}/g, (_match, path: string) => { const parts = path.split('.'); let current: unknown = variables; for (const part of parts) { if (current == null || typeof current !== 'object') return ''; current = (current as Record)[part]; } return current != null ? String(current) : ''; }); } /** * Regex pattern for node output references: `$nodeId.output` or `$nodeId.output.field`. */ const NODE_OUTPUT_REF_REGEX = /\$([a-zA-Z_][\w-]*)\.output(?:\.([a-zA-Z_][\w]*))?/g; /** * Substitute node output references in a prompt string. * * Resolves `$nodeId.output` → full text output, and * `$nodeId.output.field` → specific structured field. * * @param prompt - The prompt template containing references. * @param nodeOutputs - Map of node id → NodeOutput. * @param escapedForBash - If true, escapes special bash characters in the output. */ export function substituteNodeOutputRefs( prompt: string, nodeOutputs: Map, escapedForBash = false, ): string { return prompt.replace(NODE_OUTPUT_REF_REGEX, (_match, nodeId: string, field?: string) => { const output = nodeOutputs.get(nodeId); if (!output) { throw new OutputRefError( `Node output reference $${nodeId}.output not found. ` + `Available nodes: ${[...nodeOutputs.keys()].join(', ')}`, ); } let value: string; if (field) { value = resolveNodeOutputField(output, field); } else { value = output.text ?? ''; } if (escapedForBash) { value = value.replace(/(["'$`\\!])/g, '\\$1'); } return value; }); } /** * Resolve a specific field from a node's structured output. * * @throws OutputRefError if the field doesn't exist or output has no fields. */ export function resolveNodeOutputField(output: NodeOutput, field: string): string { if (!output.fields || !(field in output.fields)) { throw new OutputRefError( `Node ${output.nodeId} output does not have field "${field}". ` + `Available fields: ${output.fields ? Object.keys(output.fields).join(', ') : '(none)'}`, ); } const value = output.fields[field]; return value != null ? String(value) : ''; } /** * Build a complete prompt string with context injection. * * Applies workflow variable substitution and node output reference * substitution to produce the final prompt sent to the AI provider. */ export function buildPromptWithContext( prompt: string, variables: Record, nodeOutputs: Map, escapedForBash = false, ): string { let result = substituteWorkflowVariables(prompt, variables); result = substituteNodeOutputRefs(result, nodeOutputs, escapedForBash); return result; } // --------------------------------------------------------------------------- // Condition evaluation // --------------------------------------------------------------------------- /** * Evaluate a condition expression against the current workflow context. * * Supports simple expressions: * - Truthy/falsy string check (empty string = false) * - Comparison: `==`, `!=`, `>`, `<`, `>=`, `<=` * - Boolean literals: `true`, `false` * - Variable references: `${var}` resolved before evaluation * * @param condition - The condition string to evaluate. * @param variables - Workflow variables for substitution. * @returns Whether the condition is truthy. */ export function evaluateCondition( condition: string | undefined, variables: Record, ): boolean { if (condition === undefined || condition === '') return true; // Substitute variables first const resolved = substituteWorkflowVariables(condition, variables).trim(); // Boolean literals if (resolved === 'true') return true; if (resolved === 'false') return false; // Empty after substitution = falsy if (resolved === '') return false; // Comparison operators const comparisonMatch = resolved.match(/^(.+?)\s*(==|!=|>=|<=|>|<)\s*(.+)$/); if (comparisonMatch) { const [, left, op, right] = comparisonMatch; const leftVal = left!.trim(); const rightVal = right!.trim(); switch (op) { case '==': return leftVal === rightVal; case '!=': return leftVal !== rightVal; case '>=': return parseFloat(leftVal) >= parseFloat(rightVal); case '<=': return parseFloat(leftVal) <= parseFloat(rightVal); case '>': return parseFloat(leftVal) > parseFloat(rightVal); case '<': return parseFloat(leftVal) < parseFloat(rightVal); } } // Default: non-empty string is truthy return resolved.length > 0; } // --------------------------------------------------------------------------- // Error classification // --------------------------------------------------------------------------- /** Error categories for classification. */ export type ErrorCategory = 'transient' | 'permanent' | 'timeout' | 'rate_limit' | 'unknown'; /** * Classify an error into a category for retry decisions. */ export function classifyError(error: unknown): ErrorCategory { if (error instanceof Error) { const msg = error.message.toLowerCase(); // Timeout errors if ( msg.includes('timeout') || msg.includes('timed out') || msg.includes('aborted') || error.name === 'AbortError' || error.name === 'TimeoutError' ) { return 'timeout'; } // Rate limiting if ( msg.includes('rate limit') || msg.includes('429') || msg.includes('too many requests') ) { return 'rate_limit'; } // Permanent errors if ( msg.includes('authentication') || msg.includes('unauthorized') || msg.includes('forbidden') || msg.includes('401') || msg.includes('403') || msg.includes('invalid api key') || msg.includes('permission denied') ) { return 'permanent'; } // Transient errors if ( msg.includes('network') || msg.includes('econnreset') || msg.includes('econnrefused') || msg.includes('enotfound') || msg.includes('socket hang up') || msg.includes('500') || msg.includes('502') || msg.includes('503') || msg.includes('504') || msg.includes('internal server error') || msg.includes('bad gateway') || msg.includes('service unavailable') || msg.includes('gateway timeout') ) { return 'transient'; } } return 'unknown'; } // --------------------------------------------------------------------------- // Safe messaging // --------------------------------------------------------------------------- /** * Safely send a message to the platform, swallowing errors. * * Used for non-critical notifications where failure should not * abort the workflow. */ export async function safeSendMessage( platform: { sendMessage: (convId: string, msg: string, meta?: Record) => Promise }, conversationId: string, message: string, metadata?: Record, ): Promise { try { await platform.sendMessage(conversationId, message, metadata); } catch { // Swallow — this is a best-effort notification } } // --------------------------------------------------------------------------- // Custom errors // --------------------------------------------------------------------------- /** Thrown when a node output reference cannot be resolved. */ export class OutputRefError extends Error { constructor(message: string) { super(message); this.name = 'OutputRefError'; } } /** Thrown when a cycle is detected in the DAG. */ export class DagCycleError extends Error { constructor(nodeCount: number, layerSum: number) { super( `Cycle detected in DAG: ${nodeCount} nodes but only ${layerSum} reachable via topological sort`, ); this.name = 'DagCycleError'; } } /** Thrown when a node execution times out. */ export class NodeTimeoutError extends Error { constructor(nodeId: string, timeoutMs: number) { super(`Node "${nodeId}" timed out after ${timeoutMs}ms`); this.name = 'NodeTimeoutError'; } } /** Thrown when an approval is rejected. */ export class ApprovalRejectedError extends Error { constructor(nodeId: string, reason?: string) { super(`Approval rejected for node "${nodeId}"${reason ? `: ${reason}` : ''}`); this.name = 'ApprovalRejectedError'; } } /** Thrown when a loop exceeds its maximum iterations. */ export class LoopMaxIterationsError extends Error { constructor(nodeId: string, iterations: number) { super(`Loop node "${nodeId}" exceeded max iterations (${iterations})`); this.name = 'LoopMaxIterationsError'; } } // --------------------------------------------------------------------------- // Subprocess formatting // --------------------------------------------------------------------------- /** * Format a subprocess failure into a human-readable error message. */ export function formatSubprocessFailure( command: string, exitCode: number | null, stdout: string, stderr: string, ): string { const parts: string[] = []; parts.push(`Command failed: ${command}`); if (exitCode != null) parts.push(`Exit code: ${exitCode}`); if (stderr.trim()) parts.push(`stderr: ${stderr.trim()}`); if (stdout.trim()) parts.push(`stdout: ${stdout.trim()}`); return parts.join('\n'); } // --------------------------------------------------------------------------- // Misc helpers // --------------------------------------------------------------------------- /** * Sleep for a given number of milliseconds. */ export function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } /** * Retry a function with exponential backoff. * * @param fn - The function to retry. * @param maxAttempts - Maximum number of attempts. * @param baseDelayMs - Base delay between retries in ms. * @param shouldRetry - Optional predicate to decide if a retry is warranted. */ export async function retryWithBackoff( fn: () => Promise, maxAttempts: number, baseDelayMs = 1000, shouldRetry?: (error: unknown) => boolean, ): Promise { let lastError: unknown; for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { return await fn(); } catch (error) { lastError = error; if (shouldRetry && !shouldRetry(error)) throw error; if (attempt < maxAttempts) { const delay = baseDelayMs * Math.pow(2, attempt - 1); await sleep(delay); } } } throw lastError; }