@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
@@ -53,7 +53,9 @@
53
53
  // Multi-step steps pass no cache and keep instantiate-send-teardown.
54
54
  import { createAgent, } from "@intx/agent";
55
55
  import { getLogger } from "@intx/log";
56
- import { createInboundMessage } from "@intx/mime";
56
+ import { createInboundMessage, extractAddrSpec, isMessageId } from "@intx/mime";
57
+ import { isMail } from "@intx/types/runtime";
58
+ import { runBodyThenCleanup } from "../run-body-then-cleanup.js";
57
59
  const logger = getLogger(["workflow-host", "step-invoker"]);
58
60
  /**
59
61
  * Construct the production `WorkflowRuntimeEnv.StepInvoker` adapter.
@@ -101,23 +103,30 @@ async function invokeColdStep(opts, agentFactory, req) {
101
103
  // allowlist-of-everything-except, so new `InferenceEvent` members
102
104
  // flow through by default.
103
105
  const eventForward = subscribeAgentEvents(agent, opts.onEvent);
104
- try {
105
- return stepResultFromSend(await sendWithAbort(agent, req, { closeOnAbort: true }));
106
- }
107
- finally {
108
- // `close` is idempotent: a second call after the send already
109
- // resolved still releases the workdir lock and tears down stream
110
- // consumers. We await so the lock is gone before the adapter
111
- // returns -- a subsequent step on the same workdir must not race
112
- // a still-closing agent.
113
- await agent.close();
114
- // `agent.close()` terminates every active `stream()` iterator, so
115
- // the forwarder's for-await loop has ended (or is about to). Await
116
- // it after close so the subscription is fully drained before the
117
- // adapter returns and no listener outlives the step. Awaited last
118
- // because the loop only ends once close has fired.
119
- await eventForward;
120
- }
106
+ // `agent.close()` is the wrapAgentClose-wrapped close: it runs the
107
+ // agent's own close (idempotent -- releases the workdir lock and tears
108
+ // down stream consumers) and then the plugin/tool-bundle disposers,
109
+ // which now reject the close if a disposer (e.g. the LSP subprocess
110
+ // kill) fails. Route the close through runBodyThenCleanup so a disposer
111
+ // failure surfaces on a clean step but never masks a step error already
112
+ // unwinding from `sendWithAbort`. `eventForward` is drained on every
113
+ // path -- `agent.close()` ends the forwarder's for-await loop, and it
114
+ // never rejects (subscribeAgentEvents swallows), so awaiting it in the
115
+ // cleanup cannot mask either error.
116
+ return runBodyThenCleanup(async () => stepResultFromSend(await sendWithAbort(agent, req, {
117
+ closeOnAbort: true,
118
+ mailPartReader: opts.mailPartReader,
119
+ // Cold-path per-step agents have no durable connector state; the
120
+ // warm path is the only connector-seeding path.
121
+ seedInbound: undefined,
122
+ })), async () => {
123
+ try {
124
+ await agent.close();
125
+ }
126
+ finally {
127
+ await eventForward;
128
+ }
129
+ }, (cause) => logger.error `step invoker: agent.close failed while unwinding a step error; surfacing the step error, close failure: ${cause instanceof Error ? cause.message : String(cause)}`);
121
130
  }
122
131
  /**
123
132
  * Warm-keep path (design §3b). The agent is built once on the first
@@ -160,7 +169,25 @@ async function invokeWarmStep(opts, warmCache, agentFactory, req) {
160
169
  if (sink !== null)
161
170
  sink(event);
162
171
  });
163
- warmCache.store(key, agent, eventSinkRef, eventForward);
172
+ // Establish the connector reply drain over the agent's lifetime stream
173
+ // (design §3c). A second independent consumer of the agent's stream
174
+ // alongside the observability forwarder: on each `connector.reply` it
175
+ // composes a threaded reply from the durable connector thread and sends
176
+ // it through the outbound bridge. Fold its lifetime `done` promise into
177
+ // the stored forwarder promise so the warm cache drains BOTH when it
178
+ // closes the agent at eviction -- the cache awaits one promise per entry,
179
+ // so a caller that wants the reply loop torn down with the agent combines
180
+ // the two here. The drive handle's per-turn barrier is stored on the entry
181
+ // so every message (not just this first build) can gate its reply turn on
182
+ // a durable send. Present only on the warm mail path; a warm deployment
183
+ // with no durable connector state omits it.
184
+ const replyDrive = opts.driveReplies !== undefined
185
+ ? opts.driveReplies(key, agent.stream())
186
+ : null;
187
+ const lifetimeForward = replyDrive !== null
188
+ ? Promise.all([eventForward, replyDrive.done]).then(() => undefined)
189
+ : eventForward;
190
+ warmCache.store(key, agent, eventSinkRef, lifetimeForward, replyDrive);
164
191
  // Re-apply the live source table to the just-built agent. A rotation
165
192
  // that arrived during the (async) build hit the still-empty cache as a
166
193
  // no-op `applySources` while the build had already captured the prior
@@ -176,8 +203,42 @@ async function invokeWarmStep(opts, warmCache, agentFactory, req) {
176
203
  if (opts.onEvent !== undefined) {
177
204
  warmCache.setEventSink(key, opts.onEvent);
178
205
  }
206
+ // Bind the seed hook to this step's identity so it resolves the same
207
+ // per-agent durable store the warm cache and run-boundary flush use.
208
+ const seedInbound = opts.seedInbound;
209
+ // Snapshot the reply barrier BEFORE the send. The agent resolves
210
+ // `agent.send` in the same synchronous step it pushes `connector.reply`
211
+ // onto the drain's stream, so the reply is not yet enqueued when the send
212
+ // resolves -- a snapshot taken after the send would miss this turn's reply.
213
+ const replyDrive = warmCache.getReplyDrive(key);
214
+ const replySeqBeforeSend = replyDrive !== null ? replyDrive.replySeq() : 0;
179
215
  try {
180
- return stepResultFromSend(await sendWithAbort(agent, req, { closeOnAbort: false }));
216
+ const sendResult = await sendWithAbort(agent, req, {
217
+ closeOnAbort: false,
218
+ mailPartReader: opts.mailPartReader,
219
+ seedInbound: seedInbound !== undefined
220
+ ? (message) => seedInbound(key, message)
221
+ : undefined,
222
+ });
223
+ const stepResult = stepResultFromSend(sendResult);
224
+ // Gate the step's return on THIS turn's reply being durably sent, so the
225
+ // run parks -- and the supervisor consumes the inbound mail -- only after
226
+ // the auto-reply reaches the transport. Only a reply turn produces a
227
+ // `connector.reply`; a suspended/gate turn produces none, so it must NOT
228
+ // await the barrier (that reply never arrives and the wait would hang).
229
+ // A failed send resolves the barrier with `ok: false`: fail the turn so
230
+ // the inbound mail is not consumed as replied and the run's claim-check
231
+ // replays it (at-least-once via reprocessing) rather than dropping the
232
+ // reply.
233
+ if (replyDrive !== null && sendResult.type === "reply") {
234
+ const settlement = await replyDrive.waitForReplyAfter(replySeqBeforeSend);
235
+ if (!settlement.ok) {
236
+ throw new Error("workflow step invoker: the warm agent's auto-reply send failed; " +
237
+ "failing the turn so the inbound mail replays rather than being " +
238
+ "consumed with the reply dropped", { cause: settlement.cause });
239
+ }
240
+ }
241
+ return stepResult;
181
242
  }
182
243
  finally {
183
244
  // Do NOT close the agent or drain its forwarder: both span
@@ -239,16 +300,30 @@ async function buildStepAgent(opts, agentFactory, req) {
239
300
  * step's abort attribution wins regardless of which side settles first.
240
301
  */
