@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 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
- const exec = new NodeProcessExecutor({ defaultTimeout: 30_000 });
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({ command: 'cat' });
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 forceBuffered.
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';
@@ -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":"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"}
1
+ {"version":3,"file":"process-executor.d.ts","sourceRoot":"","sources":["../src/process-executor.ts"],"names":[],"mappings":"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"}
@@ -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: new Date(startedAt).toISOString(),
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: new Date(startedAt).toISOString(),
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
- return new ObservedPipeProcess(new BunPipeProcess(subprocess), this.config.events, {
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":"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"}
@@ -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.9",
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.9",
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.9",
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.9"
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';
@@ -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: new Date(startedAt).toISOString(),
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: new Date(startedAt).toISOString(),
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
- return new ObservedPipeProcess(new BunPipeProcess(subprocess), this.config.events, {
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
+ }