@intx/workflow-host 0.2.2 → 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 (101) hide show
  1. package/README.md +77 -14
  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.d.ts +22 -1
  5. package/dist/adapters/repo-store.js +56 -65
  6. package/dist/adapters/spawn-child.d.ts +109 -44
  7. package/dist/adapters/spawn-child.js +77 -81
  8. package/dist/adapters/step-invoker.d.ts +52 -2
  9. package/dist/adapters/step-invoker.js +284 -37
  10. package/dist/adapters/substrate-mailbox-store.d.ts +80 -0
  11. package/dist/adapters/substrate-mailbox-store.js +404 -0
  12. package/dist/child/child-mailbox-reader.d.ts +10 -0
  13. package/dist/child/child-mailbox-reader.js +23 -0
  14. package/dist/child/credential-cell.d.ts +8 -0
  15. package/dist/child/credential-cell.js +66 -0
  16. package/dist/child/env-bootstrap.d.ts +20 -6
  17. package/dist/child/env-bootstrap.js +9 -1
  18. package/dist/child/from-process-env.d.ts +12 -0
  19. package/dist/child/from-process-env.js +6 -0
  20. package/dist/child/index.d.ts +6 -2
  21. package/dist/child/index.js +4 -1
  22. package/dist/child/mailbox-mutation-bridge.d.ts +61 -0
  23. package/dist/child/mailbox-mutation-bridge.js +101 -0
  24. package/dist/child/mailbox-watch-registry.d.ts +17 -0
  25. package/dist/child/mailbox-watch-registry.js +61 -0
  26. package/dist/child/outbound-mail-bridge.d.ts +3 -2
  27. package/dist/child/outbound-mail-bridge.js +20 -32
  28. package/dist/child/parked-correlations.d.ts +42 -0
  29. package/dist/child/parked-correlations.js +80 -0
  30. package/dist/child/pending-request.d.ts +89 -0
  31. package/dist/child/pending-request.js +80 -0
  32. package/dist/child/proxy-repo-store.d.ts +3 -2
  33. package/dist/child/proxy-repo-store.js +2 -0
  34. package/dist/child/run-child.d.ts +170 -14
  35. package/dist/child/run-child.js +569 -155
  36. package/dist/child/self-discovery.d.ts +10 -0
  37. package/dist/child/self-discovery.js +25 -1
  38. package/dist/child/substrate-write-bridge.d.ts +3 -2
  39. package/dist/child/substrate-write-bridge.js +21 -38
  40. package/dist/child/supervisor-backed-transport.d.ts +52 -6
  41. package/dist/child/supervisor-backed-transport.js +205 -62
  42. package/dist/child/verified-definition-loader.d.ts +33 -0
  43. package/dist/child/verified-definition-loader.js +43 -0
  44. package/dist/child/warm-agent-cache.d.ts +44 -4
  45. package/dist/child/warm-agent-cache.js +41 -10
  46. package/dist/index.d.ts +6 -4
  47. package/dist/index.js +6 -4
  48. package/dist/ipc/control-channel.d.ts +151 -2
  49. package/dist/ipc/control-channel.js +222 -29
  50. package/dist/ipc/event-channel.d.ts +32 -1
  51. package/dist/ipc/index.d.ts +1 -1
  52. package/dist/ipc/index.js +1 -1
  53. package/dist/mail-bus/hub-transport-adapter.d.ts +12 -7
  54. package/dist/mail-bus/hub-transport-adapter.js +9 -5
  55. package/dist/run-body-then-cleanup.d.ts +17 -0
  56. package/dist/run-body-then-cleanup.js +38 -0
  57. package/dist/seams/scheduler.d.ts +16 -6
  58. package/dist/seams/scheduler.js +87 -97
  59. package/dist/supervisor/cancel-signing.d.ts +2 -2
  60. package/dist/supervisor/cancel-signing.js +4 -8
  61. package/dist/supervisor/credentials.d.ts +28 -15
  62. package/dist/supervisor/credentials.js +7 -7
  63. package/dist/supervisor/dispatch-attribution.js +1 -1
  64. package/dist/supervisor/drain-timeout.d.ts +2 -2
  65. package/dist/supervisor/drain-timeout.js +1 -1
  66. package/dist/supervisor/index.d.ts +3 -3
  67. package/dist/supervisor/index.js +2 -2
  68. package/dist/supervisor/recycle.d.ts +10 -3
  69. package/dist/supervisor/recycle.js +18 -7
  70. package/dist/supervisor/run-event-compaction.d.ts +5 -5
  71. package/dist/supervisor/run-event-compaction.js +14 -19
  72. package/dist/supervisor/run-event-recovery.d.ts +34 -0
  73. package/dist/supervisor/run-event-recovery.js +45 -0
  74. package/dist/supervisor/spawn-env.d.ts +2 -2
  75. package/dist/supervisor/spawn-env.js +1 -1
  76. package/dist/supervisor/supervisor.d.ts +106 -26
  77. package/dist/supervisor/supervisor.js +1903 -414
  78. package/dist/supervisor/terminal-commit.d.ts +36 -0
  79. package/dist/supervisor/terminal-commit.js +130 -0
  80. package/dist/supervisor/types.d.ts +180 -23
  81. package/dist/testing/change-notifier.d.ts +12 -0
  82. package/dist/testing/change-notifier.js +63 -0
  83. package/dist/testing/index.d.ts +8 -0
  84. package/dist/testing/index.js +16 -0
  85. package/dist/testing/log-capture.d.ts +52 -0
  86. package/dist/testing/log-capture.js +124 -0
  87. package/dist/testing/mail-bus.d.ts +22 -0
  88. package/dist/testing/mail-bus.js +78 -0
  89. package/dist/testing/memory-streams.d.ts +43 -0
  90. package/dist/testing/memory-streams.js +211 -0
  91. package/dist/testing/spawn-observer.d.ts +12 -0
  92. package/dist/testing/spawn-observer.js +36 -0
  93. package/dist/testing/stub-repo-store.d.ts +10 -0
  94. package/dist/testing/stub-repo-store.js +39 -0
  95. package/dist/testing/supervisor-reaper.d.ts +24 -0
  96. package/dist/testing/supervisor-reaper.js +49 -0
  97. package/dist/testing/upstream-frames.d.ts +47 -0
  98. package/dist/testing/upstream-frames.js +94 -0
  99. package/dist/workflow-definition-loader.d.ts +187 -0
  100. package/dist/workflow-definition-loader.js +422 -0
  101. package/package.json +18 -11
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, with resume
30
- rehydration consulting `unconsumedSignals` so a signal that
31
- arrived while offline replays before live subscription begins.
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.
@@ -95,7 +100,7 @@ The constructor argument shape:
95
100
  `require.resolve` / `import.meta.resolve` against the host's own
