@telorun/kernel 0.24.2 → 0.25.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 (37) 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-loaders/npm-loader.d.ts +41 -12
  6. package/dist/controller-loaders/npm-loader.d.ts.map +1 -1
  7. package/dist/controller-loaders/npm-loader.js +117 -51
  8. package/dist/controller-loaders/npm-loader.js.map +1 -1
  9. package/dist/controller-registry.d.ts +9 -0
  10. package/dist/controller-registry.d.ts.map +1 -1
  11. package/dist/controller-registry.js +18 -0
  12. package/dist/controller-registry.js.map +1 -1
  13. package/dist/controllers/module/import-controller.d.ts.map +1 -1
  14. package/dist/controllers/module/import-controller.js +50 -28
  15. package/dist/controllers/module/import-controller.js.map +1 -1
  16. package/dist/evaluation-context.d.ts.map +1 -1
  17. package/dist/evaluation-context.js +13 -5
  18. package/dist/evaluation-context.js.map +1 -1
  19. package/dist/kernel.d.ts.map +1 -1
  20. package/dist/kernel.js +17 -1
  21. package/dist/kernel.js.map +1 -1
  22. package/dist/module-context.d.ts +56 -7
  23. package/dist/module-context.d.ts.map +1 -1
  24. package/dist/module-context.js +121 -10
  25. package/dist/module-context.js.map +1 -1
  26. package/dist/schema-validator.d.ts.map +1 -1
  27. package/dist/schema-validator.js +39 -3
  28. package/dist/schema-validator.js.map +1 -1
  29. package/package.json +2 -2
  30. package/src/application-env.ts +37 -0
  31. package/src/controller-loaders/npm-loader.ts +124 -58
  32. package/src/controller-registry.ts +18 -0
  33. package/src/controllers/module/import-controller.ts +58 -28
  34. package/src/evaluation-context.ts +15 -5
  35. package/src/kernel.ts +24 -1
  36. package/src/module-context.ts +153 -11
  37. package/src/schema-validator.ts +41 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telorun/kernel",
