@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,29 @@
|
|
|
1
|
+
import type { RepoId, RepoStore as SubstrateRepoStore } from "@intx/hub-sessions/substrate";
|
|
2
|
+
import { type RunState, type WorkflowEvent } from "@intx/workflow";
|
|
3
|
+
import type { RepoStore as RuntimeRepoStore } from "@intx/workflow";
|
|
4
|
+
/**
|
|
5
|
+
* Per-run discovery entry. The runtime body re-applies `seedEvents`
|
|
6
|
+
* via its `resumeFromEvents` path and resumes the run from
|
|
7
|
+
* `resumedState`. The caller hands both into `runtimeRun`.
|
|
8
|
+
*/
|
|
9
|
+
export interface DiscoveredRun {
|
|
10
|
+
runId: string;
|
|
11
|
+
seedEvents: readonly WorkflowEvent[];
|
|
12
|
+
resumedState: RunState;
|
|
13
|
+
}
|
|
14
|
+
export interface DiscoverRunsOpts {
|
|
15
|
+
/** Workflow-run substrate repo store. */
|
|
16
|
+
substrate: SubstrateRepoStore;
|
|
17
|
+
/** Workflow-run repo identity. */
|
|
18
|
+
repoId: RepoId;
|
|
19
|
+
/** Runtime-env `RepoStore` adapter the runs are read through. */
|
|
20
|
+
runtimeRepoStore: RuntimeRepoStore;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Enumerate `runs/<runId>/` subdirectories and return one
|
|
24
|
+
* `DiscoveredRun` entry per run whose log does not already end with a
|
|
25
|
+
* terminal event. Runs that already terminated are skipped because
|
|
26
|
+
* resume against a terminal log would still settle without progress
|
|
27
|
+
* but would generate spurious "resume seed" reads for no benefit.
|
|
28
|
+
*/
|
|
29
|
+
export declare function discoverInFlightRuns(opts: DiscoverRunsOpts): Promise<readonly DiscoveredRun[]>;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Self-discovery: enumerate in-flight runs from the workflow-run repo
|
|
2
|
+
// at startup and surface their event logs so the child can resume
|
|
3
|
+
// each one in place.
|
|
4
|
+
//
|
|
5
|
+
// The supervisor does not pass an explicit list of runs; the
|
|
6
|
+
// workflow-run repo's `runs/<runId>/` subtree is the authoritative
|
|
7
|
+
// ledger. A run is in-flight if its log lacks a terminal event
|
|
8
|
+
// (`RunCompleted`, `RunFailed`, `RunCancelled`). Resume seeds the
|
|
9
|
+
// runtime body via the seed-events path on `runtimeRun`.
|
|
10
|
+
//
|
|
11
|
+
// Working-tree-read pattern: the substrate's `getRepoDir(repoId)` is a
|
|
12
|
+
// pure path computation; sibling production adapters
|
|
13
|
+
// (`adapters/repo-store.ts`, `adapters/blob-substrate.ts`,
|
|
14
|
+
// `adapters/spawn-child.ts`) read working-tree contents directly via
|
|
15
|
+
// `node:fs/promises`. Self-discovery follows the same path so a
|
|
16
|
+
// startup scan does not need to consult the git object database.
|
|
17
|
+
import { isTerminalRunPhase, resumeFromLog, } from "@intx/workflow";
|
|
18
|
+
const RUNS_PREFIX = "runs";
|
|
19
|
+
/**
|
|
20
|
+
* Enumerate `runs/<runId>/` subdirectories and return one
|
|
21
|
+
* `DiscoveredRun` entry per run whose log does not already end with a
|
|
22
|
+
* terminal event. Runs that already terminated are skipped because
|
|
23
|
+
* resume against a terminal log would still settle without progress
|
|
24
|
+
* but would generate spurious "resume seed" reads for no benefit.
|
|
25
|
+
*/
|
|
26
|
+
export async function discoverInFlightRuns(opts) {
|
|
27
|
+
const fs = await import("node:fs/promises");
|
|
28
|
+
const path = await import("node:path");
|
|
29
|
+
const dir = opts.substrate.getRepoDir(opts.repoId);
|
|
30
|
+
const runsDir = path.join(dir, RUNS_PREFIX);
|
|
31
|
+
let runDirs;
|
|
32
|
+
try {
|
|
33
|
+
runDirs = await fs.readdir(runsDir);
|
|
34
|
+
}
|
|
35
|
+
catch (cause) {
|
|
36
|
+
if (isErrnoNotFound(cause))
|
|
37
|
+
return [];
|
|
38
|
+
throw cause;
|
|
39
|
+
}
|
|
40
|
+
const out = [];
|
|
41
|
+
for (const runId of runDirs) {
|
|
42
|
+
const events = await opts.runtimeRepoStore.read(runId);
|
|
43
|
+
if (events.length === 0)
|
|
44
|
+
continue;
|
|
45
|
+
const resumed = resumeFromLog(runId, events);
|
|
46
|
+
if (isTerminalRunPhase(resumed.phase))
|
|
47
|
+
continue;
|
|
48
|
+
out.push({ runId, seedEvents: events, resumedState: resumed });
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
function isErrnoNotFound(cause) {
|
|
53
|
+
if (cause === null || typeof cause !== "object")
|
|
54
|
+
return false;
|
|
55
|
+
const code = cause.code;
|
|
56
|
+
return code === "ENOENT";
|
|
57
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { ControlChannelSender, ControlPayload } from "../ipc/control-channel.js";
|
|
2
|
+
/**
|
|
3
|
+
* Arguments the bridge takes per `writeTreePreservingPrefix` call. The
|
|
4
|
+
* shape mirrors the substrate's `WriteTreePreservingPrefixArgs` plus
|
|
5
|
+
* the repoId/ref the supervisor needs to route the write to the right
|
|
6
|
+
* underlying substrate.
|
|
7
|
+
*/
|
|
8
|
+
export interface SubstrateWriteRequest {
|
|
9
|
+
repoId: {
|
|
10
|
+
kind: string;
|
|
11
|
+
id: string;
|
|
12
|
+
};
|
|
13
|
+
ref: string;
|
|
14
|
+
preservePrefix: string;
|
|
15
|
+
message: string;
|
|
16
|
+
/**
|
|
17
|
+
* Caller's merge closure. The bridge invokes it locally inside the
|
|
18
|
+
* supervisor-driven merge round-trip; the closure receives the
|
|
19
|
+
* existing prefix entries the supervisor decoded from
|
|
20
|
+
* `substrate.merge.request`'s payload and returns the prospective
|
|
21
|
+
* tree the bridge encodes back into `substrate.merge.response`.
|
|
22
|
+
*/
|
|
23
|
+
merge: (existing: ReadonlyMap<string, Uint8Array>) => Promise<Record<string, string | Uint8Array>>;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Bridge surface the child's substrate proxy reaches into. `submit`
|
|
27
|
+
* sends a `substrate.write.request` upstream and resolves once the
|
|
28
|
+
* supervisor's matching `substrate.write.response` lands. The
|
|
29
|
+
* `handleMergeRequest` and `handleWriteResponse` hooks are the
|
|
30
|
+
* receiver-side entry points the child's control loop invokes when
|
|
31
|
+
* the corresponding downstream frames arrive.
|
|
32
|
+
*
|
|
33
|
+
* `cancelAll` is the cleanup hook the control loop invokes on any
|
|
34
|
+
* exit path so a pending write does not leak an awaiter when the
|
|
35
|
+
* supervisor has torn the IPC down.
|
|
36
|
+
*/
|
|
37
|
+
export interface ChildSubstrateWriteBridge {
|
|
38
|
+
submit(req: SubstrateWriteRequest): Promise<{
|
|
39
|
+
commitSha: string;
|
|
40
|
+
}>;
|
|
41
|
+
handleMergeRequest(data: Extract<ControlPayload, {
|
|
42
|
+
type: "substrate.merge.request";
|
|
43
|
+
}>["data"]): void;
|
|
44
|
+
handleWriteResponse(data: Extract<ControlPayload, {
|
|
45
|
+
type: "substrate.write.response";
|
|
46
|
+
}>["data"]): void;
|
|
47
|
+
cancelAll(reason: string): void;
|
|
48
|
+
readonly pendingCount: number;
|
|
49
|
+
}
|
|
50
|
+
export interface CreateChildSubstrateWriteBridgeOpts {
|
|
51
|
+
upstreamSender: ControlChannelSender;
|
|
52
|
+
/**
|
|
53
|
+
* Optional `requestId` allocator. Production wires a per-instance
|
|
54
|
+
* monotonic counter plus a random suffix; tests inject a
|
|
55
|
+
* deterministic factory so the upstream frame's `requestId` is
|
|
56
|
+
* predictable.
|
|
57
|
+
*/
|
|
58
|
+
allocateRequestId?: () => string;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Construct the child-side substrate-write bridge. Pending writes
|
|
62
|
+
* live in a map keyed by `requestId`; the bridge resolves the awaiter
|
|
63
|
+
* when the supervisor's matching `substrate.write.response` lands.
|
|
64
|
+
*
|
|
65
|
+
* The supervisor may emit zero or more `substrate.merge.request`
|
|
66
|
+
* frames per pending write (the supervisor's merge callback may run
|
|
67
|
+
* once per attempt; the substrate retries on conflict). The bridge
|
|
68
|
+
* resolves each merge request synchronously through the pending
|
|
69
|
+
* entry's `merge` closure and emits `substrate.merge.response`. The
|
|
70
|
+
* pending entry stays alive until the terminal write response lands.
|
|
71
|
+
*/
|
|
72
|
+
export declare function createChildSubstrateWriteBridge(opts: CreateChildSubstrateWriteBridgeOpts): ChildSubstrateWriteBridge;
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// Child-side substrate-write bridge.
|
|
2
|
+
//
|
|
3
|
+
// The workflow-process child holds no write authority over the
|
|
4
|
+
// workflow-run repo's ref. The hub linearizes writes at the ref tip;
|
|
5
|
+
// a child that opened its own substrate would race the supervisor's
|
|
6
|
+
// inbox / processing / consumed writes at the same ref. The supervisor
|
|
7
|
+
// owns the write contract; the child proxies its
|
|
8
|
+
// `writeTreePreservingPrefix` calls over the control IPC into the
|
|
9
|
+
// supervisor's substrate.
|
|
10
|
+
//
|
|
11
|
+
// Lifecycle of one proxied write:
|
|
12
|
+
//
|
|
13
|
+
// 1. The child's proxy `RepoStore.writeTreePreservingPrefix` mints a
|
|
14
|
+
// `requestId`, registers a pending entry holding the caller's
|
|
15
|
+
// original merge closure plus resolve/reject hooks, and emits
|
|
16
|
+
// `substrate.write.request` upstream.
|
|
17
|
+
// 2. The supervisor receives the request and invokes its own wrapped
|
|
18
|
+
// `writeTreePreservingPrefix` against the supervisor's repoStore.
|
|
19
|
+
// Inside the supervisor's merge callback, the supervisor sends
|
|
20
|
+
// `substrate.merge.request` back to the child carrying the
|
|
21
|
+
// existing prefix entries.
|
|
22
|
+
// 3. The bridge resolves the existing entries through the pending
|
|
23
|
+
// entry's merge closure, encodes the resulting tree as
|
|
24
|
+
// base64-coded files, and replies with `substrate.merge.response`.
|
|
25
|
+
// 4. The supervisor's merge callback returns the decoded files; the
|
|
26
|
+
// substrate commits the prospective tree under the per-repo lock.
|
|
27
|
+
// 5. The supervisor sends `substrate.write.response` with the
|
|
28
|
+
// resulting `commitSha` (or the structured failure). The bridge
|
|
29
|
+
// resolves / rejects the pending awaiter; the child's substrate
|
|
30
|
+
// proxy returns the result to its caller.
|
|
31
|
+
//
|
|
32
|
+
// The bridge does NOT serialize the merge closure: the closure lives
|
|
33
|
+
// in the child's address space, so the merge invocation always runs
|
|
34
|
+
// here. The IPC carries the bytes the closure consumes and the bytes
|
|
35
|
+
// the closure produces, both base64-encoded.
|
|
36
|
+
import { getLogger } from "@intx/log";
|
|
37
|
+
import { base64Decode, base64Encode } from "@intx/types";
|
|
38
|
+
const logger = getLogger(["workflow-host", "child", "substrate-write-bridge"]);
|
|
39
|
+
/**
|
|
40
|
+
* Construct the child-side substrate-write bridge. Pending writes
|
|
41
|
+
* live in a map keyed by `requestId`; the bridge resolves the awaiter
|
|
42
|
+
* when the supervisor's matching `substrate.write.response` lands.
|
|
43
|
+
*
|
|
44
|
+
* The supervisor may emit zero or more `substrate.merge.request`
|
|
45
|
+
* frames per pending write (the supervisor's merge callback may run
|
|
46
|
+
* once per attempt; the substrate retries on conflict). The bridge
|
|
47
|
+
* resolves each merge request synchronously through the pending
|
|
48
|
+
* entry's `merge` closure and emits `substrate.merge.response`. The
|
|
49
|
+
* pending entry stays alive until the terminal write response lands.
|
|
50
|
+
*/
|
|
51
|
+
export function createChildSubstrateWriteBridge(opts) {
|
|
52
|
+
const pending = new Map();
|
|
53
|
+
const allocate = opts.allocateRequestId ?? defaultRequestIdAllocator();
|
|
54
|
+
return {
|
|
55
|
+
get pendingCount() {
|
|
56
|
+
return pending.size;
|
|
57
|
+
},
|
|
58
|
+
async submit(req) {
|
|
59
|
+
const requestId = allocate();
|
|
60
|
+
const resultPromise = new Promise((resolve, reject) => {
|
|
61
|
+
pending.set(requestId, { req, resolve, reject });
|
|
62
|
+
});
|
|
63
|
+
try {
|
|
64
|
+
await opts.upstreamSender.send({
|
|
65
|
+
type: "substrate.write.request",
|
|
66
|
+
data: {
|
|
67
|
+
requestId,
|
|
68
|
+
repoId: { kind: req.repoId.kind, id: req.repoId.id },
|
|
69
|
+
ref: req.ref,
|
|
70
|
+
preservePrefix: req.preservePrefix,
|
|
71
|
+
message: req.message,
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
catch (cause) {
|
|
76
|
+
pending.delete(requestId);
|
|
77
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
78
|
+
throw new Error(`workflow-child substrate write: upstream send failed for requestId ${requestId}: ${message}`, { cause });
|
|
79
|
+
}
|
|
80
|
+
return resultPromise;
|
|
81
|
+
},
|
|
82
|
+
handleMergeRequest(data) {
|
|
83
|
+
const entry = pending.get(data.requestId);
|
|
84
|
+
if (entry === undefined) {
|
|
85
|
+
logger.warn `substrate.merge.request landed with no pending entry; requestId=${data.requestId} dropped`;
|
|
86
|
+
// Reply with a structured failure so the supervisor's merge
|
|
87
|
+
// callback can short-circuit rather than wedge waiting on a
|
|
88
|
+
// response that will never come.
|
|
89
|
+
void opts.upstreamSender
|
|
90
|
+
.send({
|
|
91
|
+
type: "substrate.merge.response",
|
|
92
|
+
data: {
|
|
93
|
+
requestId: data.requestId,
|
|
94
|
+
result: {
|
|
95
|
+
ok: false,
|
|
96
|
+
reason: `workflow-child substrate write: no pending entry for requestId ${data.requestId}`,
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
})
|
|
100
|
+
.catch((cause) => {
|
|
101
|
+
const msg = cause instanceof Error ? cause.message : String(cause);
|
|
102
|
+
logger.error `substrate.merge.response upstream send (no-pending) failed: ${msg}`;
|
|
103
|
+
});
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
void (async () => {
|
|
107
|
+
try {
|
|
108
|
+
const existing = decodeMergeRequest(data.existing);
|
|
109
|
+
const merged = await entry.req.merge(existing);
|
|
110
|
+
const files = encodeFiles(merged);
|
|
111
|
+
await opts.upstreamSender.send({
|
|
112
|
+
type: "substrate.merge.response",
|
|
113
|
+
data: {
|
|
114
|
+
requestId: data.requestId,
|
|
115
|
+
result: { ok: true, files },
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
catch (cause) {
|
|
120
|
+
const reason = cause instanceof Error ? cause.message : String(cause);
|
|
121
|
+
try {
|
|
122
|
+
await opts.upstreamSender.send({
|
|
123
|
+
type: "substrate.merge.response",
|
|
124
|
+
data: {
|
|
125
|
+
requestId: data.requestId,
|
|
126
|
+
result: { ok: false, reason },
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
catch (sendCause) {
|
|
131
|
+
const msg = sendCause instanceof Error
|
|
132
|
+
? sendCause.message
|
|
133
|
+
: String(sendCause);
|
|
134
|
+
logger.error `substrate.merge.response upstream send (failure path) failed: ${msg}`;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
})();
|
|
138
|
+
},
|
|
139
|
+
handleWriteResponse(data) {
|
|
140
|
+
const entry = pending.get(data.requestId);
|
|
141
|
+
if (entry === undefined) {
|
|
142
|
+
logger.warn `substrate.write.response landed with no pending entry; requestId=${data.requestId} dropped`;
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
pending.delete(data.requestId);
|
|
146
|
+
if (data.result.ok) {
|
|
147
|
+
entry.resolve({ commitSha: data.result.commitSha });
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
entry.reject(new Error(`workflow-child substrate write (requestId=${data.requestId}) rejected by supervisor: ${data.result.reason}`));
|
|
151
|
+
},
|
|
152
|
+
cancelAll(reason) {
|
|
153
|
+
for (const [requestId, entry] of pending) {
|
|
154
|
+
entry.reject(new Error(`workflow-child substrate write (requestId=${requestId}) cancelled: ${reason}`));
|
|
155
|
+
}
|
|
156
|
+
pending.clear();
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
function decodeMergeRequest(existing) {
|
|
161
|
+
const out = new Map();
|
|
162
|
+
for (const entry of existing) {
|
|
163
|
+
out.set(entry.path, base64ToBytes(entry.contentBase64));
|
|
164
|
+
}
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
function encodeFiles(files) {
|
|
168
|
+
const out = [];
|
|
169
|
+
for (const [path, content] of Object.entries(files)) {
|
|
170
|
+
const bytes = typeof content === "string" ? new TextEncoder().encode(content) : content;
|
|
171
|
+
out.push({ path, contentBase64: bytesToBase64(bytes) });
|
|
172
|
+
}
|
|
173
|
+
return out;
|
|
174
|
+
}
|
|
175
|
+
function bytesToBase64(bytes) {
|
|
176
|
+
return base64Encode(bytes);
|
|
177
|
+
}
|
|
178
|
+
function base64ToBytes(value) {
|
|
179
|
+
return base64Decode(value);
|
|
180
|
+
}
|
|
181
|
+
function defaultRequestIdAllocator() {
|
|
182
|
+
let counter = 0;
|
|
183
|
+
return () => {
|
|
184
|
+
counter += 1;
|
|
185
|
+
const rand = Math.random().toString(36).slice(2, 10);
|
|
186
|
+
return `sw-${String(counter)}-${rand}`;
|
|
187
|
+
};
|
|
188
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { MessageTransport } from "@intx/types/runtime";
|
|
2
|
+
import type { ChildOutboundMailBridge } from "./outbound-mail-bridge.js";
|
|
3
|
+
/**
|
|
4
|
+
* Construct a `MessageTransport` whose outbound side routes through the
|
|
5
|
+
* supervisor (via `bridge`) and whose inbound side is inert. `address`
|
|
6
|
+
* is the agent's mail address; the supervisor signs the outbound mail as
|
|
7
|
+
* this address through the host transport, so it must be the address the
|
|
8
|
+
* host registered the agent's `CryptoProvider` against.
|
|
9
|
+
*/
|
|
10
|
+
export declare function createSupervisorBackedTransport(bridge: ChildOutboundMailBridge, address: string): MessageTransport;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Supervisor-backed `MessageTransport` for a unified-host step agent
|
|
2
|
+
// (OUTBOUND half of mailbox ownership, §3a).
|
|
3
|
+
//
|
|
4
|
+
// Under the unified host the supervisor is the sole mail owner: it holds
|
|
5
|
+
// the durable inbox and the host transport against which the agent's
|
|
6
|
+
// address is registered with its signing key. The step agent therefore
|
|
7
|
+
// does NOT subscribe its own transport for inbound mail (the supervisor
|
|
8
|
+
// delivers inputs via the step path) and does NOT hold a signing key to
|
|
9
|
+
// send outbound mail. Its mail tools are backed by this transport:
|
|
10
|
+
//
|
|
11
|
+
// - INBOUND is a no-op. `watch` returns a no-op unsubscribe and never
|
|
12
|
+
// fires; the supervisor delivers the agent's input as the step
|
|
13
|
+
// input, not through the agent's own mailbox. The IMAP read surface
|
|
14
|
+
// (`search`, `fetchFull`, `fetchHeaders`, ...) throws: the agent
|
|
15
|
+
// owns no mailbox in the unified host, so a read against one is a
|
|
16
|
+
// programming error, surfaced loudly rather than returning a
|
|
17
|
+
// silently-empty result that would hide the missing inbound surface.
|
|
18
|
+
// - OUTBOUND (`send` / `append`) routes through the supervisor over
|
|
19
|
+
// the control IPC via the outbound-mail bridge. The supervisor
|
|
20
|
+
// performs the actual signed send through the host transport, so the
|
|
21
|
+
// outbound mail carries the agent's signature with full parity to
|
|
22
|
+
// the in-process path. The agent never holds the key.
|
|
23
|
+
/**
|
|
24
|
+
* Construct a `MessageTransport` whose outbound side routes through the
|
|
25
|
+
* supervisor (via `bridge`) and whose inbound side is inert. `address`
|
|
26
|
+
* is the agent's mail address; the supervisor signs the outbound mail as
|
|
27
|
+
* this address through the host transport, so it must be the address the
|
|
28
|
+
* host registered the agent's `CryptoProvider` against.
|
|
29
|
+
*/
|
|
30
|
+
export function createSupervisorBackedTransport(bridge, address) {
|
|
31
|
+
function inboundUnsupported(method) {
|
|
32
|
+
throw new Error(`supervisor-backed transport: ${method} is not supported for unified-host step agent ${address}; the supervisor owns the mailbox and delivers inbound mail as the step input`);
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
async send(message, _signal) {
|
|
36
|
+
return bridge.submit(address, message);
|
|
37
|
+
},
|
|
38
|
+
async append(_mailbox, _message, _flags, _signal) {
|
|
39
|
+
// `append` writes into a mailbox the agent owns; in the unified
|
|
40
|
+
// host the agent owns none. The mail tools do not append (they
|
|
41
|
+
// `send`), so a reachable `append` is a programming error.
|
|
42
|
+
return inboundUnsupported("append");
|
|
43
|
+
},
|
|
44
|
+
async listMailboxes(_signal) {
|
|
45
|
+
return inboundUnsupported("listMailboxes");
|
|
46
|
+
},
|
|
47
|
+
async createMailbox(_name, _signal) {
|
|
48
|
+
return inboundUnsupported("createMailbox");
|
|
49
|
+
},
|
|
50
|
+
async deleteMailbox(_name, _signal) {
|
|
51
|
+
return inboundUnsupported("deleteMailbox");
|
|
52
|
+
},
|
|
53
|
+
async getMailboxStatus(_name, _signal) {
|
|
54
|
+
return inboundUnsupported("getMailboxStatus");
|
|
55
|
+
},
|
|
56
|
+
async search(_mailbox, _query, _signal) {
|
|
57
|
+
return inboundUnsupported("search");
|
|
58
|
+
},
|
|
59
|
+
async thread(_mailbox, _algorithm, _query, _signal) {
|
|
60
|
+
return inboundUnsupported("thread");
|
|
61
|
+
},
|
|
62
|
+
async fetchHeaders(_ref, _signal) {
|
|
63
|
+
return inboundUnsupported("fetchHeaders");
|
|
64
|
+
},
|
|
65
|
+
async fetchStructure(_ref, _signal) {
|
|
66
|
+
return inboundUnsupported("fetchStructure");
|
|
67
|
+
},
|
|
68
|
+
async fetchPart(_ref, _partPath, _signal) {
|
|
69
|
+
return inboundUnsupported("fetchPart");
|
|
70
|
+
},
|
|
71
|
+
async fetchFull(_ref, _signal) {
|
|
72
|
+
return inboundUnsupported("fetchFull");
|
|
73
|
+
},
|
|
74
|
+
async setFlags(_ref, _flags, _signal) {
|
|
75
|
+
return inboundUnsupported("setFlags");
|
|
76
|
+
},
|
|
77
|
+
async clearFlags(_ref, _flags, _signal) {
|
|
78
|
+
return inboundUnsupported("clearFlags");
|
|
79
|
+
},
|
|
80
|
+
async move(_ref, _toMailbox, _signal) {
|
|
81
|
+
return inboundUnsupported("move");
|
|
82
|
+
},
|
|
83
|
+
async copy(_ref, _toMailbox, _signal) {
|
|
84
|
+
return inboundUnsupported("copy");
|
|
85
|
+
},
|
|
86
|
+
async expunge(_mailbox, _signal) {
|
|
87
|
+
return inboundUnsupported("expunge");
|
|
88
|
+
},
|
|
89
|
+
watch(_mailbox, _callback) {
|
|
90
|
+
// Inbound delivery is a no-op: the supervisor delivers the agent's
|
|
91
|
+
// input as the step input, not through the agent's mailbox. The
|
|
92
|
+
// watch never fires; return a no-op unsubscribe so a mail tool that
|
|
93
|
+
// installs a watch (mail_wait) does not throw at install time but
|
|
94
|
+
// also never observes a spurious event.
|
|
95
|
+
return () => undefined;
|
|
96
|
+
},
|
|
97
|
+
async sync(_mailbox, _knownState, _signal) {
|
|
98
|
+
return inboundUnsupported("sync");
|
|
99
|
+
},
|
|
100
|
+
async createList(_address, _name, _signal) {
|
|
101
|
+
return inboundUnsupported("createList");
|
|
102
|
+
},
|
|
103
|
+
async listMembers(_address, _signal) {
|
|
104
|
+
return inboundUnsupported("listMembers");
|
|
105
|
+
},
|
|
106
|
+
async subscribe(_listAddress, _subscriberAddress, _signal) {
|
|
107
|
+
return inboundUnsupported("subscribe");
|
|
108
|
+
},
|
|
109
|
+
async unsubscribe(_listAddress, _subscriberAddress, _signal) {
|
|
110
|
+
return inboundUnsupported("unsubscribe");
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { Agent } from "@intx/agent";
|
|
2
|
+
import type { InferenceEvent, InferenceSource } from "@intx/types/runtime";
|
|
3
|
+
/**
|
|
4
|
+
* Mutable per-entry event sink the warm agent's stream forwarder reads
|
|
5
|
+
* before forwarding each event. The step-invoker swaps `current` to the
|
|
6
|
+
* active step's `onEvent` before every `agent.send`, so events from the
|
|
7
|
+
* agent's single lifetime stream reach whichever run is in flight. A
|
|
8
|
+
* `null` `current` drops events (no run is driving the agent), which is
|
|
9
|
+
* the correct behaviour in the gap between sends.
|
|
10
|
+
*/
|
|
11
|
+
export interface WarmEventSinkRef {
|
|
12
|
+
current: ((event: InferenceEvent) => void) | null;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Per-address warm-agent cache. Keyed by the step's stable identity (the
|
|
16
|
+
* single step's id), so a long-lived agent resolves to the same entry on
|
|
17
|
+
* every inbound message. The cache is single-writer from the run-loop's
|
|
18
|
+
* perspective: the step-invoker builds-or-reuses inside one step
|
|
19
|
+
* invocation, and the run-loop evicts at teardown.
|
|
20
|
+
*/
|
|
21
|
+
export interface WarmAgentCache {
|
|
22
|
+
/**
|
|
23
|
+
* Return the warm agent cached for `key`, or `null` when none is
|
|
24
|
+
* built yet (the lazy first-message path). The caller builds the
|
|
25
|
+
* agent and calls `store` on a miss.
|
|
26
|
+
*/
|
|
27
|
+
acquire(key: string): Agent | null;
|
|
28
|
+
/**
|
|
29
|
+
* Cache a freshly-built warm agent under `key`. The `eventSinkRef` is
|
|
30
|
+
* the mutable sink the agent's stream forwarder reads; `eventForward`
|
|
31
|
+
* is the forwarder loop's settle promise. Throws if an entry already
|
|
32
|
+
* exists for `key` -- a double-build is a step-invoker bug, not a
|
|
33
|
+
* silent overwrite that would leak the prior agent's LSP subprocess.
|
|
34
|
+
*/
|
|
35
|
+
store(key: string, agent: Agent, eventSinkRef: WarmEventSinkRef, eventForward: Promise<void>): void;
|
|
36
|
+
/**
|
|
37
|
+
* Point the warm agent's stream forwarder at the active step's event
|
|
38
|
+
* sink before its `agent.send`. Throws when no entry exists for
|
|
39
|
+
* `key` -- the step-invoker must `store` before it rewrites the sink.
|
|
40
|
+
*/
|
|
41
|
+
setEventSink(key: string, onEvent: (event: InferenceEvent) => void): void;
|
|
42
|
+
/**
|
|
43
|
+
* Clear the active event sink for `key` after a step's `agent.send`
|
|
44
|
+
* settles, so a stray event between messages is dropped rather than
|
|
45
|
+
* delivered to a torn-down per-run channel. A missing entry is a
|
|
46
|
+
* no-op: the agent may already have been evicted.
|
|
47
|
+
*/
|
|
48
|
+
clearEventSink(key: string): void;
|
|
49
|
+
/**
|
|
50
|
+
* Apply a rotated inference-source list to every retained warm agent in
|
|
51
|
+
* place, via `Agent.setSources`. A single-step warm cache holds 0 or 1
|
|
52
|
+
* entry, so this rotates the one built agent -- or is a no-op when none
|
|
53
|
+
* is built yet (the pre-first-build window). The swap mutates the
|
|
54
|
+
* agent's shared active-source object in place and takes effect on the
|
|
55
|
+
* reactor's next inference call; the single-threaded control loop means
|
|
56
|
+
* there is no torn read against a concurrent send. `setSources` validates
|
|
57
|
+
* the list and throws on an invalid source (or a closed agent, if a
|
|
58
|
+
* rotation races eviction), so a bad rotation surfaces rather than being
|
|
59
|
+
* swallowed.
|
|
60
|
+
*/
|
|
61
|
+
applySources(sources: InferenceSource[], defaultSource: string): void;
|
|
62
|
+
/**
|
|
63
|
+
* Tear down every cached warm agent: run the wrapped `agent.close()`
|
|
64
|
+
* (disposing plugins and killing the LSP subprocess) and drain the
|
|
65
|
+
* stream forwarder. Idempotent -- a second call after the cache is
|
|
66
|
+
* empty is a no-op, so the run-loop can evict on both the shutdown
|
|
67
|
+
* frame and the exit-path `finally` without double-closing. Resolves
|
|
68
|
+
* once every agent is closed and every forwarder has drained, so no
|
|
69
|
+
* LSP subprocess outlives the call.
|
|
70
|
+
*/
|
|
71
|
+
evictAll(reason: string): Promise<void>;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Construct an empty warm-agent cache. The run-loop builds one per
|
|
75
|
+
* spawn when the deployment is a warm candidate and threads it into the
|
|
76
|
+
* step-invoker; multi-step deployments construct none.
|
|
77
|
+
*/
|
|
78
|
+
export declare function createWarmAgentCache(): WarmAgentCache;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Warm-agent cache for the workflow-process child (design §3b).
|
|
2
|
+
//
|
|
3
|
+
// A long-lived single-step agent is built once -- tools materialized,
|
|
4
|
+
// plugins instantiated, the LSP subprocess spawned -- and reused across
|
|
5
|
+
// every inbound message. Re-materializing tools and re-spawning the LSP
|
|
6
|
+
// per message is the instantiate-send-teardown cost the warm cache
|
|
7
|
+
// removes; keeping the agent alive across messages is also what
|
|
8
|
+
// preserves in-memory conversation continuity (durability across child
|
|
9
|
+
// respawns lands in §3c, a later sub-step).
|
|
10
|
+
//
|
|
11
|
+
// Ownership and lifetime. The cache lives in the child's address space,
|
|
12
|
+
// owned by the run-loop (`run-child.ts`), NOT the supervisor. The
|
|
13
|
+
// step-invoker consults it on every step invocation: a cache hit reuses
|
|
14
|
+
// the warm agent, a miss builds and stores one lazily. The cached agent
|
|
15
|
+
// is torn down -- the wrapped `agent.close()` runs, disposing plugins
|
|
16
|
+
// and killing the LSP subprocess -- only at the run-loop's eviction
|
|
17
|
+
// points (child shutdown, deployment undeploy, recycle, post-drain
|
|
18
|
+
// teardown), never between messages. On recycle the child process dies,
|
|
19
|
+
// killing the LSP grandchild regardless; the respawned child starts with
|
|
20
|
+
// an empty cache and re-warms lazily.
|
|
21
|
+
//
|
|
22
|
+
// Per-message event sink. The agent's `stream()` is consumed once, for
|
|
23
|
+
// the agent's whole life, by a single forwarder owned by the entry. The
|
|
24
|
+
// per-step `onEvent` sink the runtime threads in differs per message
|
|
25
|
+
// (it carries the run id in its error-log path), so the forwarder routes
|
|
26
|
+
// through a mutable reference the step-invoker rewrites before each
|
|
27
|
+
// `agent.send`. The forwarder loop ends only when the agent closes at an
|
|
28
|
+
// eviction point.
|
|
29
|
+
//
|
|
30
|
+
// Warm-keep is gated explicitly: the cache is constructed only when the
|
|
31
|
+
// deploy projection marks the deployment a warm candidate (the
|
|
32
|
+
// single-step launched agent). Multi-step deployments pass no cache and
|
|
33
|
+
// keep instantiate-send-teardown per step. The decision is never a
|
|
34
|
+
// silent default -- a multi-step agent is never warm-kept.
|
|
35
|
+
import { getLogger } from "@intx/log";
|
|
36
|
+
const logger = getLogger(["workflow-host", "child", "warm-agent-cache"]);
|
|
37
|
+
/**
|
|
38
|
+
* Construct an empty warm-agent cache. The run-loop builds one per
|
|
39
|
+
* spawn when the deployment is a warm candidate and threads it into the
|
|
40
|
+
* step-invoker; multi-step deployments construct none.
|
|
41
|
+
*/
|
|
42
|
+
export function createWarmAgentCache() {
|
|
43
|
+
const entries = new Map();
|
|
44
|
+
function acquire(key) {
|
|
45
|
+
const entry = entries.get(key);
|
|
46
|
+
return entry === undefined ? null : entry.agent;
|
|
47
|
+
}
|
|
48
|
+
function store(key, agent, eventSinkRef, eventForward) {
|
|
49
|
+
if (entries.has(key)) {
|
|
50
|
+
throw new Error(`warm-agent cache: an entry already exists for ${key}; the step-invoker must reuse the cached agent rather than rebuild it`);
|
|
51
|
+
}
|
|
52
|
+
entries.set(key, { agent, eventSinkRef, eventForward });
|
|
53
|
+
}
|
|
54
|
+
function setEventSink(key, onEvent) {
|
|
55
|
+
const entry = entries.get(key);
|
|
56
|
+
if (entry === undefined) {
|
|
57
|
+
throw new Error(`warm-agent cache: setEventSink for ${key} with no cached entry; the step-invoker must store the warm agent before wiring its per-message event sink`);
|
|
58
|
+
}
|
|
59
|
+
entry.eventSinkRef.current = onEvent;
|
|
60
|
+
}
|
|
61
|
+
function clearEventSink(key) {
|
|
62
|
+
const entry = entries.get(key);
|
|
63
|
+
if (entry === undefined)
|
|
64
|
+
return;
|
|
65
|
+
entry.eventSinkRef.current = null;
|
|
66
|
+
}
|
|
67
|
+
function applySources(sources, defaultSource) {
|
|
68
|
+
for (const entry of entries.values()) {
|
|
69
|
+
entry.agent.setSources(sources, defaultSource);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
async function evictAll(reason) {
|
|
73
|
+
if (entries.size === 0)
|
|
74
|
+
return;
|
|
75
|
+
const toEvict = [...entries.values()];
|
|
76
|
+
entries.clear();
|
|
77
|
+
for (const entry of toEvict) {
|
|
78
|
+
// Clear the sink first so any event emitted during the agent's
|
|
79
|
+
// shutdown window is dropped rather than delivered to a per-run
|
|
80
|
+
// channel the run-loop is tearing down.
|
|
81
|
+
entry.eventSinkRef.current = null;
|
|
82
|
+
try {
|
|
83
|
+
// The wrapped close (see `createToolBearingAgentFactory`) runs
|
|
84
|
+
// the agent's own close and then the plugin + tool-bundle
|
|
85
|
+
// disposers, killing the LSP subprocess. A close failure must
|
|
86
|
+
// surface, not be swallowed -- a leaked LSP subprocess is
|
|
87
|
+
// exactly the failure warm-keep risks -- so it propagates after
|
|
88
|
+
// we have drained what we can.
|
|
89
|
+
await entry.agent.close();
|
|
90
|
+
}
|
|
91
|
+
catch (cause) {
|
|
92
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
93
|
+
logger.error `warm-agent eviction (${reason}): agent.close failed: ${message}`;
|
|
94
|
+
throw cause instanceof Error ? cause : new Error(message);
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
// `agent.close()` terminates the stream iterator, so the
|
|
98
|
+
// forwarder loop has ended (or is about to). Await it so no
|
|
99
|
+
// forwarder outlives the eviction.
|
|
100
|
+
await entry.eventForward;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
acquire,
|
|
106
|
+
store,
|
|
107
|
+
setEventSink,
|
|
108
|
+
clearEventSink,
|
|
109
|
+
applySources,
|
|
110
|
+
evictAll,
|
|
111
|
+
};
|
|
112
|
+
}
|