@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
@@ -35,11 +35,13 @@
35
35
  // the closure produces, both base64-encoded.
36
36
  import { getLogger } from "@intx/log";
37
37
  import { base64Decode, base64Encode } from "@intx/types";
38
+ import { createPendingRequestCore } from "./pending-request.js";
38
39
  const logger = getLogger(["workflow-host", "child", "substrate-write-bridge"]);
39
40
  /**
40
41
  * Construct the child-side substrate-write bridge. Pending writes
41
- * live in a map keyed by `requestId`; the bridge resolves the awaiter
42
- * when the supervisor's matching `substrate.write.response` lands.
42
+ * live in the shared pending-request core keyed by `requestId`; the
43
+ * bridge resolves the awaiter when the supervisor's matching
44
+ * `substrate.write.response` lands.
43
45
  *
44
46
  * The supervisor may emit zero or more `substrate.merge.request`
45
47
  * frames per pending write (the supervisor's merge callback may run
@@ -49,17 +51,17 @@ const logger = getLogger(["workflow-host", "child", "substrate-write-bridge"]);
49
51
  * pending entry stays alive until the terminal write response lands.
50
52
  */
51
53
  export function createChildSubstrateWriteBridge(opts) {
52
- const pending = new Map();
53
- const allocate = opts.allocateRequestId ?? defaultRequestIdAllocator();
54
+ const pending = createPendingRequestCore({
55
+ label: "workflow-child substrate write",
56
+ allocatorPrefix: "sw",
57
+ allocateRequestId: opts.allocateRequestId,
58
+ });
54
59
  return {
55
60
  get pendingCount() {
56
- return pending.size;
61
+ return pending.pendingCount;
57
62
  },
58
63
  async submit(req) {
59
- const requestId = allocate();
60
- const resultPromise = new Promise((resolve, reject) => {
61
- pending.set(requestId, { req, resolve, reject });
62
- });
64
+ const { requestId, promise } = pending.register(req);
63
65
  try {
64
66
  await opts.upstreamSender.send({
65
67
  type: "substrate.write.request",
@@ -73,11 +75,10 @@ export function createChildSubstrateWriteBridge(opts) {
73
75
  });
74
76
  }
75
77
  catch (cause) {
76
- pending.delete(requestId);
77
- const message = cause instanceof Error ? cause.message : String(cause);
78
- throw new Error(`workflow-child substrate write: upstream send failed for requestId ${requestId}: ${message}`, { cause });
78
+ pending.discard(requestId);
79
+ throw pending.sendFailedError(requestId, cause);
79
80
  }
80
- return resultPromise;
81
+ return promise;
81
82
  },
82
83
  handleMergeRequest(data) {
83
84
  const entry = pending.get(data.requestId);
@@ -93,7 +94,7 @@ export function createChildSubstrateWriteBridge(opts) {
93
94
  requestId: data.requestId,
94
95
  result: {
95
96
  ok: false,
96
- reason: `workflow-child substrate write: no pending entry for requestId ${data.requestId}`,
97
+ reason: pending.noPendingError(data.requestId).message,
97
98
  },
98
99
  },
99
100
  })
@@ -106,7 +107,7 @@ export function createChildSubstrateWriteBridge(opts) {
106
107
  void (async () => {
107
108
  try {
108
109
  const existing = decodeMergeRequest(data.existing);
109
- const merged = await entry.req.merge(existing);
110
+ const merged = await entry.meta.merge(existing);
110
111
  const files = encodeFiles(merged);
111
112
  await opts.upstreamSender.send({
112
113
  type: "substrate.merge.response",
@@ -137,30 +138,26 @@ export function createChildSubstrateWriteBridge(opts) {
137
138
  })();
138
139
  },
139
140
  handleWriteResponse(data) {
140
- const entry = pending.get(data.requestId);
141
+ const entry = pending.settle(data.requestId);
141
142
  if (entry === undefined) {
142
143
  logger.warn `substrate.write.response landed with no pending entry; requestId=${data.requestId} dropped`;
143
144
  return;
144
145
  }
145
- pending.delete(data.requestId);
146
146
  if (data.result.ok) {
147
147
  entry.resolve({ commitSha: data.result.commitSha });
148
148
  return;
149
149
  }
150
- entry.reject(new Error(`workflow-child substrate write (requestId=${data.requestId}) rejected by supervisor: ${data.result.reason}`));
150
+ entry.reject(pending.rejectedError(data.requestId, data.result.reason));
151
151
  },
152
152
  cancelAll(reason) {
153
- for (const [requestId, entry] of pending) {
154
- entry.reject(new Error(`workflow-child substrate write (requestId=${requestId}) cancelled: ${reason}`));
155
- }
156
- pending.clear();
153
+ pending.cancelAll(reason);
157
154
  },
158
155
  };
159
156
  }
160
157
  function decodeMergeRequest(existing) {
161
158
  const out = new Map();
162
159
  for (const entry of existing) {
163
- out.set(entry.path, base64ToBytes(entry.contentBase64));
160
+ out.set(entry.path, base64Decode(entry.contentBase64));
164
161
  }
165
162
  return out;
166
163
  }
@@ -168,21 +165,7 @@ function encodeFiles(files) {
168
165
  const out = [];
169
166
  for (const [path, content] of Object.entries(files)) {
170
167
  const bytes = typeof content === "string" ? new TextEncoder().encode(content) : content;
171
- out.push({ path, contentBase64: bytesToBase64(bytes) });
168
+ out.push({ path, contentBase64: base64Encode(bytes) });
172
169
  }
173
170
  return out;
174
171
  }
175
- function bytesToBase64(bytes) {
176
- return base64Encode(bytes);
177
- }
178
- function base64ToBytes(value) {
179
- return base64Decode(value);
180
- }
181
- function defaultRequestIdAllocator() {
182
- let counter = 0;
183
- return () => {
184
- counter += 1;
185
- const rand = Math.random().toString(36).slice(2, 10);
186
- return `sw-${String(counter)}-${rand}`;
187
- };
188
- }
@@ -1,10 +1,56 @@
1
- import type { MessageTransport } from "@intx/types/runtime";
1
+ import type { CryptoProvider, MessageTransport } from "@intx/types/runtime";
2
+ import type { ChildMailboxReader } from "./child-mailbox-reader.js";
3
+ import type { ChildMailboxMutationBridge } from "./mailbox-mutation-bridge.js";
4
+ import type { MailboxWatchRegistry } from "./mailbox-watch-registry.js";
2
5
  import type { ChildOutboundMailBridge } from "./outbound-mail-bridge.js";
6
+ /**
7
+ * The dependencies backing the transport's whole inbox capability: the local
8
+ * IMAP READ surface (`reader` / `watchRegistry` / `getCrypto`) that resolves
9
+ * against the deployment's substrate mailbox, plus the routed-WRITE channel
10
+ * (`mutationBridge`) that carries flag writes and expunge up to the supervisor.
11
+ * The sidecar wires the bundle only for a build that owns an inbound mailbox
12
+ * (the warm agent); a build without it has no inbox and every inbound method
13
+ * throws a clear "not wired" error. Reads are local; writes route upstream --
14
+ * the child never flushes the run ref, so it never races the supervisor.
15
+ */
16
+ export interface SupervisorBackedTransportInbound {
17
+ /**
18
+ * Opens a fresh committed snapshot of the deployment's substrate `INBOX`.
19
+ * Every inbound read opens a new snapshot, so a read taken after a
20
+ * `mailbox.notify` -- or after a routed write the supervisor flushed before
21
+ * replying -- observes the committed state.
22
+ */
23
+ reader: ChildMailboxReader;
24
+ /**
25
+ * The registry the child's control loop fires `mailbox.notify` into. It must
26
+ * be the same instance `runWorkflowChild` routes the frame to, so a `watch`
27
+ * installed here observes the supervisor's notification.
28
+ */
29
+ watchRegistry: MailboxWatchRegistry;
30
+ /**
31
+ * Resolve a sender address to its `CryptoProvider` so `fetchFull` can verify
32
+ * the message signature. Returns `undefined` when no key is known for the
33
+ * sender, in which case the signature status is reported as `unknown`.
34
+ */
35
+ getCrypto: (fromAddress: string) => CryptoProvider | undefined;
36
+ /**
37
+ * The upstream channel the write methods route through. `setFlags` /
38
+ * `clearFlags` / `expunge` call `mutationBridge.submit`, which emits a
39
+ * `mailbox.mutate.request` and resolves once the supervisor applies the
40
+ * mutation to its owned store and replies. Bundled with the read surface
41
+ * because the write methods and the reads share one presence condition:
42
+ * this agent owns an inbox, or it owns none.
43
+ */
44
+ mutationBridge: ChildMailboxMutationBridge;
45
+ }
3
46
  /**
4
47
  * Construct a `MessageTransport` whose outbound side routes through the
5
- * supervisor (via `bridge`) and whose inbound side is inert. `address`
6
- * is the agent's mail address; the supervisor signs the outbound mail as
7
- * this address through the host transport, so it must be the address the
8
- * host registered the agent's `CryptoProvider` against.
48
+ * supervisor (via `bridge`) and whose inbound side is a local IMAP read
49
+ * surface over `inbound`. `address` is the agent's mail address; the
50
+ * supervisor signs the outbound mail as this address through the host
51
+ * transport, so it must be the address the host registered the agent's
52
+ * `CryptoProvider` against. When `inbound` is omitted, the inbound methods
53
+ * throw a clear "not wired" error; the sidecar supplies it once the child's
54
+ * mailbox reader and watch registry are threaded through.
9
55
  */
10
- export declare function createSupervisorBackedTransport(bridge: ChildOutboundMailBridge, address: string): MessageTransport;
56
+ export declare function createSupervisorBackedTransport(bridge: ChildOutboundMailBridge, address: string, inbound?: SupervisorBackedTransportInbound): MessageTransport;
@@ -1,35 +1,80 @@
1
1
  // Supervisor-backed `MessageTransport` for a unified-host step agent
2
- // (OUTBOUND half of mailbox ownership, §3a).
2
+ // (both halves of mailbox ownership, §3a OUTBOUND and §3b INBOUND).
3
3
  //
4
4
  // Under the unified host the supervisor is the sole mail owner: it holds
5
5
  // the durable inbox and the host transport against which the agent's
6
6
  // address is registered with its signing key. The step agent therefore
7
- // does NOT subscribe its own transport for inbound mail (the supervisor
8
- // delivers inputs via the step path) and does NOT hold a signing key to
9
- // send outbound mail. Its mail tools are backed by this transport:
7
+ // does NOT hold a signing key to send outbound mail, and it does NOT own
8
+ // the host-side inbox directly. Its mail tools are backed by this
9
+ // transport:
10
10
  //
11
- // - INBOUND is a no-op. `watch` returns a no-op unsubscribe and never
12
- // fires; the supervisor delivers the agent's input as the step
13
- // input, not through the agent's own mailbox. The IMAP read surface
14
- // (`search`, `fetchFull`, `fetchHeaders`, ...) throws: the agent
15
- // owns no mailbox in the unified host, so a read against one is a
16
- // programming error, surfaced loudly rather than returning a
17
- // silently-empty result that would hide the missing inbound surface.
18
- // - OUTBOUND (`send` / `append`) routes through the supervisor over
19
- // the control IPC via the outbound-mail bridge. The supervisor
20
- // performs the actual signed send through the host transport, so the
21
- // outbound mail carries the agent's signature with full parity to
22
- // the in-process path. The agent never holds the key.
11
+ // - INBOUND is a functional local IMAP read surface once the sidecar
12
+ // wires it (the `inbound` constructor argument). The supervisor
13
+ // commits an arrived message to the deployment's workflow-run
14
+ // substrate mailbox (`mailbox/INBOX/`) and fires a `mailbox.notify`
15
+ // control frame. The read surface (`search`, `thread`,
16
+ // `fetchHeaders`, `fetchStructure`, `fetchPart`, `fetchFull`, `sync`,
17
+ // `getMailboxStatus`) answers by opening a fresh committed snapshot of
18
+ // that mailbox through the child mailbox reader and running the
19
+ // `@intx/mailbox` pure query functions over it -- local, no hub or
20
+ // IPC round-trip. `watch` registers into the child watch registry, so
21
+ // `mail_wait` unblocks when the routed `mailbox.notify` fires. The
22
+ // WRITE methods (`setFlags` / `clearFlags` / `expunge`) do NOT touch
23
+ // the local read surface: they route up to the supervisor through the
24
+ // mailbox-mutation bridge (a `mailbox.mutate.request` frame), which
25
+ // applies the mutation to the supervisor's owned store and replies.
26
+ // The child never flushes the run ref, so it never races the
27
+ // supervisor's mirror. The agent owns only the `INBOX`, so every
28
+ // inbound method rejects a request for any other mailbox rather than
29
+ // silently serving `INBOX` under the wrong name. When the sidecar
30
+ // constructs the transport without the `inbound` argument, the inbound
31
+ // methods throw a clear "not wired" error rather than answer against a
32
+ // missing surface.
33
+ // - OUTBOUND (`send`) routes through the supervisor over the control
34
+ // IPC via the outbound-mail bridge. The supervisor performs the
35
+ // actual signed send through the host transport, so the outbound mail
36
+ // carries the agent's signature with full parity to the in-process
37
+ // path. The agent never holds the key.
38
+ //
39
+ // A handful of methods stay unsupported and throw: they act on a resource
40
+ // the unified-host agent does not own. `append` and the mailbox-management
41
+ // methods (`listMailboxes` / `createMailbox` / `deleteMailbox`) target a
42
+ // mailbox the agent does not own; `move` / `copy` need a second mailbox it
43
+ // does not own; and the distribution-list methods are unimplemented across
44
+ // every transport.
45
+ import { executeSearch, executeThread, fetchFull as doFetchFull, fetchHeaders as doFetchHeaders, fetchPart as doFetchPart, fetchStructure as doFetchStructure, } from "@intx/mailbox";
46
+ import { deriveWorkflowRunId } from "@intx/types";
47
+ import { MAILBOX_INBOX_DIR } from "../adapters/substrate-mailbox-store.js";
23
48
  /**
24
49
  * Construct a `MessageTransport` whose outbound side routes through the
25
- * supervisor (via `bridge`) and whose inbound side is inert. `address`
26
- * is the agent's mail address; the supervisor signs the outbound mail as
27
- * this address through the host transport, so it must be the address the
28
- * host registered the agent's `CryptoProvider` against.
50
+ * supervisor (via `bridge`) and whose inbound side is a local IMAP read
51
+ * surface over `inbound`. `address` is the agent's mail address; the
52
+ * supervisor signs the outbound mail as this address through the host
53
+ * transport, so it must be the address the host registered the agent's
54
+ * `CryptoProvider` against. When `inbound` is omitted, the inbound methods
55
+ * throw a clear "not wired" error; the sidecar supplies it once the child's
56
+ * mailbox reader and watch registry are threaded through.
29
57
  */
30
- export function createSupervisorBackedTransport(bridge, address) {
31
- function inboundUnsupported(method) {
32
- throw new Error(`supervisor-backed transport: ${method} is not supported for unified-host step agent ${address}; the supervisor owns the mailbox and delivers inbound mail as the step input`);
58
+ export function createSupervisorBackedTransport(bridge, address, inbound) {
59
+ function unsupported(method) {
60
+ throw new Error(`supervisor-backed transport: ${method} is not supported for unified-host step agent ${address}; the supervisor owns the mailbox and the agent owns only its own ${MAILBOX_INBOX_DIR}`);
61
+ }
62
+ // Return the wired inbound surface, or fail loud when the sidecar
63
+ // constructed the transport without it -- an inbound read against a missing
64
+ // surface is a wiring error, not a silently-empty result.
65
+ function requireInbound(method) {
66
+ if (inbound === undefined) {
67
+ throw new Error(`supervisor-backed transport: ${method} needs the inbound surface, but it is not wired for unified-host step agent ${address}; the sidecar must construct the transport with its mailbox reader, watch registry, and crypto`);
68
+ }
69
+ return inbound;
70
+ }
71
+ // The unified-host agent owns exactly one mailbox, the substrate `INBOX`
72
+ // the reader opens. Reject any other name rather than serve `INBOX` under
73
+ // it, which would return the wrong mailbox's messages mislabeled.
74
+ function requireInbox(mailbox) {
75
+ if (mailbox !== MAILBOX_INBOX_DIR) {
76
+ throw new Error(`supervisor-backed transport: unified-host step agent ${address} owns only the "${MAILBOX_INBOX_DIR}" mailbox; "${mailbox}" is not available`);
77
+ }
33
78
  }
34
79
  return {
35
80
  async send(message, _signal) {
@@ -39,75 +84,173 @@ export function createSupervisorBackedTransport(bridge, address) {
39
84
  // `append` writes into a mailbox the agent owns; in the unified
40
85
  // host the agent owns none. The mail tools do not append (they
41
86
  // `send`), so a reachable `append` is a programming error.
42
- return inboundUnsupported("append");
87
+ return unsupported("append");
43
88
  },
44
89
  async listMailboxes(_signal) {
45
- return inboundUnsupported("listMailboxes");
90
+ return unsupported("listMailboxes");
46
91
  },
47
92
  async createMailbox(_name, _signal) {
48
- return inboundUnsupported("createMailbox");
93
+ return unsupported("createMailbox");
49
94
  },
50
95
  async deleteMailbox(_name, _signal) {
51
- return inboundUnsupported("deleteMailbox");
96
+ return unsupported("deleteMailbox");
52
97
  },
53
- async getMailboxStatus(_name, _signal) {
54
- return inboundUnsupported("getMailboxStatus");
98
+ async getMailboxStatus(name, _signal) {
99
+ const { reader } = requireInbound("getMailboxStatus");
100
+ requireInbox(name);
101
+ const store = await reader.open();
102
+ const unseen = store.messages.filter((m) => !m.flags.has("\\Seen")).length;
103
+ return {
104
+ total: store.messages.length,
105
+ unseen,
106
+ recent: 0,
107
+ uidNext: store.uidNext,
108
+ uidValidity: store.uidValidity,
109
+ highestModSeq: store.highestModSeq,
110
+ };
55
111
  },
56
- async search(_mailbox, _query, _signal) {
57
- return inboundUnsupported("search");
112
+ async search(mailbox, query, _signal) {
113
+ const { reader } = requireInbound("search");
114
+ requireInbox(mailbox);
115
+ const store = await reader.open();
116
+ return await executeSearch(mailbox, store, query);
58
117
  },
59
- async thread(_mailbox, _algorithm, _query, _signal) {
60
- return inboundUnsupported("thread");
118
+ async thread(mailbox, algorithm, query, _signal) {
119
+ const { reader } = requireInbound("thread");
120
+ requireInbox(mailbox);
121
+ const store = await reader.open();
122
+ return await executeThread(mailbox, store, algorithm, query);
61
123
  },
62
- async fetchHeaders(_ref, _signal) {
63
- return inboundUnsupported("fetchHeaders");
124
+ async fetchHeaders(ref, _signal) {
125
+ const { reader } = requireInbound("fetchHeaders");
126
+ requireInbox(ref.mailbox);
127
+ const store = await reader.open();
128
+ return await doFetchHeaders(ref, store);
64
129
  },
65
- async fetchStructure(_ref, _signal) {
66
- return inboundUnsupported("fetchStructure");
130
+ async fetchStructure(ref, _signal) {
131
+ const { reader } = requireInbound("fetchStructure");
132
+ requireInbox(ref.mailbox);
133
+ const store = await reader.open();
134
+ return await doFetchStructure(ref, store);
67
135
  },
68
- async fetchPart(_ref, _partPath, _signal) {
69
- return inboundUnsupported("fetchPart");
136
+ async fetchPart(ref, partPath, _signal) {
137
+ const { reader } = requireInbound("fetchPart");
138
+ requireInbox(ref.mailbox);
139
+ const store = await reader.open();
140
+ return await doFetchPart(ref, partPath, store);
70
141
  },
71
- async fetchFull(_ref, _signal) {
72
- return inboundUnsupported("fetchFull");
142
+ async fetchFull(ref, _signal) {
143
+ const { reader, getCrypto } = requireInbound("fetchFull");
144
+ requireInbox(ref.mailbox);
145
+ const store = await reader.open();
146
+ return await doFetchFull(ref, store, getCrypto);
73
147
  },
74
- async setFlags(_ref, _flags, _signal) {
75
- return inboundUnsupported("setFlags");
148
+ async setFlags(ref, flags, _signal) {
149
+ const { mutationBridge } = requireInbound("setFlags");
150
+ requireInbox(ref.mailbox);
151
+ // Route the flag write to the supervisor -- the sole mailbox writer --
152
+ // rather than flushing a second store against the run ref. `submit`
153
+ // resolves only after the supervisor flushes, so a subsequent read
154
+ // observes the flag.
155
+ await mutationBridge.submit({
156
+ runId: deriveWorkflowRunId(address),
157
+ mailbox: ref.mailbox,
158
+ op: "addFlags",
159
+ uid: ref.uid,
160
+ flags,
161
+ });
76
162
  },
77
- async clearFlags(_ref, _flags, _signal) {
78
- return inboundUnsupported("clearFlags");
163
+ async clearFlags(ref, flags, _signal) {
164
+ const { mutationBridge } = requireInbound("clearFlags");
165
+ requireInbox(ref.mailbox);
166
+ await mutationBridge.submit({
167
+ runId: deriveWorkflowRunId(address),
168
+ mailbox: ref.mailbox,
169
+ op: "removeFlags",
170
+ uid: ref.uid,
171
+ flags,
172
+ });
79
173
  },
80
174
  async move(_ref, _toMailbox, _signal) {
81
- return inboundUnsupported("move");
175
+ return unsupported("move");
82
176
  },
83
177
  async copy(_ref, _toMailbox, _signal) {
84
- return inboundUnsupported("copy");
178
+ return unsupported("copy");
85
179
  },
86
- async expunge(_mailbox, _signal) {
87
- return inboundUnsupported("expunge");
180
+ async expunge(mailbox, _signal) {
181
+ const { mutationBridge } = requireInbound("expunge");
182
+ requireInbox(mailbox);
183
+ // Route to the supervisor, which sweeps every `\Deleted` message out of
184
+ // its owned INBOX and returns the swept uids. The expunged bytes survive
185
+ // in git history (a workflow-run repo's objects are never GC'd), so the
186
+ // replication check permits the deletion. A caller expunging after a
187
+ // `setFlags(\Deleted)` MUST await the two in sequence -- the supervisor
188
+ // applies mutations in arrival order, so an unawaited (concurrent) pair
189
+ // could let the sweep run before the flag is set and miss the message.
190
+ const result = await mutationBridge.submit({
191
+ runId: deriveWorkflowRunId(address),
192
+ mailbox,
193
+ op: "expunge",
194
+ });
195
+ return { expungedUids: result.expungedUids ?? [] };
88
196
  },
89
- watch(_mailbox, _callback) {
90
- // Inbound delivery is a no-op: the supervisor delivers the agent's
91
- // input as the step input, not through the agent's mailbox. The
92
- // watch never fires; return a no-op unsubscribe so a mail tool that
93
- // installs a watch (mail_wait) does not throw at install time but
94
- // also never observes a spurious event.
95
- return () => undefined;
197
+ watch(mailbox, callback) {
198
+ const { watchRegistry } = requireInbound("watch");
199
+ requireInbox(mailbox);
200
+ // The supervisor -- the sole mail owner -- fires `mailbox.notify` into
201
+ // the registry when new mail lands; the registry delivers the typed
202
+ // event to this callback. `mail_wait` installs the watch and unblocks on
203
+ // the first delivery.
204
+ return watchRegistry.watch(mailbox, callback);
96
205
  },
97
- async sync(_mailbox, _knownState, _signal) {
98
- return inboundUnsupported("sync");
206
+ async sync(mailbox, knownState, _signal) {
207
+ const { reader } = requireInbound("sync");
208
+ requireInbox(mailbox);
209
+ const store = await reader.open();
210
+ const result = store.sync({
211
+ uidValidity: knownState.uidValidity,
212
+ highestModSeq: knownState.highestModSeq,
213
+ });
214
+ if (result.resync) {
215
+ return {
216
+ vanished: [],
217
+ changed: [],
218
+ newMessages: result.messages.map((m) => ({ uid: m.uid, mailbox })),
219
+ fullResyncRequired: true,
220
+ };
221
+ }
222
+ // The backing reports every message whose modseq advanced past the
223
+ // client's known state as `changed`. Split it against the client's known
224
+ // `uidNext`: a uid at or beyond it is a new arrival, one below it is a
225
+ // flag change on a message the client already held.
226
+ const newMessages = [];
227
+ const changed = [];
228
+ for (const m of result.changed) {
229
+ if (m.uid >= knownState.uidNext) {
230
+ newMessages.push({ uid: m.uid, mailbox });
231
+ }
232
+ else {
233
+ changed.push({ uid: m.uid, flags: Array.from(m.flags) });
234
+ }
235
+ }
236
+ return {
237
+ vanished: [...result.vanished],
238
+ changed,
239
+ newMessages,
240
+ fullResyncRequired: false,
241
+ };
99
242
  },
100
243
  async createList(_address, _name, _signal) {
101
- return inboundUnsupported("createList");
244
+ return unsupported("createList");
102
245
  },
103
246
  async listMembers(_address, _signal) {
104
- return inboundUnsupported("listMembers");
247
+ return unsupported("listMembers");
105
248
  },
106
249
  async subscribe(_listAddress, _subscriberAddress, _signal) {
107
- return inboundUnsupported("subscribe");
250
+ return unsupported("subscribe");
108
251
  },
109
252
  async unsubscribe(_listAddress, _subscriberAddress, _signal) {
110
- return inboundUnsupported("unsubscribe");
253
+ return unsupported("unsubscribe");
111
254
  },
112
255
  };
113
256
  }
@@ -11,6 +11,35 @@ import type { InferenceEvent, InferenceSource } from "@intx/types/runtime";
11
11
  export interface WarmEventSinkRef {
12
12
  current: ((event: InferenceEvent) => void) | null;
13
13
  }
14
+ /**
15
+ * Per-turn settle barrier the connector reply drain exposes to the warm
16
+ * step (design §3c durability). The step snapshots `replySeq()` before its
17
+ * `agent.send` and, for a turn that produced a reply, awaits
18
+ * `waitForReplyAfter(snapshot)` so the run parks -- and the supervisor
19
+ * consumes the inbound mail -- only after the reply is durably sent. This is
20
+ * the structural subset of the harness `ConnectorReplyDrain` the warm path
21
+ * needs; declaring it here keeps the workflow-host package independent of
22
+ * `@intx/harness`, while the drain the sidecar builds satisfies it.
23
+ */
24
+ export type WarmReplySettlement = {
25
+ readonly ok: true;
26
+ } | {
27
+ readonly ok: false;
28
+ readonly cause: unknown;
29
+ };
30
+ export interface WarmReplyDrive {
31
+ /** Settles when the drain loop exits at eviction. Folded into `eventForward`. */
32
+ readonly done: Promise<void>;
33
+ /** Monotonic count of replies that have settled (sent-and-acked or failed). */
34
+ replySeq(): number;
35
+ /**
36
+ * Resolve once the reply at index `n` has settled, with its outcome. A
37
+ * failure outcome (or a drain that tears down before reply `n` arrives)
38
+ * carries `ok: false` so the caller fails the turn rather than treating the
39
+ * reply as sent.
40
+ */
41
+ waitForReplyAfter(n: number): Promise<WarmReplySettlement>;
42
+ }
14
43
  /**
15
44
  * Per-address warm-agent cache. Keyed by the step's stable identity (the
16
45
  * single step's id), so a long-lived agent resolves to the same entry on
@@ -28,11 +57,22 @@ export interface WarmAgentCache {
28
57
  /**
29
58
  * Cache a freshly-built warm agent under `key`. The `eventSinkRef` is
30
59
  * the mutable sink the agent's stream forwarder reads; `eventForward`
31
- * is the forwarder loop's settle promise. Throws if an entry already
32
- * exists for `key` -- a double-build is a step-invoker bug, not a
33
- * silent overwrite that would leak the prior agent's LSP subprocess.
60
+ * is the forwarder loop's settle promise. `replyDrive` is the connector
61
+ * reply drain's per-turn barrier, or `null` when the deployment drives no
62
+ * threaded replies. Throws if an entry already exists for `key` -- a
63
+ * double-build is a step-invoker bug, not a silent overwrite that would
64
+ * leak the prior agent's LSP subprocess.
65
+ */
66
+ store(key: string, agent: Agent, eventSinkRef: WarmEventSinkRef, eventForward: Promise<void>, replyDrive: WarmReplyDrive | null): void;
67
+ /**
68
+ * Return the connector reply drain's per-turn barrier cached for `key`, or
69
+ * `null` when the deployment drives no threaded replies. The step-invoker
70
+ * fetches it on every message -- the drain is established once at the
71
+ * first-message build but each message's send must snapshot and await it.
72
+ * Throws when no entry exists for `key`: a barrier fetch before the warm
73
+ * agent is stored is a step-invoker sequencing bug.
34
74
  */
35
- store(key: string, agent: Agent, eventSinkRef: WarmEventSinkRef, eventForward: Promise<void>): void;
75
+ getReplyDrive(key: string): WarmReplyDrive | null;
36
76
  /**
37
77
  * Point the warm agent's stream forwarder at the active step's event
38
78
  * sink before its `agent.send`. Throws when no entry exists for