@gobing-ai/ts-runtime 0.4.8 → 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 +62 -14
- package/dist/context.js +2 -2
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/process-executor.d.ts +58 -7
- package/dist/process-executor.d.ts.map +1 -1
- package/dist/process-executor.js +74 -14
- 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/dist/runtime-node-bun.js +2 -2
- package/package.json +4 -4
- package/src/context.ts +2 -2
- package/src/index.ts +14 -2
- package/src/process-executor.ts +135 -12
- package/src/process-registry.ts +263 -0
- package/src/runtime-node-bun.ts +2 -2
|
@@ -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
|
+
}
|
package/src/runtime-node-bun.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { buildConfigFromObject, getProcessEnv } from './config';
|
|
|
4
4
|
import { DbModuleNotInstalledError } from './db-errors';
|
|
5
5
|
import type { FileSystem } from './file-system';
|
|
6
6
|
import { createNodeFileSystem } from './file-system-node';
|
|
7
|
-
import {
|
|
7
|
+
import { NodeProcessExecutor, type ProcessExecutorConfig } from './process-executor';
|
|
8
8
|
import type { RuntimeFactory } from './runtime-factory';
|
|
9
9
|
import type { DatabaseConfig, LoadConfigOptions, RuntimeDbAdapter } from './types';
|
|
10
10
|
|
|
@@ -34,7 +34,7 @@ export const nodeBunFactory: RuntimeFactory = {
|
|
|
34
34
|
|
|
35
35
|
createFileSystem: () => getNodeFileSystem(),
|
|
36
36
|
|
|
37
|
-
createProcessExecutor: (config?: ProcessExecutorConfig) => new
|
|
37
|
+
createProcessExecutor: (config?: ProcessExecutorConfig) => new NodeProcessExecutor(config),
|
|
38
38
|
|
|
39
39
|
async loadConfig(options?: LoadConfigOptions): Promise<Config> {
|
|
40
40
|
return loadNodeConfig(options);
|