@telorun/kernel 0.24.2 → 0.26.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 (68) hide show
  1. package/dist/application-env.d.ts +18 -0
  2. package/dist/application-env.d.ts.map +1 -1
  3. package/dist/application-env.js +36 -0
  4. package/dist/application-env.js.map +1 -1
  5. package/dist/controller-loader.d.ts +4 -0
  6. package/dist/controller-loader.d.ts.map +1 -1
  7. package/dist/controller-loader.js +4 -1
  8. package/dist/controller-loader.js.map +1 -1
  9. package/dist/controller-loaders/npm-loader.d.ts +51 -12
  10. package/dist/controller-loaders/npm-loader.d.ts.map +1 -1
  11. package/dist/controller-loaders/npm-loader.js +119 -52
  12. package/dist/controller-loaders/npm-loader.js.map +1 -1
  13. package/dist/controller-registry.d.ts +9 -0
  14. package/dist/controller-registry.d.ts.map +1 -1
  15. package/dist/controller-registry.js +18 -0
  16. package/dist/controller-registry.js.map +1 -1
  17. package/dist/controllers/module/import-controller.d.ts.map +1 -1
  18. package/dist/controllers/module/import-controller.js +50 -28
  19. package/dist/controllers/module/import-controller.js.map +1 -1
  20. package/dist/controllers/resource-definition/resource-definition-controller.d.ts.map +1 -1
  21. package/dist/controllers/resource-definition/resource-definition-controller.js +1 -0
  22. package/dist/controllers/resource-definition/resource-definition-controller.js.map +1 -1
  23. package/dist/evaluation-context.d.ts.map +1 -1
  24. package/dist/evaluation-context.js +13 -5
  25. package/dist/evaluation-context.js.map +1 -1
  26. package/dist/index.d.ts +1 -1
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +1 -1
  29. package/dist/index.js.map +1 -1
  30. package/dist/kernel.d.ts +8 -0
  31. package/dist/kernel.d.ts.map +1 -1
  32. package/dist/kernel.js +46 -15
  33. package/dist/kernel.js.map +1 -1
  34. package/dist/manifest-sources/analysis-stamp.d.ts +2 -2
  35. package/dist/manifest-sources/analysis-stamp.d.ts.map +1 -1
  36. package/dist/manifest-sources/analysis-stamp.js +9 -4
  37. package/dist/manifest-sources/analysis-stamp.js.map +1 -1
  38. package/dist/manifest-sources/local-manifest-cache-source.d.ts +13 -3
  39. package/dist/manifest-sources/local-manifest-cache-source.d.ts.map +1 -1
  40. package/dist/manifest-sources/local-manifest-cache-source.js +25 -6
  41. package/dist/manifest-sources/local-manifest-cache-source.js.map +1 -1
  42. package/dist/module-context.d.ts +56 -7
  43. package/dist/module-context.d.ts.map +1 -1
  44. package/dist/module-context.js +121 -10
  45. package/dist/module-context.js.map +1 -1
  46. package/dist/resource-context.d.ts +1 -0
  47. package/dist/resource-context.d.ts.map +1 -1
  48. package/dist/resource-context.js +3 -0
  49. package/dist/resource-context.js.map +1 -1
  50. package/dist/schema-validator.d.ts +8 -1
  51. package/dist/schema-validator.d.ts.map +1 -1
  52. package/dist/schema-validator.js +47 -5
  53. package/dist/schema-validator.js.map +1 -1
  54. package/package.json +3 -3
  55. package/src/application-env.ts +37 -0
  56. package/src/controller-loader.ts +8 -1
  57. package/src/controller-loaders/npm-loader.ts +136 -59
  58. package/src/controller-registry.ts +18 -0
  59. package/src/controllers/module/import-controller.ts +58 -28
  60. package/src/controllers/resource-definition/resource-definition-controller.ts +1 -0
  61. package/src/evaluation-context.ts +15 -5
  62. package/src/index.ts +1 -0
  63. package/src/kernel.ts +65 -16
  64. package/src/manifest-sources/analysis-stamp.ts +9 -2
  65. package/src/manifest-sources/local-manifest-cache-source.ts +29 -4
  66. package/src/module-context.ts +153 -11
  67. package/src/resource-context.ts +4 -0
  68. package/src/schema-validator.ts +49 -5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telorun/kernel",
3
- "version": "0.24.2",
3
+ "version": "0.26.0",
4
4
  "description": "Telo Runtime - A lightweight, polyglot execution host.",
