init project
This commit is contained in:
Vendored
+148
@@ -0,0 +1,148 @@
|
||||
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
|
||||
Reference in New Issue
Block a user