@intx/workflow-host 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +21 -4
  2. package/dist/adapters/mail-part-store.d.ts +46 -0
  3. package/dist/adapters/mail-part-store.js +251 -0
  4. package/dist/adapters/repo-store.js +5 -14
  5. package/dist/adapters/spawn-child.d.ts +42 -6
  6. package/dist/adapters/spawn-child.js +8 -18
  7. package/dist/adapters/step-invoker.d.ts +52 -2
  8. package/dist/adapters/step-invoker.js +230 -60
  9. package/dist/adapters/substrate-mailbox-store.d.ts +80 -0
  10. package/dist/adapters/substrate-mailbox-store.js +404 -0
  11. package/dist/child/child-mailbox-reader.d.ts +10 -0
  12. package/dist/child/child-mailbox-reader.js +23 -0
  13. package/dist/child/credential-cell.d.ts +8 -0
  14. package/dist/child/credential-cell.js +66 -0
  15. package/dist/child/from-process-env.d.ts +12 -0
  16. package/dist/child/from-process-env.js +6 -0
  17. package/dist/child/index.d.ts +4 -1
  18. package/dist/child/index.js +4 -1
  19. package/dist/child/mailbox-mutation-bridge.d.ts +61 -0
  20. package/dist/child/mailbox-mutation-bridge.js +101 -0
  21. package/dist/child/mailbox-watch-registry.d.ts +17 -0
  22. package/dist/child/mailbox-watch-registry.js +61 -0
  23. package/dist/child/outbound-mail-bridge.d.ts +3 -2
  24. package/dist/child/outbound-mail-bridge.js +20 -32
  25. package/dist/child/pending-request.d.ts +89 -0
  26. package/dist/child/pending-request.js +80 -0
  27. package/dist/child/run-child.d.ts +69 -7
  28. package/dist/child/run-child.js +307 -75
  29. package/dist/child/substrate-write-bridge.d.ts +3 -2
  30. package/dist/child/substrate-write-bridge.js +21 -38
  31. package/dist/child/supervisor-backed-transport.d.ts +52 -6
  32. package/dist/child/supervisor-backed-transport.js +205 -62
  33. package/dist/child/warm-agent-cache.d.ts +44 -4
  34. package/dist/child/warm-agent-cache.js +41 -10
  35. package/dist/index.d.ts +4 -3
  36. package/dist/index.js +4 -3
  37. package/dist/ipc/control-channel.d.ts +93 -2
  38. package/dist/ipc/control-channel.js +147 -47
  39. package/dist/ipc/index.d.ts +1 -1
  40. package/dist/ipc/index.js +1 -1
  41. package/dist/run-body-then-cleanup.d.ts +17 -0
  42. package/dist/run-body-then-cleanup.js +38 -0
  43. package/dist/seams/scheduler.d.ts +12 -0
  44. package/dist/seams/scheduler.js +13 -4
  45. package/dist/supervisor/cancel-signing.js +3 -7
  46. package/dist/supervisor/credentials.d.ts +17 -5
  47. package/dist/supervisor/recycle.d.ts +5 -1
  48. package/dist/supervisor/run-event-compaction.d.ts +2 -2
  49. package/dist/supervisor/run-event-compaction.js +11 -16
  50. package/dist/supervisor/run-event-recovery.d.ts +34 -0
  51. package/dist/supervisor/run-event-recovery.js +45 -0
  52. package/dist/supervisor/supervisor.d.ts +27 -4
  53. package/dist/supervisor/supervisor.js +644 -58
  54. package/dist/supervisor/terminal-commit.js +3 -7
  55. package/dist/supervisor/types.d.ts +30 -0
  56. package/dist/testing/change-notifier.d.ts +12 -0
  57. package/dist/testing/change-notifier.js +63 -0
  58. package/dist/testing/index.d.ts +8 -0
  59. package/dist/testing/index.js +16 -0
  60. package/dist/testing/log-capture.d.ts +52 -0
  61. package/dist/testing/log-capture.js +124 -0
  62. package/dist/testing/mail-bus.d.ts +22 -0
  63. package/dist/testing/mail-bus.js +78 -0
  64. package/dist/testing/memory-streams.d.ts +43 -0
  65. package/dist/testing/memory-streams.js +211 -0
  66. package/dist/testing/spawn-observer.d.ts +12 -0
  67. package/dist/testing/spawn-observer.js +36 -0
  68. package/dist/testing/stub-repo-store.d.ts +10 -0
  69. package/dist/testing/stub-repo-store.js +39 -0
  70. package/dist/testing/supervisor-reaper.d.ts +24 -0
  71. package/dist/testing/supervisor-reaper.js +49 -0
  72. package/dist/testing/upstream-frames.d.ts +47 -0
  73. package/dist/testing/upstream-frames.js +94 -0
  74. package/dist/workflow-definition-loader.d.ts +56 -0
  75. package/dist/workflow-definition-loader.js +106 -0
  76. package/package.json +17 -11
  77. package/dist/conversation-text.d.ts +0 -23
  78. package/dist/conversation-text.js +0 -56
