@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.
- package/README.md +77 -14
- package/dist/adapters/mail-part-store.d.ts +46 -0
- package/dist/adapters/mail-part-store.js +251 -0
- package/dist/adapters/repo-store.d.ts +22 -1
- package/dist/adapters/repo-store.js +56 -65
- package/dist/adapters/spawn-child.d.ts +109 -44
- package/dist/adapters/spawn-child.js +77 -81
- package/dist/adapters/step-invoker.d.ts +52 -2
- package/dist/adapters/step-invoker.js +284 -37
- 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/env-bootstrap.d.ts +20 -6
- package/dist/child/env-bootstrap.js +9 -1
- 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 +6 -2
- 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/parked-correlations.d.ts +42 -0
- package/dist/child/parked-correlations.js +80 -0
- package/dist/child/pending-request.d.ts +89 -0
- package/dist/child/pending-request.js +80 -0
- package/dist/child/proxy-repo-store.d.ts +3 -2
- package/dist/child/proxy-repo-store.js +2 -0
- package/dist/child/run-child.d.ts +170 -14
- package/dist/child/run-child.js +569 -155
- package/dist/child/self-discovery.d.ts +10 -0
- package/dist/child/self-discovery.js +25 -1
- 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/verified-definition-loader.d.ts +33 -0
- package/dist/child/verified-definition-loader.js +43 -0
- package/dist/child/warm-agent-cache.d.ts +44 -4
- package/dist/child/warm-agent-cache.js +41 -10
- package/dist/index.d.ts +6 -4
- package/dist/index.js +6 -4
- package/dist/ipc/control-channel.d.ts +151 -2
- package/dist/ipc/control-channel.js +222 -29
- package/dist/ipc/event-channel.d.ts +32 -1
- package/dist/ipc/index.d.ts +1 -1
- package/dist/ipc/index.js +1 -1
- package/dist/mail-bus/hub-transport-adapter.d.ts +12 -7
- package/dist/mail-bus/hub-transport-adapter.js +9 -5
- 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 +16 -6
- package/dist/seams/scheduler.js +87 -97
- package/dist/supervisor/cancel-signing.d.ts +2 -2
- package/dist/supervisor/cancel-signing.js +4 -8
- package/dist/supervisor/credentials.d.ts +28 -15
- package/dist/supervisor/credentials.js +7 -7
- package/dist/supervisor/dispatch-attribution.js +1 -1
- package/dist/supervisor/drain-timeout.d.ts +2 -2
- package/dist/supervisor/drain-timeout.js +1 -1
- package/dist/supervisor/index.d.ts +3 -3
- package/dist/supervisor/index.js +2 -2
- package/dist/supervisor/recycle.d.ts +10 -3
- package/dist/supervisor/recycle.js +18 -7
- package/dist/supervisor/run-event-compaction.d.ts +5 -5
- package/dist/supervisor/run-event-compaction.js +14 -19
- package/dist/supervisor/run-event-recovery.d.ts +34 -0
- package/dist/supervisor/run-event-recovery.js +45 -0
- package/dist/supervisor/spawn-env.d.ts +2 -2
- package/dist/supervisor/spawn-env.js +1 -1
- package/dist/supervisor/supervisor.d.ts +106 -26
- package/dist/supervisor/supervisor.js +1903 -414
- package/dist/supervisor/terminal-commit.d.ts +36 -0
- package/dist/supervisor/terminal-commit.js +130 -0
- package/dist/supervisor/types.d.ts +180 -23
- 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 +187 -0
- package/dist/workflow-definition-loader.js +422 -0
- 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
|
|
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.
|
|
@@ -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
|
-
`
|
|
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`
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
|
153
|
-
if (
|
|
143
|
+
const seqFromName = parseEventSeq(child.name);
|
|
144
|
+
if (seqFromName === null)
|
|
154
145
|
continue;
|
|
155
|
-
const
|
|
156
|
-
const
|
|
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(
|
|
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
|
|
260
|
-
if (
|
|
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
|
-
}
|