@intx/workflow-host 0.2.2 → 0.4.0

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 (101) hide show
  1. package/README.md +77 -14
  2. package/dist/adapters/mail-part-store.d.ts +46 -0
  3. package/dist/adapters/mail-part-store.js +251 -0
  4. package/dist/adapters/repo-store.d.ts +22 -1
  5. package/dist/adapters/repo-store.js +56 -65
  6. package/dist/adapters/spawn-child.d.ts +109 -44
  7. package/dist/adapters/spawn-child.js +77 -81
  8. package/dist/adapters/step-invoker.d.ts +52 -2
  9. package/dist/adapters/step-invoker.js +284 -37
  10. package/dist/adapters/substrate-mailbox-store.d.ts +80 -0
  11. package/dist/adapters/substrate-mailbox-store.js +404 -0
  12. package/dist/child/child-mailbox-reader.d.ts +10 -0
  13. package/dist/child/child-mailbox-reader.js +23 -0
  14. package/dist/child/credential-cell.d.ts +8 -0
  15. package/dist/child/credential-cell.js +66 -0
  16. package/dist/child/env-bootstrap.d.ts +20 -6
  17. package/dist/child/env-bootstrap.js +9 -1
  18. package/dist/child/from-process-env.d.ts +12 -0
  19. package/dist/child/from-process-env.js +6 -0
  20. package/dist/child/index.d.ts +6 -2
  21. package/dist/child/index.js +4 -1
  22. package/dist/child/mailbox-mutation-bridge.d.ts +61 -0
  23. package/dist/child/mailbox-mutation-bridge.js +101 -0
  24. package/dist/child/mailbox-watch-registry.d.ts +17 -0
  25. package/dist/child/mailbox-watch-registry.js +61 -0
  26. package/dist/child/outbound-mail-bridge.d.ts +3 -2
  27. package/dist/child/outbound-mail-bridge.js +20 -32
  28. package/dist/child/parked-correlations.d.ts +42 -0
  29. package/dist/child/parked-correlations.js +80 -0
  30. package/dist/child/pending-request.d.ts +89 -0
  31. package/dist/child/pending-request.js +80 -0
  32. package/dist/child/proxy-repo-store.d.ts +3 -2
  33. package/dist/child/proxy-repo-store.js +2 -0
  34. package/dist/child/run-child.d.ts +170 -14
  35. package/dist/child/run-child.js +569 -155
  36. package/dist/child/self-discovery.d.ts +10 -0
  37. package/dist/child/self-discovery.js +25 -1
  38. package/dist/child/substrate-write-bridge.d.ts +3 -2
  39. package/dist/child/substrate-write-bridge.js +21 -38
  40. package/dist/child/supervisor-backed-transport.d.ts +52 -6
  41. package/dist/child/supervisor-backed-transport.js +205 -62
  42. package/dist/child/verified-definition-loader.d.ts +33 -0
  43. package/dist/child/verified-definition-loader.js +43 -0
  44. package/dist/child/warm-agent-cache.d.ts +44 -4
  45. package/dist/child/warm-agent-cache.js +41 -10
  46. package/dist/index.d.ts +6 -4
  47. package/dist/index.js +6 -4
  48. package/dist/ipc/control-channel.d.ts +151 -2
  49. package/dist/ipc/control-channel.js +222 -29
  50. package/dist/ipc/event-channel.d.ts +32 -1
  51. package/dist/ipc/index.d.ts +1 -1
  52. package/dist/ipc/index.js +1 -1
  53. package/dist/mail-bus/hub-transport-adapter.d.ts +12 -7
  54. package/dist/mail-bus/hub-transport-adapter.js +9 -5
  55. package/dist/run-body-then-cleanup.d.ts +17 -0
  56. package/dist/run-body-then-cleanup.js +38 -0
  57. package/dist/seams/scheduler.d.ts +16 -6
  58. package/dist/seams/scheduler.js +87 -97
  59. package/dist/supervisor/cancel-signing.d.ts +2 -2
  60. package/dist/supervisor/cancel-signing.js +4 -8
  61. package/dist/supervisor/credentials.d.ts +28 -15
  62. package/dist/supervisor/credentials.js +7 -7
  63. package/dist/supervisor/dispatch-attribution.js +1 -1
  64. package/dist/supervisor/drain-timeout.d.ts +2 -2
  65. package/dist/supervisor/drain-timeout.js +1 -1
  66. package/dist/supervisor/index.d.ts +3 -3
  67. package/dist/supervisor/index.js +2 -2
  68. package/dist/supervisor/recycle.d.ts +10 -3
  69. package/dist/supervisor/recycle.js +18 -7
  70. package/dist/supervisor/run-event-compaction.d.ts +5 -5
  71. package/dist/supervisor/run-event-compaction.js +14 -19
  72. package/dist/supervisor/run-event-recovery.d.ts +34 -0
  73. package/dist/supervisor/run-event-recovery.js +45 -0
  74. package/dist/supervisor/spawn-env.d.ts +2 -2
  75. package/dist/supervisor/spawn-env.js +1 -1
  76. package/dist/supervisor/supervisor.d.ts +106 -26
  77. package/dist/supervisor/supervisor.js +1903 -414
  78. package/dist/supervisor/terminal-commit.d.ts +36 -0
  79. package/dist/supervisor/terminal-commit.js +130 -0
  80. package/dist/supervisor/types.d.ts +180 -23
  81. package/dist/testing/change-notifier.d.ts +12 -0
  82. package/dist/testing/change-notifier.js +63 -0
  83. package/dist/testing/index.d.ts +8 -0
  84. package/dist/testing/index.js +16 -0
  85. package/dist/testing/log-capture.d.ts +52 -0
  86. package/dist/testing/log-capture.js +124 -0
  87. package/dist/testing/mail-bus.d.ts +22 -0
  88. package/dist/testing/mail-bus.js +78 -0
  89. package/dist/testing/memory-streams.d.ts +43 -0
  90. package/dist/testing/memory-streams.js +211 -0
  91. package/dist/testing/spawn-observer.d.ts +12 -0
  92. package/dist/testing/spawn-observer.js +36 -0
  93. package/dist/testing/stub-repo-store.d.ts +10 -0
  94. package/dist/testing/stub-repo-store.js +39 -0
  95. package/dist/testing/supervisor-reaper.d.ts +24 -0
  96. package/dist/testing/supervisor-reaper.js +49 -0
  97. package/dist/testing/upstream-frames.d.ts +47 -0
  98. package/dist/testing/upstream-frames.js +94 -0
  99. package/dist/workflow-definition-loader.d.ts +187 -0
  100. package/dist/workflow-definition-loader.js +422 -0
  101. package/package.json +18 -11
@@ -25,7 +25,8 @@
25
25
  // each one whose log lacks a terminal event.
26
26
  // 4. Emit `ready` on the control channel.
27
27
  // 5. Loop on control-channel frames:
