@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
@@ -0,0 +1,101 @@
1
+ // Child-side mailbox-mutation bridge (INBOUND half of mailbox ownership,
2
+ // §3b).
3
+ //
4
+ // The supervisor is the sole mail owner: it holds the long-lived
5
+ // substrate mailbox store and is the only writer to the workflow-run
6
+ // ref. A step agent reads its INBOX locally (the supervisor-backed
7
+ // transport's read surface opens fresh committed snapshots), but every
8
+ // MUTATION of the mailbox -- flag writes (`\Seen`, `\Deleted`, ...) and
9
+ // `expunge` -- routes up to the supervisor through this bridge rather
10
+ // than being flushed from the child. A second writer flushing the same
11
+ // ref from the child would race the supervisor's in-memory mirror and
12
+ // break uid / modseq monotonicity, so the child never writes the
13
+ // mailbox directly.
14
+ //
15
+ // Lifecycle of one mutation:
16
+ //
17
+ // 1. The agent's mail tool (flag or expunge) calls the
18
+ // supervisor-backed transport's `setFlags` / `clearFlags` /
19
+ // `expunge`. The transport calls `bridge.submit(mutation)`.
20
+ // 2. `submit` mints a `requestId`, registers a pending awaiter, and
21
+ // emits `mailbox.mutate.request` upstream carrying the op and its
22
+ // operands.
23
+ // 3. The supervisor applies the op to its owned mailbox store,
24
+ // flushes, and replies with `mailbox.mutate.response`. The reply is
25
+ // sent only after the flush, so the child's next committed read
26
+ // observes the mutation (the same flush-before-signal ordering
27
+ // `mailbox.notify` relies on).
28
+ // 4. The bridge resolves / rejects the pending awaiter; the
29
+ // transport method returns to the mail tool. A supervisor-side
30
+ // failure (unknown uid, substrate fault) surfaces as a rejection so
31
+ // the agent's mail-tool call fails loudly rather than silently
32
+ // dropping the mutation.
33
+ import { getLogger } from "@intx/log";
34
+ import { createPendingRequestCore } from "./pending-request.js";
35
+ const logger = getLogger(["workflow-host", "child", "mailbox-mutation-bridge"]);
36
+ /**
37
+ * Construct the child-side mailbox-mutation bridge. Pending mutations
38
+ * live in the shared pending-request core keyed by `requestId`; the
39
+ * bridge resolves the awaiter when the supervisor's matching
40
+ * `mailbox.mutate.response` lands.
41
+ */
42
+ export function createChildMailboxMutationBridge(opts) {
43
+ const pending = createPendingRequestCore({
44
+ label: "workflow-child mailbox mutation",
45
+ allocatorPrefix: "mm",
46
+ allocateRequestId: opts.allocateRequestId,
47
+ });
48
+ return {
49
+ get pendingCount() {
50
+ return pending.pendingCount;
51
+ },
52
+ async submit(mutation) {
53
+ const { requestId, promise } = pending.register(undefined);
54
+ const data = mutation.op === "expunge"
55
+ ? {
56
+ requestId,
57
+ runId: mutation.runId,
58
+ mailbox: mutation.mailbox,
59
+ op: mutation.op,
60
+ }
61
+ : {
62
+ requestId,
63
+ runId: mutation.runId,
64
+ mailbox: mutation.mailbox,
65
+ op: mutation.op,
66
+ uid: mutation.uid,
67
+ flags: mutation.flags,
68
+ };
69
+ try {
70
+ await opts.upstreamSender.send({
71
+ type: "mailbox.mutate.request",
72
+ data,
73
+ });
74
+ }
75
+ catch (cause) {
76
+ pending.discard(requestId);
77
+ throw pending.sendFailedError(requestId, cause);
78
+ }
79
+ return promise;
80
+ },
81
+ handleResult(data) {
82
+ const entry = pending.settle(data.requestId);
83
+ if (entry === undefined) {
84
+ logger.warn `mailbox.mutate.response landed with no pending entry; requestId=${data.requestId} dropped`;
85
+ return;
86
+ }
87
+ if (data.result.ok) {
88
+ const result = {};
89
+ if (data.result.expungedUids !== undefined) {
90
+ result.expungedUids = data.result.expungedUids;
91
+ }
92
+ entry.resolve(result);
93
+ return;
94
+ }
95
+ entry.reject(pending.rejectedError(data.requestId, data.result.reason));
96
+ },
97
+ cancelAll(reason) {
98
+ pending.cancelAll(reason);
99
+ },
100
+ };
101
+ }
@@ -0,0 +1,17 @@
1
+ import type { MailboxEvent, Unsubscribe } from "@intx/types/runtime";
2
+ export interface MailboxWatchRegistry {
3
+ /**
4
+ * Register a callback for a mailbox. Returns an `Unsubscribe` that removes
5
+ * it; after unsubscribe the callback observes no further events, including
6
+ * one whose `fire` preceded the unsubscribe but whose asynchronous delivery
7
+ * had not yet run.
8
+ */
9
+ watch(mailbox: string, callback: (event: MailboxEvent) => void): Unsubscribe;
10
+ /**
11
+ * Deliver a `MailboxEvent` to every callback currently registered for the
12
+ * mailbox, each on its own microtask. A no-op when no callback is registered
13
+ * for the mailbox.
14
+ */
15
+ fire(mailbox: string, event: MailboxEvent): void;
16
+ }
17
+ export declare function createMailboxWatchRegistry(): MailboxWatchRegistry;
@@ -0,0 +1,61 @@
1
+ // Child-side mailbox watch registry (INBOUND half of mailbox ownership,
2
+ // design §3b).
3
+ //
4
+ // The supervisor is the sole mail owner: it commits an arrived message to the
5
+ // workflow-run substrate mailbox and fires a `mailbox.notify` control frame.
6
+ // The child's control loop routes that frame to this registry's `fire`, which
7
+ // delivers a typed `exists` `MailboxEvent` to every callback registered for the
8
+ // mailbox through `watch`. The step agent's supervisor-backed transport
9
+ // implements `MessageTransport.watch` over this registry, so `mail_wait`
10
+ // unblocks when new mail lands -- decoupled from the FIFO trigger dispatch that
11
+ // resolves a run's first input.
12
+ //
13
+ // Delivery is ASYNCHRONOUS. A `fire` never invokes a callback synchronously on
14
+ // the delivering call stack: it schedules each callback on a microtask, per the
15
+ // IMAP IDLE contract the `MailboxEvent` watcher models (MESSAGE.md § Real-Time
16
+ // Notification). Delivery re-checks registration at the microtask, so a watcher
17
+ // that unsubscribes between `fire` and delivery observes no event.
18
+ export function createMailboxWatchRegistry() {
19
+ const watchers = new Map();
20
+ return {
21
+ watch(mailbox, callback) {
22
+ let set = watchers.get(mailbox);
23
+ if (set === undefined) {
24
+ set = new Set();
25
+ watchers.set(mailbox, set);
26
+ }
27
+ set.add(callback);
28
+ let active = true;
29
+ return () => {
30
+ // Idempotent: a double-unsubscribe must not remove a same-identity
31
+ // callback a later `watch` re-registered.
32
+ if (!active)
33
+ return;
34
+ active = false;
35
+ const current = watchers.get(mailbox);
36
+ if (current === undefined)
37
+ return;
38
+ current.delete(callback);
39
+ if (current.size === 0)
40
+ watchers.delete(mailbox);
41
+ };
42
+ },
43
+ fire(mailbox, event) {
44
+ const set = watchers.get(mailbox);
45
+ if (set === undefined)
46
+ return;
47
+ // Snapshot the callbacks registered at fire time, then deliver each on
48
+ // its own microtask so no callback runs synchronously on this call
49
+ // stack. Re-check membership at delivery so a callback unsubscribed
50
+ // between now and its microtask does not receive the event.
51
+ for (const callback of [...set]) {
52
+ queueMicrotask(() => {
53
+ const current = watchers.get(mailbox);
54
+ if (current === undefined || !current.has(callback))
55
+ return;
56
+ callback(event);
57
+ });
58
+ }
59
+ },
60
+ };
61
+ }
@@ -30,7 +30,8 @@ export interface CreateChildOutboundMailBridgeOpts {
30
30
  }