package/README.md CHANGED
@@ -26,9 +26,14 @@ The package is organized along the abstract pieces it implements:
26
26
  startup recovery walk and a live `subscribeKind` loop so a
27
27
  `TimerSet` committed by an active workflow process fires without
28
28
  waiting for a process restart. `signal-channel.ts` funnels live
29
- `SignalReceived` commits into the matching awaiter, with resume
30
- rehydration consulting `unconsumedSignals` so a signal that
31
- arrived while offline replays before live subscription begins.
29
+ `SignalReceived` commits into the matching awaiter. The channel
30
+ reads `unconsumedSignals` through its injected `readState`
31
+ reader, but every production call site passes an `emptyState`
32
+ reader, so that queue is always empty in production: a pre-await
33
+ signal resolves through the live `subscribeKind` tail, and
34
+ resume rehydration of a signal that arrived while the run was
35
+ offline is not wired. Plumbing the runtime body's own `RunState`
36
+ reader into the child is what that capability waits on.
32
37
  - `ipc/` — control and event channel implementations the
33
38
  supervisor wraps. Threat model lives at the top of
34
39
  `ipc/index.ts`; the supervisor uses these primitives directly.
@@ -287,7 +292,19 @@ The contract is intentionally narrow:
287
292
  factory returns `RunWorkflowChildBindings`: substrate `RepoStore`,
288
293
  principal, per-deployment repo ids, scheduler, step invoker, child
289
294
  spawner, grant evaluator. The factory consumes the typed struct,
290
- never `NodeJS.ProcessEnv` directly.
295
+ never `NodeJS.ProcessEnv` directly. Each spawner declares
296
+ `hasUpstreamSignalResolver` on the runtime env it builds: `true`
297
+ for a run an answer can reach -- the deployment's own addressable
298
+ run, or a suspendable body whose container relays a decision back
299
+ down -- and `false` for a terminal `childWorkflow` child, which
300
+ carries no address and is run to its terminal rather than driven
301
+ across parks. The field is required, so an omission is a compile
302
+ error; a wrong `true` is not. Declared on a terminal-child seam it
303
+ type-checks and reinstates the hang the flag exists to remove. The
304
+ in-tree seams show both answers: `buildRuntimeEnv` declares `true`
305
+ for the deployment's own run, while in the sidecar's substrate
306
+ factory `createSidecarRunChild` declares `false` and
307
+ `createSidecarSpawnSuspendableChild` declares `true`.
291
308
  3. **The helper fails loudly.** A missing or malformed spawn-time
292
309
  env throws via `parseSpawnTimeEnv`; a substrate-config key the
293
310
  host listed but the supervisor did not populate throws before the
