@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.
Files changed (101) hide show
  1. package/README.md +77 -14
  2. package/dist/adapters/mail-part-store.d.ts +46 -0
  3. package/dist/adapters/mail-part-store.js +251 -0
  4. package/dist/adapters/repo-store.d.ts +22 -1
  5. package/dist/adapters/repo-store.js +56 -65
  6. package/dist/adapters/spawn-child.d.ts +109 -44
  7. package/dist/adapters/spawn-child.js +77 -81
  8. package/dist/adapters/step-invoker.d.ts +52 -2
  9. package/dist/adapters/step-invoker.js +284 -37
  10. package/dist/adapters/substrate-mailbox-store.d.ts +80 -0
  11. package/dist/adapters/substrate-mailbox-store.js +404 -0
  12. package/dist/child/child-mailbox-reader.d.ts +10 -0
  13. package/dist/child/child-mailbox-reader.js +23 -0
  14. package/dist/child/credential-cell.d.ts +8 -0
  15. package/dist/child/credential-cell.js +66 -0
  16. package/dist/child/env-bootstrap.d.ts +20 -6
  17. package/dist/child/env-bootstrap.js +9 -1
  18. package/dist/child/from-process-env.d.ts +12 -0
  19. package/dist/child/from-process-env.js +6 -0
  20. package/dist/child/index.d.ts +6 -2
  21. package/dist/child/index.js +4 -1
  22. package/dist/child/mailbox-mutation-bridge.d.ts +61 -0
  23. package/dist/child/mailbox-mutation-bridge.js +101 -0
  24. package/dist/child/mailbox-watch-registry.d.ts +17 -0
  25. package/dist/child/mailbox-watch-registry.js +61 -0
  26. package/dist/child/outbound-mail-bridge.d.ts +3 -2
  27. package/dist/child/outbound-mail-bridge.js +20 -32
  28. package/dist/child/parked-correlations.d.ts +42 -0
  29. package/dist/child/parked-correlations.js +80 -0
  30. package/dist/child/pending-request.d.ts +89 -0
  31. package/dist/child/pending-request.js +80 -0
  32. package/dist/child/proxy-repo-store.d.ts +3 -2
  33. package/dist/child/proxy-repo-store.js +2 -0
  34. package/dist/child/run-child.d.ts +170 -14
  35. package/dist/child/run-child.js +569 -155
  36. package/dist/child/self-discovery.d.ts +10 -0
  37. package/dist/child/self-discovery.js +25 -1
  38. package/dist/child/substrate-write-bridge.d.ts +3 -2
  39. package/dist/child/substrate-write-bridge.js +21 -38
  40. package/dist/child/supervisor-backed-transport.d.ts +52 -6
  41. package/dist/child/supervisor-backed-transport.js +205 -62
  42. package/dist/child/verified-definition-loader.d.ts +33 -0
  43. package/dist/child/verified-definition-loader.js +43 -0
  44. package/dist/child/warm-agent-cache.d.ts +44 -4
  45. package/dist/child/warm-agent-cache.js +41 -10
  46. package/dist/index.d.ts +6 -4
  47. package/dist/index.js +6 -4
  48. package/dist/ipc/control-channel.d.ts +151 -2
  49. package/dist/ipc/control-channel.js +222 -29
  50. package/dist/ipc/event-channel.d.ts +32 -1
  51. package/dist/ipc/index.d.ts +1 -1
  52. package/dist/ipc/index.js +1 -1
  53. package/dist/mail-bus/hub-transport-adapter.d.ts +12 -7
  54. package/dist/mail-bus/hub-transport-adapter.js +9 -5
  55. package/dist/run-body-then-cleanup.d.ts +17 -0
  56. package/dist/run-body-then-cleanup.js +38 -0
  57. package/dist/seams/scheduler.d.ts +16 -6
  58. package/dist/seams/scheduler.js +87 -97
  59. package/dist/supervisor/cancel-signing.d.ts +2 -2
  60. package/dist/supervisor/cancel-signing.js +4 -8
  61. package/dist/supervisor/credentials.d.ts +28 -15
  62. package/dist/supervisor/credentials.js +7 -7
  63. package/dist/supervisor/dispatch-attribution.js +1 -1
  64. package/dist/supervisor/drain-timeout.d.ts +2 -2
  65. package/dist/supervisor/drain-timeout.js +1 -1
  66. package/dist/supervisor/index.d.ts +3 -3
  67. package/dist/supervisor/index.js +2 -2
  68. package/dist/supervisor/recycle.d.ts +10 -3
  69. package/dist/supervisor/recycle.js +18 -7
  70. package/dist/supervisor/run-event-compaction.d.ts +5 -5
  71. package/dist/supervisor/run-event-compaction.js +14 -19
  72. package/dist/supervisor/run-event-recovery.d.ts +34 -0
  73. package/dist/supervisor/run-event-recovery.js +45 -0
  74. package/dist/supervisor/spawn-env.d.ts +2 -2
  75. package/dist/supervisor/spawn-env.js +1 -1
  76. package/dist/supervisor/supervisor.d.ts +106 -26
  77. package/dist/supervisor/supervisor.js +1903 -414
  78. package/dist/supervisor/terminal-commit.d.ts +36 -0
  79. package/dist/supervisor/terminal-commit.js +130 -0
  80. package/dist/supervisor/types.d.ts +180 -23
  81. package/dist/testing/change-notifier.d.ts +12 -0
  82. package/dist/testing/change-notifier.js +63 -0
  83. package/dist/testing/index.d.ts +8 -0
  84. package/dist/testing/index.js +16 -0
  85. package/dist/testing/log-capture.d.ts +52 -0
  86. package/dist/testing/log-capture.js +124 -0
  87. package/dist/testing/mail-bus.d.ts +22 -0
  88. package/dist/testing/mail-bus.js +78 -0
  89. package/dist/testing/memory-streams.d.ts +43 -0
  90. package/dist/testing/memory-streams.js +211 -0
  91. package/dist/testing/spawn-observer.d.ts +12 -0
  92. package/dist/testing/spawn-observer.js +36 -0
  93. package/dist/testing/stub-repo-store.d.ts +10 -0
  94. package/dist/testing/stub-repo-store.js +39 -0
  95. package/dist/testing/supervisor-reaper.d.ts +24 -0
  96. package/dist/testing/supervisor-reaper.js +49 -0
  97. package/dist/testing/upstream-frames.d.ts +47 -0
  98. package/dist/testing/upstream-frames.js +94 -0
  99. package/dist/workflow-definition-loader.d.ts +187 -0
  100. package/dist/workflow-definition-loader.js +422 -0
  101. 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
- "version": "0.2.2",
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.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",
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": [