@intx/workflow-host 0.2.2

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 (81) hide show
  1. package/LICENSE +176 -0
  2. package/README.md +287 -0
  3. package/dist/adapters/blob-substrate.d.ts +49 -0
  4. package/dist/adapters/blob-substrate.js +140 -0
  5. package/dist/adapters/repo-store.d.ts +39 -0
  6. package/dist/adapters/repo-store.js +344 -0
  7. package/dist/adapters/spawn-child.d.ts +74 -0
  8. package/dist/adapters/spawn-child.js +152 -0
  9. package/dist/adapters/step-invoker.d.ts +114 -0
  10. package/dist/adapters/step-invoker.js +360 -0
  11. package/dist/child/env-bootstrap.d.ts +56 -0
  12. package/dist/child/env-bootstrap.js +120 -0
  13. package/dist/child/from-process-env.d.ts +127 -0
  14. package/dist/child/from-process-env.js +183 -0
  15. package/dist/child/index.d.ts +9 -0
  16. package/dist/child/index.js +9 -0
  17. package/dist/child/outbound-mail-bridge.d.ts +36 -0
  18. package/dist/child/outbound-mail-bridge.js +143 -0
  19. package/dist/child/proxy-repo-store.d.ts +27 -0
  20. package/dist/child/proxy-repo-store.js +200 -0
  21. package/dist/child/run-child.d.ts +320 -0
  22. package/dist/child/run-child.js +900 -0
  23. package/dist/child/self-discovery.d.ts +29 -0
  24. package/dist/child/self-discovery.js +57 -0
  25. package/dist/child/substrate-write-bridge.d.ts +72 -0
  26. package/dist/child/substrate-write-bridge.js +188 -0
  27. package/dist/child/supervisor-backed-transport.d.ts +10 -0
  28. package/dist/child/supervisor-backed-transport.js +113 -0
  29. package/dist/child/warm-agent-cache.d.ts +78 -0
  30. package/dist/child/warm-agent-cache.js +112 -0
  31. package/dist/drain-controller.d.ts +37 -0
  32. package/dist/drain-controller.js +46 -0
  33. package/dist/index.d.ts +10 -0
  34. package/dist/index.js +10 -0
  35. package/dist/ipc/control-channel.d.ts +336 -0
  36. package/dist/ipc/control-channel.js +532 -0
  37. package/dist/ipc/crypto.d.ts +46 -0
  38. package/dist/ipc/crypto.js +126 -0
  39. package/dist/ipc/envelope.d.ts +53 -0
  40. package/dist/ipc/envelope.js +88 -0
  41. package/dist/ipc/event-channel.d.ts +677 -0
  42. package/dist/ipc/event-channel.js +278 -0
  43. package/dist/ipc/index.d.ts +4 -0
  44. package/dist/ipc/index.js +143 -0
  45. package/dist/mail-bus/hub-transport-adapter.d.ts +30 -0
  46. package/dist/mail-bus/hub-transport-adapter.js +76 -0
  47. package/dist/mail-bus/index.d.ts +1 -0
  48. package/dist/mail-bus/index.js +1 -0
  49. package/dist/seams/index.d.ts +3 -0
  50. package/dist/seams/index.js +3 -0
  51. package/dist/seams/scheduler-adapter.d.ts +3 -0
  52. package/dist/seams/scheduler-adapter.js +24 -0
  53. package/dist/seams/scheduler.d.ts +94 -0
  54. package/dist/seams/scheduler.js +397 -0
  55. package/dist/seams/signal-channel.d.ts +74 -0
  56. package/dist/seams/signal-channel.js +304 -0
  57. package/dist/supervisor/cancel-signing.d.ts +68 -0
  58. package/dist/supervisor/cancel-signing.js +144 -0
  59. package/dist/supervisor/child-termination.d.ts +51 -0
  60. package/dist/supervisor/child-termination.js +76 -0
  61. package/dist/supervisor/credentials.d.ts +101 -0
  62. package/dist/supervisor/credentials.js +153 -0
  63. package/dist/supervisor/dispatch-attribution.d.ts +37 -0
  64. package/dist/supervisor/dispatch-attribution.js +114 -0
  65. package/dist/supervisor/drain-timeout.d.ts +127 -0
  66. package/dist/supervisor/drain-timeout.js +231 -0
  67. package/dist/supervisor/index.d.ts +7 -0
  68. package/dist/supervisor/index.js +6 -0
  69. package/dist/supervisor/recycle.d.ts +212 -0
  70. package/dist/supervisor/recycle.js +440 -0
  71. package/dist/supervisor/run-event-compaction.d.ts +34 -0
  72. package/dist/supervisor/run-event-compaction.js +115 -0
  73. package/dist/supervisor/spawn-env.d.ts +39 -0
  74. package/dist/supervisor/spawn-env.js +36 -0
  75. package/dist/supervisor/supervisor.d.ts +202 -0
  76. package/dist/supervisor/supervisor.js +2244 -0
  77. package/dist/supervisor/terminal-broadcaster.d.ts +45 -0
  78. package/dist/supervisor/terminal-broadcaster.js +184 -0
  79. package/dist/supervisor/types.d.ts +542 -0
  80. package/dist/supervisor/types.js +10 -0
  81. package/package.json +35 -0
