import * as plugins from '../plugins.js'; import { EventEmitter } from 'events'; import { Logger, ProcessError, handleError } from '../shared/common/utils.errorhandler.js'; import type { IProcessLog } from '../shared/protocol/ipc.types.js'; export interface IProcessWrapperOptions { command: string; args?: string[]; cwd: string; env?: NodeJS.ProcessEnv; name: string; logBuffer?: number; // Number of log lines to keep in memory (default: 100) } export class ProcessWrapper extends EventEmitter { private process: plugins.childProcess.ChildProcess | null = null; private options: IProcessWrapperOptions; private logs: IProcessLog[] = []; private logBufferSize: number; private startTime: Date | null = null; private logger: Logger; private nextSeq: number = 0; private runId: string = ''; private stdoutRemainder: string = ''; private stderrRemainder: string = ''; constructor(options: IProcessWrapperOptions) { super(); this.options = options; this.logBufferSize = options.logBuffer || 100; this.logger = new Logger(`ProcessWrapper:${options.name}`); this.runId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; } /** * Start the wrapped process */ public start(): void { this.addSystemLog('Starting process...'); try { this.logger.debug(`Starting process: ${this.options.command}`); if (this.options.args && this.options.args.length > 0) { this.process = plugins.childProcess.spawn( this.options.command, this.options.args, { cwd: this.options.cwd, env: { ...process.env, ...(this.options.env || {}) }, stdio: ['ignore', 'pipe', 'pipe'], // We need to pipe stdout and stderr }, ); } else { // Use shell mode to allow a full command string this.process = plugins.childProcess.spawn(this.options.command, { cwd: this.options.cwd, env: { ...process.env, ...(this.options.env || {}) }, stdio: ['ignore', 'pipe', 'pipe'], // We need to pipe stdout and stderr shell: true, }); } this.startTime = new Date(); // Handle process exit this.process.on('exit', (code, signal) => { const exitMessage = `Process exited with code ${code}, signal ${signal}`; this.logger.info(exitMessage); this.addSystemLog(exitMessage); // Clear remainder buffers on exit this.stdoutRemainder = ''; this.stderrRemainder = ''; this.emit('exit', code, signal); }); // Handle errors this.process.on('error', (error) => { const processError = new ProcessError( error.message, 'ERR_PROCESS_EXECUTION', { command: this.options.command, pid: this.process?.pid }, ); this.logger.error(processError); this.addSystemLog(`Process error: ${processError.toString()}`); this.emit('error', processError); }); // Capture stdout if (this.process.stdout) { if (process.env.TSPM_DEBUG) { console.error( `[ProcessWrapper] Setting up stdout listener for process ${this.process.pid}`, ); } this.process.stdout.on('data', (data) => { if (process.env.TSPM_DEBUG) { console.error( `[ProcessWrapper] Received stdout data from PID ${this.process?.pid}: ${data .toString() .substring(0, 100)}`, ); } // Add data to remainder buffer and split by newlines const text = this.stdoutRemainder + data.toString(); const lines = text.split('\n'); // The last element might be a partial line this.stdoutRemainder = lines.pop() || ''; // Process complete lines for (const line of lines) { if (process.env.TSPM_DEBUG) { console.error(`[ProcessWrapper] Processing stdout line: ${line}`); } this.logger.debug(`Captured stdout: ${line}`); this.addLog('stdout', line); } }); // Flush remainder on stream end this.process.stdout.on('end', () => { if (this.stdoutRemainder) { this.logger.debug(`Flushing stdout remainder: ${this.stdoutRemainder}`); this.addLog('stdout', this.stdoutRemainder); this.stdoutRemainder = ''; } }); } else { this.logger.warn('Process stdout is null'); } // Capture stderr if (this.process.stderr) { this.process.stderr.on('data', (data) => { // Add data to remainder buffer and split by newlines const text = this.stderrRemainder + data.toString(); const lines = text.split('\n'); // The last element might be a partial line this.stderrRemainder = lines.pop() || ''; // Process complete lines for (const line of lines) { this.addLog('stderr', line); } }); // Flush remainder on stream end this.process.stderr.on('end', () => { if (this.stderrRemainder) { this.addLog('stderr', this.stderrRemainder); this.stderrRemainder = ''; } }); } this.addSystemLog(`Process started with PID ${this.process.pid}`); this.logger.info(`Process started with PID ${this.process.pid}`); this.emit('start', this.process.pid); } catch (error: Error | unknown) { const processError = error instanceof ProcessError ? error : new ProcessError( error instanceof Error ? error.message : String(error), 'ERR_PROCESS_START_FAILED', { command: this.options.command }, ); this.logger.error(processError); this.addSystemLog(`Failed to start process: ${processError.toString()}`); this.emit('error', processError); throw processError; } } /** * Stop the wrapped process */ public stop(): void { if (!this.process) { this.logger.debug('Stop called but no process is running'); this.addSystemLog('No process running'); return; } this.logger.info('Stopping process...'); this.addSystemLog('Stopping process...'); // First try SIGTERM for graceful shutdown if (this.process.pid) { try { this.logger.debug(`Sending SIGTERM to process ${this.process.pid}`); process.kill(this.process.pid, 'SIGTERM'); // Give it 5 seconds to shut down gracefully setTimeout((): void => { if (this.process && this.process.pid) { this.logger.warn( `Process ${this.process.pid} did not exit gracefully, force killing...`, ); this.addSystemLog( 'Process did not exit gracefully, force killing...', ); try { process.kill(this.process.pid, 'SIGKILL'); } catch (error: Error | unknown) { // Process might have exited between checks this.logger.debug( `Failed to send SIGKILL, process probably already exited: ${ error instanceof Error ? error.message : String(error) }`, ); } } }, 5000); } catch (error: Error | unknown) { const processError = new ProcessError( error instanceof Error ? error.message : String(error), 'ERR_PROCESS_STOP_FAILED', { pid: this.process.pid }, ); this.logger.error(processError); this.addSystemLog(`Error stopping process: ${processError.toString()}`); } } } /** * Get the process ID if running */ public getPid(): number | null { return this.process?.pid || null; } /** * Get the current logs */ public getLogs(limit: number = this.logBufferSize): IProcessLog[] { // Return the most recent logs up to the limit return this.logs.slice(-limit); } /** * Get uptime in milliseconds */ public getUptime(): number { if (!this.startTime) return 0; return Date.now() - this.startTime.getTime(); } /** * Check if the process is currently running */ public isRunning(): boolean { return this.process !== null && typeof this.process.exitCode !== 'number'; } /** * Add a log entry from stdout or stderr */ private addLog(type: 'stdout' | 'stderr', message: string): void { const log: IProcessLog = { timestamp: new Date(), type, message, seq: this.nextSeq++, runId: this.runId, }; this.logs.push(log); // Trim logs if they exceed buffer size if (this.logs.length > this.logBufferSize) { this.logs = this.logs.slice(-this.logBufferSize); } // Emit log event for potential handlers this.emit('log', log); } /** * Add a system log entry (not from the process itself) */ private addSystemLog(message: string): void { const log: IProcessLog = { timestamp: new Date(), type: 'system', message, seq: this.nextSeq++, runId: this.runId, }; this.logs.push(log); // Trim logs if they exceed buffer size if (this.logs.length > this.logBufferSize) { this.logs = this.logs.slice(-this.logBufferSize); } // Emit log event for potential handlers this.emit('log', log); } }