@intx/workflow-host 0.2.2

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.
Files changed (81) hide show
  1. package/LICENSE +176 -0
  2. package/README.md +287 -0
  3. package/dist/adapters/blob-substrate.d.ts +49 -0
  4. package/dist/adapters/blob-substrate.js +140 -0
  5. package/dist/adapters/repo-store.d.ts +39 -0
  6. package/dist/adapters/repo-store.js +344 -0
  7. package/dist/adapters/spawn-child.d.ts +74 -0
  8. package/dist/adapters/spawn-child.js +152 -0
  9. package/dist/adapters/step-invoker.d.ts +114 -0
  10. package/dist/adapters/step-invoker.js +360 -0
  11. package/dist/child/env-bootstrap.d.ts +56 -0
  12. package/dist/child/env-bootstrap.js +120 -0
  13. package/dist/child/from-process-env.d.ts +127 -0
  14. package/dist/child/from-process-env.js +183 -0
  15. package/dist/child/index.d.ts +9 -0
  16. package/dist/child/index.js +9 -0
  17. package/dist/child/outbound-mail-bridge.d.ts +36 -0
  18. package/dist/child/outbound-mail-bridge.js +143 -0
  19. package/dist/child/proxy-repo-store.d.ts +27 -0
  20. package/dist/child/proxy-repo-store.js +200 -0
  21. package/dist/child/run-child.d.ts +320 -0
  22. package/dist/child/run-child.js +900 -0
  23. package/dist/child/self-discovery.d.ts +29 -0
  24. package/dist/child/self-discovery.js +57 -0
  25. package/dist/child/substrate-write-bridge.d.ts +72 -0
  26. package/dist/child/substrate-write-bridge.js +188 -0
  27. package/dist/child/supervisor-backed-transport.d.ts +10 -0
  28. package/dist/child/supervisor-backed-transport.js +113 -0
  29. package/dist/child/warm-agent-cache.d.ts +78 -0
  30. package/dist/child/warm-agent-cache.js +112 -0
  31. package/dist/drain-controller.d.ts +37 -0
  32. package/dist/drain-controller.js +46 -0
  33. package/dist/index.d.ts +10 -0
  34. package/dist/index.js +10 -0
  35. package/dist/ipc/control-channel.d.ts +336 -0
  36. package/dist/ipc/control-channel.js +532 -0
  37. package/dist/ipc/crypto.d.ts +46 -0
  38. package/dist/ipc/crypto.js +126 -0
  39. package/dist/ipc/envelope.d.ts +53 -0
  40. package/dist/ipc/envelope.js +88 -0
  41. package/dist/ipc/event-channel.d.ts +677 -0
  42. package/dist/ipc/event-channel.js +278 -0
  43. package/dist/ipc/index.d.ts +4 -0
  44. package/dist/ipc/index.js +143 -0
  45. package/dist/mail-bus/hub-transport-adapter.d.ts +30 -0
  46. package/dist/mail-bus/hub-transport-adapter.js +76 -0
  47. package/dist/mail-bus/index.d.ts +1 -0
  48. package/dist/mail-bus/index.js +1 -0
  49. package/dist/seams/index.d.ts +3 -0
  50. package/dist/seams/index.js +3 -0
  51. package/dist/seams/scheduler-adapter.d.ts +3 -0
  52. package/dist/seams/scheduler-adapter.js +24 -0
  53. package/dist/seams/scheduler.d.ts +94 -0
  54. package/dist/seams/scheduler.js +397 -0
  55. package/dist/seams/signal-channel.d.ts +74 -0
  56. package/dist/seams/signal-channel.js +304 -0
  57. package/dist/supervisor/cancel-signing.d.ts +68 -0
  58. package/dist/supervisor/cancel-signing.js +144 -0
  59. package/dist/supervisor/child-termination.d.ts +51 -0
  60. package/dist/supervisor/child-termination.js +76 -0
  61. package/dist/supervisor/credentials.d.ts +101 -0
  62. package/dist/supervisor/credentials.js +153 -0
  63. package/dist/supervisor/dispatch-attribution.d.ts +37 -0
  64. package/dist/supervisor/dispatch-attribution.js +114 -0
  65. package/dist/supervisor/drain-timeout.d.ts +127 -0
  66. package/dist/supervisor/drain-timeout.js +231 -0
  67. package/dist/supervisor/index.d.ts +7 -0
  68. package/dist/supervisor/index.js +6 -0
  69. package/dist/supervisor/recycle.d.ts +212 -0
  70. package/dist/supervisor/recycle.js +440 -0
  71. package/dist/supervisor/run-event-compaction.d.ts +34 -0
  72. package/dist/supervisor/run-event-compaction.js +115 -0
  73. package/dist/supervisor/spawn-env.d.ts +39 -0
  74. package/dist/supervisor/spawn-env.js +36 -0
  75. package/dist/supervisor/supervisor.d.ts +202 -0
  76. package/dist/supervisor/supervisor.js +2244 -0
  77. package/dist/supervisor/terminal-broadcaster.d.ts +45 -0
  78. package/dist/supervisor/terminal-broadcaster.js +184 -0
  79. package/dist/supervisor/types.d.ts +542 -0
  80. package/dist/supervisor/types.js +10 -0
  81. package/package.json +35 -0