@@ -0,0 +1,46 @@
1
+ import type { Principal, RepoId, RepoStore as SubstrateRepoStore } from "@intx/hub-sessions/substrate";
2
+ import type { Mail, MailPartReader, MessageHeaders, MessagePart } from "@intx/types/runtime";
3
+ /**
4
+ * Thrown for a DETERMINISTIC, input-shaped rejection of an inbound mail -- a
5
+ * messageId that cannot form a usable path segment. Distinct from a transient
6
+ * substrate write failure so the caller drops the offending mail (replaying it
7
+ * would fail identically) rather than treating it as a retryable fault.
8
+ */
9
+ export declare class InvalidMailError extends Error {
10
+ constructor(message: string, options?: {
11
+ cause?: unknown;
12
+ });
13
+ }
14
+ export type MailPartStoreOpts = {
15
+ substrate: SubstrateRepoStore;
16
+ repoId: RepoId;
17
+ principal: Principal;
18
+ runId: string;
19
+ ref: string;
20
+ };
21
+ /**
22
+ * Commit a decoded message's parts and assemble the JSON-safe `Mail`. Every
23
+ * part's bytes are written in ONE prefix-preserving commit under the message's
24
+ * directory (write-once, atomic), and each part becomes a `MailPart` descriptor
25
+ * carrying its metadata, an opaque `ref`, and -- for a small UTF-8 text part --
26
+ * its decoded `text` inline.
27
+ */
28
+ export declare function commitMail(opts: MailPartStoreOpts, messageId: string, decoded: {
29
+ headers: MessageHeaders;
30
+ rawHeaders: Record<string, string[]>;
31
+ parts: MessagePart[];
32
+ }): Promise<Mail>;
33
+ export type MailPartReaderOpts = {
34
+ substrate: SubstrateRepoStore;
35
+ repoId: RepoId;
36
+ principal: Principal;
37
+ ref: string;
38
+ };
39
+ /**
40
+ * Construct the single mail-part reader for a deployment's workflow-run repo.
41
+ * `read` resolves any run's `MailPart.ref` to the committed bytes through a
42
+ * committed read pinned to the object store, so a cross-run read (a childflow
43
+ * or body step resolving a parent's part) never observes the lagging working
44
+ * tree.
45
+ */
46
+ export declare function createMailPartReader(opts: MailPartReaderOpts): MailPartReader;
@@ -0,0 +1,251 @@
1
+ // Durable store for a run's inbound-mail parts.
2
+ //
3
+ // The supervisor decodes an inbound MIME message into parts (via `decodeMail`)
4
+ // and commits each part's decoded bytes here as a real file under
5
+ // `runs/<runId>/parts/<urlEncoded(messageId)>/<index>-<name>`, returning the
6
+ // JSON-safe `MailPart[]` descriptors that ride in the run's trigger/signal
7
+ // payload. A `MailPartReader` resolves a descriptor's opaque `ref` back to the
8
+ // committed bytes for any consumer -- an agent's content-block projection, a
9
+ // workflow tool, a child run -- through the single, environment-agnostic
10
+ // `MailPartReader` interface.
11
+ //
12
+ // Modeled on the sibling `blob-substrate` adapter: same per-run handles, same
13
+ // substrate primitives (`writeTreePreservingPrefix` to write raw bytes;
14
+ // `openCommittedReads` to read them back from a coherent object-store snapshot
15
+ // rather than the lagging working tree). The write happens in one commit per
16
+ // message (write-once, atomic). The kind handler validates the subtree shape;
17
+ // this module sanitizes untrusted names to satisfy it and reuses the handler's
18
+ // path-component byte cap.
19
+ import { MAX_MAIL_PART_PATH_COMPONENT_BYTES, WORKFLOW_RUN_PARTS_DIR, WORKFLOW_RUN_RUNS_PREFIX, } from "@intx/hub-sessions/substrate";
20
+ const REF_SCHEME = "mail-part:///";
21
+ // Content types whose bytes are UTF-8 text and small enough to also inline as
22
+ // `MailPart.text`, so a selector can read them without resolving the ref.
23
+ const INLINE_TEXT_MAX_BYTES = 1024 * 1024;
24
+ const CONTROL_CHAR_MAX = 0x1f;
25
+ const DEL_CHAR = 0x7f;
26
+ // Unicode line/paragraph separators. JavaScript's regex `.` does NOT match
27
+ // these, so the kind handler's `<index>-<name>` check (whose name group is
28
+ // `.+`) rejects a filename containing them. The sanitizer must strip them to
29
+ // keep its "satisfies the handler by construction" contract.
30
+ const LINE_SEPARATOR = 0x2028;
31
+ const PARAGRAPH_SEPARATOR = 0x2029;
32
+ const encoder = new TextEncoder();
33
+ function byteLength(value) {
34
+ return encoder.encode(value).length;
35
+ }
36
+ /**
37
+ * Thrown for a DETERMINISTIC, input-shaped rejection of an inbound mail -- a
38
+ * messageId that cannot form a usable path segment. Distinct from a transient
39
+ * substrate write failure so the caller drops the offending mail (replaying it
40
+ * would fail identically) rather than treating it as a retryable fault.
41
+ */
42
+ export class InvalidMailError extends Error {
43
+ constructor(message, options) {
44
+ super(message, options);
45
+ this.name = "InvalidMailError";
46
+ }
47
+ }
48
+ function encodeMessageSegment(messageId) {
49
+ const encoded = encodeURIComponent(messageId);
50
+ if (byteLength(encoded) > MAX_MAIL_PART_PATH_COMPONENT_BYTES) {
51
+ throw new InvalidMailError(`mail part store: messageId ${JSON.stringify(messageId)} url-encodes to ${String(byteLength(encoded))} bytes, over the ${String(MAX_MAIL_PART_PATH_COMPONENT_BYTES)}-byte path-component limit`);
52
+ }
53
+ // `encodeURIComponent` leaves `.` unescaped, so "." or ".." would form a
54
+ // traversal segment; reject it where the messageId -> segment constraint is
55
+ // owned. An empty segment is unreachable for a non-empty messageId but is
56
+ // refused for the same reason.
57
+ if (encoded.length === 0 || encoded === "." || encoded === "..") {
58
+ throw new InvalidMailError(`mail part store: messageId ${JSON.stringify(messageId)} url-encodes to ${JSON.stringify(encoded)}, which is not a usable path segment`);
59
+ }
60
+ return encoded;
61
+ }
62
+ /**
63
+ * Reduce an untrusted part name (a MIME filename, or a fallback) to one safe
64
+ * path segment: path separators, NUL, and control characters become `_`. The
65
+ * `<index>-` prefix guarantees per-message uniqueness, so a sanitization
66
+ * collision between two parts of one message is harmless.
67
+ */
68
+ function sanitizePartName(name) {
69
+ let out = "";
70
+ for (const ch of name) {
71
+ const code = ch.codePointAt(0) ?? 0;
72
+ out +=
73
+ ch === "/" ||
74
+ ch === "\\" ||
75
+ code <= CONTROL_CHAR_MAX ||
76
+ code === DEL_CHAR ||
77
+ code === LINE_SEPARATOR ||
78
+ code === PARAGRAPH_SEPARATOR
79
+ ? "_"
80
+ : ch;
81
+ }
82
+ return out.length > 0 ? out : "part";
83
+ }
84
+ /** Truncate to at most `maxBytes` UTF-8 bytes on a codepoint boundary. */
85
+ function truncateToBytes(value, maxBytes) {
86
+ if (byteLength(value) <= maxBytes)
87
+ return value;
88
+ let out = "";
89
+ let used = 0;
90
+ for (const ch of value) {
91
+ const chBytes = byteLength(ch);
92
+ if (used + chBytes > maxBytes)
93
+ break;
94
+ out += ch;
95
+ used += chBytes;
96
+ }
97
+ return out;
98
+ }
99
+ /**
100
+ * The on-disk filename for one part: `<index>-<name>`, sanitized and truncated
101
+ * to the handler's byte cap. Satisfies the handler's `<index>-<name>` shape by
102
+ * construction.
103
+ */
104
+ function partFilename(index, part) {
105
+ const prefix = `${String(index)}-`;
106
+ const budget = MAX_MAIL_PART_PATH_COMPONENT_BYTES - byteLength(prefix);
107
+ const rawName = part.filename ?? defaultPartName(part.contentType);
108
+ const safeName = truncateToBytes(sanitizePartName(rawName), budget);
109
+ return `${prefix}${safeName.length > 0 ? safeName : "part"}`;
110
+ }
111
+ /** A stable fallback name for a part with no filename, derived from its type. */
112
+ function defaultPartName(contentType) {
113
+ const slash = contentType.indexOf("/");
114
+ const subtype = slash === -1 ? contentType : contentType.slice(slash + 1);
115
+ const safeSubtype = subtype.replace(/[^a-z0-9]+/gi, "") || "bin";
116
+ return `part.${safeSubtype}`;
117
+ }
118
+ function isTextType(contentType) {
119
+ return (contentType.startsWith("text/") ||
120
+ contentType === "application/json" ||
121
+ contentType === "application/vnd.interchange+json");
122
+ }
123
+ function mailPartRef(runId, messageSegment, filename) {
124
+ return `${REF_SCHEME}${encodeURIComponent(runId)}/${messageSegment}/${encodeURIComponent(filename)}`;
125
+ }
126
+ /**
127
+ * Parse a `mail-part:///` ref into its run id, message segment, and filename.
128
+ * The ref is persisted in the event log and re-read on resume, so it is
129
+ * treated as untrusted: the scheme must match, it must be exactly three
130
+ * non-empty segments, and no segment may traverse.
131
+ */
132
+ function parseMailPartRef(ref) {
133
+ if (!ref.startsWith(REF_SCHEME)) {
134
+ throw new Error(`mail part reader: unrecognized ref ${JSON.stringify(ref)}`);
135
+ }
136
+ const rest = ref.slice(REF_SCHEME.length);
137
+ const segments = rest.split("/");
138
+ const malformed = `mail part reader: malformed ref ${JSON.stringify(ref)}`;
139
+ if (segments.length !== 3 ||
140
+ segments.some((s) => s.length === 0) ||
141
+ rest.includes("\\")) {
142
+ throw new Error(malformed);
143
+ }
144
+ // `runId` and `filename` were percent-encoded into the ref; `messageSegment`
145
+ // is stored encoded and matches the on-disk directory name verbatim.
146
+ let runId;
147
+ let filename;
148
+ try {
149
+ runId = decodeURIComponent(segments[0] ?? "");
150
+ filename = decodeURIComponent(segments[2] ?? "");
151
+ }
152
+ catch (cause) {
153
+ throw new Error(malformed, { cause });
154
+ }
155
+ const messageSegment = segments[1] ?? "";
156
+ // Reject traversal on the DECODED values too: a ref could encode `..`
157
+ // (`%2e%2e`) or a path separator (`%2f`, `%5c`) that only reveals itself
158
+ // after decoding, forming a compound traversal segment like `../..`.
159
+ const traverses = (s) => s === "." || s === ".." || s.includes("/") || s.includes("\\");
160
+ if ([runId, messageSegment, filename].some(traverses)) {
161
+ throw new Error(malformed);
162
+ }
163
+ return { runId, messageSegment, filename };
164
+ }
165
+ /**
166
+ * Commit a decoded message's parts and assemble the JSON-safe `Mail`. Every
167
+ * part's bytes are written in ONE prefix-preserving commit under the message's
168
+ * directory (write-once, atomic), and each part becomes a `MailPart` descriptor
169
+ * carrying its metadata, an opaque `ref`, and -- for a small UTF-8 text part --
170
+ * its decoded `text` inline.
171
+ */
172
+ export async function commitMail(opts, messageId, decoded) {
173
+ const messageSegment = encodeMessageSegment(messageId);
174
+ const messagePrefix = `${WORKFLOW_RUN_RUNS_PREFIX}/${opts.runId}/${WORKFLOW_RUN_PARTS_DIR}/${messageSegment}/`;
175
+ const fresh = {};
176
+ const mailParts = decoded.parts.map((part, index) => {
177
+ const filename = partFilename(index, part);
178
+ fresh[`${messagePrefix}${filename}`] = part.content;
179
+ const descriptor = {
180
+ contentType: part.contentType,
181
+ ref: mailPartRef(opts.runId, messageSegment, filename),
182
+ };
183
+ if (part.filename !== undefined)
184
+ descriptor.filename = part.filename;
185
+ if (part.disposition !== undefined)
186
+ descriptor.disposition = part.disposition;
187
+ if (isTextType(part.contentType) &&
188
+ part.content.byteLength <= INLINE_TEXT_MAX_BYTES) {
189
+ descriptor.text = new TextDecoder("utf-8", { fatal: false }).decode(part.content);
190
+ }
191
+ return descriptor;
192
+ });
193
+ if (Object.keys(fresh).length > 0) {
194
+ try {
195
+ await opts.substrate.writeTreePreservingPrefix(opts.principal, opts.repoId, opts.ref, {
196
+ preservePrefix: messagePrefix,
197
+ merge: async (existing) => {
198
+ const files = {};
199
+ for (const [k, v] of existing)
200
+ files[k] = v;
201
+ for (const [k, v] of Object.entries(fresh))
202
+ files[k] = v;
203
+ return files;
204
+ },
205
+ message: `commit ${String(decoded.parts.length)} mail part(s) for message ${messageId} of run ${opts.runId}`,
206
+ });
207
+ }
208
+ catch (cause) {
209
+ const message = cause instanceof Error ? cause.message : String(cause);
210
+ // A path_violation is a shape rejection of this message's own (already
211
+ // sanitized) content: it is deterministic, so replaying the same bytes
212
+ // fails identically. Surface it as InvalidMailError so the caller drops
213
+ // the mail rather than retrying it forever as a transient fault.
214
+ if (message.startsWith("path_violation: ")) {
215
+ throw new InvalidMailError(message.slice("path_violation: ".length), {
216
+ cause,
217
+ });
218
+ }
219
+ throw cause;
220
+ }
221
+ }
222
+ return {
223
+ headers: decoded.headers,
224
+ rawHeaders: decoded.rawHeaders,
225
+ parts: mailParts,
226
+ };
227
+ }
228
+ /**
229
+ * Construct the single mail-part reader for a deployment's workflow-run repo.
230
+ * `read` resolves any run's `MailPart.ref` to the committed bytes through a
231
+ * committed read pinned to the object store, so a cross-run read (a childflow
232
+ * or body step resolving a parent's part) never observes the lagging working
233
+ * tree.
234
+ */
235
+ export function createMailPartReader(opts) {
236
+ return {
237
+ async read(ref) {
238
+ const { runId, messageSegment, filename } = parseMailPartRef(ref);
239
+ const dir = `${WORKFLOW_RUN_RUNS_PREFIX}/${runId}/${WORKFLOW_RUN_PARTS_DIR}/${messageSegment}`;
240
+ const reads = await opts.substrate.openCommittedReads(opts.principal, opts.repoId, opts.ref);
241
+ if (reads === null) {
242
+ throw new Error(`mail part reader: repo ${opts.repoId.id} ref ${opts.ref} has no committed tree; cannot resolve ${ref}`);
243
+ }
244
+ const entry = (await reads.listDir(dir)).find((e) => e.name === filename && e.type === "blob");
245
+ if (entry === undefined) {
246
+ throw new Error(`mail part reader: no committed part at ${dir}/${filename}`);
247
+ }
248
+ return reads.readBlobByOid(entry.oid);
249
+ },
250
+ };
251
+ }
@@ -27,10 +27,9 @@
27
27
  // prospective tree via `validatePush`. Translated into a thrown
