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