scripts-orchestrator
Version:
A powerful script orchestrator for running parallel commands with dependency management, background processes, and health checks
507 lines (439 loc) • 19.4 kB
JavaScript
import { spawn } from 'child_process';
import fs from 'fs';
import path from 'path';
import { log } from './logger.js';
import { HealthCheck } from './health-check.js';
export class ProcessManager {
constructor() {
this.logger = log;
this.backgroundProcesses = [];
this.backgroundProcessesDetails = [];
this.logFolder = 'scripts-orchestrator-logs'; // Default log folder
}
setLogFolder(logFolder) {
this.logFolder = logFolder;
this.logger.verbose(`Log folder set to: ${logFolder}`);
}
addBackgroundProcess({ command, url, startedByScript, process_tracking, kill_command }) {
this.logger.verbose(`Adding background process: ${command} (${url})`);
this.backgroundProcessesDetails.push({
command,
url,
startedByScript,
process_tracking,
kill_command,
});
}
async runCommand({ cmd, logFile, background = false, healthCheck = null, kill_command = null, isRetry = false }) {
const baseDir = this.logFolder ? path.resolve(this.logFolder) : process.cwd();
const LOGS_DIR = path.join(baseDir, 'scripts-orchestrator-logs');
const LOG_FILE = logFile || path.join(LOGS_DIR, `${cmd}.log`);
try {
if (!fs.existsSync(LOGS_DIR)) {
this.logger.verbose(`Creating logs directory at ${LOGS_DIR}`);
fs.mkdirSync(LOGS_DIR, { recursive: true });
}
if (!isRetry) {
this.logger.verbose(`Clearing log file at ${LOG_FILE}`);
fs.writeFileSync(LOG_FILE, ''); // Clear the log file
} else {
this.logger.verbose(`Appending to existing log file at ${LOG_FILE} (retry attempt)`);
}
} catch (error) {
this.logger.error(`Failed to setup log file: ${error.message}`);
return Promise.resolve({ success: false, output: '' });
}
return new Promise((resolve) => {
this.logger.info(`Running: npm run ${cmd}`);
// Create isolated environment for each process
const isolatedEnv = this.createIsolatedEnvironment({ command: cmd });
const options = {
shell: true,
detached: background,
stdio: ['ignore', 'pipe', 'pipe'],
cwd: process.cwd(),
env: isolatedEnv,
windowsHide: true,
...(background ? { processGroup: true } : {}),
};
//this.logger.verbose(`Process options: ${JSON.stringify(options, null, 2)}`);
try {
this.logger.verbose(`Spawning process with command: npm run ${cmd}`);
const processInstance = spawn('npm', ['run', cmd], options);
processInstance.on('error', (error) => {
this.logger.error(`Failed to start process: ${error.message}`);
//this.logger.verbose(`Process error details: ${JSON.stringify(error, null, 2)}`);
resolve({ success: false, output: '' });
});
if (background) {
const processGroupId = processInstance.pid;
this.logger.verbose(`Background process spawned with PID: ${processGroupId}`);
// Track process exit for background processes
let processExited = false;
let processExitCode = null;
processInstance.on('exit', (code, signal) => {
processExited = true;
processExitCode = code;
this.logger.verbose(`Background process ${cmd} (PID: ${processGroupId}) exited with code: ${code}, signal: ${signal}`);
});
processInstance.stdout.on('data', (data) => {
try {
fs.appendFileSync(LOG_FILE, data.toString());
} catch (error) {
this.logger.error(`Failed to write to log file: ${error.message}`);
}
});
processInstance.stderr.on('data', (data) => {
try {
fs.appendFileSync(LOG_FILE, data.toString());
} catch (error) {
this.logger.error(`Failed to write to log file: ${error.message}`);
}
});
const verifyProcess = async () => {
const maxAttempts = 5;
const baseDelay = 1000;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
// First check if the process has already exited with an error
if (processExited && processExitCode !== 0) {
this.logger.error(`Background process ${cmd} exited with code ${processExitCode}`);
let output = '';
try {
output = fs.readFileSync(LOG_FILE, 'utf8');
this.logger.verbose(`Process output: ${output}`);
} catch (error) {
this.logger.error(`Failed to read log file: ${error.message}`);
}
return { success: false, output };
}
this.logger.verbose(`Verifying process ${processGroupId} (attempt ${attempt}/${maxAttempts})`);
process.kill(processGroupId, 0);
this.logger.verbose(`Process ${processGroupId} is running`);
// Wait a bit more to ensure the process doesn't exit immediately after verification
await new Promise((resolve) => setTimeout(resolve, 500));
// Check again if the process exited during our wait
if (processExited && processExitCode !== 0) {
this.logger.error(`Background process ${cmd} exited with code ${processExitCode} shortly after starting`);
let output = '';
try {
output = fs.readFileSync(LOG_FILE, 'utf8');
this.logger.verbose(`Process output: ${output}`);
} catch (error) {
this.logger.error(`Failed to read log file: ${error.message}`);
}
return { success: false, output };
}
this.backgroundProcesses.push(processGroupId);
this.backgroundProcessesDetails.push({
command: cmd,
pgid: processGroupId,
startTime: Date.now(),
url: healthCheck?.url,
startedByScript: true,
kill_command,
});
this.logger.verbose(`Unreferencing process ${processGroupId}`);
processInstance.unref();
this.logger.verbose(
`Background process started: npm run ${cmd} (PGID: ${processGroupId})`,
);
return { success: true, output: '' };
} catch (error) {
if (attempt === maxAttempts) {
this.logger.error(`Failed to start background process: npm run ${cmd}`);
this.logger.verbose(`Final verification attempt failed: ${error.message}`);
return { success: false, output: '' };
}
this.logger.verbose(`Verification attempt ${attempt} failed: ${error.message}`);
this.logger.verbose(`Waiting ${baseDelay * Math.pow(2, attempt - 1)}ms before next attempt`);
await new Promise((resolve) =>
setTimeout(resolve, baseDelay * Math.pow(2, attempt - 1)),
);
}
}
return { success: false, output: '' };
};
verifyProcess().then(resolve);
} else {
processInstance.stdout.on('data', (data) => {
try {
fs.appendFileSync(LOG_FILE, data.toString());
} catch (error) {
this.logger.error(`Failed to write to log file: ${error.message}`);
}
});
processInstance.stderr.on('data', (data) => {
try {
fs.appendFileSync(LOG_FILE, data.toString());
} catch (error) {
this.logger.error(`Failed to write to log file: ${error.message}`);
}
});
processInstance.on('close', async (code) => {
let output = '';
try {
output = fs.readFileSync(LOG_FILE, 'utf8');
} catch (error) {
this.logger.error(`Failed to read log file: ${error.message}`);
}
if (code !== 0) {
this.logger.error(`Failed: npm run ${cmd} (exit code: ${code})`);
this.logger.verbose(`Process output: ${output}`);
resolve({ success: false, output });
} else {
this.logger.success(`Completed: npm run ${cmd}`);
resolve({ success: true, output });
}
});
}
} catch (error) {
this.logger.error(`Failed to spawn process: ${error.message}`);
//this.logger.verbose(`Spawn error details: ${JSON.stringify(error, null, 2)}`);
resolve({ success: false, output: '' });
}
});
}
createIsolatedEnvironment({ command }) {
// Create a deep copy to avoid any reference sharing
const baseEnv = JSON.parse(JSON.stringify(process.env));
// Set standard environment variables
const isolatedEnv = {
...baseEnv,
NODE_ENV: process.env.NODE_ENV || 'development',
// Add command-specific environment isolation
SCRIPTS_ORCHESTRATOR_COMMAND: command,
SCRIPTS_ORCHESTRATOR_PID: process.pid.toString(),
// Force fresh PATH to avoid any dynamic modifications
PATH: process.env.PATH,
// Ensure npm/node paths are isolated
npm_config_cache: path.join(process.cwd(), 'node_modules/.cache/npm'),
// Prevent npm from sharing config between parallel processes
npm_config_progress: 'false',
npm_config_loglevel: 'error',
};
// Remove any potentially problematic environment variables
delete isolatedEnv.npm_lifecycle_event;
delete isolatedEnv.npm_lifecycle_script;
return isolatedEnv;
}
async cleanup() {
try {
this.logger.info('\nCleaning up background processes...');
// Debug: Log the number of processes we're tracking
this.logger.info(`- Found ${this.backgroundProcessesDetails.length} background processes to clean up`);
// Debug: Log each process details
this.backgroundProcessesDetails.forEach(({ command, pgid, url, startedByScript, kill_command }, index) => {
this.logger.verbose(`- Process ${index + 1}: command=${command}, pgid=${pgid}, url=${url}, startedByScript=${startedByScript}, kill_command=${kill_command}`);
});
const killPromises = this.backgroundProcessesDetails.map(
async ({ command, pgid, url, startedByScript, kill_command }) => {
await this.cleanupProcess({ command, pgid, url, startedByScript, kill_command });
},
);
await Promise.allSettled(killPromises);
this.backgroundProcesses = [];
this.backgroundProcessesDetails = [];
} catch (error) {
this.logger.error(`Cleanup failed: ${error.message}`);
}
}
async cleanupCommand(commandName) {
this.logger.info(`\nCleaning up processes for command: ${commandName}`);
// Find processes for this specific command
const commandProcesses = this.backgroundProcessesDetails.filter(
({ command }) => command === commandName
);
if (commandProcesses.length === 0) {
this.logger.verbose(`- No background processes found for command: ${commandName}`);
return;
}
this.logger.verbose(`- Found ${commandProcesses.length} background processes for command: ${commandName}`);
const killPromises = commandProcesses.map(
async ({ command, pgid, url, startedByScript, kill_command }) => {
await this.cleanupProcess({ command, pgid, url, startedByScript, kill_command });
}
);
await Promise.allSettled(killPromises);
// Remove the cleaned up processes from our tracking arrays
this.backgroundProcesses = this.backgroundProcesses.filter(pgid =>
!commandProcesses.some(proc => proc.pgid === pgid)
);
this.backgroundProcessesDetails = this.backgroundProcessesDetails.filter(
({ command }) => command !== commandName
);
}
async cleanupProcess({ command, pgid, url, startedByScript, kill_command }) {
if (!startedByScript) {
this.logger.verbose(
`- Skipping cleanup for ${command} (${url}) as it was not started by this script`,
);
return;
}
this.logger.verbose(`- Processing cleanup for ${command} (kill_command: ${kill_command})`);
// Try custom kill command first if specified
if (kill_command) {
try {
this.logger.verbose(`- Using custom kill command: npm run ${kill_command}`);
const result = await this.runCommand({ cmd: kill_command, logFile: null, background: false });
if (result.success) {
this.logger.verbose(`- Successfully killed ${command} using custom command`);
return;
} else {
this.logger.verbose('- Custom kill command failed, falling back to process signals');
}
} catch (error) {
this.logger.verbose(`- Custom kill command error: ${error.message}, falling back`);
}
} else {
this.logger.verbose(`- No kill_command specified for ${command}, using process signals`);
}
try {
// First try to kill the process group
try {
process.kill(pgid, 0);
} catch (error) {
this.logger.verbose(
`- Process ${command} (PGID: ${pgid}) already terminated`,
);
return;
}
// Cross-platform process termination
const isWindows = process.platform === 'win32';
if (isWindows) {
// Windows: use taskkill to terminate process tree
try {
const killProcess = spawn('taskkill', ['/F', '/T', '/PID', pgid.toString()]);
await new Promise((resolve) => {
killProcess.on('close', resolve);
});
this.logger.verbose(`- Terminated background process: ${command} (PID: ${pgid})`);
return;
} catch (killError) {
this.logger.verbose(`- Failed to use taskkill, falling back to process.kill: ${killError.message}`);
}
}
// Unix/Linux/macOS or Windows fallback: Try SIGTERM first
process.kill(pgid, 'SIGTERM');
await new Promise((resolve, reject) => {
let timeout, checkInterval;
timeout = setTimeout(() => {
if (checkInterval) clearInterval(checkInterval);
reject(new Error('Process termination timeout'));
}, 5000);
checkInterval = setInterval(() => {
try {
process.kill(pgid, 0);
} catch (error) {
if (checkInterval) clearInterval(checkInterval);
if (timeout) clearTimeout(timeout);
resolve();
}
}, 100);
});
this.logger.verbose(
`- Terminated background process: ${command} (PGID: ${pgid})`,
);
} catch (error) {
this.logger.verbose(`- Failed to terminate process group: ${error.message}`);
}
// Check if the URL is still responding after termination attempt
if (url) {
try {
const urlObj = new URL(url);
const port = urlObj.port || (urlObj.protocol === 'https:' ? '443' : '80');
// Use shared HTTP utility for cross-platform compatibility
const urlResult = await HealthCheck.makeHttpRequest(url, 2000);
if (urlResult.success && urlResult.statusCode === 200) {
this.logger.verbose(`- URL ${url} is still responding after termination, finding process on port ${port}`);
// Find and kill process using the port - cross-platform approach
try {
const isWindows = process.platform === 'win32';
let findPortCmd, findPortArgs;
if (isWindows) {
// Windows: use netstat
findPortCmd = 'netstat';
findPortArgs = ['-ano'];
} else {
// Unix/Linux/macOS: use lsof
findPortCmd = 'lsof';
findPortArgs = ['-i', `:${port}`, '-t'];
}
const findProcess = spawn(findPortCmd, findPortArgs);
const result = await new Promise((resolve) => {
let output = '';
findProcess.stdout.on('data', (data) => {
output += data.toString();
});
findProcess.on('close', (code) => {
resolve({ code, output });
});
});
if (result.code === 0 && result.output.trim()) {
let pids = [];
if (isWindows) {
// Parse netstat output to find PIDs for the specific port
const lines = result.output.split('\n');
for (const line of lines) {
if (line.includes(`:${port} `) && line.includes('LISTENING')) {
const parts = line.trim().split(/\s+/);
const pid = parts[parts.length - 1];
if (pid && !isNaN(pid)) {
pids.push(pid);
}
}
}
} else {
// lsof output is already just PIDs
pids = result.output.trim().split('\n');
}
for (const pid of pids) {
try {
if (isWindows) {
// Windows: use taskkill
const killProcess = spawn('taskkill', ['/F', '/PID', pid]);
await new Promise((resolve) => {
killProcess.on('close', resolve);
});
} else {
// Unix/Linux/macOS: use process.kill
process.kill(parseInt(pid), 'SIGKILL');
}
this.logger.verbose(`- Killed process (PID: ${pid}) using port ${port}`);
} catch (killError) {
if (killError.code !== 'ESRCH') {
this.logger.error(`- Failed to kill process (PID: ${pid}): ${killError.message}`);
}
}
}
}
} catch (portError) {
this.logger.error(`- Failed to find process using port ${port}: ${portError.message}`);
}
}
} catch (error) {
this.logger.verbose(`- URL check failed: ${error.message}`);
}
}
// Final attempt to kill the process group
try {
const isWindows = process.platform === 'win32';
if (isWindows) {
// Windows: force kill with taskkill
const killProcess = spawn('taskkill', ['/F', '/T', '/PID', pgid.toString()]);
await new Promise((resolve) => {
killProcess.on('close', resolve);
});
} else {
// Unix/Linux/macOS: use SIGKILL
process.kill(pgid, 'SIGKILL');
}
} catch (error) {
if (error.code !== 'ESRCH') {
this.logger.error(`- Failed to kill process group: ${error.message}`);
}
}
}
}
// For backward compatibility
export const processManager = new ProcessManager();