@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,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/dist/runtime-node-bun.js
CHANGED
|
@@ -2,7 +2,7 @@ import { parse as parseYaml } from 'yaml';
|
|
|
2
2
|
import { buildConfigFromObject, getProcessEnv } from './config.js';
|
|
3
3
|
import { DbModuleNotInstalledError } from './db-errors.js';
|
|
4
4
|
import { createNodeFileSystem } from './file-system-node.js';
|
|
5
|
-
import {
|
|
5
|
+
import { NodeProcessExecutor } from './process-executor.js';
|
|
6
6
|
// Lazy re-initialisable singleton for test isolation.
|
|
7
7
|
let _nodeFileSystem;
|
|
8
8
|
function getNodeFileSystem() {
|
|
@@ -26,7 +26,7 @@ export const nodeBunFactory = {
|
|
|
26
26
|
hasSqlDatabase: true,
|
|
27
27
|
},
|
|
28
28
|
createFileSystem: () => getNodeFileSystem(),
|
|
29
|
-
createProcessExecutor: (config) => new
|
|
29
|
+
createProcessExecutor: (config) => new NodeProcessExecutor(config),
|
|
30
30
|
async loadConfig(options) {
|
|
31
31
|
return loadNodeConfig(options);
|
|
32
32
|
},
|
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/context.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type { FileSystem } from './file-system';
|
|
|
4
4
|
import { createNodeFileSystem } from './file-system-node';
|
|
5
5
|
import { loadRuntimeFactory } from './platform';
|
|
6
6
|
import type { ProcessExecutor as ProcessExecutorService } from './process-executor';
|
|
7
|
-
import {
|
|
7
|
+
import { nodeBunFactory } from './runtime-node-bun';
|
|
8
8
|
import type { RuntimeCapabilities, RuntimeName } from './types';
|
|
9
9
|
/** Execution scope of a runtime context — determines service lifecycle and availability. */
|
|
10
10
|
export type RuntimeScope = 'process' | 'server-request' | 'scheduled-event' | 'test';
|
|
@@ -50,7 +50,7 @@ export class RuntimeContext<TServices extends RuntimeServiceMap = RuntimeService
|
|
|
50
50
|
(options.services?.fileSystem ?? createNodeFileSystem()) as TServices['fileSystem'],
|
|
51
51
|
);
|
|
52
52
|
if (this.capabilities.hasProcessExecution && options.services?.processExecutor === undefined) {
|
|
53
|
-
this.register('processExecutor',
|
|
53
|
+
this.register('processExecutor', nodeBunFactory.createProcessExecutor() as TServices['processExecutor']);
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
for (const [key, value] of Object.entries(options.services ?? {})) {
|
package/src/index.ts
CHANGED
|
@@ -30,7 +30,19 @@ export type {
|
|
|
30
30
|
ProcessSignal,
|
|
31
31
|
TracerPort,
|
|
32
32
|
} from './process-executor';
|
|
33
|
-
export { ProcessExecutor } from './process-executor';
|
|
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';
|
|
@@ -39,7 +51,7 @@ export * from './types';
|
|
|
39
51
|
|
|
40
52
|
// ── Deprecated re-exports (backward compatibility) ──────────────────────
|
|
41
53
|
|
|
42
|
-
export { BunPipeProcessSpawner, BunSyncProcessExecutor
|
|
54
|
+
export { BunPipeProcessSpawner, BunSyncProcessExecutor } from './process-executor';
|
|
43
55
|
|
|
44
56
|
/**
|
|
45
57
|
* @deprecated Use {@link ProcessExecutor} directly for async execution.
|
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. */
|
|
@@ -99,15 +121,44 @@ export interface PipeProcess {
|
|
|
99
121
|
kill(signal?: ProcessSignal): void;
|
|
100
122
|
}
|
|
101
123
|
|
|
102
|
-
// ── ProcessExecutor
|
|
124
|
+
// ── ProcessExecutor (canonical interface) ────────────────────────────────
|
|
103
125
|
|
|
104
126
|
/**
|
|
105
|
-
* Runtime-agnostic process executor
|
|
127
|
+
* Runtime-agnostic process executor contract.
|
|
106
128
|
*
|
|
107
129
|
* Every invocation supports timeout enforcement, output capture, and
|
|
108
|
-
* configurable output policy (buffered vs streamed).
|
|
130
|
+
* configurable output policy (buffered vs streamed). Concrete implementations
|
|
131
|
+
* are obtained through `RuntimeFactory.createProcessExecutor`; the Node/Bun
|
|
132
|
+
* implementation is {@link NodeProcessExecutor}. Test doubles implement this
|
|
133
|
+
* interface structurally — no concrete subclassing required.
|
|
134
|
+
*/
|
|
135
|
+
export interface ProcessExecutor {
|
|
136
|
+
/**
|
|
137
|
+
* Run a command, buffered by default. Returns a structured {@link ProcessResult}.
|
|
138
|
+
* Does NOT throw on non-zero exit codes unless `rejectOnError` is set.
|
|
139
|
+
*/
|
|
140
|
+
run(options: ProcessOptions): Promise<ProcessResult>;
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Spawn a long-running interactive process with streaming I/O.
|
|
144
|
+
*
|
|
145
|
+
* Returns a {@link PipeProcess} handle with streaming stdout/stderr and
|
|
146
|
+
* stdin write support.
|
|
147
|
+
*/
|
|
148
|
+
runStreaming(options: PipeProcessOptions): PipeProcess;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ── NodeProcessExecutor (concrete Node/Bun implementation) ───────────────
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Concrete Node/Bun implementation of {@link ProcessExecutor}, wrapping `execa`
|
|
155
|
+
* for buffered execution and `Bun.spawn` for streaming pipe execution.
|
|
156
|
+
*
|
|
157
|
+
* Obtain a default instance through `RuntimeFactory.createProcessExecutor`
|
|
158
|
+
* (e.g. `nodeBunFactory.createProcessExecutor()`); construct directly only in
|
|
159
|
+
* runtime-factory wiring or concrete implementation tests.
|
|
109
160
|
*/
|
|
110
|
-
export class ProcessExecutor {
|
|
161
|
+
export class NodeProcessExecutor implements ProcessExecutor {
|
|
111
162
|
private readonly config: ProcessExecutorConfig;
|
|
112
163
|
|
|
113
164
|
constructor(config: ProcessExecutorConfig = {}) {
|
|
@@ -135,18 +186,27 @@ export class ProcessExecutor {
|
|
|
135
186
|
signal: options.signal,
|
|
136
187
|
});
|
|
137
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
|
+
});
|
|
138
197
|
this.emitProcessEvent('process.started', {
|
|
139
198
|
command: options.command,
|
|
140
199
|
args,
|
|
141
200
|
exitCode: null,
|
|
142
201
|
durationMs: 0,
|
|
143
202
|
reason: 'exit',
|
|
144
|
-
timestamp:
|
|
203
|
+
timestamp: startedIso,
|
|
145
204
|
...(options.label !== undefined ? { label: options.label } : {}),
|
|
146
205
|
});
|
|
147
206
|
|
|
148
207
|
try {
|
|
149
208
|
const result = await execa(options.command, args, execaOptions);
|
|
209
|
+
// execa@9 Result does not expose pid — buffered runs leave pid unset.
|
|
150
210
|
const processResult = {
|
|
151
211
|
command: options.command,
|
|
152
212
|
args,
|
|
@@ -156,6 +216,7 @@ export class ProcessExecutor {
|
|
|
156
216
|
...(result.signalDescription !== undefined ? { signal: result.signalDescription } : {}),
|
|
157
217
|
durationMs: result.durationMs,
|
|
158
218
|
};
|
|
219
|
+
this.completeRegistry(registryId, processResult.exitCode);
|
|
159
220
|
this.emitExitedFromResult(options, processResult, result);
|
|
160
221
|
return processResult;
|
|
161
222
|
} catch (error) {
|
|
@@ -182,6 +243,7 @@ export class ProcessExecutor {
|
|
|
182
243
|
: {}),
|
|
183
244
|
durationMs: failed.durationMs ?? Date.now() - startedAt,
|
|
184
245
|
};
|
|
246
|
+
this.completeRegistry(registryId, processResult.exitCode);
|
|
185
247
|
this.emitExitedFromResult(options, processResult, error, error);
|
|
186
248
|
if (options.rejectOnError) throw error;
|
|
187
249
|
return processResult;
|
|
@@ -197,15 +259,24 @@ export class ProcessExecutor {
|
|
|
197
259
|
runStreaming(options: PipeProcessOptions): PipeProcess {
|
|
198
260
|
const args = options.args ?? [];
|
|
199
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
|
+
});
|
|
200
272
|
try {
|
|
201
|
-
const startedAt = Date.now();
|
|
202
273
|
this.emitProcessEvent('process.started', {
|
|
203
274
|
command: options.command,
|
|
204
275
|
args,
|
|
205
276
|
exitCode: null,
|
|
206
277
|
durationMs: 0,
|
|
207
278
|
reason: 'exit',
|
|
208
|
-
timestamp:
|
|
279
|
+
timestamp: startedIso,
|
|
209
280
|
...(options.label !== undefined ? { label: options.label } : {}),
|
|
210
281
|
});
|
|
211
282
|
const subprocess = Bun.spawn({
|
|
@@ -216,13 +287,20 @@ export class ProcessExecutor {
|
|
|
216
287
|
...(options.cwd !== undefined ? { cwd: options.cwd } : {}),
|
|
217
288
|
...(options.env !== undefined ? { env: options.env } : {}),
|
|
218
289
|
});
|
|
219
|
-
|
|
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, {
|
|
220
295
|
command: options.command,
|
|
221
296
|
args,
|
|
222
297
|
startedAt,
|
|
298
|
+
registry: this.config.registry,
|
|
299
|
+
registryId,
|
|
223
300
|
...(options.label !== undefined ? { label: options.label } : {}),
|
|
224
301
|
});
|
|
225
302
|
} catch (error) {
|
|
303
|
+
this.completeRegistry(registryId, null);
|
|
226
304
|
this.emitProcessEvent('process.exited', {
|
|
227
305
|
command: options.command,
|
|
228
306
|
args,
|
|
@@ -242,6 +320,38 @@ export class ProcessExecutor {
|
|
|
242
320
|
return await this.config.tracer.traceAsync(name, async () => await fn());
|
|
243
321
|
}
|
|
244
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
|
+
|
|
245
355
|
private emitExitedFromResult(
|
|
246
356
|
options: ProcessOptions,
|
|
247
357
|
result: ProcessResult,
|
|
@@ -286,9 +396,17 @@ class ObservedPipeProcess implements PipeProcess {
|
|
|
286
396
|
args: string[];
|
|
287
397
|
startedAt: number;
|
|
288
398
|
label?: string;
|
|
399
|
+
registry?: ProcessRegistry;
|
|
400
|
+
registryId: string;
|
|
289
401
|
},
|
|
290
402
|
) {
|
|
291
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
|
+
}
|
|
292
410
|
events?.emit('process.exited', {
|
|
293
411
|
command: context.command,
|
|
294
412
|
args: context.args,
|
|
@@ -374,13 +492,18 @@ class BunPipeProcess implements PipeProcess {
|
|
|
374
492
|
}
|
|
375
493
|
}
|
|
376
494
|
|
|
377
|
-
// ── Deprecated
|
|
495
|
+
// ── Deprecated constructible ProcessExecutor value alias ──────────────────
|
|
378
496
|
|
|
379
497
|
/**
|
|
380
|
-
* @deprecated
|
|
381
|
-
*
|
|
498
|
+
* @deprecated Construct {@link NodeProcessExecutor} directly or obtain a default
|
|
499
|
+
* through `RuntimeFactory.createProcessExecutor` (e.g. `nodeBunFactory.createProcessExecutor()`).
|
|
500
|
+
* This value alias preserves source compatibility for `new ProcessExecutor(...)` callers
|
|
501
|
+
* during the interface extraction release; it will be removed in a future release.
|
|
502
|
+
* `import type { ProcessExecutor }` resolves to the canonical interface, not this alias.
|
|
382
503
|
*/
|
|
383
|
-
export
|
|
504
|
+
export const ProcessExecutor = NodeProcessExecutor;
|
|
505
|
+
|
|
506
|
+
// ── Deprecated backward-compatible helpers ────────────────────────────────
|
|
384
507
|
|
|
385
508
|
/**
|
|
386
509
|
* @deprecated Use `Bun.spawnSync` or `child_process.spawnSync` directly.
|