@kadi.build/core 0.9.0 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +424 -1
- package/agent.json +19 -0
- package/dist/agent-json.d.ts +231 -0
- package/dist/agent-json.d.ts.map +1 -0
- package/dist/agent-json.js +554 -0
- package/dist/agent-json.js.map +1 -0
- package/dist/client.d.ts +34 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +50 -0
- package/dist/client.js.map +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/process-manager.d.ts +235 -0
- package/dist/process-manager.d.ts.map +1 -0
- package/dist/process-manager.js +647 -0
- package/dist/process-manager.js.map +1 -0
- package/dist/stdio-framing.d.ts +88 -0
- package/dist/stdio-framing.d.ts.map +1 -0
- package/dist/stdio-framing.js +194 -0
- package/dist/stdio-framing.js.map +1 -0
- package/dist/transports/native.d.ts.map +1 -1
- package/dist/transports/native.js +3 -2
- package/dist/transports/native.js.map +1 -1
- package/dist/transports/stdio.d.ts.map +1 -1
- package/dist/transports/stdio.js +3 -181
- package/dist/transports/stdio.js.map +1 -1
- package/dist/types.d.ts +256 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/utils.d.ts +107 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +212 -0
- package/dist/utils.js.map +1 -0
- package/package.json +3 -1
- package/scripts/symlink.mjs +131 -0
- package/src/agent-json.ts +655 -0
- package/src/client.ts +56 -0
- package/src/errors.ts +15 -0
- package/src/index.ts +32 -0
- package/src/process-manager.ts +821 -0
- package/src/stdio-framing.ts +227 -0
- package/src/transports/native.ts +3 -2
- package/src/transports/stdio.ts +4 -221
- package/src/types.ts +277 -0
- package/src/utils.ts +246 -0
|
@@ -0,0 +1,821 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ProcessManager for kadi-core
|
|
3
|
+
*
|
|
4
|
+
* Manages background child processes with three execution modes:
|
|
5
|
+
*
|
|
6
|
+
* - **headless**: Fire-and-forget. No communication channel.
|
|
7
|
+
* - **piped**: stdout/stderr are streamed back and buffered.
|
|
8
|
+
* - **bridge**: Full JSON-RPC stdio bridge (Content-Length framing).
|
|
9
|
+
*
|
|
10
|
+
* Primary use case: Running long tasks (builds, deploys, inference) in the
|
|
11
|
+
* background so the main agent's event loop stays free for broker heartbeats.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```typescript
|
|
15
|
+
* import { ProcessManager } from '@kadi.build/core';
|
|
16
|
+
*
|
|
17
|
+
* const pm = new ProcessManager();
|
|
18
|
+
*
|
|
19
|
+
* // Headless build
|
|
20
|
+
* const build = await pm.spawn('build', {
|
|
21
|
+
* command: 'docker', args: ['build', '.'], mode: 'headless',
|
|
22
|
+
* });
|
|
23
|
+
* const status = pm.getStatus('build');
|
|
24
|
+
*
|
|
25
|
+
* // Piped deploy with live output
|
|
26
|
+
* const deploy = await pm.spawn('deploy', {
|
|
27
|
+
* command: 'kadi', args: ['deploy'], mode: 'piped',
|
|
28
|
+
* });
|
|
29
|
+
* deploy.on('stdout', (data) => console.log(data));
|
|
30
|
+
*
|
|
31
|
+
* // Bridge mode for interactive workers
|
|
32
|
+
* const worker = await pm.spawn('worker', {
|
|
33
|
+
* command: 'python3', args: ['worker.py'], mode: 'bridge',
|
|
34
|
+
* });
|
|
35
|
+
* const result = await worker.request('run-inference', { prompt: 'hello' });
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { spawn, type ChildProcess } from 'child_process';
|
|
40
|
+
import { EventEmitter } from 'events';
|
|
41
|
+
import type {
|
|
42
|
+
ProcessMode,
|
|
43
|
+
ProcessState,
|
|
44
|
+
SpawnOptions,
|
|
45
|
+
ProcessInfo,
|
|
46
|
+
ProcessExitInfo,
|
|
47
|
+
ProcessOutput,
|
|
48
|
+
ProcessListOptions,
|
|
49
|
+
ProcessPruneOptions,
|
|
50
|
+
} from './types.js';
|
|
51
|
+
import { KadiError } from './errors.js';
|
|
52
|
+
import { StdioMessageReader, StdioMessageWriter, type JsonRpcNotification } from './stdio-framing.js';
|
|
53
|
+
import * as protocol from './protocol.js';
|
|
54
|
+
|
|
55
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
56
|
+
// CONSTANTS
|
|
57
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
58
|
+
|
|
59
|
+
const DEFAULT_KILL_GRACE_PERIOD = 5000;
|
|
60
|
+
const DEFAULT_MAX_OUTPUT_BUFFER = 10 * 1024 * 1024; // 10 MB
|
|
61
|
+
|
|
62
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
63
|
+
// MANAGED PROCESS
|
|
64
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A handle to a spawned background process.
|
|
68
|
+
*
|
|
69
|
+
* Provides event subscription, lifecycle management, and (in bridge mode)
|
|
70
|
+
* JSON-RPC request/response communication.
|
|
71
|
+
*/
|
|
72
|
+
export class ManagedProcess extends EventEmitter {
|
|
73
|
+
/** Unique identifier */
|
|
74
|
+
readonly id: string;
|
|
75
|
+
|
|
76
|
+
/** OS process ID */
|
|
77
|
+
readonly pid: number;
|
|
78
|
+
|
|
79
|
+
/** Execution mode */
|
|
80
|
+
readonly mode: ProcessMode;
|
|
81
|
+
|
|
82
|
+
/** Internal state */
|
|
83
|
+
private _state: ProcessState = 'running';
|
|
84
|
+
private _exitCode: number | null = null;
|
|
85
|
+
private _signal: string | null = null;
|
|
86
|
+
private _startedAt: Date;
|
|
87
|
+
private _endedAt: Date | null = null;
|
|
88
|
+
|
|
89
|
+
/** Command info for getInfo() */
|
|
90
|
+
private readonly _command: string;
|
|
91
|
+
private readonly _args: string[];
|
|
92
|
+
private readonly _cwd: string;
|
|
93
|
+
|
|
94
|
+
/** Output buffering (piped mode) */
|
|
95
|
+
private _stdout = '';
|
|
96
|
+
private _stderr = '';
|
|
97
|
+
private readonly _maxOutputBuffer: number;
|
|
98
|
+
|
|
99
|
+
/** Bridge mode internals */
|
|
100
|
+
private _reader: StdioMessageReader | null = null;
|
|
101
|
+
private _writer: StdioMessageWriter | null = null;
|
|
102
|
+
private _bridgeIdCounter = 1;
|
|
103
|
+
|
|
104
|
+
/** The underlying child process */
|
|
105
|
+
private readonly _proc: ChildProcess;
|
|
106
|
+
|
|
107
|
+
/** Timeout timer for auto-kill */
|
|
108
|
+
private _timeoutTimer: ReturnType<typeof setTimeout> | null = null;
|
|
109
|
+
|
|
110
|
+
/** Kill grace period */
|
|
111
|
+
private readonly _killGracePeriod: number;
|
|
112
|
+
|
|
113
|
+
/** Waiters for the exit event */
|
|
114
|
+
private readonly _exitWaiters: Array<{
|
|
115
|
+
resolve: (info: ProcessExitInfo) => void;
|
|
116
|
+
}> = [];
|
|
117
|
+
|
|
118
|
+
constructor(
|
|
119
|
+
id: string,
|
|
120
|
+
proc: ChildProcess,
|
|
121
|
+
options: SpawnOptions,
|
|
122
|
+
) {
|
|
123
|
+
super();
|
|
124
|
+
|
|
125
|
+
this.id = id;
|
|
126
|
+
this._proc = proc;
|
|
127
|
+
this.pid = proc.pid!;
|
|
128
|
+
this.mode = options.mode;
|
|
129
|
+
this._command = options.command;
|
|
130
|
+
this._args = options.args ?? [];
|
|
131
|
+
this._cwd = options.cwd ?? process.cwd();
|
|
132
|
+
this._startedAt = new Date();
|
|
133
|
+
this._killGracePeriod = options.killGracePeriod ?? DEFAULT_KILL_GRACE_PERIOD;
|
|
134
|
+
this._maxOutputBuffer = options.maxOutputBuffer ?? DEFAULT_MAX_OUTPUT_BUFFER;
|
|
135
|
+
|
|
136
|
+
// Set up auto-kill timeout
|
|
137
|
+
if (options.timeout && options.timeout > 0) {
|
|
138
|
+
this._timeoutTimer = setTimeout(() => {
|
|
139
|
+
this.kill().catch(() => {});
|
|
140
|
+
}, options.timeout);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Set up process exit handler
|
|
144
|
+
proc.on('exit', (code, signal) => {
|
|
145
|
+
this.handleExit(code, signal);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
proc.on('error', (error) => {
|
|
149
|
+
if (this._state === 'running') {
|
|
150
|
+
this._state = 'errored';
|
|
151
|
+
this._endedAt = new Date();
|
|
152
|
+
this.emit('error', error);
|
|
153
|
+
this.resolveExitWaiters();
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// Set up output streaming based on mode
|
|
158
|
+
if (options.mode === 'piped') {
|
|
159
|
+
this.setupPipedMode(proc);
|
|
160
|
+
} else if (options.mode === 'bridge') {
|
|
161
|
+
this.setupBridgeMode(proc, options);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ─────────────────────────────────────────────────────────────────
|
|
166
|
+
// STATE GETTERS
|
|
167
|
+
// ─────────────────────────────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
/** Current lifecycle state */
|
|
170
|
+
get state(): ProcessState { return this._state; }
|
|
171
|
+
|
|
172
|
+
/** Whether the process is still running */
|
|
173
|
+
get isRunning(): boolean { return this._state === 'running'; }
|
|
174
|
+
|
|
175
|
+
// ─────────────────────────────────────────────────────────────────
|
|
176
|
+
// MODE SETUP
|
|
177
|
+
// ─────────────────────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
private setupPipedMode(proc: ChildProcess): void {
|
|
180
|
+
if (proc.stdout) {
|
|
181
|
+
proc.stdout.on('data', (chunk: Buffer) => {
|
|
182
|
+
const str = chunk.toString('utf-8');
|
|
183
|
+
this.appendOutput('stdout', str);
|
|
184
|
+
this.emit('stdout', str);
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (proc.stderr) {
|
|
189
|
+
proc.stderr.on('data', (chunk: Buffer) => {
|
|
190
|
+
const str = chunk.toString('utf-8');
|
|
191
|
+
this.appendOutput('stderr', str);
|
|
192
|
+
this.emit('stderr', str);
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private setupBridgeMode(proc: ChildProcess, _options: SpawnOptions): void {
|
|
198
|
+
if (!proc.stdin || !proc.stdout) {
|
|
199
|
+
throw new KadiError(
|
|
200
|
+
'Failed to get stdio streams for bridge mode',
|
|
201
|
+
'PROCESS_BRIDGE_ERROR',
|
|
202
|
+
{ processId: this.id },
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
this._reader = new StdioMessageReader(proc.stdout);
|
|
207
|
+
this._writer = new StdioMessageWriter(proc.stdin);
|
|
208
|
+
|
|
209
|
+
// Route notifications to event emitter
|
|
210
|
+
this._reader.setNotificationHandler((notification: JsonRpcNotification) => {
|
|
211
|
+
this.emit('notification', notification.method, notification.params);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// Also capture stderr for debugging
|
|
215
|
+
if (proc.stderr) {
|
|
216
|
+
proc.stderr.on('data', (chunk: Buffer) => {
|
|
217
|
+
const str = chunk.toString('utf-8');
|
|
218
|
+
this.appendOutput('stderr', str);
|
|
219
|
+
this.emit('stderr', str);
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ─────────────────────────────────────────────────────────────────
|
|
225
|
+
// OUTPUT BUFFERING
|
|
226
|
+
// ─────────────────────────────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
private appendOutput(stream: 'stdout' | 'stderr', data: string): void {
|
|
229
|
+
if (stream === 'stdout') {
|
|
230
|
+
this._stdout += data;
|
|
231
|
+
if (this._stdout.length > this._maxOutputBuffer) {
|
|
232
|
+
// Ring buffer: keep the most recent data
|
|
233
|
+
this._stdout = this._stdout.slice(-this._maxOutputBuffer);
|
|
234
|
+
}
|
|
235
|
+
} else {
|
|
236
|
+
this._stderr += data;
|
|
237
|
+
if (this._stderr.length > this._maxOutputBuffer) {
|
|
238
|
+
this._stderr = this._stderr.slice(-this._maxOutputBuffer);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Get all buffered output.
|
|
245
|
+
* Only available in piped and bridge modes.
|
|
246
|
+
*/
|
|
247
|
+
getOutput(): ProcessOutput {
|
|
248
|
+
return { stdout: this._stdout, stderr: this._stderr };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ─────────────────────────────────────────────────────────────────
|
|
252
|
+
// BRIDGE MODE API
|
|
253
|
+
// ─────────────────────────────────────────────────────────────────
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Send a JSON-RPC request and await the response.
|
|
257
|
+
* Only available in bridge mode.
|
|
258
|
+
*
|
|
259
|
+
* @param method - The RPC method to invoke
|
|
260
|
+
* @param params - Parameters to send
|
|
261
|
+
* @param timeout - Timeout in ms (default: 600000)
|
|
262
|
+
* @returns The result from the response
|
|
263
|
+
*
|
|
264
|
+
* @example
|
|
265
|
+
* ```typescript
|
|
266
|
+
* const result = await proc.request('run-inference', { prompt: 'hello' });
|
|
267
|
+
* ```
|
|
268
|
+
*/
|
|
269
|
+
async request(method: string, params?: unknown, timeout?: number): Promise<unknown> {
|
|
270
|
+
if (this.mode !== 'bridge') {
|
|
271
|
+
throw new KadiError(
|
|
272
|
+
'request() is only available in bridge mode',
|
|
273
|
+
'PROCESS_BRIDGE_ERROR',
|
|
274
|
+
{ processId: this.id, mode: this.mode },
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (!this.isRunning) {
|
|
279
|
+
throw new KadiError(
|
|
280
|
+
`Process "${this.id}" is not running (state: ${this._state})`,
|
|
281
|
+
'PROCESS_NOT_RUNNING',
|
|
282
|
+
{ processId: this.id, state: this._state },
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (!this._reader || !this._writer) {
|
|
287
|
+
throw new KadiError(
|
|
288
|
+
'Bridge streams not initialized',
|
|
289
|
+
'PROCESS_BRIDGE_ERROR',
|
|
290
|
+
{ processId: this.id },
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const id = this._bridgeIdCounter++;
|
|
295
|
+
const request = protocol.request(id, method, params ?? {});
|
|
296
|
+
|
|
297
|
+
this._writer.write(request);
|
|
298
|
+
const response = await this._reader.waitForResponse(id, timeout);
|
|
299
|
+
|
|
300
|
+
if (response.error) {
|
|
301
|
+
throw new KadiError(
|
|
302
|
+
response.error.message,
|
|
303
|
+
'PROCESS_BRIDGE_ERROR',
|
|
304
|
+
{ processId: this.id, code: response.error.code, data: response.error.data },
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return response.result;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Send a JSON-RPC notification (no response expected).
|
|
313
|
+
* Only available in bridge mode.
|
|
314
|
+
*/
|
|
315
|
+
notify(method: string, params?: unknown): void {
|
|
316
|
+
if (this.mode !== 'bridge') {
|
|
317
|
+
throw new KadiError(
|
|
318
|
+
'notify() is only available in bridge mode',
|
|
319
|
+
'PROCESS_BRIDGE_ERROR',
|
|
320
|
+
{ processId: this.id, mode: this.mode },
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (!this.isRunning || !this._writer) {
|
|
325
|
+
throw new KadiError(
|
|
326
|
+
`Process "${this.id}" is not running`,
|
|
327
|
+
'PROCESS_NOT_RUNNING',
|
|
328
|
+
{ processId: this.id },
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Notifications have no id
|
|
333
|
+
const notification = {
|
|
334
|
+
jsonrpc: '2.0' as const,
|
|
335
|
+
method,
|
|
336
|
+
params: params ?? {},
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
const json = JSON.stringify(notification);
|
|
340
|
+
const contentLength = Buffer.byteLength(json, 'utf-8');
|
|
341
|
+
const header = `Content-Length: ${contentLength}\r\n\r\n`;
|
|
342
|
+
|
|
343
|
+
this._proc.stdin!.write(header + json);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Write raw data to the process stdin.
|
|
348
|
+
* Available in piped and bridge modes.
|
|
349
|
+
*/
|
|
350
|
+
write(data: string | Buffer): void {
|
|
351
|
+
if (this.mode === 'headless') {
|
|
352
|
+
throw new KadiError(
|
|
353
|
+
'write() is not available in headless mode',
|
|
354
|
+
'PROCESS_BRIDGE_ERROR',
|
|
355
|
+
{ processId: this.id },
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (!this.isRunning || !this._proc.stdin) {
|
|
360
|
+
throw new KadiError(
|
|
361
|
+
`Process "${this.id}" is not running`,
|
|
362
|
+
'PROCESS_NOT_RUNNING',
|
|
363
|
+
{ processId: this.id },
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
this._proc.stdin.write(data);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ─────────────────────────────────────────────────────────────────
|
|
371
|
+
// LIFECYCLE
|
|
372
|
+
// ─────────────────────────────────────────────────────────────────
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Kill this process.
|
|
376
|
+
* Sends SIGTERM, waits for grace period, then SIGKILL.
|
|
377
|
+
*/
|
|
378
|
+
async kill(signal: NodeJS.Signals = 'SIGTERM'): Promise<void> {
|
|
379
|
+
if (!this.isRunning) return;
|
|
380
|
+
|
|
381
|
+
this._proc.kill(signal);
|
|
382
|
+
|
|
383
|
+
await new Promise<void>((resolve) => {
|
|
384
|
+
const forceKillTimer = setTimeout(() => {
|
|
385
|
+
if (this.isRunning) {
|
|
386
|
+
this._proc.kill('SIGKILL');
|
|
387
|
+
}
|
|
388
|
+
resolve();
|
|
389
|
+
}, this._killGracePeriod);
|
|
390
|
+
|
|
391
|
+
this._proc.once('exit', () => {
|
|
392
|
+
clearTimeout(forceKillTimer);
|
|
393
|
+
resolve();
|
|
394
|
+
});
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Wait for the process to exit.
|
|
400
|
+
* Resolves immediately if already exited.
|
|
401
|
+
*/
|
|
402
|
+
waitForExit(): Promise<ProcessExitInfo> {
|
|
403
|
+
if (!this.isRunning) {
|
|
404
|
+
return Promise.resolve({
|
|
405
|
+
exitCode: this._exitCode,
|
|
406
|
+
signal: this._signal,
|
|
407
|
+
duration: this._endedAt
|
|
408
|
+
? this._endedAt.getTime() - this._startedAt.getTime()
|
|
409
|
+
: 0,
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
return new Promise((resolve) => {
|
|
414
|
+
this._exitWaiters.push({ resolve });
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Get a snapshot of this process's info.
|
|
420
|
+
*/
|
|
421
|
+
getInfo(): ProcessInfo {
|
|
422
|
+
const endedAt = this._endedAt;
|
|
423
|
+
return {
|
|
424
|
+
id: this.id,
|
|
425
|
+
pid: this.pid,
|
|
426
|
+
state: this._state,
|
|
427
|
+
mode: this.mode,
|
|
428
|
+
startedAt: this._startedAt,
|
|
429
|
+
endedAt,
|
|
430
|
+
exitCode: this._exitCode,
|
|
431
|
+
signal: this._signal,
|
|
432
|
+
duration: endedAt
|
|
433
|
+
? endedAt.getTime() - this._startedAt.getTime()
|
|
434
|
+
: null,
|
|
435
|
+
command: this._command,
|
|
436
|
+
args: this._args,
|
|
437
|
+
cwd: this._cwd,
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// ─────────────────────────────────────────────────────────────────
|
|
442
|
+
// INTERNAL: EXIT HANDLING
|
|
443
|
+
// ─────────────────────────────────────────────────────────────────
|
|
444
|
+
|
|
445
|
+
private handleExit(code: number | null, signal: string | null): void {
|
|
446
|
+
if (this._timeoutTimer) {
|
|
447
|
+
clearTimeout(this._timeoutTimer);
|
|
448
|
+
this._timeoutTimer = null;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
this._exitCode = code;
|
|
452
|
+
this._signal = signal;
|
|
453
|
+
this._endedAt = new Date();
|
|
454
|
+
|
|
455
|
+
if (this._state === 'running') {
|
|
456
|
+
this._state = signal ? 'killed' : 'exited';
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Cancel any pending bridge requests
|
|
460
|
+
if (this._reader) {
|
|
461
|
+
this._reader.cancelAll(
|
|
462
|
+
new KadiError('Process exited', 'PROCESS_NOT_RUNNING', { processId: this.id }),
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const exitInfo: ProcessExitInfo = {
|
|
467
|
+
exitCode: code,
|
|
468
|
+
signal,
|
|
469
|
+
duration: this._endedAt.getTime() - this._startedAt.getTime(),
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
this.emit('exit', exitInfo);
|
|
473
|
+
this.resolveExitWaiters();
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
private resolveExitWaiters(): void {
|
|
477
|
+
const info: ProcessExitInfo = {
|
|
478
|
+
exitCode: this._exitCode,
|
|
479
|
+
signal: this._signal,
|
|
480
|
+
duration: this._endedAt
|
|
481
|
+
? this._endedAt.getTime() - this._startedAt.getTime()
|
|
482
|
+
: 0,
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
for (const waiter of this._exitWaiters) {
|
|
486
|
+
waiter.resolve(info);
|
|
487
|
+
}
|
|
488
|
+
this._exitWaiters.length = 0;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
493
|
+
// PROCESS MANAGER
|
|
494
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Manages background child processes.
|
|
498
|
+
*
|
|
499
|
+
* Provides spawn, status, kill, and lifecycle management across
|
|
500
|
+
* headless, piped, and bridge execution modes.
|
|
501
|
+
*/
|
|
502
|
+
export class ProcessManager {
|
|
503
|
+
/** Active and recently exited processes */
|
|
504
|
+
private readonly processes: Map<string, ManagedProcess> = new Map();
|
|
505
|
+
|
|
506
|
+
/** Cleanup handler registered with process.on('exit') */
|
|
507
|
+
private cleanupRegistered = false;
|
|
508
|
+
|
|
509
|
+
constructor() {
|
|
510
|
+
this.registerCleanup();
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// ─────────────────────────────────────────────────────────────────
|
|
514
|
+
// SPAWN
|
|
515
|
+
// ─────────────────────────────────────────────────────────────────
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Spawn a new managed background process.
|
|
519
|
+
*
|
|
520
|
+
* @param id - Unique name for this process
|
|
521
|
+
* @param options - Spawn configuration (command, args, mode, etc.)
|
|
522
|
+
* @returns A ManagedProcess handle
|
|
523
|
+
* @throws PROCESS_ALREADY_EXISTS if a running process with this id exists
|
|
524
|
+
* @throws PROCESS_SPAWN_FAILED if the command fails to start
|
|
525
|
+
*
|
|
526
|
+
* @example
|
|
527
|
+
* ```typescript
|
|
528
|
+
* const build = await pm.spawn('build', {
|
|
529
|
+
* command: 'docker',
|
|
530
|
+
* args: ['build', '-t', 'my-app', '.'],
|
|
531
|
+
* cwd: '/path/to/project',
|
|
532
|
+
* mode: 'piped',
|
|
533
|
+
* });
|
|
534
|
+
* ```
|
|
535
|
+
*/
|
|
536
|
+
async spawn(id: string, options: SpawnOptions): Promise<ManagedProcess> {
|
|
537
|
+
// Check for duplicate running process
|
|
538
|
+
const existing = this.processes.get(id);
|
|
539
|
+
if (existing && existing.isRunning) {
|
|
540
|
+
throw new KadiError(
|
|
541
|
+
`Process "${id}" is already running`,
|
|
542
|
+
'PROCESS_ALREADY_EXISTS',
|
|
543
|
+
{
|
|
544
|
+
processId: id,
|
|
545
|
+
pid: existing.pid,
|
|
546
|
+
hint: `Kill it first with pm.kill("${id}") or use a different name`,
|
|
547
|
+
},
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// Replace dead process entry
|
|
552
|
+
if (existing) {
|
|
553
|
+
this.processes.delete(id);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// Determine stdio configuration based on mode
|
|
557
|
+
const stdio = this.getStdioConfig(options.mode);
|
|
558
|
+
|
|
559
|
+
// Spawn the child process
|
|
560
|
+
let proc: ChildProcess;
|
|
561
|
+
try {
|
|
562
|
+
proc = spawn(options.command, options.args ?? [], {
|
|
563
|
+
stdio,
|
|
564
|
+
cwd: options.cwd,
|
|
565
|
+
env: options.env
|
|
566
|
+
? { ...process.env, ...options.env }
|
|
567
|
+
: undefined,
|
|
568
|
+
detached: options.mode === 'headless', // Detach headless processes
|
|
569
|
+
});
|
|
570
|
+
} catch (error) {
|
|
571
|
+
throw new KadiError(
|
|
572
|
+
`Failed to spawn process "${id}": ${options.command}`,
|
|
573
|
+
'PROCESS_SPAWN_FAILED',
|
|
574
|
+
{
|
|
575
|
+
processId: id,
|
|
576
|
+
command: options.command,
|
|
577
|
+
args: options.args,
|
|
578
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
579
|
+
hint: 'Check that the command exists and is executable',
|
|
580
|
+
},
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// Verify PID was assigned
|
|
585
|
+
if (proc.pid === undefined) {
|
|
586
|
+
throw new KadiError(
|
|
587
|
+
`Failed to spawn process "${id}": no PID assigned`,
|
|
588
|
+
'PROCESS_SPAWN_FAILED',
|
|
589
|
+
{ processId: id, command: options.command },
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// Handle spawn errors (e.g., ENOENT — command not found)
|
|
594
|
+
// This must be set up synchronously before the next tick
|
|
595
|
+
const spawnError = await new Promise<Error | null>((resolve) => {
|
|
596
|
+
proc.once('error', (err) => resolve(err));
|
|
597
|
+
// If no error by next tick, the spawn succeeded
|
|
598
|
+
setImmediate(() => resolve(null));
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
if (spawnError) {
|
|
602
|
+
throw new KadiError(
|
|
603
|
+
`Failed to spawn process "${id}": ${spawnError.message}`,
|
|
604
|
+
'PROCESS_SPAWN_FAILED',
|
|
605
|
+
{
|
|
606
|
+
processId: id,
|
|
607
|
+
command: options.command,
|
|
608
|
+
reason: spawnError.message,
|
|
609
|
+
hint: 'Check that the command exists and is executable',
|
|
610
|
+
},
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// Create and track the managed process
|
|
615
|
+
const managed = new ManagedProcess(id, proc, options);
|
|
616
|
+
this.processes.set(id, managed);
|
|
617
|
+
|
|
618
|
+
return managed;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// ─────────────────────────────────────────────────────────────────
|
|
622
|
+
// STATUS & QUERYING
|
|
623
|
+
// ─────────────────────────────────────────────────────────────────
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Get status information for a specific process.
|
|
627
|
+
*
|
|
628
|
+
* @param id - Process identifier
|
|
629
|
+
* @returns ProcessInfo snapshot
|
|
630
|
+
* @throws PROCESS_NOT_FOUND if no process with this id exists
|
|
631
|
+
*/
|
|
632
|
+
getStatus(id: string): ProcessInfo {
|
|
633
|
+
const proc = this.processes.get(id);
|
|
634
|
+
if (!proc) {
|
|
635
|
+
throw new KadiError(
|
|
636
|
+
`Process "${id}" not found`,
|
|
637
|
+
'PROCESS_NOT_FOUND',
|
|
638
|
+
{
|
|
639
|
+
processId: id,
|
|
640
|
+
available: Array.from(this.processes.keys()),
|
|
641
|
+
hint: 'Use pm.list() to see all managed processes',
|
|
642
|
+
},
|
|
643
|
+
);
|
|
644
|
+
}
|
|
645
|
+
return proc.getInfo();
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* Get the ManagedProcess handle for a specific process.
|
|
650
|
+
* Useful when you need to attach events or call bridge methods.
|
|
651
|
+
*
|
|
652
|
+
* @param id - Process identifier
|
|
653
|
+
* @returns The ManagedProcess instance
|
|
654
|
+
* @throws PROCESS_NOT_FOUND if no process with this id exists
|
|
655
|
+
*/
|
|
656
|
+
get(id: string): ManagedProcess {
|
|
657
|
+
const proc = this.processes.get(id);
|
|
658
|
+
if (!proc) {
|
|
659
|
+
throw new KadiError(
|
|
660
|
+
`Process "${id}" not found`,
|
|
661
|
+
'PROCESS_NOT_FOUND',
|
|
662
|
+
{
|
|
663
|
+
processId: id,
|
|
664
|
+
available: Array.from(this.processes.keys()),
|
|
665
|
+
},
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
return proc;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Get buffered output for a process (piped/bridge modes).
|
|
673
|
+
*
|
|
674
|
+
* @param id - Process identifier
|
|
675
|
+
* @returns Buffered stdout and stderr
|
|
676
|
+
*/
|
|
677
|
+
getOutput(id: string): ProcessOutput {
|
|
678
|
+
return this.get(id).getOutput();
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* List all managed processes, optionally filtered by state.
|
|
683
|
+
*
|
|
684
|
+
* @param options - Optional filter criteria
|
|
685
|
+
* @returns Array of ProcessInfo snapshots
|
|
686
|
+
*/
|
|
687
|
+
list(options?: ProcessListOptions): ProcessInfo[] {
|
|
688
|
+
const results: ProcessInfo[] = [];
|
|
689
|
+
|
|
690
|
+
for (const proc of this.processes.values()) {
|
|
691
|
+
const info = proc.getInfo();
|
|
692
|
+
if (options?.state && info.state !== options.state) continue;
|
|
693
|
+
if (options?.mode && info.mode !== options.mode) continue;
|
|
694
|
+
results.push(info);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
return results;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// ─────────────────────────────────────────────────────────────────
|
|
701
|
+
// LIFECYCLE MANAGEMENT
|
|
702
|
+
// ─────────────────────────────────────────────────────────────────
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* Wait for a process to exit.
|
|
706
|
+
*
|
|
707
|
+
* @param id - Process identifier
|
|
708
|
+
* @returns Exit information
|
|
709
|
+
*/
|
|
710
|
+
async waitFor(id: string): Promise<ProcessExitInfo> {
|
|
711
|
+
return this.get(id).waitForExit();
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* Kill a specific process.
|
|
716
|
+
*
|
|
717
|
+
* @param id - Process identifier
|
|
718
|
+
* @param signal - Signal to send (default: SIGTERM)
|
|
719
|
+
*/
|
|
720
|
+
async kill(id: string, signal?: NodeJS.Signals): Promise<void> {
|
|
721
|
+
const proc = this.processes.get(id);
|
|
722
|
+
if (!proc) {
|
|
723
|
+
throw new KadiError(
|
|
724
|
+
`Process "${id}" not found`,
|
|
725
|
+
'PROCESS_NOT_FOUND',
|
|
726
|
+
{ processId: id },
|
|
727
|
+
);
|
|
728
|
+
}
|
|
729
|
+
await proc.kill(signal);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* Kill all running managed processes.
|
|
734
|
+
*/
|
|
735
|
+
async killAll(): Promise<void> {
|
|
736
|
+
const kills: Promise<void>[] = [];
|
|
737
|
+
|
|
738
|
+
for (const proc of this.processes.values()) {
|
|
739
|
+
if (proc.isRunning) {
|
|
740
|
+
kills.push(proc.kill());
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
await Promise.allSettled(kills);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* Remove exited/errored/killed processes from tracking.
|
|
749
|
+
*
|
|
750
|
+
* @param options - Optional criteria for which processes to remove
|
|
751
|
+
*/
|
|
752
|
+
prune(options?: ProcessPruneOptions): void {
|
|
753
|
+
const now = Date.now();
|
|
754
|
+
|
|
755
|
+
for (const [id, proc] of this.processes) {
|
|
756
|
+
if (proc.isRunning) continue;
|
|
757
|
+
|
|
758
|
+
const info = proc.getInfo();
|
|
759
|
+
if (options?.olderThan !== undefined) {
|
|
760
|
+
const endedAt = info.endedAt?.getTime() ?? now;
|
|
761
|
+
const ageSeconds = (now - endedAt) / 1000;
|
|
762
|
+
if (ageSeconds >= options.olderThan) {
|
|
763
|
+
this.processes.delete(id);
|
|
764
|
+
}
|
|
765
|
+
} else {
|
|
766
|
+
// No filter — remove all non-running
|
|
767
|
+
this.processes.delete(id);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* Graceful shutdown: SIGTERM all running processes, wait, then SIGKILL remainders.
|
|
774
|
+
*/
|
|
775
|
+
async shutdown(): Promise<void> {
|
|
776
|
+
await this.killAll();
|
|
777
|
+
this.prune();
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// ─────────────────────────────────────────────────────────────────
|
|
781
|
+
// INTERNAL
|
|
782
|
+
// ─────────────────────────────────────────────────────────────────
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* Determine stdio configuration based on process mode.
|
|
786
|
+
*/
|
|
787
|
+
private getStdioConfig(mode: ProcessMode): Array<'pipe' | 'ignore' | 'inherit'> {
|
|
788
|
+
switch (mode) {
|
|
789
|
+
case 'headless':
|
|
790
|
+
return ['ignore', 'ignore', 'ignore'];
|
|
791
|
+
case 'piped':
|
|
792
|
+
return ['ignore', 'pipe', 'pipe'];
|
|
793
|
+
case 'bridge':
|
|
794
|
+
return ['pipe', 'pipe', 'pipe'];
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* Register a cleanup handler to kill child processes when the parent exits.
|
|
800
|
+
*/
|
|
801
|
+
private registerCleanup(): void {
|
|
802
|
+
if (this.cleanupRegistered) return;
|
|
803
|
+
this.cleanupRegistered = true;
|
|
804
|
+
|
|
805
|
+
const cleanup = () => {
|
|
806
|
+
for (const proc of this.processes.values()) {
|
|
807
|
+
if (proc.isRunning) {
|
|
808
|
+
try {
|
|
809
|
+
proc.kill('SIGTERM').catch(() => {});
|
|
810
|
+
} catch {
|
|
811
|
+
// Best-effort cleanup
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
};
|
|
816
|
+
|
|
817
|
+
process.on('exit', cleanup);
|
|
818
|
+
process.on('SIGTERM', cleanup);
|
|
819
|
+
process.on('SIGINT', cleanup);
|
|
820
|
+
}
|
|
821
|
+
}
|