@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.
- package/README.md +77 -14
- package/dist/adapters/mail-part-store.d.ts +46 -0
- package/dist/adapters/mail-part-store.js +251 -0
- package/dist/adapters/repo-store.d.ts +22 -1
- package/dist/adapters/repo-store.js +56 -65
- package/dist/adapters/spawn-child.d.ts +109 -44
- package/dist/adapters/spawn-child.js +77 -81
- package/dist/adapters/step-invoker.d.ts +52 -2
- package/dist/adapters/step-invoker.js +284 -37
- package/dist/adapters/substrate-mailbox-store.d.ts +80 -0
- package/dist/adapters/substrate-mailbox-store.js +404 -0
- package/dist/child/child-mailbox-reader.d.ts +10 -0
- package/dist/child/child-mailbox-reader.js +23 -0
- package/dist/child/credential-cell.d.ts +8 -0
- package/dist/child/credential-cell.js +66 -0
- package/dist/child/env-bootstrap.d.ts +20 -6
- package/dist/child/env-bootstrap.js +9 -1
- package/dist/child/from-process-env.d.ts +12 -0
- package/dist/child/from-process-env.js +6 -0
- package/dist/child/index.d.ts +6 -2
- package/dist/child/index.js +4 -1
- package/dist/child/mailbox-mutation-bridge.d.ts +61 -0
- package/dist/child/mailbox-mutation-bridge.js +101 -0
- package/dist/child/mailbox-watch-registry.d.ts +17 -0
- package/dist/child/mailbox-watch-registry.js +61 -0
- package/dist/child/outbound-mail-bridge.d.ts +3 -2
- package/dist/child/outbound-mail-bridge.js +20 -32
- package/dist/child/parked-correlations.d.ts +42 -0
- package/dist/child/parked-correlations.js +80 -0
- package/dist/child/pending-request.d.ts +89 -0
- package/dist/child/pending-request.js +80 -0
- package/dist/child/proxy-repo-store.d.ts +3 -2
- package/dist/child/proxy-repo-store.js +2 -0
- package/dist/child/run-child.d.ts +170 -14
- package/dist/child/run-child.js +569 -155
- package/dist/child/self-discovery.d.ts +10 -0
- package/dist/child/self-discovery.js +25 -1
- package/dist/child/substrate-write-bridge.d.ts +3 -2
- package/dist/child/substrate-write-bridge.js +21 -38
- package/dist/child/supervisor-backed-transport.d.ts +52 -6
- package/dist/child/supervisor-backed-transport.js +205 -62
- package/dist/child/verified-definition-loader.d.ts +33 -0
- package/dist/child/verified-definition-loader.js +43 -0
- package/dist/child/warm-agent-cache.d.ts +44 -4
- package/dist/child/warm-agent-cache.js +41 -10
- package/dist/index.d.ts +6 -4
- package/dist/index.js +6 -4
- package/dist/ipc/control-channel.d.ts +151 -2
- package/dist/ipc/control-channel.js +222 -29
- package/dist/ipc/event-channel.d.ts +32 -1
- package/dist/ipc/index.d.ts +1 -1
- package/dist/ipc/index.js +1 -1
- package/dist/mail-bus/hub-transport-adapter.d.ts +12 -7
- package/dist/mail-bus/hub-transport-adapter.js +9 -5
- package/dist/run-body-then-cleanup.d.ts +17 -0
- package/dist/run-body-then-cleanup.js +38 -0
- package/dist/seams/scheduler.d.ts +16 -6
- package/dist/seams/scheduler.js +87 -97
- package/dist/supervisor/cancel-signing.d.ts +2 -2
- package/dist/supervisor/cancel-signing.js +4 -8
- package/dist/supervisor/credentials.d.ts +28 -15
- package/dist/supervisor/credentials.js +7 -7
- package/dist/supervisor/dispatch-attribution.js +1 -1
- package/dist/supervisor/drain-timeout.d.ts +2 -2
- package/dist/supervisor/drain-timeout.js +1 -1
- package/dist/supervisor/index.d.ts +3 -3
- package/dist/supervisor/index.js +2 -2
- package/dist/supervisor/recycle.d.ts +10 -3
- package/dist/supervisor/recycle.js +18 -7
- package/dist/supervisor/run-event-compaction.d.ts +5 -5
- package/dist/supervisor/run-event-compaction.js +14 -19
- package/dist/supervisor/run-event-recovery.d.ts +34 -0
- package/dist/supervisor/run-event-recovery.js +45 -0
- package/dist/supervisor/spawn-env.d.ts +2 -2
- package/dist/supervisor/spawn-env.js +1 -1
- package/dist/supervisor/supervisor.d.ts +106 -26
- package/dist/supervisor/supervisor.js +1903 -414
- package/dist/supervisor/terminal-commit.d.ts +36 -0
- package/dist/supervisor/terminal-commit.js +130 -0
- package/dist/supervisor/types.d.ts +180 -23
- package/dist/testing/change-notifier.d.ts +12 -0
- package/dist/testing/change-notifier.js +63 -0
- package/dist/testing/index.d.ts +8 -0
- package/dist/testing/index.js +16 -0
- package/dist/testing/log-capture.d.ts +52 -0
- package/dist/testing/log-capture.js +124 -0
- package/dist/testing/mail-bus.d.ts +22 -0
- package/dist/testing/mail-bus.js +78 -0
- package/dist/testing/memory-streams.d.ts +43 -0
- package/dist/testing/memory-streams.js +211 -0
- package/dist/testing/spawn-observer.d.ts +12 -0
- package/dist/testing/spawn-observer.js +36 -0
- package/dist/testing/stub-repo-store.d.ts +10 -0
- package/dist/testing/stub-repo-store.js +39 -0
- package/dist/testing/supervisor-reaper.d.ts +24 -0
- package/dist/testing/supervisor-reaper.js +49 -0
- package/dist/testing/upstream-frames.d.ts +47 -0
- package/dist/testing/upstream-frames.js +94 -0
- package/dist/workflow-definition-loader.d.ts +187 -0
- package/dist/workflow-definition-loader.js +422 -0
- package/package.json +18 -11
package/dist/child/run-child.js
CHANGED
|
@@ -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` ->
|
|
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 {
|
|
54
|
-
import {
|
|
55
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 --
|
|
324
|
-
//
|
|
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,
|
|
331
|
-
// work is dropped by declining here. Record the runId
|
|
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
|
-
//
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
// payload; the one-step workflow's first step defaults
|
|
344
|
-
// selector to `trigger.payload` (defineWorkflow's default-input
|
|
345
|
-
// convention), so the step input resolves to the inbound message
|
|
346
|
-
//
|
|
347
|
-
//
|
|
348
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|