UNPKG

jay-code

Version:

Streamlined AI CLI orchestration engine with mathematical rigor and enterprise-grade reliability

474 lines (397 loc) 10.9 kB
/** * Dependency graph management for task scheduling */ import type { Task } from '../utils/types.js'; import { TaskDependencyError } from '../utils/errors.js'; import type { ILogger } from '../core/logger.js'; export interface DependencyNode { taskId: string; dependencies: Set<string>; dependents: Set<string>; status: 'pending' | 'ready' | 'running' | 'completed' | 'failed'; } export interface DependencyPath { from: string; to: string; path: string[]; } /** * Manages task dependencies and determines execution order */ export class DependencyGraph { private nodes = new Map<string, DependencyNode>(); private completedTasks = new Set<string>(); constructor(private logger: ILogger) {} /** * Add a task to the dependency graph */ addTask(task: Task): void { if (this.nodes.has(task.id)) { this.logger.warn('Task already exists in dependency graph', { taskId: task.id }); return; } const node: DependencyNode = { taskId: task.id, dependencies: new Set(task.dependencies), dependents: new Set(), status: 'pending', }; // Validate dependencies exist for (const depId of task.dependencies) { if (!this.nodes.has(depId) && !this.completedTasks.has(depId)) { throw new TaskDependencyError(task.id, [depId]); } } // Add node this.nodes.set(task.id, node); // Update dependents for dependencies for (const depId of task.dependencies) { const depNode = this.nodes.get(depId); if (depNode) { depNode.dependents.add(task.id); } } // Check if task is ready if (this.isTaskReady(task.id)) { node.status = 'ready'; } } /** * Remove a task from the dependency graph */ removeTask(taskId: string): void { const node = this.nodes.get(taskId); if (!node) { return; } // Remove from dependents of dependencies for (const depId of node.dependencies) { const depNode = this.nodes.get(depId); if (depNode) { depNode.dependents.delete(taskId); } } // Remove from dependencies of dependents for (const depId of node.dependents) { const depNode = this.nodes.get(depId); if (depNode) { depNode.dependencies.delete(taskId); // Check if dependent is now ready if (this.isTaskReady(depId)) { depNode.status = 'ready'; } } } this.nodes.delete(taskId); } /** * Mark a task as completed */ markCompleted(taskId: string): string[] { const node = this.nodes.get(taskId); if (!node) { this.logger.warn('Task not found in dependency graph', { taskId }); return []; } node.status = 'completed'; this.completedTasks.add(taskId); // Find newly ready tasks const readyTasks: string[] = []; for (const dependentId of node.dependents) { const dependent = this.nodes.get(dependentId); if (dependent && dependent.status === 'pending' && this.isTaskReady(dependentId)) { dependent.status = 'ready'; readyTasks.push(dependentId); } } // Remove from active graph this.removeTask(taskId); return readyTasks; } /** * Mark a task as failed */ markFailed(taskId: string): string[] { const node = this.nodes.get(taskId); if (!node) { return []; } node.status = 'failed'; // Get all dependent tasks that need to be cancelled const toCancelIds = this.getAllDependents(taskId); // Mark all dependents as failed for (const depId of toCancelIds) { const depNode = this.nodes.get(depId); if (depNode) { depNode.status = 'failed'; } } return toCancelIds; } /** * Check if a task is ready to run */ isTaskReady(taskId: string): boolean { const node = this.nodes.get(taskId); if (!node) { return false; } // All dependencies must be completed for (const depId of node.dependencies) { if (!this.completedTasks.has(depId)) { return false; } } return true; } /** * Get all ready tasks */ getReadyTasks(): string[] { const ready: string[] = []; for (const [taskId, node] of this.nodes) { if (node.status === 'ready' || (node.status === 'pending' && this.isTaskReady(taskId))) { ready.push(taskId); node.status = 'ready'; } } return ready; } /** * Get all dependents of a task (recursive) */ getAllDependents(taskId: string): string[] { const visited = new Set<string>(); const dependents: string[] = []; const visit = (id: string) => { if (visited.has(id)) { return; } visited.add(id); const node = this.nodes.get(id); if (!node) { return; } for (const depId of node.dependents) { if (!visited.has(depId)) { dependents.push(depId); visit(depId); } } }; visit(taskId); return dependents; } /** * Detect circular dependencies */ detectCycles(): string[][] { const cycles: string[][] = []; const visited = new Set<string>(); const recursionStack = new Set<string>(); const currentPath: string[] = []; const hasCycle = (taskId: string): boolean => { visited.add(taskId); recursionStack.add(taskId); currentPath.push(taskId); const node = this.nodes.get(taskId); if (!node) { currentPath.pop(); recursionStack.delete(taskId); return false; } for (const depId of node.dependencies) { if (!visited.has(depId)) { if (hasCycle(depId)) { return true; } } else if (recursionStack.has(depId)) { // Found cycle const cycleStart = currentPath.indexOf(depId); const cycle = currentPath.slice(cycleStart); cycle.push(depId); // Complete the cycle cycles.push(cycle); return true; } } currentPath.pop(); recursionStack.delete(taskId); return false; }; // Check all nodes for (const taskId of this.nodes.keys()) { if (!visited.has(taskId)) { hasCycle(taskId); } } return cycles; } /** * Get topological sort of tasks */ topologicalSort(): string[] | null { // Check for cycles first const cycles = this.detectCycles(); if (cycles.length > 0) { this.logger.error('Cannot perform topological sort due to cycles', { cycles }); return null; } const sorted: string[] = []; const visited = new Set<string>(); const visit = (taskId: string) => { if (visited.has(taskId)) { return; } visited.add(taskId); const node = this.nodes.get(taskId); if (!node) { return; } // Visit dependencies first for (const depId of node.dependencies) { if (!visited.has(depId)) { visit(depId); } } sorted.push(taskId); }; // Visit all nodes for (const taskId of this.nodes.keys()) { if (!visited.has(taskId)) { visit(taskId); } } return sorted; } /** * Find critical path (longest path through the graph) */ findCriticalPath(): DependencyPath | null { const paths: DependencyPath[] = []; // Find all paths from tasks with no dependencies to tasks with no dependents const sources = Array.from(this.nodes.entries()) .filter(([_, node]) => node.dependencies.size === 0) .map(([id]) => id); const sinks = Array.from(this.nodes.entries()) .filter(([_, node]) => node.dependents.size === 0) .map(([id]) => id); for (const source of sources) { for (const sink of sinks) { const path = this.findPath(source, sink); if (path) { paths.push({ from: source, to: sink, path }); } } } // Return longest path if (paths.length === 0) { return null; } return paths.reduce((longest, current) => current.path.length > longest.path.length ? current : longest, ); } /** * Find path between two tasks */ private findPath(from: string, to: string): string[] | null { if (from === to) { return [from]; } const visited = new Set<string>(); const queue: Array<{ taskId: string; path: string[] }> = [{ taskId: from, path: [from] }]; while (queue.length > 0) { const { taskId, path } = queue.shift()!; if (visited.has(taskId)) { continue; } visited.add(taskId); const node = this.nodes.get(taskId); if (!node) { continue; } for (const depId of node.dependents) { if (depId === to) { return [...path, to]; } if (!visited.has(depId)) { queue.push({ taskId: depId, path: [...path, depId] }); } } } return null; } /** * Get graph statistics */ getStats(): Record<string, unknown> { const stats = { totalTasks: this.nodes.size, completedTasks: this.completedTasks.size, readyTasks: 0, pendingTasks: 0, runningTasks: 0, failedTasks: 0, avgDependencies: 0, maxDependencies: 0, cycles: this.detectCycles(), }; let totalDeps = 0; for (const node of this.nodes.values()) { totalDeps += node.dependencies.size; stats.maxDependencies = Math.max(stats.maxDependencies, node.dependencies.size); switch (node.status) { case 'ready': stats.readyTasks++; break; case 'pending': stats.pendingTasks++; break; case 'running': stats.runningTasks++; break; case 'failed': stats.failedTasks++; break; } } stats.avgDependencies = this.nodes.size > 0 ? totalDeps / this.nodes.size : 0; return stats; } /** * Export graph to DOT format for visualization */ toDot(): string { let dot = 'digraph TaskDependencies {\n'; dot += ' rankdir=LR;\n'; dot += ' node [shape=box];\n\n'; // Add nodes with status colors for (const [taskId, node] of this.nodes) { let color = 'white'; switch (node.status) { case 'ready': color = 'lightgreen'; break; case 'running': color = 'yellow'; break; case 'completed': color = 'green'; break; case 'failed': color = 'red'; break; } dot += ` "${taskId}" [style=filled, fillcolor=${color}];\n`; } dot += '\n'; // Add edges for (const [taskId, node] of this.nodes) { for (const depId of node.dependencies) { dot += ` "${depId}" -> "${taskId}";\n`; } } dot += '}\n'; return dot; } }