@intx/workflow-host 0.2.2 → 0.3.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.
Files changed (53) hide show
  1. package/README.md +56 -10
  2. package/dist/adapters/repo-store.d.ts +22 -1
  3. package/dist/adapters/repo-store.js +53 -53
  4. package/dist/adapters/spawn-child.d.ts +71 -42
  5. package/dist/adapters/spawn-child.js +83 -77
  6. package/dist/adapters/step-invoker.js +84 -7
  7. package/dist/child/env-bootstrap.d.ts +20 -6
  8. package/dist/child/env-bootstrap.js +9 -1
  9. package/dist/child/index.d.ts +2 -1
  10. package/dist/child/parked-correlations.d.ts +42 -0
  11. package/dist/child/parked-correlations.js +80 -0
  12. package/dist/child/proxy-repo-store.d.ts +3 -2
  13. package/dist/child/proxy-repo-store.js +2 -0
  14. package/dist/child/run-child.d.ts +107 -13
  15. package/dist/child/run-child.js +290 -108
  16. package/dist/child/self-discovery.d.ts +10 -0
  17. package/dist/child/self-discovery.js +25 -1
  18. package/dist/child/verified-definition-loader.d.ts +33 -0
  19. package/dist/child/verified-definition-loader.js +43 -0
  20. package/dist/conversation-text.d.ts +23 -0
  21. package/dist/conversation-text.js +56 -0
  22. package/dist/index.d.ts +4 -3
  23. package/dist/index.js +3 -2
  24. package/dist/ipc/control-channel.d.ts +58 -0
  25. package/dist/ipc/control-channel.js +94 -1
  26. package/dist/ipc/event-channel.d.ts +32 -1
  27. package/dist/mail-bus/hub-transport-adapter.d.ts +12 -7
  28. package/dist/mail-bus/hub-transport-adapter.js +9 -5
  29. package/dist/seams/scheduler.d.ts +4 -6
  30. package/dist/seams/scheduler.js +74 -93
  31. package/dist/supervisor/cancel-signing.d.ts +2 -2
  32. package/dist/supervisor/cancel-signing.js +1 -1
  33. package/dist/supervisor/credentials.d.ts +11 -10
  34. package/dist/supervisor/credentials.js +7 -7
  35. package/dist/supervisor/dispatch-attribution.js +1 -1
  36. package/dist/supervisor/drain-timeout.d.ts +2 -2
  37. package/dist/supervisor/drain-timeout.js +1 -1
  38. package/dist/supervisor/index.d.ts +3 -3
  39. package/dist/supervisor/index.js +2 -2
  40. package/dist/supervisor/recycle.d.ts +5 -2
  41. package/dist/supervisor/recycle.js +18 -7
  42. package/dist/supervisor/run-event-compaction.d.ts +5 -5
  43. package/dist/supervisor/run-event-compaction.js +5 -5
  44. package/dist/supervisor/spawn-env.d.ts +2 -2
  45. package/dist/supervisor/spawn-env.js +1 -1
  46. package/dist/supervisor/supervisor.d.ts +82 -25
  47. package/dist/supervisor/supervisor.js +1313 -410
  48. package/dist/supervisor/terminal-commit.d.ts +36 -0
  49. package/dist/supervisor/terminal-commit.js +134 -0
  50. package/dist/supervisor/types.d.ts +150 -23
  51. package/dist/workflow-definition-loader.d.ts +131 -0
  52. package/dist/workflow-definition-loader.js +316 -0
  53. package/package.json +12 -11
