@deepseek-ai/dsh-workflow-worker-thread 0.0.1-rc.3
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/LICENSE +28 -0
- package/README.i18n.yaml +6 -0
- package/README.md +124 -0
- package/README.zh.md +124 -0
- package/lib/index.js +896 -0
- package/lib/invariant.js +23 -0
- package/lib/types/host.d.ts +151 -0
- package/lib/types/index.d.ts +58 -0
- package/lib/types/invariant.d.ts +16 -0
- package/lib/types/meta.d.ts +19 -0
- package/lib/types/protocol.d.ts +132 -0
- package/lib/types/realm.d.ts +39 -0
- package/lib/types/runtime.d.ts +109 -0
- package/lib/types/session.d.ts +33 -0
- package/lib/types/types.d.ts +88 -0
- package/lib/types/worker.d.ts +8 -0
- package/lib/worker.cjs +783 -0
- package/package.json +69 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,896 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { availableParallelism } from "node:os";
|
|
3
|
+
import * as vm from "node:vm";
|
|
4
|
+
import z from "@deepseek-ai/schemastery";
|
|
5
|
+
import WorkflowEngine, { WorkflowError, WorkflowRunId } from "@deepseek-ai/dsh-workflow";
|
|
6
|
+
import { Worker } from "node:worker_threads";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { assertNever } from "@deepseek-ai/dsh-llm";
|
|
9
|
+
import { snapshotJsonValue } from "@deepseek-ai/dsh-session";
|
|
10
|
+
//#region lib/types/realm.js
|
|
11
|
+
/**
|
|
12
|
+
* Materializes values leaving the script vm into plain JSON before they cross the worker
|
|
13
|
+
* boundary, and renders thrown script values without rejecting the run. The walk rejects
|
|
14
|
+
* values that JSON cannot preserve but trusts model-written workflow scripts: getters and proxy traps may
|
|
15
|
+
* run, and the vm is not a security boundary. The worker provides host-loop isolation and
|
|
16
|
+
* forced termination, not hostile-value containment. See
|
|
17
|
+
* .agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md for the isolation rationale.
|
|
18
|
+
* @module @deepseek-ai/dsh-workflow-worker-thread/realm
|
|
19
|
+
*/
|
|
20
|
+
/** Thrown by {@link materializeFromRealm}; the caller wraps it into the right `WorkflowError` code. */
|
|
21
|
+
var MaterializeError = class extends Error {
|
|
22
|
+
path;
|
|
23
|
+
reason;
|
|
24
|
+
constructor(path, reason) {
|
|
25
|
+
super(`${path}: ${reason}`);
|
|
26
|
+
this.path = path;
|
|
27
|
+
this.reason = reason;
|
|
28
|
+
this.name = "MaterializeError";
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Render a thrown value to failure text without ever throwing: prefer the
|
|
33
|
+
* `stack` (host or realm — a realm error's `stack` is a plain string read),
|
|
34
|
+
* fall back to `message`, then `String()`. Reading those properties MAY run
|
|
35
|
+
* script code (a getter, `toString`) — accepted under the module's trust
|
|
36
|
+
* premise; if that code itself throws, a fixed label is returned instead.
|
|
37
|
+
* @param error - any value thrown in the host or worker realm.
|
|
38
|
+
* @returns human-readable text for the failure report; prefers the stack.
|
|
39
|
+
*/
|
|
40
|
+
function renderThrown(error) {
|
|
41
|
+
try {
|
|
42
|
+
const stack = error?.stack;
|
|
43
|
+
if (typeof stack === "string" && stack.length > 0) return stack;
|
|
44
|
+
const message = error?.message;
|
|
45
|
+
if (typeof message === "string" && message.length > 0) return message;
|
|
46
|
+
return String(error);
|
|
47
|
+
} catch {
|
|
48
|
+
return "[unrenderable thrown value]";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Whether an object's prototype chain represents a plain data object: `null`, or a prototype
|
|
53
|
+
* whose own prototype is `null` (the realm's `Object.prototype` — which we
|
|
54
|
+
* cannot compare by identity across realms). A `Date`/`Map`/class instance
|
|
55
|
+
* has a longer chain and is rejected.
|
|
56
|
+
*/
|
|
57
|
+
function hasPlainPrototype(value) {
|
|
58
|
+
const proto = Object.getPrototypeOf(value);
|
|
59
|
+
if (proto === null) return true;
|
|
60
|
+
return Object.getPrototypeOf(proto) === null;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Copy `value` (typically from the vm realm) into plain host JSON data. Root `undefined` is
|
|
64
|
+
* returned unchanged; nested `undefined` and values JSON cannot represent losslessly fail
|
|
65
|
+
* with the offending path. Property accessors run normally, and a throwing read is wrapped
|
|
66
|
+
* with its rendered failure.
|
|
67
|
+
*
|
|
68
|
+
* @param value - the realm value to materialize.
|
|
69
|
+
* @param root - the path label for the root value (error messages).
|
|
70
|
+
* @returns the host-realm copy (plain objects/arrays/scalars only).
|
|
71
|
+
* @throws {@link MaterializeError} for unsupported values, cycles, sparse arrays, exotic
|
|
72
|
+
* prototypes, or property reads that throw.
|
|
73
|
+
*/
|
|
74
|
+
function materializeFromRealm(value, root = "value") {
|
|
75
|
+
if (value === void 0) return void 0;
|
|
76
|
+
try {
|
|
77
|
+
return materialize(value, root, /* @__PURE__ */ new Set());
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (error instanceof MaterializeError) throw error;
|
|
80
|
+
throw new MaterializeError(root, `reading the value threw: ${renderThrown(error)}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function materialize(value, path, seen) {
|
|
84
|
+
switch (typeof value) {
|
|
85
|
+
case "boolean":
|
|
86
|
+
case "string": return value;
|
|
87
|
+
case "number":
|
|
88
|
+
if (!Number.isFinite(value)) throw new MaterializeError(path, "non-finite numbers are not JSON data");
|
|
89
|
+
return value;
|
|
90
|
+
case "bigint": throw new MaterializeError(path, "bigints are not JSON data");
|
|
91
|
+
case "function": throw new MaterializeError(path, "functions are not plain JSON data");
|
|
92
|
+
case "symbol": throw new MaterializeError(path, "symbols are not plain JSON data");
|
|
93
|
+
case "undefined": throw new MaterializeError(path, "undefined is not JSON data");
|
|
94
|
+
case "object": break;
|
|
95
|
+
}
|
|
96
|
+
if (value === null) return null;
|
|
97
|
+
const objectValue = value;
|
|
98
|
+
if (seen.has(objectValue)) throw new MaterializeError(path, "circular references are not JSON data");
|
|
99
|
+
seen.add(objectValue);
|
|
100
|
+
try {
|
|
101
|
+
if (Array.isArray(objectValue)) return materializeArray(objectValue, path, seen);
|
|
102
|
+
return materializeObject(objectValue, path, seen);
|
|
103
|
+
} finally {
|
|
104
|
+
seen.delete(objectValue);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function materializeArray(value, path, seen) {
|
|
108
|
+
const out = [];
|
|
109
|
+
for (let index = 0; index < value.length; index++) {
|
|
110
|
+
if (!(index in value)) throw new MaterializeError(`${path}[${index}]`, "sparse arrays are not JSON data");
|
|
111
|
+
out.push(materialize(value[index], `${path}[${index}]`, seen));
|
|
112
|
+
}
|
|
113
|
+
for (const key of Object.keys(value)) {
|
|
114
|
+
const index = Number(key);
|
|
115
|
+
if (!Number.isInteger(index) || index < 0 || index >= value.length) throw new MaterializeError(`${path}.${key}`, "arrays with non-index properties are not JSON data");
|
|
116
|
+
}
|
|
117
|
+
if (Object.getOwnPropertySymbols(value).length > 0) throw new MaterializeError(path, "symbol-keyed properties are not plain JSON data");
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
function materializeObject(value, path, seen) {
|
|
121
|
+
if (!hasPlainPrototype(value)) throw new MaterializeError(path, "only plain objects and arrays are JSON data (exotic prototype)");
|
|
122
|
+
if (Object.getOwnPropertySymbols(value).length > 0) throw new MaterializeError(path, "symbol-keyed properties are not plain JSON data");
|
|
123
|
+
const out = {};
|
|
124
|
+
for (const key of Object.keys(value)) Object.defineProperty(out, key, {
|
|
125
|
+
value: materialize(value[key], `${path}.${key}`, seen),
|
|
126
|
+
enumerable: true,
|
|
127
|
+
writable: true,
|
|
128
|
+
configurable: true
|
|
129
|
+
});
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
//#endregion
|
|
133
|
+
//#region lib/types/protocol.js
|
|
134
|
+
/**
|
|
135
|
+
* The host⇄worker wire protocol: one string-valued enum of message tags per direction, a
|
|
136
|
+
* payload map giving each tag its parameters (the single source of truth), and the message
|
|
137
|
+
* unions derived from them. Payloads are plain JSON by construction for structured clone. Both
|
|
138
|
+
* directions are closed engine protocols whose receivers use `assertNever`; generic typed senders
|
|
139
|
+
* make tag/payload mismatches compile-time errors rather than silently skipped messages.
|
|
140
|
+
* @module @deepseek-ai/dsh-workflow-worker-thread/protocol
|
|
141
|
+
*/
|
|
142
|
+
/** Message tags the worker sends the host (the wire values are the tag strings). */
|
|
143
|
+
var WorkerToHostType;
|
|
144
|
+
(function(WorkerToHostType) {
|
|
145
|
+
/** The startup handshake: the session is listening and awaits {@link HostToWorkerType.Go}. */
|
|
146
|
+
WorkerToHostType["Ready"] = "ready";
|
|
147
|
+
/** Observer narration: a `phase(title)` call. */
|
|
148
|
+
WorkerToHostType["Phase"] = "phase";
|
|
149
|
+
/** Observer narration: a `log(message)` call. */
|
|
150
|
+
WorkerToHostType["Log"] = "log";
|
|
151
|
+
/** Observer lifecycle: one `agent()` call started a child. */
|
|
152
|
+
WorkerToHostType["AgentStart"] = "agent-start";
|
|
153
|
+
/** Observer lifecycle: one `agent()` call settled. */
|
|
154
|
+
WorkerToHostType["AgentEnd"] = "agent-end";
|
|
155
|
+
/** Child RPC: start a child on the host (answered by ChildStarted or ChildStartError). */
|
|
156
|
+
WorkerToHostType["ChildStart"] = "child-start";
|
|
157
|
+
/** Child RPC: dispose a started child (answered by ChildDisposed). */
|
|
158
|
+
WorkerToHostType["ChildDispose"] = "child-dispose";
|
|
159
|
+
/** The run's single terminal result. */
|
|
160
|
+
WorkerToHostType["Result"] = "result";
|
|
161
|
+
})(WorkerToHostType || (WorkerToHostType = {}));
|
|
162
|
+
/** Message tags the host sends the worker (the wire values are the tag strings). */
|
|
163
|
+
var HostToWorkerType;
|
|
164
|
+
(function(HostToWorkerType) {
|
|
165
|
+
/** Releases the startup gate: run the script body. */
|
|
166
|
+
HostToWorkerType["Go"] = "go";
|
|
167
|
+
/** Cancel the run: hooks start throwing and the script dies at its next await. */
|
|
168
|
+
HostToWorkerType["Cancel"] = "cancel";
|
|
169
|
+
/** Child RPC reply: the provider fulfilled with a published run (exactly one start reply per ChildStart). */
|
|
170
|
+
HostToWorkerType["ChildStarted"] = "child-started";
|
|
171
|
+
/** Child RPC reply: the provider's asynchronous start failed. */
|
|
172
|
+
HostToWorkerType["ChildStartError"] = "child-start-error";
|
|
173
|
+
/** Child RPC: a started child's result RESOLVED (its JSON projection). */
|
|
174
|
+
HostToWorkerType["ChildSettled"] = "child-settled";
|
|
175
|
+
/** Child RPC: a started child's result REJECTED (an infrastructure fault, rendered). */
|
|
176
|
+
HostToWorkerType["ChildFailed"] = "child-failed";
|
|
177
|
+
/** Child RPC reply: a requested disposal completed. */
|
|
178
|
+
HostToWorkerType["ChildDisposed"] = "child-disposed";
|
|
179
|
+
})(HostToWorkerType || (HostToWorkerType = {}));
|
|
180
|
+
//#endregion
|
|
181
|
+
//#region lib/types/host.js
|
|
182
|
+
/**
|
|
183
|
+
* Host side of one workflow run. The first worker result, unexpected death, or
|
|
184
|
+
* cancellation-grace expiry owns settlement and closes message admission.
|
|
185
|
+
* Pending starts share one abort signal; published children share idempotent
|
|
186
|
+
* cleanup, and quiescence waits for both while synthesizing any missing end events.
|
|
187
|
+
* @module @deepseek-ai/dsh-workflow-worker-thread/host
|
|
188
|
+
*/
|
|
189
|
+
/**
|
|
190
|
+
* Resolve a built worker bundle or an unbuilt bootstrap that installs both tsx
|
|
191
|
+
* transforms inside the worker. Both shapes clear `execArgv` and the ambient
|
|
192
|
+
* environment; the unbuilt shape forwards only `TSX_TSCONFIG_PATH` for path
|
|
193
|
+
* resolution.
|
|
194
|
+
* @param init - the run payload, passed as `workerData`.
|
|
195
|
+
* @returns the entry path or URL and the Worker options to spawn it with.
|
|
196
|
+
*/
|
|
197
|
+
function resolveWorkerSpawn(init) {
|
|
198
|
+
/* v8 ignore next 3 -- the built-output arm: tests always run unbuilt (src/); the built-worker e2e exercises this shape for real */
|
|
199
|
+
if (!import.meta.url.endsWith(".ts")) return {
|
|
200
|
+
entry: fileURLToPath(new URL("./worker.cjs", import.meta.url)),
|
|
201
|
+
options: {
|
|
202
|
+
workerData: init,
|
|
203
|
+
env: {},
|
|
204
|
+
execArgv: []
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
const workerEntry = new URL("./worker.ts", import.meta.url);
|
|
208
|
+
const tsxEsmApiEntry = import.meta.resolve("tsx/esm/api");
|
|
209
|
+
const tsxCjsApiEntry = import.meta.resolve("tsx/cjs/api");
|
|
210
|
+
const bootstrap = [
|
|
211
|
+
`import { register as registerEsm } from ${JSON.stringify(tsxEsmApiEntry)}`,
|
|
212
|
+
`import { register as registerCjs } from ${JSON.stringify(tsxCjsApiEntry)}`,
|
|
213
|
+
"registerCjs()",
|
|
214
|
+
"registerEsm()",
|
|
215
|
+
`await import(${JSON.stringify(workerEntry.href)})`
|
|
216
|
+
].join("\n");
|
|
217
|
+
return {
|
|
218
|
+
entry: new URL(`data:text/javascript,${encodeURIComponent(bootstrap)}`),
|
|
219
|
+
options: {
|
|
220
|
+
workerData: init,
|
|
221
|
+
env: process.env.TSX_TSCONFIG_PATH === void 0 ? {} : { TSX_TSCONFIG_PATH: process.env.TSX_TSCONFIG_PATH },
|
|
222
|
+
execArgv: []
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* One live worker-engine run — the seam's {@link WorkflowRun}, returned by
|
|
228
|
+
* `start()` directly. Owns the Worker, the child registry, and the result
|
|
229
|
+
* settlement; `result` never rejects. `meta` is trusted same-process data
|
|
230
|
+
* borrowed as immutable by the handle and lifecycle events. The holder-bound
|
|
231
|
+
* SubagentRuntime handle is captured before the
|
|
232
|
+
* engine returns this run, so unloading the engine removes only the ability to
|
|
233
|
+
* start another workflow; this run can still start and clean up its children.
|
|
234
|
+
*/
|
|
235
|
+
var WorkerRun = class {
|
|
236
|
+
ctx;
|
|
237
|
+
subagents;
|
|
238
|
+
id;
|
|
239
|
+
meta;
|
|
240
|
+
parent;
|
|
241
|
+
provider;
|
|
242
|
+
disposeGraceMs;
|
|
243
|
+
observer;
|
|
244
|
+
/** Settles exactly once with the run's outcome; never rejects. */
|
|
245
|
+
result;
|
|
246
|
+
settleResolve;
|
|
247
|
+
settled = false;
|
|
248
|
+
/** A Result/death/grace outcome atomically won before teardown callbacks. */
|
|
249
|
+
terminalClaimed = false;
|
|
250
|
+
/** The first death signal closes worker-message admission and owns failure-time cleanup. */
|
|
251
|
+
workerDeathObserved = false;
|
|
252
|
+
cancelReason;
|
|
253
|
+
graceTimer;
|
|
254
|
+
worker;
|
|
255
|
+
/** Set on `exit`: the thread is gone, so posting has nowhere to go. */
|
|
256
|
+
workerGone = false;
|
|
257
|
+
/** Accepted `child-start` messages — the terminate-path `agentsStarted` (see module doc). */
|
|
258
|
+
hostStarted = 0;
|
|
259
|
+
/** Published children by callId; an entry leaves only after disposal settles. */
|
|
260
|
+
children = /* @__PURE__ */ new Map();
|
|
261
|
+
/** Provider starts that have not yet fulfilled or rejected. */
|
|
262
|
+
pendingStarts = /* @__PURE__ */ new Set();
|
|
263
|
+
/** Started-but-not-ended agents by seq — the pairing ledger the HOST guarantees (see {@link endAgent}). */
|
|
264
|
+
liveAgents = /* @__PURE__ */ new Map();
|
|
265
|
+
quiescenceWaiters = [];
|
|
266
|
+
/** The per-run abort fanout every child start request carries. */
|
|
267
|
+
controller = new AbortController();
|
|
268
|
+
/** External start signal and the exact callback installed on it, retained only until first settle/teardown. */
|
|
269
|
+
inputSignal;
|
|
270
|
+
inputSignalAbort;
|
|
271
|
+
disposed;
|
|
272
|
+
constructor(ctx, subagents, id, meta, parent, init, provider, disposeGraceMs, observer, signal) {
|
|
273
|
+
this.ctx = ctx;
|
|
274
|
+
this.subagents = subagents;
|
|
275
|
+
this.id = id;
|
|
276
|
+
this.meta = meta;
|
|
277
|
+
this.parent = parent;
|
|
278
|
+
this.provider = provider;
|
|
279
|
+
this.disposeGraceMs = disposeGraceMs;
|
|
280
|
+
this.observer = observer;
|
|
281
|
+
this.result = new Promise((resolve) => {
|
|
282
|
+
this.settleResolve = resolve;
|
|
283
|
+
});
|
|
284
|
+
const { entry, options } = resolveWorkerSpawn(init);
|
|
285
|
+
this.worker = new Worker(entry, options);
|
|
286
|
+
this.worker.on("message", (message) => {
|
|
287
|
+
this.onMessage(message);
|
|
288
|
+
});
|
|
289
|
+
this.worker.on("error", (error) => {
|
|
290
|
+
this.onWorkerDeath(`workflow worker failed: ${renderThrown(error)}`, false);
|
|
291
|
+
});
|
|
292
|
+
/* v8 ignore next -- messageerror: not constructible from the engine's own protocol (every payload is JSON data) */
|
|
293
|
+
this.worker.on("messageerror", (error) => {
|
|
294
|
+
this.onWorkerDeath(`workflow worker message failed to deserialize: ${renderThrown(error)}`, false);
|
|
295
|
+
});
|
|
296
|
+
this.worker.on("exit", (code) => {
|
|
297
|
+
this.workerGone = true;
|
|
298
|
+
this.onWorkerDeath(`workflow worker exited before the run settled (exit code ${code})`, true);
|
|
299
|
+
});
|
|
300
|
+
if (signal?.aborted) this.cancel("workflow start signal already aborted");
|
|
301
|
+
else if (signal !== void 0) {
|
|
302
|
+
const onAbort = () => {
|
|
303
|
+
this.detachInputSignal();
|
|
304
|
+
this.cancel("workflow signal aborted");
|
|
305
|
+
};
|
|
306
|
+
this.inputSignal = signal;
|
|
307
|
+
this.inputSignalAbort = onAbort;
|
|
308
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Cancel the run: the worker is told (its hooks start throwing and the
|
|
313
|
+
* script dies at its next await), the required signal shared by every child
|
|
314
|
+
* start is aborted, and the grace timer
|
|
315
|
+
* arms: a run still unsettled `disposeGraceMs` later force-settles
|
|
316
|
+
* `cancelled` and its worker is TERMINATED. Idempotent; the first reason
|
|
317
|
+
* wins.
|
|
318
|
+
* @param reason - human-readable cause (default `'workflow cancelled'`).
|
|
319
|
+
*/
|
|
320
|
+
cancel(reason) {
|
|
321
|
+
if (this.settled || this.terminalClaimed || this.cancelReason !== void 0) return;
|
|
322
|
+
this.cancelReason = reason ?? "workflow cancelled";
|
|
323
|
+
this.post(HostToWorkerType.Cancel, { reason: this.cancelReason });
|
|
324
|
+
this.abortChildren(this.cancelReason);
|
|
325
|
+
this.graceTimer = setTimeout(() => {
|
|
326
|
+
this.terminalClaimed = true;
|
|
327
|
+
this.endStrandedAgents();
|
|
328
|
+
this.settleResult(this.cancelledResult(this.hostStarted));
|
|
329
|
+
this.worker.terminate();
|
|
330
|
+
}, this.disposeGraceMs);
|
|
331
|
+
this.graceTimer.unref();
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Cancel + bounded settle + termination. Host-drives every registered
|
|
335
|
+
* child's disposal IMMEDIATELY — a wedged worker can relay no dispose RPC,
|
|
336
|
+
* and deferring child teardown to the post-terminate reap would spend the
|
|
337
|
+
* whole grace waiting for a quiescence that cannot start, then return with
|
|
338
|
+
* the disposals still in flight — so child disposal overlaps the same
|
|
339
|
+
* grace the worker gets to settle (the worker's own dispose RPCs join the
|
|
340
|
+
* shared per-child disposal). Waits (at most the grace) for the result and
|
|
341
|
+
* child quiescence, then terminates the worker unconditionally — the
|
|
342
|
+
* thread never outlives its run — and reaps whatever children remain
|
|
343
|
+
* (their disposal is contained, not awaited past the grace, the same
|
|
344
|
+
* abandonment the seam documents for a slow-disposing child). Idempotent;
|
|
345
|
+
* safe on every path.
|
|
346
|
+
* @returns resolves when the run's resources are released or abandoned.
|
|
347
|
+
*/
|
|
348
|
+
dispose() {
|
|
349
|
+
if (this.disposed !== void 0) return this.disposed;
|
|
350
|
+
const claimed = Promise.withResolvers();
|
|
351
|
+
this.disposed = claimed.promise;
|
|
352
|
+
(async () => {
|
|
353
|
+
this.detachInputSignal();
|
|
354
|
+
this.cancel("workflow disposed");
|
|
355
|
+
this.reapChildren("workflow disposed");
|
|
356
|
+
await Promise.race([(async () => {
|
|
357
|
+
await this.result;
|
|
358
|
+
await this.childQuiescence();
|
|
359
|
+
})(), sleep(this.disposeGraceMs)]);
|
|
360
|
+
await this.worker.terminate();
|
|
361
|
+
this.reapChildren("workflow disposed");
|
|
362
|
+
})().then(
|
|
363
|
+
() => {
|
|
364
|
+
claimed.resolve(void 0);
|
|
365
|
+
},
|
|
366
|
+
/* v8 ignore next -- result/quiescence never reject and Worker.terminate is the only external promise */
|
|
367
|
+
(error) => {
|
|
368
|
+
claimed.reject(error);
|
|
369
|
+
}
|
|
370
|
+
);
|
|
371
|
+
return this.disposed;
|
|
372
|
+
}
|
|
373
|
+
/** Post one message to the worker (payload looked up from the tag's map entry), tolerating a thread that is already gone. */
|
|
374
|
+
post(type, payload) {
|
|
375
|
+
if (this.workerGone || this.workerDeathObserved) return;
|
|
376
|
+
try {
|
|
377
|
+
this.worker.postMessage({
|
|
378
|
+
type,
|
|
379
|
+
...payload
|
|
380
|
+
});
|
|
381
|
+
} catch (error) {
|
|
382
|
+
/* v8 ignore next -- postMessage teardown race (a throw between exit and its event): not constructible in-process */
|
|
383
|
+
this.ctx.logger.warn(`workflow-worker-thread: postMessage failed: ${renderThrown(error)}`);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
onMessage(message) {
|
|
387
|
+
if (this.workerDeathObserved) return;
|
|
388
|
+
switch (message.type) {
|
|
389
|
+
case WorkerToHostType.Ready:
|
|
390
|
+
this.post(HostToWorkerType.Go, {});
|
|
391
|
+
break;
|
|
392
|
+
case WorkerToHostType.Phase:
|
|
393
|
+
if (this.cancelReason === void 0) this.observer.phase(message.title);
|
|
394
|
+
break;
|
|
395
|
+
case WorkerToHostType.Log:
|
|
396
|
+
if (this.cancelReason === void 0) this.observer.log(message.message);
|
|
397
|
+
break;
|
|
398
|
+
case WorkerToHostType.AgentStart:
|
|
399
|
+
this.liveAgents.set(message.info.seq, message.info);
|
|
400
|
+
this.observer.agentStart(message.info);
|
|
401
|
+
break;
|
|
402
|
+
case WorkerToHostType.AgentEnd:
|
|
403
|
+
this.endAgent(message.info);
|
|
404
|
+
break;
|
|
405
|
+
case WorkerToHostType.ChildStart:
|
|
406
|
+
this.onChildStart(message.callId, message.request);
|
|
407
|
+
break;
|
|
408
|
+
case WorkerToHostType.ChildDispose:
|
|
409
|
+
this.onChildDispose(message.callId);
|
|
410
|
+
break;
|
|
411
|
+
case WorkerToHostType.Result:
|
|
412
|
+
this.onResult(message.result);
|
|
413
|
+
break;
|
|
414
|
+
/* v8 ignore next 2 -- closed engine-owned union; the arm only makes adding a message type a compile error */
|
|
415
|
+
default: assertNever(message, "worker-to-host message");
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
/** Why a ready provider result may no longer be admitted to the worker. */
|
|
419
|
+
childAdmissionFailure() {
|
|
420
|
+
if (this.cancelReason !== void 0) return {
|
|
421
|
+
reason: this.cancelReason,
|
|
422
|
+
rendered: `workflow run cancelled: ${this.cancelReason}`
|
|
423
|
+
};
|
|
424
|
+
if (this.workerDeathObserved) return {
|
|
425
|
+
reason: "workflow worker gone",
|
|
426
|
+
rendered: "workflow worker is no longer available"
|
|
427
|
+
};
|
|
428
|
+
if (this.terminalClaimed) return {
|
|
429
|
+
reason: "workflow settled",
|
|
430
|
+
rendered: "workflow run already settled"
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
onChildStart(callId, request) {
|
|
434
|
+
const initialFailure = this.childAdmissionFailure();
|
|
435
|
+
if (initialFailure !== void 0) {
|
|
436
|
+
this.post(HostToWorkerType.ChildStartError, {
|
|
437
|
+
callId,
|
|
438
|
+
rendered: initialFailure.rendered
|
|
439
|
+
});
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
this.hostStarted += 1;
|
|
443
|
+
const task = this.startChild(callId, request);
|
|
444
|
+
this.pendingStarts.add(task);
|
|
445
|
+
task.then(
|
|
446
|
+
() => {
|
|
447
|
+
this.finishPendingStart(task);
|
|
448
|
+
},
|
|
449
|
+
/* v8 ignore next -- startChild contains provider and cleanup failures */
|
|
450
|
+
() => {
|
|
451
|
+
this.finishPendingStart(task);
|
|
452
|
+
}
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
/** Await one provider-owned startup transaction and publish only while admitted. */
|
|
456
|
+
async startChild(callId, request) {
|
|
457
|
+
let run;
|
|
458
|
+
try {
|
|
459
|
+
run = await this.subagents.start(this.provider, {
|
|
460
|
+
prompt: [{
|
|
461
|
+
type: "text",
|
|
462
|
+
text: request.prompt
|
|
463
|
+
}],
|
|
464
|
+
parent: this.parent,
|
|
465
|
+
signal: this.controller.signal,
|
|
466
|
+
...request.schema !== void 0 ? { outputSchema: request.schema } : {},
|
|
467
|
+
...request.provider !== void 0 || request.model !== void 0 ? { agentOptions: {
|
|
468
|
+
...request.provider !== void 0 ? { provider: request.provider } : {},
|
|
469
|
+
...request.model !== void 0 ? { model: request.model } : {}
|
|
470
|
+
} } : {}
|
|
471
|
+
});
|
|
472
|
+
} catch (error) {
|
|
473
|
+
const failure = this.childAdmissionFailure();
|
|
474
|
+
this.post(HostToWorkerType.ChildStartError, {
|
|
475
|
+
callId,
|
|
476
|
+
rendered: failure?.rendered ?? renderThrown(error)
|
|
477
|
+
});
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
const failure = this.childAdmissionFailure();
|
|
481
|
+
if (failure !== void 0) {
|
|
482
|
+
this.post(HostToWorkerType.ChildStartError, {
|
|
483
|
+
callId,
|
|
484
|
+
rendered: failure.rendered
|
|
485
|
+
});
|
|
486
|
+
try {
|
|
487
|
+
await run.dispose();
|
|
488
|
+
} catch (error) {
|
|
489
|
+
this.ctx.logger.warn(`workflow-worker-thread: refused child dispose failed: ${renderThrown(error)}`);
|
|
490
|
+
}
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
const record = { run };
|
|
494
|
+
this.children.set(callId, record);
|
|
495
|
+
const forwardResult = run.result.then((result) => {
|
|
496
|
+
try {
|
|
497
|
+
const snapshot = snapshotJsonValue({
|
|
498
|
+
output: result.output,
|
|
499
|
+
...result.structured !== void 0 ? { structured: result.structured } : {},
|
|
500
|
+
stopReason: result.stopReason
|
|
501
|
+
});
|
|
502
|
+
if (snapshot === void 0) throw new TypeError("child result is not losslessly JSON-serializable");
|
|
503
|
+
return () => {
|
|
504
|
+
this.post(HostToWorkerType.ChildSettled, {
|
|
505
|
+
callId,
|
|
506
|
+
result: snapshot
|
|
507
|
+
});
|
|
508
|
+
};
|
|
509
|
+
} catch (error) {
|
|
510
|
+
const rendered = `workflow child result could not cross the worker boundary: ${renderThrown(error)}`;
|
|
511
|
+
return () => {
|
|
512
|
+
this.post(HostToWorkerType.ChildFailed, {
|
|
513
|
+
callId,
|
|
514
|
+
rendered
|
|
515
|
+
});
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
}, (error) => {
|
|
519
|
+
const rendered = renderThrown(error);
|
|
520
|
+
return () => {
|
|
521
|
+
this.post(HostToWorkerType.ChildFailed, {
|
|
522
|
+
callId,
|
|
523
|
+
rendered
|
|
524
|
+
});
|
|
525
|
+
};
|
|
526
|
+
});
|
|
527
|
+
this.post(HostToWorkerType.ChildStarted, {
|
|
528
|
+
callId,
|
|
529
|
+
childId: run.id
|
|
530
|
+
});
|
|
531
|
+
forwardResult.then((forward) => {
|
|
532
|
+
forward();
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
onChildDispose(callId) {
|
|
536
|
+
const record = this.children.get(callId);
|
|
537
|
+
if (record === void 0) {
|
|
538
|
+
this.post(HostToWorkerType.ChildDisposed, { callId });
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
this.disposeChild(callId, record).then(() => {
|
|
542
|
+
this.post(HostToWorkerType.ChildDisposed, { callId });
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* Start (or join) one registered child's disposal; the registry entry
|
|
547
|
+
* leaves when it settles. Memoized per callId: the worker's dispose RPC,
|
|
548
|
+
* the dispose() host drive, and the reap can all land on the same child —
|
|
549
|
+
* the child's `dispose()` runs once and every caller awaits that one
|
|
550
|
+
* settlement. A rejection is contained (the subagent seam's dispose() is
|
|
551
|
+
* not supposed to reject, but a backend that does anyway must not break
|
|
552
|
+
* quiescence): logged, and the child still leaves the registry.
|
|
553
|
+
* @param callId - the child's registry key.
|
|
554
|
+
* @param record - the registered child (the caller looked it up).
|
|
555
|
+
* @returns resolves when the disposal settled either way; never rejects.
|
|
556
|
+
*/
|
|
557
|
+
disposeChild(callId, record) {
|
|
558
|
+
if (record.disposal !== void 0) return record.disposal;
|
|
559
|
+
record.disposal = Promise.resolve().then(() => record.run.dispose()).catch((error) => {
|
|
560
|
+
this.ctx.logger.warn(`workflow-worker-thread: child dispose failed: ${renderThrown(error)}`);
|
|
561
|
+
}).then(() => {
|
|
562
|
+
this.finishChild(callId);
|
|
563
|
+
});
|
|
564
|
+
return record.disposal;
|
|
565
|
+
}
|
|
566
|
+
/** Drop a child record and release quiescence waiters when all work ends. */
|
|
567
|
+
finishChild(callId) {
|
|
568
|
+
this.children.delete(callId);
|
|
569
|
+
this.notifyChildQuiescence();
|
|
570
|
+
}
|
|
571
|
+
/** Retire one provider startup transaction. */
|
|
572
|
+
finishPendingStart(task) {
|
|
573
|
+
this.pendingStarts.delete(task);
|
|
574
|
+
this.notifyChildQuiescence();
|
|
575
|
+
}
|
|
576
|
+
/** Release waiters only after both pending starts and published children end. */
|
|
577
|
+
notifyChildQuiescence() {
|
|
578
|
+
if (this.children.size !== 0 || this.pendingStarts.size !== 0) return;
|
|
579
|
+
for (const waiter of this.quiescenceWaiters.splice(0)) waiter();
|
|
580
|
+
}
|
|
581
|
+
/** Resolves once every pending start and published child has reached quiescence. */
|
|
582
|
+
childQuiescence() {
|
|
583
|
+
if (this.children.size === 0 && this.pendingStarts.size === 0) return Promise.resolve();
|
|
584
|
+
return new Promise((resolve) => {
|
|
585
|
+
this.quiescenceWaiters.push(resolve);
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
/** Abort + dispose every registered child (worker death / final teardown); disposal is contained, not awaited. */
|
|
589
|
+
reapChildren(reason) {
|
|
590
|
+
this.abortChildren(this.cancelReason ?? reason);
|
|
591
|
+
for (const [callId, record] of [...this.children]) this.disposeChild(callId, record);
|
|
592
|
+
}
|
|
593
|
+
/** Abort the one canonical signal shared by pending and published children. */
|
|
594
|
+
abortChildren(reason) {
|
|
595
|
+
if (!this.controller.signal.aborted) this.controller.abort(reason);
|
|
596
|
+
}
|
|
597
|
+
onResult(result) {
|
|
598
|
+
if (this.terminalClaimed) return;
|
|
599
|
+
const cancellationWasRequested = this.cancelReason !== void 0;
|
|
600
|
+
this.terminalClaimed = true;
|
|
601
|
+
this.reapChildren("workflow settled");
|
|
602
|
+
if (!cancellationWasRequested) {
|
|
603
|
+
this.settleResult(result);
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
if (result.stopReason !== "cancelled") {
|
|
607
|
+
this.settleResult(this.cancelledResult(result.agentsStarted));
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
this.settleResult(result);
|
|
611
|
+
}
|
|
612
|
+
/** Process an error/messageerror/exit signal; `exit` also performs the final disposal sweep. */
|
|
613
|
+
onWorkerDeath(message, isExit) {
|
|
614
|
+
if (!this.workerDeathObserved) {
|
|
615
|
+
this.workerDeathObserved = true;
|
|
616
|
+
const outcomeWasClaimed = this.terminalClaimed;
|
|
617
|
+
const cancellationWasRequested = this.cancelReason !== void 0;
|
|
618
|
+
if (!outcomeWasClaimed) this.terminalClaimed = true;
|
|
619
|
+
if (this.children.size > 0 || this.pendingStarts.size > 0) this.reapChildren("workflow worker gone");
|
|
620
|
+
this.endStrandedAgents();
|
|
621
|
+
if (!outcomeWasClaimed) if (cancellationWasRequested) this.settleResult(this.cancelledResult(this.hostStarted));
|
|
622
|
+
else this.settleResult({
|
|
623
|
+
value: null,
|
|
624
|
+
stopReason: "error",
|
|
625
|
+
error: message,
|
|
626
|
+
agentsStarted: this.hostStarted
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
if (!isExit) return;
|
|
630
|
+
for (const [callId, record] of [...this.children]) this.disposeChild(callId, record);
|
|
631
|
+
this.endStrandedAgents();
|
|
632
|
+
}
|
|
633
|
+
/**
|
|
634
|
+
* The single agent-end emission gate: forwards `end` iff its start is still
|
|
635
|
+
* unpaired in the ledger, so every forwarded `workflow/agent-start` gets
|
|
636
|
+
* EXACTLY one `workflow/agent-end` — the worker's own report where it can
|
|
637
|
+
* speak, a host-synthesized one where it cannot ({@link endStrandedAgents}).
|
|
638
|
+
* @param end - the settlement to emit (worker-reported or synthesized).
|
|
639
|
+
*/
|
|
640
|
+
endAgent(end) {
|
|
641
|
+
/* v8 ignore next -- a real end still in flight across the grace force-settle: not orderable in-process */
|
|
642
|
+
if (!this.liveAgents.delete(end.seq)) return;
|
|
643
|
+
this.observer.agentEnd(end);
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* Synthesize the missing `agent-end` for every started-but-unpaired agent,
|
|
647
|
+
* outcome `'cancelled'`: the reap cancels every child, and a real
|
|
648
|
+
* settlement racing the force-settle loses to that already-started external
|
|
649
|
+
* cancellation. The atomic terminal boundaries in {@link onResult} and
|
|
650
|
+
* {@link onWorkerDeath} deliberately exclude teardown callbacks as contenders.
|
|
651
|
+
* Called where the worker can no longer speak (the grace force-settle,
|
|
652
|
+
* worker death, physical exit). When grace/death is the terminal source it
|
|
653
|
+
* runs before settleResult, so already-known pairs precede `workflow/end`;
|
|
654
|
+
* after an earlier Result, exit cleanup may close a survivor afterward.
|
|
655
|
+
* The ledger preserves exactly-once pairing in both orders.
|
|
656
|
+
*/
|
|
657
|
+
endStrandedAgents() {
|
|
658
|
+
for (const info of [...this.liveAgents.values()]) this.endAgent({
|
|
659
|
+
...info,
|
|
660
|
+
outcome: "cancelled"
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
cancelledResult(agentsStarted) {
|
|
664
|
+
return {
|
|
665
|
+
value: null,
|
|
666
|
+
stopReason: "cancelled",
|
|
667
|
+
error: `workflow run cancelled: ${this.cancelReason ?? "workflow cancelled"}`,
|
|
668
|
+
agentsStarted
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
/** Remove the exact abort callback installed on the caller's start signal. */
|
|
672
|
+
detachInputSignal() {
|
|
673
|
+
const signal = this.inputSignal;
|
|
674
|
+
const onAbort = this.inputSignalAbort;
|
|
675
|
+
if (signal === void 0 || onAbort === void 0) return;
|
|
676
|
+
this.inputSignal = void 0;
|
|
677
|
+
this.inputSignalAbort = void 0;
|
|
678
|
+
signal.removeEventListener("abort", onAbort);
|
|
679
|
+
}
|
|
680
|
+
/** First settle wins; disarms the grace timer and releases the caller signal. */
|
|
681
|
+
settleResult(result) {
|
|
682
|
+
/* v8 ignore next -- defensive fallback outside the claimed state machine */
|
|
683
|
+
if (this.settled) return;
|
|
684
|
+
this.terminalClaimed = true;
|
|
685
|
+
this.settled = true;
|
|
686
|
+
this.detachInputSignal();
|
|
687
|
+
clearTimeout(this.graceTimer);
|
|
688
|
+
this.settleResolve(result);
|
|
689
|
+
}
|
|
690
|
+
};
|
|
691
|
+
/** A plain timer sleep (the dispose grace); unref'd so it never holds the process open. */
|
|
692
|
+
function sleep(ms) {
|
|
693
|
+
return new Promise((resolve) => {
|
|
694
|
+
setTimeout(resolve, ms).unref();
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
//#endregion
|
|
698
|
+
//#region lib/types/meta.js
|
|
699
|
+
/**
|
|
700
|
+
* Meta validation checks caller-provided DATA against the {@link WorkflowMeta}
|
|
701
|
+
* contract and rejects every violation by name. Meta arrives as schema-checked
|
|
702
|
+
* JSON data, never evaluated script text; evaluating it on the host could run getters outside the
|
|
703
|
+
* worker timeout that exists to isolate model-written code.
|
|
704
|
+
* @module @deepseek-ai/dsh-workflow-worker-thread/meta
|
|
705
|
+
*/
|
|
706
|
+
/** Collect shape violations for a meta value (plain JSON data by the seam contract). */
|
|
707
|
+
function validateMetaShape(meta) {
|
|
708
|
+
const violations = [];
|
|
709
|
+
if (typeof meta !== "object" || meta === null || Array.isArray(meta)) return { violations: ["meta must be an object"] };
|
|
710
|
+
const record = meta;
|
|
711
|
+
const known = new Set([
|
|
712
|
+
"name",
|
|
713
|
+
"description",
|
|
714
|
+
"whenToUse",
|
|
715
|
+
"phases"
|
|
716
|
+
]);
|
|
717
|
+
for (const key of Object.keys(record)) if (!known.has(key)) violations.push(`meta.${key} is not a recognized field (name/description/whenToUse/phases)`);
|
|
718
|
+
if (typeof record.name !== "string" || record.name.length === 0) violations.push("meta.name must be a non-empty string");
|
|
719
|
+
if (typeof record.description !== "string" || record.description.length === 0) violations.push("meta.description must be a non-empty string");
|
|
720
|
+
if (record.whenToUse !== void 0 && typeof record.whenToUse !== "string") violations.push("meta.whenToUse must be a string");
|
|
721
|
+
const phases = [];
|
|
722
|
+
if (record.phases !== void 0) if (!Array.isArray(record.phases)) violations.push("meta.phases must be an array");
|
|
723
|
+
else record.phases.forEach((phase, index) => {
|
|
724
|
+
if (typeof phase !== "object" || phase === null || Array.isArray(phase)) {
|
|
725
|
+
violations.push(`meta.phases[${index}] must be an object`);
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
const entry = phase;
|
|
729
|
+
for (const key of Object.keys(entry)) if (![
|
|
730
|
+
"title",
|
|
731
|
+
"detail",
|
|
732
|
+
"provider",
|
|
733
|
+
"model"
|
|
734
|
+
].includes(key)) violations.push(`meta.phases[${index}].${key} is not a recognized field`);
|
|
735
|
+
if (typeof entry.title !== "string" || entry.title.length === 0) violations.push(`meta.phases[${index}].title must be a non-empty string`);
|
|
736
|
+
if (entry.detail !== void 0 && typeof entry.detail !== "string") violations.push(`meta.phases[${index}].detail must be a string`);
|
|
737
|
+
if (entry.provider !== void 0 && typeof entry.provider !== "string") violations.push(`meta.phases[${index}].provider must be a string`);
|
|
738
|
+
if (entry.model !== void 0 && typeof entry.model !== "string") violations.push(`meta.phases[${index}].model must be a string`);
|
|
739
|
+
if (violations.length === 0) phases.push({
|
|
740
|
+
title: entry.title,
|
|
741
|
+
...entry.detail !== void 0 ? { detail: entry.detail } : {},
|
|
742
|
+
...entry.provider !== void 0 ? { provider: entry.provider } : {},
|
|
743
|
+
...entry.model !== void 0 ? { model: entry.model } : {}
|
|
744
|
+
});
|
|
745
|
+
});
|
|
746
|
+
if (violations.length > 0) return { violations };
|
|
747
|
+
return {
|
|
748
|
+
violations,
|
|
749
|
+
meta: {
|
|
750
|
+
name: record.name,
|
|
751
|
+
description: record.description,
|
|
752
|
+
...record.whenToUse !== void 0 ? { whenToUse: record.whenToUse } : {},
|
|
753
|
+
...record.phases !== void 0 ? { phases } : {}
|
|
754
|
+
}
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* Validate a caller-provided meta value against the {@link WorkflowMeta}
|
|
759
|
+
* contract. Throws `META_INVALID` naming every violation (unknown fields,
|
|
760
|
+
* missing/mistyped `name`/`description`, malformed `phases`); the returned
|
|
761
|
+
* meta is a NORMALIZED copy built from the validated fields, so the engine
|
|
762
|
+
* never aliases the caller's object.
|
|
763
|
+
* @param value - the meta data from the start request (plain JSON by the seam contract).
|
|
764
|
+
* @returns the validated, normalized meta block.
|
|
765
|
+
*/
|
|
766
|
+
function validateMeta(value) {
|
|
767
|
+
const { meta, violations } = validateMetaShape(value);
|
|
768
|
+
if (meta === void 0) throw new WorkflowError(`invalid meta: ${violations.join("; ")}`, "META_INVALID");
|
|
769
|
+
return meta;
|
|
770
|
+
}
|
|
771
|
+
//#endregion
|
|
772
|
+
//#region lib/types/index.js
|
|
773
|
+
/**
|
|
774
|
+
* Worker-thread workflow engine. Each run executes its model-written script in
|
|
775
|
+
* an escapable vm context on a fresh worker and bridges `agent()` calls to host
|
|
776
|
+
* subagents. The thread prevents synchronous script work from blocking the host
|
|
777
|
+
* and permits forced termination, but it is containment rather than a security boundary.
|
|
778
|
+
* @module @deepseek-ai/dsh-workflow-worker-thread
|
|
779
|
+
*/
|
|
780
|
+
/** A body that still carries the Claude Code-style meta header (meta rides the seam as data here). */
|
|
781
|
+
const META_STATEMENT = /^\s*export\s+const\s+meta\b/;
|
|
782
|
+
/**
|
|
783
|
+
* Parse-check the body with the SAME wrapper the worker-side runtime
|
|
784
|
+
* compiles, so `start()` keeps the seam's synchronous `SCRIPT_PARSE` throw
|
|
785
|
+
* (the worker's own compile happens a thread away, after `start()` returned).
|
|
786
|
+
* One redundant parse per run, bought deliberately for the contract. A body
|
|
787
|
+
* opening with `export const meta` gets a pointed message instead of the
|
|
788
|
+
* wrapper's bare SyntaxError — the model's likeliest authoring slip.
|
|
789
|
+
*/
|
|
790
|
+
function assertBodyParses(body, name) {
|
|
791
|
+
if (META_STATEMENT.test(body)) throw new WorkflowError("workflow meta rides the `meta` request field, not the script: remove the `export const meta = {...}` statement from the body", "SCRIPT_PARSE");
|
|
792
|
+
try {
|
|
793
|
+
new vm.Script(`(async () => {\n${body}\n})()`, {
|
|
794
|
+
filename: `workflow:${name}`,
|
|
795
|
+
lineOffset: -1
|
|
796
|
+
});
|
|
797
|
+
} catch (error) {
|
|
798
|
+
throw new WorkflowError(`workflow script does not parse: ${String(error)}`, "SCRIPT_PARSE", { cause: error });
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
/** Resolve one run's provider route before publishing work. */
|
|
802
|
+
function resolveSubagentProvider(ctx, configured, override) {
|
|
803
|
+
const provider = override ?? configured;
|
|
804
|
+
if (provider.length === 0 || provider !== provider.trim()) throw new WorkflowError("workflow subagentProvider must be a non-empty normalized string", "INVALID_ARGUMENT");
|
|
805
|
+
if (ctx.subagents.getProvider(provider) === void 0) throw new WorkflowError(`no subagent provider registered for "${provider}"`, "AGENT_START");
|
|
806
|
+
return provider;
|
|
807
|
+
}
|
|
808
|
+
/** Resolve one run's total-child cap against the engine deployment ceiling. */
|
|
809
|
+
function resolveMaxTotalAgents(requested, ceiling) {
|
|
810
|
+
if (requested === void 0) return ceiling;
|
|
811
|
+
if (!Number.isSafeInteger(requested) || requested < 1) throw new WorkflowError("workflow maxTotalAgents must be a positive safe integer", "INVALID_ARGUMENT");
|
|
812
|
+
if (requested > ceiling) throw new WorkflowError(`workflow maxTotalAgents ${requested} exceeds the engine ceiling ${ceiling}`, "INVALID_ARGUMENT");
|
|
813
|
+
return requested;
|
|
814
|
+
}
|
|
815
|
+
/**
|
|
816
|
+
* The worker-thread engine service. `start()` validates the script up front
|
|
817
|
+
* (meta + a host-side body parse) and returns a {@link WorkflowRun} whose
|
|
818
|
+
* `result` never rejects; the `workflow/*` events fire around the run per
|
|
819
|
+
* the seam contract.
|
|
820
|
+
*/
|
|
821
|
+
var WorkerThreadWorkflowEngine = class extends WorkflowEngine {
|
|
822
|
+
static inject = ["subagents"];
|
|
823
|
+
static Config = z.object({
|
|
824
|
+
provider: z.string().default("spawn"),
|
|
825
|
+
maxConcurrentAgents: z.natural().default(0),
|
|
826
|
+
maxTotalAgents: z.natural().min(1).default(1e3),
|
|
827
|
+
maxItemsPerCall: z.natural().min(1).default(4096),
|
|
828
|
+
syncTimeoutMs: z.natural().min(1).default(5e3),
|
|
829
|
+
disposeGraceMs: z.natural().default(5e3)
|
|
830
|
+
});
|
|
831
|
+
config;
|
|
832
|
+
constructor(ctx, config) {
|
|
833
|
+
super(ctx);
|
|
834
|
+
this.config = config;
|
|
835
|
+
}
|
|
836
|
+
/**
|
|
837
|
+
* Validate and execute a workflow script in a fresh worker thread. Throws
|
|
838
|
+
* {@link WorkflowError} synchronously (`META_INVALID` for a malformed meta
|
|
839
|
+
* block, `SCRIPT_PARSE` for a body that does not compile) for a request
|
|
840
|
+
* that cannot begin; once a run is returned, every failure resolves through
|
|
841
|
+
* `result.stopReason` instead.
|
|
842
|
+
* @param request - the script body, its meta data and `args`, the parent
|
|
843
|
+
* agent, and an optional cancel signal.
|
|
844
|
+
* @returns the live run (its `result` resolves when the script settles).
|
|
845
|
+
*/
|
|
846
|
+
start(request) {
|
|
847
|
+
const meta = validateMeta(request.meta);
|
|
848
|
+
assertBodyParses(request.script, meta.name);
|
|
849
|
+
const subagentProvider = resolveSubagentProvider(this.ctx, this.config.provider, request.subagentProvider);
|
|
850
|
+
const maxTotalAgents = resolveMaxTotalAgents(request.maxTotalAgents, this.config.maxTotalAgents);
|
|
851
|
+
const id = WorkflowRunId(randomUUID());
|
|
852
|
+
const info = {
|
|
853
|
+
id,
|
|
854
|
+
meta
|
|
855
|
+
};
|
|
856
|
+
const limits = {
|
|
857
|
+
maxConcurrentAgents: this.config.maxConcurrentAgents === 0 ? Math.min(16, Math.max(1, availableParallelism() - 2)) : this.config.maxConcurrentAgents,
|
|
858
|
+
maxTotalAgents,
|
|
859
|
+
maxItemsPerCall: this.config.maxItemsPerCall,
|
|
860
|
+
syncTimeoutMs: this.config.syncTimeoutMs
|
|
861
|
+
};
|
|
862
|
+
const init = {
|
|
863
|
+
meta,
|
|
864
|
+
body: request.script,
|
|
865
|
+
...request.args !== void 0 ? { args: request.args } : {},
|
|
866
|
+
limits
|
|
867
|
+
};
|
|
868
|
+
const runCtx = this.ctx;
|
|
869
|
+
const subagents = runCtx.subagents;
|
|
870
|
+
const workerRun = new WorkerRun(runCtx, subagents, id, meta, request.parent, init, subagentProvider, this.config.disposeGraceMs, {
|
|
871
|
+
phase: (title) => {
|
|
872
|
+
this.emitWorkflowEvent("workflow/phase", info, title);
|
|
873
|
+
},
|
|
874
|
+
log: (message) => {
|
|
875
|
+
this.emitWorkflowEvent("workflow/log", info, message);
|
|
876
|
+
},
|
|
877
|
+
agentStart: (agent) => {
|
|
878
|
+
this.emitWorkflowEvent("workflow/agent-start", info, agent);
|
|
879
|
+
},
|
|
880
|
+
agentEnd: (agent) => {
|
|
881
|
+
this.emitWorkflowEvent("workflow/agent-end", info, agent);
|
|
882
|
+
}
|
|
883
|
+
}, request.signal);
|
|
884
|
+
this.emitWorkflowEvent("workflow/start", info);
|
|
885
|
+
workerRun.result.then((settled) => {
|
|
886
|
+
this.emitWorkflowEvent("workflow/end", info, {
|
|
887
|
+
stopReason: settled.stopReason,
|
|
888
|
+
...settled.error !== void 0 ? { error: settled.error } : {},
|
|
889
|
+
agentsStarted: settled.agentsStarted
|
|
890
|
+
});
|
|
891
|
+
});
|
|
892
|
+
return workerRun;
|
|
893
|
+
}
|
|
894
|
+
};
|
|
895
|
+
//#endregion
|
|
896
|
+
export { MaterializeError, WorkerThreadWorkflowEngine as default, materializeFromRealm, validateMeta };
|