@@ -0,0 +1,900 @@
1
+ // `runWorkflowChild` -- the workflow-process child's runtime body.
2
+ //
3
+ // The package-owned binary at `packages/workflow-host/bin/workflow-child`
4
+ // is a thin wrapper that parses `process.env`, opens stdin/stdout for
5
+ // the control channel, accepts the inherited event-channel fd, builds
6
+ // the substrate `RepoStore`, and invokes this function. Tests bypass
7
+ // the binary and call `runWorkflowChild` directly with mock streams
8
+ // and an in-memory substrate.
9
+ //
10
+ // The signature accepts every I/O and substrate handle as an injected
11
+ // dependency. Nothing inside this function reads `process.env` or
12
+ // reaches into a singleton; the binary's job is to bridge the
13
+ // process-shaped surfaces to this function's typed opts.
14
+ //
15
+ // Lifecycle:
16
+ // 1. Open the control channel and event channel using the IPC
17
+ // primitives. Verify the supervisor's first signed control frame
18
+ // by virtue of the receiver iterator's per-frame signature check.
19
+ // 2. Construct the `WorkflowRuntimeEnv` from the production env
20
+ // adapters (RepoStore, BlobSubstrate, StepInvoker, SpawnChild)
21
+ // and the substrate-shaped seams (signal channel; scheduler is a
22
+ // host-process singleton supplied by the binary).
23
+ // 3. Discover any in-flight runs via the workflow-run repo's `runs/`
24
+ // subdirectory and call `runtimeRun` with `resumeFromEvents` for
25
+ // each one whose log lacks a terminal event.
26
+ // 4. Emit `ready` on the control channel.
27
+ // 5. Loop on control-channel frames:
28
+ // - `trigger.fired` -> open a new run via `runtimeRun`.
29
+ // - `grants-updated` -> replace the credentialsSnapshot.
30
+ // - `drain` -> forward to the drain controller (no-op here).
31
+ // - `shutdown` -> stop accepting new triggers and exit the
32
+ // loop.
33
+ //
34
+ // The `WorkflowAuthorize` closure evaluates grants against the active
35
+ // `credentialsSnapshot`. The snapshot's initial value can arrive in
36
+ // the spawn-time env bootstrap (multi-step deploys whose host wires
37
+ // the snapshot up-front) or via the first `grants-updated` control
38
+ // frame; the closure re-reads the closure-local snapshot on every
39
+ // invocation so a live update applies to subsequent steps without
40
+ // reconstructing the env.
41
+ //
42
+ // The DrainController is wired here against the production
43
+ // `createWorkflowHostDrainController`: on receipt of the supervisor's
44
+ // `drain` control mail the controller flips its signal, the runtime
45
+ // body observes the change at its four observation points, and the
46
+ // `behaviorFor` resolver derived from the loaded `WorkflowDefinition`
47
+ // classifies each in-flight step as cancel-mode or wait-mode. The
48
+ // supervisor's recycle policy is OS-driven (drain, SIGTERM, SIGKILL,
49
+ // respawn) and does not require a child-side control frame.
50
+ import { type } from "arktype";
51
+ import { getLogger } from "@intx/log";
52
+ import { generateKeyPair } from "@intx/crypto";
53
+ import { base64Decode, hexEncode } from "@intx/types";
54
+ import { readProcessingEntry, workflowDefinitionEnvelopeSchema, } from "@intx/hub-sessions/substrate";
55
+ import { extractPartByPath, parseHeaderSection, parseMimePart, } from "@intx/mime";
56
+ import { createDefaultDirectorRegistry } from "@intx/agent";
57
+ import { emptyState, runtimeRun } from "@intx/workflow";
58
+ import { createWorkflowHostDrainController, } from "../drain-controller.js";
59
+ import { createWorkflowRunRepoStore } from "../adapters/repo-store.js";
60
+ import { createWorkflowRunBlobSubstrate } from "../adapters/blob-substrate.js";
61
+ import { createControlChannelSender, createEventChannelSender, receiveControlChannel, } from "../ipc/index.js";
62
+ import { createWorkflowHostSignalChannel } from "../seams/signal-channel.js";
63
+ import { hashGrants } from "../supervisor/credentials.js";
64
+ import { discoverInFlightRuns } from "./self-discovery.js";
65
+ import { createWarmAgentCache } from "./warm-agent-cache.js";
66
+ const logger = getLogger(["workflow-host", "child"]);
67
+ const WORKFLOW_JSON_PATH = "workflow.json";
68
+ export function createCredentialsBackedAuthorize(ref, evaluate) {
69
+ return async (resource, action, ctx) => {
70
+ const stepId = ctx?.stepId;
71
+ if (stepId === undefined) {
72
+ throw new Error("workflow-child authorize: missing stepId in AuthorizeContext; the runtime body must thread it through every step invocation");
73
+ }
74
+ const snapshot = ref.current;
75
+ if (snapshot === null) {
76
+ throw new Error("workflow-child authorize: no credentialsSnapshot active; the supervisor must push one before any step runs");
77
+ }
78
+ const entry = snapshot.steps.find((s) => s.stepId === stepId);
79
+ if (entry === undefined) {
80
+ throw new Error(`workflow-child authorize: credentialsSnapshot has no entry for stepId ${stepId}`);
81
+ }
82
+ return evaluate({
83
+ resource,
84
+ action,
85
+ stepId,
86
+ attempt: ctx?.attempt,
87
+ runId: ctx?.runId,
88
+ grants: entry.grants,
89
+ });
90
+ };
91
+ }
92
+ /**
93
+ * Run the workflow-process child. Resolves once the control channel
94
+ * emits `shutdown` (or ends without a frame, in which case the loop
95
+ * exits cleanly).
96
+ */
97
+ export async function runWorkflowChild(opts) {
98
+ const credentialsRef = {
99
+ current: opts.bindings.initialCredentialsSnapshot ?? null,
100
+ };
101
+ const sourcesRef = {
102
+ current: opts.bindings.initialSources ?? {},
103
+ };
104
+ const directors = opts.bindings.directors ?? createDefaultDirectorRegistry();
105
+ const clock = opts.bindings.clock ?? defaultClock;
106
+ const newId = opts.bindings.newId ?? defaultNewId;
107
+ // Mint the child's own upstream-signing keypair. The private half
108
+ // never leaves this address space; the public half rides on the
109
+ // `ready` frame's payload so the supervisor can verify subsequent
110
+ // upstream frames against it.
111
+ const childKeyPair = await (opts.bindings.ipcChildKeyPairFactory ?? generateKeyPair)();
112
+ const runtimeRepoStore = createWorkflowRunRepoStore({
113
+ substrate: opts.bindings.substrate,
114
+ repoId: opts.bindings.workflowRunRepoId,
115
+ principal: opts.bindings.principal,
116
+ ref: opts.bindings.workflowRunRef,
117
+ });
118
+ const eventSender = createEventChannelSender({
119
+ hmacKey: opts.env.hmacKey,
120
+ channelId: opts.env.channelId,
121
+ writer: opts.eventWriter,
122
+ });
123
+ const definition = await loadWorkflowDefinition(opts.bindings);
124
+ const authorize = createCredentialsBackedAuthorize(credentialsRef, opts.bindings.evaluateGrants);
125
+ const drainController = createWorkflowHostDrainController({ definition });
126
+ // Warm-agent cache (design §3b). Built only when the deployment is a
127
+ // warm candidate (the single-step long-lived agent the deploy
128
+ // projection marked). The cache lives in this run-loop's address
129
+ // space, holds the constructed agent across messages, and is evicted
130
+ // -- running the wrapped `agent.close()` that kills the LSP subprocess
131
+ // -- at the loop's teardown points (the shutdown frame and the
132
+ // exit-path `finally` below). A multi-step deployment leaves this
133
+ // `undefined`, so its steps keep instantiate-send-teardown and no
134
+ // multi-step agent is ever warm-kept.
135
+ const warmCache = opts.env.warmKeep
136
+ ? createWarmAgentCache()
137
+ : undefined;
138
+ // Construct the upstream control-channel sender up-front. The
139
+ // supervisor's `waitForReady` consumes the `ready` frame and the
140
+ // upstream-control pump consumes every subsequent upstream payload
141
+ // (`pack.push.request`, `terminal.event`, `recycle.request`) on the
142
+ // same iterator. Building the sender here lets the resume loop
143
+ // below attach a terminal-event emitter onto every resumed run's
144
+ // `complete` promise without re-deriving the sender lazily.
145
+ const upstreamSender = opts.upstreamSender ??
146
+ createControlChannelSender({
147
+ privateKeySeed: childKeyPair.privateKey,
148
+ channelId: opts.env.channelId,
149
+ writer: opts.controlWriter,
150
+ });
151
+ // Self-discovery before announcing `ready`. The runtime body must
152
+ // see every in-flight run before the supervisor starts forwarding
153
+ // `trigger.fired` frames; otherwise a fresh trigger could land
154
+ // ahead of a resume and the runtime would commit a duplicate run
155
+ // entry for the same id.
156
+ const discovered = await discoverInFlightRuns({
157
+ substrate: opts.bindings.substrate,
158
+ repoId: opts.bindings.workflowRunRepoId,
159
+ runtimeRepoStore,
160
+ });
161
+ const resumedRunIds = [];
162
+ // One-driver-per-run claim. A runId present here is already being
163
+ // driven by a live `runtimeRun` in this process (a resume below, or an
164
+ // earlier trigger). The trigger.fire path consults it to refuse
165
+ // spawning a second concurrent driver for the same runId: two drivers
166
+ // race to settle the same residual and the loser throws an uncaught
167
+ // TransitionError into its fire-and-forget continuation. Each site
168
+ // removes its entry when the run reaches terminal.
169
+ const runsInFlight = new Map();
170
+ for (const run of discovered) {
171
+ const env = buildRuntimeEnv({
172
+ runId: run.runId,
173
+ bindings: opts.bindings,
174
+ runtimeRepoStore,
175
+ authorize,
176
+ directors,
177
+ clock,
178
+ newId,
179
+ drainController,
180
+ warmCache,
181
+ sourcesRef,
182
+ onEvent: (event) => {
183
+ void eventSender.send(event).catch((cause) => {
184
+ logger.error `event-channel send failed during resume run ${run.runId}: ${String(cause)}`;
185
+ });
186
+ },
187
+ });
188
+ const handle = runtimeRun(definition, env, {
189
+ runId: run.runId,
190
+ resumeFromEvents: run.seedEvents,
191
+ });
192
+ runsInFlight.set(run.runId, handle);
193
+ // Fire-and-forget: the runtime body's `complete` settles when the
194
+ // run reaches a terminal phase; the child's control-loop does not
195
+ // block on resumed runs. The supervisor's dispatch loop / drain
196
+ // accumulator subscribes to the resumed run's terminal via the
197
+ // `terminal.event` upstream frame the child emits below.
198
+ void handle.complete
199
+ .then((result) => {
200
+ reclaimRunStorageIfCold({
201
+ warmKeep: opts.env.warmKeep,
202
+ cleanupRunStorage: opts.bindings.cleanupRunStorage,
203
+ runId: run.runId,
204
+ });
205
+ return emitTerminalEvent(upstreamSender, result);
206
+ })
207
+ .catch((cause) => {
208
+ logger.error `resumed run ${run.runId} failed: ${String(cause)}`;
209
+ })
210
+ .finally(() => {
211
+ runsInFlight.delete(run.runId);
212
+ });
213
+ resumedRunIds.push(run.runId);
214
+ }
215
+ // `ready` rides over the control channel back to the supervisor.
216
+ // The supervisor's `waitForReady` consumes it on its receive side.
217
+ // The upstream sender is constructed above so the resume loop can
218
+ // attach a terminal-event emitter onto every resumed run's
219
+ // `complete` promise; the same sender lives behind the pack-push
220
+ // bridge the process wrapper builds (when the caller supplies one),
221
+ // so the upstream frame sequence is monotonic across `ready`,
222
+ // every `pack.push.request`, every `terminal.event`, and any
223
+ // future child-originated upstream payload. Upstream frames are
224
+ // signed by the child's own private key; the `ready` payload
225
+ // publishes the matching public half so the supervisor can verify
226
+ // every subsequent upstream frame.
227
+ await upstreamSender.send({
228
+ type: "ready",
229
+ data: {
230
+ childPid: process.pid,
231
+ childPublicKey: hexEncode(childKeyPair.publicKey),
232
+ },
233
+ });
234
+ const triggeredRunIds = [];
235
+ // Control-loop. The receiver iterator yields one verified payload
236
+ // per call; any signature/channelId/seq violation crashes the
237
+ // receiver via `onCrash` and ends the iterator.
238
+ const iter = receiveControlChannel({
239
+ publicKey: opts.env.hostPublicKey,
240
+ channelId: opts.env.channelId,
241
+ reader: opts.controlReader,
242
+ onCrash: (reason) => {
243
+ logger.error `workflow-child control channel crash: ${reason}`;
244
+ },
245
+ });
246
+ try {
247
+ for await (const payload of iter) {
248
+ if (await handleControlPayload(payload, {
249
+ env: opts.env,
250
+ bindings: opts.bindings,
251
+ credentialsRef,
252
+ runtimeRepoStore,
253
+ definition,
254
+ authorize,
255
+ directors,
256
+ clock,
257
+ newId,
258
+ eventSender,
259
+ upstreamSender,
260
+ drainController,
261
+ triggeredRunIds,
262
+ runsInFlight,
263
+ warmCache,
264
+ sourcesRef,
265
+ ...(opts.substrateWriteBridge !== undefined
266
+ ? { substrateWriteBridge: opts.substrateWriteBridge }
267
+ : {}),
268
+ ...(opts.outboundMailBridge !== undefined
269
+ ? { outboundMailBridge: opts.outboundMailBridge }
270
+ : {}),
271
+ })) {
272
+ // shutdown received; the shutdown case already cancelled any
273
+ // pending substrate writes before returning true.
274
+ break;
275
+ }
276
+ }
277
+ }
278
+ finally {
279
+ // Any exit path -- clean (iterator end), dirty (thrown error),
280
+ // shutdown (already cancelled, repeat is a no-op on an empty map)
281
+ // -- cancels every still-pending substrate write so the runtime
282
+ // call site that fired the write surfaces a structured rejection
283
+ // rather than awaiting indefinitely on a control channel the
284
+ // supervisor has already torn down.
285
+ if (opts.substrateWriteBridge !== undefined) {
286
+ opts.substrateWriteBridge.cancelAll("workflow-child control loop exited");
287
+ }
288
+ // Same contract for outbound mail: a step agent's mail-tool send
289
+ // that is still awaiting the supervisor's `outbound.result` when
290
+ // the control loop exits must surface a structured rejection rather
291
+ // than hang on a torn-down channel.
292
+ if (opts.outboundMailBridge !== undefined) {
293
+ opts.outboundMailBridge.cancelAll("workflow-child control loop exited");
294
+ }
295
+ // Evict the warm-agent cache (design §3b) on every exit path:
296
+ // graceful (shutdown frame -> iterator end), dirty (thrown error),
297
+ // or the control channel closing. Eviction runs the wrapped
298
+ // `agent.close()` that disposes plugins and kills the LSP
299
+ // subprocess, so no warm agent or LSP outlives the run-loop. On a
300
+ // production hard kill (recycle/SIGKILL) the process dies before
301
+ // this runs, but the OS reaps the LSP grandchild regardless; this
302
+ // path covers the graceful teardown the eviction contract names.
303
+ if (warmCache !== undefined) {
304
+ await warmCache.evictAll("workflow-child control loop exited");
305
+ }
306
+ }
307
+ return {
308
+ resumedRunIds,
309
+ triggeredRunIds,
310
+ finalCredentialsSnapshot: credentialsRef.current,
311
+ };
312
+ }
313
+ /**
314
+ * Handle a single control-channel payload. Returns `true` when the
315
+ * payload signals shutdown so the caller exits the loop; otherwise
316
+ * `false`.
317
+ */
318
+ async function handleControlPayload(payload, ctx) {
319
+ switch (payload.type) {
320
+ case "trigger.fire": {
321
+ // One driver per runId. If this child is already driving this
322
+ // runId -- self-discovery resumed it, or an earlier trigger opened
323
+ // it -- the supervisor's re-fire (which carries `runId = messageId`
324
+ // and no resumeFromEvents) must NOT spawn a second `runtimeRun`. A
325
+ // second concurrent driver would race the live one to settle the
326
+ // same residual and the loser throws an uncaught TransitionError,
327
+ // and even a driver that avoided the throw would double-emit the
328
+ // terminal. The live driver's completion continuation owns the
329
+ // single terminal emission; the supervisor's terminal-event-driven
330
+ // `markConsumed` consumes the message off that one terminal, so no
331
+ // work is dropped by declining here. Record the runId (the
332
+ // supervisor did fire a trigger and it was accepted) and signal
333
+ // "handled, not shutdown" the same way the normal trigger case
334
+ // returns, without awaiting the live handle's `complete` inline
335
+ // (that would block the control loop).
336
+ if (ctx.runsInFlight.has(payload.data.runId)) {
337
+ ctx.triggeredRunIds.push(payload.data.runId);
338
+ return false;
339
+ }
340
+ // Resolve the inbound mail bytes for this messageId from the
341
+ // claim-check processing entry the supervisor created when it
342
+ // dequeued the message. The bytes become the run's trigger
343
+ // payload; the one-step workflow's first step defaults its input
344
+ // selector to `trigger.payload` (defineWorkflow's default-input
345
+ // convention), so the step input resolves to the inbound message
346
+ // and `agent.send` receives it. A missing or unreadable entry
347
+ // surfaces loudly -- the run cannot proceed without its input,
348
+ // and silently running the agent with empty input would mask a
349
+ // real mailbox-ownership failure.
350
+ const triggerPayload = await resolveTriggerPayload({
351
+ substrate: ctx.bindings.substrate,
352
+ principal: ctx.bindings.principal,
353
+ workflowRunRepoId: ctx.bindings.workflowRunRepoId,
354
+ mailboxAddress: ctx.env.mailboxAddress,
355
+ messageId: payload.data.messageId,
356
+ });
357
+ const env = buildRuntimeEnv({
358
+ runId: payload.data.runId,
359
+ bindings: ctx.bindings,
360
+ runtimeRepoStore: ctx.runtimeRepoStore,
361
+ authorize: ctx.authorize,
362
+ directors: ctx.directors,
363
+ clock: ctx.clock,
364
+ newId: ctx.newId,
365
+ drainController: ctx.drainController,
366
+ warmCache: ctx.warmCache,
367
+ sourcesRef: ctx.sourcesRef,
368
+ onEvent: (event) => {
369
+ void ctx.eventSender.send(event).catch((cause) => {
370
+ logger.error `event-channel send failed during run ${payload.data.runId}: ${String(cause)}`;
371
+ });
372
+ },
373
+ });
374
+ const handle = runtimeRun(ctx.definition, env, {
375
+ runId: payload.data.runId,
376
+ consumedMessageId: payload.data.messageId,
377
+ triggerPayload,
378
+ });
379
+ ctx.runsInFlight.set(payload.data.runId, handle);
380
+ // Fan the run's terminal status back to the supervisor over the
381
+ // upstream control channel. The supervisor's dispatch loop and
382
+ // any armed drainTimeout accumulator subscribe through the
383
+ // per-cohort broadcaster the supervisor owns; the broadcaster
384
+ // settles when this frame lands. The runtime body commits the
385
+ // terminal event to the workflow-run substrate as part of the
386
+ // same lifecycle moment, so the on-disk audit chain and the
387
+ // peer notification originate from the same code path.
388
+ void handle.complete
389
+ .then((result) => {
390
+ reclaimRunStorageIfCold({
391
+ warmKeep: ctx.env.warmKeep,
392
+ cleanupRunStorage: ctx.bindings.cleanupRunStorage,
393
+ runId: payload.data.runId,
394
+ });
395
+ return emitTerminalEvent(ctx.upstreamSender, result);
396
+ })
397
+ .catch((cause) => {
398
+ logger.error `triggered run ${payload.data.runId} failed: ${String(cause)}`;
399
+ })
400
+ .finally(() => {
401
+ ctx.runsInFlight.delete(payload.data.runId);
402
+ });
403
+ ctx.triggeredRunIds.push(payload.data.runId);
404
+ return false;
405
+ }
406
+ case "grants-updated": {
407
+ // The supervisor pushes the fresh snapshot inline. Replace the
408
+ // closure-local snapshot reference so every subsequent
409
+ // `authorize` call against the credentials-backed closure
410
+ // (`createCredentialsBackedAuthorize`) reads the new per-step
411
+ // grants without reconstructing the workflow env. The optional
412
+ // `stepHashes` cross-check is informational: when present, a
413
+ // mismatch against the snapshot's per-step contentHash crashes
414
+ // the child rather than silently honoring a desynchronized
415
+ // push.
416
+ const snapshot = {
417
+ steps: payload.data.snapshot.steps.map((s) => ({
418
+ stepId: s.stepId,
419
+ address: s.address,
420
+ grants: s.grants,
421
+ contentHash: s.contentHash,
422
+ })),
423
+ };
424
+ if (payload.data.stepHashes !== undefined) {
425
+ for (const step of snapshot.steps) {
426
+ const expected = payload.data.stepHashes[step.stepId];
427
+ if (expected !== undefined && expected !== step.contentHash) {
428
+ throw new Error(`workflow-child grants-updated: stepHashes pin for ${step.stepId} (${expected}) does not match snapshot contentHash (${step.contentHash})`);
429
+ }
430
+ }
431
+ }
432
+ ctx.credentialsRef.current = snapshot;
433
+ return false;
434
+ }
435
+ case "signal.deliver": {
436
+ // Land the signal as a `SignalReceived` commit on the run's
437
+ // event log. The signal-channel substrate's `subscribeKind`
438
+ // peer (the per-run signal channel installed at run start) is
439
+ // what resolves any pending `awaitNext` awaiter -- the
440
+ // control-loop's job is just to commit. Constructing an
441
+ // ad-hoc signal channel scoped to this runId keeps the
442
+ // control-loop free of per-run signal-channel bookkeeping
443
+ // while still routing through the canonical writer path.
444
+ //
445
+ // The deliver path writes through `writeTreePreservingPrefix`,
446
+ // which the sidecar's substrate factory wraps with a pack-push
447
+ // hook. The hook emits a `pack.push.request` on the upstream
448
+ // control channel and awaits the supervisor's matching
449
+ // `pack.push.response` on the same downstream stream this
450
+ // iterator pulls from. Awaiting the deliver inline blocks the
451
+ // iterator from pulling the response that resolves the deliver
452
+ // -- a deadlock observed end-to-end with the workflow-run
453
+ // pack-pushing wrapper. Fire the deliver off the loop so the
454
+ // iterator continues pumping `pack.push.response` (and any other
455
+ // downstream payload) while the deliver settles in the
456
+ // background. A commit failure surfaces via the logger; the
457
+ // runtime body's `signalChannel.awaitNext` peer either resolves
458
+ // (deliver landed) or remains pending until a subsequent
459
+ // delivery.
460
+ const transientSignalChannel = createWorkflowHostSignalChannel({
461
+ repoStore: ctx.bindings.substrate,
462
+ principal: ctx.bindings.principal,
463
+ repoId: ctx.bindings.workflowRunRepoId,
464
+ ref: ctx.bindings.workflowRunRef,
465
+ runId: payload.data.runId,
466
+ readState: () => emptyState(payload.data.runId),
467
+ newId: () => ctx.newId("sig"),
468
+ clock: ctx.clock,
469
+ });
470
+ void (async () => {
471
+ try {
472
+ await transientSignalChannel.deliver(payload.data.signalName, payload.data.payload, payload.data.signalId);
473
+ }
474
+ catch (cause) {
475
+ const reason = cause instanceof Error ? cause.message : String(cause);
476
+ logger.warn `signal.deliver commit failed runId=${payload.data.runId} signalName=${payload.data.signalName}: ${reason}`;
477
+ }
478
+ finally {
479
+ await transientSignalChannel.stop();
480
+ }
481
+ })();
482
+ return false;
483
+ }
484
+ case "drain": {
485
+ // The supervisor's `drain` control mail flips the controller's
486
+ // signal. The runtime body's four observation points read the
487
+ // signal on their next tick; cancel-mode steps abort their
488
+ // local controllers, wait-mode steps continue. The
489
+ // supervisor's drainTimeout accumulator (host-side) escalates
490
+ // to a signed CancelRequested if cancel-mode work outlasts the
491
+ // deadline.
492
+ logger.info `workflow-child drain requested (deadlineMs=${String(payload.data.deadlineMs)})`;
493
+ ctx.drainController.requestDrain();
494
+ return false;
495
+ }
496
+ case "shutdown": {
497
+ logger.info `workflow-child shutdown requested (${payload.data.reason})`;
498
+ if (ctx.substrateWriteBridge !== undefined) {
499
+ ctx.substrateWriteBridge.cancelAll("workflow-child shutdown requested");
500
+ }
501
+ return true;
502
+ }
503
+ case "sources-updated": {
504
+ // Live inference-source rotation for the warm single-step agent. The
505
+ // wire boundary (`SourcesUpdatedData`) already guaranteed the list is
506
+ // non-empty, its ids are unique, and its head is the default, so this
507
+ // trusts the frame and does not re-validate it.
508
+ //
509
+ // Only a single-step deployment rotates sources: its sole step's id
510
+ // is the sole key in the sources table, so the whole table is
511
+ // replaced. A multi-step deployment has no single per-agent source
512
+ // identity to swap and is never routed a sources-updated frame;
513
+ // assert it so a mis-route fails loudly rather than corrupting the
514
+ // table.
515
+ if (ctx.definition.stepOrder.length !== 1) {
516
+ throw new Error(`workflow-child sources-updated: only a single-step deployment can rotate sources; got ${String(ctx.definition.stepOrder.length)} steps`);
517
+ }
518
+ const stepId = ctx.definition.stepOrder[0];
519
+ if (stepId === undefined) {
520
+ throw new Error("workflow-child sources-updated: single-step deployment has no step id");
521
+ }
522
+ // A sources-updated only reaches a warm single-step deployment, which
523
+ // always builds a warm cache. An absent cache is a routing bug, not a
524
+ // silent no-op.
525
+ if (ctx.warmCache === undefined) {
526
+ throw new Error("workflow-child sources-updated: no warm cache; a sources rotation must target a warm single-step deployment");
527
+ }
528
+ // Swap the built warm agent first (a no-op when none is built yet),
529
+ // then update the table the next cold build reads. Applying to the
530
+ // agent first means a rotation racing eviction -- a closed-agent
531
+ // `setSources` throw -- leaves the table untouched rather than ahead
532
+ // of a half-applied swap.
533
+ ctx.warmCache.applySources(payload.data.sources, payload.data.defaultSource);
534
+ ctx.sourcesRef.current = { [stepId]: payload.data.sources };
535
+ return false;
536
+ }
537
+ case "ready": {
538
+ // `ready` is a child->supervisor frame; receiving one on the
539
+ // child's downstream side is a protocol violation that the
540
+ // sender should not be able to produce against the typed union.
541
+ throw new Error("workflow-child received a `ready` frame on its inbound control channel; this is a supervisor-only payload");
542
+ }
543
+ case "recycle.request": {
544
+ // `recycle.request` is the child->supervisor self-initiated
545
+ // recycle path; receiving one on the child's downstream side is
546
+ // the same shape of protocol violation as a downstream `ready`.
547
+ throw new Error("workflow-child received a `recycle.request` frame on its inbound control channel; this is a child-only upstream payload");
548
+ }
549
+ case "substrate.write.request": {
550
+ // `substrate.write.request` is the child->supervisor proxied
551
+ // write path; receiving one on the child's downstream side is a
552
+ // protocol violation in the same shape as a downstream `ready`.
553
+ throw new Error("workflow-child received a `substrate.write.request` frame on its inbound control channel; this is a child-only upstream payload");
554
+ }
555
+ case "substrate.merge.response": {
556
+ // `substrate.merge.response` is the child->supervisor merge
557
+ // result frame; receiving one on the child's downstream side is
558
+ // a protocol violation in the same shape as a downstream
559
+ // `ready`.
560
+ throw new Error("workflow-child received a `substrate.merge.response` frame on its inbound control channel; this is a child-only upstream payload");
561
+ }
562
+ case "terminal.event": {
563
+ // `terminal.event` is the child->supervisor terminal-run
564
+ // notification frame; receiving one on the child's downstream
565
+ // side is a protocol violation in the same shape as a downstream
566
+ // `ready` or `recycle.request`.
567
+ throw new Error("workflow-child received a `terminal.event` frame on its inbound control channel; this is a child-only upstream payload");
568
+ }
569
+ case "outbound.message": {
570
+ // `outbound.message` is the child->supervisor outbound-mail
571
+ // request frame; receiving one on the child's downstream side is a
572
+ // protocol violation in the same shape as a downstream `ready`.
573
+ throw new Error("workflow-child received an `outbound.message` frame on its inbound control channel; this is a child-only upstream payload");
574
+ }
575
+ case "outbound.result": {
576
+ // Route the supervisor's signed-send result to the outbound-mail
577
+ // bridge if one is wired. A result that lands without an active
578
+ // bridge means a stale supervisor frame for which no awaiter
579
+ // exists; log and drop rather than throwing so the runtime keeps
580
+ // progressing.
581
+ if (ctx.outboundMailBridge === undefined) {
582
+ logger.warn `workflow-child outbound.result received without a bridge wired; requestId=${payload.data.requestId} dropped`;
583
+ return false;
584
+ }
585
+ ctx.outboundMailBridge.handleResult(payload.data);
586
+ return false;
587
+ }
588
+ case "substrate.merge.request": {
589
+ // Route the request to the substrate-write bridge if one is
590
+ // wired. A request that lands without an active bridge means a
591
+ // stale supervisor frame for which no awaiter exists; log and
592
+ // drop rather than throwing so the runtime keeps progressing.
593
+ if (ctx.substrateWriteBridge === undefined) {
594
+ logger.warn `workflow-child substrate.merge.request received without a bridge wired; requestId=${payload.data.requestId} dropped`;
595
+ return false;
596
+ }
597
+ ctx.substrateWriteBridge.handleMergeRequest(payload.data);
598
+ return false;
599
+ }
600
+ case "substrate.write.response": {
601
+ // Route the response to the substrate-write bridge if one is
602
+ // wired. A response that lands without an active bridge means a
603
+ // stale supervisor frame for which no awaiter exists; log and
604
+ // drop rather than throwing so the runtime keeps progressing.
605
+ if (ctx.substrateWriteBridge === undefined) {
606
+ logger.warn `workflow-child substrate.write.response received without a bridge wired; requestId=${payload.data.requestId} dropped`;
607
+ return false;
608
+ }
609
+ ctx.substrateWriteBridge.handleWriteResponse(payload.data);
610
+ return false;
611
+ }
612
+ }
613
+ }
614
+ /**
615
+ * Construct a `WorkflowRuntimeEnv` for one run. Each run gets its own
616
+ * `BlobSubstrate` and `SignalChannel` because both are per-run by
617
+ * shape; the substrate handle and per-deployment `RepoStore` adapter
618
+ * are shared across runs.
619
+ */
620
+ function buildRuntimeEnv(args) {
621
+ const signalChannel = createWorkflowHostSignalChannel({
622
+ repoStore: args.bindings.substrate,
623
+ principal: args.bindings.principal,
624
+ repoId: args.bindings.workflowRunRepoId,
625
+ ref: args.bindings.workflowRunRef,
626
+ runId: args.runId,
627
+ readState: () => emptyState(args.runId),
628
+ newId: () => args.newId("sig"),
629
+ clock: args.clock,
630
+ });
631
+ const blobs = createWorkflowRunBlobSubstrate({
632
+ substrate: args.bindings.substrate,
633
+ repoId: args.bindings.workflowRunRepoId,
634
+ principal: args.bindings.principal,
635
+ runId: args.runId,
636
+ ref: args.bindings.workflowRunRef,
637
+ });
638
+ // Wrap the step invoker so every `InferenceEvent` the harness emits
639
+ // funnels through the per-run `onEvent` closure, which forwards
640
+ // the event up the HMAC-authenticated event channel. The wrap is
641
+ // the only translation point between the workflow-runtime's
642
+ // narrow `StepInvoker` shape (no event slot) and the host's
643
+ // `ChildStepInvoker` shape (carries onEvent), so the workflow-
644
+ // runtime never has to know an event firehose exists.
645
+ const invokeStep = async (req) => {
646
+ return args.bindings.invokeStep(req, args.onEvent, args.authorize, args.warmCache, args.sourcesRef);
647
+ };
648
+ return {
649
+ repoStore: args.runtimeRepoStore,
650
+ scheduler: args.bindings.scheduler,
651
+ signalChannel,
652
+ blobs,
653
+ directors: args.directors,
654
+ authorize: args.authorize,
655
+ invokeStep,
656
+ spawnChild: args.bindings.spawnChild,
657
+ clock: args.clock,
658
+ newId: args.newId,
659
+ drain: args.drainController,
660
+ };
661
+ }
662
+ /**
663
+ * Mirror a run's terminal status back to the supervisor over the
664
+ * upstream control channel. Fired once per run from the resume and
665
+ * trigger.fire paths' `complete` continuation. The supervisor's
666
+ * per-cohort terminal broadcaster fans the event out to the dispatch
667
+ * loop and any armed drainTimeout accumulator subscribed for the
668
+ * runId.
669
+ *
670
+ * The frame mirrors the run's committed terminal event: every field --
671
+ * `kind`, `seq`, `at`, and (for `RunFailed`) `error.message` -- is
672
+ * sourced from that event, which is why the frame's `seq` matches the
673
+ * on-disk audit-log entry. `terminalStatus` is only the cross-check: the
674
+ * found event's `kind` must agree with it. A missing terminal event, or
675
+ * one whose kind disagrees, is a runtime producer bug (the runtime
676
+ * commits the terminal event last), and emitting a frame anyway would
677
+ * desync the supervisor from the durable log that `discoverInFlightRuns`
678
+ * reads on resume -- the supervisor would settle a run the on-disk log
679
+ * still shows in-flight. So this throws instead: no frame keeps the
680
+ * supervisor and the durable log agreeing that the run is unsettled, and
681
+ * the next recycle/restart resumes it. The throw propagates to the
682
+ * caller's `complete` continuation, which logs it.
683
+ *
684
+ * Errors flowing out of `upstreamSender.send` are a different case --
685
+ * a transport send failure, logged but not rethrown. The supervisor's
686
+ * dispatch loop is the authoritative settler through its cohort abort
687
+ * signal, so a lost frame surfaces structurally as a wedged dispatch
688
+ * rather than a silent lifecycle failure. The invariant throws above run
689
+ * before the send so that catch never swallows them.
690
+ */
691
+ export function emitTerminalEvent(upstreamSender, result) {
692
+ // Recover the terminal event from the committed event log. The runtime
693
+ // body commits the terminal event last; walking from the end finds it in
694
+ // one step without rebuilding the state machine.
695
+ let terminalEvent = null;
696
+ for (let i = result.events.length - 1; i >= 0; i -= 1) {
697
+ const candidate = result.events[i];
698
+ if (candidate === undefined)
699
+ continue;
700
+ if (candidate.kind === "RunCompleted" ||
701
+ candidate.kind === "RunFailed" ||
702
+ candidate.kind === "RunCancelled") {
703
+ terminalEvent = candidate;
704
+ break;
705
+ }
706
+ }
707
+ if (terminalEvent === null) {
708
+ throw new Error(`emitTerminalEvent: run ${result.runId} terminated as ${result.terminalStatus} but its committed event log carries no terminal event (the runtime commits it last; this is a producer bug)`);
709
+ }
710
+ const expectedKind = result.terminalStatus === "completed"
711
+ ? "RunCompleted"
712
+ : result.terminalStatus === "cancelled"
713
+ ? "RunCancelled"
714
+ : "RunFailed";
715
+ if (terminalEvent.kind !== expectedKind) {
716
+ throw new Error(`emitTerminalEvent: run ${result.runId} terminated as ${result.terminalStatus} but its committed terminal event is ${terminalEvent.kind}`);
717
+ }
718
+ // The RunFailed-missing-error.message case the supervisor's
719
+ // `synthesizeTerminalEvent` guards is unreachable here: `result.events`
720
+ // is typed `WorkflowEvent[]`, and `RunFailed.error.message` is a
721
+ // non-optional `string`, so a RunFailed reached here always carries one.
722
+ // The supervisor needs that guard because it parses untrusted JSON.
723
+ let payload;
724
+ if (terminalEvent.kind === "RunCompleted") {
725
+ payload = {
726
+ runId: result.runId,
727
+ seq: terminalEvent.seq,
728
+ kind: "RunCompleted",
729
+ at: terminalEvent.at,
730
+ };
731
+ }
732
+ else if (terminalEvent.kind === "RunCancelled") {
733
+ payload = {
734
+ runId: result.runId,
735
+ seq: terminalEvent.seq,
736
+ kind: "RunCancelled",
737
+ at: terminalEvent.at,
738
+ };
739
+ }
740
+ else {
741
+ payload = {
742
+ runId: result.runId,
743
+ seq: terminalEvent.seq,
744
+ kind: "RunFailed",
745
+ at: terminalEvent.at,
746
+ error: { message: terminalEvent.error.message },
747
+ };
748
+ }
749
+ return upstreamSender
750
+ .send({
751
+ type: "terminal.event",
752
+ data: payload,
753
+ })
754
+ .catch((cause) => {
755
+ const message = cause instanceof Error ? cause.message : String(cause);
756
+ logger.error `terminal.event upstream send failed for runId=${result.runId}: ${message}`;
757
+ });
758
+ }
759
+ /**
760
+ * Reclaim a completed run's local-disk scratch on the COLD path.
761
+ *
762
+ * Gated on `!warmKeep`: a warm deployment's single agent reuses one
763
+ * stable workspace across runs (the substrate factory roots its scratch
764
+ * per agent, not per run), so per-run deletion there would wipe a live
765
+ * conversation's files mid-stream. On the cold path each run rebuilds
766
+ * its agent + scratch, so once the run is terminal nothing reopens its
767
+ * `runs/<runId>/` subtree (resume reads the substrate run log, not local
768
+ * step state) and the subtree is safe to drop.
769
+ *
770
+ * Best-effort: a reclamation failure is logged and swallowed -- it must
771
+ * never gate the run's terminal status or the upstream terminal.event.
772
+ */
773
+ function reclaimRunStorageIfCold(opts) {
774
+ if (opts.warmKeep)
775
+ return;
776
+ if (opts.cleanupRunStorage === undefined)
777
+ return;
778
+ void opts.cleanupRunStorage(opts.runId).catch((cause) => {
779
+ const message = cause instanceof Error ? cause.message : String(cause);
780
+ logger.warn `workflow-step-state cleanup failed for runId=${opts.runId}: ${message}`;
781
+ });
782
+ }
783
+ /**
784
+ * Resolve the run's trigger payload from the inbound mail message the
785
+ * supervisor moved to the claim-check processing queue. Reads the
786
+ * processing entry by messageId (a read-only snapshot of the
787
+ * `refs/heads/events` tip that cannot race the supervisor's
788
+ * `markConsumed` write), decodes the inlined raw MIME bytes, and
789
+ * extracts the conversation text the agent's `agent.send` receives.
790
+ *
791
+ * Defensive: a missing processing entry, an entry with no inlined
792
+ * bytes, or unparseable mail all throw. The run cannot proceed without
793
+ * its input, and a placeholder would mask a mailbox-ownership failure.
794
+ */
795
+ async function resolveTriggerPayload(args) {
796
+ const entry = await readProcessingEntry(args.substrate, args.principal, args.workflowRunRepoId, args.mailboxAddress, args.messageId);
797
+ if (entry === null) {
798
+ throw new Error(`workflow-child trigger.fire: no claim-check processing entry for messageId ${args.messageId} at ${args.mailboxAddress}; the run has no input to deliver to the agent`);
799
+ }
800
+ const rawMessageBase64 = entry.envelope.rawMessage;
801
+ if (rawMessageBase64 === undefined) {
802
+ throw new Error(`workflow-child trigger.fire: processing entry for messageId ${args.messageId} carries no inlined rawMessage; the supervisor must inline the inbound mail bytes for the child to deliver them as the step input`);
803
+ }
804
+ const raw = base64Decode(rawMessageBase64);
805
+ return extractConversationText(raw, args.messageId);
806
+ }
807
+ /**
808
+ * Extract the conversation body text from a raw inbound MIME message.
809
+ *
810
+ * Three on-wire shapes are handled, matching every producer the mail
811
+ * bus accepts:
812
+ * 1. The Interchange assembler's `multipart/signed` envelope whose
813
+ * first part is a `multipart/mixed` body carrying the text at part
814
+ * path `1.1`.
815
+ * 2. A `multipart/signed` envelope wrapping a bare `text/plain` part
816
+ * (a sender that signs without the `multipart/mixed` wrapper); the
817
+ * text is at part path `1`.
818
+ * 3. A flat top-level `text/plain` message (no multipart structure at
819
+ * all); the body is the bytes after the header section.
820
+ *
821
+ * The top-level `Content-Type` selects the shape: only a `multipart/*`
822
+ * root walks into parts; anything else reads the single body directly.
823
+ * This mirrors the conversation branch of mail-memory's `fetchFull`
824
+ * while also tolerating the flat single-part case the in-process agent
825
+ * accepts, so a non-standard inbound mail still delivers its text to
826
+ * the agent rather than crashing the run.
827
+ */
828
+ function extractConversationText(raw, messageId) {
829
+ const { headers, bodyOffset } = parseHeaderSection(raw);
830
+ const rootMime = (headers.get("content-type") ?? "")
831
+ .split(";")[0]
832
+ ?.trim()
833
+ .toLowerCase();
834
+ if (rootMime === undefined || !rootMime.startsWith("multipart/")) {
835
+ // Flat single-part message: the body is everything after the
836
+ // header section.
837
+ return new TextDecoder("utf-8", { fatal: false }).decode(raw.subarray(bodyOffset));
838
+ }
839
+ let part1;
840
+ try {
841
+ part1 = parseMimePart(extractPartByPath(raw, "1"));
842
+ }
843
+ catch (cause) {
844
+ throw new Error(`workflow-child trigger.fire: cannot parse inbound mail part 1 for messageId ${messageId}`, { cause });
845
+ }
846
+ const part1Mime = (part1.contentType.split(";")[0] ?? "")
847
+ .trim()
848
+ .toLowerCase();
849
+ const bodyBytes = part1Mime.startsWith("multipart/")
850
+ ? parseMimePart(extractPartByPath(raw, "1.1")).body
851
+ : part1.body;
852
+ return new TextDecoder("utf-8", { fatal: false }).decode(bodyBytes);
853
+ }
854
+ /**
855
+ * Load the `WorkflowDefinition` from the workflow asset repo's deploy
856
+ * ref. Mirrors the sibling `spawn-child` adapter's working-tree-read
857
+ * pattern -- the deploy orchestrator's `writeTree` materializes
858
+ * `workflow.json` under the substrate's repo dir, so a flat
859
+ * `fs.readFile` returns the bytes without round-tripping through git.
860
+ */
861
+ async function loadWorkflowDefinition(bindings) {
862
+ const fs = await import("node:fs/promises");
863
+ const path = await import("node:path");
864
+ const dir = bindings.substrate.getRepoDir(bindings.workflowDefinitionRepoId);
865
+ const workflowPath = path.join(dir, WORKFLOW_JSON_PATH);
866
+ let raw;
867
+ try {
868
+ raw = await fs.readFile(workflowPath, "utf8");
869
+ }
870
+ catch (cause) {
871
+ throw new Error(`workflow-child: cannot read ${WORKFLOW_JSON_PATH} for ${bindings.workflowDefinitionRepoId.kind}/${bindings.workflowDefinitionRepoId.id} on ${bindings.workflowDefinitionRef}`, { cause });
872
+ }
873
+ let parsed;
874
+ try {
875
+ parsed = JSON.parse(raw);
876
+ }
877
+ catch (cause) {
878
+ throw new Error(`workflow-child: ${WORKFLOW_JSON_PATH} for ${bindings.workflowDefinitionRepoId.kind}/${bindings.workflowDefinitionRepoId.id} on ${bindings.workflowDefinitionRef} is not valid JSON`, { cause });
879
+ }
880
+ const validated = workflowDefinitionEnvelopeSchema(parsed);
881
+ if (validated instanceof type.errors) {
882
+ throw new Error(`workflow-child: ${WORKFLOW_JSON_PATH} for ${bindings.workflowDefinitionRepoId.kind}/${bindings.workflowDefinitionRepoId.id} on ${bindings.workflowDefinitionRef} failed envelope validation: ${validated.summary}`);
883
+ }
884
+ // The envelope schema enforces the structural shape; the
885
+ // discriminated narrow over every primitive variant lives downstream
886
+ // in the runtime body. The sibling `spawn-child` adapter follows the
887
+ // same pattern at the same boundary.
888
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- envelope schema enforces structural shape; primitive narrows live downstream in the runtime body
889
+ return validated;
890
+ }
891
+ function defaultClock() {
892
+ return new Date();
893
+ }
894
+ let idCounter = 0;
895
+ function defaultNewId(prefix) {
896
+ idCounter += 1;
897
+ return `${prefix}-${String(idCounter)}-${Math.random().toString(36).slice(2, 8)}`;
898
+ }
899
+ /** Re-export the hash helper so callers can verify the snapshot's pin. */
900
+ export { hashGrants };