@@ -0,0 +1,140 @@
1
+ // Production `WorkflowRuntimeEnv.BlobSubstrate` adapter.
2
+ //
3
+ // Mirrors the in-memory `runlocal/blob-substrate.ts` ref shape so the
4
+ // runtime body's `ref` contract is symmetric across substrates:
5
+ //
6
+ // - `inline:<encoded-json>` for values whose JSON-stringified form
7
+ // fits inside the inline threshold (1 MiB by default). The ref's
8
+ // whole body after the `inline:` prefix is the verbatim JSON
9
+ // payload; `resolveRef` parses it back. Storing the encoded JSON
10
+ // instead of a substrate write avoids a commit per small output.
11
+ // - `blob:<sha256-prefix>` for values above the threshold. The
12
+ // encoded bytes land at `runs/<runId>/blobs/<sha256-prefix>` on
13
+ // the workflow-run repo via `writeTreePreservingPrefix`;
14
+ // `resolveRef` reads them back from the repo directory.
15
+ //
16
+ // Constructed per-run -- the `runId` is part of the on-disk path and
17
+ // each run gets its own adapter. The sibling repo-store adapter is
18
+ // per-deployment because every run shares the same events ref; the
19
+ // blob-substrate's per-run path means the closure carries `runId`
20
+ // rather than re-deriving it on every call.
21
+ //
22
+ // Error translation matches the sibling repo-store adapter: the
23
+ // substrate's `path_violation:` prefix is stripped so the runtime sees
24
+ // a clean reason; every other error propagates unchanged.
25
+ //
26
+ // `ephemeral: false` -- blob refs survive instance turnover because
27
+ // they resolve back to bytes on the workflow-run repo.
28
+ import { hexEncode } from "@intx/types";
29
+ const ONE_MIB = 1024 * 1024;
30
+ const SHA256_PREFIX_BYTES = 32;
31
+ const RUNS_PREFIX = "runs";
32
+ const BLOBS_DIR = "blobs";
33
+ const INLINE_PREFIX = "inline:";
34
+ const BLOB_PREFIX = "blob:";
35
+ /**
36
+ * Construct the production `WorkflowRuntimeEnv.BlobSubstrate` adapter
37
+ * for the supplied run. The returned object satisfies the runtime-env
38
+ * interface; substrate handle, principal, repo routing, and run id
39
+ * live in closure.
40
+ */
41
+ export function createWorkflowRunBlobSubstrate(opts) {
42
+ const inlineMax = opts.inlineMaxBytes ?? ONE_MIB;
43
+ return {
44
+ ephemeral: false,
45
+ async recordOutput(stepId, attempt, value) {
46
+ const encoded = JSON.stringify(value);
47
+ if (encoded === undefined) {
48
+ // Mirrors the in-memory adapter: JSON.stringify returns
49
+ // undefined for undefined, functions, and symbols. Surfacing
50
+ // it as an error keeps the contract honest -- silent coercion
51
+ // to null destroys information about the actual step output.
52
+ throw new Error(`step ${stepId} attempt ${String(attempt)} produced an output the blob substrate cannot serialize (typeof ${typeof value})`);
53
+ }
54
+ if (encoded.length <= inlineMax) {
55
+ return { ref: `${INLINE_PREFIX}${encoded}` };
56
+ }
57
+ const bytes = new TextEncoder().encode(encoded);
58
+ const key = await sha256Hex(bytes);
59
+ await writeBlob(opts, key, bytes);
60
+ return { ref: `${BLOB_PREFIX}${key}` };
61
+ },
62
+ async resolveRef(ref) {
63
+ if (ref.startsWith(INLINE_PREFIX)) {
64
+ return JSON.parse(ref.slice(INLINE_PREFIX.length));
65
+ }
66
+ if (ref.startsWith(BLOB_PREFIX)) {
67
+ const key = ref.slice(BLOB_PREFIX.length);
68
+ const bytes = await readBlob(opts, key);
69
+ return JSON.parse(new TextDecoder().decode(bytes));
70
+ }
71
+ throw new Error(`unrecognized ref ${ref}`);
72
+ },
73
+ };
74
+ }
75
+ function blobsPrefixFor(runId) {
76
+ return `${RUNS_PREFIX}/${runId}/${BLOBS_DIR}/`;
77
+ }
78
+ async function sha256Hex(bytes) {
79
+ const digest = await crypto.subtle.digest("SHA-256",
80
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- ArrayBuffer-backed at the call site; Web Crypto's BufferSource type rejects Uint8Array<ArrayBufferLike> under TS 5.9 (microsoft/TypeScript#62240)
81
+ bytes);
82
+ const hex = hexEncode(new Uint8Array(digest));
83
+ // A SHA-256 hex string is always 64 chars, so this is unreachable
84
+ // today; kept as a cheap invariant pinning the on-disk blob-key
85
+ // shape should the digest width ever change.
86
+ if (hex.length !== SHA256_PREFIX_BYTES * 2) {
87
+ throw new Error(`unexpected sha256 hex length ${String(hex.length)}; expected ${String(SHA256_PREFIX_BYTES * 2)}`);
88
+ }
89
+ return hex;
90
+ }
91
+ async function writeBlob(opts, key, bytes) {
92
+ const prefix = blobsPrefixFor(opts.runId);
93
+ try {
94
+ await opts.substrate.writeTreePreservingPrefix(opts.principal, opts.repoId, opts.ref, {
95
+ preservePrefix: prefix,
96
+ merge: async (existing) => {
97
+ const files = {};
98
+ for (const [k, v] of existing)
99
+ files[k] = v;
100
+ // Content-addressed by sha256: a re-recorded value with the
101
+ // same bytes lands at the same path. Overwriting the entry
102
+ // with identical bytes is harmless because the workflow-run
103
+ // kind handler's append-only checks compare prior-vs-
104
+ // prospective bytes and accept matches.
105
+ files[`${prefix}${key}`] = bytes;
106
+ return files;
107
+ },
108
+ message: `record blob ${key} for run ${opts.runId}`,
109
+ });
110
+ }
111
+ catch (cause) {
112
+ const message = cause instanceof Error ? cause.message : String(cause);
113
+ if (message.startsWith("path_violation: ")) {
114
+ const reason = message.slice("path_violation: ".length);
115
+ throw new Error(reason, { cause });
116
+ }
117
+ throw cause;
118
+ }
119
+ }
120
+ async function readBlob(opts, key) {
121
+ const fs = await import("node:fs/promises");
122
+ const path = await import("node:path");
123
+ const dir = opts.substrate.getRepoDir(opts.repoId);
124
+ const blobPath = path.join(dir, RUNS_PREFIX, opts.runId, BLOBS_DIR, key);
125
+ try {
126
+ return await fs.readFile(blobPath);
127
+ }
128
+ catch (cause) {
129
+ if (isErrnoNotFound(cause)) {
130
+ throw new Error(`workflow-runtime: blob ${key} for run ${opts.runId} not found on disk`, { cause });
131
+ }
132
+ throw cause;
133
+ }
134
+ }
135
+ function isErrnoNotFound(cause) {
136
+ if (cause === null || typeof cause !== "object")
137
+ return false;
138
+ const code = cause.code;
139
+ return code === "ENOENT";
140
+ }
@@ -0,0 +1,39 @@
1
+ import type { Principal, RepoId, RepoStore as SubstrateRepoStore } from "@intx/hub-sessions/substrate";
2
+ import type { RepoStore } from "@intx/workflow";
3
+ export type WorkflowRunRepoStoreOpts = {
4
+ /**
5
+ * Substrate handle the adapter reads from and writes to. The caller
6
+ * wires this against the substrate's registered workflow-run kind
7
+ * handler -- the adapter's writes land under
8
+ * `runs/<runId>/events/<seq>.json` and the handler's `validatePush`
9
+ * is the layer that catches structural rejections.
10
+ */
11
+ substrate: SubstrateRepoStore;
12
+ /**
13
+ * Workflow-run repo identifying the owning deployment. A single
14
+ * adapter instance services every run inside this deployment; the
15
+ * adapter's `read` / `append` / `subscribe` calls take a `runId` to
16
+ * route within the repo.
17
+ */
18
+ repoId: RepoId;
19
+ /**
20
+ * Principal the adapter presents to the substrate. The workflow-run
21
+ * kind handler accepts a workflow-process principal scoped to the
22
+ * deployment as the runtime body's writer; that is the principal
23
+ * shape the production wiring supplies.
24
+ */
25
+ principal: Principal;
26
+ /**
27
+ * Events ref the adapter reads from and writes to. The workflow-run
28
+ * repo layout pins all `runs/<runId>/events/` blobs under a single
29
+ * moving ref. Callers typically supply `"refs/heads/main"`.
30
+ */
31
+ ref: string;
32
+ };
33
+ /**
34
+ * Construct the production `WorkflowRuntimeEnv.RepoStore` adapter for
35
+ * the supplied deployment. The returned object satisfies the
36
+ * runtime-env interface; the substrate handle and per-deployment
37
+ * routing live in closure.
38
+ */
39
+ export declare function createWorkflowRunRepoStore(opts: WorkflowRunRepoStoreOpts): RepoStore;
@@ -0,0 +1,344 @@
1
+ // Production `WorkflowRuntimeEnv.RepoStore` adapter.
2
+ //
3
+ // The runtime body sees the runtime-env shape: `read(runId)`,
4
+ // `append(runId, event)`, `subscribe(runId, opts)`. This adapter
5
+ // translates each call into operations against the workflow-run
6
+ // substrate (`@intx/hub-sessions` RepoStore plus the workflow-run kind
7
+ // handler) for a single deployment's workflow-run repo.
8
+ //
9
+ // On-disk envelope shape: every event blob committed under
10
+ // `runs/<runId>/events/<seq>.json` carries `{ seq, type, ...rest }`
11
+ // where `type` is the workflow-event discriminator (matching the
12
+ // substrate's `subscribeKind` `type` field and the workflow-run kind
13
+ // handler's `EventEnvelope` contract). The state-machine `WorkflowEvent`
14
+ // uses `kind` as its discriminator; the adapter performs the
15
+ // `kind` <-> `type` translation at the substrate boundary so the
16
+ // runtime body and state machine never see a mismatched discriminator.
17
+ //
18
+ // Append-result error translation (interface-decisions Bonus 1):
19
+ // - `seq_conflict`: the caller supplied an `event.seq` that does not
20
+ // match the seq computed from the substrate's prior tree. The
21
+ // runtime body is the single writer to the run's event log; a
22
+ // mismatch here means a parallel writer landed in between the
23
+ // caller's read and the merge under the substrate's per-repo lock.
24
+ // Translated into a thrown Error naming the run and the diverging
25
+ // seqs. No retries: the runtime decides higher up.
26
+ // - `validate_failed`: the substrate's kind handler rejected the
27
+ // prospective tree via `validatePush`. Translated into a thrown
28
+ // Error carrying the handler's `reason` text. No retries.
29
+ import { type } from "arktype";
30
+ import { subscribeKind, WORKFLOW_RUN_EVENTS_FILE, splitCombinedEventLog, } from "@intx/hub-sessions/substrate";
31
+ const RUNS_PREFIX = "runs";
32
+ const EVENTS_DIR = "events";
33
+ const EVENT_FILENAME_RE = /^(0|[1-9][0-9]*)\.json$/;
34
+ /**
35
+ * On-disk envelope shape committed under
36
+ * `runs/<runId>/events/<seq>.json`. Carries the seq cross-check the
37
+ * workflow-run kind handler validates against the filename, the `type`
38
+ * discriminator the substrate's `subscribeKind` filters on, and an
39
+ * open object for the rest of the workflow-event fields. Switching
40
+ * from the catch-all `"+": "ignore"` to `"+": "delete"` would strip
41
+ * unknown fields; the adapter wants them preserved so the round-trip
42
+ * back into `WorkflowEvent` carries every state-machine field.
43
+ */
44
+ const OnDiskEnvelope = type({
45
+ seq: "number >= 0",
46
+ type: "string",
47
+ "[string]": "unknown",
48
+ });
49
+ /**
50
+ * Every state-machine `WorkflowEvent` kind. Used to populate
51
+ * `subscribeKind`'s `kinds` filter so the substrate's typed tail
52
+ * surfaces every event blob the runtime cares about (the substrate
53
+ * helper filters on the `type` field; an empty filter would yield
54
+ * nothing).
55
+ */
56
+ const ALL_WORKFLOW_EVENT_TYPES = [
57
+ "RunStarted",
58
+ "StepStarted",
59
+ "StepCompleted",
60
+ "StepFailed",
61
+ "AttemptScheduled",
62
+ "SignalAwaited",
63
+ "SignalReceived",
64
+ "TimerSet",
65
+ "TimerFired",
66
+ "CancelRequested",
67
+ "CancelPropagated",
68
+ "ChildSpawned",
69
+ "ChildCancelRequested",
70
+ "ChildCompleted",
71
+ "RunCompleted",
72
+ "RunFailed",
73
+ "RunCancelled",
74
+ ];
75
+ /**
76
+ * Construct the production `WorkflowRuntimeEnv.RepoStore` adapter for
77
+ * the supplied deployment. The returned object satisfies the
78
+ * runtime-env interface; the substrate handle and per-deployment
79
+ * routing live in closure.
80
+ */
81
+ export function createWorkflowRunRepoStore(opts) {
82
+ return {
83
+ async read(runId) {
84
+ return readAllEventsForRun(opts, runId);
85
+ },
86
+ async append(runId, event) {
87
+ await appendBatchEvents(opts, runId, [event]);
88
+ },
89
+ async appendBatch(runId, events) {
90
+ await appendBatchEvents(opts, runId, events);
91
+ },
92
+ subscribe(runId, subOpts) {
93
+ return subscribeRun(opts, runId, subOpts);
94
+ },
95
+ };
96
+ }
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);
102
+ const entries = [];
103
+ // 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) {
130
+ throw new Error(`workflow-runtime: run ${runId} carries both a combined ${WORKFLOW_RUN_EVENTS_FILE} and a per-event ${EVENTS_DIR}/ directory`);
131
+ }
132
+ for (const line of splitCombinedEventLog(combinedRaw)) {
133
+ entries.push(parseEventEnvelope(line, `${opts.repoId.id}/${runId}/${WORKFLOW_RUN_EVENTS_FILE}`));
134
+ }
135
+ entries.sort((a, b) => a.seq - b.seq);
136
+ return entries.map((e) => e.event);
137
+ }
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)
151
+ continue;
152
+ const seqStr = match[1];
153
+ if (seqStr === undefined)
154
+ 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}`;
158
+ const entry = parseEventEnvelope(raw, source);
159
+ if (entry.seq !== seqFromName) {
160
+ throw new Error(`workflow-runtime: read ${source} body.seq ${String(entry.seq)} does not match filename seq ${String(seqFromName)}`);
161
+ }
162
+ entries.push(entry);
163
+ }
164
+ entries.sort((a, b) => a.seq - b.seq);
165
+ return entries.map((e) => e.event);
166
+ }
167
+ // Parse one on-disk event envelope -- a per-event file's bytes or one line
168
+ // of a combined `events.jsonl` (which holds the same bytes verbatim). The
169
+ // per-event caller additionally cross-checks the seq against the filename;
170
+ // the combined form carries the seq only in the body.
171
+ function parseEventEnvelope(raw, source) {
172
+ let parsed;
173
+ try {
174
+ parsed = JSON.parse(raw);
175
+ }
176
+ catch (cause) {
177
+ throw new Error(`workflow-runtime: read ${source} is not valid JSON`, {
178
+ cause,
179
+ });
180
+ }
181
+ const envelope = OnDiskEnvelope(parsed);
182
+ if (envelope instanceof type.errors) {
183
+ throw new Error(`workflow-runtime: read ${source} envelope invalid: ${envelope.summary}`);
184
+ }
185
+ return { seq: envelope.seq, event: onDiskToWorkflowEvent(envelope) };
186
+ }
187
+ /**
188
+ * Translate a validated on-disk envelope (`{seq, type, ...rest}`) into
189
+ * the state-machine `WorkflowEvent` shape (`{seq, kind, ...rest}`).
190
+ * The state-machine `WorkflowEvent` discriminated union is narrowed by
191
+ * the next layer (`applyEvent` / `resumeFromLog`); the adapter
192
+ * surfaces a structural object that carries the discriminator under
193
+ * its state-machine field name without re-asserting through the
194
+ * discriminated union here.
195
+ */
196
+ function onDiskToWorkflowEvent(envelope) {
197
+ const { seq, type: typeStr, ...rest } = envelope;
198
+ const built = { ...rest, kind: typeStr, seq };
199
+ // The runtime body and state machine read events through
200
+ // `WorkflowEvent`'s `kind` discriminator; the adapter has confirmed
201
+ // the on-disk envelope carries a string `type` and integer `seq`, so
202
+ // the constructed object satisfies the discriminator contract. The
203
+ // narrow against the discriminated-union variants lives in the
204
+ // state machine (`applyEvent` / `resumeFromLog`), not here -- the
205
+ // in-memory store in `runlocal` follows the same pattern and stores
206
+ // `WorkflowEvent` objects opaquely. Synthesizing a 17-variant
207
+ // arktype validator at the adapter layer would duplicate the
208
+ // state-machine narrow.
209
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- WorkflowEvent's discriminated union is narrowed downstream by the state machine; no runtime validator at this layer
210
+ return built;
211
+ }
212
+ /**
213
+ * Translate a state-machine `WorkflowEvent` (using `kind` as the
214
+ * discriminator) into the on-disk envelope shape (`{seq, type,
215
+ * ...rest}`) the workflow-run kind handler validates and the
216
+ * substrate's `subscribeKind` helper filters on.
217
+ */
218
+ function workflowEventToOnDisk(event, seq) {
219
+ const { kind, seq: _eventSeq, ...rest } = event;
220
+ return { seq, type: kind, ...rest };
221
+ }
222
+ /**
223
+ * Append one or more contiguous events to a run's event log in a
224
+ * SINGLE durable commit. The events must carry strictly-monotonic,
225
+ * gap-free seqs continuing the prior tree's tip (the first event's seq
226
+ * is `priorLastSeq + 1`); the merge writes every `events/<seq>.json`
227
+ * blob into one `writeTreePreservingPrefix` tree-rewrite, so N events
228
+ * cost one tree-rewrite + one ref-advance instead of N. An empty
229
+ * `events` array is a no-op. The seq-contiguity check is performed
230
+ * against the supplied events themselves so a caller that buffered a
231
+ * gap surfaces a single-writer conflict rather than a silent gap in
232
+ * the tree.
233
+ */
234
+ async function appendBatchEvents(opts, runId, events) {
235
+ if (events.length === 0)
236
+ return;
237
+ const prefix = `${RUNS_PREFIX}/${runId}/${EVENTS_DIR}/`;
238
+ const firstEvent = events[0];
239
+ if (firstEvent === undefined)
240
+ throw new Error("unreachable");
241
+ const lastEvent = events[events.length - 1];
242
+ if (lastEvent === undefined)
243
+ throw new Error("unreachable");
244
+ let seqConflict = null;
245
+ try {
246
+ await opts.substrate.writeTreePreservingPrefix(opts.principal, opts.repoId, opts.ref, {
247
+ preservePrefix: prefix,
248
+ merge: async (existing) => {
249
+ // The runtime body emits events at `state.lastSeq + 1` and
250
+ // `emptyState.lastSeq = 0`, so the first append on an empty
251
+ // events tree carries seq=1. The adapter mirrors that
252
+ // convention: the prior tree's lastSeq is the maximum seq
253
+ // observed under the prefix, or 0 when no events exist yet;
254
+ // the expected next seq is `priorLastSeq + 1`. Every event
255
+ // in the batch must continue contiguously from there.
256
+ let priorLastSeq = 0;
257
+ for (const filepath of existing.keys()) {
258
+ 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)
264
+ continue;
265
+ const seq = Number.parseInt(seqStr, 10);
266
+ if (seq > priorLastSeq)
267
+ priorLastSeq = seq;
268
+ }
269
+ const files = {};
270
+ for (const [k, v] of existing)
271
+ files[k] = v;
272
+ let expectedSeq = priorLastSeq + 1;
273
+ for (const event of events) {
274
+ if (event.seq !== expectedSeq) {
275
+ // Capture the divergence and return an unchanged tree so
276
+ // the substrate's commit short-circuits (the kind handler
277
+ // accepts an empty diff against the same prior tree); the
278
+ // throw happens outside the merge callback so the
279
+ // adapter's error carries the full context. The empty
280
+ // tree returned here preserves the existing prefix
281
+ // bit-for-bit so the rollback path inside the substrate
282
+ // does not need to fire.
283
+ seqConflict = { expected: expectedSeq, supplied: event.seq };
284
+ const passthrough = {};
285
+ for (const [k, v] of existing)
286
+ passthrough[k] = v;
287
+ return passthrough;
288
+ }
289
+ const onDisk = workflowEventToOnDisk(event, expectedSeq);
290
+ files[`${prefix}${String(expectedSeq)}.json`] =
291
+ JSON.stringify(onDisk);
292
+ expectedSeq += 1;
293
+ }
294
+ return files;
295
+ },
296
+ message: events.length === 1
297
+ ? `append workflow event ${firstEvent.kind} for run ${runId}`
298
+ : `append ${String(events.length)} workflow events ${firstEvent.kind}..${lastEvent.kind} for run ${runId}`,
299
+ });
300
+ }
301
+ catch (cause) {
302
+ const message = cause instanceof Error ? cause.message : String(cause);
303
+ if (message.startsWith("path_violation: ")) {
304
+ const reason = message.slice("path_violation: ".length);
305
+ throw new Error(reason, { cause });
306
+ }
307
+ throw cause;
308
+ }
309
+ if (seqConflict !== null) {
310
+ const conflict = seqConflict;
311
+ throw new Error(`workflow-runtime: seq conflict on append to ${runId}; single-writer invariant violated (expected seq ${String(conflict.expected)} from prior tree, caller supplied ${String(conflict.supplied)})`);
312
+ }
313
+ }
314
+ async function* subscribeRun(opts, runId, subOpts) {
315
+ // `subscribeKind` requires a `kinds` filter; supplying every known
316
+ // workflow-event `type` keeps the runtime body's contract intact (it
317
+ // wants every event for the run, not a subset). The substrate helper
318
+ // also surfaces per-run attribution via the entry's `runId`, which
319
+ // we use to filter just this run's events. Replay-then-live mode
320
+ // mirrors `SubscribeOpts.from`: `"head"` emits only events committed
321
+ // strictly after subscription, `{ seq }` enumerates prior events at
322
+ // or after the supplied seq before transitioning to live.
323
+ const kindOpts = {
324
+ signal: subOpts.signal,
325
+ from: subOpts.from,
326
+ kinds: ALL_WORKFLOW_EVENT_TYPES,
327
+ };
328
+ if (subOpts.bufferLimit !== undefined) {
329
+ kindOpts.bufferLimit = subOpts.bufferLimit;
330
+ }
331
+ const iter = subscribeKind(opts.substrate, opts.principal, opts.repoId, opts.ref, OnDiskEnvelope, kindOpts);
332
+ for await (const entry of iter) {
333
+ if (entry.runId !== runId)
334
+ continue;
335
+ const event = onDiskToWorkflowEvent(entry.event);
336
+ yield { seq: entry.event.seq, event };
337
+ }
338
+ }
339
+ function isErrnoNotFound(cause) {
340
+ if (cause === null || typeof cause !== "object")
341
+ return false;
342
+ const code = cause.code;
343
+ return code === "ENOENT";
344
+ }
@@ -0,0 +1,74 @@
1
+ import type { Principal, RepoStore } from "@intx/hub-sessions/substrate";
2
+ import type { SpawnChildWorkflow, WorkflowDefinition } from "@intx/workflow";
3
+ /**
4
+ * The terminal-status shape the runtime body expects back from a
5
+ * spawn. Mirrored from `SpawnChildWorkflow`'s return type so the
6
+ * `runChild` callback's signature is symmetric with the adapter's.
7
+ */
8
+ export type ChildTerminalStatus = "completed" | "failed" | "cancelled";
9
+ /**
10
+ * Runtime-supplied child execution callback. The supervisor owns the
11
+ * child `WorkflowRuntimeEnv` construction (per-deployment substrate,
12
+ * per-run blob substrate, child director registry) and the
13
+ * `runtimeRun` invocation; the adapter is the single resolution
14
+ * point that hands the supervisor a concrete `WorkflowDefinition`
15
+ * alongside the parent attribution the runtime body produced.
16
+ *
17
+ * The callback receives the same `AbortSignal` the parent runtime
18
+ * passed into the adapter so a parent-initiated cancellation
19
+ * propagates to the child without an intermediate wrapper.
20
+ */
21
+ export type RunChildWorkflow = (input: {
22
+ definition: WorkflowDefinition;
23
+ definitionRef: string;
24
+ childRunId: string;
25
+ input: unknown;
26
+ parentRunId: string;
27
+ parentStepId: string;
28
+ signal: AbortSignal;
29
+ }) => Promise<{
30
+ terminalStatus: ChildTerminalStatus;
31
+ }>;
32
+ export interface WorkflowSpawnChildOpts {
33
+ /**
34
+ * Substrate the deploy orchestrator wrote the workflow asset into.
35
+ * The adapter reads the workflow envelope through
36
+ * `substrate.getRepoDir` -- the deploy-time `writeTree` already
37
+ * materialized the file under the returned directory and a flat
38
+ * `fs.readFile` does not need to walk the git object database.
39
+ */
40
+ substrate: RepoStore;
41
+ /**
42
+ * Principal the adapter presents to the substrate for any future
43
+ * authorize-gated read path. The current implementation does not
44
+ * gate `getRepoDir` (the substrate documents it as a pure path
45
+ * computation), but holding the principal in closure keeps the
46
+ * adapter symmetric with the sibling production adapters and ready
47
+ * for a future API that surfaces an authorize gate on the same
48
+ * read path.
49
+ */
50
+ principal: Principal;
51
+ /**
52
+ * Ref under the workflow asset's repo whose tree holds the
53
+ * deployed `workflow.json`. Callers typically supply
54
+ * `"refs/heads/main"` -- the workflow-kind handler enforces the
55
+ * envelope's structural shape at push time so a deploy ref read
56
+ * here either yields a valid envelope or surfaces a targeted
57
+ * parse/validation error.
58
+ */
59
+ deployRef: string;
60
+ /**
61
+ * Runtime-supplied child execution callback. The adapter delegates
62
+ * here once the `WorkflowDefinition` is resolved; the supervisor
63
+ * owns the child `WorkflowRuntimeEnv` and the `runtimeRun`
64
+ * invocation.
65
+ */
66
+ runChild: RunChildWorkflow;
67
+ }
68
+ /**
69
+ * Construct the production `WorkflowRuntimeEnv.SpawnChildWorkflow`
70
+ * adapter. The substrate handle, the principal, the deploy ref, and
71
+ * the runtime-supplied child callback live in closure; the returned
72
+ * callable satisfies the runtime-env interface.
73
+ */
74
+ export declare function createWorkflowSpawnChild(opts: WorkflowSpawnChildOpts): SpawnChildWorkflow;