28
28
  // Error carrying the handler's `reason` text. No retries.
29
29
  import { type } from "arktype";
30
- import { subscribeKind, WORKFLOW_RUN_EVENTS_FILE, splitCombinedEventLog, } from "@intx/hub-sessions/substrate";
30
+ import { parseEventSeq, subscribeKind, WORKFLOW_RUN_EVENTS_FILE, splitCombinedEventLog, } from "@intx/hub-sessions/substrate";
31
31
  const RUNS_PREFIX = "runs";
32
32
  const EVENTS_DIR = "events";
33
- const EVENT_FILENAME_RE = /^(0|[1-9][0-9]*)\.json$/;
34
33
  /**
35
34
  * On-disk envelope shape committed under
36
35
  * `runs/<runId>/events/<seq>.json`. Carries the seq cross-check the
@@ -141,13 +140,9 @@ async function readAllEventsForRun(opts, runId) {
141
140
  for (const child of eventBlobs) {
142
141
  if (child.type !== "blob")
143
142
  continue;
144
- const match = EVENT_FILENAME_RE.exec(child.name);
145
- if (match === null)
143
+ const seqFromName = parseEventSeq(child.name);
144
+ if (seqFromName === null)
146
145
  continue;
147
- const seqStr = match[1];
148
- if (seqStr === undefined)
149
- continue;
150
- const seqFromName = Number.parseInt(seqStr, 10);
151
146
  const raw = decoder.decode(await reads.readBlobByOid(child.oid));
152
147
  const source = `${opts.repoId.id}/${runId}/${EVENTS_DIR}/${child.name}`;
153
148
  const entry = parseEventEnvelope(raw, source);
@@ -262,13 +257,9 @@ async function appendBatchEvents(opts, runId, events) {
262
257
  let priorLastSeq = 0;
263
258
  for (const filepath of existing.keys()) {
264
259
  const name = filepath.slice(prefix.length);
265
- const match = EVENT_FILENAME_RE.exec(name);
266
- if (match === null)
267
- continue;
268
- const seqStr = match[1];
269
- if (seqStr === undefined)
260
+ const seq = parseEventSeq(name);
261
+ if (seq === null)
270
262
  continue;
271
- const seq = Number.parseInt(seqStr, 10);
272
263
  if (seq > priorLastSeq)
273
264
  priorLastSeq = seq;
274
265
  }
@@ -1,5 +1,6 @@
1
1
  import type { InferenceEvent } from "@intx/types/runtime";
2
2
  import type { SpawnChildWorkflow, SpawnSuspendableChild, SuspendableChildHandle, WorkflowDefinition, WorkflowEvent } from "@intx/workflow";
3
+ import type { CredentialMaterialRef } from "../child/run-child.js";
3
4
  /**
4
5
  * The terminal-status shape the runtime body expects back from a
5
6
  * spawn. Mirrored from `SpawnChildWorkflow`'s return type so the
@@ -16,7 +17,18 @@ export type ChildTerminalStatus = "completed" | "failed" | "cancelled";
16
17
  *
17
18
  * The callback receives the same `AbortSignal` the parent runtime
18
19
  * passed into the adapter so a parent-initiated cancellation
19
- * propagates to the child without an intermediate wrapper.
20
+ * propagates to the child without an intermediate wrapper. It also
21
+ * receives the parent run's live `onEvent` sink so the child's agent
22
+ * steps emit inference events up the same channel (mirroring
23
+ * {@link RunSuspendableChild}), and the spawn `depth` / ceiling so the
24
+ * child run's own spawns keep counting against the tree-wide bound.
25
+ *
26
+ * The callback also receives the parent run's live credential-material cell
27
+ * (the same reference the top-level step invoker reads live), so the child's
28
+ * inference resolves its source secret by `credentialId` against the run's
29
+ * current delivery -- a rotation the parent applies reaches the child through
30
+ * the shared reference. A non-sidecar executor that carries no credential
31
+ * material omits it.
20
32
  */
