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