5
5
  "keywords": [
6
6
  "telo",
@@ -47,7 +47,7 @@
47
47
  "dependencies": {
48
48
  "@marcbachmann/cel-js": "^7.6.1",
49
49
  "@sinclair/typebox": "^0.34.48",
50
- "@telorun/analyzer": "0.22.0",
50
+ "@telorun/analyzer": "0.23.0",
51
51
  "@telorun/templating": "0.8.0",
52
52
  "ajv": "^8.17.1",
53
53
  "ajv-formats": "^3.0.1",
@@ -59,7 +59,7 @@
59
59
  "@types/node": "^20.0.0",
60
60
  "typescript": "^5.0.0",
61
61
  "vitest": "^2.1.8",
62
- "@telorun/sdk": "0.23.0"
62
+ "@telorun/sdk": "0.26.0"
63
63
  },
64
64
  "peerDependencies": {
65
65
  "@telorun/sdk": "*"
@@ -124,6 +124,43 @@ export function precompileApplicationEnvSchemas(
124
124
  }
125
125
  }
126
126
 
127
+ /**
128
+ * Build-time cache warm for resource-config validators. The runtime
129
+ * `_createInstance` compiles `controller.schema` — which falls back to the
130
+ * declaring `Telo.Definition`'s own `schema` — to validate every resource's
131
+ * config, then validates inputs/outputs against `inputType` / `outputType`.
132
+ * The analyze-only warm pass stops before instantiation, so without this those
133
+ * validators are absent from the `__validators` cache and the runtime
134
+ * recompiles (and, on a read-only image, fails to persist) them on every boot.
135
+ *
136
+ * Compiling each definition's `schema` (plus any inline `inputType` /
137
+ * `outputType` object schemas) here writes them into the same content-addressed
138
+ * cache the runtime reads, keyed identically because the same schema object is
139
+ * fed to the same `validator.compile`. Definitions whose controller exports its
140
+ * own `schema` (rare) still recompile at runtime — that needs the controller
141
+ * loaded, which the warm pass does not do. Compile failures are swallowed; a
142
+ * genuinely broken schema surfaces through analysis / runtime, not here.
143
+ */
144
+ export function precompileDefinitionSchemas(
145
+ manifests: Array<Record<string, any>>,
146
+ validator: SchemaValidator,
147
+ ): void {
148
+ const compile = (schema: unknown): void => {
149
+ if (!schema || typeof schema !== "object") return;
150
+ try {
151
+ validator.compile(schema as any);
152
+ } catch {
153
+ // Broken schemas are reported by analysis / runtime, not the warm pass.
154
+ }
155
+ };
156
+ for (const m of manifests) {
157
+ if (m?.kind !== "Telo.Definition") continue;
158
+ compile(m.schema);
159
+ compile(m.inputType);
160
+ compile(m.outputType);
161
+ }
162
+ }
163
+
127
164
  /**
128
165
  * Populate the root Application's `ports` namespace from host environment
129
166
  * variables. Mirrors `resolveBlock` but fixes the value type to a port integer
@@ -55,6 +55,10 @@ export interface ControllerLoaderOptions {
55
55
  * (cargo loader has its own per-crate cache and does not need this).
56
56
  */
57
57
  entryUrl?: string;
58
+ /** Explicit npm install root (`<cache-root>/npm`), threaded from the kernel's
59
+ * single `resolveCacheRoot`. Overrides the entry-anchored default so a
60
+ * relocated `TELO_CACHE_DIR` is honoured. */
61
+ installRoot?: string;
58
62
  }
59
63
 
60
64
  /**
@@ -82,7 +86,10 @@ export class ControllerLoader {
82
86
 
83
87
  constructor(options: ControllerLoaderOptions = {}) {
84
88
  this.emit = options.emit;
85
- this.npmLoader = new NpmControllerLoader({ entryUrl: options.entryUrl });
89
+ this.npmLoader = new NpmControllerLoader({
90
+ entryUrl: options.entryUrl,
91
+ installRoot: options.installRoot,
92
+ });
86
93
  this.napiLoader = new NapiControllerLoader();
87
94
  this.bundleLoader = new BundleControllerLoader();
88
95
  }
@@ -97,6 +97,14 @@ export interface NpmControllerLoaderOptions {
97
97
  * `pkg-name` cli command resolves it from its argument.
98
98
  */
99
99
  entryUrl?: string;
100
+ /**
101
+ * Explicit install root, threaded from the kernel's single `resolveCacheRoot`
102
+ * (`<cache-root>/npm`). When set it overrides the entry-anchored
103
+ * `computeInstallRoot`, so a relocated `TELO_CACHE_DIR` (e.g. a prebuilt
104
+ * image baking deps at `/telo-cache`) is honoured without this loader reading
105
+ * the env itself.
106
+ */
107
+ installRoot?: string;
100
108
  }
101
109
 
