@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.
- package/LICENSE +176 -0
- package/README.md +287 -0
- package/dist/adapters/blob-substrate.d.ts +49 -0
- package/dist/adapters/blob-substrate.js +140 -0
- package/dist/adapters/repo-store.d.ts +39 -0
- package/dist/adapters/repo-store.js +344 -0
- package/dist/adapters/spawn-child.d.ts +74 -0
- package/dist/adapters/spawn-child.js +152 -0
- package/dist/adapters/step-invoker.d.ts +114 -0
- package/dist/adapters/step-invoker.js +360 -0
- package/dist/child/env-bootstrap.d.ts +56 -0
- package/dist/child/env-bootstrap.js +120 -0
- package/dist/child/from-process-env.d.ts +127 -0
- package/dist/child/from-process-env.js +183 -0
- package/dist/child/index.d.ts +9 -0
- package/dist/child/index.js +9 -0
- package/dist/child/outbound-mail-bridge.d.ts +36 -0
- package/dist/child/outbound-mail-bridge.js +143 -0
- package/dist/child/proxy-repo-store.d.ts +27 -0
- package/dist/child/proxy-repo-store.js +200 -0
- package/dist/child/run-child.d.ts +320 -0
- package/dist/child/run-child.js +900 -0
- package/dist/child/self-discovery.d.ts +29 -0
- package/dist/child/self-discovery.js +57 -0
- package/dist/child/substrate-write-bridge.d.ts +72 -0
- package/dist/child/substrate-write-bridge.js +188 -0
- package/dist/child/supervisor-backed-transport.d.ts +10 -0
- package/dist/child/supervisor-backed-transport.js +113 -0
- package/dist/child/warm-agent-cache.d.ts +78 -0
- package/dist/child/warm-agent-cache.js +112 -0
- package/dist/drain-controller.d.ts +37 -0
- package/dist/drain-controller.js +46 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/ipc/control-channel.d.ts +336 -0
- package/dist/ipc/control-channel.js +532 -0
- package/dist/ipc/crypto.d.ts +46 -0
- package/dist/ipc/crypto.js +126 -0
- package/dist/ipc/envelope.d.ts +53 -0
- package/dist/ipc/envelope.js +88 -0
- package/dist/ipc/event-channel.d.ts +677 -0
- package/dist/ipc/event-channel.js +278 -0
- package/dist/ipc/index.d.ts +4 -0
- package/dist/ipc/index.js +143 -0
- package/dist/mail-bus/hub-transport-adapter.d.ts +30 -0
- package/dist/mail-bus/hub-transport-adapter.js +76 -0
- package/dist/mail-bus/index.d.ts +1 -0
- package/dist/mail-bus/index.js +1 -0
- package/dist/seams/index.d.ts +3 -0
- package/dist/seams/index.js +3 -0
- package/dist/seams/scheduler-adapter.d.ts +3 -0
- package/dist/seams/scheduler-adapter.js +24 -0
- package/dist/seams/scheduler.d.ts +94 -0
- package/dist/seams/scheduler.js +397 -0
- package/dist/seams/signal-channel.d.ts +74 -0
- package/dist/seams/signal-channel.js +304 -0
- package/dist/supervisor/cancel-signing.d.ts +68 -0
- package/dist/supervisor/cancel-signing.js +144 -0
- package/dist/supervisor/child-termination.d.ts +51 -0
- package/dist/supervisor/child-termination.js +76 -0
- package/dist/supervisor/credentials.d.ts +101 -0
- package/dist/supervisor/credentials.js +153 -0
- package/dist/supervisor/dispatch-attribution.d.ts +37 -0
- package/dist/supervisor/dispatch-attribution.js +114 -0
- package/dist/supervisor/drain-timeout.d.ts +127 -0
- package/dist/supervisor/drain-timeout.js +231 -0
- package/dist/supervisor/index.d.ts +7 -0
- package/dist/supervisor/index.js +6 -0
- package/dist/supervisor/recycle.d.ts +212 -0
- package/dist/supervisor/recycle.js +440 -0
- package/dist/supervisor/run-event-compaction.d.ts +34 -0
- package/dist/supervisor/run-event-compaction.js +115 -0
- package/dist/supervisor/spawn-env.d.ts +39 -0
- package/dist/supervisor/spawn-env.js +36 -0
- package/dist/supervisor/supervisor.d.ts +202 -0
- package/dist/supervisor/supervisor.js +2244 -0
- package/dist/supervisor/terminal-broadcaster.d.ts +45 -0
- package/dist/supervisor/terminal-broadcaster.js +184 -0
- package/dist/supervisor/types.d.ts +542 -0
- package/dist/supervisor/types.js +10 -0
- 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;
|