@intx/workflow-host 0.2.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +77 -14
- package/dist/adapters/mail-part-store.d.ts +46 -0
- package/dist/adapters/mail-part-store.js +251 -0
- package/dist/adapters/repo-store.d.ts +22 -1
- package/dist/adapters/repo-store.js +56 -65
- package/dist/adapters/spawn-child.d.ts +109 -44
- package/dist/adapters/spawn-child.js +77 -81
- package/dist/adapters/step-invoker.d.ts +52 -2
- package/dist/adapters/step-invoker.js +284 -37
- package/dist/adapters/substrate-mailbox-store.d.ts +80 -0
- package/dist/adapters/substrate-mailbox-store.js +404 -0
- package/dist/child/child-mailbox-reader.d.ts +10 -0
- package/dist/child/child-mailbox-reader.js +23 -0
- package/dist/child/credential-cell.d.ts +8 -0
- package/dist/child/credential-cell.js +66 -0
- package/dist/child/env-bootstrap.d.ts +20 -6
- package/dist/child/env-bootstrap.js +9 -1
- package/dist/child/from-process-env.d.ts +12 -0
- package/dist/child/from-process-env.js +6 -0
- package/dist/child/index.d.ts +6 -2
- package/dist/child/index.js +4 -1
- package/dist/child/mailbox-mutation-bridge.d.ts +61 -0
- package/dist/child/mailbox-mutation-bridge.js +101 -0
- package/dist/child/mailbox-watch-registry.d.ts +17 -0
- package/dist/child/mailbox-watch-registry.js +61 -0
- package/dist/child/outbound-mail-bridge.d.ts +3 -2
- package/dist/child/outbound-mail-bridge.js +20 -32
- package/dist/child/parked-correlations.d.ts +42 -0
- package/dist/child/parked-correlations.js +80 -0
- package/dist/child/pending-request.d.ts +89 -0
- package/dist/child/pending-request.js +80 -0
- package/dist/child/proxy-repo-store.d.ts +3 -2
- package/dist/child/proxy-repo-store.js +2 -0
- package/dist/child/run-child.d.ts +170 -14
- package/dist/child/run-child.js +569 -155
- package/dist/child/self-discovery.d.ts +10 -0
- package/dist/child/self-discovery.js +25 -1
- package/dist/child/substrate-write-bridge.d.ts +3 -2
- package/dist/child/substrate-write-bridge.js +21 -38
- package/dist/child/supervisor-backed-transport.d.ts +52 -6
- package/dist/child/supervisor-backed-transport.js +205 -62
- package/dist/child/verified-definition-loader.d.ts +33 -0
- package/dist/child/verified-definition-loader.js +43 -0
- package/dist/child/warm-agent-cache.d.ts +44 -4
- package/dist/child/warm-agent-cache.js +41 -10
- package/dist/index.d.ts +6 -4
- package/dist/index.js +6 -4
- package/dist/ipc/control-channel.d.ts +151 -2
- package/dist/ipc/control-channel.js +222 -29
- package/dist/ipc/event-channel.d.ts +32 -1
- package/dist/ipc/index.d.ts +1 -1
- package/dist/ipc/index.js +1 -1
- package/dist/mail-bus/hub-transport-adapter.d.ts +12 -7
- package/dist/mail-bus/hub-transport-adapter.js +9 -5
- package/dist/run-body-then-cleanup.d.ts +17 -0
- package/dist/run-body-then-cleanup.js +38 -0
- package/dist/seams/scheduler.d.ts +16 -6
- package/dist/seams/scheduler.js +87 -97
- package/dist/supervisor/cancel-signing.d.ts +2 -2
- package/dist/supervisor/cancel-signing.js +4 -8
- package/dist/supervisor/credentials.d.ts +28 -15
- package/dist/supervisor/credentials.js +7 -7
- package/dist/supervisor/dispatch-attribution.js +1 -1
- package/dist/supervisor/drain-timeout.d.ts +2 -2
- package/dist/supervisor/drain-timeout.js +1 -1
- package/dist/supervisor/index.d.ts +3 -3
- package/dist/supervisor/index.js +2 -2
- package/dist/supervisor/recycle.d.ts +10 -3
- package/dist/supervisor/recycle.js +18 -7
- package/dist/supervisor/run-event-compaction.d.ts +5 -5
- package/dist/supervisor/run-event-compaction.js +14 -19
- package/dist/supervisor/run-event-recovery.d.ts +34 -0
- package/dist/supervisor/run-event-recovery.js +45 -0
- package/dist/supervisor/spawn-env.d.ts +2 -2
- package/dist/supervisor/spawn-env.js +1 -1
- package/dist/supervisor/supervisor.d.ts +106 -26
- package/dist/supervisor/supervisor.js +1903 -414
- package/dist/supervisor/terminal-commit.d.ts +36 -0
- package/dist/supervisor/terminal-commit.js +130 -0
- package/dist/supervisor/types.d.ts +180 -23
- package/dist/testing/change-notifier.d.ts +12 -0
- package/dist/testing/change-notifier.js +63 -0
- package/dist/testing/index.d.ts +8 -0
- package/dist/testing/index.js +16 -0
- package/dist/testing/log-capture.d.ts +52 -0
- package/dist/testing/log-capture.js +124 -0
- package/dist/testing/mail-bus.d.ts +22 -0
- package/dist/testing/mail-bus.js +78 -0
- package/dist/testing/memory-streams.d.ts +43 -0
- package/dist/testing/memory-streams.js +211 -0
- package/dist/testing/spawn-observer.d.ts +12 -0
- package/dist/testing/spawn-observer.js +36 -0
- package/dist/testing/stub-repo-store.d.ts +10 -0
- package/dist/testing/stub-repo-store.js +39 -0
- package/dist/testing/supervisor-reaper.d.ts +24 -0
- package/dist/testing/supervisor-reaper.js +49 -0
- package/dist/testing/upstream-frames.d.ts +47 -0
- package/dist/testing/upstream-frames.js +94 -0
- package/dist/workflow-definition-loader.d.ts +187 -0
- package/dist/workflow-definition-loader.js +422 -0
- package/package.json +18 -11
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
// Workflow-definition loader: the code-evaluation step the sidecar
|
|
2
|
+
// child performs during probe and deploy.
|
|
3
|
+
//
|
|
4
|
+
// The closure-materialization machinery in `@intx/tool-packaging`
|
|
5
|
+
// fetches, verifies, extracts, and lays out an installed workflow
|
|
6
|
+
// package (and its dependency closure) into a resolvable
|
|
7
|
+
// `node_modules/` tree. This module takes that materialized package
|
|
8
|
+
// directory, reads its `package.json`, imports the module named by the
|
|
9
|
+
// `interchange.workflow` field, and evaluates it: the module's
|
|
10
|
+
// `defineWorkflow(...)` call produces a `WorkflowDefinition`, which is
|
|
11
|
+
// validated at this boundary before being returned.
|
|
12
|
+
//
|
|
13
|
+
// Materialization is deliberately NOT done here. `@intx/workflow-host`
|
|
14
|
+
// stays free of a `@intx/tool-packaging` dependency (the sidecar owns
|
|
15
|
+
// that layer, see `apps/sidecar/src/tool-materialization.ts`), so the
|
|
16
|
+
// caller runs the closure machinery and hands the resulting package
|
|
17
|
+
// directory in. This module only performs the import + evaluate +
|
|
18
|
+
// validate step, which is the part that must run inside the child's
|
|
19
|
+
// address space because it evaluates author code.
|
|
20
|
+
import { promises as fs } from "node:fs";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
import { pathToFileURL } from "node:url";
|
|
23
|
+
import { type } from "arktype";
|
|
24
|
+
import { getLogger } from "@intx/log";
|
|
25
|
+
import { createDefaultDirectorRegistry, createWorkflowDirectorRegistry, isAnnotatedDirectorFactory, isAnnotatedPluginFactory, } from "@intx/agent";
|
|
26
|
+
import { PackageJSON, isContainedEntryPath } from "@intx/types/package-json";
|
|
27
|
+
import { workflowDefinitionEnvelopeSchema } from "@intx/hub-sessions/substrate";
|
|
28
|
+
const logger = getLogger(["workflow-host", "definition-loader"]);
|
|
29
|
+
/**
|
|
30
|
+
* Import the `interchange.workflow` entry from a materialized workflow
|
|
31
|
+
* package closure, evaluate it, and return the validated
|
|
32
|
+
* `WorkflowDefinition` its `defineWorkflow(...)` call produced.
|
|
33
|
+
*
|
|
34
|
+
* @param args - the materialized package directory plus optional import
|
|
35
|
+
* seams
|
|
36
|
+
* @returns the validated `WorkflowDefinition`
|
|
37
|
+
* @throws if the package.json is missing/malformed, declares no
|
|
38
|
+
* `interchange.workflow` entry, the entry path escapes the package
|
|
39
|
+
* directory, the module cannot be imported, or its evaluation does not
|
|
40
|
+
* produce exactly one value that validates as a `WorkflowDefinition`
|
|
41
|
+
*/
|
|
42
|
+
export async function loadWorkflowDefinitionFromClosure(args) {
|
|
43
|
+
const importModule = args.importModule ?? ((url) => import(url));
|
|
44
|
+
const pkgJson = await readPackageJSON(args.packageDir);
|
|
45
|
+
const entryRel = pkgJson.interchange?.workflow;
|
|
46
|
+
if (entryRel === undefined) {
|
|
47
|
+
throw new Error(`workflow package at ${args.packageDir} has no "interchange.workflow" field in package.json`);
|
|
48
|
+
}
|
|
49
|
+
const entryAbs = await resolveContainedEntry(args.packageDir, entryRel, "interchange.workflow");
|
|
50
|
+
const importUrl = args.importCacheKey === undefined
|
|
51
|
+
? pathToFileURL(entryAbs).href
|
|
52
|
+
: `${pathToFileURL(entryAbs).href}?importCacheKey=${encodeURIComponent(args.importCacheKey)}`;
|
|
53
|
+
let mod;
|
|
54
|
+
try {
|
|
55
|
+
mod = await importModule(importUrl);
|
|
56
|
+
}
|
|
57
|
+
catch (cause) {
|
|
58
|
+
throw new Error(`failed to import interchange.workflow entry ${JSON.stringify(entryRel)} for workflow package at ${args.packageDir}`, { cause });
|
|
59
|
+
}
|
|
60
|
+
if (mod === null || typeof mod !== "object") {
|
|
61
|
+
throw new Error(`interchange.workflow entry ${JSON.stringify(entryRel)} for workflow package at ${args.packageDir} did not evaluate to a module object`);
|
|
62
|
+
}
|
|
63
|
+
const definition = selectWorkflowDefinition(mod, args.packageDir, entryRel);
|
|
64
|
+
logger.debug `loaded workflow definition ${definition.id} from ${args.packageDir}`;
|
|
65
|
+
return definition;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Compose the `DirectorRegistry` for a workflow closure from the closure
|
|
69
|
+
* package's OWN `interchange.directors` module (if any), alongside the
|
|
70
|
+
* built-in default director. A package with no `interchange.directors`
|
|
71
|
+
* field composes to the built-ins-only registry -- absence is valid, a
|
|
72
|
+
* workflow need not ship a director. A present-but-empty directors module
|
|
73
|
+
* is malformed and throws, matching the tool-package loader.
|
|
74
|
+
*
|
|
75
|
+
* Only the workflow's OWN package directors are loaded here. Directors
|
|
76
|
+
* shipped by PINNED dependency packages are deliberately not resolved on
|
|
77
|
+
* the source-ref path yet: the airlocked probe does not materialize pinned
|
|
78
|
+
* packages, so loading them here would let the runtime resolve a director
|
|
79
|
+
* the probe never advertised for approval. A workflow referencing a
|
|
80
|
+
* pinned-package director fails closed (the capability walk reports it as
|
|
81
|
+
* unresolved).
|
|
82
|
+
*
|
|
83
|
+
* @throws if the directors entry path escapes the package, the module
|
|
84
|
+
* cannot be imported, or it exports no `AnnotatedDirectorFactory` value
|
|
85
|
+
*/
|
|
86
|
+
export async function loadWorkflowDirectorRegistryFromClosure(args) {
|
|
87
|
+
const importModule = args.importModule ?? ((url) => import(url));
|
|
88
|
+
const pkgJson = await readPackageJSON(args.packageDir);
|
|
89
|
+
const entryRel = pkgJson.interchange?.directors;
|
|
90
|
+
if (entryRel === undefined) {
|
|
91
|
+
// No custom directors: built-ins only.
|
|
92
|
+
return createDefaultDirectorRegistry();
|
|
93
|
+
}
|
|
94
|
+
const entryAbs = await resolveContainedEntry(args.packageDir, entryRel, "interchange.directors");
|
|
95
|
+
const importUrl = args.importCacheKey === undefined
|
|
96
|
+
? pathToFileURL(entryAbs).href
|
|
97
|
+
: `${pathToFileURL(entryAbs).href}?importCacheKey=${encodeURIComponent(args.importCacheKey)}`;
|
|
98
|
+
let mod;
|
|
99
|
+
try {
|
|
100
|
+
mod = await importModule(importUrl);
|
|
101
|
+
}
|
|
102
|
+
catch (cause) {
|
|
103
|
+
throw new Error(`failed to import interchange.directors entry ${JSON.stringify(entryRel)} for workflow package at ${args.packageDir}`, { cause });
|
|
104
|
+
}
|
|
105
|
+
if (mod === null || typeof mod !== "object") {
|
|
106
|
+
throw new Error(`interchange.directors entry ${JSON.stringify(entryRel)} for workflow package at ${args.packageDir} did not evaluate to a module object`);
|
|
107
|
+
}
|
|
108
|
+
const loaded = Object.values(mod).filter(isAnnotatedDirectorFactory);
|
|
109
|
+
if (loaded.length === 0) {
|
|
110
|
+
throw new Error(`interchange.directors entry ${JSON.stringify(entryRel)} for workflow package at ${args.packageDir} exported no AnnotatedDirectorFactory values`);
|
|
111
|
+
}
|
|
112
|
+
logger.debug `loaded ${String(loaded.length)} custom director(s) from ${args.packageDir}`;
|
|
113
|
+
return createWorkflowDirectorRegistry(loaded);
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Compose the `LoopFnRegistry` for a workflow closure from the closure
|
|
117
|
+
* package's OWN `interchange.loops` module. A `loop` primitive's `while` and
|
|
118
|
+
* `carry` refs resolve by EXPORT NAME against that module's exports.
|
|
119
|
+
*
|
|
120
|
+
* Unlike directors there is NO built-in default: a package with no
|
|
121
|
+
* `interchange.loops` field composes to an EMPTY registry that throws on any
|
|
122
|
+
* ref lookup. A workflow that declares a `loop` but ships no loops module thus
|
|
123
|
+
* fails closed when its refs are resolved (eagerly, at establish); a workflow
|
|
124
|
+
* with no `loop` primitive never resolves a ref, so an absent field is valid
|
|
125
|
+
* there. Loading OUTSIDE the definition-hash re-verify is safe: the approved
|
|
126
|
+
* hash pins each ref string, and the closure's SRI pins the module bytes.
|
|
127
|
+
*
|
|
128
|
+
* @throws (from the returned registry) if a requested ref names no export, or
|
|
129
|
+
* names an export that is not a function.
|
|
130
|
+
* @throws if the loops entry path escapes the package or cannot be imported.
|
|
131
|
+
*/
|
|
132
|
+
export async function loadWorkflowLoopFnsFromClosure(args) {
|
|
133
|
+
const importModule = args.importModule ?? ((url) => import(url));
|
|
134
|
+
const pkgJson = await readPackageJSON(args.packageDir);
|
|
135
|
+
const entryRel = pkgJson.interchange?.loops;
|
|
136
|
+
if (entryRel === undefined) {
|
|
137
|
+
// No loops module. A workflow with no loop primitive never calls this; one
|
|
138
|
+
// that declares a loop fails closed here when its ref is resolved.
|
|
139
|
+
return (ref) => {
|
|
140
|
+
throw new Error(`loop fn ${JSON.stringify(ref)} was requested, but the workflow package at ${args.packageDir} declares no interchange.loops module`);
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
const entryAbs = await resolveContainedEntry(args.packageDir, entryRel, "interchange.loops");
|
|
144
|
+
const importUrl = args.importCacheKey === undefined
|
|
145
|
+
? pathToFileURL(entryAbs).href
|
|
146
|
+
: `${pathToFileURL(entryAbs).href}?importCacheKey=${encodeURIComponent(args.importCacheKey)}`;
|
|
147
|
+
let mod;
|
|
148
|
+
try {
|
|
149
|
+
mod = await importModule(importUrl);
|
|
150
|
+
}
|
|
151
|
+
catch (cause) {
|
|
152
|
+
throw new Error(`failed to import interchange.loops entry ${JSON.stringify(entryRel)} for workflow package at ${args.packageDir}`, { cause });
|
|
153
|
+
}
|
|
154
|
+
if (mod === null || typeof mod !== "object") {
|
|
155
|
+
throw new Error(`interchange.loops entry ${JSON.stringify(entryRel)} for workflow package at ${args.packageDir} did not evaluate to a module object`);
|
|
156
|
+
}
|
|
157
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- module namespace object: loop fns resolve by export name
|
|
158
|
+
const loopModule = mod;
|
|
159
|
+
logger.debug `loaded interchange.loops module from ${args.packageDir}`;
|
|
160
|
+
return (ref) => {
|
|
161
|
+
const fn = loopModule[ref];
|
|
162
|
+
if (typeof fn !== "function") {
|
|
163
|
+
throw new Error(`interchange.loops entry ${JSON.stringify(entryRel)} for workflow package at ${args.packageDir} exports no loop fn named ${JSON.stringify(ref)}`);
|
|
164
|
+
}
|
|
165
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- resolved by export name; the loop runtime applies it as a pure (childOutput, carryState) fn
|
|
166
|
+
return fn;
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Compose the action-handler resolver for a workflow closure from the closure
|
|
171
|
+
* package's OWN `interchange.actions` module. An `action` primitive's `handler`
|
|
172
|
+
* ref resolves by EXPORT NAME against that module's exports.
|
|
173
|
+
*
|
|
174
|
+
* Mirrors {@link loadWorkflowLoopFnsFromClosure}: there is NO built-in default,
|
|
175
|
+
* so a package with no `interchange.actions` field composes to a resolver that
|
|
176
|
+
* throws on any lookup. A workflow that declares an `action` but ships no
|
|
177
|
+
* actions module fails closed when its handler is resolved (eagerly, at
|
|
178
|
+
* establish); a workflow with no `action` primitive never resolves a handler.
|
|
179
|
+
* Loading OUTSIDE the definition-hash re-verify is safe: the approved hash pins
|
|
180
|
+
* each handler ref string, and the closure's SRI pins the module bytes.
|
|
181
|
+
*
|
|
182
|
+
* @throws (from the returned resolver) if a requested ref names no export, or an
|
|
183
|
+
* export that is not a function.
|
|
184
|
+
* @throws if the actions entry path escapes the package or cannot be imported.
|
|
185
|
+
*/
|
|
186
|
+
export async function loadWorkflowActionHandlersFromClosure(args) {
|
|
187
|
+
const importModule = args.importModule ?? ((url) => import(url));
|
|
188
|
+
const pkgJson = await readPackageJSON(args.packageDir);
|
|
189
|
+
const entryRel = pkgJson.interchange?.actions;
|
|
190
|
+
if (entryRel === undefined) {
|
|
191
|
+
return (ref) => {
|
|
192
|
+
throw new Error(`action handler ${JSON.stringify(ref)} was requested, but the workflow package at ${args.packageDir} declares no interchange.actions module`);
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
const entryAbs = await resolveContainedEntry(args.packageDir, entryRel, "interchange.actions");
|
|
196
|
+
const importUrl = args.importCacheKey === undefined
|
|
197
|
+
? pathToFileURL(entryAbs).href
|
|
198
|
+
: `${pathToFileURL(entryAbs).href}?importCacheKey=${encodeURIComponent(args.importCacheKey)}`;
|
|
199
|
+
let mod;
|
|
200
|
+
try {
|
|
201
|
+
mod = await importModule(importUrl);
|
|
202
|
+
}
|
|
203
|
+
catch (cause) {
|
|
204
|
+
throw new Error(`failed to import interchange.actions entry ${JSON.stringify(entryRel)} for workflow package at ${args.packageDir}`, { cause });
|
|
205
|
+
}
|
|
206
|
+
if (mod === null || typeof mod !== "object") {
|
|
207
|
+
throw new Error(`interchange.actions entry ${JSON.stringify(entryRel)} for workflow package at ${args.packageDir} did not evaluate to a module object`);
|
|
208
|
+
}
|
|
209
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- module namespace object: action handlers resolve by export name
|
|
210
|
+
const actionModule = mod;
|
|
211
|
+
logger.debug `loaded interchange.actions module from ${args.packageDir}`;
|
|
212
|
+
return (ref) => {
|
|
213
|
+
const fn = actionModule[ref];
|
|
214
|
+
if (typeof fn !== "function") {
|
|
215
|
+
throw new Error(`interchange.actions entry ${JSON.stringify(entryRel)} for workflow package at ${args.packageDir} exports no action handler named ${JSON.stringify(ref)}`);
|
|
216
|
+
}
|
|
217
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- resolved by export name; invoked as an ActionHandler (input, ctx, signal) by createDefaultActionInvoker
|
|
218
|
+
return fn;
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Import each declared plugin package's `interchange.tools` module from the
|
|
223
|
+
* materialized workflow closure and collect the `AnnotatedPluginFactory`
|
|
224
|
+
* values it exports. This is the run-child counterpart to the tool-package
|
|
225
|
+
* loader's plugin channel: a source-ref workflow contributes no plugin factory
|
|
226
|
+
* through its agent definition (a plugin has no agent slot), so the child
|
|
227
|
+
* materializes the declared plugins straight from the already-laid-out closure
|
|
228
|
+
* -- no re-download, no manifest -- and feeds them into the existing per-step
|
|
229
|
+
* plugin chain. The closure bytes were SRI-verified when the deploy applied the
|
|
230
|
+
* frozen closure, and resolution walks the same `node_modules/` graph the
|
|
231
|
+
* workflow entry's imports use.
|
|
232
|
+
*
|
|
233
|
+
* @throws if a declared plugin package cannot be resolved, declares no
|
|
234
|
+
* `interchange.tools` entry, the entry escapes the package, cannot be
|
|
235
|
+
* imported, or exports no `AnnotatedPluginFactory` value
|
|
236
|
+
*/
|
|
237
|
+
export async function loadWorkflowPluginFactoriesFromClosure(args) {
|
|
238
|
+
const importModule = args.importModule ?? ((url) => import(url));
|
|
239
|
+
const out = [];
|
|
240
|
+
for (const pluginName of args.plugins) {
|
|
241
|
+
const factories = await loadPluginPackageFactories({
|
|
242
|
+
workflowPackageDir: args.packageDir,
|
|
243
|
+
pluginName,
|
|
244
|
+
importModule,
|
|
245
|
+
...(args.importCacheKey !== undefined
|
|
246
|
+
? { importCacheKey: args.importCacheKey }
|
|
247
|
+
: {}),
|
|
248
|
+
});
|
|
249
|
+
out.push(...factories);
|
|
250
|
+
}
|
|
251
|
+
return out;
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Read the static tool `definitions` each declared plugin package
|
|
255
|
+
* contributes, keyed by plugin-package name, WITHOUT retaining the plugin
|
|
256
|
+
* factory (so the caller never instantiates a plugin, which for LSP would
|
|
257
|
+
* start a subprocess). This is the probe/capability-walk counterpart to
|
|
258
|
+
* `loadWorkflowPluginFactoriesFromClosure`: it loads the SAME plugin module
|
|
259
|
+
* from the SAME frozen closure so the tool grant surface the walk approves
|
|
260
|
+
* matches the plugin the run-child materializes.
|
|
261
|
+
*
|
|
262
|
+
* A plugin package that exports plugin factories but declares no tool
|
|
263
|
+
* definitions (a middleware-only plugin) maps to an empty array -- valid,
|
|
264
|
+
* it contributes no tool grant.
|
|
265
|
+
*
|
|
266
|
+
* @throws under the same conditions as `loadWorkflowPluginFactoriesFromClosure`
|
|
267
|
+
*/
|
|
268
|
+
export async function loadWorkflowPluginToolDefinitionsFromClosure(args) {
|
|
269
|
+
const importModule = args.importModule ?? ((url) => import(url));
|
|
270
|
+
const byPackage = new Map();
|
|
271
|
+
for (const pluginName of args.plugins) {
|
|
272
|
+
const factories = await loadPluginPackageFactories({
|
|
273
|
+
workflowPackageDir: args.packageDir,
|
|
274
|
+
pluginName,
|
|
275
|
+
importModule,
|
|
276
|
+
...(args.importCacheKey !== undefined
|
|
277
|
+
? { importCacheKey: args.importCacheKey }
|
|
278
|
+
: {}),
|
|
279
|
+
});
|
|
280
|
+
const definitions = [];
|
|
281
|
+
for (const factory of factories) {
|
|
282
|
+
definitions.push(...factory.definitions);
|
|
283
|
+
}
|
|
284
|
+
byPackage.set(pluginName, definitions);
|
|
285
|
+
}
|
|
286
|
+
return byPackage;
|
|
287
|
+
}
|
|
288
|
+
async function loadPluginPackageFactories(args) {
|
|
289
|
+
// Resolve the plugin package from the workflow package's laid-out
|
|
290
|
+
// `node_modules/`. The closure materializer symlinks each direct
|
|
291
|
+
// dependency into the requirer's `node_modules/`, so a declared plugin
|
|
292
|
+
// package (which must be a workflow dependency) sits here. Realpath it so
|
|
293
|
+
// a plugin whose entry-path containment is checked below compares
|
|
294
|
+
// realpath-vs-realpath.
|
|
295
|
+
const linkedDir = path.join(args.workflowPackageDir, "node_modules", args.pluginName);
|
|
296
|
+
let pluginPkgDir;
|
|
297
|
+
try {
|
|
298
|
+
pluginPkgDir = await fs.realpath(linkedDir);
|
|
299
|
+
}
|
|
300
|
+
catch (cause) {
|
|
301
|
+
throw new Error(`plugin package ${JSON.stringify(args.pluginName)} could not be resolved from the workflow closure at ${args.workflowPackageDir}; it must be a direct dependency of the workflow package`, { cause });
|
|
302
|
+
}
|
|
303
|
+
const pkgJson = await readPackageJSON(pluginPkgDir);
|
|
304
|
+
const entryRel = pkgJson.interchange?.tools;
|
|
305
|
+
if (entryRel === undefined) {
|
|
306
|
+
throw new Error(`plugin package ${JSON.stringify(args.pluginName)} at ${pluginPkgDir} declares no "interchange.tools" entry; it is not a tool package`);
|
|
307
|
+
}
|
|
308
|
+
const entryAbs = await resolveContainedEntry(pluginPkgDir, entryRel, "interchange.tools");
|
|
309
|
+
const importUrl = args.importCacheKey === undefined
|
|
310
|
+
? pathToFileURL(entryAbs).href
|
|
311
|
+
: `${pathToFileURL(entryAbs).href}?importCacheKey=${encodeURIComponent(args.importCacheKey)}`;
|
|
312
|
+
let mod;
|
|
313
|
+
try {
|
|
314
|
+
mod = await args.importModule(importUrl);
|
|
315
|
+
}
|
|
316
|
+
catch (cause) {
|
|
317
|
+
throw new Error(`failed to import interchange.tools entry ${JSON.stringify(entryRel)} for plugin package ${JSON.stringify(args.pluginName)} at ${pluginPkgDir}`, { cause });
|
|
318
|
+
}
|
|
319
|
+
if (mod === null || typeof mod !== "object") {
|
|
320
|
+
throw new Error(`interchange.tools entry ${JSON.stringify(entryRel)} for plugin package ${JSON.stringify(args.pluginName)} at ${pluginPkgDir} did not evaluate to a module object`);
|
|
321
|
+
}
|
|
322
|
+
const factories = Object.values(mod).filter(isAnnotatedPluginFactory);
|
|
323
|
+
if (factories.length === 0) {
|
|
324
|
+
throw new Error(`interchange.tools entry ${JSON.stringify(entryRel)} for plugin package ${JSON.stringify(args.pluginName)} at ${pluginPkgDir} exported no AnnotatedPluginFactory values; a package named in an agent's plugins list must export a definePlugin factory`);
|
|
325
|
+
}
|
|
326
|
+
logger.debug `loaded ${String(factories.length)} plugin factory(ies) from ${args.pluginName} at ${pluginPkgDir}`;
|
|
327
|
+
return factories;
|
|
328
|
+
}
|
|
329
|
+
async function readPackageJSON(packageDir) {
|
|
330
|
+
const pkgJsonPath = path.join(packageDir, "package.json");
|
|
331
|
+
let raw;
|
|
332
|
+
try {
|
|
333
|
+
raw = await fs.readFile(pkgJsonPath, "utf8");
|
|
334
|
+
}
|
|
335
|
+
catch (cause) {
|
|
336
|
+
throw new Error(`cannot read package.json for workflow package at ${packageDir}`, { cause });
|
|
337
|
+
}
|
|
338
|
+
let parsed;
|
|
339
|
+
try {
|
|
340
|
+
parsed = JSON.parse(raw);
|
|
341
|
+
}
|
|
342
|
+
catch (cause) {
|
|
343
|
+
throw new Error(`malformed package.json for workflow package at ${packageDir}`, { cause });
|
|
344
|
+
}
|
|
345
|
+
const validated = PackageJSON(parsed);
|
|
346
|
+
if (validated instanceof type.errors) {
|
|
347
|
+
throw new Error(`package.json for workflow package at ${packageDir} failed validation: ${validated.summary}`);
|
|
348
|
+
}
|
|
349
|
+
return validated;
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Resolve `entryRel` against `packageDir` and confine the result to the
|
|
353
|
+
* package's own directory. `entryRel` originates from the package's
|
|
354
|
+
* `package.json` and crosses the trust boundary into the child process,
|
|
355
|
+
* so a `..`-traversal, an absolute path, or a `node_modules` symlink
|
|
356
|
+
* escape would let a malicious package import any file the child can
|
|
357
|
+
* read. The string-level check rejects `..`/absolute paths; the
|
|
358
|
+
* realpath check rejects an escape through a symlink in the closure's
|
|
359
|
+
* `node_modules` layout. Both sides are realpath'd so the comparison
|
|
360
|
+
* holds even when the closure lives under a symlinked temp root (macOS
|
|
361
|
+
* resolves `/tmp` to `/private/tmp`).
|
|
362
|
+
*/
|
|
363
|
+
async function resolveContainedEntry(packageDir, entryRel, fieldLabel) {
|
|
364
|
+
// String-level containment, shared with the push-time asset validator so the
|
|
365
|
+
// two boundaries agree on what "contained" means.
|
|
366
|
+
if (!isContainedEntryPath(entryRel)) {
|
|
367
|
+
throw new Error(`${fieldLabel} entry path ${JSON.stringify(entryRel)} escapes the workflow package directory ${packageDir}`);
|
|
368
|
+
}
|
|
369
|
+
const entryAbs = path.resolve(packageDir, entryRel);
|
|
370
|
+
let realPackageDir;
|
|
371
|
+
let realEntryAbs;
|
|
372
|
+
try {
|
|
373
|
+
realPackageDir = await fs.realpath(packageDir);
|
|
374
|
+
realEntryAbs = await fs.realpath(entryAbs);
|
|
375
|
+
}
|
|
376
|
+
catch (cause) {
|
|
377
|
+
throw new Error(`${fieldLabel} entry path ${JSON.stringify(entryRel)} for workflow package at ${packageDir} could not be resolved`, { cause });
|
|
378
|
+
}
|
|
379
|
+
const realContainmentRoot = realPackageDir.endsWith(path.sep)
|
|
380
|
+
? realPackageDir
|
|
381
|
+
: realPackageDir + path.sep;
|
|
382
|
+
if (realEntryAbs !== realPackageDir &&
|
|
383
|
+
!realEntryAbs.startsWith(realContainmentRoot)) {
|
|
384
|
+
throw new Error(`${fieldLabel} entry path ${JSON.stringify(entryRel)} for workflow package at ${packageDir} escapes the package directory via a symlink`);
|
|
385
|
+
}
|
|
386
|
+
return entryAbs;
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Pick the single `WorkflowDefinition` the entry module produces. A
|
|
390
|
+
* workflow package's entry evaluates one `defineWorkflow(...)` call and
|
|
391
|
+
* exposes its result as an export (by convention `export default`, but a
|
|
392
|
+
* named export is accepted too). Every export is validated against the
|
|
393
|
+
* envelope schema; exactly one must pass. Zero or more than one is a
|
|
394
|
+
* malformed workflow package and fails loudly rather than guessing.
|
|
395
|
+
*/
|
|
396
|
+
function selectWorkflowDefinition(mod, packageDir, entryRel) {
|
|
397
|
+
const matches = [];
|
|
398
|
+
for (const value of Object.values(mod)) {
|
|
399
|
+
const validated = workflowDefinitionEnvelopeSchema(value);
|
|
400
|
+
if (validated instanceof type.errors) {
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
// The envelope schema enforces the cross-cutting structural shape
|
|
404
|
+
// (`id`, `triggers`, `steps`, `stepOrder`); the per-primitive narrow
|
|
405
|
+
// lives downstream in the runtime that hydrates the definition. This
|
|
406
|
+
// mirrors the boundary the repo's other `WorkflowDefinition` readers
|
|
407
|
+
// use (see `run-child.ts`, `spawn-child.ts`).
|
|
408
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- envelope schema enforces structural shape; primitive narrows live downstream in the runtime body
|
|
409
|
+
matches.push(validated);
|
|
410
|
+
}
|
|
411
|
+
if (matches.length === 0) {
|
|
412
|
+
throw new Error(`interchange.workflow entry ${JSON.stringify(entryRel)} for workflow package at ${packageDir} exported no value that validates as a WorkflowDefinition`);
|
|
413
|
+
}
|
|
414
|
+
if (matches.length > 1) {
|
|
415
|
+
throw new Error(`interchange.workflow entry ${JSON.stringify(entryRel)} for workflow package at ${packageDir} exported ${String(matches.length)} WorkflowDefinition values; the entry must produce exactly one`);
|
|
416
|
+
}
|
|
417
|
+
const [definition] = matches;
|
|
418
|
+
if (definition === undefined) {
|
|
419
|
+
throw new Error(`interchange.workflow entry ${JSON.stringify(entryRel)} for workflow package at ${packageDir} produced no WorkflowDefinition`);
|
|
420
|
+
}
|
|
421
|
+
return definition;
|
|
422
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intx/workflow-host",
|
|
3
|
-
"
|
|
3
|
+
"description": "Production-host implementations of the abstract WorkflowRuntimeEnv from @intx/workflow",
|
|
4
|
+
"version": "0.4.0",
|
|
4
5
|
"license": "LGPL-2.1-only",
|
|
5
6
|
"type": "module",
|
|
6
7
|
"exports": {
|
|
@@ -8,19 +9,25 @@
|
|
|
8
9
|
"intx-src": "./src/index.ts",
|
|
9
10
|
"types": "./dist/index.d.ts",
|
|
10
11
|
"default": "./dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./testing": {
|
|
14
|
+
"intx-src": "./src/testing/index.ts",
|
|
15
|
+
"types": "./dist/testing/index.d.ts",
|
|
16
|
+
"default": "./dist/testing/index.js"
|
|
11
17
|
}
|
|
12
18
|
},
|
|
13
19
|
"dependencies": {
|
|
14
|
-
"@intx/agent": "0.
|
|
15
|
-
"@intx/crypto": "0.
|
|
16
|
-
"@intx/hub-sessions": "0.
|
|
17
|
-
"@intx/inference": "0.
|
|
18
|
-
"@intx/log": "0.
|
|
19
|
-
"@intx/mail-memory": "0.
|
|
20
|
-
"@intx/
|
|
21
|
-
"@intx/
|
|
22
|
-
"@intx/
|
|
23
|
-
"@intx/
|
|
20
|
+
"@intx/agent": "0.4.0",
|
|
21
|
+
"@intx/crypto": "0.4.0",
|
|
22
|
+
"@intx/hub-sessions": "0.4.0",
|
|
23
|
+
"@intx/inference": "0.4.0",
|
|
24
|
+
"@intx/log": "0.4.0",
|
|
25
|
+
"@intx/mail-memory": "0.4.0",
|
|
26
|
+
"@intx/mailbox": "0.4.0",
|
|
27
|
+
"@intx/mime": "0.4.0",
|
|
28
|
+
"@intx/storage-isogit": "0.4.0",
|
|
29
|
+
"@intx/types": "0.4.0",
|
|
30
|
+
"@intx/workflow": "0.4.0",
|
|
24
31
|
"arktype": "^2.1.29"
|
|
25
32
|
},
|
|
26
33
|
"files": [
|