@@ -0,0 +1,316 @@
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
+ * Import each declared plugin package's `interchange.tools` module from the
117
+ * materialized workflow closure and collect the `AnnotatedPluginFactory`
118
+ * values it exports. This is the run-child counterpart to the tool-package
119
+ * loader's plugin channel: a source-ref workflow contributes no plugin factory
120
+ * through its agent definition (a plugin has no agent slot), so the child
121
+ * materializes the declared plugins straight from the already-laid-out closure
122
+ * -- no re-download, no manifest -- and feeds them into the existing per-step
123
+ * plugin chain. The closure bytes were SRI-verified when the deploy applied the
124
+ * frozen closure, and resolution walks the same `node_modules/` graph the
125
+ * workflow entry's imports use.
126
+ *
127
+ * @throws if a declared plugin package cannot be resolved, declares no
128
+ * `interchange.tools` entry, the entry escapes the package, cannot be
129
+ * imported, or exports no `AnnotatedPluginFactory` value
130
+ */
131
+ export async function loadWorkflowPluginFactoriesFromClosure(args) {
132
+ const importModule = args.importModule ?? ((url) => import(url));
133
+ const out = [];
134
+ for (const pluginName of args.plugins) {
135
+ const factories = await loadPluginPackageFactories({
136
+ workflowPackageDir: args.packageDir,
137
+ pluginName,
138
+ importModule,
139
+ ...(args.importCacheKey !== undefined
140
+ ? { importCacheKey: args.importCacheKey }
141
+ : {}),
142
+ });
143
+ out.push(...factories);
144
+ }
145
+ return out;
146
+ }
147
+ /**
148
+ * Read the static tool `definitions` each declared plugin package
149
+ * contributes, keyed by plugin-package name, WITHOUT retaining the plugin
150
+ * factory (so the caller never instantiates a plugin, which for LSP would
151
+ * start a subprocess). This is the probe/capability-walk counterpart to
152
+ * `loadWorkflowPluginFactoriesFromClosure`: it loads the SAME plugin module
153
+ * from the SAME frozen closure so the tool grant surface the walk approves
154
+ * matches the plugin the run-child materializes.
155
+ *
156
+ * A plugin package that exports plugin factories but declares no tool
157
+ * definitions (a middleware-only plugin) maps to an empty array -- valid,
158
+ * it contributes no tool grant.
159
+ *
160
+ * @throws under the same conditions as `loadWorkflowPluginFactoriesFromClosure`
161
+ */
162
+ export async function loadWorkflowPluginToolDefinitionsFromClosure(args) {
163
+ const importModule = args.importModule ?? ((url) => import(url));
164
+ const byPackage = new Map();
165
+ for (const pluginName of args.plugins) {
166
+ const factories = await loadPluginPackageFactories({
167
+ workflowPackageDir: args.packageDir,
168
+ pluginName,
169
+ importModule,
170
+ ...(args.importCacheKey !== undefined
171
+ ? { importCacheKey: args.importCacheKey }
172
+ : {}),
173
+ });
174
+ const definitions = [];
175
+ for (const factory of factories) {
176
+ definitions.push(...factory.definitions);
177
+ }
178
+ byPackage.set(pluginName, definitions);
179
+ }
180
+ return byPackage;
181
+ }
182
+ async function loadPluginPackageFactories(args) {
183
+ // Resolve the plugin package from the workflow package's laid-out
184
+ // `node_modules/`. The closure materializer symlinks each direct
185
+ // dependency into the requirer's `node_modules/`, so a declared plugin
186
+ // package (which must be a workflow dependency) sits here. Realpath it so
187
+ // a plugin whose entry-path containment is checked below compares
188
+ // realpath-vs-realpath.
189
+ const linkedDir = path.join(args.workflowPackageDir, "node_modules", args.pluginName);
190
+ let pluginPkgDir;
191
+ try {
192
+ pluginPkgDir = await fs.realpath(linkedDir);
193
+ }
194
+ catch (cause) {
195
+ 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 });
196
+ }
197
+ const pkgJson = await readPackageJSON(pluginPkgDir);
198
+ const entryRel = pkgJson.interchange?.tools;
199
+ if (entryRel === undefined) {
200
+ throw new Error(`plugin package ${JSON.stringify(args.pluginName)} at ${pluginPkgDir} declares no "interchange.tools" entry; it is not a tool package`);
201
+ }
202
+ const entryAbs = await resolveContainedEntry(pluginPkgDir, entryRel, "interchange.tools");
203
+ const importUrl = args.importCacheKey === undefined
204
+ ? pathToFileURL(entryAbs).href
205
+ : `${pathToFileURL(entryAbs).href}?importCacheKey=${encodeURIComponent(args.importCacheKey)}`;
206
+ let mod;
207
+ try {
208
+ mod = await args.importModule(importUrl);
209
+ }
210
+ catch (cause) {
211
+ throw new Error(`failed to import interchange.tools entry ${JSON.stringify(entryRel)} for plugin package ${JSON.stringify(args.pluginName)} at ${pluginPkgDir}`, { cause });
212
+ }
213
+ if (mod === null || typeof mod !== "object") {
214
+ 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`);
215
+ }
216
+ const factories = Object.values(mod).filter(isAnnotatedPluginFactory);
217
+ if (factories.length === 0) {
218
+ 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`);
219
+ }
220
+ logger.debug `loaded ${String(factories.length)} plugin factory(ies) from ${args.pluginName} at ${pluginPkgDir}`;
221
+ return factories;
222
+ }
223
+ async function readPackageJSON(packageDir) {
224
+ const pkgJsonPath = path.join(packageDir, "package.json");
225
+ let raw;
226
+ try {
227
+ raw = await fs.readFile(pkgJsonPath, "utf8");
228
+ }
229
+ catch (cause) {
230
+ throw new Error(`cannot read package.json for workflow package at ${packageDir}`, { cause });
231
+ }
232
+ let parsed;
233
+ try {
234
+ parsed = JSON.parse(raw);
235
+ }
236
+ catch (cause) {
237
+ throw new Error(`malformed package.json for workflow package at ${packageDir}`, { cause });
238
+ }
239
+ const validated = PackageJSON(parsed);
240
+ if (validated instanceof type.errors) {
241
+ throw new Error(`package.json for workflow package at ${packageDir} failed validation: ${validated.summary}`);
242
+ }
243
+ return validated;
244
+ }
245
+ /**
246
+ * Resolve `entryRel` against `packageDir` and confine the result to the
247
+ * package's own directory. `entryRel` originates from the package's
248
+ * `package.json` and crosses the trust boundary into the child process,
249
+ * so a `..`-traversal, an absolute path, or a `node_modules` symlink
250
+ * escape would let a malicious package import any file the child can
251
+ * read. The string-level check rejects `..`/absolute paths; the
252
+ * realpath check rejects an escape through a symlink in the closure's
253
+ * `node_modules` layout. Both sides are realpath'd so the comparison
254
+ * holds even when the closure lives under a symlinked temp root (macOS
255
+ * resolves `/tmp` to `/private/tmp`).
256
+ */
257
+ async function resolveContainedEntry(packageDir, entryRel, fieldLabel) {
258
+ // String-level containment, shared with the push-time asset validator so the
259
+ // two boundaries agree on what "contained" means.
260
+ if (!isContainedEntryPath(entryRel)) {
261
+ throw new Error(`${fieldLabel} entry path ${JSON.stringify(entryRel)} escapes the workflow package directory ${packageDir}`);
262
+ }
263
+ const entryAbs = path.resolve(packageDir, entryRel);
264
+ let realPackageDir;
265
+ let realEntryAbs;
266
+ try {
267
+ realPackageDir = await fs.realpath(packageDir);
268
+ realEntryAbs = await fs.realpath(entryAbs);
269
+ }
270
+ catch (cause) {
271
+ throw new Error(`${fieldLabel} entry path ${JSON.stringify(entryRel)} for workflow package at ${packageDir} could not be resolved`, { cause });
272
+ }
273
+ const realContainmentRoot = realPackageDir.endsWith(path.sep)
274
+ ? realPackageDir
275
+ : realPackageDir + path.sep;
276
+ if (realEntryAbs !== realPackageDir &&
277
+ !realEntryAbs.startsWith(realContainmentRoot)) {
278
+ throw new Error(`${fieldLabel} entry path ${JSON.stringify(entryRel)} for workflow package at ${packageDir} escapes the package directory via a symlink`);
279
+ }
280
+ return entryAbs;
281
+ }
282
+ /**
283
+ * Pick the single `WorkflowDefinition` the entry module produces. A
284
+ * workflow package's entry evaluates one `defineWorkflow(...)` call and
285
+ * exposes its result as an export (by convention `export default`, but a
286
+ * named export is accepted too). Every export is validated against the
287
+ * envelope schema; exactly one must pass. Zero or more than one is a
288
+ * malformed workflow package and fails loudly rather than guessing.
289
+ */
290
+ function selectWorkflowDefinition(mod, packageDir, entryRel) {
291
+ const matches = [];
292
+ for (const value of Object.values(mod)) {
293
+ const validated = workflowDefinitionEnvelopeSchema(value);
294
+ if (validated instanceof type.errors) {
295
+ continue;
296
+ }
297
+ // The envelope schema enforces the cross-cutting structural shape
298
+ // (`id`, `triggers`, `steps`, `stepOrder`); the per-primitive narrow
299
+ // lives downstream in the runtime that hydrates the definition. This
300
+ // mirrors the boundary the repo's other `WorkflowDefinition` readers
301
+ // use (see `run-child.ts`, `spawn-child.ts`).
302
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- envelope schema enforces structural shape; primitive narrows live downstream in the runtime body
303
+ matches.push(validated);
304
+ }
305
+ if (matches.length === 0) {
306
+ throw new Error(`interchange.workflow entry ${JSON.stringify(entryRel)} for workflow package at ${packageDir} exported no value that validates as a WorkflowDefinition`);
307
+ }
308
+ if (matches.length > 1) {
309
+ 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`);
310
+ }
311
+ const [definition] = matches;
312
+ if (definition === undefined) {
313
+ throw new Error(`interchange.workflow entry ${JSON.stringify(entryRel)} for workflow package at ${packageDir} produced no WorkflowDefinition`);
314
+ }
315
+ return definition;
316
+ }
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@intx/workflow-host",
3
- "version": "0.2.2",
3
+ "description": "Production-host implementations of the abstract WorkflowRuntimeEnv from @intx/workflow",
4
+ "version": "0.3.0",
4
5
  "license": "LGPL-2.1-only",
5
6
  "type": "module",
6
7
  "exports": {
@@ -11,16 +12,16 @@
11
12
  }
12
13
  },
13
14
  "dependencies": {
14
- "@intx/agent": "0.2.2",
15
- "@intx/crypto": "0.2.2",
16
- "@intx/hub-sessions": "0.2.2",
17
- "@intx/inference": "0.2.2",
18
- "@intx/log": "0.2.2",
19
- "@intx/mail-memory": "0.2.2",
20
- "@intx/mime": "0.2.2",
21
- "@intx/storage-isogit": "0.2.2",
22
- "@intx/types": "0.2.2",
23
- "@intx/workflow": "0.2.2",
15
+ "@intx/agent": "0.3.0",
16
+ "@intx/crypto": "0.3.0",
17
+ "@intx/hub-sessions": "0.3.0",
18
+ "@intx/inference": "0.3.0",
19
+ "@intx/log": "0.3.0",
20
+ "@intx/mail-memory": "0.3.0",
21
+ "@intx/mime": "0.3.0",
22
+ "@intx/storage-isogit": "0.3.0",
23
+ "@intx/types": "0.3.0",
24
+ "@intx/workflow": "0.3.0",
24
25
  "arktype": "^2.1.29"
25
26
  },
26
27
  "files": [