28
- // - `trigger.fired` -> open a new run via `runtimeRun`.
28
+ // - `trigger.fired` -> first-fire the deployment's top-level run via
29
+ // `runtimeRun` (the supervisor only sends this for an absent log).
29
30
  // - `grants-updated` -> replace the credentialsSnapshot.
30
31
  // - `drain` -> forward to the drain controller (no-op here).
31
32
  // - `shutdown` -> stop accepting new triggers and exit the
@@ -47,24 +48,27 @@
47
48
  // classifies each in-flight step as cancel-mode or wait-mode. The
48
49
  // supervisor's recycle policy is OS-driven (drain, SIGTERM, SIGKILL,
49
50
  // respawn) and does not require a child-side control frame.
50
- import { type } from "arktype";
51
51
  import { getLogger } from "@intx/log";
52
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";
53
+ import { hexEncode } from "@intx/types";
54
+ import { rewriteInlineOnTriggerBodies, rewriteInlineChildWorkflowBodies, enumerateInlineLoopBodies, eagerlyResolveLoopFns, } from "@intx/workflow";
55
+ import { baseStepId, createDefaultActionInvoker, createInMemoryEffectLedger, createLoopIterationHandle, emptyState, runtimeRun, } from "@intx/workflow";
58
56
  import { createWorkflowHostDrainController, } from "../drain-controller.js";
59
57
  import { createWorkflowRunRepoStore } from "../adapters/repo-store.js";
60
58
  import { createWorkflowRunBlobSubstrate } from "../adapters/blob-substrate.js";
59
+ import { createMailPartReader } from "../adapters/mail-part-store.js";
60
+ import { createInMemorySpawnSuspendableChild, createInMemorySpawnChild, } from "../adapters/spawn-child.js";
61
61
  import { createControlChannelSender, createEventChannelSender, receiveControlChannel, } from "../ipc/index.js";
62
+ import { runBodyThenCleanup } from "../run-body-then-cleanup.js";
62
63
  import { createWorkflowHostSignalChannel } from "../seams/signal-channel.js";
63
64
  import { hashGrants } from "../supervisor/credentials.js";
65
+ import { loadVerifiedWorkflowDefinitionFromClosure } from "./verified-definition-loader.js";
66
+ import { loadWorkflowActionHandlersFromClosure, loadWorkflowDirectorRegistryFromClosure, loadWorkflowLoopFnsFromClosure, } from "../workflow-definition-loader.js";
64
67
  import { discoverInFlightRuns } from "./self-discovery.js";
68
+ import { collectParkedApprovalCorrelations, } from "./parked-correlations.js";
65
69
  import { createWarmAgentCache } from "./warm-agent-cache.js";
70
+ import { mergeCredentialDelivery } from "./credential-cell.js";
66
71
  const logger = getLogger(["workflow-host", "child"]);
