UNPKG

@simonecoelhosfo/optimizely-mcp-server

Version:

Optimizely MCP Server for AI assistants with integrated CLI tools

588 lines 26.1 kB
/** * Dependency Resolver * @description Resolves dependencies between orchestration steps and creates execution plans * @author Optimizely MCP Server * @version 1.0.0 */ import { getLogger } from '../../logging/Logger.js'; import * as JSONPath from 'jsonpath'; export class DependencyResolver { logger = getLogger(); /** * Build execution plan with proper ordering and batching */ async buildExecutionPlan(steps, state) { this.logger.info({ stepCount: steps.length }, 'Building execution plan'); // 1. Build dependency graph const graph = this.buildDependencyGraph(steps); // 2. Detect circular dependencies if (this.hasCircularDependencies(graph)) { throw new Error('Circular dependencies detected in orchestration template'); } // 3. Topological sort to determine execution order const sortedSteps = this.topologicalSort(steps, graph); // 4. Group into batches that can run in parallel const batches = this.groupIntoBatches(sortedSteps, graph); // 5. Estimate execution time const estimatedDuration = this.estimateExecutionTime(batches); this.logger.info({ batchCount: batches.length, estimatedDuration }, 'Execution plan created'); return { batches, estimated_duration_ms: estimatedDuration, dependency_graph: graph }; } /** * Build dependency graph from steps */ buildDependencyGraph(steps) { const nodes = new Map(); const edges = []; // Create nodes steps.forEach(step => { nodes.set(step.id, { step_id: step.id, step_name: step.name, dependencies: step.depends_on || [], dependents: [] }); }); // Create edges and update dependents steps.forEach(step => { if (step.depends_on) { step.depends_on.forEach(depId => { // Create edge edges.push({ from: depId, to: step.id, type: 'required' }); // Update dependent list const depNode = nodes.get(depId); if (depNode) { depNode.dependents.push(step.id); } else { this.logger.warn({ stepId: step.id, missingDependency: depId }, 'Step depends on non-existent step'); } }); } }); return { nodes, edges }; } /** * Check for circular dependencies using DFS */ hasCircularDependencies(graph) { const visited = new Set(); const recursionStack = new Set(); const hasCycle = (nodeId) => { visited.add(nodeId); recursionStack.add(nodeId); const node = graph.nodes.get(nodeId); if (!node) return false; // Check all dependents for (const dependent of node.dependents) { if (!visited.has(dependent)) { if (hasCycle(dependent)) { return true; } } else if (recursionStack.has(dependent)) { // Found a back edge (cycle) return true; } } recursionStack.delete(nodeId); return false; }; // Check each unvisited node for (const [nodeId] of graph.nodes) { if (!visited.has(nodeId)) { if (hasCycle(nodeId)) { return true; } } } return false; } /** * Perform topological sort using Kahn's algorithm */ topologicalSort(steps, graph) { const sorted = []; const stepMap = new Map(steps.map(s => [s.id, s])); // Calculate in-degrees const inDegree = new Map(); graph.nodes.forEach((node, id) => { inDegree.set(id, node.dependencies.length); }); // Queue of nodes with no dependencies const queue = []; inDegree.forEach((degree, nodeId) => { if (degree === 0) { queue.push(nodeId); } }); // Process queue while (queue.length > 0) { const nodeId = queue.shift(); const step = stepMap.get(nodeId); if (step) { sorted.push(step); } // Reduce in-degree for dependents const node = graph.nodes.get(nodeId); if (node) { node.dependents.forEach(depId => { const currentDegree = inDegree.get(depId) || 0; inDegree.set(depId, currentDegree - 1); if (currentDegree - 1 === 0) { queue.push(depId); } }); } } // Check if all nodes were processed if (sorted.length !== steps.length) { throw new Error('Failed to sort all steps - possible circular dependency'); } return sorted; } /** * Group steps into batches that can run in parallel */ groupIntoBatches(sortedSteps, graph) { const batches = []; const processedSteps = new Set(); let batchNumber = 0; while (processedSteps.size < sortedSteps.length) { const batchSteps = []; // Find all steps that can be executed in this batch for (const step of sortedSteps) { if (processedSteps.has(step.id)) { continue; } // Check if all dependencies have been processed const node = graph.nodes.get(step.id); if (node && node.dependencies.every(dep => processedSteps.has(dep))) { batchSteps.push(step); } } if (batchSteps.length === 0) { throw new Error('Unable to create batch - dependency deadlock'); } // Mark steps as processed batchSteps.forEach(step => processedSteps.add(step.id)); // Create batch batches.push({ batch_number: batchNumber++, steps: batchSteps, can_parallelize: batchSteps.length > 1, estimated_duration_ms: this.estimateBatchDuration(batchSteps) }); } return batches; } /** * Estimate execution time for batches */ estimateExecutionTime(batches) { return batches.reduce((total, batch) => { return total + batch.estimated_duration_ms; }, 0); } /** * Estimate duration for a batch */ estimateBatchDuration(steps) { // Base estimate per step type const stepDurations = steps.map(step => { switch (step.type) { case 'template': return 2000; // 2 seconds for API calls case 'plugin': return 1000; // 1 second for plugin execution case 'wait': return step.wait?.duration || 0; case 'conditional': case 'loop': return 500; // 0.5 seconds for logic evaluation default: return 1000; } }); // For parallel batches, use the maximum duration // For sequential, sum all durations return Math.max(...stepDurations); } /** * Resolve entity reference during execution */ async resolveEntityReference(reference, state, entityRouter) { this.logger.debug({ reference }, 'Resolving entity reference'); // Handle different reference patterns if (reference.startsWith('${') && reference.endsWith('}')) { const expr = reference.slice(2, -1); // Handle find: pattern if (expr.startsWith('find:')) { return await this.findEntity(expr, state, entityRouter); } // Handle function calls like toLowerCase(variable) const functionMatch = expr.match(/^(\w+)\((.+)\)$/); if (functionMatch) { const [, functionName, argument] = functionMatch; return this.executeFunction(functionName, argument, state); } // Handle JSONPath expression const result = this.evaluateJSONPath(expr, state); console.log(`📋 [DependencyResolver] evaluateJSONPath(${expr}) returned: ${result}`); return result; } // Return literal value return reference; } /** * Find entity by pattern */ async findEntity(pattern, state, entityRouter) { // Pattern format: find:entity_type:field=value const parts = pattern.split(':'); if (parts.length < 3) { throw new Error(`Invalid find pattern: ${pattern}`); } const entityType = parts[1]; const condition = parts[2]; const [field, value] = condition.split('='); if (!entityRouter) { throw new Error('Entity router not available for entity lookup'); } // List entities and find match const entities = await entityRouter.listEntities({ entity_type: entityType, project_id: state.parameters.project_id }); const match = entities.find((e) => e[field] === value); if (!match) { throw new Error(`Entity not found: ${pattern}`); } return match.id; } /** * Evaluate JSONPath expression */ evaluateJSONPath(path, state) { const context = { parameters: state.parameters, outputs: state.outputs, variables: state.variables, steps: this.convertStepsToObject(state.steps) }; try { // First try direct parameter lookup for simple cases if (!path.includes('.') && !path.includes('[')) { console.log(`📋 [DependencyResolver] Simple path lookup: ${path}`); console.log(`📋 [DependencyResolver] In parameters: ${state.parameters[path]}`); console.log(`📋 [DependencyResolver] In variables: ${state.variables[path]}`); if (state.parameters[path] !== undefined) { console.log(`📋 [DependencyResolver] Found in parameters, returning: ${state.parameters[path]}`); return state.parameters[path]; } if (state.variables[path] !== undefined) { console.log(`📋 [DependencyResolver] Found in variables, returning: ${state.variables[path]}`); return state.variables[path]; } } // Handle step references like "create_event.entity_id" if (path.includes('.')) { const parts = path.split('.'); if (parts.length === 2) { const [stepId, field] = parts; console.log(`📋 [DependencyResolver] Resolving step reference: ${path}`); console.log(`📋 [DependencyResolver] Step ID: ${stepId}, Field: ${field}`); console.log(`📋 [DependencyResolver] Available steps:`, Array.from(state.steps.keys())); // Check if this is a step reference const stepState = state.steps.get(stepId); console.log(`📋 [DependencyResolver] Step state found: ${!!stepState}`); if (stepState) { console.log(`📋 [DependencyResolver] Step status: ${stepState.status}`); console.log(`📋 [DependencyResolver] Has result: ${!!stepState.result}`); } if (stepState && stepState.result) { // Handle entity_id specially if (field === 'entity_id') { // Check various possible locations for entity_id if (stepState.result.data?.data?.id) { return stepState.result.data.data.id; } if (stepState.result.data?.id) { return stepState.result.data.id; } if (stepState.result.id) { return stepState.result.id; } } // Try to find the field in the result if (stepState.result.data?.data?.[field] !== undefined) { return stepState.result.data.data[field]; } if (stepState.result.data?.[field] !== undefined) { return stepState.result.data[field]; } if (stepState.result[field] !== undefined) { return stepState.result[field]; } } // Also check variables for created entities // Try different key formats used by StepExecutor const possibleKeys = [ `created_${stepId}`, // created_event_conversion `created_event_${stepId}`, // created_event_event_conversion `created_page_${stepId}`, // created_page_page_home `created_audience_${stepId}`, // created_audience_audience_level_1 `created_campaign_${stepId}` // created_campaign_campaign_1 ]; console.log(`📋 [DependencyResolver] Checking state.variables with keys:`, possibleKeys); console.log(`📋 [DependencyResolver] Available variables:`, Object.keys(state.variables)); console.log(`📋 [DependencyResolver] Full state.variables:`, JSON.stringify(state.variables, null, 2)); for (const variableKey of possibleKeys) { if (state.variables[variableKey]) { console.log(`📋 [DependencyResolver] Found entity in state.variables with key: ${variableKey}`); const entity = state.variables[variableKey]; if (field === 'entity_id') { // Check multiple possible locations for entity ID const entityId = entity.entityId || entity.entity_id || entity.id; if (entityId) { console.log(`📋 [DependencyResolver] Returning entity ID: ${entityId}`); return entityId; } } if (entity[field] !== undefined) { console.log(`📋 [DependencyResolver] Returning entity.${field}: ${entity[field]}`); return entity[field]; } } } } } // Then try JSONPath evaluation const result = JSONPath.query(context, `$.${path}`); // If no result, also try as a parameter path if (result.length === 0 && state.parameters[path] !== undefined) { return state.parameters[path]; } return result.length === 1 ? result[0] : result.length === 0 ? undefined : result; } catch (error) { this.logger.warn({ path, error: error instanceof Error ? error.message : String(error) }, 'Failed to evaluate JSONPath'); // Fallback: try direct parameter access if (state.parameters[path] !== undefined) { return state.parameters[path]; } return undefined; } } /** * Convert steps map to object for JSONPath */ convertStepsToObject(steps) { const obj = {}; steps.forEach((value, key) => { obj[key] = value; }); return obj; } /** * Execute a function on a resolved value */ executeFunction(functionName, argument, state) { // First resolve the argument const resolvedArg = this.evaluateJSONPath(argument, state); this.logger.debug({ functionName, argument, resolvedArg }, 'Executing function'); // Execute the function switch (functionName.toLowerCase()) { case 'tolowercase': case 'lowercase': return String(resolvedArg).toLowerCase(); case 'touppercase': case 'uppercase': return String(resolvedArg).toUpperCase(); case 'trim': return String(resolvedArg).trim(); case 'replace': // For more complex functions, we'd need to parse multiple arguments throw new Error('replace() function requires additional implementation'); default: throw new Error(`Unknown function: ${functionName}`); } } /** * Resolve all references in an object */ async resolveReferences(obj, state, entityRouter) { console.log(`📌 [DependencyResolver] resolveReferences called with:`, JSON.stringify(obj, null, 2)); if (typeof obj === 'string') { // Handle string templates with multiple variables if (obj.includes('${')) { let result = obj; const templateRegex = /\$\{([^}]+)\}/g; const matches = [...obj.matchAll(templateRegex)]; for (const match of matches) { const fullMatch = match[0]; const varName = match[1]; this.logger.debug({ template: obj, variable: varName, parameters: state.parameters, variables: state.variables }, 'Resolving template variable'); // Resolve the variable const resolved = await this.resolveEntityReference(fullMatch, state, entityRouter); this.logger.debug({ variable: varName, resolved: resolved }, 'Template variable resolved'); // Replace in the string if (resolved !== undefined && resolved !== null) { result = result.replace(fullMatch, String(resolved)); } else { // Mark unresolved variables for later handling console.log(`⚠️ [DependencyResolver] Could not resolve template variable: ${fullMatch}`); } } // If the string still contains unresolved template variables, return a special marker if (result.includes('${')) { console.log(`⚠️ [DependencyResolver] String contains unresolved variables: ${result}`); return { __unresolved__: true, original: result }; } return result; } return obj; } if (Array.isArray(obj)) { const resolved = await Promise.all(obj.map(item => this.resolveReferences(item, state, entityRouter))); // Filter out unresolved items from arrays const filtered = resolved.filter(item => { if (item && typeof item === 'object' && item.__unresolved__) { console.log(`⚠️ [DependencyResolver] Filtering out unresolved array item: ${item.original}`); return false; } // Also check if item is a string that still contains unresolved variables if (typeof item === 'string' && item.includes('${')) { console.log(`⚠️ [DependencyResolver] Filtering out unresolved string from array: ${item}`); return false; } return true; }); return filtered; } if (obj && typeof obj === 'object') { // Special handling for ref objects used in orchestration templates // Convert {"ref": {"id": "12345"}} to just "12345" if (obj.ref && obj.ref.id && Object.keys(obj).length === 1) { console.log(`🔴🔴🔴 [DependencyResolver] CRITICAL: Resolving ref object to ID: ${obj.ref.id}`); console.log(`🔴🔴🔴 [DependencyResolver] CRITICAL: Original object was: ${JSON.stringify(obj)}`); return obj.ref.id; } const resolved = {}; for (const [key, value] of Object.entries(obj)) { const resolvedValue = await this.resolveReferences(value, state, entityRouter); // Skip unresolved object properties if (resolvedValue && typeof resolvedValue === 'object' && resolvedValue.__unresolved__) { console.log(`⚠️ [DependencyResolver] Skipping unresolved property ${key}: ${resolvedValue.original}`); continue; } resolved[key] = resolvedValue; } return resolved; } return obj; } /** * Resolve ONLY JSONPath variables like ${step.field} - don't touch data structures * This is the MINIMAL transformation needed for orchestration */ async resolveJSONPathOnly(obj, state) { console.log(`🔹 [DependencyResolver] resolveJSONPathOnly called with:`, JSON.stringify(obj, null, 2)); if (typeof obj === 'string' && obj.includes('${')) { // Only replace ${step.field} with actual values from state let result = obj; const templateRegex = /\$\{([^}]+)\}/g; const matches = [...obj.matchAll(templateRegex)]; for (const match of matches) { const fullMatch = match[0]; // "${create_page.entity_id}" const varPath = match[1]; // "create_page.entity_id" console.log(`🔹 [DependencyResolver] Resolving JSONPath variable: ${fullMatch} (path: ${varPath})`); // Check if this is a function call like toLowerCase(base_name) const functionMatch = varPath.match(/^(\w+)\((.+)\)$/); let resolved; if (functionMatch) { const [, functionName, argument] = functionMatch; console.log(`🔹 [DependencyResolver] Detected function call: ${functionName}(${argument})`); resolved = this.executeFunction(functionName, argument, state); } else { // Use existing evaluateJSONPath method for simple variables resolved = this.evaluateJSONPath(varPath, state); } console.log(`🔹 [DependencyResolver] Resolved ${fullMatch} to: ${resolved}`); if (resolved !== undefined && resolved !== null) { // CRITICAL FIX: If the entire string is just the variable, return the original type if (obj === fullMatch) { console.log(`🔹 [DependencyResolver] Returning original type for standalone variable`); return resolved; // Return with original type preserved } // Otherwise, we're doing string interpolation, so convert to string result = result.replace(fullMatch, String(resolved)); } else { console.log(`⚠️ [DependencyResolver] Could not resolve JSONPath variable: ${fullMatch}`); } } console.log(`🔹 [DependencyResolver] String resolution result: "${obj}" → "${result}"`); return result; } if (Array.isArray(obj)) { console.log(`🔹 [DependencyResolver] Processing array with ${obj.length} items`); const resolved = await Promise.all(obj.map((item, index) => { console.log(`🔹 [DependencyResolver] Processing array item ${index}:`, item); return this.resolveJSONPathOnly(item, state); })); console.log(`🔹 [DependencyResolver] Array resolution complete:`, resolved); return resolved; } if (obj && typeof obj === 'object') { console.log(`🔹 [DependencyResolver] Processing object with keys: ${Object.keys(obj).join(', ')}`); const resolved = {}; for (const [key, value] of Object.entries(obj)) { console.log(`🔹 [DependencyResolver] Processing object property "${key}":`, value); resolved[key] = await this.resolveJSONPathOnly(value, state); } console.log(`🔹 [DependencyResolver] Object resolution complete:`, resolved); return resolved; } // Return primitives unchanged - NO TRANSFORMATION console.log(`🔹 [DependencyResolver] Returning primitive unchanged:`, obj); return obj; } } //# sourceMappingURL=DependencyResolver.js.map