21
33
  export type RunChildWorkflow = (input: {
22
34
  definition: WorkflowDefinition;
@@ -26,7 +38,9 @@ export type RunChildWorkflow = (input: {
26
38
  parentRunId: string;
27
39
  parentStepId: string;
28
40
  signal: AbortSignal;
29
- }) => Promise<{
41
+ depth: number;
42
+ maxChildSpawnDepth: number;
43
+ }, onEvent: (event: InferenceEvent) => void, credentialMaterial?: CredentialMaterialRef) => Promise<{
30
44
  terminalStatus: ChildTerminalStatus;
31
45
  }>;
32
46
  /**
@@ -43,10 +57,21 @@ export type RunChildWorkflow = (input: {
43
57
  * {@link createInMemorySpawnSuspendableChild} but drives the child terminal-only
44
58
  * (await its terminal status) rather than across approval parks.
45
59
  */
60
+ /**
61
+ * Host-side widening of the runtime {@link SpawnChildWorkflow} contract: the
62
+ * same input plus the per-run `onEvent` sink the host injects. The runtime env
63
+ * carries the narrow `SpawnChildWorkflow` (no event slot); the caller wraps this
64
+ * with its run's `onEvent`, mirroring {@link HostSpawnSuspendableChild}. The
65
+ * sink is a call argument (not closed over at construction) because the resolver
66
+ * is selected once per deployment while `onEvent` is built per run. The run's
67
+ * live credential-material cell rides the same seam, so the child's inference
68
+ * reads the parent's current credential delivery.
69
+ */
70
+ export type HostSpawnChild = (input: Parameters<SpawnChildWorkflow>[0], onEvent: (event: InferenceEvent) => void, credentialMaterial?: CredentialMaterialRef) => ReturnType<SpawnChildWorkflow>;
46
71
  export declare function createInMemorySpawnChild(opts: {
47
72
  bodies: ReadonlyMap<string, WorkflowDefinition>;
48
73
  runChild: RunChildWorkflow;
49
- }): SpawnChildWorkflow;
74
+ }): HostSpawnChild;
50
75
  /**
51
76
  * Runtime-supplied suspendable child execution callback. The park-aware
52
77
  * analog of {@link RunChildWorkflow}: the supervisor owns the child
@@ -65,6 +90,8 @@ export type RunSuspendableChild = (input: {
65
90
  parentRunId: string;
66
91
  parentStepId: string;
67
92
  signal: AbortSignal;
93
+ depth: number;
94
+ maxChildSpawnDepth: number;
68
95
  resumeFromEvents?: readonly WorkflowEvent[];
69
96
  },
70
97
  /**
@@ -74,16 +101,25 @@ export type RunSuspendableChild = (input: {
74
101
  * silently dropped. Per-run durable attribution is unaffected -- the child
75
102
  * runtime commits its events under `runs/<childRunId>/events/` regardless.
76
103
  */