67
- const WORKFLOW_JSON_PATH = "workflow.json";
68
72
  export function createCredentialsBackedAuthorize(ref, evaluate) {
69
73
  return async (resource, action, ctx) => {
70
74
  const stepId = ctx?.stepId;
@@ -75,9 +79,17 @@ export function createCredentialsBackedAuthorize(ref, evaluate) {
75
79
  if (snapshot === null) {
76
80
  throw new Error("workflow-child authorize: no credentialsSnapshot active; the supervisor must push one before any step runs");
77
81
  }
78
- const entry = snapshot.steps.find((s) => s.stepId === stepId);
82
+ // The credentials snapshot is keyed per base step; a map iteration's
83
+ // scoped id `<base>[<index>]` resolves to its base entry so every
84
+ // iteration shares the base step's grants. `baseStepId` is the identity
85
+ // on an unscoped id, so a plain step is unaffected.
86
+ const lookupStepId = baseStepId(stepId);
87
+ const entry = snapshot.steps.find((s) => s.stepId === lookupStepId);
79
88
  if (entry === undefined) {
80
- throw new Error(`workflow-child authorize: credentialsSnapshot has no entry for stepId ${stepId}`);
89
+ const scopedNote = lookupStepId === stepId
90
+ ? ""
91
+ : ` (normalized from scoped invocation id ${stepId})`;
92
+ throw new Error(`workflow-child authorize: credentialsSnapshot has no entry for stepId ${lookupStepId}${scopedNote}`);
81
93
  }
82
94
  return evaluate({
83
95
  resource,
@@ -101,7 +113,28 @@ export async function runWorkflowChild(opts) {
101
113
  const sourcesRef = {
102
114
  current: opts.bindings.initialSources ?? {},
103
115
  };
104
- const directors = opts.bindings.directors ?? createDefaultDirectorRegistry();
116
+ const credentialMaterialRef = {
117
+ current: opts.bindings.initialCredentialMaterial ?? null,
118
+ };
119
+ // The per-run credential wiring the top-level step invoker carries to the
120
+ // substrate: the live material cell and a resolver for a step's grants from
121
+ // the same credentials snapshot `authorize` reads. Built once over the two
122
+ // refs; every step build reads them live, so a rotation -- or a revoking
123
+ // re-push that swaps a ref -- is reflected without rebuilding the wiring.
124
+ const credentialWiring = {
125
+ materialRef: credentialMaterialRef,
126
+ resolveStepGrants: (stepId) => {
127
+ const snapshot = credentialsRef.current;
128
+ if (snapshot === null) {
129
+ throw new Error(`workflow-child credential wiring: no credentials snapshot for step ${stepId}; a tool-bearing step cannot resolve its grants before the run carries any`);
130
+ }
131
+ const entry = snapshot.steps.find((step) => step.stepId === baseStepId(stepId));
132
+ if (entry === undefined) {
133
+ throw new Error(`workflow-child credential wiring: credentials snapshot has no entry for step ${baseStepId(stepId)}`);
134
+ }
135
+ return entry.grants;
136
+ },
137
+ };
105
138
  const clock = opts.bindings.clock ?? defaultClock;
106
139
  const newId = opts.bindings.newId ?? defaultNewId;
107
140
  // Mint the child's own upstream-signing keypair. The private half
@@ -120,7 +153,160 @@ export async function runWorkflowChild(opts) {
120
153
  channelId: opts.env.channelId,
121
154
  writer: opts.eventWriter,
122
155
  });
123
- const definition = await loadWorkflowDefinition(opts.bindings);
156
+ // Re-verify barrier at the load boundary. Source-ref is the only deploy
157
+ // lineage: the inert projection is a non-executable approval surface (agents
158
+ // carry `modelSources`/no `inference`, tool factories are plain data), so the
159
+ // child EVALUATES the pinned code closure to a live definition and re-verifies
160
+ // by projecting it back to inert and hashing (`computeLiveDefinitionHash`)
161
+ // against `opts.env.definitionHash`; a divergent closure fails closed. The
162
+ // load happens once before both the resume loop and the trigger loop, so the
163
+ // same verified definition serves every fresh trigger AND every resume.
164
+ //
165
+ // Post-verify structural rewrite: the re-verify above hashed the closure's
166
+ // INLINE onTrigger bodies (matching the frozen approval); now lift each to a
167
+ // `{ ref }` so the runtime dispatches to the body child, and keep the
168
+ // extracted body definitions in an in-memory map. The suspendable-child
169
+ // resolver runs each body from THIS map -- the parent's already-re-verified
170
+ // closure -- with no disk read and no separate per-body re-verify. The rewrite
171
+ // MUST follow the re-verify: rewriting first would diverge from the frozen
172
+ // inline-body hash.
173
+ const verifiedDefinition = await loadVerifiedWorkflowDefinitionFromClosure({
174
+ packageDir: opts.env.closurePackageDir,
175
+ approvedHash: opts.env.definitionHash,
176
+ });
177
+ const { workflow, bodies } = rewriteInlineOnTriggerBodies(verifiedDefinition);
178
+ let definition = workflow;
179
+ const bodiesMap = new Map(bodies.map((b) => [b.ref, b.definition]));
180
+ // An owned `childWorkflow` import embeds its child inline in the parent's
181
+ // definition (folded into the parent's hash and approval), so it is already
182
+ // covered by the re-verify above. Lift each inline child to an internal
183
+ // `{ ref }` -- the form the runtime dispatches -- and keep the lifted
184
+ // definitions in an in-memory map. The terminal childWorkflow resolver below
185
+ // runs each child from THIS map, with no on-disk asset read and no separate
186
+ // per-child re-verify.
187
+ const childRewrite = rewriteInlineChildWorkflowBodies(definition);
188
+ definition = childRewrite.workflow;
189
+ const childBodiesMap = new Map(childRewrite.bodies.map((b) => [b.ref, b.definition]));
190
+ // A loop iteration runs its body as a suspendable child through the same seam
191
+ // an onTrigger body uses, so register each top-level loop body in `bodiesMap`
192
+ // under its `<workflowId>__<stepId>` ref. Unlike an onTrigger or childWorkflow
193
+ // body, a loop keeps its body INLINE on the primitive -- both hash layers
194
+ // project the body inline, so rewriting the primitive would change every
195
+ // existing loop's hash. `enumerateInlineLoopBodies` mints a ref-keyed COPY and
196
+ // leaves the primitive untouched, and a given step is exactly one primitive
197
+ // kind, so a loop ref never collides with an onTrigger or childWorkflow ref.
198
+ //
199
+ // A loop body may itself contain a `childWorkflow` grandchild: rewrite the
200
+ // COPY's inline children to `{ ref }` (the primitive, and thus the hash, is
201
+ // untouched) and fold the extracted grandchildren into `childBodiesMap` HERE
202
+ // -- before the eager loop-fn/handler resolution and the terminal-childWorkflow
203
+ // host selection below, both of which read `childBodiesMap`. Merging later
204
+ // would resolve a grandchild's refs mid-run instead of at establish, and would
205
+ // leave `childBodiesMap` empty for a deployment whose only child is a
206
+ // loop-body grandchild, so its spawn would find no wired terminal host.
207
+ // Keep each loop body's PRE-rewrite form (its childWorkflow grandchild still
208
+ // inline) keyed by ref: the grants cap for a loop iteration re-walks it, and
209
+ // capping the rewritten `{ ref }` form would skip -- and so under-authorize --
210
+ // the grandchild's declared resources.
211
+ const loopBodyPreRewrite = new Map();
212
+ for (const loopBody of enumerateInlineLoopBodies(definition)) {
213
+ loopBodyPreRewrite.set(loopBody.ref, loopBody.definition);
214
+ const bodyRewrite = rewriteInlineChildWorkflowBodies(loopBody.definition);
215
+ bodiesMap.set(loopBody.ref, bodyRewrite.workflow);
216
+ for (const grandchild of bodyRewrite.bodies) {
217
+ childBodiesMap.set(grandchild.ref, grandchild.definition);
218
+ }
219
+ }
220
+ // Directors resolve from the pinned closure so a custom director authored in
221
+ // the workflow's own package runs. Loading directors OUTSIDE the
222
+ // definition-hash re-verify is safe: the approved hash pins each director's
223
+ // id + config (which director runs cannot change post-approval) and the
224
+ // closure's SRI pins its module bytes. Folding directors into the hash would
225
+ // be redundant, so it is deliberately not done -- see
226
+ // `loadWorkflowDirectorRegistryFromClosure`.
227
+ const directors = await loadWorkflowDirectorRegistryFromClosure({
228
+ packageDir: opts.env.closurePackageDir,
229
+ });
230
+ // Loop `while`/`carry` functions resolve from the pinned closure's
231
+ // `interchange.loops` module, loaded alongside the directors and OUTSIDE the
232
+ // definition-hash re-verify for the same reason: the approved hash pins each
233
+ // ref string and the closure's SRI pins the module bytes. Resolve every loop
234
+ // ref reachable from the definition (its own loop bodies, and the lifted
235
+ // onTrigger/childWorkflow bodies, which share this same registry at runtime)
236
+ // eagerly here, so a deployment that declares a loop whose fn the closure
237
+ // does not export fails at establish rather than mid-run.
238
+ const loopFns = await loadWorkflowLoopFnsFromClosure({
239
+ packageDir: opts.env.closurePackageDir,
240
+ });
241
+ eagerlyResolveLoopFns([definition, ...bodiesMap.values(), ...childBodiesMap.values()], loopFns);
242
+ // Action handlers resolve from the pinned closure's `interchange.actions`
243
+ // module, on the same terms as loop fns. Resolve every action handler ref
244
+ // reachable from the definition eagerly here (recursing into loop bodies,
245
+ // where an action body is the common case), so a deployment that declares an
246
+ // action whose handler the closure does not export fails at establish rather
247
+ // than mid-run.
248
+ const actionResolver = await loadWorkflowActionHandlersFromClosure({
249
+ packageDir: opts.env.closurePackageDir,
250
+ });
251
+ eagerlyResolveActionHandlers([definition, ...bodiesMap.values(), ...childBodiesMap.values()], actionResolver);
252
+ // Suspendable-child resolver (onTrigger section bodies and loop bodies),
253
+ // selected ONCE per deployment: the bodies map is immutable and the per-run
254
+ // `onEvent` is injected later in `buildRuntimeEnv`. Resolve each body from the
255
+ // parent's in-memory closure (already re-verified above) via the raw executor
256
+ // binding. A deployment that carries bodies but whose host wired no executor is
257
+ // a misconfiguration -- fail loud at startup rather than silently falling back
258
+ // to a disk read (the exact behaviour this arm exists to avoid). A deployment
259
+ // with no suspendable body leaves the host undefined; its slot is never
260
+ // invoked.
261
+ let suspendableChildHost;
262
+ if (bodiesMap.size > 0) {
263
+ const executor = opts.bindings.runSuspendableChild;
264
+ if (executor === undefined) {
265
+ throw new Error("workflow-child: source-ref deployment carries suspendable bodies " +
266
+ "(onTrigger sections or loop bodies) but the host wired no " +
267
+ "runSuspendableChild executor; cannot resolve bodies in-memory");
268
+ }
269
+ suspendableChildHost = createInMemorySpawnSuspendableChild({
270
+ bodies: bodiesMap,
271
+ runSuspendableChild: executor,
272
+ });
273
+ }
274
+ // Terminal childWorkflow resolver, selected ONCE per deployment. When the
275
+ // definition embeds any inline child (the lifted map is non-empty), resolve
276
+ // each from that in-memory map via the raw terminal executor -- the parent's
277
+ // own re-verified closure -- so an owned child spawns with no disk read. A
278
+ // deployment that embeds a childWorkflow but whose host wired no executor is
279
+ // a misconfiguration and fails loud at startup rather than falling back to a
280
+ // disk read. A definition with no inline child keeps the injected binding (a
281
+ // test seam); its childWorkflow slot is never invoked.
282
+ // `HostSpawnChild` (a call-arg `onEvent`) like the suspendable host: the
283
+ // resolver is deployment-scoped but each run injects its own event sink in
284
+ // `buildRuntimeEnv`. The two fallback arms ignore the sink.
285
+ let spawnChild;
286
+ if (childBodiesMap.size > 0) {
287
+ const executor = opts.bindings.runChild;
288
+ if (executor === undefined) {
289
+ throw new Error("workflow-child: deployment embeds childWorkflow imports but the " +
290
+ "host wired no runChild executor; cannot resolve children in-memory");
291
+ }
292
+ spawnChild = createInMemorySpawnChild({
293
+ bodies: childBodiesMap,
294
+ runChild: executor,
295
+ });
296
+ }
297
+ else if (opts.bindings.spawnChild !== undefined) {
298
+ const injected = opts.bindings.spawnChild;
299
+ spawnChild = (input, _onEvent) => injected(input);
300
+ }
301
+ else {
302
+ // No inline child and no injected binding: a workflow that nonetheless
303
+ // reaches a childWorkflow spawn fails loud here rather than silently
304
+ // completing against a child that never ran.
305
+ spawnChild = async ({ definitionRef }, _onEvent) => {
306
+ throw new Error(`workflow-child: childWorkflow ${definitionRef} reached the runtime ` +
307
+ `but no child executor is wired`);
308
+ };
309
+ }
124
310
  const authorize = createCredentialsBackedAuthorize(credentialsRef, opts.bindings.evaluateGrants);
125
311
  const drainController = createWorkflowHostDrainController({ definition });
126
312
  // Warm-agent cache (design §3b). Built only when the deployment is a
@@ -174,16 +360,24 @@ export async function runWorkflowChild(opts) {
174
360
  runtimeRepoStore,
175
361
  authorize,
176
362
  directors,
363
+ suspendableChildHost,
364
+ bodiesMap,
365
+ loopBodyPreRewrite,
366
+ spawnChild,
367
+ loopFns,
368
+ actionResolver,
177
369
  clock,
178
370
  newId,
179
371
  drainController,
180
372
  warmCache,
181
373
  sourcesRef,
374
+ credentialWiring,
182
375
  onEvent: (event) => {
183
376
  void eventSender.send(event).catch((cause) => {
184
377
  logger.error `event-channel send failed during resume run ${run.runId}: ${String(cause)}`;
185
378
  });
186
379
  },
380
+ upstreamSender,
187
381
  });
188
382
  const handle = runtimeRun(definition, env, {
189
383
  runId: run.runId,
@@ -202,13 +396,12 @@ export async function runWorkflowChild(opts) {
202
396
  cleanupRunStorage: opts.bindings.cleanupRunStorage,
203
397
  runId: run.runId,
204
398
  });
399
+ runsInFlight.delete(run.runId);
205
400
  return emitTerminalEvent(upstreamSender, result);
206
401
  })
207
402
  .catch((cause) => {
208
- logger.error `resumed run ${run.runId} failed: ${String(cause)}`;
209
- })
210
- .finally(() => {
211
403
  runsInFlight.delete(run.runId);
404
+ logger.error `resumed run ${run.runId} failed: ${String(cause)}`;
212
405
  });
213
406
  resumedRunIds.push(run.runId);
214
407
  }
@@ -231,6 +424,12 @@ export async function runWorkflowChild(opts) {
231
424
  childPublicKey: hexEncode(childKeyPair.publicKey),
232
425
  },
233
426
  });