3
- "version": "0.24.2",
3
+ "version": "0.25.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",
@@ -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
@@ -104,14 +104,17 @@ export interface NpmControllerLoaderOptions {
104
104
  * local entry manifest (`file://` URL or bare path) the root lives at
105
105
  * `<entry-manifest-dir>/.telo/npm/`; for an HTTP(S) entry URL it lives in a
106
106
  * 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.
107
+ * Every controller — registry tag or `local_path` — is installed under a
108
+ * version-scoped npm alias (`npm install <alias>@<spec>`, see `installAlias`)
109
+ * into this root, then imported from `<root>/node_modules/<alias>`. The alias
110
+ * encodes `name@version`, so a graph that references one package at two
111
+ * versions keeps both in the single flat `node_modules` instead of the last
112
+ * `--save` clobbering the first. This still collapses two parallel module
113
+ * realms (kernel-side @telorun/sdk vs. controller-side @telorun/sdk) into one:
114
+ * `@telorun/sdk` is exempt from aliasing and wired in as a `file:` dep under
115
+ * its real name, npm/pnpm hoist a single copy, and Node's ESM resolver follows
116
+ * it to the same realpath as the kernel — so `Stream` (and any other
117
+ * class-identity-sensitive type) has a single constructor across the process.
115
118
  *
116
119
  * Per-kernel state lives on each `NpmControllerLoader` instance (one is
117
120
  * constructed per `ControllerLoader`, which is itself constructed per
@@ -169,17 +172,21 @@ export class NpmControllerLoader {
169
172
  throw new Error(`Invalid PURL '${purl}': missing package name`);
170
173
  }
171
174
  const packageName = parsed.namespace ? `${parsed.namespace}/${parsed.name}` : parsed.name;
175
+ const version = parsed.version ?? null;
176
+ // Version-scope the install under an alias so the same package at two
177
+ // versions can coexist in one flat node_modules (see installAlias).
178
+ const alias = installAlias(packageName, version);
172
179
 
173
180
  const installRoot = await this.ensureInstallRoot();
174
181
  const resolved = await resolveInstallSpec(parsed, packageName, baseUri);
175
182
  const source = await this.installPackage(
176
183
  installRoot,
177
- packageName,
184
+ alias,
178
185
  resolved.spec,
179
186
  resolved.kind,
180
- parsed.version ?? null,
187
+ version,
181
188
  );
182
- const instance = await loadFromInstall(installRoot, packageName, parsed.subpath ?? null, purl);
189
+ const instance = await loadFromInstall(installRoot, alias, parsed.subpath ?? null, purl);
183
190
  return { instance, source };
184
191
  }
185
192
 
@@ -296,6 +303,11 @@ export class NpmControllerLoader {
296
303
  installRoot: string,
297
304
  dependencies: Record<string, string>,
298
305
  ): Promise<void> {
306
+ // `dependencies` is only the realm-collapse deps (`@telorun/sdk`), keyed by
307
+ // real `name@spec`. Controllers, by contrast, key `installedSpecs` by their
308
+ // version-scoped alias in `installPackage`. The two key spaces are
309
+ // intentionally disjoint — realm-collapse names are wired in here and never
310
+ // flow through `installPackage`, so an alias and a `name@spec` never clash.
299
311
  for (const [name, spec] of Object.entries(dependencies)) {
300
312
  this.installedSpecs.add(`${name}@${spec}`);
301
313
  }
@@ -303,11 +315,14 @@ export class NpmControllerLoader {
303
315
  }
304
316
 
305
317
  /**
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.
318
+ * Install one controller into the existing root under its version-scoped
319
+ * `alias` folder. Single-flight per alias within the process; cross-process
320
+ * safety is the fs-lock around the `npm install` call. If the alias is
321
+ * already installed (right version on disk, or a matching `file:` record),
322
+ * skip — reusing the previous install.
310
323
  *
324
+ * `spec` is the source half of the install (`npm:<name>@<version>` or
325
+ * `file:<abs>`); the package manager is invoked with `<alias>@<spec>`.
311
326
  * `kind` distinguishes a `local_path` source (loader synthesized `file:`
312
327
  * spec) from a registry tag. The CLI silences progress for both `cache`
313
328
  * and `local`; `npm-install` is the only event surfaced. Folding this
@@ -316,12 +331,12 @@ export class NpmControllerLoader {
316
331
  */
317
332
  private async installPackage(
318
333
  installRoot: string,
319
- packageName: string,
334
+ alias: string,
320
335
  spec: string,
321
336
  kind: SpecKind,
322
337
  requestedVersion: string | null,
323
338
  ): Promise<NpmResolveSource> {
324
- const cacheKey = `${packageName}@${spec}`;
339
+ const cacheKey = alias;
325
340
  if (this.installedSpecs.has(cacheKey)) return "cache";
326
341
 
327
342
  const inFlight = this.inFlight.get(cacheKey);
@@ -330,17 +345,20 @@ export class NpmControllerLoader {
330
345
  return "cache";
331
346
  }
332
347
 
333
- const targetPath = path.join(installRoot, "node_modules", ...packageName.split("/"));
348
+ // The package is installed under its version-scoped alias, so its folder
349
+ // name in node_modules is the alias, not the bare package name.
350
+ const targetPath = path.join(installRoot, "node_modules", alias);
334
351
 
335
352
  // 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
353
+ // `package.json` version field. The recorded dep spec can't be used here
354
+ // because npm rewrites registry specs on `--save` — we pass
355
+ // `<alias>@npm:@scope/pkg@0.3.4`, npm writes `npm:@scope/pkg@^0.3.4` — so a
356
+ // string comparison never matches and every fresh `NpmControllerLoader`
357
+ // (one per `Telo.Definition.init`) would fall through to a no-op but ~200ms
341
358
  // `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.
359
+ // entirely: if the requested PURL version equals what's on disk under this
360
+ // alias, we already have the right thing. Because the alias encodes the
361
+ // version, a present alias folder is always the requested version.
344
362
  //
345
363
  // Ranges (`^1.0.0`, `~2.3.0`) fall through to a real `npm install` —
346
364
  // they're rare in PURLs (which typically pin) and a proper range check
@@ -354,37 +372,47 @@ export class NpmControllerLoader {
354
372
  this.installedSpecs.add(cacheKey);
355
373
  return "cache";
356
374
  }
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";
375
+ } else {
376
+ // Local (`file:`) spec fast path: consult the in-process snapshot of the
377
+ // install root's `dependencies` map (seeded by `materializeInstallRoot`).
378
+ // Normalize because npm rewrites absolute `file:` deps to relative paths
379
+ // inside the install root's `package.json` the loader passes the
380
+ // absolute path, the on-disk record is `file:../../foo`.
381
+ const cachedSpec = this.rootDeps[alias];
382
+ if (
383
+ cachedSpec !== undefined &&
384
+ normalizeFileSpec(cachedSpec, installRoot) === normalizeFileSpec(spec, installRoot) &&
385
+ (await pathExists(targetPath))
386
+ ) {
387
+ this.installedSpecs.add(cacheKey);
388
+ return "cache";
389
+ }
372
390
  }
373
391
 
374
392
  const work = (async () => {
375
393
  await withInstallLock(installRoot, async () => {
376
394
  // 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;
395
+ // spec between the fast-path miss and our acquisition.
396
+ if (kind === "registry") {
397
+ const installedVersion = await readInstalledVersion(targetPath);
398
+ if (
399
+ installedVersion !== null &&
400
+ (requestedVersion === null || requestedVersion === installedVersion)
401
+ ) {
402
+ return;
403
+ }
404
+ } else {
405
+ // Normalize the on-disk record to absolute form before comparing —
406
+ // npm rewrites `file:` deps to be relative to the install root.
407
+ const lockedSpec = await readDepSpec(installRoot, alias);
408
+ if (
409
+ lockedSpec !== undefined &&
410
+ normalizeFileSpec(lockedSpec, installRoot) === normalizeFileSpec(spec, installRoot) &&
411
+ (await pathExists(targetPath))
412
+ ) {
413
+ this.rootDeps[alias] = lockedSpec;
414
+ return;
415
+ }
388
416
  }
389
417
 
390
418
  await runPackageManager(installRoot, [
@@ -394,14 +422,17 @@ export class NpmControllerLoader {
394
422
  "--silent",
395
423
  ...PEER_INSTALL_FLAGS,
396
424
  "--save",
397
- spec,
425
+ // `<alias>@<source-spec>` installs the package under the alias folder
426
+ // (`npm:` for registry, `file:` for local) so multiple versions of
427
+ // one package name coexist in the single install root.
428
+ `${alias}@${spec}`,
398
429
  ]);
399
430
  // Re-read what npm actually wrote (it normalizes `file:` paths to
400
431
  // be relative to the install root). Caching the spec in its on-disk
401
432
  // 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;
433
+ const written = await readDepSpec(installRoot, alias);
434
+ if (written !== undefined) this.rootDeps[alias] = written;
435
+ else this.rootDeps[alias] = spec;
405
436
  });
406
437
  })();
407
438
 
@@ -693,6 +724,35 @@ function computeInstallRoot(entryUrl: string): string {
693
724
  );
694
725
  }
695
726
 
727
+ /**
728
+ * Version-qualified install alias for a controller package. The kernel's single
729
+ * flat install root can hold only one folder per package name, but a manifest
730
+ * graph may legitimately reference the same package at multiple versions (e.g.
731
+ * a library pins `@telorun/mcp-client@0.3.1` while the app uses `0.4.0`).
732
+ * Installing each `name@version` under a distinct npm alias
733
+ * (`npm install <alias>@npm:<name>@<version>`) lets every version coexist in
734
+ * one `node_modules`, mirroring the per-(name, version) identity of a Telo
735
+ * module singleton. `@telorun/sdk` is intentionally NOT routed through here —
736
+ * it stays under its real name so realm-collapse hoists a single copy across
737
+ * the kernel/controller boundary (see REALM_COLLAPSE_NAMES).
738
+ *
739
+ * The result is a valid unscoped npm package name: the scope `@` is dropped,
740
+ * `/` becomes `__`, and any character outside npm's name grammar is replaced
741
+ * with `-`. Because that sanitization is lossy (e.g. build metadata `1.0.0+x`
742
+ * and prerelease `1.0.0-x` both fold to `1.0.0-x`, and a `__` already in a
743
+ * package name overlaps the scope separator), the alias ends with a short hash
744
+ * of the *exact* `name@version` — so two distinct pairs can never collide onto
745
+ * one folder regardless of how their readable prefixes sanitize. The readable
746
+ * prefix is kept purely so the install tree is greppable.
747
+ */
748
+ function installAlias(packageName: string, version: string | null): string {
749
+ const prefix = `${packageName.replace(/^@/, "").replace(/\//g, "__")}__${version ?? "latest"}`
750
+ .replace(/[^a-zA-Z0-9._-]/g, "-")
751
+ .toLowerCase();
752
+ const digest = sha256(`${packageName}@${version ?? ""}`).slice(0, 8);
753
+ return `${prefix}__${digest}`;
754
+ }
755
+
696
756
  /**
697
757
  * Normalize a `file:` spec to an absolute path so two specs that point at the
698
758
  * same source — one absolute (what the loader synthesizes from `local_path`)
@@ -770,7 +830,10 @@ async function resolveInstallSpec(
770
830
  return { kind: "local", spec: `file:${absolutePath}`, absolutePath };
771
831
  }
772
832
  }
773
- const spec = parsed.version ? `${packageName}@${parsed.version}` : packageName;
833
+ // `npm:` source spec so the caller can install it under a version-scoped
834
+ // alias (`<alias>@npm:<name>@<version>`); both versions of one package name
835
+ // then coexist in the single flat install root.
836
+ const spec = parsed.version ? `npm:${packageName}@${parsed.version}` : `npm:${packageName}`;
774
837
  return { kind: "registry", spec };
775
838
  }
776
839
 
@@ -782,11 +845,13 @@ async function resolveInstallSpec(
782
845
  */
783
846
  async function loadFromInstall(
784
847
  installRoot: string,
785
- packageName: string,
848
+ alias: string,
786
849
  subpath: string | null,
787
850
  purl: string,
788
851
  ): Promise<ControllerInstance> {
789
- const packageRoot = path.join(installRoot, "node_modules", ...packageName.split("/"));
852
+ // The package lives under its version-scoped alias folder (see installAlias),
853
+ // not its bare scoped name.
854
+ const packageRoot = path.join(installRoot, "node_modules", alias);
790
855
  const entry = subpath ? `./${subpath}` : ".";
791
856
  const entryFile = await resolvePackageEntry(packageRoot, entry);
792
857
  // ESM dynamic `import()` accepts either a relative specifier or a `file://`
@@ -937,6 +1002,7 @@ function resolveExportTargetValue(
937
1002
  * Not part of the kernel's public API — consumers should not import these.
938
1003
  */
939
1004
  export const __testing__ = {
1005
+ installAlias,
940
1006
  normalizeFileSpec,
941
1007
  resolvePackageExportTarget,
942
1008
  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();
@@ -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));
package/src/kernel.ts CHANGED
@@ -41,7 +41,11 @@ import {
41
41
  writeAnalysisStamp,
42
42
  } from "./manifest-sources/analysis-stamp.js";
43
43
  import { resolveEntryDir } from "./manifest-sources/local-manifest-cache-source.js";
44
- import { precompileApplicationEnvSchemas, resolveApplicationEnv } from "./application-env.js";
44
+ import {
45
+ precompileApplicationEnvSchemas,
46
+ precompileDefinitionSchemas,
47
+ resolveApplicationEnv,
48
+ } from "./application-env.js";
45
49
  import { policyFingerprint } from "./runtime-registry.js";
46
50
  import { SchemaValidator } from "./schema-validator.js";
47
51
 
@@ -441,6 +445,25 @@ export class Kernel implements IKernel {
441
445
  this.sharedSchemaValidator,
442
446
  );
443
447
  }
448
+ // Bake the per-kind resource-config validators too. `_createInstance`
449
+ // compiles `controller.schema` (which falls back to the definition's own
450
+ // `schema`) for every resource at runtime — work this analyze-only pass
451
+ // otherwise skips because it stops before instantiation, leaving the
452
+ // runtime to recompile and fail to persist them on a read-only image.
453
+ // Pre-compiling every `Telo.Definition` schema here writes them into the
454
+ // same content-addressed `__validators/` cache the runtime reads.
455
+ precompileDefinitionSchemas(staticManifests, this.sharedSchemaValidator);
456
+ // Framework/builtin controller schemas (`Telo.Import`, `Telo.Definition`,
457
+ // the module controller, …) aren't in the static manifests but are
458
+ // validated per-resource at runtime just the same. They're registered by
459
+ // `loadBuiltinDefinitions` above, so bake them from the registry.
460
+ precompileDefinitionSchemas(
461
+ this.controllers.getControllerSchemas().map((schema) => ({
462
+ kind: "Telo.Definition",
463
+ schema,
464
+ })),
465
+ this.sharedSchemaValidator,
466
+ );
444
467
  return;
445
468
  }
446
469