77
- onEvent: (event: InferenceEvent) => void) => Promise<SuspendableChildHandle>;
104
+ onEvent: (event: InferenceEvent) => void,
105
+ /**
106
+ * The parent run's live credential-material cell. Threaded so the body's
107
+ * inference resolves its source secret by `credentialId` against the run's
108
+ * current delivery, reached live through the shared reference on a rotation.
109
+ * A non-sidecar executor that carries no credential material omits it.
110
+ */
111
+ credentialMaterial?: CredentialMaterialRef) => Promise<SuspendableChildHandle>;
78
112
  /**
79
113
  * Host-side widening of the runtime {@link SpawnSuspendableChild} contract: the
80
114
  * same input plus the per-run `onEvent` sink the host injects. The runtime
81
115
  * calls the narrow `SpawnSuspendableChild` (no event slot); the host binding
82
116
  * wired into the runtime env closes over the run's funnel and forwards it here,
83
117
  * mirroring how `ChildStepInvoker` widens the runtime `StepInvoker` with
84
- * `onEvent`. The runtime contract in `@intx/workflow` stays untouched.
118
+ * `onEvent`. The runtime contract in `@intx/workflow` stays untouched. The
119
+ * run's live credential-material cell rides the same seam, so the body's
120
+ * inference reads the parent's current credential delivery.
85
121
  */