96
101
  package, `@intx/<host>`).
97
102
  - `substrateEnv`, `workflowRunRepoId`, `workflowRunRef`,
98
- `deploymentId`, `deploymentMailAddress`, `readPrincipal`,
103
+ `anchorRunId`, `deploymentMailAddress`, `readPrincipal`,
99
104
  `deriveStepAddress`, `deriveStepRepoId?`, `ipcKeyPairFactory?` —
100
105
  per-deployment configuration the supervisor needs in its closure
101
106
  state.
@@ -144,9 +149,61 @@ signed shape as the operator and drain origins.
144
149
  `shutdown()` unregisters the mail address, kills the child, and
145
150
  disposes subscriptions.
146
151
 
147
- `drain` and `recycle` are stubs in this commit; the full
148
- implementations land with the drain controller and recycle paths
149
- respectively.
152
+ `drain(opts)` sends the drain control mail and waits for in-flight
153
+ runs to drain per each step's `drainBehavior`; on the drain-timeout it
154
+ escalates to a signed `CancelRequested{origin: "supervisor-drain"}`.
155
+
156
+ `recycle(opts)` tears the current child down and stands a fresh one up
157
+ against the SAME deploy tree (same materialized source closure, same
158
+ per-step credential repos). It is strictly orthogonal to redeploy,
159
+ which mints a new deploy tree. Operator, supervisor-policy (max-uptime
160
+ / max-rss / grants-staleness), and workflow-process-self-initiated
161
+ origins all funnel through the same path.
162
+
163
+ ### Respawn policy
164
+
165
+ An UNEXPECTED child exit — a crash, OOM, panic, or signal, as opposed
166
+ to a supervisor-initiated shutdown or recycle — is detected by watching
167
+ the child process's `exited`, not the IPC channel: a clean process
168
+ death ends the channel readers without a protocol-level crash callback,
169
+ so `exited` is the only universal death signal. The supervisor
170
+ classifies the exit by cohort generation and lifecycle phase — an exit
171
+ of the current running cohort that no planned teardown owns is
172
+ unexpected.
173
+
174
+ On an unexpected exit the supervisor, with no external intervention:
175
+
176
+ 1. Replays any mail stranded mid-flight — entries the dead child's
177
+ in-flight dispatch left in the per-address `processing/` subtree —
178
+ back into `inbox/` under their original `<receivedAt>-<messageId>`
179
+ keys. Those keys sort ahead of any mail that arrived during the
180
+ kill/respawn gap, so the stranded entry is re-dispatched first and
181
+ FIFO ordering holds across the respawn boundary.
182
+ 2. Spawns a fresh workflow-process child against the same deploy tree
183
+ (reusing the recycle path's respawn machinery) and resumes dispatch.
184
+
185
+ The respawn is bounded so a persistently-broken child cannot saturate
186
+ the host. Every bound is operator-overridable via
187
+ `WorkflowSupervisorBindings`; the defaults are:
188
+
189
+ - **Exponential backoff.** Each respawn waits before spawning, starting
190
+ at `respawnBackoffInitialMs` (1s) and doubling to a
191
+ `respawnBackoffMaxMs` (30s) cap.
192
+ - **Crash-loop guard.** If the child exits unexpectedly
193
+ `crashLoopMaxCount` (3) times within `crashLoopWindowMs` (60s), the
194
+ supervisor stops respawning and latches the deployment to a terminal
195
+ `crash-looping` state.
196
+ - **Stable-run reset.** Once a crash-respawned child stays up for
197
+ `crashLoopStableResetMs` (60s), the crash counter and the backoff
198
+ reset, so a flap followed by stability does not permanently latch.
199
+
200
+ `crash-looping` is an in-memory, per-process phase — no external reader
201
+ observes it. The durable, externally-queryable signal is the run's
202
+ status: on latch the supervisor (the sole writer of the workflow-run
203
+ repo) commits a `RunFailed` for the deployment's stable run, flipping
204
+ its `workflow_run.status` to `failed` through the same pack path every
205
+ other terminal run uses. External automation that watches run status
206
+ sees the crash-loop as a failed run.
150
207
 
151
208
  ### Host wiring
152
209
 
@@ -208,12 +265,6 @@ without reconstructing the env. The closure looks up the
208
265
  originating step's grants by `stepId` and delegates to a
209
266
  host-supplied `GrantEvaluator`.
210
267
 
211
- ### Placeholders
212
-
213
- `DrainController` is a no-op placeholder in this commit; the real
214
- controller lands separately. `recycle` is a no-op pending the
215
- recycle path.
216
-
217
268
  ## Hosting the workflow-process child
218
269
 
219
270
  `@intx/workflow-host` ships the runtime body
@@ -241,7 +292,19 @@ The contract is intentionally narrow:
241
292
  factory returns `RunWorkflowChildBindings`: substrate `RepoStore`,
242
293
  principal, per-deployment repo ids, scheduler, step invoker, child
243
294
  spawner, grant evaluator. The factory consumes the typed struct,
244
- 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`.
245
308
  3. **The helper fails loudly.** A missing or malformed spawn-time
246
309
  env throws via `parseSpawnTimeEnv`; a substrate-config key the
247
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
+ }
@@ -1,5 +1,5 @@
1
1
  import type { Principal, RepoId, RepoStore as SubstrateRepoStore } from "@intx/hub-sessions/substrate";
2
- import type { RepoStore } from "@intx/workflow";
2
+ import type { RepoStore, WorkflowEvent } from "@intx/workflow";
3
3
  export type WorkflowRunRepoStoreOpts = {
4
4
  /**
5
5
  * Substrate handle the adapter reads from and writes to. The caller
@@ -23,6 +23,18 @@ export type WorkflowRunRepoStoreOpts = {
23
23
  * shape the production wiring supplies.
24
24
  */
25
25
  principal: Principal;
26
+ /**
27
+ * Principal used for a control-plane cancel append (a batch that is
28
+ * entirely `CancelRequested`). The workflow-run kind handler requires a
29
+ * `CancelRequested` be signed by a `supervisor` principal -- a
30
+ * `workflow-process` principal may write run-body events but not a cancel.
31
+ * An in-process child runs under real supervisor authority, so its host
32
+ * supplies a supervisor principal here while run-body events keep their
33
+ * `workflow-process` attribution. Absent when the writer issues no
34
+ * in-process cancel, in which case a cancel fails loud at the push boundary
35
+ * rather than being silently mis-attributed.
36
+ */
37
+ controlPlanePrincipal?: Principal;
26
38
  /**
27
39
  * Events ref the adapter reads from and writes to. The workflow-run
28
40
  * repo layout pins all `runs/<runId>/events/` blobs under a single
@@ -37,3 +49,12 @@ export type WorkflowRunRepoStoreOpts = {
37
49
  * routing live in closure.
38
50
  */
39
51
  export declare function createWorkflowRunRepoStore(opts: WorkflowRunRepoStoreOpts): RepoStore;
52
+ /**
53
+ * Translate a state-machine `WorkflowEvent` (using `kind` as the
54
+ * discriminator) into the on-disk envelope shape (`{seq, type,
55
+ * ...rest}`) the workflow-run kind handler validates and the
56
+ * substrate's `subscribeKind` helper filters on. Exported so the
57
+ * supervisor's terminal-commit path encodes a supervisor-authored
58
+ * `RunFailed` through the same single source of the on-disk shape.
59
+ */
60
+ export declare function workflowEventToOnDisk(event: WorkflowEvent, seq: number): Record<string, unknown>;
@@ -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
@@ -61,6 +60,7 @@ const ALL_WORKFLOW_EVENT_TYPES = [
61
60
  "AttemptScheduled",
62
61
  "SignalAwaited",
63
62
  "SignalReceived",
63
+ "SignalAwaitAbandoned",
64
64
  "TimerSet",
65
65
  "TimerFired",
66
66
  "CancelRequested",
@@ -95,66 +95,56 @@ export function createWorkflowRunRepoStore(opts) {
95
95
  };
96
96
  }
97
97
  async function readAllEventsForRun(opts, runId) {
98
- const fs = await import("node:fs/promises");
99
- const path = await import("node:path");
100
- const dir = opts.substrate.getRepoDir(opts.repoId);
101
- const runDir = path.join(dir, RUNS_PREFIX, runId);
98
+ // Read the committed tree through the substrate, never the working
99
+ // checkout under `getRepoDir`. `openCommittedReads` pins the ref to its
100
+ // tip commit and serves every read from the git object store, so an
101
+ // enumerate-then-read sequence is a single coherent snapshot even while
102
+ // a concurrent append re-materializes the checkout. The prior
103
+ // implementation read the working tree directly (raw readdir/readFile)
104
+ // and raced that materialization: a blob `readdir` had just enumerated
105
+ // could vanish before `readFile` on a contended filesystem, surfacing a
106
+ // spurious ENOENT. Reading under the substrate's per-repo write lock was
107
+ // the alternative considered and rejected -- it would serialize every
108
+ // read behind the single writer and couple read latency to write
109
+ // contention, whereas the pinned committed tree is lock-free and already
110
+ // consistent because every append lands as exactly one commit.
111
+ const reads = await opts.substrate.openCommittedReads(opts.principal, opts.repoId, opts.ref);
112
+ // Null mirrors the prior readdir-ENOENT contract: an uninitialised repo
113
+ // or an unresolved ref holds no runs at all.
114
+ if (reads === null)
115
+ return [];
116
+ const runDir = `${RUNS_PREFIX}/${runId}`;
117
+ const runChildren = await reads.listDir(runDir);
118
+ const decoder = new TextDecoder();
102
119
  const entries = [];
103
120
  // A terminated run is sealed into a single combined `events.jsonl`; an
104
- // in-flight run keeps per-event `events/<seq>.json` files. The combined
105
- // file's presence selects the read path; the two forms are mutually
106
- // exclusive in a run directory.
107
- let combinedRaw;
108
- try {
109
- combinedRaw = await fs.readFile(path.join(runDir, WORKFLOW_RUN_EVENTS_FILE), "utf8");
110
- }
111
- catch (cause) {
112
- if (!isErrnoNotFound(cause))
113
- throw cause;
114
- combinedRaw = null;
115
- }
116
- if (combinedRaw !== null) {
117
- // The two forms are mutually exclusive; a run carrying both is a
118
- // botched seal, and silently reading only the combined file would
119
- // mask it, so surface it instead.
120
- let perEventPresent = false;
121
- try {
122
- await fs.access(path.join(runDir, EVENTS_DIR));
123
- perEventPresent = true;
124
- }
125
- catch (cause) {
126
- if (!isErrnoNotFound(cause))
127
- throw cause;
128
- }
129
- if (perEventPresent) {
121
+ // in-flight run keeps per-event `events/<seq>.json` files. The two forms
122
+ // are mutually exclusive; a run carrying both is a botched seal, and
123
+ // silently reading only the combined file would mask it, so surface it.
124
+ const combined = runChildren.find((e) => e.type === "blob" && e.name === WORKFLOW_RUN_EVENTS_FILE);
125
+ const perEventDir = runChildren.find((e) => e.type === "tree" && e.name === EVENTS_DIR);
126
+ if (combined !== undefined) {
127
+ if (perEventDir !== undefined) {
130
128
  throw new Error(`workflow-runtime: run ${runId} carries both a combined ${WORKFLOW_RUN_EVENTS_FILE} and a per-event ${EVENTS_DIR}/ directory`);
131
129
  }
130
+ const combinedRaw = decoder.decode(await reads.readBlobByOid(combined.oid));
132
131
  for (const line of splitCombinedEventLog(combinedRaw)) {
133
132
  entries.push(parseEventEnvelope(line, `${opts.repoId.id}/${runId}/${WORKFLOW_RUN_EVENTS_FILE}`));
134
133
  }
135
134
  entries.sort((a, b) => a.seq - b.seq);
136
135
  return entries.map((e) => e.event);
137
136
  }
138
- const eventsDir = path.join(runDir, EVENTS_DIR);
139
- let filenames;
140
- try {
141
- filenames = await fs.readdir(eventsDir);
142
- }
143
- catch (cause) {
144
- if (isErrnoNotFound(cause))
145
- return [];
146
- throw cause;
147
- }
148
- for (const name of filenames) {
149
- const match = EVENT_FILENAME_RE.exec(name);
150
- if (match === null)
137
+ // Per-event form. `listDir` on an absent or non-tree path returns the
138
+ // empty array, so a run with no events reads as the empty log.
139
+ const eventBlobs = await reads.listDir(`${runDir}/${EVENTS_DIR}`);
140
+ for (const child of eventBlobs) {
141
+ if (child.type !== "blob")
151
142
  continue;
152
- const seqStr = match[1];
153
- if (seqStr === undefined)
143
+ const seqFromName = parseEventSeq(child.name);
144
+ if (seqFromName === null)
154
145
  continue;
155
- const seqFromName = Number.parseInt(seqStr, 10);
156
- const raw = await fs.readFile(path.join(eventsDir, name), "utf8");
157
- const source = `${opts.repoId.id}/${runId}/${EVENTS_DIR}/${name}`;
146
+ const raw = decoder.decode(await reads.readBlobByOid(child.oid));
147
+ const source = `${opts.repoId.id}/${runId}/${EVENTS_DIR}/${child.name}`;
158
148
  const entry = parseEventEnvelope(raw, source);
159
149
  if (entry.seq !== seqFromName) {
160
150
  throw new Error(`workflow-runtime: read ${source} body.seq ${String(entry.seq)} does not match filename seq ${String(seqFromName)}`);
@@ -213,9 +203,11 @@ function onDiskToWorkflowEvent(envelope) {
213
203
  * Translate a state-machine `WorkflowEvent` (using `kind` as the
214
204
  * discriminator) into the on-disk envelope shape (`{seq, type,
215
205
  * ...rest}`) the workflow-run kind handler validates and the
216
- * substrate's `subscribeKind` helper filters on.
206
+ * substrate's `subscribeKind` helper filters on. Exported so the
207
+ * supervisor's terminal-commit path encodes a supervisor-authored
208
+ * `RunFailed` through the same single source of the on-disk shape.
217
209
  */
218
- function workflowEventToOnDisk(event, seq) {
210
+ export function workflowEventToOnDisk(event, seq) {
219
211
  const { kind, seq: _eventSeq, ...rest } = event;
220
212
  return { seq, type: kind, ...rest };
221
213
  }
@@ -241,9 +233,18 @@ async function appendBatchEvents(opts, runId, events) {
241
233
  const lastEvent = events[events.length - 1];
242
234
  if (lastEvent === undefined)
243
235
  throw new Error("unreachable");
236
+ // A control-plane cancel append (an isolated `CancelRequested`, which the
237
+ // kind handler requires be signed by a supervisor principal) is written under
238
+ // `controlPlanePrincipal` when the host supplied one; every other batch --
239
+ // including run-body events -- keeps the workflow-process `principal`. A mixed
240
+ // batch is never a cancel, so it stays on `principal`.
241
+ const principal = opts.controlPlanePrincipal !== undefined &&
242
+ events.every((event) => event.kind === "CancelRequested")
243
+ ? opts.controlPlanePrincipal
244
+ : opts.principal;
244
245
  let seqConflict = null;
245
246
  try {
246
- await opts.substrate.writeTreePreservingPrefix(opts.principal, opts.repoId, opts.ref, {
247
+ await opts.substrate.writeTreePreservingPrefix(principal, opts.repoId, opts.ref, {
247
248
  preservePrefix: prefix,
248
249
  merge: async (existing) => {
249
250
  // The runtime body emits events at `state.lastSeq + 1` and
@@ -256,13 +257,9 @@ async function appendBatchEvents(opts, runId, events) {
256
257
  let priorLastSeq = 0;
257
258
  for (const filepath of existing.keys()) {
258
259
  const name = filepath.slice(prefix.length);
259
- const match = EVENT_FILENAME_RE.exec(name);
260
- if (match === null)
261
- continue;
262
- const seqStr = match[1];
263
- if (seqStr === undefined)
260
+ const seq = parseEventSeq(name);
261
+ if (seq === null)
264
262
  continue;
265
- const seq = Number.parseInt(seqStr, 10);
266
263
  if (seq > priorLastSeq)
267
264
  priorLastSeq = seq;
268
265
  }
@@ -336,9 +333,3 @@ async function* subscribeRun(opts, runId, subOpts) {
336
333
  yield { seq: entry.event.seq, event };
337
334
  }
338
335
  }
339
- function isErrnoNotFound(cause) {
340
- if (cause === null || typeof cause !== "object")
341
- return false;
342
- const code = cause.code;
343
- return code === "ENOENT";
344
- }