@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/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 };