86
- export type HostSpawnSuspendableChild = (input: Parameters<SpawnSuspendableChild>[0], onEvent: (event: InferenceEvent) => void) => ReturnType<SpawnSuspendableChild>;
122
+ export type HostSpawnSuspendableChild = (input: Parameters<SpawnSuspendableChild>[0], onEvent: (event: InferenceEvent) => void, credentialMaterial?: CredentialMaterialRef) => ReturnType<SpawnSuspendableChild>;
87
123
  /**
88
124
  * Construct the `WorkflowRuntimeEnv.SpawnSuspendableChild` adapter for the
89
125
  * source-ref (code-sourced) path -- the only deploy lineage. The parent child
@@ -59,22 +59,8 @@
59
59
  // `runChild` does -- but the callback's input shape (`{ definition,
60
60
  // childRunId, ... }`) is the seam that makes the scoping unambiguous
61
61
  // at the boundary.
62
- /**
63
- * Construct the terminal `WorkflowRuntimeEnv.SpawnChildWorkflow` adapter for an
64
- * owned childWorkflow import. The child re-evaluated the whole pinned closure
65
- * and lifted every inline child to an internal `{ ref }`, so the child
66
- * definitions are in hand and already covered by the parent's re-verify.
67
- * Resolve each `definitionRef`
68
- * from the in-memory `bodies` map and delegate to the runtime-supplied
69
- * `runChild`, with NO on-disk round-trip and NO separate per-child re-verify:
70
- * materializing the child back out and re-fingerprinting it would round-trip
71
- * trusted-in-hand data for no gain, and the closure re-eval on restart
72
- * re-derives the same bodies durably. Mirrors
73
- * {@link createInMemorySpawnSuspendableChild} but drives the child terminal-only
74
- * (await its terminal status) rather than across approval parks.
75
- */
76
62
  export function createInMemorySpawnChild(opts) {
77
- return async ({ definitionRef, childRunId, input, parentRunId, parentStepId, signal, }) => {
63
+ return async ({ definitionRef, childRunId, input, parentRunId, parentStepId, signal, depth, maxChildSpawnDepth, }, onEvent, credentialMaterial) => {
78
64
  if (signal.aborted) {
79
65
  throw abortError(signal);
80
66
  }
@@ -101,7 +87,9 @@ export function createInMemorySpawnChild(opts) {
101
87
  parentRunId,
102
88
  parentStepId,
103
89
  signal,
104
- });
90
+ depth,
91
+ maxChildSpawnDepth,
92
+ }, onEvent, credentialMaterial);
105
93
  return { terminalStatus: result.terminalStatus };
106
94
  };
107
95
  }
@@ -119,7 +107,7 @@ export function createInMemorySpawnChild(opts) {
119
107
  * per-body boundary is the deferred, opt-in SandboxBoundary case).
120
108
  */