31
31
  /**
32
32
  * Construct the child-side outbound-mail bridge. Pending sends live in
33
- * a map keyed by `requestId`; the bridge resolves the awaiter when the
34
- * supervisor's matching `outbound.result` lands.
33
+ * the shared pending-request core keyed by `requestId`; the bridge
34
+ * resolves the awaiter when the supervisor's matching `outbound.result`
35
+ * lands.
35
36
  */
36
37
  export declare function createChildOutboundMailBridge(opts: CreateChildOutboundMailBridgeOpts): ChildOutboundMailBridge;
@@ -32,24 +32,26 @@
32
32
  // send.
33
33
  import { getLogger } from "@intx/log";
34
34
  import { base64Encode } from "@intx/types";
35
+ import { createPendingRequestCore } from "./pending-request.js";
35
36
  const logger = getLogger(["workflow-host", "child", "outbound-mail-bridge"]);
36
37
  /**
37
38
  * Construct the child-side outbound-mail bridge. Pending sends live in
38
- * a map keyed by `requestId`; the bridge resolves the awaiter when the
39
- * supervisor's matching `outbound.result` lands.
39
+ * the shared pending-request core keyed by `requestId`; the bridge
40
+ * resolves the awaiter when the supervisor's matching `outbound.result`
41
+ * lands.
40
42
  */