102
110
  /**
@@ -104,14 +112,17 @@ export interface NpmControllerLoaderOptions {
104
112
  * local entry manifest (`file://` URL or bare path) the root lives at
105
113
  * `<entry-manifest-dir>/.telo/npm/`; for an HTTP(S) entry URL it lives in a
106
114
  * user-level cache keyed by `sha256(entryUrl)` (see `computeInstallRoot`).
107
- * Every controller — registry tag or `local_path` — is installed via
108
- * `npm install <spec>` into this root, then imported from
109
- * `<root>/node_modules/<pkg>`. This collapses two parallel
110
- * module realms (kernel-side @telorun/sdk vs. controller-side @telorun/sdk)
111
- * into one: the kernel's own SDK is wired in as a `file:` dep, npm/pnpm
112
- * symlink it, and Node's ESM resolver follows the symlink to the same
113
- * realpath as the kernel so `Stream` (and any other class-identity-sensitive
114
- * type) has a single constructor across the process.
115
+ * Every controller — registry tag or `local_path` — is installed under a
116
+ * version-scoped npm alias (`npm install <alias>@<spec>`, see `installAlias`)
117
+ * into this root, then imported from `<root>/node_modules/<alias>`. The alias
118
+ * encodes `name@version`, so a graph that references one package at two
119
+ * versions keeps both in the single flat `node_modules` instead of the last
120
+ * `--save` clobbering the first. This still collapses two parallel module
121
+ * realms (kernel-side @telorun/sdk vs. controller-side @telorun/sdk) into one:
122
+ * `@telorun/sdk` is exempt from aliasing and wired in as a `file:` dep under
123
+ * its real name, npm/pnpm hoist a single copy, and Node's ESM resolver follows
124
+ * it to the same realpath as the kernel — so `Stream` (and any other
125
+ * class-identity-sensitive type) has a single constructor across the process.
115
126
  *
116
127
  * Per-kernel state lives on each `NpmControllerLoader` instance (one is
117
128
  * constructed per `ControllerLoader`, which is itself constructed per
@@ -124,6 +135,8 @@ export interface NpmControllerLoaderOptions {
124
135
  */
