@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.
- package/README.md +21 -4
- package/dist/adapters/mail-part-store.d.ts +46 -0
- package/dist/adapters/mail-part-store.js +251 -0
- package/dist/adapters/repo-store.js +5 -14
- package/dist/adapters/spawn-child.d.ts +42 -6
- package/dist/adapters/spawn-child.js +8 -18
- package/dist/adapters/step-invoker.d.ts +52 -2
- package/dist/adapters/step-invoker.js +230 -60
- package/dist/adapters/substrate-mailbox-store.d.ts +80 -0
- package/dist/adapters/substrate-mailbox-store.js +404 -0
- package/dist/child/child-mailbox-reader.d.ts +10 -0
- package/dist/child/child-mailbox-reader.js +23 -0
- package/dist/child/credential-cell.d.ts +8 -0
- package/dist/child/credential-cell.js +66 -0
- package/dist/child/from-process-env.d.ts +12 -0
- package/dist/child/from-process-env.js +6 -0
- package/dist/child/index.d.ts +4 -1
- package/dist/child/index.js +4 -1
- package/dist/child/mailbox-mutation-bridge.d.ts +61 -0
- package/dist/child/mailbox-mutation-bridge.js +101 -0
- package/dist/child/mailbox-watch-registry.d.ts +17 -0
- package/dist/child/mailbox-watch-registry.js +61 -0
- package/dist/child/outbound-mail-bridge.d.ts +3 -2
- package/dist/child/outbound-mail-bridge.js +20 -32
- package/dist/child/pending-request.d.ts +89 -0
- package/dist/child/pending-request.js +80 -0
- package/dist/child/run-child.d.ts +69 -7
- package/dist/child/run-child.js +307 -75
- package/dist/child/substrate-write-bridge.d.ts +3 -2
- package/dist/child/substrate-write-bridge.js +21 -38
- package/dist/child/supervisor-backed-transport.d.ts +52 -6
- package/dist/child/supervisor-backed-transport.js +205 -62
- package/dist/child/warm-agent-cache.d.ts +44 -4
- package/dist/child/warm-agent-cache.js +41 -10
- package/dist/index.d.ts +4 -3
- package/dist/index.js +4 -3
- package/dist/ipc/control-channel.d.ts +93 -2
- package/dist/ipc/control-channel.js +147 -47
- package/dist/ipc/index.d.ts +1 -1
- package/dist/ipc/index.js +1 -1
- package/dist/run-body-then-cleanup.d.ts +17 -0
- package/dist/run-body-then-cleanup.js +38 -0
- package/dist/seams/scheduler.d.ts +12 -0
- package/dist/seams/scheduler.js +13 -4
- package/dist/supervisor/cancel-signing.js +3 -7
- package/dist/supervisor/credentials.d.ts +17 -5
- package/dist/supervisor/recycle.d.ts +5 -1
- package/dist/supervisor/run-event-compaction.d.ts +2 -2
- package/dist/supervisor/run-event-compaction.js +11 -16
- package/dist/supervisor/run-event-recovery.d.ts +34 -0
- package/dist/supervisor/run-event-recovery.js +45 -0
- package/dist/supervisor/supervisor.d.ts +27 -4
- package/dist/supervisor/supervisor.js +644 -58
- package/dist/supervisor/terminal-commit.js +3 -7
- package/dist/supervisor/types.d.ts +30 -0
- package/dist/testing/change-notifier.d.ts +12 -0
- package/dist/testing/change-notifier.js +63 -0
- package/dist/testing/index.d.ts +8 -0
- package/dist/testing/index.js +16 -0
- package/dist/testing/log-capture.d.ts +52 -0
- package/dist/testing/log-capture.js +124 -0
- package/dist/testing/mail-bus.d.ts +22 -0
- package/dist/testing/mail-bus.js +78 -0
- package/dist/testing/memory-streams.d.ts +43 -0
- package/dist/testing/memory-streams.js +211 -0
- package/dist/testing/spawn-observer.d.ts +12 -0
- package/dist/testing/spawn-observer.js +36 -0
- package/dist/testing/stub-repo-store.d.ts +10 -0
- package/dist/testing/stub-repo-store.js +39 -0
- package/dist/testing/supervisor-reaper.d.ts +24 -0
- package/dist/testing/supervisor-reaper.js +49 -0
- package/dist/testing/upstream-frames.d.ts +47 -0
- package/dist/testing/upstream-frames.js +94 -0
- package/dist/workflow-definition-loader.d.ts +56 -0
- package/dist/workflow-definition-loader.js +106 -0
- package/package.json +17 -11
- package/dist/conversation-text.d.ts +0 -23
- 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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
246
|
-
//
|
|
247
|
-
//
|
|
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>;
|