import { randomUUID } from 'node:crypto'; import { EventEmitter } from 'node:events'; import { execa } from 'execa'; import { config } from '../config.js'; import { logger } from './logger.js'; class ProcessManager { processes = new Map(); start(options) { if (this.runningCount() >= config.maxConcurrentProcesses) { throw new Error(`Refusing to start another process: already at the max of ${config.maxConcurrentProcesses} concurrent managed processes. Stop one first with stop_process.`); } const id = randomUUID(); const emitter = new EventEmitter(); const subprocess = execa(options.command, options.args, { cwd: options.cwd, env: options.env, reject: false, stdin: 'ignore', forceKillAfterDelay: 5000, }); const managed = { id, label: options.label, command: options.command, args: options.args, cwd: options.cwd, pid: subprocess.pid, status: 'running', exitCode: null, startedAt: new Date().toISOString(), emitter, logs: [], subprocess, }; const appendLog = (chunk) => { const text = chunk.toString(); for (const line of text.split(/\r?\n/)) { if (line.length === 0) continue; managed.logs.push(line); if (managed.logs.length > config.maxBufferedLogLines) { managed.logs.splice(0, managed.logs.length - config.maxBufferedLogLines); } emitter.emit('log', line); } }; subprocess.stdout?.on('data', appendLog); subprocess.stderr?.on('data', appendLog); subprocess .then((result) => { managed.status = managed.status === 'stopped' ? 'stopped' : 'exited'; managed.exitCode = result.exitCode ?? null; managed.exitedAt = new Date().toISOString(); emitter.emit('exit'); }) .catch((error) => { managed.status = 'error'; managed.exitedAt = new Date().toISOString(); appendLog(`[process error] ${error instanceof Error ? error.message : String(error)}`); emitter.emit('exit'); logger.error(`managed process ${id} failed`, { error: error instanceof Error ? error.message : error }); }); this.processes.set(id, managed); return this.toInfo(managed); } /** * Resolves once a log line matches `pattern`, the process exits, or `timeoutMs` elapses. * Used by start_dev_server so it can report the actual "ready" URL instead of guessing. */ waitForLog(id, pattern, timeoutMs) { const managed = this.require(id); const existing = managed.logs.find((line) => pattern.test(line)); if (existing) return Promise.resolve({ matchedLine: existing, timedOut: false }); return new Promise((resolve) => { const cleanup = () => { clearTimeout(timer); managed.emitter.off('log', onLog); managed.emitter.off('exit', onExit); }; const onLog = (line) => { if (pattern.test(line)) { cleanup(); resolve({ matchedLine: line, timedOut: false }); } }; const onExit = () => { cleanup(); resolve({ matchedLine: null, timedOut: false }); }; const timer = setTimeout(() => { cleanup(); resolve({ matchedLine: null, timedOut: true }); }, timeoutMs); managed.emitter.on('log', onLog); managed.emitter.on('exit', onExit); }); } stop(id) { const managed = this.require(id); if (managed.status === 'running') { managed.status = 'stopped'; managed.subprocess.kill('SIGTERM'); } return this.toInfo(managed); } getLogs(id, tail) { const managed = this.require(id); const logs = tail && tail > 0 ? managed.logs.slice(-tail) : managed.logs.slice(); return { info: this.toInfo(managed), logs }; } list() { return Array.from(this.processes.values()).map((p) => this.toInfo(p)); } stopAll() { for (const managed of this.processes.values()) { if (managed.status === 'running') { managed.subprocess.kill('SIGTERM'); } } } runningCount() { return Array.from(this.processes.values()).filter((p) => p.status === 'running').length; } require(id) { const managed = this.processes.get(id); if (!managed) { throw new Error(`Unknown processId "${id}". It may have already exited and been forgotten, or never existed.`); } return managed; } toInfo(p) { return { id: p.id, label: p.label, command: p.command, args: p.args, cwd: p.cwd, pid: p.pid, status: p.status, exitCode: p.exitCode, startedAt: p.startedAt, exitedAt: p.exitedAt, }; } } export const processManager = new ProcessManager(); //# sourceMappingURL=processManager.js.map