@simonecoelhosfo/optimizely-mcp-server
Version:
Optimizely MCP Server for AI assistants with integrated CLI tools
588 lines • 26.1 kB
JavaScript
/**
* 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