@orkestrel/process 0.0.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.
@@ -0,0 +1,704 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { spawn, spawnSync } from "node:child_process";
3
+ import { extname } from "node:path";
4
+ import { PROCESS_EVIDENCE, PROCESS_GRACE, PROCESS_OUTPUT, createDuplicateError, createRunError } from "../core/index.js";
5
+ import { createInterface } from "node:readline";
6
+ import { StringDecoder } from "node:string_decoder";
7
+ import { Emitter } from "@orkestrel/emitter";
8
+ //#region src/server/helpers.ts
9
+ /**
10
+ * Signals one owned child process, or its detached process group on a POSIX host.
11
+ *
12
+ * @remarks
13
+ * On a POSIX host a child spawned `detached` leads its own process group, so signalling the
14
+ * negated pid reaches the whole tree. On Windows, or when no pid is available, the signal goes to
15
+ * the child directly. A throw during signalling is swallowed: the process can exit and win the
16
+ * signal race, and the child's close event remains the authoritative terminal state.
17
+ *
18
+ * @param child - The child process signal boundary
19
+ * @param signal - The signal to deliver
20
+ * @returns Nothing
21
+ *
22
+ * @example
23
+ * ```ts
24
+ * killProcess(child, 'SIGTERM')
25
+ * ```
26
+ */
27
+ function killProcess(child, signal) {
28
+ try {
29
+ if (process.platform === "win32" || child.pid === void 0) {
30
+ child.kill(signal);
31
+ return;
32
+ }
33
+ process.kill(-child.pid, signal);
34
+ } catch {}
35
+ }
36
+ /**
37
+ * Trims a buffer to at most `limit` trailing bytes without splitting a UTF-8 sequence.
38
+ *
39
+ * @remarks
40
+ * Keeps the tail — the diagnostic end of a stderr stream. When the cut point lands inside a
41
+ * multibyte sequence, the start advances past the leading continuation bytes so the retained
42
+ * bytes always begin on a code-point boundary.
43
+ *
44
+ * @param buffer - The accumulated bytes
45
+ * @param limit - The maximum retained byte count
46
+ * @returns The trailing bytes, at most `limit` and never starting mid-sequence
47
+ *
48
+ * @example
49
+ * ```ts
50
+ * trimTail(Buffer.from('hello'), 3) // <Buffer 6c 6c 6f>
51
+ * ```
52
+ */
53
+ function trimTail(buffer, limit) {
54
+ if (buffer.byteLength <= limit) return buffer;
55
+ let start = buffer.byteLength - limit;
56
+ while (start < buffer.byteLength) {
57
+ const byte = buffer[start];
58
+ if (byte === void 0 || (byte & 192) !== 128) break;
59
+ start += 1;
60
+ }
61
+ return Buffer.from(buffer.subarray(start));
62
+ }
63
+ /**
64
+ * Trims a buffer to at most `limit` leading bytes without splitting a UTF-8 sequence.
65
+ *
66
+ * @remarks
67
+ * Keeps the head — the captured start of a one-shot run's output. When the cut point lands inside
68
+ * a multibyte sequence, the end retreats to the sequence's start so the retained bytes always end
69
+ * on a code-point boundary and decode without a replacement character.
70
+ *
71
+ * @param buffer - The accumulated bytes
72
+ * @param limit - The maximum retained byte count
73
+ * @returns The leading bytes, at most `limit` and never ending mid-sequence
74
+ *
75
+ * @example
76
+ * ```ts
77
+ * trimHead(Buffer.from('hello'), 3) // <Buffer 68 65 6c>
78
+ * ```
79
+ */
80
+ function trimHead(buffer, limit) {
81
+ if (buffer.byteLength <= limit) return buffer;
82
+ let end = limit;
83
+ while (end > 0) {
84
+ const byte = buffer[end];
85
+ if (byte === void 0 || (byte & 192) !== 128) break;
86
+ end -= 1;
87
+ }
88
+ return Buffer.from(buffer.subarray(0, end));
89
+ }
90
+ /**
91
+ * Checks whether a command file must be spawned through a shell on the current host.
92
+ *
93
+ * @remarks
94
+ * Node v22+ refuses to spawn a Windows `.cmd` or `.bat` batch file directly and returns `EINVAL`,
95
+ * and a bare command name such as `git` resolves through `PATHEXT` to a batch shim rather than an
96
+ * executable. Both cases run through the shell. A POSIX host, and a Windows path carrying a real
97
+ * executable extension such as `.exe`, spawn directly. When the result is `true`, treat the
98
+ * command file and arguments as trusted: shell invocation exposes shell metacharacters.
99
+ *
100
+ * @param file - The command executable name or path
101
+ * @returns True when the file must run through a shell; false when it spawns directly
102
+ *
103
+ * @example
104
+ * ```ts
105
+ * requiresShell('git') // true on Windows, false on POSIX
106
+ * ```
107
+ */
108
+ function requiresShell(file) {
109
+ if (process.platform !== "win32") return false;
110
+ const lower = file.toLowerCase();
111
+ return lower.endsWith(".cmd") || lower.endsWith(".bat") || extname(file) === "";
112
+ }
113
+ /**
114
+ * Renders one command into its diagnostic command line.
115
+ *
116
+ * @param command - The executable and its argument vector
117
+ * @returns The space-joined command line, for a {@link RunResult} and error messages
118
+ *
119
+ * @example
120
+ * ```ts
121
+ * commandLine({ file: 'git', arguments: ['status'] }) // 'git status'
122
+ * ```
123
+ */
124
+ function commandLine(command) {
125
+ return [command.file, ...command.arguments].join(" ");
126
+ }
127
+ /**
128
+ * Merges environment overrides over the current process environment.
129
+ *
130
+ * @remarks
131
+ * Later maps override earlier ones and an `undefined` value unsets a key. The base is the parent
132
+ * process environment, so a child inherits it unless a key is overridden or unset.
133
+ *
134
+ * @param base - The command's own environment overrides
135
+ * @param override - Per-invocation overrides applied last
136
+ * @returns The merged environment for a spawn
137
+ *
138
+ * @example
139
+ * ```ts
140
+ * mergeEnvironment({ TOKEN: 'a' }, { TOKEN: undefined }) // TOKEN unset
141
+ * ```
142
+ */
143
+ function mergeEnvironment(base, override) {
144
+ return {
145
+ ...process.env,
146
+ ...base,
147
+ ...override
148
+ };
149
+ }
150
+ /**
151
+ * Builds one settled {@link RunResult} from a completed one-shot run's captured bytes and exit.
152
+ *
153
+ * @remarks
154
+ * `failed` is derived: a run failed when it timed out, was ended by a signal, or exited with a
155
+ * code other than `0` — a `null` code from a spawn fault is therefore a failure. Both output
156
+ * buffers are byte-bounded by `limit` before decoding.
157
+ *
158
+ * @param command - The diagnostic command line
159
+ * @param stdout - The captured standard output bytes
160
+ * @param stderr - The captured standard error bytes
161
+ * @param code - The exit code, or `null` when a signal or spawn fault ended the run
162
+ * @param signal - The terminating signal, or `null`
163
+ * @param timedOut - If `true`, the run's own timeout elapsed; if `false`, it did not
164
+ * @param limit - The maximum captured bytes for each stream
165
+ * @returns The frozen run outcome
166
+ *
167
+ * @example
168
+ * ```ts
169
+ * buildRunResult('git status', Buffer.from('ok'), Buffer.alloc(0), 0, null, false, 1024)
170
+ * ```
171
+ */
172
+ function buildRunResult(command, stdout, stderr, code, signal, timedOut, limit) {
173
+ return Object.freeze({
174
+ command,
175
+ stdout: trimHead(stdout, limit).toString("utf8"),
176
+ stderr: trimHead(stderr, limit).toString("utf8"),
177
+ code,
178
+ signal,
179
+ failed: timedOut || signal !== null || code !== 0,
180
+ timedOut
181
+ });
182
+ }
183
+ /**
184
+ * Runs one command to completion, buffering its output, and settles with the outcome.
185
+ *
186
+ * @remarks
187
+ * The child is spawned through the cross-platform command resolver ({@link requiresShell}) and, on
188
+ * a POSIX host, detached so its whole process group can be terminated. Standard output and error
189
+ * are each byte-bounded by `limit` (default {@link PROCESS_OUTPUT}). A positive `timeout` arms a
190
+ * timer that terminates the child through `SIGTERM`, then `SIGKILL` after `grace`
191
+ * (default {@link PROCESS_GRACE}); aborting `signal` terminates the same way. The child's
192
+ * `environment` merges over the parent, then `options.environment` on top, and `options.input`
193
+ * overrides `command.input`. Unless `reject` is `false`, a failed run rejects with a
194
+ * {@link createRunError} carrying the {@link RunResult}.
195
+ *
196
+ * @param command - The executable, arguments, and optional environment and input
197
+ * @param options - Working directory, timeout, grace, signal, capture limit, and reject behavior
198
+ * @returns The settled run outcome
199
+ * @throws A {@link ProcessError} carrying the {@link RunResult} when the run failed and `reject` is not `false`
200
+ *
201
+ * @example
202
+ * ```ts
203
+ * const result = await run({ file: 'git', arguments: ['status'] }, { workspace: process.cwd() })
204
+ * ```
205
+ */
206
+ async function run(command, options) {
207
+ const limit = options?.limit ?? PROCESS_OUTPUT;
208
+ const grace = options?.grace ?? PROCESS_GRACE;
209
+ const timeout = options?.timeout ?? 0;
210
+ const reject = options?.reject ?? true;
211
+ const input = options?.input ?? command.input;
212
+ const line = commandLine(command);
213
+ const settled = Promise.withResolvers();
214
+ const terminate = new AbortController();
215
+ const cleanup = new AbortController();
216
+ let stdout = Buffer.alloc(0);
217
+ let stderr = Buffer.alloc(0);
218
+ let timedOut = false;
219
+ let killTimer;
220
+ let timeoutTimer;
221
+ let done = false;
222
+ const child = spawn(command.file, [...command.arguments], {
223
+ cwd: options?.workspace ?? process.cwd(),
224
+ detached: process.platform !== "win32",
225
+ env: mergeEnvironment(command.environment, options?.environment),
226
+ stdio: [
227
+ "pipe",
228
+ "pipe",
229
+ "pipe"
230
+ ],
231
+ shell: requiresShell(command.file),
232
+ windowsHide: true
233
+ });
234
+ terminate.signal.addEventListener("abort", () => {
235
+ killProcess(child, "SIGTERM");
236
+ killTimer = setTimeout(() => killProcess(child, "SIGKILL"), grace);
237
+ }, { once: true });
238
+ child.stdin.on("error", () => void 0);
239
+ child.stdout.on("data", (chunk) => {
240
+ if (Buffer.isBuffer(chunk)) stdout = trimHead(Buffer.concat([stdout, chunk]), limit);
241
+ });
242
+ child.stderr.on("data", (chunk) => {
243
+ if (Buffer.isBuffer(chunk)) stderr = trimHead(Buffer.concat([stderr, chunk]), limit);
244
+ });
245
+ child.once("error", () => {
246
+ if (done) return;
247
+ done = true;
248
+ cleanup.abort();
249
+ clearTimeout(killTimer);
250
+ clearTimeout(timeoutTimer);
251
+ settled.resolve(buildRunResult(line, stdout, stderr, null, null, false, limit));
252
+ });
253
+ child.once("close", (code, signal) => {
254
+ if (done) return;
255
+ done = true;
256
+ cleanup.abort();
257
+ clearTimeout(killTimer);
258
+ clearTimeout(timeoutTimer);
259
+ settled.resolve(buildRunResult(line, stdout, stderr, code, signal, timedOut, limit));
260
+ });
261
+ if (input !== void 0) child.stdin.write(input);
262
+ child.stdin.end();
263
+ if (timeout > 0) timeoutTimer = setTimeout(() => {
264
+ timedOut = true;
265
+ terminate.abort();
266
+ }, timeout);
267
+ if (options?.signal !== void 0) {
268
+ const signal = options.signal;
269
+ signal.addEventListener("abort", () => terminate.abort(), {
270
+ once: true,
271
+ signal: cleanup.signal
272
+ });
273
+ if (signal.aborted) terminate.abort();
274
+ }
275
+ const result = await settled.promise;
276
+ if (result.failed && reject) throw createRunError(result);
277
+ return result;
278
+ }
279
+ /**
280
+ * Runs one command to completion synchronously, buffering its output, and returns the outcome.
281
+ *
282
+ * @remarks
283
+ * The synchronous counterpart of {@link run}. The child is spawned through the same cross-platform
284
+ * command resolver ({@link requiresShell}). Standard output and error are each byte-bounded by
285
+ * `limit` (default {@link PROCESS_OUTPUT}). A positive `timeout` is enforced by the host, which
286
+ * ends the child with `SIGKILL` when it elapses. The environment and input follow the same merge
287
+ * as {@link run}. Unless `reject` is `false`, a failed run throws a {@link createRunError} carrying
288
+ * the {@link RunResult}.
289
+ *
290
+ * @param command - The executable, arguments, and optional environment and input
291
+ * @param options - Working directory, timeout, signal, capture limit, and reject behavior
292
+ * @returns The run outcome
293
+ * @throws A {@link ProcessError} carrying the {@link RunResult} when the run failed and `reject` is not `false`
294
+ *
295
+ * @example
296
+ * ```ts
297
+ * const result = runSync({ file: 'git', arguments: ['--version'] }, { reject: false })
298
+ * ```
299
+ */
300
+ function runSync(command, options) {
301
+ const limit = options?.limit ?? PROCESS_OUTPUT;
302
+ const timeout = options?.timeout ?? 0;
303
+ const reject = options?.reject ?? true;
304
+ const input = options?.input ?? command.input;
305
+ const line = commandLine(command);
306
+ const outcome = spawnSync(command.file, [...command.arguments], {
307
+ cwd: options?.workspace ?? process.cwd(),
308
+ env: mergeEnvironment(command.environment, options?.environment),
309
+ encoding: "buffer",
310
+ maxBuffer: limit,
311
+ shell: requiresShell(command.file),
312
+ windowsHide: true,
313
+ ...input !== void 0 ? { input } : {},
314
+ ...options?.signal !== void 0 ? { signal: options.signal } : {},
315
+ ...timeout > 0 ? {
316
+ timeout,
317
+ killSignal: "SIGKILL"
318
+ } : {}
319
+ });
320
+ const error = outcome.error;
321
+ const timedOut = error !== void 0 && "code" in error && error.code === "ETIMEDOUT";
322
+ const result = buildRunResult(line, Buffer.isBuffer(outcome.stdout) ? outcome.stdout : Buffer.alloc(0), Buffer.isBuffer(outcome.stderr) ? outcome.stderr : Buffer.alloc(0), outcome.status, outcome.signal, timedOut, limit);
323
+ if (result.failed && reject) throw createRunError(result);
324
+ return result;
325
+ }
326
+ //#endregion
327
+ //#region src/server/Process.ts
328
+ /**
329
+ * One supervised child process with eagerly framed output and bounded termination.
330
+ *
331
+ * @remarks
332
+ * Standard output is drained eagerly through `readline`, so `exit` resolves and a late consumer of
333
+ * `lines` still receives every framed line, including a final line written without a trailing
334
+ * newline. Standard error is decoded and forwarded live as the `stderr` event while a byte-bounded
335
+ * raw tail is retained as `evidence`. The typed `emitter` also carries the terminal `exit`,
336
+ * alongside the `exit` promise. `stop` terminates the child through `SIGTERM`, then `SIGKILL` after
337
+ * `grace`, and is idempotent; `destroy` stops the child and destroys the emitter last.
338
+ *
339
+ * @example
340
+ * ```ts
341
+ * import { Process } from '@orkestrel/process/server'
342
+ *
343
+ * const child = new Process({
344
+ * command: { file: 'node', arguments: ['--version'] },
345
+ * workspace: process.cwd(),
346
+ * grace: 5000,
347
+ * })
348
+ * const exit = await child.exit
349
+ * await child.destroy()
350
+ * ```
351
+ */
352
+ var Process = class {
353
+ #emitter;
354
+ #child;
355
+ #reader;
356
+ #grace;
357
+ #limit;
358
+ #decoder = new StringDecoder("utf8");
359
+ #exit = Promise.withResolvers();
360
+ #lines;
361
+ #buffer = [];
362
+ #waiters = [];
363
+ #signal;
364
+ #abort;
365
+ #tail = Buffer.alloc(0);
366
+ #closed = false;
367
+ #ended = false;
368
+ #stopping;
369
+ #ending;
370
+ /**
371
+ * Spawn one child process and begin eager stream capture.
372
+ *
373
+ * @param options - Command, workspace, termination, evidence, stdin, and observation settings
374
+ */
375
+ constructor(options) {
376
+ const on = options.on;
377
+ const error = options.error;
378
+ this.#emitter = new Emitter({
379
+ ...on === void 0 ? {} : { on },
380
+ ...error === void 0 ? {} : { error }
381
+ });
382
+ this.#grace = options.grace;
383
+ this.#limit = options.evidence ?? PROCESS_EVIDENCE;
384
+ this.#signal = options.signal;
385
+ this.#lines = Object.freeze({ [Symbol.asyncIterator]: this.#iterate.bind(this) });
386
+ this.#child = spawn(options.command.file, [...options.command.arguments], {
387
+ cwd: options.workspace,
388
+ detached: process.platform !== "win32",
389
+ env: mergeEnvironment(options.command.environment),
390
+ stdio: [
391
+ "pipe",
392
+ "pipe",
393
+ "pipe"
394
+ ],
395
+ windowsHide: true
396
+ });
397
+ this.#reader = createInterface({
398
+ input: this.#child.stdout,
399
+ crlfDelay: Infinity
400
+ });
401
+ this.#reader.on("line", this.#push.bind(this));
402
+ this.#reader.once("close", this.#finish.bind(this));
403
+ this.#child.once("error", () => void 0);
404
+ this.#child.once("close", this.#close.bind(this));
405
+ this.#child.stdin.on("error", () => void 0);
406
+ this.#child.stderr.on("data", this.#retain.bind(this));
407
+ if (options.command.input !== void 0) this.#child.stdin.write(options.command.input);
408
+ if (options.writable !== true) this.#child.stdin.end();
409
+ if (this.#signal !== void 0) {
410
+ this.#abort = this.#terminate.bind(this);
411
+ this.#signal.addEventListener("abort", this.#abort, { once: true });
412
+ if (this.#signal.aborted) this.stop();
413
+ }
414
+ }
415
+ /** The typed lifecycle observation surface. */
416
+ get emitter() {
417
+ return this.#emitter;
418
+ }
419
+ /** The eagerly captured stdout lines, in arrival order, ending when the child's stdout closes. */
420
+ get lines() {
421
+ return this.#lines;
422
+ }
423
+ /** The decoded byte-bounded stderr tail. */
424
+ get evidence() {
425
+ return this.#tail.toString("utf8");
426
+ }
427
+ /** The terminal child state, observed once from the close event. */
428
+ get exit() {
429
+ return this.#exit.promise;
430
+ }
431
+ /**
432
+ * Write one line to the open standard-input channel.
433
+ *
434
+ * @param text - The line text without its trailing newline
435
+ * @returns True when the channel accepted the line without backpressure; false otherwise
436
+ */
437
+ send(text) {
438
+ if (this.#closed || this.#child.stdin.destroyed || this.#child.stdin.writableEnded) return false;
439
+ try {
440
+ return this.#child.stdin.write(`${text}\n`);
441
+ } catch {
442
+ return false;
443
+ }
444
+ }
445
+ /**
446
+ * Terminate the child process tree and await its observed exit.
447
+ *
448
+ * @returns A promise that resolves after bounded termination
449
+ */
450
+ stop() {
451
+ if (this.#stopping !== void 0) return this.#stopping;
452
+ this.#stopping = this.#kill();
453
+ return this.#stopping;
454
+ }
455
+ /**
456
+ * Stop the child and destroy the observation emitter.
457
+ *
458
+ * @returns The stable barrier shared by every call
459
+ */
460
+ destroy() {
461
+ if (this.#ending !== void 0) return this.#ending;
462
+ this.#ending = this.#teardown();
463
+ return this.#ending;
464
+ }
465
+ #iterate() {
466
+ return Object.freeze({ next: this.#next.bind(this) });
467
+ }
468
+ #next() {
469
+ const line = this.#buffer.shift();
470
+ if (line !== void 0) return Promise.resolve(Object.freeze({
471
+ done: false,
472
+ value: line
473
+ }));
474
+ if (this.#ended) return Promise.resolve(Object.freeze({
475
+ done: true,
476
+ value: void 0
477
+ }));
478
+ const waiter = Promise.withResolvers();
479
+ this.#waiters.push(waiter);
480
+ return waiter.promise;
481
+ }
482
+ #push(line) {
483
+ if (this.#ended) return;
484
+ const waiter = this.#waiters.shift();
485
+ if (waiter === void 0) {
486
+ this.#buffer.push(line);
487
+ return;
488
+ }
489
+ waiter.resolve(Object.freeze({
490
+ done: false,
491
+ value: line
492
+ }));
493
+ }
494
+ #finish() {
495
+ if (this.#ended) return;
496
+ this.#ended = true;
497
+ for (const waiter of this.#waiters) waiter.resolve(Object.freeze({
498
+ done: true,
499
+ value: void 0
500
+ }));
501
+ this.#waiters.length = 0;
502
+ }
503
+ #retain(chunk) {
504
+ if (!Buffer.isBuffer(chunk) || this.#closed) return;
505
+ this.#tail = trimTail(Buffer.concat([this.#tail, chunk]), this.#limit);
506
+ const text = this.#decoder.write(chunk);
507
+ if (text.length > 0) this.#emitter.emit("stderr", text);
508
+ }
509
+ #close(code, signal) {
510
+ if (this.#closed) return;
511
+ const suffix = this.#decoder.end();
512
+ if (suffix.length > 0) this.#emitter.emit("stderr", suffix);
513
+ this.#closed = true;
514
+ if (this.#signal !== void 0 && this.#abort !== void 0) this.#signal.removeEventListener("abort", this.#abort);
515
+ this.#reader.close();
516
+ const exit = Object.freeze({
517
+ code,
518
+ signal
519
+ });
520
+ this.#exit.resolve(exit);
521
+ this.#emitter.emit("exit", exit);
522
+ }
523
+ #terminate() {
524
+ this.stop();
525
+ }
526
+ async #kill() {
527
+ if (this.#closed) return;
528
+ killProcess(this.#child, "SIGTERM");
529
+ const grace = Promise.withResolvers();
530
+ const timer = setTimeout(grace.resolve, this.#grace);
531
+ await Promise.race([this.#exit.promise, grace.promise]);
532
+ clearTimeout(timer);
533
+ if (this.#closed) return;
534
+ killProcess(this.#child, "SIGKILL");
535
+ await this.#exit.promise;
536
+ }
537
+ async #teardown() {
538
+ await this.stop();
539
+ this.#emitter.destroy();
540
+ }
541
+ };
542
+ //#endregion
543
+ //#region src/server/ProcessManager.ts
544
+ /**
545
+ * A keyed registry of live supervised child processes.
546
+ *
547
+ * @remarks
548
+ * A child launched under an id joins the registry and emits `launch`; when it settles it removes
549
+ * itself and emits `exit`, so `count` and `processes` reflect only live children. `launch` throws
550
+ * a {@link createDuplicateError} when the id is already live — a spawn fault surfaces through the
551
+ * returned child's `exit`, never from `launch`. `destroy` stops every child, then destroys the
552
+ * registry emitter last.
553
+ *
554
+ * @example
555
+ * ```ts
556
+ * import { ProcessManager } from '@orkestrel/process/server'
557
+ *
558
+ * const manager = new ProcessManager()
559
+ * const child = manager.launch('build', {
560
+ * command: { file: 'node', arguments: ['build.js'] },
561
+ * workspace: process.cwd(),
562
+ * grace: 5000,
563
+ * })
564
+ * await child.exit
565
+ * await manager.destroy()
566
+ * ```
567
+ */
568
+ var ProcessManager = class {
569
+ #emitter;
570
+ #children = /* @__PURE__ */ new Map();
571
+ #ending;
572
+ /**
573
+ * Construct a process registry.
574
+ *
575
+ * @param options - Initial fleet-level observation hooks and listener-error handling
576
+ */
577
+ constructor(options) {
578
+ const on = options?.on;
579
+ const error = options?.error;
580
+ this.#emitter = new Emitter({
581
+ ...on === void 0 ? {} : { on },
582
+ ...error === void 0 ? {} : { error }
583
+ });
584
+ }
585
+ /** The typed fleet-level observation surface. */
586
+ get emitter() {
587
+ return this.#emitter;
588
+ }
589
+ /** The number of live children. */
590
+ get count() {
591
+ return this.#children.size;
592
+ }
593
+ /**
594
+ * The live child under `id`, or `undefined` when none is.
595
+ *
596
+ * @param id - The registry key
597
+ * @returns The child, or `undefined`
598
+ */
599
+ process(id) {
600
+ return this.#children.get(id);
601
+ }
602
+ /**
603
+ * A snapshot of every live child.
604
+ *
605
+ * @returns The live children in launch order
606
+ */
607
+ processes() {
608
+ return [...this.#children.values()];
609
+ }
610
+ /**
611
+ * Spawn and register one child under `id`.
612
+ *
613
+ * @param id - The registry key, unique among live children
614
+ * @param options - The child construction options
615
+ * @returns The launched child
616
+ * @throws A {@link ProcessError} coded `duplicate` when `id` is already live
617
+ */
618
+ launch(id, options) {
619
+ if (this.#children.has(id)) throw createDuplicateError(id);
620
+ const child = new Process(options);
621
+ this.#children.set(id, child);
622
+ child.emitter.on("exit", (exit) => this.#evict(id, child, exit));
623
+ this.#emitter.emit("launch", id);
624
+ return child;
625
+ }
626
+ stop(target) {
627
+ if (target === void 0) return this.#stopAll();
628
+ if (typeof target === "string") return this.#stopOne(target);
629
+ return this.#stopMany(target);
630
+ }
631
+ /**
632
+ * Stop every child, then destroy the registry emitter last.
633
+ *
634
+ * @returns The stable barrier shared by every call
635
+ */
636
+ destroy() {
637
+ if (this.#ending !== void 0) return this.#ending;
638
+ this.#ending = this.#teardown();
639
+ return this.#ending;
640
+ }
641
+ #evict(id, child, exit) {
642
+ if (this.#children.get(id) !== child) return;
643
+ this.#children.delete(id);
644
+ this.#emitter.emit("exit", id, exit);
645
+ }
646
+ async #stopOne(id) {
647
+ const child = this.#children.get(id);
648
+ if (child === void 0) return false;
649
+ await child.stop();
650
+ return true;
651
+ }
652
+ async #stopMany(ids) {
653
+ return (await Promise.all(ids.map((id) => this.#stopOne(id)))).every((ok) => ok);
654
+ }
655
+ async #stopAll() {
656
+ await Promise.all([...this.#children.values()].map((child) => child.stop()));
657
+ }
658
+ async #teardown() {
659
+ await Promise.all([...this.#children.values()].map((child) => child.destroy()));
660
+ this.#emitter.destroy();
661
+ }
662
+ };
663
+ //#endregion
664
+ //#region src/server/factories.ts
665
+ /**
666
+ * Creates one supervised child process.
667
+ *
668
+ * @param options - Command, workspace, termination, evidence, stdin, and observation settings
669
+ * @returns The launched {@link ProcessInterface}
670
+ *
671
+ * @example
672
+ * ```ts
673
+ * import { createProcess } from '@orkestrel/process/server'
674
+ *
675
+ * const child = createProcess({
676
+ * command: { file: 'node', arguments: ['--version'] },
677
+ * workspace: process.cwd(),
678
+ * grace: 5000,
679
+ * })
680
+ * ```
681
+ */
682
+ function createProcess(options) {
683
+ return new Process(options);
684
+ }
685
+ /**
686
+ * Creates one keyed registry of supervised child processes.
687
+ *
688
+ * @param options - Initial fleet-level observation hooks and listener-error handling
689
+ * @returns The {@link ProcessManagerInterface} registry
690
+ *
691
+ * @example
692
+ * ```ts
693
+ * import { createProcessManager } from '@orkestrel/process/server'
694
+ *
695
+ * const manager = createProcessManager()
696
+ * ```
697
+ */
698
+ function createProcessManager(options) {
699
+ return new ProcessManager(options);
700
+ }
701
+ //#endregion
702
+ export { Process, ProcessManager, buildRunResult, commandLine, createProcess, createProcessManager, killProcess, mergeEnvironment, requiresShell, run, runSync, trimHead, trimTail };
703
+
704
+ //# sourceMappingURL=index.js.map