UNPKG

scripts-orchestrator

Version:

A powerful script orchestrator for running parallel commands with dependency management, background processes, and health checks

417 lines (372 loc) 14.4 kB
import { processManager } from './process-manager.js'; import { healthCheck } from './health-check.js'; import { log } from './logger.js'; import { GitCache } from './git-cache.js'; export class Orchestrator { constructor(config, startPhase = null, logFolder = null, phases = null, sequential = false, force = false) { this.config = config; this.startPhase = startPhase; this.logFolder = logFolder; this.phases = phases; this.sequential = sequential; this.force = force; this.processManager = processManager; this.healthCheck = healthCheck; this.logger = log; this.failedCommands = []; this.skippedCommands = []; this.commandTimings = new Map(); this.gitCache = new GitCache(logFolder); // Set the log folder in process manager if (logFolder) { this.processManager.setLogFolder(logFolder); } // Flatten commands for easier tracking this.allCommands = this.flattenCommands(config); } flattenCommands(config) { // Handle both old array format and new phases format if (Array.isArray(config)) { return config; } if (config.phases) { return config.phases.flatMap(phase => phase.parallel || []); } return []; } formatDuration(ms) { if (ms < 1000) return `${ms}ms`; const seconds = Math.floor(ms / 1000); const minutes = Math.floor(seconds / 60); const remainingSeconds = seconds % 60; if (minutes > 0) { return `${minutes}m ${remainingSeconds}s`; } return `${seconds}s`; } async executeCommand(commandConfig, visited = new Set()) { const { command, dependencies = [], background = false, status = 'enabled', log, logFile, attempts = 1, retry_command, should_retry, process_tracking = false, health_check, kill_command, env, } = commandConfig; const startTime = Date.now(); // Check for circular dependencies if (visited.has(command)) { this.logger.error( `Circular dependency detected: ${Array.from(visited).join(' -> ')} -> ${command}`, ); this.failedCommands.push(command); this.commandTimings.set(command, Date.now() - startTime); return false; } visited.add(command); // Skip execution if the command is disabled if (status === 'disabled') { this.logger.warn(`Skipping: npm run ${command} (status: disabled)`); this.skippedCommands.push(command); this.commandTimings.set(command, Date.now() - startTime); visited.delete(command); return true; } const checkUrl = health_check?.url; if (checkUrl) { this.logger.info(`Checking if ${checkUrl} is already available...`); const urlAvailable = await this.healthCheck.waitForUrl({url: checkUrl, maxAttempts: 1, silent:true}); if (urlAvailable) { this.logger.verbose(`${checkUrl} is already available. Skipping ${command} start.`); this.processManager.addBackgroundProcess({ command, url: checkUrl, startedByScript: false, process_tracking, kill_command, }); this.commandTimings.set(command, Date.now() - startTime); visited.delete(command); return true; } } // Execute dependencies first for (const dependency of dependencies) { const dependencySuccess = await this.executeCommand(dependency, visited); if (!dependencySuccess) { this.logger.error(`Skipping ${command} due to failed dependency`); this.skippedCommands.push(command); this.commandTimings.set(command, Date.now() - startTime); visited.delete(command); return false; } if (dependency.health_check?.url) { this.logger.info(`Waiting for ${dependency.health_check.url} to be available...`); const urlAvailable = await this.healthCheck.waitForUrl({ url: dependency.health_check.url, maxAttempts: dependency.health_check?.max_attempts || 20, interval: dependency.health_check?.interval || 2000, }); if (!urlAvailable) { this.logger.error( `URL ${dependency.health_check.url} is not available after maximum attempts`, ); this.skippedCommands.push(command); this.commandTimings.set(command, Date.now() - startTime); visited.delete(command); return false; } if (dependency.wait) { this.logger.verbose(`Waiting ${dependency.wait}ms`); await new Promise((resolve) => { setTimeout(() => { this.logger.verbose(`Resolving after a wait of ${dependency.wait}ms`); resolve(true); }, dependency.wait); }); } } } // Execute the main command with retries let result = false; let commandOutput = ''; let commandFailed = false; for (let attempt = 1; attempt <= attempts; attempt++) { if (attempt > 1) { this.logger.warn(`Retrying ${command} (attempt ${attempt}/${attempts})`); await new Promise((resolve) => setTimeout(resolve, 1000)); } const { success, output } = await this.processManager.runCommand({ cmd: attempt === 1 ? command : retry_command || command, logFile: log || logFile, // Prefer 'log' key over 'logFile' for backwards compatibility background, healthCheck: health_check, kill_command, isRetry: attempt > 1, env, }); commandOutput = output; result = success; if (result) { // Remove from failed commands if it was there this.failedCommands = this.failedCommands.filter(cmd => cmd !== command); commandFailed = false; break; } else if (attempt < attempts) { if (should_retry && !should_retry(commandOutput)) { this.logger.warn( `${command} failed but doesn't meet retry criteria. Skipping retry.`, ); commandFailed = true; break; } this.logger.error(`Attempt ${attempt}/${attempts} failed for ${command}`); commandFailed = true; } else { commandFailed = true; } } if (commandFailed) { this.failedCommands.push(command); // Cleanup any background processes for this failed command if (background) { this.logger.warn(`Command ${command} failed after all attempts. Cleaning up background processes.`); try { await this.processManager.cleanupCommand(command); } catch (cleanupError) { this.logger.error(`Failed to cleanup processes for ${command}: ${cleanupError.message}`); } } } this.commandTimings.set(command, Date.now() - startTime); visited.delete(command); return result; } summarizeResults() { this.logger.info('\nCommand Summary:'); let hasFailures = false; this.allCommands.forEach(({ command }) => { const duration = this.commandTimings.get(command); const durationStr = duration ? ` (${this.formatDuration(duration)})` : ''; if (this.failedCommands.includes(command)) { hasFailures = true; // Get the actual log path from process manager const logPath = this.processManager.getLogPath(command); this.logger.error(`- ${command}: ❌${durationStr} (See ${logPath})`); } else if (this.skippedCommands.includes(command)) { hasFailures = true; this.logger.warn(`- ${command}: ⚠️${durationStr} (Skipped due to failed dependency)`); } else { this.logger.success(`- ${command}: ✅${durationStr}`); } }); if (hasFailures) { this.logger.error('\n❌ Some commands failed or were skipped. See details above.'); } else { this.logger.success('\n🎉 All commands executed successfully!'); } } async run() { try { // Check if we should skip execution based on git state (unless forced) if (!this.force) { const shouldSkip = await this.gitCache.shouldSkipExecution(); if (shouldSkip) { this.logger.success('🎉 No changes detected, skipping execution!'); this.logger.info('💡 To force execution, use: --force'); process.exit(0); } } else { this.logger.info('⚡ Force execution enabled, skipping git cache check'); } let hasFailures = false; let phaseFailed = false; let startPhaseFound = false; // Handle both old array format and new phases format if (Array.isArray(this.config)) { // Legacy: Run all commands in parallel or sequential based on flag if (this.sequential) { this.logger.info('🔄 Running in sequential mode'); const results = []; for (const commandConfig of this.config) { const result = await this.executeCommand(commandConfig); results.push(result); if (!result) { hasFailures = true; break; // Stop on first failure in sequential mode } } } else { const tasks = this.config.map((commandConfig) => this.executeCommand(commandConfig), ); const results = await Promise.all(tasks); hasFailures = results.some(result => !result); } } else if (this.config.phases) { // New: Run phases sequentially, commands within phases in parallel or sequential based on flag if (this.sequential) { this.logger.info('🔄 Running in sequential mode'); } for (const phase of this.config.phases) { // Check if we should start from this phase if (this.startPhase && !startPhaseFound) { if (phase.name === this.startPhase) { startPhaseFound = true; this.logger.info(`\n🎯 Starting from phase: ${phase.name}`); } else { // Mark all commands in previous phases as skipped phase.parallel.forEach(({ command }) => { this.skippedCommands.push(command); this.commandTimings.set(command, 0); }); continue; } } // Check if this is an optional phase that should be skipped if (phase.optional === true && this.phases && !this.phases.includes(phase.name)) { this.logger.info(`\n⏭️ Skipping optional phase: ${phase.name} (not explicitly requested)`); // Mark all commands in this phase as skipped phase.parallel.forEach(({ command }) => { this.skippedCommands.push(command); this.commandTimings.set(command, 0); }); continue; } if (phaseFailed) { // Mark all commands in remaining phases as skipped phase.parallel.forEach(({ command }) => { this.skippedCommands.push(command); this.commandTimings.set(command, 0); }); continue; } this.logger.info(`\n🔄 Starting phase: ${phase.name}`); let results; if (this.sequential) { // Run commands sequentially results = []; for (const commandConfig of phase.parallel) { const result = await this.executeCommand(commandConfig); results.push(result); if (!result) { // In sequential mode, stop phase execution on first failure break; } } } else { // Run commands in parallel const tasks = phase.parallel.map((commandConfig) => this.executeCommand(commandConfig), ); results = await Promise.all(tasks); } const phaseHasFailures = results.some(result => !result); if (phaseHasFailures) { hasFailures = true; phaseFailed = true; this.logger.error(`❌ Phase "${phase.name}" completed with failures`); } else { this.logger.success(`✅ Phase "${phase.name}" completed successfully`); } } } // Validate start phase if specified if (this.startPhase && !startPhaseFound) { const availablePhases = this.config.phases.map(p => p.name).join(', '); this.logger.error(`❌ Start phase "${this.startPhase}" not found. Available phases: ${availablePhases}`); process.exit(1); } // Validate phases if specified if (this.phases) { const availablePhases = this.config.phases.map(p => p.name); const invalidPhases = this.phases.filter(phase => !availablePhases.includes(phase)); if (invalidPhases.length > 0) { this.logger.error(`❌ Invalid phases specified: ${invalidPhases.join(', ')}. Available phases: ${availablePhases.join(', ')}`); process.exit(1); } } // Check final status hasFailures = hasFailures || this.failedCommands.length > 0 || this.skippedCommands.length > 0; // Add a small delay to ensure all processes have finished await new Promise((resolve) => setTimeout(resolve, 1000)); this.summarizeResults(); // Cleanup before exit since finally blocks don't run after process.exit() try { await this.processManager.cleanup(); } catch (error) { this.logger.error(`Cleanup failed: ${error.message}`); } // Update git cache on successful execution if (!hasFailures) { await this.gitCache.updateCache(); } // Force exit with appropriate status if (hasFailures) { this.logger.info('Exiting with failure status...'); process.exit(1); } else { this.logger.info('Exiting with success status...'); process.exit(0); } } catch (error) { this.logger.error(`Orchestrator failed: ${error.message}`); // Cleanup on error try { await this.processManager.cleanup(); } catch (cleanupError) { this.logger.error(`Cleanup failed: ${cleanupError.message}`); } process.exit(1); } } }