125
136
  export class NpmControllerLoader {
126
137
  private readonly entryUrl?: string;
138
+ /** Threaded install root (`<cache-root>/npm`); overrides `computeInstallRoot`. */
139
+ private readonly installRootOverride?: string;
127
140
 
128
141
  /**
129
142
  * Per-process cache of "this controller's package + version is already
@@ -161,6 +174,7 @@ export class NpmControllerLoader {
161
174
 
162
175
  constructor(options: NpmControllerLoaderOptions = {}) {
163
176
  this.entryUrl = options.entryUrl;
177
+ this.installRootOverride = options.installRoot;
164
178
  }
165
179
 
166
180
  async load(purl: string, baseUri: string): Promise<NpmLoadResult> {
@@ -169,17 +183,21 @@ export class NpmControllerLoader {
169
183
  throw new Error(`Invalid PURL '${purl}': missing package name`);
170
184
  }
171
185
  const packageName = parsed.namespace ? `${parsed.namespace}/${parsed.name}` : parsed.name;
186
+ const version = parsed.version ?? null;
187
+ // Version-scope the install under an alias so the same package at two
188
+ // versions can coexist in one flat node_modules (see installAlias).
189
+ const alias = installAlias(packageName, version);
172
190
 
173
191
  const installRoot = await this.ensureInstallRoot();
174
192
  const resolved = await resolveInstallSpec(parsed, packageName, baseUri);
175
193
  const source = await this.installPackage(
176
194
  installRoot,
177
- packageName,
195
+ alias,
178
196
  resolved.spec,
179
197
  resolved.kind,
180
- parsed.version ?? null,
198
+ version,
181
199
  );
182
- const instance = await loadFromInstall(installRoot, packageName, parsed.subpath ?? null, purl);
200
+ const instance = await loadFromInstall(installRoot, alias, parsed.subpath ?? null, purl);
183
201
  return { instance, source };
184
202
  }
185
203
 
@@ -212,7 +230,7 @@ export class NpmControllerLoader {
212
230
  );
213
231
  }
214
232
  const entryUrlStr = this.entryUrl;
215
- const installRoot = computeInstallRoot(entryUrlStr);
233
+ const installRoot = this.installRootOverride ?? computeInstallRoot(entryUrlStr);
216
234
 
217
235
  // Build the install-root package.json: kernel-runtime deps as `file:` refs
218
236
  // pointing at the kernel-side realpath. Modules declare these names as
@@ -296,6 +314,11 @@ export class NpmControllerLoader {
296
314
  installRoot: string,
297
315
  dependencies: Record<string, string>,
298
316
  ): Promise<void> {
317
+ // `dependencies` is only the realm-collapse deps (`@telorun/sdk`), keyed by
318
+ // real `name@spec`. Controllers, by contrast, key `installedSpecs` by their
319
+ // version-scoped alias in `installPackage`. The two key spaces are
320
+ // intentionally disjoint — realm-collapse names are wired in here and never
321
+ // flow through `installPackage`, so an alias and a `name@spec` never clash.
299
322
  for (const [name, spec] of Object.entries(dependencies)) {
300
323
  this.installedSpecs.add(`${name}@${spec}`);
301
324
  }
@@ -303,11 +326,14 @@ export class NpmControllerLoader {
303
326
  }
304
327
 
305
328
  /**
306
- * Install one controller spec into the existing root. Single-flight per
307
- * spec within the process; cross-process safety is the fs-lock around the
308
- * `npm install` call. If the spec is already present in the manifest's
309
- * package.json (under `dependencies`), skip reusing the previous install.
329
+ * Install one controller into the existing root under its version-scoped
330
+ * `alias` folder. Single-flight per alias within the process; cross-process
331
+ * safety is the fs-lock around the `npm install` call. If the alias is
332
+ * already installed (right version on disk, or a matching `file:` record),
333
+ * skip — reusing the previous install.
310
334
  *
335
+ * `spec` is the source half of the install (`npm:<name>@<version>` or
336
+ * `file:<abs>`); the package manager is invoked with `<alias>@<spec>`.
311
337
  * `kind` distinguishes a `local_path` source (loader synthesized `file:`
312
338
  * spec) from a registry tag. The CLI silences progress for both `cache`
313
339
  * and `local`; `npm-install` is the only event surfaced. Folding this
@@ -316,12 +342,12 @@ export class NpmControllerLoader {
316
342
  */
317
343
  private async installPackage(
318
344
  installRoot: string,
319
- packageName: string,
345
+ alias: string,
320
346
  spec: string,
321
347
  kind: SpecKind,
322
348
  requestedVersion: string | null,
323
349
  ): Promise<NpmResolveSource> {
324
- const cacheKey = `${packageName}@${spec}`;
350
+ const cacheKey = alias;
325
351
  if (this.installedSpecs.has(cacheKey)) return "cache";
326
352
 
327
353
  const inFlight = this.inFlight.get(cacheKey);
@@ -330,17 +356,20 @@ export class NpmControllerLoader {
330
356
  return "cache";
331
357
  }
332
358
 
333
- const targetPath = path.join(installRoot, "node_modules", ...packageName.split("/"));
359
+ // The package is installed under its version-scoped alias, so its folder
360
+ // name in node_modules is the alias, not the bare package name.
361
+ const targetPath = path.join(installRoot, "node_modules", alias);
334
362
 
335
363
  // Registry fast path: compare against the installed package's own
336
- // `package.json` version field. `rootDeps[packageName]` can't be used
337
- // here because npm rewrites registry specs on `--save` — we pass
338
- // `@scope/pkg@0.3.4`, npm writes `^0.3.4` — so a string comparison
339
- // never matches and every fresh `NpmControllerLoader` (one per
340
- // `Telo.Definition.init`) would fall through to a no-op but ~200ms
364
+ // `package.json` version field. The recorded dep spec can't be used here
365
+ // because npm rewrites registry specs on `--save` — we pass
366
+ // `<alias>@npm:@scope/pkg@0.3.4`, npm writes `npm:@scope/pkg@^0.3.4` — so a
367
+ // string comparison never matches and every fresh `NpmControllerLoader`
368
+ // (one per `Telo.Definition.init`) would fall through to a no-op but ~200ms
341
369
  // `npm install`. Reading the installed package.json sidesteps that
342
- // entirely: if the requested PURL version equals what's on disk, we
343
- // already have the right thing.
370
+ // entirely: if the requested PURL version equals what's on disk under this
371
+ // alias, we already have the right thing. Because the alias encodes the
372
+ // version, a present alias folder is always the requested version.
344
373
  //
345
374
  // Ranges (`^1.0.0`, `~2.3.0`) fall through to a real `npm install` —
346
375
  // they're rare in PURLs (which typically pin) and a proper range check
@@ -354,37 +383,47 @@ export class NpmControllerLoader {
354
383
  this.installedSpecs.add(cacheKey);
355
384
  return "cache";
356
385
  }
357
- }
358
-
359
- // Local (`file:`) spec fast path: consult the in-process snapshot of the
360
- // install root's `dependencies` map (seeded by `materializeInstallRoot`).
361
- // Normalize because npm rewrites absolute `file:` deps to relative paths
362
- // inside the install root's `package.json` — the loader passes the
363
- // absolute path, the on-disk record is `file:../../foo`.
364
- const cachedSpec = this.rootDeps[packageName];
365
- if (
366
- cachedSpec !== undefined &&
367
- normalizeFileSpec(cachedSpec, installRoot) === normalizeFileSpec(spec, installRoot) &&
368
- (await pathExists(targetPath))
369
- ) {
370
- this.installedSpecs.add(cacheKey);
371
- return "cache";
386
+ } else {
387
+ // Local (`file:`) spec fast path: consult the in-process snapshot of the
388
+ // install root's `dependencies` map (seeded by `materializeInstallRoot`).
389
+ // Normalize because npm rewrites absolute `file:` deps to relative paths
390
+ // inside the install root's `package.json` the loader passes the
391
+ // absolute path, the on-disk record is `file:../../foo`.
392
+ const cachedSpec = this.rootDeps[alias];
393
+ if (
394
+ cachedSpec !== undefined &&
395
+ normalizeFileSpec(cachedSpec, installRoot) === normalizeFileSpec(spec, installRoot) &&
396
+ (await pathExists(targetPath))
397
+ ) {
398
+ this.installedSpecs.add(cacheKey);
399
+ return "cache";
400
+ }
372
401
  }
373
402
 
374
403
  const work = (async () => {
375
404
  await withInstallLock(installRoot, async () => {
376
405
  // Re-check inside the lock: a peer process may have installed the
377
- // spec between the fast-path miss and our acquisition. Normalize
378
- // the on-disk record to absolute form before comparing — npm
379
- // rewrites `file:` deps to be relative to the install root.
380
- const lockedSpec = await readDepSpec(installRoot, packageName);
381
- if (
382
- lockedSpec !== undefined &&
383
- normalizeFileSpec(lockedSpec, installRoot) === normalizeFileSpec(spec, installRoot) &&
384
- (await pathExists(targetPath))
385
- ) {
386
- this.rootDeps[packageName] = lockedSpec;
387
- return;
406
+ // spec between the fast-path miss and our acquisition.
407
+ if (kind === "registry") {
408
+ const installedVersion = await readInstalledVersion(targetPath);
409
+ if (
410
+ installedVersion !== null &&
411
+ (requestedVersion === null || requestedVersion === installedVersion)
412
+ ) {
413
+ return;
414
+ }
415
+ } else {
416
+ // Normalize the on-disk record to absolute form before comparing —
417
+ // npm rewrites `file:` deps to be relative to the install root.
418
+ const lockedSpec = await readDepSpec(installRoot, alias);
419
+ if (
420
+ lockedSpec !== undefined &&
421
+ normalizeFileSpec(lockedSpec, installRoot) === normalizeFileSpec(spec, installRoot) &&
422
+ (await pathExists(targetPath))
423
+ ) {
424
+ this.rootDeps[alias] = lockedSpec;
425
+ return;
426
+ }
388
427
  }
389
428
 
390
429
  await runPackageManager(installRoot, [
@@ -394,14 +433,17 @@ export class NpmControllerLoader {
394
433
  "--silent",
395
434
  ...PEER_INSTALL_FLAGS,
396
435
  "--save",
397
- spec,
436
+ // `<alias>@<source-spec>` installs the package under the alias folder
437
+ // (`npm:` for registry, `file:` for local) so multiple versions of
438
+ // one package name coexist in the single install root.
439
+ `${alias}@${spec}`,
398
440
  ]);
399
441
  // Re-read what npm actually wrote (it normalizes `file:` paths to
400
442
  // be relative to the install root). Caching the spec in its on-disk
401
443
  // form keeps subsequent fast-path comparisons stable.
402
- const written = await readDepSpec(installRoot, packageName);
403
- if (written !== undefined) this.rootDeps[packageName] = written;
404
- else this.rootDeps[packageName] = spec;
444
+ const written = await readDepSpec(installRoot, alias);
445
+ if (written !== undefined) this.rootDeps[alias] = written;
446
+ else this.rootDeps[alias] = spec;
405
447
  });
406
448
  })();
407
449
 
@@ -693,6 +735,35 @@ function computeInstallRoot(entryUrl: string): string {
693
735
  );
694
736
  }
695
737
 
738
+ /**
739
+ * Version-qualified install alias for a controller package. The kernel's single
740
+ * flat install root can hold only one folder per package name, but a manifest
741
+ * graph may legitimately reference the same package at multiple versions (e.g.
742
+ * a library pins `@telorun/mcp-client@0.3.1` while the app uses `0.4.0`).
743
+ * Installing each `name@version` under a distinct npm alias
744
+ * (`npm install <alias>@npm:<name>@<version>`) lets every version coexist in
745
+ * one `node_modules`, mirroring the per-(name, version) identity of a Telo
746
+ * module singleton. `@telorun/sdk` is intentionally NOT routed through here —
747
+ * it stays under its real name so realm-collapse hoists a single copy across
748
+ * the kernel/controller boundary (see REALM_COLLAPSE_NAMES).
749
+ *
750
+ * The result is a valid unscoped npm package name: the scope `@` is dropped,
751
+ * `/` becomes `__`, and any character outside npm's name grammar is replaced
752
+ * with `-`. Because that sanitization is lossy (e.g. build metadata `1.0.0+x`
753
+ * and prerelease `1.0.0-x` both fold to `1.0.0-x`, and a `__` already in a
754
+ * package name overlaps the scope separator), the alias ends with a short hash
755
+ * of the *exact* `name@version` — so two distinct pairs can never collide onto
756
+ * one folder regardless of how their readable prefixes sanitize. The readable
757
+ * prefix is kept purely so the install tree is greppable.
758
+ */
759
+ function installAlias(packageName: string, version: string | null): string {
760
+ const prefix = `${packageName.replace(/^@/, "").replace(/\//g, "__")}__${version ?? "latest"}`
761
+ .replace(/[^a-zA-Z0-9._-]/g, "-")
762
+ .toLowerCase();
763
+ const digest = sha256(`${packageName}@${version ?? ""}`).slice(0, 8);
764
+ return `${prefix}__${digest}`;
765
+ }
766
+
696
767
  /**
697
768
  * Normalize a `file:` spec to an absolute path so two specs that point at the
698
769
  * same source — one absolute (what the loader synthesizes from `local_path`)
@@ -770,7 +841,10 @@ async function resolveInstallSpec(
770
841
  return { kind: "local", spec: `file:${absolutePath}`, absolutePath };
771
842
  }
772
843
  }
773
- const spec = parsed.version ? `${packageName}@${parsed.version}` : packageName;
844
+ // `npm:` source spec so the caller can install it under a version-scoped
845
+ // alias (`<alias>@npm:<name>@<version>`); both versions of one package name
846
+ // then coexist in the single flat install root.
847
+ const spec = parsed.version ? `npm:${packageName}@${parsed.version}` : `npm:${packageName}`;
774
848
  return { kind: "registry", spec };
775
849
  }
776
850
 
@@ -782,11 +856,13 @@ async function resolveInstallSpec(
782
856
  */
783
857
  async function loadFromInstall(
784
858
  installRoot: string,
785
- packageName: string,
859
+ alias: string,
786
860
  subpath: string | null,
787
861
  purl: string,
788
862
  ): Promise<ControllerInstance> {
789
- const packageRoot = path.join(installRoot, "node_modules", ...packageName.split("/"));
863
+ // The package lives under its version-scoped alias folder (see installAlias),
864
+ // not its bare scoped name.
865
+ const packageRoot = path.join(installRoot, "node_modules", alias);
790
866
  const entry = subpath ? `./${subpath}` : ".";
791
867
  const entryFile = await resolvePackageEntry(packageRoot, entry);
792
868
  // ESM dynamic `import()` accepts either a relative specifier or a `file://`
@@ -937,6 +1013,7 @@ function resolveExportTargetValue(
937
1013
  * Not part of the kernel's public API — consumers should not import these.
938
1014
  */
939
1015
  export const __testing__ = {
1016
+ installAlias,
940
1017
  normalizeFileSpec,
941
1018
  resolvePackageExportTarget,
942
1019
  resolveExportTargetValue,
@@ -98,6 +98,24 @@ export class ControllerRegistry {
98
98
  return Array.from(this.controllersByKind.keys());
99
99
  }
100
100
 
101
+ /**
102
+ * Distinct controller `schema` objects across all registered kinds (one per
103
+ * kind, default fingerprint preferred). Used by the build-time validator warm
104
+ * to pre-compile the framework/builtin controller schemas (`Telo.Import`,
105
+ * `Telo.Definition`, the module controller, …) the runtime validates
106
+ * resources against — module-defined kinds aren't registered here until
107
+ * instantiation, so those are warmed from the static manifests instead.
108
+ */
109
+ getControllerSchemas(): object[] {
110
+ const schemas: object[] = [];
111
+ for (const byFp of this.controllersByKind.values()) {
112
+ const controller = byFp.get(DEFAULT_FINGERPRINT) ?? byFp.values().next().value;
113
+ const schema = controller?.schema;
114
+ if (schema && typeof schema === "object") schemas.push(schema);
115
+ }
116
+ return schemas;
117
+ }
118
+
101
119
  /**
102
120
  * Register a controller for a (kind, fingerprint). Multiple registrations
103
121
  * for the same kind with different fingerprints coexist; same fingerprint
@@ -1,4 +1,4 @@
1
- import { AnalysisRegistry, DiagnosticSeverity, StaticAnalyzer } from "@telorun/analyzer";
1
+ import { AnalysisRegistry, DiagnosticSeverity, parseExportEntry, StaticAnalyzer } from "@telorun/analyzer";
2
2
  import type { ResourceInstance } from "@telorun/sdk";
3
3
  import { RuntimeError } from "@telorun/sdk";
4
4
  import type { BuiltinControllerContext } from "../../internal-context.js";
@@ -108,12 +108,27 @@ export async function create(
108
108
  validateRequiredInputs(moduleManifest.variables ?? {}, resource.variables ?? {}, "variables");
109
109
  validateRequiredInputs(moduleManifest.secrets ?? {}, resource.secrets ?? {}, "secrets");
110
110
 
111
- // Create child context with the imported variables/secrets baked in, so that
112
- // ${{ variables.x }} / ${{ secrets.y }} templates resolve correctly at runtime.
111
+ // Evaluate the import's variables/secrets ONCE against the IMPORTER's config
112
+ // scope, instead of baking the raw compiled-value objects verbatim. Resolution
113
+ // is eager and per-hop: each importer resolves its child's inputs from its own
114
+ // already-settled config, so config flows app -> lib -> lib at any nesting depth
115
+ // and a leaf reads `variables.X` as an O(1) concrete lookup with no chain-walk.
116
+ // The single concrete result seeds both the child scope and the snapshot value-
117
+ // flow surface, so they can never diverge.
118
+ //
119
+ // NOTE: `expandValue` evaluates against the importer's FULL context (variables /
120
+ // secrets, plus env/resources for a root app). The config-only import contract —
121
+ // no resources/env/ports in import inputs — is enforced by the analyzer, not here;
122
+ // a `${{ resources.X }}` slipping past analysis (skipValidation / programmatic
123
+ // load) would evaluate at runtime rather than being rejected.
124
+ const importVariables =
125
+ (ctx.expandValue(resource.variables, {}) as Record<string, unknown>) ?? {};
126
+ const importSecrets =
127
+ (ctx.expandValue(resource.secrets, {}) as Record<string, unknown>) ?? {};
113
128
  const childCtx = new ModuleContext(
114
129
  ctx.moduleContext.source,
115
- (resource.variables as Record<string, unknown>) ?? {},
116
- (resource.secrets as Record<string, unknown>) ?? {},
130
+ importVariables,
131
+ importSecrets,
117
132
  {},
118
133
  [],
119
134
  ctx.moduleContext.createInstance,
@@ -157,8 +172,26 @@ export async function create(
157
172
  // Throws if resources.X is not yet populated — the kernel retry loop catches this and retries.
158
173
  // const evaluatedExports: any = child.expand(moduleManifest.exports ?? {});
159
174
 
160
- const exportedKinds: string[] = moduleManifest.exports?.kinds ?? [];
161
- const exportedResourceNames: string[] = moduleManifest.exports?.resources ?? [];
175
+ // `exports.kinds` entries are a bare kind name (locally defined) or `Alias.Kind` (a re-export
176
+ // of an imported library's kind). `parseExportEntry` (shared with the analyzer) yields
177
+ // `{name, alias?}` — `name` is the exported kind suffix, `alias` (when set) names this
178
+ // library's own import it re-exports from.
179
+ const kindEntries = ((moduleManifest.exports?.kinds ?? []) as string[]).map(parseExportEntry);
180
+ const exportedKindSuffixes = kindEntries.map((k) => k.name);
181
+ // `exports.resources` entries are a bare name (`Db`, a locally-owned export) or a dotted
182
+ // `Alias.Name` (re-export of an imported instance, under name `Name`) — same grammar as
183
+ // `exports.kinds`.
184
+ const exportEntries = ((moduleManifest.exports?.resources ?? []) as unknown[]).map((e) => {
185
+ if (typeof e !== "string") {
186
+ throw new RuntimeError(
187
+ "ERR_INVALID_EXPORT",
188
+ `Library '${targetModule}' exports.resources entries must be plain names ('Name' or ` +
189
+ `'Alias.Name'); the '!ref' tag is not allowed in exports.resources.`,
190
+ );
191
+ }
192
+ return parseExportEntry(e);
193
+ });
194
+ const exportedResourceNames = exportEntries.map((e) => e.name);
162
195
  for (const name of exportedResourceNames) {
163
196
  if (name === "variables" || name === "secrets") {
164
197
  throw new RuntimeError(
@@ -167,30 +200,22 @@ export async function create(
167
200
  );
168
201
  }
169
202
  }
170
- ctx.registerModuleImport(alias, targetModule, exportedKinds);
203
+ ctx.registerModuleImport(alias, targetModule, exportedKindSuffixes);
171
204
 
172
205
  // Publish the child's exported instances to the parent so cross-module `!ref Alias.name`
173
206
  // (Phase 5 injection / boot targets) and `${{ resources.Alias.name }}` (CEL value-flow)
174
- // resolve. The gate is `exports.resources`; the lookup is lazy instances exist after
175
- // this import's init() has run child.initializeResources().
207
+ // resolve. The gate is `exports.resources`; the child's terminal getter is read lazily
208
+ // it exists after this import's init() built the child's export table. Handing the parent
209
+ // the child's TERMINAL getter (not a wrapper) keeps resolution O(1) across re-export hops.
176
210
  (ctx.moduleContext as ModuleContext).registerImportedScope(
177
211
  alias,
178
212
  exportedResourceNames,
179
- (name) => {
180
- const entry = childCtx.resourceInstances.get(name);
181
- if (!entry) return undefined;
182
- // Canonicalize the authored kind to `<module>.<Kind>` so the cross-module ref shape and
183
- // event naming are scope-independent. Exported instances are authored `Self.<Kind>` —
184
- // the only case the std modules use, and the only one canonicalized here; it maps to the
185
- // owning module directly, matching the analyzer's rule in resolve-ref-sentinels.ts. A
186
- // re-exported foreign kind (authored via another import alias) is a future case with no
187
- // current consumer and is left verbatim.
188
- const rawKind = entry.resource.kind as string;
189
- const kind = rawKind.startsWith("Self.")
190
- ? `${targetModule}.${rawKind.slice("Self.".length)}`
191
- : rawKind;
192
- return { kind, instance: entry.instance };
193
- },
213
+ (name) => childCtx.getTerminalExport(name),
214
+ );
215
+ // Same for kinds: `kind: Alias.Kind` resolves through the child's exported-kind table,
216
+ // covering both locally-defined and transitively re-exported kinds in O(1).
217
+ (ctx.moduleContext as ModuleContext).registerImportedKindScope(alias, (suffix) =>
218
+ childCtx.getExportedKind(suffix),
194
219
  );
195
220
 
196
221
  // Return a ResourceInstance whose snapshot() surfaces the exported values under
@@ -201,19 +226,24 @@ export async function create(
201
226
  snapshot: async () => {
202
227
  const exported: Record<string, unknown> = {};
203
228
  for (const name of exportedResourceNames) {
204
- const inst = childCtx.resourceInstances.get(name)?.instance;
229
+ const inst = childCtx.getExported(name)?.instance;
205
230
  if (inst && typeof inst.snapshot === "function") {
206
231
  exported[name] = await Promise.resolve(inst.snapshot());
207
232
  }
208
233
  }
209
234
  return {
210
- variables: ctx.expandValue(resource.variables, {}) ?? {},
211
- secrets: ctx.expandValue(resource.secrets, {}) ?? {},
235
+ variables: importVariables,
236
+ secrets: importSecrets,
212
237
  ...exported,
213
238
  };
214
239
  },
215
240
  init: async () => {
216
241
  await child.initializeResources();
242
+ // Build this import's flattened export tables now that its own imports are
243
+ // registered (leaves-first), so a re-export (`!ref Alias.name` / `Alias.Kind`)
244
+ // copies the source import's terminal getter / canonical kind by reference —
245
+ // O(1) resolution at any depth.
246
+ childCtx.buildExportTable(exportEntries, kindEntries, targetModule);
217
247
  },
218
248
  teardown: async () => {
219
249
  await child.teardownResources();
@@ -54,6 +54,7 @@ class ResourceDefinition implements ResourceInstance {
54
54
  const loader = new ControllerLoader({
55
55
  emit: (e) => ctx.emit(e.name, e.payload),
56
56
  entryUrl: ctx.getEntryUrl(),
57
+ installRoot: ctx.getInstallRoot(),
57
58
  });
58
59
  const controllerInstance = await loader.load(
59
60
  this.resource.controllers,
@@ -501,10 +501,13 @@ export class EvaluationContext implements IEvaluationContext {
501
501
  // the resolved entry for event topics and error messages.
502
502
  const effectiveKind = kind || (entry.resource.kind as string);
503
503
 
504
- if (typeof entry.instance.invoke !== "function") {
504
+ if (
505
+ typeof entry.instance.invoke !== "function" &&
506
+ typeof entry.instance.run !== "function"
507
+ ) {
505
508
  throw new RuntimeError(
506
509
  "ERR_RESOURCE_NOT_INVOKABLE",
507
- `Resource ${effectiveKind}.${name} does not have an invoke method`,
510
+ `Resource ${effectiveKind}.${name} does not have an invoke or run method`,
508
511
  );
509
512
  }
510
513
 
@@ -524,10 +527,10 @@ export class EvaluationContext implements IEvaluationContext {
524
527
  inputs: TInputs,
525
528
  ctx?: InvokeContext,
526
529
  ): Promise<any> {
527
- if (typeof instance.invoke !== "function") {
530
+ if (typeof instance.invoke !== "function" && typeof instance.run !== "function") {
528
531
  throw new RuntimeError(
529
532
  "ERR_RESOURCE_NOT_INVOKABLE",
530
- `Resource ${kind}.${name} does not have an invoke method`,
533
+ `Resource ${kind}.${name} does not have an invoke or run method`,
531
534
  );
532
535
  }
533
536
  return this.runInvoke(kind, name, instance, inputs, ctx);
@@ -560,8 +563,15 @@ export class EvaluationContext implements IEvaluationContext {
560
563
  try {
561
564
  // Only (re)establish the ALS scope when the token differs from the ambient
562
565
  // one — nested invokes that inherited it skip the redundant `run`.
566
+ // A step / boot-target slot may reference a pure Runnable (the schema
567
+ // allows `telo#Runnable` alongside `telo#Invocable`); dispatch it via
568
+ // `run()` — side effects only, no outputs. Prefer `invoke()` when both
569
+ // exist so a dual-capability instance (e.g. Run.Sequence) keeps invoke
570
+ // semantics and returns its `steps`/`outputs`.
563
571
  const call = () =>
564
- (instance.invoke as (i: any, c?: InvokeContext) => any)(inputs as any, invokeCtx);
572
+ typeof instance.invoke === "function"
573
+ ? (instance.invoke as (i: any, c?: InvokeContext) => any)(inputs as any, invokeCtx)
574
+ : (instance.run as (c?: InvokeContext) => any)(invokeCtx);
565
575
  const outputs = await (invokeCtx === ambient
566
576
  ? call()
567
577
  : cancellationStore.run(invokeCtx, call));