427
+ // Report self-discovered runs so the supervisor seeds its cohort
428
+ // tracking before the dispatch loop starts.
429
+ await upstreamSender.send({
430
+ type: "resumed.runs",
431
+ data: { runIds: resumedRunIds },
432
+ });
234
433
  const triggeredRunIds = [];
235
434
  // Control-loop. The receiver iterator yields one verified payload
236
435
  // per call; any signature/channelId/seq violation crashes the
@@ -243,7 +442,14 @@ export async function runWorkflowChild(opts) {
243
442
  logger.error `workflow-child control channel crash: ${reason}`;
244
443
  },
245
444
  });
246
- try {
445
+ // Resolve the mailbox watch registry the control loop routes `mailbox.notify`
446
+ // frames to. Production wires it on the bindings (the substrate factory builds
447
+ // one instance and shares it with the warm agent's supervisor-backed
448
+ // transport); a test may inject one directly through the opts, which wins.
449
+ // Both absent leaves inbound `mailbox.notify` frames logged and dropped -- a
450
+ // deploy with no wired mail surface.
451
+ const mailboxWatchRegistry = opts.mailboxWatchRegistry ?? opts.bindings.mailboxWatchRegistry;
452
+ const runControlLoop = async () => {
247
453
  for await (const payload of iter) {
248
454
  if (await handleControlPayload(payload, {
249
455
  env: opts.env,
@@ -253,6 +459,12 @@ export async function runWorkflowChild(opts) {
253
459
  definition,
254
460
  authorize,
255
461
  directors,
462
+ suspendableChildHost,
463
+ bodiesMap,
464
+ loopBodyPreRewrite,
465
+ spawnChild,
466
+ loopFns,
467
+ actionResolver,
256
468
  clock,
257
469
  newId,
258
470
  eventSender,
@@ -262,20 +474,28 @@ export async function runWorkflowChild(opts) {
262
474
  runsInFlight,
263
475
  warmCache,
264
476
  sourcesRef,
477
+ credentialMaterialRef,
478
+ credentialWiring,
265
479
  ...(opts.substrateWriteBridge !== undefined
266
480
  ? { substrateWriteBridge: opts.substrateWriteBridge }
267
481
  : {}),
268
482
  ...(opts.outboundMailBridge !== undefined
269
483
  ? { outboundMailBridge: opts.outboundMailBridge }
270
484
  : {}),
485
+ ...(opts.mailboxMutationBridge !== undefined
486
+ ? { mailboxMutationBridge: opts.mailboxMutationBridge }
487
+ : {}),
488
+ ...(mailboxWatchRegistry !== undefined
489
+ ? { mailboxWatchRegistry }
490
+ : {}),
271
491
  })) {
272
492
  // shutdown received; the shutdown case already cancelled any
273
493
  // pending substrate writes before returning true.
274
494
  break;
275
495
  }
276
496
  }
277
- }
278
- finally {
497
+ };
498
+ const cleanupControlLoop = async () => {
279
499
  // Any exit path -- clean (iterator end), dirty (thrown error),
280
500
  // shutdown (already cancelled, repeat is a no-op on an empty map)
281
501
  // -- cancels every still-pending substrate write so the runtime
@@ -292,6 +512,13 @@ export async function runWorkflowChild(opts) {
292
512
  if (opts.outboundMailBridge !== undefined) {
293
513
  opts.outboundMailBridge.cancelAll("workflow-child control loop exited");
294
514
  }
515
+ // Same contract for mailbox mutations: a step agent's flag or
516
+ // `expunge` still awaiting the supervisor's `mailbox.mutate.response`
517
+ // when the control loop exits must surface a structured rejection
518
+ // rather than hang on a torn-down channel.
519
+ if (opts.mailboxMutationBridge !== undefined) {
520
+ opts.mailboxMutationBridge.cancelAll("workflow-child control loop exited");
521
+ }
295
522
  // Evict the warm-agent cache (design §3b) on every exit path:
296
523
  // graceful (shutdown frame -> iterator end), dirty (thrown error),
297
524
  // or the control channel closing. Eviction runs the wrapped
@@ -303,7 +530,12 @@ export async function runWorkflowChild(opts) {
303
530
  if (warmCache !== undefined) {
304
531
  await warmCache.evictAll("workflow-child control loop exited");
305
532
  }
306
- }
533
+ };
534
+ // Run the control loop, then always run the cleanup above. A failing
535
+ // eviction (the wrapped agent close rejects when a plugin/LSP disposer
536
+ // fails) surfaces on a clean exit, but must not mask a control-loop
537
+ // error already unwinding -- so it is logged, not rethrown, in that case.
538
+ await runBodyThenCleanup(runControlLoop, cleanupControlLoop, (cause) => logger.error `workflow-child: warm-agent eviction failed while unwinding a control-loop error; surfacing the control-loop error, eviction failure: ${cause instanceof Error ? cause.message : String(cause)}`);
307
539
  return {
308
540
  resumedRunIds,
309
541
  triggeredRunIds,
@@ -320,16 +552,16 @@ async function handleControlPayload(payload, ctx) {
320
552
  case "trigger.fire": {
321
553
  // One driver per runId. If this child is already driving this
322
554
  // 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
555
+ // it -- a duplicate/stale trigger frame (which carries the local part
556
+ // of the deployment's mail address as the runId and no resumeFromEvents)
557
+ // must NOT spawn a second `runtimeRun`. A
325
558
  // second concurrent driver would race the live one to settle the
326
559
  // same residual and the loser throws an uncaught TransitionError,
327
560
  // and even a driver that avoided the throw would double-emit the
328
561
  // terminal. The live driver's completion continuation owns the
329
562
  // 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
563
+ // `markConsumed` consumes the original message off that one terminal,
564
+ // so no work is dropped by declining here. Record the runId and signal
333
565
  // "handled, not shutdown" the same way the normal trigger case
334
566
  // returns, without awaiting the live handle's `complete` inline
335
567
  // (that would block the control loop).
@@ -337,39 +569,39 @@ async function handleControlPayload(payload, ctx) {
337
569
  ctx.triggeredRunIds.push(payload.data.runId);
338
570
  return false;
339
571
  }
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
- });
572
+ // The supervisor resolved the inbound mail to the run's input (the
573
+ // conversation text plus references to attachment bytes it committed to
574
+ // the workflow-run substrate) and shipped it in the frame. It becomes
575
+ // the run's trigger payload; the one-step workflow's first step defaults
576
+ // its input selector to `trigger.payload` (defineWorkflow's default-input
577
+ // convention), so the step input resolves to the inbound message and
578
+ // `agent.send` receives it once its attachment references are resolved to
579
+ // bytes at send time.
580
+ const triggerPayload = payload.data.payload;
357
581
  const env = buildRuntimeEnv({
358
582
  runId: payload.data.runId,
359
583
  bindings: ctx.bindings,
360
584
  runtimeRepoStore: ctx.runtimeRepoStore,
361
585
  authorize: ctx.authorize,
362
586
  directors: ctx.directors,
587
+ suspendableChildHost: ctx.suspendableChildHost,
588
+ bodiesMap: ctx.bodiesMap,
589
+ loopBodyPreRewrite: ctx.loopBodyPreRewrite,
590
+ spawnChild: ctx.spawnChild,
591
+ loopFns: ctx.loopFns,
592
+ actionResolver: ctx.actionResolver,
363
593
  clock: ctx.clock,
364
594
  newId: ctx.newId,
365
595
  drainController: ctx.drainController,
366
596
  warmCache: ctx.warmCache,
367
597
  sourcesRef: ctx.sourcesRef,
598
+ credentialWiring: ctx.credentialWiring,
368
599
  onEvent: (event) => {
369
600
  void ctx.eventSender.send(event).catch((cause) => {
370
601
  logger.error `event-channel send failed during run ${payload.data.runId}: ${String(cause)}`;
371
602
  });
372
603
  },
604
+ upstreamSender: ctx.upstreamSender,
373
605
  });
374
606
  const handle = runtimeRun(ctx.definition, env, {
375
607
  runId: payload.data.runId,
@@ -392,13 +624,12 @@ async function handleControlPayload(payload, ctx) {
392
624
  cleanupRunStorage: ctx.bindings.cleanupRunStorage,
393
625
  runId: payload.data.runId,
394
626
  });
627
+ ctx.runsInFlight.delete(payload.data.runId);
395
628
  return emitTerminalEvent(ctx.upstreamSender, result);
396
629
  })
397
630
  .catch((cause) => {
398
- logger.error `triggered run ${payload.data.runId} failed: ${String(cause)}`;
399
- })
400
- .finally(() => {
401
631
  ctx.runsInFlight.delete(payload.data.runId);
632
+ logger.error `triggered run ${payload.data.runId} failed: ${String(cause)}`;
402
633
  });
403
634
  ctx.triggeredRunIds.push(payload.data.runId);
404
635
  return false;
@@ -432,7 +663,30 @@ async function handleControlPayload(payload, ctx) {
432
663
  ctx.credentialsRef.current = snapshot;
433
664
  return false;
434
665
  }
666
+ case "credentials-updated": {
667
+ // Merge the delivery into the live cell (see `mergeCredentialDelivery`):
668
+ // materials upsert by credentialId, bindings by (consumer, handle), and
669
+ // `revoke` drops named credentialIds plus any binding referencing them.
670
+ // Merge rather than wholesale-replace because the cell has several
671
+ // independently-scoped producers, so a swap would evict another
672
+ // producer's credentials. The result is assigned in one atomic
673
+ // whole-object swap, so a concurrent reader never observes a torn cell.
674
+ // The secret stays on this ref only; nothing here copies it into a
675
+ // snapshot, event, or state.
676
+ ctx.credentialMaterialRef.current = mergeCredentialDelivery(ctx.credentialMaterialRef.current, payload.data.delivery, payload.data.revoke);
677
+ return false;
678
+ }
435
679
  case "signal.deliver": {
680
+ // Drop a delivery for a run this child is not driving. The dispatch path
681
+ // only ever targets a live run id, but a stale or mis-routed frame -- a
682
+ // synthetic body-child id, or a run that crashed and has not been
683
+ // re-discovered -- must not commit an orphan `SignalReceived` to a log no
684
+ // awaiter is tailing. `runsInFlight` is the one-driver authority on which
685
+ // runs this child drives.
686
+ if (!ctx.runsInFlight.has(payload.data.runId)) {
687
+ logger.warn `signal.deliver for run ${payload.data.runId} which is not in flight; dropping (signalName=${payload.data.signalName})`;
688
+ return false;
689
+ }
436
690
  // Land the signal as a `SignalReceived` commit on the run's
437
691
  // event log. The signal-channel substrate's `subscribeKind`
438
692
  // peer (the per-run signal channel installed at run start) is
@@ -566,6 +820,13 @@ async function handleControlPayload(payload, ctx) {
566
820
  // `ready` or `recycle.request`.
567
821
  throw new Error("workflow-child received a `terminal.event` frame on its inbound control channel; this is a child-only upstream payload");
568
822
  }
823
+ case "park.notify": {
824
+ // `park.notify` is the child->supervisor suspension-notification
825
+ // frame; receiving one on the child's downstream side is a
826
+ // protocol violation in the same shape as a downstream
827
+ // `terminal.event`.
828
+ throw new Error("workflow-child received a `park.notify` frame on its inbound control channel; this is a child-only upstream payload");
829
+ }
569
830
  case "outbound.message": {
570
831
  // `outbound.message` is the child->supervisor outbound-mail
571
832
  // request frame; receiving one on the child's downstream side is a
@@ -585,6 +846,43 @@ async function handleControlPayload(payload, ctx) {
585
846
  ctx.outboundMailBridge.handleResult(payload.data);
586
847
  return false;
587
848
  }
849
+ case "mailbox.notify": {
850
+ // Route the supervisor's new-mail notification to the child's watch
851
+ // registry so a step agent's `watch`/`mail_wait` observes the arrival.
852
+ // A notify that lands without a registry means no watcher on the child
853
+ // side asked for inbound events; log and drop rather than throwing so
854
+ // the runtime keeps progressing (mirrors the `outbound.result` arm).
855
+ if (ctx.mailboxWatchRegistry === undefined) {
856
+ logger.warn `workflow-child mailbox.notify received without a watch registry wired; mailbox=${payload.data.mailbox} uid=${String(payload.data.uid)} dropped`;
857
+ return false;
858
+ }
859
+ ctx.mailboxWatchRegistry.fire(payload.data.mailbox, {
860
+ type: "exists",
861
+ uid: payload.data.uid,
862
+ headers: payload.data.headers,
863
+ });
864
+ return false;
865
+ }
866
+ case "mailbox.mutate.request": {
867
+ // `mailbox.mutate.request` is the child->supervisor mailbox-mutation
868
+ // request frame; receiving one on the child's downstream side is a
869
+ // protocol violation in the same shape as a downstream
870
+ // `outbound.message`.
871
+ throw new Error("workflow-child received a `mailbox.mutate.request` frame on its inbound control channel; this is a child-only upstream payload");
872
+ }
873
+ case "mailbox.mutate.response": {
874
+ // Route the supervisor's applied-mutation result to the
875
+ // mailbox-mutation bridge if one is wired. A response that lands
876
+ // without an active bridge means a stale supervisor frame for which
877
+ // no awaiter exists; log and drop rather than throwing so the
878
+ // runtime keeps progressing (mirrors the `outbound.result` arm).
879
+ if (ctx.mailboxMutationBridge === undefined) {
880
+ logger.warn `workflow-child mailbox.mutate.response received without a bridge wired; requestId=${payload.data.requestId} dropped`;
881
+ return false;
882
+ }
883
+ ctx.mailboxMutationBridge.handleResult(payload.data);
884
+ return false;
885
+ }
588
886
  case "substrate.merge.request": {
589
887
  // Route the request to the substrate-write bridge if one is
590
888
  // wired. A request that lands without an active bridge means a
@@ -609,6 +907,42 @@ async function handleControlPayload(payload, ctx) {
609
907
  ctx.substrateWriteBridge.handleWriteResponse(payload.data);
610
908
  return false;
611
909
  }
910
+ case "parked-correlations.request": {
911
+ // Answer the supervisor's re-registration enumeration from durable
912
+ // state. Awaiting inline is safe -- unlike `signal.deliver`, this
913
+ // reads (self-discovery + the snapshot binding) and sends one upstream
914
+ // reply without awaiting any downstream frame, so it cannot deadlock
915
+ // the iterator against a response it is itself blocking. A store
916
+ // inconsistency (an enumerated park with no durable snapshot, or no
917
+ // binding to recover one) throws out of the loop like the other
918
+ // invariant-violation arms rather than dropping a correlation the hub
919
+ // is waiting to register.
920
+ const parked = await collectParkedApprovalCorrelations({
921
+ substrate: ctx.bindings.substrate,
922
+ repoId: ctx.bindings.workflowRunRepoId,
923
+ runtimeRepoStore: ctx.runtimeRepoStore,
924
+ ...(ctx.bindings.loadParkedApproval !== undefined
925
+ ? { loadParkedApproval: ctx.bindings.loadParkedApproval }
926
+ : {}),
927
+ });
928
+ await ctx.upstreamSender.send({
929
+ type: "parked-correlations.response",
930
+ data: { requestId: payload.data.requestId, parked },
931
+ });
932
+ return false;
933
+ }
934
+ case "resumed.runs": {
935
+ // `resumed.runs` is the child->supervisor self-discovery report;
936
+ // receiving one on the child's downstream side is a protocol
937
+ // violation in the same shape as a downstream `ready`.
938
+ throw new Error("workflow-child received a `resumed.runs` frame on its inbound control channel; this is a child-only upstream payload");
939
+ }
940
+ case "parked-correlations.response": {
941
+ // `parked-correlations.response` is the child->supervisor reply frame;
942
+ // receiving one on the child's downstream side is a protocol violation
943
+ // in the same shape as a downstream `substrate.merge.response`.
944
+ throw new Error("workflow-child received a `parked-correlations.response` frame on its inbound control channel; this is a child-only upstream payload");
945
+ }
612
946
  }
613
947
  }
614
948
  /**
@@ -617,6 +951,28 @@ async function handleControlPayload(payload, ctx) {
617
951
  * shape; the substrate handle and per-deployment `RepoStore` adapter
618
952
  * are shared across runs.
619
953
  */
954
+ /**
955
+ * Force-resolve every `action` handler ref reachable from these definitions
956
+ * against the resolver, so a missing action handler surfaces at establish
957
+ * rather than when the action is first invoked mid-run. Recurses into loop
958
+ * bodies (an action body is the common loop shape). The caller passes the
959
+ * lifted onTrigger/childWorkflow bodies separately, as with loop fns.
960
+ */
961
+ function eagerlyResolveActionHandlers(definitions, actionResolver) {
962
+ const visit = (def) => {
963
+ for (const step of Object.values(def.steps)) {
964
+ if (step.kind === "action") {
965
+ // Throws (fail closed) if the handler names no export, or a non-function.
966
+ actionResolver(step.handler);
967
+ }
968
+ else if (step.kind === "loop") {
969
+ visit(step.body);
970
+ }
971
+ }
972
+ };
973
+ for (const def of definitions)
974
+ visit(def);
975
+ }
620
976
  function buildRuntimeEnv(args) {
621
977
  const signalChannel = createWorkflowHostSignalChannel({
622
978
  repoStore: args.bindings.substrate,
@@ -635,6 +991,17 @@ function buildRuntimeEnv(args) {
635
991
  runId: args.runId,
636
992
  ref: args.bindings.workflowRunRef,
637
993
  });
994
+ // Reader for inbound-mail parts, a sibling of `blobs` over the same
995
+ // workflow-run repo. The step invoker resolves a `Mail` part's `ref` to its
996
+ // bytes through it at `agent.send` time; the supervisor committed the bytes
997
+ // before the trigger. Deployment-scoped (the ref encodes the owning run), so
998
+ // one reader resolves any run's parts.
999
+ const mailPartReader = createMailPartReader({
1000
+ substrate: args.bindings.substrate,
1001
+ repoId: args.bindings.workflowRunRepoId,
1002
+ principal: args.bindings.principal,
1003
+ ref: args.bindings.workflowRunRef,
1004
+ });
638
1005
  // Wrap the step invoker so every `InferenceEvent` the harness emits
639
1006
  // funnels through the per-run `onEvent` closure, which forwards
640
1007
  // the event up the HMAC-authenticated event channel. The wrap is
@@ -643,9 +1010,27 @@ function buildRuntimeEnv(args) {
643
1010
  // `ChildStepInvoker` shape (carries onEvent), so the workflow-
644
1011
  // runtime never has to know an event firehose exists.
645
1012
  const invokeStep = async (req) => {
646
- return args.bindings.invokeStep(req, args.onEvent, args.authorize, args.warmCache, args.sourcesRef);
1013
+ return args.bindings.invokeStep(req, args.onEvent, args.authorize, args.warmCache, args.sourcesRef, args.credentialWiring, mailPartReader);
647
1014
  };
648
- return {
1015
+ // Adapt the host binding (which takes the run's `onEvent` sink) down to the
1016
+ // runtime's narrow `SpawnSuspendableChild` by injecting THIS run's event
1017
+ // funnel -- the same closure `invokeStep` forwards -- so a body's live
1018
+ // inference events ride the parent run's event channel to the hub stream
1019
+ // (and inherit its loud-on-failure logging), while the runtime env keeps the
1020
+ // narrow contract with no event slot. The run's live credential-material cell
1021
+ // rides the same seam so the body's inference resolves its source secret
1022
+ // against the parent's current delivery, reached live on a rotation.
1023
+ const hostSuspendable = args.suspendableChildHost;
1024
+ const spawnSuspendableChild = hostSuspendable === undefined
1025
+ ? undefined
1026
+ : (spawnInput) => hostSuspendable(spawnInput, args.onEvent, args.credentialWiring.materialRef);
1027
+ // Same adaptation for the terminal childWorkflow seam: inject THIS run's event
1028
+ // funnel so a child's live inference events ride the parent run's channel, and
1029
+ // the live credential-material cell so the child's inference resolves its
1030
+ // source secret against the parent's current delivery, while the runtime env
1031
+ // keeps the narrow `SpawnChildWorkflow` (no event slot).
1032
+ const spawnChild = (spawnInput) => args.spawnChild(spawnInput, args.onEvent, args.credentialWiring.materialRef);
1033
+ const env = {
649
1034
  repoStore: args.runtimeRepoStore,
650
1035
  scheduler: args.bindings.scheduler,
651
1036
  signalChannel,
@@ -653,11 +1038,148 @@ function buildRuntimeEnv(args) {
653
1038
  directors: args.directors,
654
1039
  authorize: args.authorize,
655
1040
  invokeStep,
656
- spawnChild: args.bindings.spawnChild,
1041
+ spawnChild,
1042
+ // The deployment's addressable run. Its parks are registered with the hub
1043
+ // through the notify sink below, and a resolved decision is delivered back
1044
+ // onto this run's own channel.
1045
+ hasUpstreamSignalResolver: true,
1046
+ // Resolve a loop's `while`/`carry` refs against the closure's loop module.
1047
+ // Every ref was force-resolved at establish, so a lookup here cannot fail
1048
+ // for a definition that passed startup.
1049
+ loopFns: args.loopFns,
1050
+ // Wire the suspendable-child seam only when the host supplied it; a child
1051
+ // that never runs an onTrigger section omits the binding, and the runtime
1052
+ // body fails loud if a workflow reaches a section the env did not wire.
1053
+ ...(spawnSuspendableChild !== undefined ? { spawnSuspendableChild } : {}),
657
1054
  clock: args.clock,
658
1055
  newId: args.newId,
659
1056
  drain: args.drainController,
1057
+ // Forward a control-plane suspension up the same upstream control
1058
+ // channel `terminal.event` rides, so the supervisor can stamp the
1059
+ // deployment identity and register the correlation at the hub. The
1060
+ // runtime body fires this once per fresh park on a reserved
1061
+ // `signalName(correlationId)` channel.
1062
+ onPark: (park) => {
1063
+ void emitParkNotify(args.upstreamSender, park);
1064
+ },
1065
+ // Let the resume classifier recover a step that crashed across the park
1066
+ // boundary. Absent (tests, the recursive child-workflow adapter) leaves a
1067
+ // crashed invocation a terminal failure.
1068
+ ...(args.bindings.readParkedApprovalOps !== undefined
1069
+ ? { readParkedApprovalOps: args.bindings.readParkedApprovalOps }
1070
+ : {}),
660
1071
  };
1072
+ // The suspendable-loop executor runs each iteration's body under THIS run's
1073
+ // inherited env (its real tool-bearing invokeStep, invokeAction, credentials
1074
+ // authorize, effect ledger, and durable shared repoStore/blobs), giving the
1075
+ // body only its own substrate-backed signal channel -- the inherited-env
1076
+ // iteration model plus park capability, distinct from an onTrigger body's
1077
+ // fresh capped env. Resolved from the bodies map by ref (loop
1078
+ // bodies were registered there at establish) and wrapped with this run's
1079
+ // event funnel. Assigned AFTER env construction because it closes over `env`.
1080
+ const loopIterationHost = createInMemorySpawnSuspendableChild({
1081
+ bodies: args.bodiesMap,
1082
+ runSuspendableChild: async (loopInput, _onEvent) => {
1083
+ // Materialize this iteration's own grants file BEFORE the body runs (i.e.
1084
+ // before `createLoopIterationHandle` drives the iteration's first event
1085
+ // append), so a `childWorkflow` grandchild spawned from the body reads it
1086
+ // as authority (the sidecar's runChild fails closed on a missing parent
1087
+ // grants file). This ordering is LOAD-BEARING: the grants write is
1088
+ // write-once and its shallow-prefix rebuild is safe only while the
1089
+ // iteration run's subtree is still empty -- see `capAndPersistChildGrants`.
1090
+ // The cap must walk the PRE-rewrite loop body (grandchild still inline);
1091
+ // the rewritten body in `bodiesMap` would skip the grandchild's resources.
1092
+ // Every loop body is registered in `loopBodyPreRewrite` at establish under
1093
+ // the same ref the runtime dispatches, so a miss is a defect -- fail loud
1094
+ // rather than silently skip, which would re-open the fail-closed
1095
+ // grandchild spawn. The BINDING is a separate sidecar-only seam: `runLocal`
1096
+ // keeps no per-run grants file (its grandchild spawn never fails closed),
1097
+ // so it omits the binding, and an absent binding leaves the iteration's
1098
+ // grants unmaterialized -- matching the in-process model with no disk
1099
+ // authority.
1100
+ const preRewriteBody = args.loopBodyPreRewrite.get(loopInput.definitionRef);
1101
+ if (preRewriteBody === undefined) {
1102
+ throw new Error(`workflow-child: loop iteration ${loopInput.childRunId} has no ` +
1103
+ `pre-rewrite body registered for ref ${loopInput.definitionRef}`);
1104
+ }
1105
+ await args.bindings.materializeLoopIterationGrants?.({
1106
+ parentRunId: loopInput.parentRunId,
1107
+ childRunId: loopInput.childRunId,
1108
+ definition: preRewriteBody,
1109
+ });
1110
+ const childSignalChannel = createWorkflowHostSignalChannel({
1111
+ repoStore: args.bindings.substrate,
1112
+ principal: args.bindings.principal,
1113
+ repoId: args.bindings.workflowRunRepoId,
1114
+ ref: args.bindings.workflowRunRef,
1115
+ runId: loopInput.childRunId,
1116
+ readState: () => emptyState(loopInput.childRunId),
1117
+ newId: () => args.newId("sig"),
1118
+ clock: args.clock,
1119
+ });
1120
+ return createLoopIterationHandle(env, {
1121
+ definition: loopInput.definition,
1122
+ childRunId: loopInput.childRunId,
1123
+ input: loopInput.input,
1124
+ depth: loopInput.depth,
1125
+ maxChildSpawnDepth: loopInput.maxChildSpawnDepth,
1126
+ ...(loopInput.resumeFromEvents !== undefined
1127
+ ? { resumeFromEvents: loopInput.resumeFromEvents }
1128
+ : {}),
1129
+ signal: loopInput.signal,
1130
+ signalChannel: childSignalChannel,
1131
+ cleanup: () => childSignalChannel.stop(),
1132
+ });
1133
+ },
1134
+ });
1135
+ env.spawnLoopIteration = (spawnInput) => loopIterationHost(spawnInput, args.onEvent);
1136
+ // Action handlers run against a per-run effect ledger. The ledger is
1137
+ // IN-MEMORY, and that is correct -- not a shortcut -- on the deployed store:
1138
+ // appends are immediate-durable single-ref commits, `runAction` flushes
1139
+ // StepStarted durably before the effect, and the runtime never re-invokes a
1140
+ // crashed action (a mid-action crash settles the step failed; a loop-body
1141
+ // action leaves a non-empty child log that fails the iteration loud rather
1142
+ // than re-running). So the ledger is never consulted across a crash; its
1143
+ // cross-crash exactly-once rests on that store-consistency invariant, which
1144
+ // the store layer owns. A durable ledger here would re-enforce a constraint
1145
+ // a lower layer already guarantees. Within a single invocation the ledger
1146
+ // still dedups a handler that performs the same effect twice.
1147
+ const effects = createInMemoryEffectLedger();
1148
+ env.effects = effects;
1149
+ env.invokeAction = createDefaultActionInvoker(args.authorize, effects, args.actionResolver);
1150
+ return env;
1151
+ }
1152
+ /**
1153
+ * Forward a control-plane suspension to the supervisor over the upstream
1154
+ * control channel. Fired from `env.onPark` each time a workflow agent step
1155
+ * parks on a reserved `signalName(correlationId)` channel. The supervisor's
1156
+ * `park.notify` arm stamps the deployment identity it owns and sends a
1157
+ * `signal.correlation.register` frame to the hub.
1158
+ *
1159
+ * Best-effort like `emitTerminalEvent`'s send: a transport failure is logged,
1160
+ * not rethrown. A lost frame means the correlation is not registered and the
1161
+ * parked run cannot be resumed until it is re-registered; the failure surfaces
1162
+ * structurally as a run that never resumes rather than a silent lifecycle
1163
+ * corruption. The register at the hub is idempotent, so a re-park resume's
1164
+ * re-emit is safe.
1165
+ */
1166
+ export function emitParkNotify(upstreamSender, park) {
1167
+ return upstreamSender
1168
+ .send({
1169
+ type: "park.notify",
1170
+ data: {
1171
+ runId: park.runId,
1172
+ correlationId: park.correlationId,
1173
+ parkKind: park.parkKind,
1174
+ ...(park.approvalSnapshot !== undefined
1175
+ ? { snapshot: park.approvalSnapshot }
1176
+ : {}),
1177
+ },
1178
+ })
1179
+ .catch((cause) => {
1180
+ const message = cause instanceof Error ? cause.message : String(cause);
1181
+ logger.error `park.notify upstream send failed for runId=${park.runId} correlationId=${park.correlationId}: ${message}`;
1182
+ });
661
1183
  }
662
1184
  /**
663
1185
  * Mirror a run's terminal status back to the supervisor over the
@@ -780,114 +1302,6 @@ function reclaimRunStorageIfCold(opts) {
780
1302
  logger.warn `workflow-step-state cleanup failed for runId=${opts.runId}: ${message}`;
781
1303
  });
782
1304
  }
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
1305
  function defaultClock() {
892
1306
  return new Date();
893
1307
  }