241
302
  async function sendWithAbort(agent, req, cfg) {
303
+ // Re-check the abort signal before building the message. `buildEnv` and
304
+ // `agentFactory` (or a warm-cache acquire) yield to the microtask queue, and
305
+ // the caller can fire `signal.abort()` in between. Building the message can
306
+ // itself yield (attachment resolution), so guard here too.
307
+ if (req.signal.aborted)
308
+ throw abortError(req.signal);
309
+ // Resolve the step input into the value `agent.send` receives. A build
310
+ // failure (a bad resume shape, an unresolvable attachment) rejects the step.
311
+ const { message, mailInbound } = await buildSendMessage(req, cfg.mailPartReader);
312
+ // Seed the warm agent's connector thread from a mail-derived inbound before
313
+ // the send, so the reply path has thread state (design §3c). Only the
314
+ // mail branch surfaces `mailInbound`; an approval-resume inbound and a
315
+ // synthesized string do not advance the thread. The seed is awaited so its
316
+ // durable flush completes (or surfaces) before the reply is produced; a
317
+ // seed failure rejects the step rather than composing an unthreaded reply.
318
+ if (mailInbound !== null && cfg.seedInbound !== undefined) {
319
+ await cfg.seedInbound(mailInbound);
320
+ }
242
321
  let abortListener = null;
243
322
  try {
244
323
  return await new Promise((resolve, reject) => {
245
- // Re-check the abort signal inside the executor. `buildEnv` and
246
- // `agentFactory` (or a warm-cache acquire) yield to the
247
- // microtask queue, and the caller can fire `signal.abort()`
248
- // between the entry-time check and here. Without this re-check,
249
- // a mid-construction abort would attach the listener to an
250
- // already-aborted signal that never fires the event again, and
251
- // the send would hang to the workflow runtime's step timeout.
324
+ // Re-check after the (async) message build: a mid-build abort must not
325
+ // attach the listener to an already-aborted signal that never fires the
326
+ // event again, or the send would hang to the runtime's step timeout.
252
327
  if (req.signal.aborted) {
253
328
  reject(abortError(req.signal));
254
329
  return;
@@ -264,39 +339,6 @@ async function sendWithAbort(agent, req, cfg) {
264
339
  };
265
340
  abortListener = onAbort;
266
341
  req.signal.addEventListener("abort", onAbort, { once: true });
267
- let message;
268
- try {
269
- // How the resumed input is delivered depends on the park kind:
270
- //
271
- // - `"approval"`: the reactor is parked mid-turn on a tool/authz gate.
272
- // Build the full `InboundMessage` stamped with `resume.correlationId`
273
- // so the header reaches the reactor's `tryCorrelate` and matches the
274
- // rehydrated gate. The object form is load-bearing -- a plain string
275
- // would drop the correlation id and the resumed cycle would never
276
- // match.
277
- // - `"input"`: the step re-armed between turns; the decision is simply
278
- // the next user turn, with NO gate to correlate. Deliver it as the
279
- // plain synthesized content, exactly as a first invocation does.
280
- //
281
- // A first invocation (no resume) sends the plain synthesized input;
282
- // `agent.send` stamps its own synthetic addressing.
283
- message =
284
- req.resume === undefined
285
- ? synthesizeInputContent(req.input)
286
- : req.resume.kind === "input"
287
- ? synthesizeInputContent(req.resume.decision)
288
- : createInboundMessage({
289
- from: "signal@local",
290
- to: "agent@local",
291
- content: synthesizeInputContent(req.resume.decision),
292
- interchangeType: "conversation.message",
293
- correlationId: req.resume.correlationId,
294
- });
295
- }
296
- catch (cause) {
297
- reject(cause instanceof Error ? cause : new Error(String(cause)));
298
- return;
299
- }
300
342
  const sendOpts = cfg.closeOnAbort ? undefined : { signal: req.signal };
301
343
  agent.send(message, sendOpts).then(resolve, (cause) => {
302
344
  reject(cause instanceof Error ? cause : new Error(String(cause)));
@@ -400,6 +442,134 @@ function stepResultFromSend(result) {
400
442
  }
401
443
  return { output: { reply: result.reply, turn: result.turn } };
402
444
  }
445
+ /**
446
+ * Resolve the step input into the value `agent.send` receives. The delivery
447
+ * depends on the resume kind and the input shape:
448
+ *
449
+ * - `"approval"` resume: the reactor is parked mid-turn on a tool/authz gate.
450
+ * Build the full `InboundMessage` stamped with `resume.correlationId` so the
451
+ * header reaches the reactor's `tryCorrelate` and matches the rehydrated
452
+ * gate. The object form is load-bearing -- a plain string would drop the
453
+ * correlation id and the resumed cycle would never match. An approval
454
+ * decision is never a mail-derived `Mail`, so it stays on this branch.
455
+ * - A mail-derived `Mail` (first invocation or `"input"` resume): project its
456
+ * parts into a real `InboundMessage` (text and/or attachments), resolving
457
+ * each non-text part's bytes through the reader.
458
+ * - Anything else (a first invocation or `"input"` resume carrying an
459
+ * arbitrary step value): synthesize plain text; `agent.send` stamps its own
460
+ * synthetic addressing.
461
+ */
462
+ async function buildSendMessage(req, mailPartReader) {
463
+ if (req.resume !== undefined && req.resume.kind !== "input") {
464
+ return {
465
+ message: createInboundMessage({
466
+ from: "signal@local",
467
+ to: "agent@local",
468
+ content: synthesizeInputContent(req.resume.decision),
469
+ interchangeType: "conversation.message",
470
+ correlationId: req.resume.correlationId,
471
+ }),
472
+ mailInbound: null,
473
+ };
474
+ }
475
+ const rawInput = req.resume === undefined ? req.input : req.resume.decision;
476
+ // A step whose input is a decoded `Mail` is projected into the agent's
477
+ // inbound message; the strict `isMail` guard keeps an arbitrary step value
478
+ // from matching. This is the one branch that carries real threading headers,
479
+ // so `mailInbound` surfaces the message for the warm path's connector seed.
480
+ if (isMail(rawInput)) {
481
+ const message = await buildInboundMessageFromMail(rawInput, mailPartReader);
482
+ return { message, mailInbound: message };
483
+ }
484
+ return { message: synthesizeInputContent(rawInput), mailInbound: null };
485
+ }
486
+ /** Extract a bare addr-spec from a header value, or fall back to a synthetic
487
+ * local address when the value is absent or unparseable. */
488
+ function safeAddr(raw, fallback) {
489
+ if (raw === undefined || raw === "")
490
+ return fallback;
491
+ try {
492
+ return extractAddrSpec(raw);
493
+ }
494
+ catch {
495
+ return fallback;
496
+ }
497
+ }
498
+ /**
499
+ * Project a decoded `Mail` into the agent's `InboundMessage`. Text parts become
500
+ * the conversation body; every other part becomes an attachment the reactor
501
+ * turns into a media / document content block. Part bytes are resolved through
502
+ * the reader; a text part small enough to have inlined `text` skips the read.
503
+ * The real sender / recipient headers are carried through so the agent frames
504
+ * the turn with the actual `From:` rather than a synthetic address. The decoded
505
+ * `Message-ID` and, when present, `In-Reply-To` / `References` ride through too,
506
+ * so the delivered message keeps its place in the conversation thread rather
507
+ * than being stamped with a fresh synthesized id. Content is omitted when empty
508
+ * -- `createInboundMessage` rejects an empty string, and an attachments-only
509
+ * message is valid.
510
+ *
511
+ * The reader is required only when a part's bytes must actually be read (a
512
+ * non-text part, or a text part too large to have inlined its `text`). A
513
+ * text-only mail whose parts all inlined -- e.g. one routed to a body step,
514
+ * which is not wired with a reader -- still delivers; a part that needs bytes
515
+ * with no reader is refused loudly rather than silently dropped.
516
+ */
517
+ async function buildInboundMessageFromMail(mail, mailPartReader) {
518
+ const noReader = () => new Error("workflow step invoker: a mail part's bytes must be read but the step has no mail-part reader wired; inbound parts are not supported for this step");
519
+ const textPieces = [];
520
+ const attachments = [];
521
+ for (const part of mail.parts) {
522
+ // A part is conversation body only when it is inline plain text. An
523
+ // attachment-disposition part (even a text/* one, e.g. an attached .txt),
524
+ // and any non-plain-text part (a text/html alternative, an image, an
525
+ // application/* payload), is delivered as an attachment so its bytes and
526
+ // filename survive rather than being folded into the turn.
527
+ const isBody = part.disposition !== "attachment" && part.contentType === "text/plain";
528
+ if (isBody) {
529
+ if (part.text !== undefined) {
530
+ textPieces.push(part.text);
531
+ continue;
532
+ }
533
+ if (mailPartReader === undefined)
534
+ throw noReader();
535
+ textPieces.push(new TextDecoder("utf-8", { fatal: false }).decode(await mailPartReader.read(part.ref)));
536
+ continue;
537
+ }
538
+ if (mailPartReader === undefined)
539
+ throw noReader();
540
+ attachments.push({
541
+ name: part.filename ?? part.contentType,
542
+ contentType: part.contentType,
543
+ data: await mailPartReader.read(part.ref),
544
+ });
545
+ }
546
+ const content = textPieces.join("\n").trim();
547
+ // Forward the mail's Message-ID / In-Reply-To / References for threading, but
548
+ // only the well-formed RFC 2822 identifiers. Inbound mail can carry a
549
+ // headerless-derived (sha256) or malformed Message-Id -- a valid claim-check
550
+ // key but not a valid identifier -- and passing it to createInboundMessage
551
+ // would throw and fail the step. When the messageId is omitted here,
552
+ // createInboundMessage synthesizes a valid one; such mail cannot thread.
553
+ const validReferences = mail.headers.references?.filter(isMessageId) ?? [];
554
+ return createInboundMessage({
555
+ from: safeAddr(mail.headers.from, "trigger@local"),
556
+ to: safeAddr(mail.headers.to[0], "agent@local"),
557
+ ...(mail.headers.subject !== undefined
558
+ ? { subject: mail.headers.subject }
559
+ : {}),
560
+ ...(isMessageId(mail.headers.messageId)
561
+ ? { messageId: mail.headers.messageId }
562
+ : {}),
563
+ ...(mail.headers.inReplyTo !== undefined &&
564
+ isMessageId(mail.headers.inReplyTo)
565
+ ? { inReplyTo: mail.headers.inReplyTo }
566
+ : {}),
567
+ ...(validReferences.length > 0 ? { references: validReferences } : {}),
568
+ ...(content.length > 0 ? { content } : {}),
569
+ ...(attachments.length > 0 ? { attachments } : {}),
570
+ interchangeType: "conversation.message",
571
+ });
572
+ }
403
573
  /**
404
574
  * Encode the step's resolved `input` as the synthetic inbound message
405
575
  * content. The workflow runtime resolves `input` from the step's input
@@ -0,0 +1,80 @@
1
+ import type { Principal, RepoId, RepoStore as SubstrateRepoStore } from "@intx/hub-sessions/substrate";
2
+ import type { MailboxStore, StoredMessage } from "@intx/mailbox";
3
+ /** Top-level subtree of the workflow-run repo that holds the mailbox. */
4
+ export declare const MAILBOX_PREFIX = "mailbox";
5
+ /** The single mailbox this backing persists, an IMAP INBOX. */
6
+ export declare const MAILBOX_INBOX_DIR = "INBOX";
7
+ /** Committed metadata blob name directly under `mailbox/INBOX/`. */
8
+ export declare const MAILBOX_INDEX_FILE = "index.json";
9
+ /** Suffix of a per-message raw-bytes blob (`<uid>.eml`). */
10
+ export declare const MAILBOX_EML_SUFFIX = ".eml";
11
+ /**
12
+ * The `mailbox/INBOX/` prefix, ending in `/` as
13
+ * `writeTreePreservingPrefix` requires. Every blob this backing writes is a
14
+ * direct child of it.
15
+ */
16
+ export declare const MAILBOX_INBOX_PREFIX = "mailbox/INBOX/";
17
+ /**
18
+ * The client's last-known synchronization state, per QRESYNC (RFC 7162). A
19
+ * mismatched `uidValidity` forces a full resync; otherwise `highestModSeq`
20
+ * bounds the changed / vanished deltas.
21
+ */
22
+ export type MailboxSyncKnownState = {
23
+ uidValidity: number;
24
+ highestModSeq: number;
25
+ };
26
+ /**
27
+ * The result of a QRESYNC `sync`. `resync: true` signals the client's
28
+ * `uidValidity` no longer matches the mailbox, so it must discard its cache
29
+ * and take the full `messages` snapshot. `resync: false` carries the deltas
30
+ * since the client's `highestModSeq`: `changed` is every live message whose
31
+ * modseq advanced past it (new arrivals and flag changes alike), and
32
+ * `vanished` is every uid expunged past it.
33
+ */
34
+ export type MailboxSyncResult = {
35
+ resync: true;
36
+ uidValidity: number;
37
+ uidNext: number;
38
+ highestModSeq: number;
39
+ messages: readonly StoredMessage[];
40
+ } | {
41
+ resync: false;
42
+ uidValidity: number;
43
+ uidNext: number;
44
+ highestModSeq: number;
45
+ changed: readonly StoredMessage[];
46
+ vanished: readonly number[];
47
+ };
48
+ /**
49
+ * A `MailboxStore` whose state is durable in the workflow-run substrate. The
50
+ * synchronous `MailboxStore` surface reads and mutates an in-memory mirror;
51
+ * `flush` persists that mirror to `mailbox/INBOX/`; `sync` answers a QRESYNC
52
+ * delta against a client's known state.
53
+ */
54
+ export interface SubstrateMailboxStore extends MailboxStore {
55
+ /** True when a mutation has occurred that `flush` has not yet persisted. */
56
+ readonly pendingWrites: boolean;
57
+ /**
58
+ * Persist the current mirror to the substrate through a delta write:
59
+ * `index.json` (always), the `<uid>.eml` blobs appended since the last
60
+ * successful flush, and deletions for the blobs whose message was removed
61
+ * since then. Every untouched `<uid>.eml` is carried forward by object id. A
62
+ * no-op when no mutation is pending.
63
+ */
64
+ flush(): Promise<void>;
65
+ /** Compute the QRESYNC delta between the mailbox and a client's known state. */
66
+ sync(known: MailboxSyncKnownState): MailboxSyncResult;
67
+ }
68
+ export type SubstrateMailboxStoreOpts = {
69
+ substrate: SubstrateRepoStore;
70
+ repoId: RepoId;
71
+ principal: Principal;
72
+ ref: string;
73
+ };
74
+ /**
75
+ * Create a workflow-run-substrate-backed `MailboxStore`. Loads the committed
76
+ * `mailbox/INBOX/` subtree into an in-memory mirror, then serves the
77
+ * synchronous `MailboxStore` surface over that mirror. Mutations stay in
78
+ * memory until `flush` persists them.
79
+ */
80
+ export declare function createSubstrateMailboxStore(opts: SubstrateMailboxStoreOpts): Promise<SubstrateMailboxStore>;