import * as plugins from '../plugins.js'; import { EventEmitter } from 'events'; import * as paths from '../paths.js'; import { ProcessMonitor } from './processmonitor.js'; import { LogPersistence } from './logpersistence.js'; import { TspmConfig } from './tspm.config.js'; import { Logger, ProcessError, ConfigError, ValidationError, handleError, } from '../shared/common/utils.errorhandler.js'; import type { IProcessConfig, IProcessInfo, IProcessLog, IMonitorConfig } from '../shared/protocol/ipc.types.js'; import { toProcessId, getNextProcessId } from '../shared/protocol/id.js'; import type { ProcessId } from '../shared/protocol/id.js'; export class ProcessManager extends EventEmitter { public processes: Map = new Map(); public processConfigs: Map = new Map(); public processInfo: Map = new Map(); private processLogs: Map = new Map(); private config: TspmConfig; private configStorageKey = 'processes'; private desiredStateStorageKey = 'desiredStates'; private desiredStates: Map = new Map(); private logger: Logger; constructor() { super(); this.logger = new Logger('Tspm'); this.config = new TspmConfig(); this.loadProcessConfigs(); this.loadDesiredStates(); } /** * Add a process configuration without starting it. * Returns the assigned numeric sequential id. */ public async add(configInput: Omit & { id?: ProcessId }): Promise { // Determine next numeric id const nextId = this.getNextSequentialId(); const config: IProcessConfig = { id: nextId, name: configInput.name || `process-${nextId}`, command: configInput.command, args: configInput.args, projectDir: configInput.projectDir, memoryLimitBytes: configInput.memoryLimitBytes || 512 * 1024 * 1024, monitorIntervalMs: configInput.monitorIntervalMs, env: configInput.env, logBufferSize: configInput.logBufferSize, autorestart: configInput.autorestart ?? true, watch: configInput.watch, watchPaths: configInput.watchPaths, }; // Store config and initial info this.processConfigs.set(config.id, config); this.processInfo.set(config.id, { id: config.id, status: 'stopped', memory: 0, restarts: 0, }); await this.saveProcessConfigs(); await this.setDesiredState(config.id, 'stopped'); return config.id; } /** * Start a new process with the given configuration */ public async start(config: IProcessConfig): Promise { this.logger.info(`Starting process with id '${config.id}'`); // Validate config if (!config.id || !config.command || !config.projectDir) { throw new ValidationError( 'Invalid process configuration: missing required fields', 'ERR_INVALID_CONFIG', { config }, ); } // Check if process with this id already exists if (this.processes.has(config.id)) { throw new ValidationError( `Process with id '${config.id}' already exists`, 'ERR_DUPLICATE_PROCESS', ); } try { // Create and store process config this.processConfigs.set(config.id, config); // Initialize process info this.processInfo.set(config.id, { id: config.id, status: 'stopped', memory: 0, restarts: 0, }); // Create and start process monitor const monitor = new ProcessMonitor({ id: config.id, // Pass the ProcessId for log persistence name: config.name || String(config.id), projectDir: config.projectDir, command: config.command, args: config.args, memoryLimitBytes: config.memoryLimitBytes, monitorIntervalMs: config.monitorIntervalMs, env: config.env, logBufferSize: config.logBufferSize, }); this.processes.set(config.id, monitor); // Set up log event handler to re-emit for pub/sub monitor.on('log', (log: IProcessLog) => { // Store log in our persistent storage if (!this.processLogs.has(config.id)) { this.processLogs.set(config.id, []); } const logs = this.processLogs.get(config.id)!; logs.push(log); // Trim logs if they exceed buffer size (default 1000) const bufferSize = config.logBufferSize || 1000; if (logs.length > bufferSize) { this.processLogs.set(config.id, logs.slice(-bufferSize)); } this.emit('process:log', { processId: config.id, log }); }); // Set up event handler to track PID when process starts monitor.on('start', (pid: number) => { this.updateProcessInfo(config.id, { pid }); }); // Set up event handler to clear PID when process exits monitor.on('exit', () => { this.updateProcessInfo(config.id, { pid: undefined }); }); await monitor.start(); // Wait a moment for the process to spawn and get its PID await new Promise(resolve => setTimeout(resolve, 100)); // Update process info with PID const pid = monitor.getPid(); this.updateProcessInfo(config.id, { status: 'online', pid: pid || undefined }); // Save updated configs await this.saveProcessConfigs(); this.logger.info(`Successfully started process with id '${config.id}'`); } catch (error: Error | unknown) { // Clean up in case of error this.processConfigs.delete(config.id); this.processInfo.delete(config.id); this.processes.delete(config.id); if (error instanceof Error) { this.logger.error(error); throw new ProcessError( `Failed to start process: ${error.message}`, 'ERR_PROCESS_START_FAILED', { id: config.id, command: config.command }, ); } else { const genericError = new ProcessError( `Failed to start process: ${String(error)}`, 'ERR_PROCESS_START_FAILED', { id: config.id }, ); this.logger.error(genericError); throw genericError; } } } /** * Stop a process by id */ public async stop(id: ProcessId): Promise { this.logger.info(`Stopping process with id '${id}'`); const monitor = this.processes.get(id); if (!monitor) { const error = new ValidationError( `Process with id '${id}' not found`, 'ERR_PROCESS_NOT_FOUND', ); this.logger.error(error); throw error; } try { await monitor.stop(); this.updateProcessInfo(id, { status: 'stopped' }); this.logger.info(`Successfully stopped process with id '${id}'`); } catch (error: Error | unknown) { const processError = new ProcessError( `Failed to stop process: ${error instanceof Error ? error.message : String(error)}`, 'ERR_PROCESS_STOP_FAILED', { id }, ); this.logger.error(processError); throw processError; } // Don't remove from the maps, just mark as stopped // This allows it to be restarted later } /** * Restart a process by id */ public async restart(id: ProcessId): Promise { this.logger.info(`Restarting process with id '${id}'`); const monitor = this.processes.get(id); const config = this.processConfigs.get(id); if (!monitor || !config) { const error = new ValidationError( `Process with id '${id}' not found`, 'ERR_PROCESS_NOT_FOUND', ); this.logger.error(error); throw error; } try { // Stop and then start the process await monitor.stop(); // Create a new monitor instance const newMonitor = new ProcessMonitor({ id: config.id, // Pass the ProcessId for log persistence name: config.name || String(config.id), projectDir: config.projectDir, command: config.command, args: config.args, memoryLimitBytes: config.memoryLimitBytes, monitorIntervalMs: config.monitorIntervalMs, env: config.env, logBufferSize: config.logBufferSize, }); // Set up log event handler for the new monitor newMonitor.on('log', (log: IProcessLog) => { // Store log in our persistent storage if (!this.processLogs.has(id)) { this.processLogs.set(id, []); } const logs = this.processLogs.get(id)!; logs.push(log); // Trim logs if they exceed buffer size (default 1000) const bufferSize = config.logBufferSize || 1000; if (logs.length > bufferSize) { this.processLogs.set(id, logs.slice(-bufferSize)); } this.emit('process:log', { processId: id, log }); }); this.processes.set(id, newMonitor); await newMonitor.start(); // Wait a moment for the process to spawn and get its PID await new Promise(resolve => setTimeout(resolve, 100)); // Update restart count and PID const info = this.processInfo.get(id); if (info) { const pid = newMonitor.getPid(); this.updateProcessInfo(id, { status: 'online', pid: pid || undefined, restarts: info.restarts + 1, }); } this.logger.info(`Successfully restarted process with id '${id}'`); } catch (error: Error | unknown) { const processError = new ProcessError( `Failed to restart process: ${error instanceof Error ? error.message : String(error)}`, 'ERR_PROCESS_RESTART_FAILED', { id }, ); this.logger.error(processError); throw processError; } } /** * Delete a process by id */ public async delete(id: ProcessId): Promise { this.logger.info(`Deleting process with id '${id}'`); // Check if process exists if (!this.processConfigs.has(id)) { const error = new ValidationError( `Process with id '${id}' not found`, 'ERR_PROCESS_NOT_FOUND', ); this.logger.error(error); throw error; } // Stop the process if it's running try { if (this.processes.has(id)) { await this.stop(id); } // Remove from all maps this.processes.delete(id); this.processConfigs.delete(id); this.processInfo.delete(id); this.processLogs.delete(id); // Delete persisted logs from disk const logPersistence = new LogPersistence(); await logPersistence.deleteLogs(id); // Save updated configs await this.saveProcessConfigs(); await this.removeDesiredState(id); this.logger.info(`Successfully deleted process with id '${id}'`); } catch (error: Error | unknown) { // Even if stop fails, we should still try to delete the configuration try { this.processes.delete(id); this.processConfigs.delete(id); this.processInfo.delete(id); this.processLogs.delete(id); // Delete persisted logs from disk even if stop failed const logPersistence = new LogPersistence(); await logPersistence.deleteLogs(id); await this.saveProcessConfigs(); await this.removeDesiredState(id); this.logger.info( `Successfully deleted process with id '${id}' after stopping failure`, ); } catch (deleteError: Error | unknown) { const configError = new ConfigError( `Failed to delete process configuration: ${deleteError instanceof Error ? deleteError.message : String(deleteError)}`, 'ERR_CONFIG_DELETE_FAILED', { id }, ); this.logger.error(configError); throw configError; } } } /** * Get a list of all process infos */ public list(): IProcessInfo[] { const infos = Array.from(this.processInfo.values()); // Enrich with live data from monitors for (const info of infos) { const monitor = this.processes.get(info.id); if (monitor) { // Update with current PID if the monitor is running const pid = monitor.getPid(); if (pid) { info.pid = pid; } // Update uptime if available const uptime = monitor.getUptime(); if (uptime !== null) { info.uptime = uptime; } // Update restart count info.restarts = monitor.getRestartCount(); // Update status based on actual running state if (monitor.isRunning()) { info.status = 'online'; } } } return infos; } /** * Get detailed info for a specific process */ public describe( id: ProcessId, ): { config: IProcessConfig; info: IProcessInfo } | null { const config = this.processConfigs.get(id); const info = this.processInfo.get(id); if (!config || !info) { return null; } return { config, info }; } /** * Get process logs */ public getLogs(id: ProcessId, limit?: number): IProcessLog[] { // Get logs from the ProcessMonitor instance const monitor = this.processes.get(id); if (monitor) { const logs = monitor.getLogs(limit); return logs; } // Fallback to stored logs if monitor doesn't exist const logs = this.processLogs.get(id) || []; if (limit && limit > 0) { return logs.slice(-limit); } return logs; } /** * Start all saved processes */ public async startAll(): Promise { for (const [id, config] of this.processConfigs.entries()) { if (!this.processes.has(id)) { await this.start(config); } } } /** * Stop all running processes */ public async stopAll(): Promise { for (const id of this.processes.keys()) { await this.stop(id); } } /** * Restart all processes */ public async restartAll(): Promise { for (const id of this.processes.keys()) { await this.restart(id); } } /** * Update the info for a process */ private updateProcessInfo(id: ProcessId, update: Partial): void { const info = this.processInfo.get(id); if (info) { this.processInfo.set(id, { ...info, ...update }); } } /** * Compute next sequential numeric id based on existing configs */ /** * Sync process stats from monitors to processInfo */ public syncProcessStats(): void { for (const [id, monitor] of this.processes.entries()) { const info = this.processInfo.get(id); if (info) { const pid = monitor.getPid(); const updates: Partial = {}; // Update PID if available if (pid) { updates.pid = pid; } // Update uptime if available const uptime = monitor.getUptime(); if (uptime !== null) { updates.uptime = uptime; } // Update restart count updates.restarts = monitor.getRestartCount(); // Update status based on actual running state updates.status = monitor.isRunning() ? 'online' : 'stopped'; this.updateProcessInfo(id, updates); } } } private getNextSequentialId(): ProcessId { return getNextProcessId(this.processConfigs.keys()); } /** * Save all process configurations to config storage */ private async saveProcessConfigs(): Promise { this.logger.debug('Saving process configurations to storage'); try { const configs = Array.from(this.processConfigs.values()); await this.config.writeKey( this.configStorageKey, JSON.stringify(configs), ); this.logger.debug(`Saved ${configs.length} process configurations`); } catch (error: Error | unknown) { const configError = new ConfigError( `Failed to save process configurations: ${error instanceof Error ? error.message : String(error)}`, 'ERR_CONFIG_SAVE_FAILED', ); this.logger.error(configError); throw configError; } } // === Desired state persistence === private async saveDesiredStates(): Promise { try { const obj: Record = {}; for (const [id, state] of this.desiredStates.entries()) { obj[String(id)] = state; } await this.config.writeKey( this.desiredStateStorageKey, JSON.stringify(obj), ); } catch (error: any) { this.logger.warn( `Failed to save desired states: ${error?.message || String(error)}`, ); } } public async loadDesiredStates(): Promise { try { const raw = await this.config.readKey(this.desiredStateStorageKey); if (raw) { const obj = JSON.parse(raw) as Record; this.desiredStates = new Map( Object.entries(obj).map(([k, v]) => [toProcessId(k), v] as const) ); this.logger.debug( `Loaded desired states for ${this.desiredStates.size} processes`, ); } } catch (error: any) { this.logger.warn( `Failed to load desired states: ${error?.message || String(error)}`, ); } } public async setDesiredState( id: ProcessId, state: IProcessInfo['status'], ): Promise { this.desiredStates.set(id, state); await this.saveDesiredStates(); } public async removeDesiredState(id: ProcessId): Promise { this.desiredStates.delete(id); await this.saveDesiredStates(); } public async setDesiredStateForAll( state: IProcessInfo['status'], ): Promise { for (const id of this.processConfigs.keys()) { this.desiredStates.set(id, state); } await this.saveDesiredStates(); } public async startDesired(): Promise { for (const [id, config] of this.processConfigs.entries()) { const desired = this.desiredStates.get(id); if (desired === 'online' && !this.processes.has(id)) { try { await this.start(config); } catch (e) { this.logger.warn( `Failed to start desired process ${id}: ${ (e as Error)?.message || String(e) }`, ); } } } } /** * Load process configurations from config storage */ public async loadProcessConfigs(): Promise { this.logger.debug('Loading process configurations from storage'); try { const configsJson = await this.config.readKey(this.configStorageKey); if (configsJson) { try { const parsed = JSON.parse(configsJson) as Array; this.logger.debug(`Loaded ${parsed.length} process configurations`); for (const raw of parsed) { // Convert legacy string IDs to ProcessId let id: ProcessId; try { id = toProcessId(raw.id); } catch { this.logger.warn( `Skipping invalid process config with non-numeric id '${raw.id || 'unknown'}'`, ); continue; } // Validate config if (!id || !raw.command || !raw.projectDir) { this.logger.warn( `Skipping invalid process config for id '${id || 'unknown'}'`, ); continue; } const config: IProcessConfig = { ...raw, id }; this.processConfigs.set(id, config); // Initialize process info this.processInfo.set(id, { id: id, status: 'stopped', memory: 0, restarts: 0, }); } } catch (parseError: Error | unknown) { const configError = new ConfigError( `Failed to parse process configurations: ${parseError instanceof Error ? parseError.message : String(parseError)}`, 'ERR_CONFIG_PARSE_FAILED', ); this.logger.error(configError); throw configError; } } else { this.logger.info('No saved process configurations found'); } } catch (error: Error | unknown) { // Only throw if it's not the "no configs found" case if (error instanceof ConfigError) { throw error; } // If no configs found or error reading, just continue with empty configs this.logger.info( 'No saved process configurations found or error reading them', ); } } /** * Reset: stop all running processes and clear all saved configurations */ public async reset(): Promise<{ stopped: ProcessId[]; removed: ProcessId[]; failed: Array<{ id: ProcessId; error: string }>; }> { this.logger.info('Resetting TSPM: stopping all processes and clearing configs'); const removed = Array.from(this.processConfigs.keys()); const stopped: ProcessId[] = []; const failed: Array<{ id: ProcessId; error: string }> = []; // Attempt to stop all currently running processes with per-id error collection for (const id of Array.from(this.processes.keys())) { try { await this.stop(id); stopped.push(id); } catch (error: any) { failed.push({ id, error: error?.message || String(error) }); } } // Clear in-memory maps regardless of stop outcomes this.processes.clear(); this.processInfo.clear(); this.processConfigs.clear(); this.desiredStates.clear(); // Remove persisted configs try { await this.config.deleteKey(this.configStorageKey); await this.config.deleteKey(this.desiredStateStorageKey).catch(() => {}); this.logger.debug('Cleared persisted process configurations'); } catch (error) { // Fallback: write empty list if deleteKey fails for any reason this.logger.warn('deleteKey failed, writing empty process list instead'); await this.saveProcessConfigs().catch(() => {}); } this.logger.info('TSPM reset complete'); return { stopped, removed, failed }; } }