@gobing-ai/ts-runtime 0.4.9 → 0.4.10
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 +43 -4
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/process-executor.d.ts +24 -0
- package/dist/process-executor.d.ts.map +1 -1
- package/dist/process-executor.js +59 -4
- package/dist/process-registry.d.ts +143 -0
- package/dist/process-registry.d.ts.map +1 -0
- package/dist/process-registry.js +146 -0
- package/package.json +4 -4
- package/src/index.ts +12 -0
- package/src/process-executor.ts +93 -4
- package/src/process-registry.ts +263 -0
package/README.md
CHANGED
|
@@ -15,6 +15,7 @@ and Cloudflare Workers through a factory pattern that auto-detects the runtime.
|
|
|
15
15
|
| Runtime factory | `RuntimeFactory` → `loadRuntimeFactory()` | `nodeBunFactory` | `cloudflareWorkersFactory` |
|
|
16
16
|
| File system | `FileSystem` | `createNodeFileSystem()` (sync `node:fs`) | `createCfFileSystem()` (stub) |
|
|
17
17
|
| Process execution | `ProcessExecutor` (interface) | `NodeProcessExecutor` — `run()` via execa, `runStreaming()` via `Bun.spawn` | throws |
|
|
18
|
+
| Process registry | `ProcessRegistry` (optional) | `InMemoryProcessRegistry` / `createInMemoryProcessRegistry()` — list + subscribe all spawns | n/a (no process exec) |
|
|
18
19
|
| SQL database | `createDbAdapter(config)` → `DbAdapter` | Bun SQLite via `@gobing-ai/ts-db` (optional peer) | throws `D1NotConfiguredError` (D1 round pending) |
|
|
19
20
|
| Configuration | `Config` (Zod schema) | YAML + env vars | CONFIG_YAML blob + env vars |
|
|
20
21
|
| Context | `RuntimeContext` | service locator | service locator |
|
|
@@ -96,6 +97,23 @@ classDiagram
|
|
|
96
97
|
+runStreaming(options) PipeProcess
|
|
97
98
|
}
|
|
98
99
|
|
|
100
|
+
class ProcessRegistry {
|
|
101
|
+
<<interface>>
|
|
102
|
+
+listExecutions(filter?) ProcessExecution[]
|
|
103
|
+
+getExecution(id) ProcessExecution?
|
|
104
|
+
+subscribe(listener) unsub
|
|
105
|
+
+begin(input) string
|
|
106
|
+
+update(id, patch) void
|
|
107
|
+
+complete(id, update?) void
|
|
108
|
+
+clear() void
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
class InMemoryProcessRegistry {
|
|
112
|
+
+listExecutions(filter?) ProcessExecution[]
|
|
113
|
+
+begin(input) string
|
|
114
|
+
+complete(id, update?) void
|
|
115
|
+
}
|
|
116
|
+
|
|
99
117
|
class PipeProcess {
|
|
100
118
|
<<interface>>
|
|
101
119
|
+pid number?
|
|
@@ -138,6 +156,8 @@ classDiagram
|
|
|
138
156
|
FileSystem <|.. createCfFileSystem : implements
|
|
139
157
|
ProcessExecutor --> PipeProcess : creates
|
|
140
158
|
ProcessExecutor <|.. NodeProcessExecutor : implements
|
|
159
|
+
ProcessRegistry <|.. InMemoryProcessRegistry : implements
|
|
160
|
+
NodeProcessExecutor ..> ProcessRegistry : optional registry
|
|
141
161
|
nodeBunFactory --> createNodeFileSystem : creates
|
|
142
162
|
nodeBunFactory --> NodeProcessExecutor : creates
|
|
143
163
|
cloudflareWorkersFactory --> createCfFileSystem : creates
|
|
@@ -536,9 +556,11 @@ const config = buildConfigFromObject({
|
|
|
536
556
|
`ProcessExecutor` is the canonical interface for process execution. `NodeProcessExecutor` is the concrete implementation wrapping `execa` (buffered) and `Bun.spawn` (streaming):
|
|
537
557
|
|
|
538
558
|
```ts
|
|
539
|
-
import { NodeProcessExecutor } from '@gobing-ai/ts-runtime';
|
|
559
|
+
import { NodeProcessExecutor, createInMemoryProcessRegistry } from '@gobing-ai/ts-runtime';
|
|
540
560
|
|
|
541
|
-
|
|
561
|
+
// Optional: shared registry so every run/runStreaming is listable (spur#0264 / M1).
|
|
562
|
+
const registry = createInMemoryProcessRegistry();
|
|
563
|
+
const exec = new NodeProcessExecutor({ defaultTimeout: 30_000, registry });
|
|
542
564
|
|
|
543
565
|
// Buffered — captures stdout/stderr, no throw on non-zero
|
|
544
566
|
const result = await exec.run({
|
|
@@ -546,21 +568,38 @@ const result = await exec.run({
|
|
|
546
568
|
args: ['status', '--short'],
|
|
547
569
|
cwd: '/path/to/repo',
|
|
548
570
|
rejectOnError: true,
|
|
571
|
+
// Optional registry metadata (defaults: run → source 'one-shot')
|
|
572
|
+
label: 'git.status',
|
|
549
573
|
});
|
|
550
574
|
|
|
551
575
|
console.log(result.stdout); // 'M src/index.ts\n'
|
|
552
576
|
console.log(`Duration: ${result.durationMs}ms`);
|
|
553
577
|
|
|
554
578
|
// Streaming — interactive subprocess with stdin control
|
|
555
|
-
const proc = exec.runStreaming({
|
|
579
|
+
const proc = exec.runStreaming({
|
|
580
|
+
command: 'cat',
|
|
581
|
+
source: 'supervisor', // tag supervised agent loops
|
|
582
|
+
agentId: 'alpha-claude',
|
|
583
|
+
});
|
|
556
584
|
proc.writeStdin('hello\n');
|
|
557
585
|
proc.endStdin();
|
|
558
586
|
const exitCode = await proc.exited; // 0
|
|
587
|
+
|
|
588
|
+
// Watch list snapshot + live subscription
|
|
589
|
+
console.log(registry.listExecutions());
|
|
590
|
+
const unsub = registry.subscribe((event) => {
|
|
591
|
+
if (event.type === 'started' || event.type === 'exited') {
|
|
592
|
+
console.log(event.type, event.execution.id, event.execution.command);
|
|
593
|
+
}
|
|
594
|
+
});
|
|
595
|
+
unsub();
|
|
559
596
|
```
|
|
560
597
|
|
|
561
598
|
`rejectOnError: true` throws on non-zero exits. `OutputPolicy` controls
|
|
562
599
|
buffered vs streamed output. `ProcessOptions` supports timeout, env, cwd,
|
|
563
|
-
maxOutput, and
|
|
600
|
+
maxOutput, forceBuffered, and optional registry fields (`source`, `teamId`,
|
|
601
|
+
`agentId`). Inject the same `ProcessRegistry` into every executor that should
|
|
602
|
+
appear in one watch list; without a registry, behavior is unchanged.
|
|
564
603
|
|
|
565
604
|
Cloudflare Workers do not expose process execution; check
|
|
566
605
|
`factory.capabilities.hasProcessExecution` first.
|
package/dist/index.d.ts
CHANGED
|
@@ -10,6 +10,8 @@ export * from './path';
|
|
|
10
10
|
export { _resetRuntimeFactory, isCloudflareWorkerRuntime, loadRuntimeFactory } from './platform';
|
|
11
11
|
export type { OutputPolicy, PipeProcess, PipeProcessOptions, ProcessEventDetail, ProcessEventSink, ProcessEvents, ProcessExecutorConfig, ProcessExitReason, ProcessOptions, ProcessResult, ProcessSignal, TracerPort, } from './process-executor';
|
|
12
12
|
export { NodeProcessExecutor, ProcessExecutor } from './process-executor';
|
|
13
|
+
export type { InMemoryProcessRegistryOptions, ProcessExecution, ProcessExecutionBegin, ProcessExecutionComplete, ProcessExecutionFilter, ProcessExecutionSource, ProcessExecutionStatus, ProcessRegistry, ProcessRegistryEvent, } from './process-registry';
|
|
14
|
+
export { createInMemoryProcessRegistry, InMemoryProcessRegistry } from './process-registry';
|
|
13
15
|
export { cloudflareWorkersFactory } from './runtime-cf';
|
|
14
16
|
export type { RuntimeFactory } from './runtime-factory';
|
|
15
17
|
export { _resetNodeFileSystem, nodeBunFactory } from './runtime-node-bun';
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,UAAU,CAAC;AACzB,cAAc,WAAW,CAAC;AAC1B,OAAO,EAAE,+BAA+B,EAAE,MAAM,WAAW,CAAC;AAC5D,OAAO,EAAE,oBAAoB,EAAE,yBAAyB,EAAE,MAAM,aAAa,CAAC;AAC9E,YAAY,EAAE,QAAQ,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAC1D,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,oBAAoB,EAAE,eAAe,EAAE,MAAM,oBAAoB,CAAC;AAC3E,OAAO,EACH,eAAe,EACf,eAAe,EACf,eAAe,EACf,gBAAgB,EAChB,YAAY,EACZ,OAAO,EACP,aAAa,GAChB,MAAM,MAAM,CAAC;AACd,cAAc,QAAQ,CAAC;AACvB,OAAO,EAAE,oBAAoB,EAAE,yBAAyB,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AACjG,YAAY,EACR,YAAY,EACZ,WAAW,EACX,kBAAkB,EAClB,kBAAkB,EAClB,gBAAgB,EAChB,aAAa,EACb,qBAAqB,EACrB,iBAAiB,EACjB,cAAc,EACd,aAAa,EACb,aAAa,EACb,UAAU,GACb,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAAE,mBAAmB,EAAE,eAAe,EAAE,MAAM,oBAAoB,CAAC;AAC1E,OAAO,EAAE,wBAAwB,EAAE,MAAM,cAAc,CAAC;AACxD,YAAY,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AACxD,OAAO,EAAE,oBAAoB,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AAC1E,cAAc,qBAAqB,CAAC;AACpC,cAAc,SAAS,CAAC;AAIxB,OAAO,EAAE,qBAAqB,EAAE,sBAAsB,EAAE,MAAM,oBAAoB,CAAC;AAEnF;;;GAGG;AACH,MAAM,MAAM,mBAAmB,GAAG,YAAY,CAAC,cAAc,oBAAoB,EAAE,sBAAsB,CAAC,CAAC;AAE3G;;GAEG;AACH,MAAM,MAAM,kBAAkB,GAAG,YAAY,CAAC,cAAc,oBAAoB,EAAE,qBAAqB,CAAC,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,UAAU,CAAC;AACzB,cAAc,WAAW,CAAC;AAC1B,OAAO,EAAE,+BAA+B,EAAE,MAAM,WAAW,CAAC;AAC5D,OAAO,EAAE,oBAAoB,EAAE,yBAAyB,EAAE,MAAM,aAAa,CAAC;AAC9E,YAAY,EAAE,QAAQ,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAC1D,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,oBAAoB,EAAE,eAAe,EAAE,MAAM,oBAAoB,CAAC;AAC3E,OAAO,EACH,eAAe,EACf,eAAe,EACf,eAAe,EACf,gBAAgB,EAChB,YAAY,EACZ,OAAO,EACP,aAAa,GAChB,MAAM,MAAM,CAAC;AACd,cAAc,QAAQ,CAAC;AACvB,OAAO,EAAE,oBAAoB,EAAE,yBAAyB,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AACjG,YAAY,EACR,YAAY,EACZ,WAAW,EACX,kBAAkB,EAClB,kBAAkB,EAClB,gBAAgB,EAChB,aAAa,EACb,qBAAqB,EACrB,iBAAiB,EACjB,cAAc,EACd,aAAa,EACb,aAAa,EACb,UAAU,GACb,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAAE,mBAAmB,EAAE,eAAe,EAAE,MAAM,oBAAoB,CAAC;AAC1E,YAAY,EACR,8BAA8B,EAC9B,gBAAgB,EAChB,qBAAqB,EACrB,wBAAwB,EACxB,sBAAsB,EACtB,sBAAsB,EACtB,sBAAsB,EACtB,eAAe,EACf,oBAAoB,GACvB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAAE,6BAA6B,EAAE,uBAAuB,EAAE,MAAM,oBAAoB,CAAC;AAC5F,OAAO,EAAE,wBAAwB,EAAE,MAAM,cAAc,CAAC;AACxD,YAAY,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AACxD,OAAO,EAAE,oBAAoB,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AAC1E,cAAc,qBAAqB,CAAC;AACpC,cAAc,SAAS,CAAC;AAIxB,OAAO,EAAE,qBAAqB,EAAE,sBAAsB,EAAE,MAAM,oBAAoB,CAAC;AAEnF;;;GAGG;AACH,MAAM,MAAM,mBAAmB,GAAG,YAAY,CAAC,cAAc,oBAAoB,EAAE,sBAAsB,CAAC,CAAC;AAE3G;;GAEG;AACH,MAAM,MAAM,kBAAkB,GAAG,YAAY,CAAC,cAAc,oBAAoB,EAAE,qBAAqB,CAAC,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -8,6 +8,7 @@ export { atomicWriteFile, atomicWriteJson, createLogStream, ensureDirForFile, re
|
|
|
8
8
|
export * from './path.js';
|
|
9
9
|
export { _resetRuntimeFactory, isCloudflareWorkerRuntime, loadRuntimeFactory } from './platform.js';
|
|
10
10
|
export { NodeProcessExecutor, ProcessExecutor } from './process-executor.js';
|
|
11
|
+
export { createInMemoryProcessRegistry, InMemoryProcessRegistry } from './process-registry.js';
|
|
11
12
|
export { cloudflareWorkersFactory } from './runtime-cf.js';
|
|
12
13
|
export { _resetNodeFileSystem, nodeBunFactory } from './runtime-node-bun.js';
|
|
13
14
|
export * from './schema-validation.js';
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ProcessExecutionSource, ProcessRegistry } from './process-registry';
|
|
1
2
|
/** Controls how stdout/stderr is captured: buffered in memory or streamed to the caller's terminal. */
|
|
2
3
|
export type OutputPolicy = {
|
|
3
4
|
mode: 'buffered';
|
|
@@ -12,6 +13,12 @@ export interface ProcessExecutorConfig {
|
|
|
12
13
|
output?: OutputPolicy;
|
|
13
14
|
events?: ProcessEventSink;
|
|
14
15
|
tracer?: TracerPort;
|
|
16
|
+
/**
|
|
17
|
+
* Optional process registry (spur#0264). When set, every `run` / `runStreaming`
|
|
18
|
+
* invocation is recorded for list/subscribe consumers (e.g. Spur Processes tab).
|
|
19
|
+
* Share one registry across all executors that should appear in the same watch list.
|
|
20
|
+
*/
|
|
21
|
+
registry?: ProcessRegistry;
|
|
15
22
|
}
|
|
16
23
|
/** Options for spawning a child process. */
|
|
17
24
|
export interface ProcessOptions {
|
|
@@ -26,6 +33,14 @@ export interface ProcessOptions {
|
|
|
26
33
|
forceBuffered?: boolean;
|
|
27
34
|
/** AbortSignal forwarded to execa as `cancelSignal` — aborts the child process when fired. */
|
|
28
35
|
signal?: AbortSignal;
|
|
36
|
+
/**
|
|
37
|
+
* Registry metadata (spur#0264). Defaults: source `'one-shot'` for `run`.
|
|
38
|
+
* Pass `source: 'supervisor'` (and optional teamId/agentId) when the spawn is
|
|
39
|
+
* a supervised team agent loop.
|
|
40
|
+
*/
|
|
41
|
+
source?: ProcessExecutionSource;
|
|
42
|
+
teamId?: string;
|
|
43
|
+
agentId?: string;
|
|
29
44
|
}
|
|
30
45
|
/** Result of a completed child process, including exit code, captured output, and duration. */
|
|
31
46
|
export interface ProcessResult {
|
|
@@ -71,6 +86,13 @@ export interface PipeProcessOptions {
|
|
|
71
86
|
cwd?: string;
|
|
72
87
|
env?: Record<string, string>;
|
|
73
88
|
label?: string;
|
|
89
|
+
/**
|
|
90
|
+
* Registry metadata (spur#0264). Defaults: source `'other'` for streaming.
|
|
91
|
+
* Supervised agent loops should pass `source: 'supervisor'` + agentId.
|
|
92
|
+
*/
|
|
93
|
+
source?: ProcessExecutionSource;
|
|
94
|
+
teamId?: string;
|
|
95
|
+
agentId?: string;
|
|
74
96
|
}
|
|
75
97
|
/** Signal values accepted by subprocess kill. */
|
|
76
98
|
type BunSubprocess = ReturnType<typeof Bun.spawn>;
|
|
@@ -134,6 +156,8 @@ export declare class NodeProcessExecutor implements ProcessExecutor {
|
|
|
134
156
|
*/
|
|
135
157
|
runStreaming(options: PipeProcessOptions): PipeProcess;
|
|
136
158
|
private trace;
|
|
159
|
+
private beginRegistry;
|
|
160
|
+
private completeRegistry;
|
|
137
161
|
private emitExitedFromResult;
|
|
138
162
|
private emitProcessEvent;
|
|
139
163
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"process-executor.d.ts","sourceRoot":"","sources":["../src/process-executor.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"process-executor.d.ts","sourceRoot":"","sources":["../src/process-executor.ts"],"names":[],"mappings":"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"}
|
package/dist/process-executor.js
CHANGED
|
@@ -34,17 +34,26 @@ export class NodeProcessExecutor {
|
|
|
34
34
|
signal: options.signal,
|
|
35
35
|
});
|
|
36
36
|
const startedAt = Date.now();
|
|
37
|
+
const startedIso = new Date(startedAt).toISOString();
|
|
38
|
+
const registryId = this.beginRegistry(options.command, args, {
|
|
39
|
+
label: options.label,
|
|
40
|
+
source: options.source ?? 'one-shot',
|
|
41
|
+
teamId: options.teamId,
|
|
42
|
+
agentId: options.agentId,
|
|
43
|
+
startedAt: startedIso,
|
|
44
|
+
});
|
|
37
45
|
this.emitProcessEvent('process.started', {
|
|
38
46
|
command: options.command,
|
|
39
47
|
args,
|
|
40
48
|
exitCode: null,
|
|
41
49
|
durationMs: 0,
|
|
42
50
|
reason: 'exit',
|
|
43
|
-
timestamp:
|
|
51
|
+
timestamp: startedIso,
|
|
44
52
|
...(options.label !== undefined ? { label: options.label } : {}),
|
|
45
53
|
});
|
|
46
54
|
try {
|
|
47
55
|
const result = await execa(options.command, args, execaOptions);
|
|
56
|
+
// execa@9 Result does not expose pid — buffered runs leave pid unset.
|
|
48
57
|
const processResult = {
|
|
49
58
|
command: options.command,
|
|
50
59
|
args,
|
|
@@ -54,6 +63,7 @@ export class NodeProcessExecutor {
|
|
|
54
63
|
...(result.signalDescription !== undefined ? { signal: result.signalDescription } : {}),
|
|
55
64
|
durationMs: result.durationMs,
|
|
56
65
|
};
|
|
66
|
+
this.completeRegistry(registryId, processResult.exitCode);
|
|
57
67
|
this.emitExitedFromResult(options, processResult, result);
|
|
58
68
|
return processResult;
|
|
59
69
|
}
|
|
@@ -72,6 +82,7 @@ export class NodeProcessExecutor {
|
|
|
72
82
|
: {}),
|
|
73
83
|
durationMs: failed.durationMs ?? Date.now() - startedAt,
|
|
74
84
|
};
|
|
85
|
+
this.completeRegistry(registryId, processResult.exitCode);
|
|
75
86
|
this.emitExitedFromResult(options, processResult, error, error);
|
|
76
87
|
if (options.rejectOnError)
|
|
77
88
|
throw error;
|
|
@@ -87,15 +98,24 @@ export class NodeProcessExecutor {
|
|
|
87
98
|
runStreaming(options) {
|
|
88
99
|
const args = options.args ?? [];
|
|
89
100
|
void this.config.tracer?.traceAsync('process.runStreaming', async () => undefined).catch(() => undefined);
|
|
101
|
+
const startedAt = Date.now();
|
|
102
|
+
const startedIso = new Date(startedAt).toISOString();
|
|
103
|
+
// Begin registry before spawn so failed spawns still appear (then complete as error).
|
|
104
|
+
const registryId = this.beginRegistry(options.command, args, {
|
|
105
|
+
label: options.label,
|
|
106
|
+
source: options.source ?? 'other',
|
|
107
|
+
teamId: options.teamId,
|
|
108
|
+
agentId: options.agentId,
|
|
109
|
+
startedAt: startedIso,
|
|
110
|
+
});
|
|
90
111
|
try {
|
|
91
|
-
const startedAt = Date.now();
|
|
92
112
|
this.emitProcessEvent('process.started', {
|
|
93
113
|
command: options.command,
|
|
94
114
|
args,
|
|
95
115
|
exitCode: null,
|
|
96
116
|
durationMs: 0,
|
|
97
117
|
reason: 'exit',
|
|
98
|
-
timestamp:
|
|
118
|
+
timestamp: startedIso,
|
|
99
119
|
...(options.label !== undefined ? { label: options.label } : {}),
|
|
100
120
|
});
|
|
101
121
|
const subprocess = Bun.spawn({
|
|
@@ -106,14 +126,21 @@ export class NodeProcessExecutor {
|
|
|
106
126
|
...(options.cwd !== undefined ? { cwd: options.cwd } : {}),
|
|
107
127
|
...(options.env !== undefined ? { env: options.env } : {}),
|
|
108
128
|
});
|
|
109
|
-
|
|
129
|
+
const pipe = new BunPipeProcess(subprocess);
|
|
130
|
+
if (pipe.pid !== null) {
|
|
131
|
+
this.config.registry?.update(registryId, { pid: pipe.pid });
|
|
132
|
+
}
|
|
133
|
+
return new ObservedPipeProcess(pipe, this.config.events, {
|
|
110
134
|
command: options.command,
|
|
111
135
|
args,
|
|
112
136
|
startedAt,
|
|
137
|
+
registry: this.config.registry,
|
|
138
|
+
registryId,
|
|
113
139
|
...(options.label !== undefined ? { label: options.label } : {}),
|
|
114
140
|
});
|
|
115
141
|
}
|
|
116
142
|
catch (error) {
|
|
143
|
+
this.completeRegistry(registryId, null);
|
|
117
144
|
this.emitProcessEvent('process.exited', {
|
|
118
145
|
command: options.command,
|
|
119
146
|
args,
|
|
@@ -132,6 +159,28 @@ export class NodeProcessExecutor {
|
|
|
132
159
|
return await fn();
|
|
133
160
|
return await this.config.tracer.traceAsync(name, async () => await fn());
|
|
134
161
|
}
|
|
162
|
+
beginRegistry(command, args, meta) {
|
|
163
|
+
const registry = this.config.registry;
|
|
164
|
+
if (!registry)
|
|
165
|
+
return '';
|
|
166
|
+
return registry.begin({
|
|
167
|
+
command,
|
|
168
|
+
args,
|
|
169
|
+
source: meta.source,
|
|
170
|
+
startedAt: meta.startedAt,
|
|
171
|
+
...(meta.label !== undefined ? { label: meta.label } : {}),
|
|
172
|
+
...(meta.teamId !== undefined ? { teamId: meta.teamId } : {}),
|
|
173
|
+
...(meta.agentId !== undefined ? { agentId: meta.agentId } : {}),
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
completeRegistry(id, exitCode, pid) {
|
|
177
|
+
if (!id || !this.config.registry)
|
|
178
|
+
return;
|
|
179
|
+
this.config.registry.complete(id, {
|
|
180
|
+
exitCode,
|
|
181
|
+
...(pid !== undefined ? { pid } : {}),
|
|
182
|
+
});
|
|
183
|
+
}
|
|
135
184
|
emitExitedFromResult(options, result, completion, error) {
|
|
136
185
|
const reason = isTimedOut(completion)
|
|
137
186
|
? 'timeout'
|
|
@@ -163,6 +212,12 @@ class ObservedPipeProcess {
|
|
|
163
212
|
constructor(inner, events, context) {
|
|
164
213
|
this.inner = inner;
|
|
165
214
|
this.exited = inner.exited.then((exitCode) => {
|
|
215
|
+
if (context.registry && context.registryId) {
|
|
216
|
+
context.registry.complete(context.registryId, {
|
|
217
|
+
exitCode,
|
|
218
|
+
...(inner.pid !== null ? { pid: inner.pid } : {}),
|
|
219
|
+
});
|
|
220
|
+
}
|
|
166
221
|
events?.emit('process.exited', {
|
|
167
222
|
command: context.command,
|
|
168
223
|
args: context.args,
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process registry of ProcessExecutor invocations (spur#0264 / M1).
|
|
3
|
+
*
|
|
4
|
+
* Tracks every `run` / `runStreaming` so board UIs (and other observers) can
|
|
5
|
+
* list all harness-launched processes — not only supervisor-owned agent loops.
|
|
6
|
+
* In-memory only; not durable across process restarts.
|
|
7
|
+
*/
|
|
8
|
+
/** Who initiated the spawn — used for filtering/grouping in consumers. */
|
|
9
|
+
export type ProcessExecutionSource = 'supervisor' | 'one-shot' | 'other';
|
|
10
|
+
/** Lifecycle status of a tracked execution. */
|
|
11
|
+
export type ProcessExecutionStatus = 'running' | 'exited';
|
|
12
|
+
/**
|
|
13
|
+
* One ProcessExecutor invocation — the minimum metadata Spur's Processes tab
|
|
14
|
+
* (and similar consumers) need for a full watch list.
|
|
15
|
+
*/
|
|
16
|
+
export interface ProcessExecution {
|
|
17
|
+
/** Unique id for this execution (stable for the process lifetime). */
|
|
18
|
+
readonly id: string;
|
|
19
|
+
/** Optional human label (e.g. `agent:alpha-claude`). */
|
|
20
|
+
readonly label?: string;
|
|
21
|
+
readonly command: string;
|
|
22
|
+
readonly args: readonly string[];
|
|
23
|
+
/** OS pid when known (may arrive after spawn for buffered runs). */
|
|
24
|
+
readonly pid?: number;
|
|
25
|
+
/** ISO-8601 start time. */
|
|
26
|
+
readonly startedAt: string;
|
|
27
|
+
/** ISO-8601 end time when exited. */
|
|
28
|
+
readonly exitedAt?: string;
|
|
29
|
+
/** Exit code when exited (`null` for signal/timeout/error without code). */
|
|
30
|
+
readonly exitCode?: number | null;
|
|
31
|
+
readonly source: ProcessExecutionSource;
|
|
32
|
+
/** Optional team association for board grouping. */
|
|
33
|
+
readonly teamId?: string;
|
|
34
|
+
/** Optional agent id when the spawn is agent-scoped. */
|
|
35
|
+
readonly agentId?: string;
|
|
36
|
+
readonly status: ProcessExecutionStatus;
|
|
37
|
+
}
|
|
38
|
+
/** Filter for {@link ProcessRegistry.listExecutions}. */
|
|
39
|
+
export interface ProcessExecutionFilter {
|
|
40
|
+
readonly source?: ProcessExecutionSource;
|
|
41
|
+
/** When true, only running; when false, only exited; omit for all. */
|
|
42
|
+
readonly running?: boolean;
|
|
43
|
+
readonly teamId?: string;
|
|
44
|
+
readonly agentId?: string;
|
|
45
|
+
}
|
|
46
|
+
/** Events emitted by {@link ProcessRegistry.subscribe}. */
|
|
47
|
+
export type ProcessRegistryEvent = {
|
|
48
|
+
readonly type: 'started';
|
|
49
|
+
readonly execution: ProcessExecution;
|
|
50
|
+
} | {
|
|
51
|
+
readonly type: 'exited';
|
|
52
|
+
readonly execution: ProcessExecution;
|
|
53
|
+
} | {
|
|
54
|
+
readonly type: 'updated';
|
|
55
|
+
readonly execution: ProcessExecution;
|
|
56
|
+
};
|
|
57
|
+
/** Input for {@link ProcessRegistry.begin}. */
|
|
58
|
+
export interface ProcessExecutionBegin {
|
|
59
|
+
readonly command: string;
|
|
60
|
+
readonly args?: readonly string[];
|
|
61
|
+
readonly label?: string;
|
|
62
|
+
readonly source?: ProcessExecutionSource;
|
|
63
|
+
readonly teamId?: string;
|
|
64
|
+
readonly agentId?: string;
|
|
65
|
+
readonly pid?: number;
|
|
66
|
+
/** Override start timestamp (ISO-8601); defaults to now. */
|
|
67
|
+
readonly startedAt?: string;
|
|
68
|
+
}
|
|
69
|
+
/** Completion patch for {@link ProcessRegistry.complete}. */
|
|
70
|
+
export interface ProcessExecutionComplete {
|
|
71
|
+
readonly exitCode?: number | null;
|
|
72
|
+
readonly pid?: number;
|
|
73
|
+
/** Override end timestamp (ISO-8601); defaults to now. */
|
|
74
|
+
readonly exitedAt?: string;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Queryable registry of process executions.
|
|
78
|
+
*
|
|
79
|
+
* Implementations are process-local. Share one instance across every
|
|
80
|
+
* {@link import('./process-executor').NodeProcessExecutor} that should appear
|
|
81
|
+
* in the same watch list.
|
|
82
|
+
*/
|
|
83
|
+
export interface ProcessRegistry {
|
|
84
|
+
/** Snapshot of tracked executions (newest-last), optionally filtered. */
|
|
85
|
+
listExecutions(filter?: ProcessExecutionFilter): readonly ProcessExecution[];
|
|
86
|
+
/** Lookup by id. */
|
|
87
|
+
getExecution(id: string): ProcessExecution | undefined;
|
|
88
|
+
/**
|
|
89
|
+
* Subscribe to start / exit / field-update events.
|
|
90
|
+
* @returns unsubscribe function
|
|
91
|
+
*/
|
|
92
|
+
subscribe(listener: (event: ProcessRegistryEvent) => void): () => void;
|
|
93
|
+
/** Record a new execution as running; returns its id. */
|
|
94
|
+
begin(input: ProcessExecutionBegin): string;
|
|
95
|
+
/** Patch mutable fields while running (e.g. pid once available). */
|
|
96
|
+
update(id: string, patch: Pick<Partial<ProcessExecution>, 'pid' | 'label'>): void;
|
|
97
|
+
/** Mark an execution exited (idempotent if already exited). */
|
|
98
|
+
complete(id: string, update?: ProcessExecutionComplete): void;
|
|
99
|
+
/** Drop all tracked executions (tests / shutdown). */
|
|
100
|
+
clear(): void;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Configuration for {@link createInMemoryProcessRegistry}.
|
|
104
|
+
*/
|
|
105
|
+
export interface InMemoryProcessRegistryOptions {
|
|
106
|
+
/**
|
|
107
|
+
* Maximum retained executions. When exceeded, oldest **exited** entries
|
|
108
|
+
* are dropped first; if still over limit, oldest entries overall.
|
|
109
|
+
* Default: 1000.
|
|
110
|
+
*/
|
|
111
|
+
readonly maxEntries?: number;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Default in-memory {@link ProcessRegistry}.
|
|
115
|
+
*
|
|
116
|
+
* Create one per process (or per spur-serve) and inject it into every
|
|
117
|
+
* `NodeProcessExecutor` that should contribute to the shared watch list:
|
|
118
|
+
*
|
|
119
|
+
* ```ts
|
|
120
|
+
* const registry = createInMemoryProcessRegistry();
|
|
121
|
+
* const exec = new NodeProcessExecutor({ registry });
|
|
122
|
+
* ```
|
|
123
|
+
*/
|
|
124
|
+
export declare class InMemoryProcessRegistry implements ProcessRegistry {
|
|
125
|
+
private readonly maxEntries;
|
|
126
|
+
private readonly byId;
|
|
127
|
+
/** Insertion order for retention / list ordering. */
|
|
128
|
+
private readonly order;
|
|
129
|
+
private readonly listeners;
|
|
130
|
+
constructor(options?: InMemoryProcessRegistryOptions);
|
|
131
|
+
listExecutions(filter?: ProcessExecutionFilter): readonly ProcessExecution[];
|
|
132
|
+
getExecution(id: string): ProcessExecution | undefined;
|
|
133
|
+
subscribe(listener: (event: ProcessRegistryEvent) => void): () => void;
|
|
134
|
+
begin(input: ProcessExecutionBegin): string;
|
|
135
|
+
update(id: string, patch: Pick<Partial<ProcessExecution>, 'pid' | 'label'>): void;
|
|
136
|
+
complete(id: string, update?: ProcessExecutionComplete): void;
|
|
137
|
+
clear(): void;
|
|
138
|
+
private emit;
|
|
139
|
+
private enforceCap;
|
|
140
|
+
}
|
|
141
|
+
/** Factory for a default in-memory registry (preferred public constructor). */
|
|
142
|
+
export declare function createInMemoryProcessRegistry(options?: InMemoryProcessRegistryOptions): ProcessRegistry;
|
|
143
|
+
//# sourceMappingURL=process-registry.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"process-registry.d.ts","sourceRoot":"","sources":["../src/process-registry.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,0EAA0E;AAC1E,MAAM,MAAM,sBAAsB,GAAG,YAAY,GAAG,UAAU,GAAG,OAAO,CAAC;AAEzE,+CAA+C;AAC/C,MAAM,MAAM,sBAAsB,GAAG,SAAS,GAAG,QAAQ,CAAC;AAE1D;;;GAGG;AACH,MAAM,WAAW,gBAAgB;IAC7B,sEAAsE;IACtE,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,wDAAwD;IACxD,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;IACjC,oEAAoE;IACpE,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IACtB,2BAA2B;IAC3B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,qCAAqC;IACrC,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,4EAA4E;IAC5E,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAClC,QAAQ,CAAC,MAAM,EAAE,sBAAsB,CAAC;IACxC,oDAAoD;IACpD,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,wDAAwD;IACxD,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,MAAM,EAAE,sBAAsB,CAAC;CAC3C;AAED,yDAAyD;AACzD,MAAM,WAAW,sBAAsB;IACnC,QAAQ,CAAC,MAAM,CAAC,EAAE,sBAAsB,CAAC;IACzC,sEAAsE;IACtE,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC;IAC3B,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;CAC7B;AAED,2DAA2D;AAC3D,MAAM,MAAM,oBAAoB,GAC1B;IAAE,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IAAC,QAAQ,CAAC,SAAS,EAAE,gBAAgB,CAAA;CAAE,GAClE;IAAE,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC;IAAC,QAAQ,CAAC,SAAS,EAAE,gBAAgB,CAAA;CAAE,GACjE;IAAE,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IAAC,QAAQ,CAAC,SAAS,EAAE,gBAAgB,CAAA;CAAE,CAAC;AAEzE,+CAA+C;AAC/C,MAAM,WAAW,qBAAqB;IAClC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,IAAI,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAClC,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,MAAM,CAAC,EAAE,sBAAsB,CAAC;IACzC,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IACtB,4DAA4D;IAC5D,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;CAC/B;AAED,6DAA6D;AAC7D,MAAM,WAAW,wBAAwB;IACrC,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAClC,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IACtB,0DAA0D;IAC1D,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;CAC9B;AAED;;;;;;GAMG;AACH,MAAM,WAAW,eAAe;IAC5B,yEAAyE;IACzE,cAAc,CAAC,MAAM,CAAC,EAAE,sBAAsB,GAAG,SAAS,gBAAgB,EAAE,CAAC;IAE7E,oBAAoB;IACpB,YAAY,CAAC,EAAE,EAAE,MAAM,GAAG,gBAAgB,GAAG,SAAS,CAAC;IAEvD;;;OAGG;IACH,SAAS,CAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,oBAAoB,KAAK,IAAI,GAAG,MAAM,IAAI,CAAC;IAEvE,yDAAyD;IACzD,KAAK,CAAC,KAAK,EAAE,qBAAqB,GAAG,MAAM,CAAC;IAE5C,oEAAoE;IACpE,MAAM,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,IAAI,CAAC,OAAO,CAAC,gBAAgB,CAAC,EAAE,KAAK,GAAG,OAAO,CAAC,GAAG,IAAI,CAAC;IAElF,+DAA+D;IAC/D,QAAQ,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,wBAAwB,GAAG,IAAI,CAAC;IAE9D,sDAAsD;IACtD,KAAK,IAAI,IAAI,CAAC;CACjB;AAED;;GAEG;AACH,MAAM,WAAW,8BAA8B;IAC3C;;;;OAIG;IACH,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;CAChC;AASD;;;;;;;;;;GAUG;AACH,qBAAa,uBAAwB,YAAW,eAAe;IAC3D,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;IACpC,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAuC;IAC5D,qDAAqD;IACrD,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAgB;IACtC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAoD;gBAElE,OAAO,GAAE,8BAAmC;IAIxD,cAAc,CAAC,MAAM,CAAC,EAAE,sBAAsB,GAAG,SAAS,gBAAgB,EAAE;IAe5E,YAAY,CAAC,EAAE,EAAE,MAAM,GAAG,gBAAgB,GAAG,SAAS;IAItD,SAAS,CAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,oBAAoB,KAAK,IAAI,GAAG,MAAM,IAAI;IAOtE,KAAK,CAAC,KAAK,EAAE,qBAAqB,GAAG,MAAM;IAqB3C,MAAM,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,IAAI,CAAC,OAAO,CAAC,gBAAgB,CAAC,EAAE,KAAK,GAAG,OAAO,CAAC,GAAG,IAAI;IAYjF,QAAQ,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,GAAE,wBAA6B,GAAG,IAAI;IAuBjE,KAAK,IAAI,IAAI;IAKb,OAAO,CAAC,IAAI;IAUZ,OAAO,CAAC,UAAU;CASrB;AAED,+EAA+E;AAC/E,wBAAgB,6BAA6B,CAAC,OAAO,CAAC,EAAE,8BAA8B,GAAG,eAAe,CAEvG"}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process registry of ProcessExecutor invocations (spur#0264 / M1).
|
|
3
|
+
*
|
|
4
|
+
* Tracks every `run` / `runStreaming` so board UIs (and other observers) can
|
|
5
|
+
* list all harness-launched processes — not only supervisor-owned agent loops.
|
|
6
|
+
* In-memory only; not durable across process restarts.
|
|
7
|
+
*/
|
|
8
|
+
let nextIdSeq = 0;
|
|
9
|
+
function allocateId() {
|
|
10
|
+
nextIdSeq += 1;
|
|
11
|
+
return `pe_${Date.now().toString(36)}_${nextIdSeq.toString(36)}`;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Default in-memory {@link ProcessRegistry}.
|
|
15
|
+
*
|
|
16
|
+
* Create one per process (or per spur-serve) and inject it into every
|
|
17
|
+
* `NodeProcessExecutor` that should contribute to the shared watch list:
|
|
18
|
+
*
|
|
19
|
+
* ```ts
|
|
20
|
+
* const registry = createInMemoryProcessRegistry();
|
|
21
|
+
* const exec = new NodeProcessExecutor({ registry });
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
export class InMemoryProcessRegistry {
|
|
25
|
+
maxEntries;
|
|
26
|
+
byId = new Map();
|
|
27
|
+
/** Insertion order for retention / list ordering. */
|
|
28
|
+
order = [];
|
|
29
|
+
listeners = new Set();
|
|
30
|
+
constructor(options = {}) {
|
|
31
|
+
this.maxEntries = options.maxEntries ?? 1000;
|
|
32
|
+
}
|
|
33
|
+
listExecutions(filter) {
|
|
34
|
+
const out = [];
|
|
35
|
+
for (const id of this.order) {
|
|
36
|
+
const exec = this.byId.get(id);
|
|
37
|
+
if (!exec)
|
|
38
|
+
continue;
|
|
39
|
+
if (filter?.source !== undefined && exec.source !== filter.source)
|
|
40
|
+
continue;
|
|
41
|
+
if (filter?.running === true && exec.status !== 'running')
|
|
42
|
+
continue;
|
|
43
|
+
if (filter?.running === false && exec.status !== 'exited')
|
|
44
|
+
continue;
|
|
45
|
+
if (filter?.teamId !== undefined && exec.teamId !== filter.teamId)
|
|
46
|
+
continue;
|
|
47
|
+
if (filter?.agentId !== undefined && exec.agentId !== filter.agentId)
|
|
48
|
+
continue;
|
|
49
|
+
out.push(exec);
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
getExecution(id) {
|
|
54
|
+
return this.byId.get(id);
|
|
55
|
+
}
|
|
56
|
+
subscribe(listener) {
|
|
57
|
+
this.listeners.add(listener);
|
|
58
|
+
return () => {
|
|
59
|
+
this.listeners.delete(listener);
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
begin(input) {
|
|
63
|
+
const id = allocateId();
|
|
64
|
+
const execution = {
|
|
65
|
+
id,
|
|
66
|
+
command: input.command,
|
|
67
|
+
args: Object.freeze([...(input.args ?? [])]),
|
|
68
|
+
source: input.source ?? 'other',
|
|
69
|
+
startedAt: input.startedAt ?? new Date().toISOString(),
|
|
70
|
+
status: 'running',
|
|
71
|
+
...(input.label !== undefined ? { label: input.label } : {}),
|
|
72
|
+
...(input.teamId !== undefined ? { teamId: input.teamId } : {}),
|
|
73
|
+
...(input.agentId !== undefined ? { agentId: input.agentId } : {}),
|
|
74
|
+
...(input.pid !== undefined ? { pid: input.pid } : {}),
|
|
75
|
+
};
|
|
76
|
+
this.byId.set(id, execution);
|
|
77
|
+
this.order.push(id);
|
|
78
|
+
this.enforceCap();
|
|
79
|
+
this.emit({ type: 'started', execution });
|
|
80
|
+
return id;
|
|
81
|
+
}
|
|
82
|
+
update(id, patch) {
|
|
83
|
+
const current = this.byId.get(id);
|
|
84
|
+
if (!current)
|
|
85
|
+
return;
|
|
86
|
+
const next = {
|
|
87
|
+
...current,
|
|
88
|
+
...(patch.pid !== undefined ? { pid: patch.pid } : {}),
|
|
89
|
+
...(patch.label !== undefined ? { label: patch.label } : {}),
|
|
90
|
+
};
|
|
91
|
+
this.byId.set(id, next);
|
|
92
|
+
this.emit({ type: 'updated', execution: next });
|
|
93
|
+
}
|
|
94
|
+
complete(id, update = {}) {
|
|
95
|
+
const current = this.byId.get(id);
|
|
96
|
+
if (!current)
|
|
97
|
+
return;
|
|
98
|
+
if (current.status === 'exited') {
|
|
99
|
+
// Already completed — still allow pid fill-in if missing.
|
|
100
|
+
if (update.pid !== undefined && current.pid === undefined) {
|
|
101
|
+
const patched = { ...current, pid: update.pid };
|
|
102
|
+
this.byId.set(id, patched);
|
|
103
|
+
this.emit({ type: 'updated', execution: patched });
|
|
104
|
+
}
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const next = {
|
|
108
|
+
...current,
|
|
109
|
+
status: 'exited',
|
|
110
|
+
exitedAt: update.exitedAt ?? new Date().toISOString(),
|
|
111
|
+
exitCode: update.exitCode !== undefined ? update.exitCode : null,
|
|
112
|
+
...(update.pid !== undefined ? { pid: update.pid } : {}),
|
|
113
|
+
};
|
|
114
|
+
this.byId.set(id, next);
|
|
115
|
+
this.emit({ type: 'exited', execution: next });
|
|
116
|
+
}
|
|
117
|
+
clear() {
|
|
118
|
+
this.byId.clear();
|
|
119
|
+
this.order.length = 0;
|
|
120
|
+
}
|
|
121
|
+
emit(event) {
|
|
122
|
+
for (const listener of this.listeners) {
|
|
123
|
+
try {
|
|
124
|
+
listener(event);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
// Listener errors must not break process tracking.
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
enforceCap() {
|
|
132
|
+
while (this.order.length > this.maxEntries) {
|
|
133
|
+
// Prefer dropping oldest exited entries.
|
|
134
|
+
let dropIdx = this.order.findIndex((id) => this.byId.get(id)?.status === 'exited');
|
|
135
|
+
if (dropIdx < 0)
|
|
136
|
+
dropIdx = 0;
|
|
137
|
+
const [removed] = this.order.splice(dropIdx, 1);
|
|
138
|
+
if (removed !== undefined)
|
|
139
|
+
this.byId.delete(removed);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/** Factory for a default in-memory registry (preferred public constructor). */
|
|
144
|
+
export function createInMemoryProcessRegistry(options) {
|
|
145
|
+
return new InMemoryProcessRegistry(options);
|
|
146
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gobing-ai/ts-runtime",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.10",
|
|
4
4
|
"description": "@gobing-ai/ts-runtime — Runtime abstractions for Bun, Node, and Cloudflare Workers.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"typescript",
|
|
@@ -58,17 +58,17 @@
|
|
|
58
58
|
"release": "echo 'Manual publish is disabled. Releases go through GitHub Actions via Trusted Publishing — push a tag: git tag @gobing-ai/ts-runtime-v<version> && git push --tags' && exit 1"
|
|
59
59
|
},
|
|
60
60
|
"dependencies": {
|
|
61
|
-
"@gobing-ai/ts-utils": "^0.4.
|
|
61
|
+
"@gobing-ai/ts-utils": "^0.4.10",
|
|
62
62
|
"execa": "^9.5.0",
|
|
63
63
|
"yaml": "^2.7.0",
|
|
64
64
|
"zod": "^4.1.0"
|
|
65
65
|
},
|
|
66
66
|
"devDependencies": {
|
|
67
|
-
"@gobing-ai/ts-db": "^0.4.
|
|
67
|
+
"@gobing-ai/ts-db": "^0.4.10",
|
|
68
68
|
"@types/bun": "1.3.14"
|
|
69
69
|
},
|
|
70
70
|
"peerDependencies": {
|
|
71
|
-
"@gobing-ai/ts-db": "^0.4.
|
|
71
|
+
"@gobing-ai/ts-db": "^0.4.10"
|
|
72
72
|
},
|
|
73
73
|
"peerDependenciesMeta": {
|
|
74
74
|
"@gobing-ai/ts-db": {
|
package/src/index.ts
CHANGED
|
@@ -31,6 +31,18 @@ export type {
|
|
|
31
31
|
TracerPort,
|
|
32
32
|
} from './process-executor';
|
|
33
33
|
export { NodeProcessExecutor, ProcessExecutor } from './process-executor';
|
|
34
|
+
export type {
|
|
35
|
+
InMemoryProcessRegistryOptions,
|
|
36
|
+
ProcessExecution,
|
|
37
|
+
ProcessExecutionBegin,
|
|
38
|
+
ProcessExecutionComplete,
|
|
39
|
+
ProcessExecutionFilter,
|
|
40
|
+
ProcessExecutionSource,
|
|
41
|
+
ProcessExecutionStatus,
|
|
42
|
+
ProcessRegistry,
|
|
43
|
+
ProcessRegistryEvent,
|
|
44
|
+
} from './process-registry';
|
|
45
|
+
export { createInMemoryProcessRegistry, InMemoryProcessRegistry } from './process-registry';
|
|
34
46
|
export { cloudflareWorkersFactory } from './runtime-cf';
|
|
35
47
|
export type { RuntimeFactory } from './runtime-factory';
|
|
36
48
|
export { _resetNodeFileSystem, nodeBunFactory } from './runtime-node-bun';
|
package/src/process-executor.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { isatty } from 'node:tty';
|
|
2
2
|
import { type Options as ExecaOptions, execa } from 'execa';
|
|
3
|
+
import type { ProcessExecutionSource, ProcessRegistry } from './process-registry';
|
|
3
4
|
|
|
4
5
|
// ── Types ────────────────────────────────────────────────────────────────
|
|
5
6
|
|
|
@@ -13,6 +14,12 @@ export interface ProcessExecutorConfig {
|
|
|
13
14
|
output?: OutputPolicy;
|
|
14
15
|
events?: ProcessEventSink;
|
|
15
16
|
tracer?: TracerPort;
|
|
17
|
+
/**
|
|
18
|
+
* Optional process registry (spur#0264). When set, every `run` / `runStreaming`
|
|
19
|
+
* invocation is recorded for list/subscribe consumers (e.g. Spur Processes tab).
|
|
20
|
+
* Share one registry across all executors that should appear in the same watch list.
|
|
21
|
+
*/
|
|
22
|
+
registry?: ProcessRegistry;
|
|
16
23
|
}
|
|
17
24
|
|
|
18
25
|
/** Options for spawning a child process. */
|
|
@@ -28,6 +35,14 @@ export interface ProcessOptions {
|
|
|
28
35
|
forceBuffered?: boolean;
|
|
29
36
|
/** AbortSignal forwarded to execa as `cancelSignal` — aborts the child process when fired. */
|
|
30
37
|
signal?: AbortSignal;
|
|
38
|
+
/**
|
|
39
|
+
* Registry metadata (spur#0264). Defaults: source `'one-shot'` for `run`.
|
|
40
|
+
* Pass `source: 'supervisor'` (and optional teamId/agentId) when the spawn is
|
|
41
|
+
* a supervised team agent loop.
|
|
42
|
+
*/
|
|
43
|
+
source?: ProcessExecutionSource;
|
|
44
|
+
teamId?: string;
|
|
45
|
+
agentId?: string;
|
|
31
46
|
}
|
|
32
47
|
|
|
33
48
|
/** Result of a completed child process, including exit code, captured output, and duration. */
|
|
@@ -80,6 +95,13 @@ export interface PipeProcessOptions {
|
|
|
80
95
|
cwd?: string;
|
|
81
96
|
env?: Record<string, string>;
|
|
82
97
|
label?: string;
|
|
98
|
+
/**
|
|
99
|
+
* Registry metadata (spur#0264). Defaults: source `'other'` for streaming.
|
|
100
|
+
* Supervised agent loops should pass `source: 'supervisor'` + agentId.
|
|
101
|
+
*/
|
|
102
|
+
source?: ProcessExecutionSource;
|
|
103
|
+
teamId?: string;
|
|
104
|
+
agentId?: string;
|
|
83
105
|
}
|
|
84
106
|
|
|
85
107
|
/** Signal values accepted by subprocess kill. */
|
|
@@ -164,18 +186,27 @@ export class NodeProcessExecutor implements ProcessExecutor {
|
|
|
164
186
|
signal: options.signal,
|
|
165
187
|
});
|
|
166
188
|
const startedAt = Date.now();
|
|
189
|
+
const startedIso = new Date(startedAt).toISOString();
|
|
190
|
+
const registryId = this.beginRegistry(options.command, args, {
|
|
191
|
+
label: options.label,
|
|
192
|
+
source: options.source ?? 'one-shot',
|
|
193
|
+
teamId: options.teamId,
|
|
194
|
+
agentId: options.agentId,
|
|
195
|
+
startedAt: startedIso,
|
|
196
|
+
});
|
|
167
197
|
this.emitProcessEvent('process.started', {
|
|
168
198
|
command: options.command,
|
|
169
199
|
args,
|
|
170
200
|
exitCode: null,
|
|
171
201
|
durationMs: 0,
|
|
172
202
|
reason: 'exit',
|
|
173
|
-
timestamp:
|
|
203
|
+
timestamp: startedIso,
|
|
174
204
|
...(options.label !== undefined ? { label: options.label } : {}),
|
|
175
205
|
});
|
|
176
206
|
|
|
177
207
|
try {
|
|
178
208
|
const result = await execa(options.command, args, execaOptions);
|
|
209
|
+
// execa@9 Result does not expose pid — buffered runs leave pid unset.
|
|
179
210
|
const processResult = {
|
|
180
211
|
command: options.command,
|
|
181
212
|
args,
|
|
@@ -185,6 +216,7 @@ export class NodeProcessExecutor implements ProcessExecutor {
|
|
|
185
216
|
...(result.signalDescription !== undefined ? { signal: result.signalDescription } : {}),
|
|
186
217
|
durationMs: result.durationMs,
|
|
187
218
|
};
|
|
219
|
+
this.completeRegistry(registryId, processResult.exitCode);
|
|
188
220
|
this.emitExitedFromResult(options, processResult, result);
|
|
189
221
|
return processResult;
|
|
190
222
|
} catch (error) {
|
|
@@ -211,6 +243,7 @@ export class NodeProcessExecutor implements ProcessExecutor {
|
|
|
211
243
|
: {}),
|
|
212
244
|
durationMs: failed.durationMs ?? Date.now() - startedAt,
|
|
213
245
|
};
|
|
246
|
+
this.completeRegistry(registryId, processResult.exitCode);
|
|
214
247
|
this.emitExitedFromResult(options, processResult, error, error);
|
|
215
248
|
if (options.rejectOnError) throw error;
|
|
216
249
|
return processResult;
|
|
@@ -226,15 +259,24 @@ export class NodeProcessExecutor implements ProcessExecutor {
|
|
|
226
259
|
runStreaming(options: PipeProcessOptions): PipeProcess {
|
|
227
260
|
const args = options.args ?? [];
|
|
228
261
|
void this.config.tracer?.traceAsync('process.runStreaming', async () => undefined).catch(() => undefined);
|
|
262
|
+
const startedAt = Date.now();
|
|
263
|
+
const startedIso = new Date(startedAt).toISOString();
|
|
264
|
+
// Begin registry before spawn so failed spawns still appear (then complete as error).
|
|
265
|
+
const registryId = this.beginRegistry(options.command, args, {
|
|
266
|
+
label: options.label,
|
|
267
|
+
source: options.source ?? 'other',
|
|
268
|
+
teamId: options.teamId,
|
|
269
|
+
agentId: options.agentId,
|
|
270
|
+
startedAt: startedIso,
|
|
271
|
+
});
|
|
229
272
|
try {
|
|
230
|
-
const startedAt = Date.now();
|
|
231
273
|
this.emitProcessEvent('process.started', {
|
|
232
274
|
command: options.command,
|
|
233
275
|
args,
|
|
234
276
|
exitCode: null,
|
|
235
277
|
durationMs: 0,
|
|
236
278
|
reason: 'exit',
|
|
237
|
-
timestamp:
|
|
279
|
+
timestamp: startedIso,
|
|
238
280
|
...(options.label !== undefined ? { label: options.label } : {}),
|
|
239
281
|
});
|
|
240
282
|
const subprocess = Bun.spawn({
|
|
@@ -245,13 +287,20 @@ export class NodeProcessExecutor implements ProcessExecutor {
|
|
|
245
287
|
...(options.cwd !== undefined ? { cwd: options.cwd } : {}),
|
|
246
288
|
...(options.env !== undefined ? { env: options.env } : {}),
|
|
247
289
|
});
|
|
248
|
-
|
|
290
|
+
const pipe = new BunPipeProcess(subprocess);
|
|
291
|
+
if (pipe.pid !== null) {
|
|
292
|
+
this.config.registry?.update(registryId, { pid: pipe.pid });
|
|
293
|
+
}
|
|
294
|
+
return new ObservedPipeProcess(pipe, this.config.events, {
|
|
249
295
|
command: options.command,
|
|
250
296
|
args,
|
|
251
297
|
startedAt,
|
|
298
|
+
registry: this.config.registry,
|
|
299
|
+
registryId,
|
|
252
300
|
...(options.label !== undefined ? { label: options.label } : {}),
|
|
253
301
|
});
|
|
254
302
|
} catch (error) {
|
|
303
|
+
this.completeRegistry(registryId, null);
|
|
255
304
|
this.emitProcessEvent('process.exited', {
|
|
256
305
|
command: options.command,
|
|
257
306
|
args,
|
|
@@ -271,6 +320,38 @@ export class NodeProcessExecutor implements ProcessExecutor {
|
|
|
271
320
|
return await this.config.tracer.traceAsync(name, async () => await fn());
|
|
272
321
|
}
|
|
273
322
|
|
|
323
|
+
private beginRegistry(
|
|
324
|
+
command: string,
|
|
325
|
+
args: string[],
|
|
326
|
+
meta: {
|
|
327
|
+
label?: string;
|
|
328
|
+
source: ProcessExecutionSource;
|
|
329
|
+
teamId?: string;
|
|
330
|
+
agentId?: string;
|
|
331
|
+
startedAt: string;
|
|
332
|
+
},
|
|
333
|
+
): string {
|
|
334
|
+
const registry = this.config.registry;
|
|
335
|
+
if (!registry) return '';
|
|
336
|
+
return registry.begin({
|
|
337
|
+
command,
|
|
338
|
+
args,
|
|
339
|
+
source: meta.source,
|
|
340
|
+
startedAt: meta.startedAt,
|
|
341
|
+
...(meta.label !== undefined ? { label: meta.label } : {}),
|
|
342
|
+
...(meta.teamId !== undefined ? { teamId: meta.teamId } : {}),
|
|
343
|
+
...(meta.agentId !== undefined ? { agentId: meta.agentId } : {}),
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
private completeRegistry(id: string, exitCode: number | null, pid?: number): void {
|
|
348
|
+
if (!id || !this.config.registry) return;
|
|
349
|
+
this.config.registry.complete(id, {
|
|
350
|
+
exitCode,
|
|
351
|
+
...(pid !== undefined ? { pid } : {}),
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
274
355
|
private emitExitedFromResult(
|
|
275
356
|
options: ProcessOptions,
|
|
276
357
|
result: ProcessResult,
|
|
@@ -315,9 +396,17 @@ class ObservedPipeProcess implements PipeProcess {
|
|
|
315
396
|
args: string[];
|
|
316
397
|
startedAt: number;
|
|
317
398
|
label?: string;
|
|
399
|
+
registry?: ProcessRegistry;
|
|
400
|
+
registryId: string;
|
|
318
401
|
},
|
|
319
402
|
) {
|
|
320
403
|
this.exited = inner.exited.then((exitCode) => {
|
|
404
|
+
if (context.registry && context.registryId) {
|
|
405
|
+
context.registry.complete(context.registryId, {
|
|
406
|
+
exitCode,
|
|
407
|
+
...(inner.pid !== null ? { pid: inner.pid } : {}),
|
|
408
|
+
});
|
|
409
|
+
}
|
|
321
410
|
events?.emit('process.exited', {
|
|
322
411
|
command: context.command,
|
|
323
412
|
args: context.args,
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process registry of ProcessExecutor invocations (spur#0264 / M1).
|
|
3
|
+
*
|
|
4
|
+
* Tracks every `run` / `runStreaming` so board UIs (and other observers) can
|
|
5
|
+
* list all harness-launched processes — not only supervisor-owned agent loops.
|
|
6
|
+
* In-memory only; not durable across process restarts.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Who initiated the spawn — used for filtering/grouping in consumers. */
|
|
10
|
+
export type ProcessExecutionSource = 'supervisor' | 'one-shot' | 'other';
|
|
11
|
+
|
|
12
|
+
/** Lifecycle status of a tracked execution. */
|
|
13
|
+
export type ProcessExecutionStatus = 'running' | 'exited';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* One ProcessExecutor invocation — the minimum metadata Spur's Processes tab
|
|
17
|
+
* (and similar consumers) need for a full watch list.
|
|
18
|
+
*/
|
|
19
|
+
export interface ProcessExecution {
|
|
20
|
+
/** Unique id for this execution (stable for the process lifetime). */
|
|
21
|
+
readonly id: string;
|
|
22
|
+
/** Optional human label (e.g. `agent:alpha-claude`). */
|
|
23
|
+
readonly label?: string;
|
|
24
|
+
readonly command: string;
|
|
25
|
+
readonly args: readonly string[];
|
|
26
|
+
/** OS pid when known (may arrive after spawn for buffered runs). */
|
|
27
|
+
readonly pid?: number;
|
|
28
|
+
/** ISO-8601 start time. */
|
|
29
|
+
readonly startedAt: string;
|
|
30
|
+
/** ISO-8601 end time when exited. */
|
|
31
|
+
readonly exitedAt?: string;
|
|
32
|
+
/** Exit code when exited (`null` for signal/timeout/error without code). */
|
|
33
|
+
readonly exitCode?: number | null;
|
|
34
|
+
readonly source: ProcessExecutionSource;
|
|
35
|
+
/** Optional team association for board grouping. */
|
|
36
|
+
readonly teamId?: string;
|
|
37
|
+
/** Optional agent id when the spawn is agent-scoped. */
|
|
38
|
+
readonly agentId?: string;
|
|
39
|
+
readonly status: ProcessExecutionStatus;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Filter for {@link ProcessRegistry.listExecutions}. */
|
|
43
|
+
export interface ProcessExecutionFilter {
|
|
44
|
+
readonly source?: ProcessExecutionSource;
|
|
45
|
+
/** When true, only running; when false, only exited; omit for all. */
|
|
46
|
+
readonly running?: boolean;
|
|
47
|
+
readonly teamId?: string;
|
|
48
|
+
readonly agentId?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Events emitted by {@link ProcessRegistry.subscribe}. */
|
|
52
|
+
export type ProcessRegistryEvent =
|
|
53
|
+
| { readonly type: 'started'; readonly execution: ProcessExecution }
|
|
54
|
+
| { readonly type: 'exited'; readonly execution: ProcessExecution }
|
|
55
|
+
| { readonly type: 'updated'; readonly execution: ProcessExecution };
|
|
56
|
+
|
|
57
|
+
/** Input for {@link ProcessRegistry.begin}. */
|
|
58
|
+
export interface ProcessExecutionBegin {
|
|
59
|
+
readonly command: string;
|
|
60
|
+
readonly args?: readonly string[];
|
|
61
|
+
readonly label?: string;
|
|
62
|
+
readonly source?: ProcessExecutionSource;
|
|
63
|
+
readonly teamId?: string;
|
|
64
|
+
readonly agentId?: string;
|
|
65
|
+
readonly pid?: number;
|
|
66
|
+
/** Override start timestamp (ISO-8601); defaults to now. */
|
|
67
|
+
readonly startedAt?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Completion patch for {@link ProcessRegistry.complete}. */
|
|
71
|
+
export interface ProcessExecutionComplete {
|
|
72
|
+
readonly exitCode?: number | null;
|
|
73
|
+
readonly pid?: number;
|
|
74
|
+
/** Override end timestamp (ISO-8601); defaults to now. */
|
|
75
|
+
readonly exitedAt?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Queryable registry of process executions.
|
|
80
|
+
*
|
|
81
|
+
* Implementations are process-local. Share one instance across every
|
|
82
|
+
* {@link import('./process-executor').NodeProcessExecutor} that should appear
|
|
83
|
+
* in the same watch list.
|
|
84
|
+
*/
|
|
85
|
+
export interface ProcessRegistry {
|
|
86
|
+
/** Snapshot of tracked executions (newest-last), optionally filtered. */
|
|
87
|
+
listExecutions(filter?: ProcessExecutionFilter): readonly ProcessExecution[];
|
|
88
|
+
|
|
89
|
+
/** Lookup by id. */
|
|
90
|
+
getExecution(id: string): ProcessExecution | undefined;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Subscribe to start / exit / field-update events.
|
|
94
|
+
* @returns unsubscribe function
|
|
95
|
+
*/
|
|
96
|
+
subscribe(listener: (event: ProcessRegistryEvent) => void): () => void;
|
|
97
|
+
|
|
98
|
+
/** Record a new execution as running; returns its id. */
|
|
99
|
+
begin(input: ProcessExecutionBegin): string;
|
|
100
|
+
|
|
101
|
+
/** Patch mutable fields while running (e.g. pid once available). */
|
|
102
|
+
update(id: string, patch: Pick<Partial<ProcessExecution>, 'pid' | 'label'>): void;
|
|
103
|
+
|
|
104
|
+
/** Mark an execution exited (idempotent if already exited). */
|
|
105
|
+
complete(id: string, update?: ProcessExecutionComplete): void;
|
|
106
|
+
|
|
107
|
+
/** Drop all tracked executions (tests / shutdown). */
|
|
108
|
+
clear(): void;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Configuration for {@link createInMemoryProcessRegistry}.
|
|
113
|
+
*/
|
|
114
|
+
export interface InMemoryProcessRegistryOptions {
|
|
115
|
+
/**
|
|
116
|
+
* Maximum retained executions. When exceeded, oldest **exited** entries
|
|
117
|
+
* are dropped first; if still over limit, oldest entries overall.
|
|
118
|
+
* Default: 1000.
|
|
119
|
+
*/
|
|
120
|
+
readonly maxEntries?: number;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
let nextIdSeq = 0;
|
|
124
|
+
|
|
125
|
+
function allocateId(): string {
|
|
126
|
+
nextIdSeq += 1;
|
|
127
|
+
return `pe_${Date.now().toString(36)}_${nextIdSeq.toString(36)}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Default in-memory {@link ProcessRegistry}.
|
|
132
|
+
*
|
|
133
|
+
* Create one per process (or per spur-serve) and inject it into every
|
|
134
|
+
* `NodeProcessExecutor` that should contribute to the shared watch list:
|
|
135
|
+
*
|
|
136
|
+
* ```ts
|
|
137
|
+
* const registry = createInMemoryProcessRegistry();
|
|
138
|
+
* const exec = new NodeProcessExecutor({ registry });
|
|
139
|
+
* ```
|
|
140
|
+
*/
|
|
141
|
+
export class InMemoryProcessRegistry implements ProcessRegistry {
|
|
142
|
+
private readonly maxEntries: number;
|
|
143
|
+
private readonly byId = new Map<string, ProcessExecution>();
|
|
144
|
+
/** Insertion order for retention / list ordering. */
|
|
145
|
+
private readonly order: string[] = [];
|
|
146
|
+
private readonly listeners = new Set<(event: ProcessRegistryEvent) => void>();
|
|
147
|
+
|
|
148
|
+
constructor(options: InMemoryProcessRegistryOptions = {}) {
|
|
149
|
+
this.maxEntries = options.maxEntries ?? 1000;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
listExecutions(filter?: ProcessExecutionFilter): readonly ProcessExecution[] {
|
|
153
|
+
const out: ProcessExecution[] = [];
|
|
154
|
+
for (const id of this.order) {
|
|
155
|
+
const exec = this.byId.get(id);
|
|
156
|
+
if (!exec) continue;
|
|
157
|
+
if (filter?.source !== undefined && exec.source !== filter.source) continue;
|
|
158
|
+
if (filter?.running === true && exec.status !== 'running') continue;
|
|
159
|
+
if (filter?.running === false && exec.status !== 'exited') continue;
|
|
160
|
+
if (filter?.teamId !== undefined && exec.teamId !== filter.teamId) continue;
|
|
161
|
+
if (filter?.agentId !== undefined && exec.agentId !== filter.agentId) continue;
|
|
162
|
+
out.push(exec);
|
|
163
|
+
}
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
getExecution(id: string): ProcessExecution | undefined {
|
|
168
|
+
return this.byId.get(id);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
subscribe(listener: (event: ProcessRegistryEvent) => void): () => void {
|
|
172
|
+
this.listeners.add(listener);
|
|
173
|
+
return () => {
|
|
174
|
+
this.listeners.delete(listener);
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
begin(input: ProcessExecutionBegin): string {
|
|
179
|
+
const id = allocateId();
|
|
180
|
+
const execution: ProcessExecution = {
|
|
181
|
+
id,
|
|
182
|
+
command: input.command,
|
|
183
|
+
args: Object.freeze([...(input.args ?? [])]),
|
|
184
|
+
source: input.source ?? 'other',
|
|
185
|
+
startedAt: input.startedAt ?? new Date().toISOString(),
|
|
186
|
+
status: 'running',
|
|
187
|
+
...(input.label !== undefined ? { label: input.label } : {}),
|
|
188
|
+
...(input.teamId !== undefined ? { teamId: input.teamId } : {}),
|
|
189
|
+
...(input.agentId !== undefined ? { agentId: input.agentId } : {}),
|
|
190
|
+
...(input.pid !== undefined ? { pid: input.pid } : {}),
|
|
191
|
+
};
|
|
192
|
+
this.byId.set(id, execution);
|
|
193
|
+
this.order.push(id);
|
|
194
|
+
this.enforceCap();
|
|
195
|
+
this.emit({ type: 'started', execution });
|
|
196
|
+
return id;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
update(id: string, patch: Pick<Partial<ProcessExecution>, 'pid' | 'label'>): void {
|
|
200
|
+
const current = this.byId.get(id);
|
|
201
|
+
if (!current) return;
|
|
202
|
+
const next: ProcessExecution = {
|
|
203
|
+
...current,
|
|
204
|
+
...(patch.pid !== undefined ? { pid: patch.pid } : {}),
|
|
205
|
+
...(patch.label !== undefined ? { label: patch.label } : {}),
|
|
206
|
+
};
|
|
207
|
+
this.byId.set(id, next);
|
|
208
|
+
this.emit({ type: 'updated', execution: next });
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
complete(id: string, update: ProcessExecutionComplete = {}): void {
|
|
212
|
+
const current = this.byId.get(id);
|
|
213
|
+
if (!current) return;
|
|
214
|
+
if (current.status === 'exited') {
|
|
215
|
+
// Already completed — still allow pid fill-in if missing.
|
|
216
|
+
if (update.pid !== undefined && current.pid === undefined) {
|
|
217
|
+
const patched: ProcessExecution = { ...current, pid: update.pid };
|
|
218
|
+
this.byId.set(id, patched);
|
|
219
|
+
this.emit({ type: 'updated', execution: patched });
|
|
220
|
+
}
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
const next: ProcessExecution = {
|
|
224
|
+
...current,
|
|
225
|
+
status: 'exited',
|
|
226
|
+
exitedAt: update.exitedAt ?? new Date().toISOString(),
|
|
227
|
+
exitCode: update.exitCode !== undefined ? update.exitCode : null,
|
|
228
|
+
...(update.pid !== undefined ? { pid: update.pid } : {}),
|
|
229
|
+
};
|
|
230
|
+
this.byId.set(id, next);
|
|
231
|
+
this.emit({ type: 'exited', execution: next });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
clear(): void {
|
|
235
|
+
this.byId.clear();
|
|
236
|
+
this.order.length = 0;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
private emit(event: ProcessRegistryEvent): void {
|
|
240
|
+
for (const listener of this.listeners) {
|
|
241
|
+
try {
|
|
242
|
+
listener(event);
|
|
243
|
+
} catch {
|
|
244
|
+
// Listener errors must not break process tracking.
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private enforceCap(): void {
|
|
250
|
+
while (this.order.length > this.maxEntries) {
|
|
251
|
+
// Prefer dropping oldest exited entries.
|
|
252
|
+
let dropIdx = this.order.findIndex((id) => this.byId.get(id)?.status === 'exited');
|
|
253
|
+
if (dropIdx < 0) dropIdx = 0;
|
|
254
|
+
const [removed] = this.order.splice(dropIdx, 1);
|
|
255
|
+
if (removed !== undefined) this.byId.delete(removed);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Factory for a default in-memory registry (preferred public constructor). */
|
|
261
|
+
export function createInMemoryProcessRegistry(options?: InMemoryProcessRegistryOptions): ProcessRegistry {
|
|
262
|
+
return new InMemoryProcessRegistry(options);
|
|
263
|
+
}
|