121
109
  export function createInMemorySpawnSuspendableChild(opts) {
122
- return async ({ definitionRef, childRunId, input, parentRunId, parentStepId, signal, resumeFromEvents, }, onEvent) => {
110
+ return async ({ definitionRef, childRunId, input, parentRunId, parentStepId, signal, depth, maxChildSpawnDepth, resumeFromEvents, }, onEvent, credentialMaterial) => {
123
111
  if (signal.aborted) {
124
112
  throw abortError(signal);
125
113
  }
@@ -140,8 +128,10 @@ export function createInMemorySpawnSuspendableChild(opts) {
140
128
  parentRunId,
141
129
  parentStepId,
142
130
  signal,
131
+ depth,
132
+ maxChildSpawnDepth,
143
133
  ...(resumeFromEvents !== undefined ? { resumeFromEvents } : {}),
144
- }, onEvent);
134
+ }, onEvent, credentialMaterial);
145
135
  };
146
136
  }
147
137
  /**
@@ -1,7 +1,7 @@
1
1
  import { type Agent, type AgentDefinition, type BaseEnv } from "@intx/agent";
2
- import type { InferenceEvent, InferenceSource } from "@intx/types/runtime";
2
+ import type { InboundMessage, InferenceEvent, InferenceSource, MailPartReader } from "@intx/types/runtime";
3
3
  import type { StepInvokeRequest, StepInvoker, WorkflowAuthorizeFn } from "@intx/workflow";
4
- import type { WarmAgentCache } from "../child/warm-agent-cache.js";
4
+ import type { WarmAgentCache, WarmReplyDrive } from "../child/warm-agent-cache.js";
5
5
  /**
6
6
  * Per-step env contributions the caller of the adapter owns.
7
7
  *
@@ -104,6 +104,56 @@ export interface WorkflowStepInvokerOpts {
104
104
  * has no cross-run conversation to mirror.
105
105
  */
106
106
  onRunBoundary?: (key: string) => Promise<void>;
107
+ /**
108
+ * Seed hook for the warm path (design §3c threading). When supplied, the
109
+ * adapter calls it before `agent.send` on every message whose delivered
110
+ * input is a mail-derived `InboundMessage`, passing the step identity
111
+ * (`authzContext.stepId`, the same key `onRunBoundary` and the warm cache
112
+ * use) and that message. The sidecar wires this to the warm agent's
113
+ * durable conversation store, which routes the message onto the connector
114
+ * thread (seeding threadRoot / lastMessageId / replyTo) so the reply path
115
+ * can compose a threaded reply. Awaited before the send so the thread
116
+ * state is committed and durably flushed before the reply is produced; a
117
+ * seed failure surfaces by rejecting the step.
118
+ *
119
+ * Only mail-derived inbound messages seed: an approval-resume inbound
120
+ * carries a synthetic sender and a correlation id, and a synthesized
121
+ * string input is not a message, so neither advances the connector
122
+ * thread. Omitted on the cold path, which has no durable connector state
123
+ * to seed.
124
+ */
125
+ seedInbound?: (key: string, message: InboundMessage) => Promise<void>;
126
+ /**
127
+ * Connector reply-drain hook for the warm path (design §3c). When supplied,
128
+ * the adapter invokes it ONCE -- at the warm agent's first-message build --
129
+ * with the step identity (`authzContext.stepId`, the same key the warm
130
+ * cache, seed, and run-boundary hooks use) and the agent's lifetime event
131
+ * stream. The sidecar wires this to the shared connector reply drain: on
132
+ * every `connector.reply` the agent emits, the drain composes a threaded
133
+ * reply from the durable store's connector thread and sends it through the
134
+ * supervisor-backed outbound bridge, then advances the thread from the send
135
+ * receipt.
136
+ *
137
+ * The returned drive handle exposes the drain's lifetime `done` promise --
138
+ * which settles when the agent's stream ends at eviction, folded into the
139
+ * warm entry's event-forward promise so the cache drains the reply loop
140
+ * alongside the observability forwarder -- plus the per-turn settle barrier
141
+ * (`replySeq` / `waitForReplyAfter`) the warm step gates each reply turn on,
142
+ * so the run parks only after the reply is durably sent.
143
+ *
144
+ * Omitted on the cold path (a torn-down per-step agent has no cross-message
145
+ * connector thread) and whenever the deployment is not warm-kept.
146
+ */
147
+ driveReplies?: (key: string, stream: ReturnType<Agent["stream"]>) => WarmReplyDrive;
148
+ /**
149
+ * Reader for the run's inbound-mail parts. When the step input is a decoded
150
+ * `Mail`, the adapter resolves each part's `ref` to its committed bytes
151
+ * through this reader and delivers a real `InboundMessage` (text and/or
152
+ * attachments) to `agent.send`. Supplied by the run child for the top-level
153
+ * run's steps; absent for body steps, where a part whose bytes must be read
154
+ * is refused loudly rather than silently flattened to text.
155
+ */
156
+ mailPartReader?: MailPartReader;
107
157
  }
108
158
  /**
109
159
  * Construct the production `WorkflowRuntimeEnv.StepInvoker` adapter.