41
43
  export function createChildOutboundMailBridge(opts) {
42
- const pending = new Map();
43
- const allocate = opts.allocateRequestId ?? defaultRequestIdAllocator();
44
+ const pending = createPendingRequestCore({
45
+ label: "workflow-child outbound mail",
46
+ allocatorPrefix: "om",
47
+ allocateRequestId: opts.allocateRequestId,
48
+ });
44
49
  return {
45
50
  get pendingCount() {
46
- return pending.size;
51
+ return pending.pendingCount;
47
52
  },
48
53
  async submit(senderAddress, message) {
49
- const requestId = allocate();
50
- const resultPromise = new Promise((resolve, reject) => {
51
- pending.set(requestId, { resolve, reject });
52
- });
54
+ const { requestId, promise } = pending.register(undefined);
53
55
  try {
54
56
  await opts.upstreamSender.send({
55
57
  type: "outbound.message",
@@ -61,19 +63,17 @@ export function createChildOutboundMailBridge(opts) {
61
63
  });
62
64
  }
63
65
  catch (cause) {
64
- pending.delete(requestId);
65
- const reason = cause instanceof Error ? cause.message : String(cause);
66
- throw new Error(`workflow-child outbound mail: upstream send failed for requestId ${requestId}: ${reason}`, { cause });
66
+ pending.discard(requestId);
67
+ throw pending.sendFailedError(requestId, cause);
67
68
  }
68
- return resultPromise;
69
+ return promise;
69
70
  },
70
71
  handleResult(data) {
71
- const entry = pending.get(data.requestId);
72
+ const entry = pending.settle(data.requestId);
72
73
  if (entry === undefined) {
73
74
  logger.warn `outbound.result landed with no pending entry; requestId=${data.requestId} dropped`;
74
75
  return;
75
76
  }
76
- pending.delete(data.requestId);
77
77
  if (data.result.ok) {
78
78
  entry.resolve({
79
79
  messageId: data.result.messageId,
@@ -81,13 +81,10 @@ export function createChildOutboundMailBridge(opts) {
81
81
  });
82
82
  return;
83
83
  }
84
- entry.reject(new Error(`workflow-child outbound mail (requestId=${data.requestId}) rejected by supervisor: ${data.result.reason}`));
84
+ entry.reject(pending.rejectedError(data.requestId, data.result.reason));
85
85
  },
86
86
  cancelAll(reason) {
87
- for (const [requestId, entry] of pending) {
88
- entry.reject(new Error(`workflow-child outbound mail (requestId=${requestId}) cancelled: ${reason}`));
89
- }
90
- pending.clear();
87
+ pending.cancelAll(reason);
91
88
  },
92
89
  };
93
90
  }
@@ -114,6 +111,8 @@ function projectOutboundMessage(message) {
114
111
  payload.summary = message.summary;
115
112
  if (message.inReplyTo !== undefined)
116
113
  payload.inReplyTo = message.inReplyTo;
114
+ if (message.references !== undefined)
115
+ payload.references = message.references;
117
116
  if (message.correlationId !== undefined) {
118
117
  payload.correlationId = message.correlationId;
119
118
  }
@@ -125,19 +124,8 @@ function projectOutboundMessage(message) {
125
124
  payload.attachments = message.attachments.map((a) => ({
126
125
  name: a.name,
127
126
  contentType: a.contentType,
128
- dataBase64: bytesToBase64(a.data),
127
+ dataBase64: base64Encode(a.data),
129
128
  }));
130
129
  }
131
130
  return payload;
132
131
  }
133
- function bytesToBase64(bytes) {
134
- return base64Encode(bytes);
135
- }
136
- function defaultRequestIdAllocator() {
137
- let counter = 0;
138
- return () => {
139
- counter += 1;
140
- const rand = Math.random().toString(36).slice(2, 10);
141
- return `om-${String(counter)}-${rand}`;
142
- };
143
- }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * A live pending entry as returned by {@link PendingRequestCore.get} and
3
+ * {@link PendingRequestCore.settle}. `meta` is the per-bridge payload the
4
+ * entry was registered with (e.g. the substrate-write request whose merge
5
+ * closure the merge round-trip invokes).
6
+ */
7
+ export type PendingEntryHandle<Value, Meta> = {
8
+ meta: Meta;
9
+ resolve: (value: Value) => void;
10
+ reject: (err: Error) => void;
11
+ };
12
+ /**
13
+ * Options for {@link createPendingRequestCore}. `label` prefixes every
14
+ * error the core builds (`<label>: upstream send failed for requestId
15
+ * <id>: ...`, `<label> (requestId=<id>) rejected by supervisor: ...`,
16
+ * `<label> (requestId=<id>) cancelled: ...`, `<label>: no pending entry
17
+ * for requestId <id>`), so each bridge's observable error strings stay
18
+ * its own. `allocatorPrefix` seeds the default requestId allocator
19
+ * (`<prefix>-<counter>-<rand>`); tests inject `allocateRequestId` for a
20
+ * deterministic id.
21
+ */
22
+ export type PendingRequestCoreOptions = {
23
+ label: string;
24
+ allocatorPrefix: string;
25
+ /** `undefined` is accepted explicitly so callers can forward an optional option verbatim. */
26
+ allocateRequestId?: (() => string) | undefined;
27
+ };
28
+ /**
29
+ * The pending-request lifecycle surface a bridge composes into its own
30
+ * `submit` / `handle*` / `cancelAll` methods.
31
+ */
32
+ export type PendingRequestCore<Value, Meta = undefined> = {
33
+ /** Number of registered-but-unsettled requests. */
34
+ readonly pendingCount: number;
35
+ /**
36
+ * Mint a requestId, register an awaiter keyed by it, and return the id
37
+ * plus the promise the bridge returns from its `submit`. The entry
38
+ * stays pending until `settle` or `cancelAll` acts on it, or `discard`
39
+ * removes it without settling (the upstream-send-failure path).
40
+ */
41
+ register(meta: Meta): {
42
+ requestId: string;
43
+ promise: Promise<Value>;
44
+ };
45
+ /**
46
+ * Remove a pending entry without settling its promise. Used when the
47
+ * upstream send itself fails, so the abandoned awaiter does not leak.
48
+ */
49
+ discard(requestId: string): void;
50
+ /**
51
+ * Look up a pending entry WITHOUT removing it. Used by the
52
+ * substrate-write bridge's merge round-trip, which must reach the
53
+ * entry's merge closure while the entry stays alive for the terminal
54
+ * write response.
55
+ */
56
+ get(requestId: string): PendingEntryHandle<Value, Meta> | undefined;
57
+ /**
58
+ * Look up a pending entry and remove it. Used by the response-frame
59
+ * handlers: the entry is gone before its promise resolves/rejects, so
60
+ * a later stale response or `cancelAll` finds nothing to act on.
61
+ */
62
+ settle(requestId: string): PendingEntryHandle<Value, Meta> | undefined;
63
+ /**
64
+ * Reject every pending entry with `<label> (requestId=<id>) cancelled:
65
+ * <reason>` and clear the map. The control loop invokes this on any
66
+ * exit path so no awaiter leaks when the supervisor has torn the IPC
67
+ * down.
68
+ */
69
+ cancelAll(reason: string): void;
70
+ /** `<label>: upstream send failed for requestId <id>: <cause message>` with `{ cause }`. */
71
+ sendFailedError(requestId: string, cause: unknown): Error;
72
+ /** `<label> (requestId=<id>) rejected by supervisor: <reason>`. */
73
+ rejectedError(requestId: string, reason: string): Error;
74
+ /** `<label>: no pending entry for requestId <id>` (substrate merge failure reply). */
75
+ noPendingError(requestId: string): Error;
76
+ };
77
+ /**
78
+ * Construct a pending-request core for one bridge. Pending entries live in
79
+ * a map keyed by `requestId`; the bridge's response handler settles the
80
+ * awaiter when the matching response frame lands.
81
+ */
82
+ export declare function createPendingRequestCore<Value, Meta = undefined>(opts: PendingRequestCoreOptions): PendingRequestCore<Value, Meta>;
83
+ /**
84
+ * Default `requestId` allocator: a per-instance monotonic counter plus a
85
+ * random suffix, so ids are unique across bridge instances without any
86
+ * cross-instance coordination. The `prefix` names the owning bridge
87
+ * (`sw-`, `om-`, `mm-`) for triage in supervisor logs.
88
+ */
89
+ export declare function defaultRequestIdAllocator(prefix: string): () => string;
@@ -0,0 +1,80 @@
1
+ // Generic pending-request core shared by the three child-side control-IPC
2
+ // bridges (substrate-write, outbound-mail, mailbox-mutation).
3
+ //
4
+ // Every bridge runs the same request/response round-trip over the control
5
+ // channel: `submit` mints a `requestId`, registers a pending awaiter keyed
6
+ // by that id, emits a request frame upstream, and resolves/rejects the
7
+ // awaiter when the supervisor's matching response frame lands. The three
8
+ // bridges differ only in the request payload they send, the value they
9
+ // resolve, the label their error messages carry, and (for the
10
+ // substrate-write bridge) an intermediate merge round-trip that must peek
11
+ // at the pending entry without settling it.
12
+ //
13
+ // This core owns that lifecycle in one place so the per-bridge files
14
+ // contain only their wire-specific parts. It never touches the control
15
+ // channel itself: each bridge performs its own `upstreamSender.send` so
16
+ // the frame payloads stay byte-identical to what the supervisor's
17
+ // `ControlPayload` validator expects.
18
+ /**
19
+ * Construct a pending-request core for one bridge. Pending entries live in
20
+ * a map keyed by `requestId`; the bridge's response handler settles the
21
+ * awaiter when the matching response frame lands.
22
+ */
23
+ export function createPendingRequestCore(opts) {
24
+ const pending = new Map();
25
+ const allocate = opts.allocateRequestId ?? defaultRequestIdAllocator(opts.allocatorPrefix);
26
+ return {
27
+ get pendingCount() {
28
+ return pending.size;
29
+ },
30
+ register(meta) {
31
+ const requestId = allocate();
32
+ const promise = new Promise((resolve, reject) => {
33
+ pending.set(requestId, { meta, resolve, reject });
34
+ });
35
+ return { requestId, promise };
36
+ },
37
+ discard(requestId) {
38
+ pending.delete(requestId);
39
+ },
40
+ get(requestId) {
41
+ return pending.get(requestId);
42
+ },
43
+ settle(requestId) {
44
+ const entry = pending.get(requestId);
45
+ if (entry !== undefined)
46
+ pending.delete(requestId);
47
+ return entry;
48
+ },
49
+ cancelAll(reason) {
50
+ for (const [requestId, entry] of pending) {
51
+ entry.reject(new Error(`${opts.label} (requestId=${requestId}) cancelled: ${reason}`));
52
+ }
53
+ pending.clear();
54
+ },
55
+ sendFailedError(requestId, cause) {
56
+ const reason = cause instanceof Error ? cause.message : String(cause);
57
+ return new Error(`${opts.label}: upstream send failed for requestId ${requestId}: ${reason}`, { cause });
58
+ },
59
+ rejectedError(requestId, reason) {
60
+ return new Error(`${opts.label} (requestId=${requestId}) rejected by supervisor: ${reason}`);
61
+ },
62
+ noPendingError(requestId) {
63
+ return new Error(`${opts.label}: no pending entry for requestId ${requestId}`);
64
+ },
65
+ };
66
+ }
67
+ /**
68
+ * Default `requestId` allocator: a per-instance monotonic counter plus a
69
+ * random suffix, so ids are unique across bridge instances without any
70
+ * cross-instance coordination. The `prefix` names the owning bridge
71
+ * (`sw-`, `om-`, `mm-`) for triage in supervisor logs.
72
+ */
73
+ export function defaultRequestIdAllocator(prefix) {
74
+ let counter = 0;
75
+ return () => {
76
+ counter += 1;
77
+ const rand = Math.random().toString(36).slice(2, 10);
78
+ return `${prefix}-${String(counter)}-${rand}`;
79
+ };
80
+ }
@@ -1,8 +1,8 @@
1
1
  import type { Principal, RepoId, RepoStore as SubstrateRepoStore } from "@intx/hub-sessions/substrate";
2
2
  import type { AuthzCallResult } from "@intx/inference";
3
- import type { RunResult, Scheduler, ReadParkedApprovalOps, StepInvokeRequest, StepInvokeResult, SpawnChildWorkflow, WorkflowAuthorizeFn, WorkflowPark } from "@intx/workflow";
3
+ import type { RunResult, Scheduler, ReadParkedApprovalOps, StepInvokeRequest, StepInvokeResult, SpawnChildWorkflow, WorkflowAuthorizeFn, WorkflowDefinition, WorkflowPark } from "@intx/workflow";
4
4
  import { type WorkflowHostDrainController } from "../drain-controller.js";
5
- import type { InferenceSource } from "@intx/types/runtime";
5
+ import type { InferenceSource, MailPartReader } from "@intx/types/runtime";
6
6
  import type { CredentialDelivery } from "@intx/types/sidecar";
7
7
  import type { RunSuspendableChild, RunChildWorkflow } from "../adapters/spawn-child.js";
8
8
  import { type ControlChannelSender, type ControlPayload, type EventPayload, type FrameWriter, type NdjsonReader, type NdjsonWriter } from "../ipc/index.js";
@@ -11,6 +11,8 @@ import { hashGrants } from "../supervisor/credentials.js";
11
11
  import type { SpawnTimeEnv } from "./env-bootstrap.js";
12
12
  import { type LoadParkedApproval } from "./parked-correlations.js";
13
13
  import type { ChildOutboundMailBridge } from "./outbound-mail-bridge.js";
14
+ import type { ChildMailboxMutationBridge } from "./mailbox-mutation-bridge.js";
15
+ import type { MailboxWatchRegistry } from "./mailbox-watch-registry.js";
14
16
  import { type WarmAgentCache } from "./warm-agent-cache.js";
15
17
  /**
16
18
  * `WorkflowAuthorize` closure factory shape. The child's authorize
@@ -31,10 +33,12 @@ export type CredentialsSnapshotRef = {
31
33
  };
32
34
  /**
33
35
  * The deployment's decrypted credential material and per-handle descriptors,
34
- * held through a mutable reference and swapped wholesale on a rotation push (a
35
- * revoked credential arrives by omission, so the swap evicts it). The secret
36
- * lives ONLY here -- read at tool-invoke time through the gated capability --
37
- * and is never copied into a snapshot, event, or state.
36
+ * held through a mutable reference. A `credentials-updated` frame MERGES into
37
+ * this cell (materials by credentialId, bindings by (consumer, handle); an
38
+ * explicit `revoke` list drops entries) rather than replacing it wholesale,
39
+ * because the cell has several independently-scoped producers. The secret lives
40
+ * ONLY here -- read at tool-invoke time through the gated capability -- and is
41
+ * never copied into a snapshot, event, or state.
38
42
  */
39
43
  export type CredentialMaterialRef = {
40
44
  current: CredentialDelivery | null;
@@ -121,7 +125,7 @@ export interface CredentialWiring {
121
125
  readonly materialRef: CredentialMaterialRef;
122
126
  readonly resolveStepGrants: (stepId: string) => readonly unknown[];
123
127
  }
124
- export type ChildStepInvoker = (req: StepInvokeRequest, onEvent: (event: EventPayload) => void, authorize: WorkflowAuthorizeFn, warmCache: WarmAgentCache | undefined, sourcesRef: SourcesSnapshotRef, credentialWiring: CredentialWiring) => Promise<StepInvokeResult>;
128
+ export type ChildStepInvoker = (req: StepInvokeRequest, onEvent: (event: EventPayload) => void, authorize: WorkflowAuthorizeFn, warmCache: WarmAgentCache | undefined, sourcesRef: SourcesSnapshotRef, credentialWiring: CredentialWiring, mailPartReader: MailPartReader) => Promise<StepInvokeResult>;
125
129
  /**
126
130
  * Bindings the binary owns: per-deployment substrate identity,
127
131
  * principal credentials, the runtime-supplied callbacks the
@@ -180,6 +184,24 @@ export interface RunWorkflowChildBindings {
180
184
  * that runs no onTrigger section omits it.
181
185
  */
182
186
  runSuspendableChild?: RunSuspendableChild;
187
+ /**
188
+ * Materialize a loop iteration run's own `runs/<childRunId>/grants.json`,
189
+ * inheriting the parent (container) run's grants capped to the loop body's
190
+ * declared resources. A loop iteration runs through the workflow-host-local
191
+ * loop executor (the inherited env), NOT the sidecar's `buildChildRunEnv`, so
192
+ * it is the one body birth path that does not otherwise write its own grants
193
+ * file -- and without it the body's `childWorkflow` grandchild spawn is
194
+ * refused as under-authorized. Wired by the sidecar (the grant-cap helpers
195
+ * live there); optional so a test or an in-process host that never spawns a
196
+ * grandchild from a loop body can omit it. `definition` MUST be the PRE-rewrite
197
+ * loop body (grandchild still inline) so the cap keeps the grandchild's
198
+ * declared resources.
199
+ */
200
+ materializeLoopIterationGrants?: (args: {
201
+ parentRunId: string;
202
+ childRunId: string;
203
+ definition: WorkflowDefinition;
204
+ }) => Promise<void>;
183
205
  /** Host-process scheduler singleton. The child consumes the same instance. */
184
206
  scheduler: Scheduler;
185
207
  /** Grant evaluator wired against the host's grant-rule grammar. */
@@ -225,6 +247,19 @@ export interface RunWorkflowChildBindings {
225
247
  * invocation step settles as a terminal failure, the pre-recovery behavior.
226
248
  */
227
249
  readParkedApprovalOps?: ReadParkedApprovalOps;
250
+ /**
251
+ * Mailbox watch registry backing the warm agent's `mail_wait` (INBOUND half
252
+ * of mailbox ownership, §3b). The host's substrate factory builds ONE
253
+ * instance at child boot, shares it with the step agent's supervisor-backed
254
+ * transport (whose `watch` registers callbacks into it), and exposes it here
255
+ * so the control loop routes each `mailbox.notify` frame to the same
256
+ * registry's `fire`. Optional: a deploy that wires no mail surface (and the
257
+ * recursive child-workflow adapter) omits it, and an inbound `mailbox.notify`
258
+ * frame is then logged and dropped. A test may instead inject a registry
259
+ * directly through `RunWorkflowChildOpts.mailboxWatchRegistry`, which takes
260
+ * precedence.
261
+ */
262
+ mailboxWatchRegistry?: MailboxWatchRegistry;
228
263
  /** Optional clock override; production wires `() => new Date()`. */
229
264
  clock?: () => Date;
230
265
  /** Optional id generator override; production wires a monotonic one. */
@@ -328,6 +363,33 @@ export interface RunWorkflowChildOpts {
328
363
  * but no agent on the child side asked for an outbound send.
329
364
  */
330
365
  outboundMailBridge?: ChildOutboundMailBridge;
366
+ /**
367
+ * Optional mailbox-mutation bridge (INBOUND half of mailbox ownership,
368
+ * §3b). The step agent's mail tools mutate the INBOX -- flag writes and
369
+ * `expunge` -- through a transport whose write methods route through
370
+ * this bridge: it emits a `mailbox.mutate.request` upstream control
371
+ * frame and resolves once the supervisor's matching
372
+ * `mailbox.mutate.response` lands. The control loop routes the
373
+ * downstream response frame to the bridge's `handleResult` and invokes
374
+ * `cancelAll` on any exit path so a pending mutation does not leak an
375
+ * awaiter after the supervisor tears the IPC down. When omitted,
376
+ * inbound `mailbox.mutate.response` frames are logged at warn-level and
377
+ * dropped -- the wire shape is well-formed but no agent on the child
378
+ * side asked for a mutation.
379
+ */
380
+ mailboxMutationBridge?: ChildMailboxMutationBridge;
381
+ /**
382
+ * Optional mailbox watch registry (INBOUND half of mailbox ownership,
383
+ * design §3b). The supervisor -- the sole mail owner -- commits an arrived
384
+ * message to the workflow-run substrate mailbox and fires a `mailbox.notify`
385
+ * control frame; the control loop routes that frame to this registry's
386
+ * `fire`, which delivers a typed `exists` `MailboxEvent` to the callbacks the
387
+ * step agent's supervisor-backed transport registered through `watch`
388
+ * (backing `mail_wait`). When omitted, an inbound `mailbox.notify` frame is
389
+ * logged at warn-level and dropped -- the wire shape is well-formed but no
390
+ * watcher on the child side asked for inbound events.
391
+ */
392
+ mailboxWatchRegistry?: MailboxWatchRegistry;
331
393
  }
332
394
  /**
333
395
  * Narrow interface the child's control loop calls when downstream