@telorun/kernel 0.11.1 → 0.13.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 (161) hide show
  1. package/LICENSE +2 -2
  2. package/dist/application-env.d.ts +24 -0
  3. package/dist/application-env.d.ts.map +1 -0
  4. package/dist/application-env.js +156 -0
  5. package/dist/application-env.js.map +1 -0
  6. package/dist/base-definition.d.ts +14 -0
  7. package/dist/base-definition.d.ts.map +1 -0
  8. package/dist/base-definition.js +17 -0
  9. package/dist/base-definition.js.map +1 -0
  10. package/dist/capabilities/capabilities/component.yaml +4 -0
  11. package/dist/capabilities/capabilities/executable.yaml +8 -0
  12. package/dist/capabilities/capabilities/handler.yaml +4 -0
  13. package/dist/capabilities/capabilities/listener.yaml +4 -0
  14. package/dist/capabilities/capabilities/provider.yaml +4 -0
  15. package/dist/capabilities/capabilities/template.yaml +4 -0
  16. package/dist/capabilities/capabilities/type.yaml +4 -0
  17. package/dist/capabilities/component.d.ts +3 -0
  18. package/dist/capabilities/component.d.ts.map +1 -0
  19. package/dist/capabilities/component.js +4 -0
  20. package/dist/capabilities/component.js.map +1 -0
  21. package/dist/capabilities/component.yaml +3 -0
  22. package/dist/capabilities/executable.d.ts +3 -0
  23. package/dist/capabilities/executable.d.ts.map +1 -0
  24. package/dist/capabilities/executable.js +5 -0
  25. package/dist/capabilities/executable.js.map +1 -0
  26. package/dist/capabilities/executable.yaml +7 -0
  27. package/dist/capabilities/handler.d.ts +3 -0
  28. package/dist/capabilities/handler.d.ts.map +1 -0
  29. package/dist/capabilities/handler.js +4 -0
  30. package/dist/capabilities/handler.js.map +1 -0
  31. package/dist/capabilities/handler.yaml +3 -0
  32. package/dist/capabilities/invokable.d.ts +3 -0
  33. package/dist/capabilities/invokable.d.ts.map +1 -0
  34. package/dist/capabilities/invokable.js +5 -0
  35. package/dist/capabilities/invokable.js.map +1 -0
  36. package/dist/capabilities/listener.d.ts +3 -0
  37. package/dist/capabilities/listener.d.ts.map +1 -0
  38. package/dist/capabilities/listener.js +5 -0
  39. package/dist/capabilities/listener.js.map +1 -0
  40. package/dist/capabilities/listener.yaml +3 -0
  41. package/dist/capabilities/mount.d.ts +3 -0
  42. package/dist/capabilities/mount.d.ts.map +1 -0
  43. package/dist/capabilities/mount.js +5 -0
  44. package/dist/capabilities/mount.js.map +1 -0
  45. package/dist/capabilities/provider.d.ts +3 -0
  46. package/dist/capabilities/provider.d.ts.map +1 -0
  47. package/dist/capabilities/provider.js +8 -0
  48. package/dist/capabilities/provider.js.map +1 -0
  49. package/dist/capabilities/provider.yaml +3 -0
  50. package/dist/capabilities/runnable.d.ts +3 -0
  51. package/dist/capabilities/runnable.d.ts.map +1 -0
  52. package/dist/capabilities/runnable.js +5 -0
  53. package/dist/capabilities/runnable.js.map +1 -0
  54. package/dist/capabilities/service.d.ts +3 -0
  55. package/dist/capabilities/service.d.ts.map +1 -0
  56. package/dist/capabilities/service.js +5 -0
  57. package/dist/capabilities/service.js.map +1 -0
  58. package/dist/capabilities/template.d.ts +3 -0
  59. package/dist/capabilities/template.d.ts.map +1 -0
  60. package/dist/capabilities/template.js +5 -0
  61. package/dist/capabilities/template.js.map +1 -0
  62. package/dist/capabilities/template.yaml +3 -0
  63. package/dist/capabilities/type.d.ts +3 -0
  64. package/dist/capabilities/type.d.ts.map +1 -0
  65. package/dist/capabilities/type.js +5 -0
  66. package/dist/capabilities/type.js.map +1 -0
  67. package/dist/capabilities/type.yaml +3 -0
  68. package/dist/controller-loaders/npm-loader.d.ts +32 -8
  69. package/dist/controller-loaders/npm-loader.d.ts.map +1 -1
  70. package/dist/controller-loaders/npm-loader.js +120 -118
  71. package/dist/controller-loaders/npm-loader.js.map +1 -1
  72. package/dist/controllers/capability/capability-controller.d.ts +32 -0
  73. package/dist/controllers/capability/capability-controller.d.ts.map +1 -0
  74. package/dist/controllers/capability/capability-controller.js +26 -0
  75. package/dist/controllers/capability/capability-controller.js.map +1 -0
  76. package/dist/controllers/module/import-controller.d.ts +3 -2
  77. package/dist/controllers/module/import-controller.d.ts.map +1 -1
  78. package/dist/controllers/module/import-controller.js +23 -25
  79. package/dist/controllers/module/import-controller.js.map +1 -1
  80. package/dist/controllers/module/module.json +48 -0
  81. package/dist/controllers/resource-definition/resource-definition-controller.d.ts +1 -0
  82. package/dist/controllers/resource-definition/resource-definition-controller.d.ts.map +1 -1
  83. package/dist/controllers/resource-definition/resource-definition-controller.js +3 -0
  84. package/dist/controllers/resource-definition/resource-definition-controller.js.map +1 -1
  85. package/dist/controllers/resource-definition/resource-template-controller.d.ts +6 -1
  86. package/dist/controllers/resource-definition/resource-template-controller.d.ts.map +1 -1
  87. package/dist/controllers/resource-definition/resource-template-controller.js +79 -13
  88. package/dist/controllers/resource-definition/resource-template-controller.js.map +1 -1
  89. package/dist/internal-context.d.ts +25 -0
  90. package/dist/internal-context.d.ts.map +1 -0
  91. package/dist/internal-context.js +2 -0
  92. package/dist/internal-context.js.map +1 -0
  93. package/dist/kernel.d.ts +21 -1
  94. package/dist/kernel.d.ts.map +1 -1
  95. package/dist/kernel.js +109 -5
  96. package/dist/kernel.js.map +1 -1
  97. package/dist/loader.d.ts +18 -0
  98. package/dist/loader.d.ts.map +1 -0
  99. package/dist/loader.js +127 -0
  100. package/dist/loader.js.map +1 -0
  101. package/dist/manifest-adapters/http-adapter.d.ts +8 -0
  102. package/dist/manifest-adapters/http-adapter.d.ts.map +1 -0
  103. package/dist/manifest-adapters/http-adapter.js +31 -0
  104. package/dist/manifest-adapters/http-adapter.js.map +1 -0
  105. package/dist/manifest-adapters/local-file-adapter.d.ts +15 -0
  106. package/dist/manifest-adapters/local-file-adapter.d.ts.map +1 -0
  107. package/dist/manifest-adapters/local-file-adapter.js +95 -0
  108. package/dist/manifest-adapters/local-file-adapter.js.map +1 -0
  109. package/dist/manifest-adapters/manifest-adapter.d.ts +35 -0
  110. package/dist/manifest-adapters/manifest-adapter.d.ts.map +1 -0
  111. package/dist/manifest-adapters/manifest-adapter.js +2 -0
  112. package/dist/manifest-adapters/manifest-adapter.js.map +1 -0
  113. package/dist/manifest-adapters/registry-adapter.d.ts +9 -0
  114. package/dist/manifest-adapters/registry-adapter.d.ts.map +1 -0
  115. package/dist/manifest-adapters/registry-adapter.js +48 -0
  116. package/dist/manifest-adapters/registry-adapter.js.map +1 -0
  117. package/dist/manifest-schemas.d.ts +7 -23
  118. package/dist/manifest-schemas.d.ts.map +1 -1
  119. package/dist/manifest-schemas.js +18 -8
  120. package/dist/manifest-schemas.js.map +1 -1
  121. package/dist/manifest-sources/analysis-stamp.d.ts +25 -0
  122. package/dist/manifest-sources/analysis-stamp.d.ts.map +1 -0
  123. package/dist/manifest-sources/analysis-stamp.js +151 -0
  124. package/dist/manifest-sources/analysis-stamp.js.map +1 -0
  125. package/dist/module-context-registry.d.ts +48 -0
  126. package/dist/module-context-registry.d.ts.map +1 -0
  127. package/dist/module-context-registry.js +91 -0
  128. package/dist/module-context-registry.js.map +1 -0
  129. package/dist/resource-context.d.ts +2 -0
  130. package/dist/resource-context.d.ts.map +1 -1
  131. package/dist/resource-context.js +28 -0
  132. package/dist/resource-context.js.map +1 -1
  133. package/dist/schema-valiator.d.ts +15 -0
  134. package/dist/schema-valiator.d.ts.map +1 -0
  135. package/dist/schema-valiator.js +127 -0
  136. package/dist/schema-valiator.js.map +1 -0
  137. package/dist/schema-validator.d.ts +28 -0
  138. package/dist/schema-validator.d.ts.map +1 -1
  139. package/dist/schema-validator.js +161 -1
  140. package/dist/schema-validator.js.map +1 -1
  141. package/dist/snapshot-serializer.d.ts +62 -0
  142. package/dist/snapshot-serializer.d.ts.map +1 -0
  143. package/dist/snapshot-serializer.js +164 -0
  144. package/dist/snapshot-serializer.js.map +1 -0
  145. package/dist/types.d.ts +65 -0
  146. package/dist/types.d.ts.map +1 -0
  147. package/dist/types.js +8 -0
  148. package/dist/types.js.map +1 -0
  149. package/package.json +9 -6
  150. package/src/application-env.ts +216 -0
  151. package/src/controller-loaders/npm-loader.ts +133 -118
  152. package/src/controllers/module/import-controller.ts +33 -36
  153. package/src/controllers/resource-definition/resource-definition-controller.ts +6 -0
  154. package/src/controllers/resource-definition/resource-template-controller.ts +110 -16
  155. package/src/internal-context.ts +25 -0
  156. package/src/kernel.ts +130 -5
  157. package/src/manifest-schemas.ts +31 -11
  158. package/src/manifest-sources/analysis-stamp.ts +169 -0
  159. package/src/resource-context.ts +34 -0
  160. package/src/schema-validator.ts +178 -2
  161. package/dist/generated/runtime-deps.json +0 -6
@@ -81,10 +81,13 @@ export interface NpmControllerLoaderOptions {
81
81
  }
82
82
 
83
83
  /**
84
- * The npm-loader maintains a single install root per kernel process at
85
- * `<entry-manifest-dir>/.telo/npm/`. Every controller registry tag or
86
- * `local_path` is installed via `npm install <spec>` into this root, then
87
- * imported from `<root>/node_modules/<pkg>`. This collapses two parallel
84
+ * The npm-loader maintains a single install root per kernel process. For a
85
+ * local entry manifest (`file://` URL or bare path) the root lives at
86
+ * `<entry-manifest-dir>/.telo/npm/`; for an HTTP(S) entry URL it lives in a
87
+ * user-level cache keyed by `sha256(entryUrl)` (see `computeInstallRoot`).
88
+ * Every controller — registry tag or `local_path` — is installed via
89
+ * `npm install <spec>` into this root, then imported from
90
+ * `<root>/node_modules/<pkg>`. This collapses two parallel
88
91
  * module realms (kernel-side @telorun/sdk vs. controller-side @telorun/sdk)
89
92
  * into one: the kernel's own SDK is wired in as a `file:` dep, npm/pnpm
90
93
  * symlink it, and Node's ESM resolver follows the symlink to the same
@@ -150,7 +153,13 @@ export class NpmControllerLoader {
150
153
 
151
154
  const installRoot = await this.ensureInstallRoot();
152
155
  const resolved = await resolveInstallSpec(parsed, packageName, baseUri);
153
- const source = await this.installPackage(installRoot, packageName, resolved.spec, resolved.kind);
156
+ const source = await this.installPackage(
157
+ installRoot,
158
+ packageName,
159
+ resolved.spec,
160
+ resolved.kind,
161
+ parsed.version ?? null,
162
+ );
154
163
  const instance = await loadFromInstall(installRoot, packageName, parsed.subpath ?? null, purl);
155
164
  return { instance, source };
156
165
  }
@@ -184,36 +193,24 @@ export class NpmControllerLoader {
184
193
  );
185
194
  }
186
195
  const entryUrlStr = this.entryUrl;
187
- // The install root is anchored next to the entry manifest on disk. For an
188
- // http(s):// entry URL there is no such anchor — `path.resolve` would
189
- // silently turn `http://host/x.yaml` into something like
190
- // `<cwd>/http:/host/x.yaml`, materializing a `.telo/npm` tree in an
191
- // unrelated directory. Reject the case loudly until we ship an explicit
192
- // strategy (e.g. a hash-keyed cache under `~/.cache/telo`) for HTTP-sourced
193
- // manifests; today nothing in the workspace exercises this path.
194
- const entryPath = parseFileUrlOrThrow(entryUrlStr);
195
- const entryDir = path.dirname(path.resolve(entryPath));
196
- const installRoot = path.join(entryDir, ".telo", "npm");
197
-
198
- // Build the install-root package.json: kernel-runtime deps as `file:` refs,
199
- // `overrides` + `pnpm.overrides` pinning every name to `$<name>`. This is
200
- // the realm-collapse mechanism — npm's `file:` symlinks the kernel's own
201
- // package locations, the resolver follows symlinks to the realpath, and
202
- // every controller transitively resolving these names lands on the
203
- // identical module instance the kernel itself is using.
204
- const runtimeDeps = await loadRuntimeDeps();
196
+ const installRoot = computeInstallRoot(entryUrlStr);
197
+
198
+ // Build the install-root package.json: kernel-runtime deps as `file:` refs
199
+ // pointing at the kernel-side realpath. Modules declare these names as
200
+ // `peerDependencies`, so npm/pnpm resolve each controller's `import` to the
201
+ // single copy provided here the realm-collapse mechanism that gives
202
+ // class-identity-sensitive types (today: `Stream`) one constructor across
203
+ // the kernel/controller boundary.
205
204
  const dependencies: Record<string, string> = {};
206
- const overrides: Record<string, string> = {};
207
- for (const name of runtimeDeps) {
205
+ for (const name of REALM_COLLAPSE_NAMES) {
208
206
  const resolvedPkgRoot = await resolveKernelPackageRoot(name);
209
207
  if (!resolvedPkgRoot) {
210
208
  // A kernel runtime dep that can't be resolved at boot is unusual but
211
- // not fatal — the realm-collapse story degrades to "rely on registry
212
- // resolution + overrides" for that name. Don't crash the loader.
209
+ // not fatal — the realm-collapse story degrades to "rely on whatever
210
+ // the package manager picks" for that name. Don't crash the loader.
213
211
  continue;
214
212
  }
215
213
  dependencies[name] = `file:${resolvedPkgRoot}`;
216
- overrides[name] = `$${name}`;
217
214
  }
218
215
 
219
216
  const packageJson = {
@@ -221,8 +218,6 @@ export class NpmControllerLoader {
221
218
  private: true,
222
219
  version: "0.0.0",
223
220
  dependencies,
224
- overrides,
225
- pnpm: { overrides },
226
221
  };
227
222
  const packageJsonPath = path.join(installRoot, "package.json");
228
223
  const stateFile = path.join(installRoot, ".telo-state.json");
@@ -299,6 +294,7 @@ export class NpmControllerLoader {
299
294
  packageName: string,
300
295
  spec: string,
301
296
  kind: SpecKind,
297
+ requestedVersion: string | null,
302
298
  ): Promise<NpmResolveSource> {
303
299
  const cacheKey = `${packageName}@${spec}`;
304
300
  if (this.installedSpecs.has(cacheKey)) return "cache";
@@ -309,22 +305,37 @@ export class NpmControllerLoader {
309
305
  return "cache";
310
306
  }
311
307
 
312
- // Lock-free fast path: consult the in-process snapshot of the install
313
- // root's `dependencies` map (seeded by `materializeInstallRoot`). If the
314
- // spec matches what's already wired in and the package directory exists,
315
- // there's nothing to do — no lock, no fork, no per-load file read. This
316
- // is the dominant case for warm test suites: every Kernel touches the
317
- // same controllers and a fresh-on-disk read per (Kernel × controller)
318
- // dominates wall time even when the actual installs are already done.
319
- //
320
- // Normalize specs before comparing because npm rewrites absolute `file:`
321
- // deps to relative paths inside the install root's `package.json`. The
322
- // loader passes absolute paths (resolved against the declaring library's
323
- // baseUri); the on-disk record is `file:../../foo`. Without normalization
324
- // every fast-path read is a string-mismatch and the loader would fall
325
- // through to a real `npm install` per controller per Kernel — turning a
326
- // 1ms hit into a 150–200ms one.
327
308
  const targetPath = path.join(installRoot, "node_modules", ...packageName.split("/"));
309
+
310
+ // Registry fast path: compare against the installed package's own
311
+ // `package.json` version field. `rootDeps[packageName]` can't be used
312
+ // here because npm rewrites registry specs on `--save` — we pass
313
+ // `@scope/pkg@0.3.4`, npm writes `^0.3.4` — so a string comparison
314
+ // never matches and every fresh `NpmControllerLoader` (one per
315
+ // `Telo.Definition.init`) would fall through to a no-op but ~200ms
316
+ // `npm install`. Reading the installed package.json sidesteps that
317
+ // entirely: if the requested PURL version equals what's on disk, we
318
+ // already have the right thing.
319
+ //
320
+ // Ranges (`^1.0.0`, `~2.3.0`) fall through to a real `npm install` —
321
+ // they're rare in PURLs (which typically pin) and a proper range check
322
+ // would need a semver dep here.
323
+ if (kind === "registry") {
324
+ const installedVersion = await readInstalledVersion(targetPath);
325
+ if (
326
+ installedVersion !== null &&
327
+ (requestedVersion === null || requestedVersion === installedVersion)
328
+ ) {
329
+ this.installedSpecs.add(cacheKey);
330
+ return "cache";
331
+ }
332
+ }
333
+
334
+ // Local (`file:`) spec fast path: consult the in-process snapshot of the
335
+ // install root's `dependencies` map (seeded by `materializeInstallRoot`).
336
+ // Normalize because npm rewrites absolute `file:` deps to relative paths
337
+ // inside the install root's `package.json` — the loader passes the
338
+ // absolute path, the on-disk record is `file:../../foo`.
328
339
  const cachedSpec = this.rootDeps[packageName];
329
340
  if (
330
341
  cachedSpec !== undefined &&
@@ -383,54 +394,18 @@ export class NpmControllerLoader {
383
394
  }
384
395
 
385
396
  /**
386
- * Read the realm-collapse name list shipped with the kernel under
387
- * `dist/generated/runtime-deps.json`. The list is small and stable (today:
388
- * `@telorun/sdk`); see `scripts/generate-runtime-deps.mjs` for the rationale
389
- * about which packages belong here. Returns an empty array if the file is
390
- * unreadable the npm-loader then degrades to "no realm collapse," which is
391
- * still a working install path; only `Stream`-flavoured class-identity bugs
392
- * resurface.
397
+ * Names of packages whose realpath must be shared between the kernel and every
398
+ * loaded controller. Each name here becomes a `file:` dep in the install-root
399
+ * `package.json`, pinned at the kernel's own resolution; controllers declare
400
+ * these names as `peerDependencies` so npm/pnpm resolves them to that single
401
+ * copy instead of nesting their own.
402
+ *
403
+ * Add a name here if you ship another shared runtime symbol whose `instanceof`
404
+ * or constructor identity matters across module boundaries. Today the only
405
+ * such name is `@telorun/sdk` (carries the `Stream` class registered with
406
+ * `@marcbachmann/cel-js`).
393
407
  */
394
- async function loadRuntimeDeps(): Promise<string[]> {
395
- const here = fileURLToPath(import.meta.url);
396
- // Walk up from this file's location to the kernel-package root (the dir
397
- // that contains package.json). In dev: `kernel/nodejs/`. In published:
398
- // the installed package root inside `node_modules/@telorun/kernel/`.
399
- // Walk-until-root rather than a fixed depth — the directory tree depth
400
- // depends on whether we're in a workspace or installed tree.
401
- const pkgDir = await walkUpToPackageRoot(path.dirname(here));
402
- if (!pkgDir) return [];
403
-
404
- const generated = path.join(pkgDir, "dist", "generated", "runtime-deps.json");
405
- if (!(await pathExists(generated))) return [];
406
- // The file is generated by `scripts/generate-runtime-deps.mjs`; if it
407
- // exists but is malformed, that's a kernel-build bug. Don't swallow —
408
- // surface so the cause is debuggable. Realm collapse is the whole point
409
- // of this code path; quietly degrading to "no realm collapse" would
410
- // silently re-introduce the very bug the file fixes.
411
- const data = JSON.parse(await fs.readFile(generated, "utf8"));
412
- if (!Array.isArray(data?.names)) {
413
- throw new Error(
414
- `[telo] ${generated} is malformed: expected { names: string[] } at top level. ` +
415
- `Re-run \`node scripts/generate-runtime-deps.mjs <pkg-dir>\` to regenerate.`,
416
- );
417
- }
418
- return data.names as string[];
419
- }
420
-
421
- /**
422
- * Walk up from `from` until the directory contains a `package.json`.
423
- * Returns null at filesystem root if none found.
424
- */
425
- async function walkUpToPackageRoot(from: string): Promise<string | null> {
426
- let dir = from;
427
- while (true) {
428
- if (await pathExists(path.join(dir, "package.json"))) return dir;
429
- const parent = path.dirname(dir);
430
- if (parent === dir) return null;
431
- dir = parent;
432
- }
433
- }
408
+ const REALM_COLLAPSE_NAMES: ReadonlyArray<string> = ["@telorun/sdk"];
434
409
 
435
410
  /**
436
411
  * Resolve a kernel-runtime dep name to the realpath of its package directory.
@@ -618,6 +593,24 @@ async function readJsonField(filePath: string, field: string): Promise<unknown>
618
593
  }
619
594
  }
620
595
 
596
+ /**
597
+ * Read the `version` field from `<packageRoot>/package.json`, or null if the
598
+ * file is missing/unreadable/malformed or carries a non-string version. Used
599
+ * by the registry fast path to decide whether an existing install already
600
+ * satisfies the requested PURL version — the install-root's own
601
+ * `dependencies` map can't answer that because npm rewrites registry specs
602
+ * on `--save`.
603
+ */
604
+ async function readInstalledVersion(packageRoot: string): Promise<string | null> {
605
+ try {
606
+ const text = await fs.readFile(path.join(packageRoot, "package.json"), "utf8");
607
+ const pkg = JSON.parse(text);
608
+ return typeof pkg?.version === "string" ? pkg.version : null;
609
+ } catch {
610
+ return null;
611
+ }
612
+ }
613
+
621
614
  /**
622
615
  * Read the install root's `dependencies[<packageName>]` value, or undefined
623
616
  * if package.json is missing/unreadable or the dep isn't listed. Used by the
@@ -635,35 +628,56 @@ async function readDepSpec(installRoot: string, packageName: string): Promise<st
635
628
  }
636
629
 
637
630
  /**
638
- * Convert an entry-manifest URL to its on-disk path, throwing a descriptive
639
- * error for any URL scheme that doesn't map to a local filesystem location.
640
- * The install-root anchoring story requires a real directory next to the
641
- * manifest; non-file schemes (e.g. `http://`, `https://`) have no such
642
- * anchor and would otherwise silently produce a junk path via
643
- * `path.resolve("http://host/x")`.
631
+ * Decide where the per-kernel install root lives for a given entry URL.
632
+ *
633
+ * - `file://` URL or bare filesystem path: anchored next to the manifest at
634
+ * `<entry-dir>/.telo/npm/`. Same as before keeps the "install lives with
635
+ * the project" story for local development and Docker builds where the
636
+ * tree is `COPY`-d into the image.
637
+ * - `http(s)://` URL: there is no on-disk anchor next to the manifest, so
638
+ * the install root lives in a user-level cache keyed by the SHA-256 of
639
+ * the entry URL: `<cacheDir>/<hash>/npm/`. Repeat runs of the same URL
640
+ * hit the same cache; distinct URLs get isolated trees so two unrelated
641
+ * remote apps don't share `node_modules` (different controller versions,
642
+ * different realm-collapse pins).
644
643
  *
645
- * Bare paths without a scheme are accepted as-is — callers that hand the
646
- * loader an absolute filesystem path are common and there's no ambiguity
647
- * to surface.
644
+ * Cache location, in priority order:
645
+ * 1. `$TELO_NPM_CACHE_DIR` (explicit override tests use this to avoid
646
+ * polluting the developer's `~/.cache`).
647
+ * 2. `$XDG_CACHE_HOME/telo/remote` (standard XDG path on Linux).
648
+ * 3. `<os.homedir()>/.cache/telo/remote` (POSIX fallback / macOS).
649
+ *
650
+ * Unrecognised schemes (anything that isn't `file://`, `http://`, or
651
+ * `https://`) still throw `ControllerEnvMissingError` so the dispatcher can
652
+ * advance to a non-npm candidate.
648
653
  */
649
- function parseFileUrlOrThrow(entryUrl: string): string {
650
- if (entryUrl.startsWith("file://")) return fileURLToPath(entryUrl);
651
- // A scheme is anything before the first `://` — distinguish a URL from a
652
- // bare filesystem path (which has no `://`).
654
+ function computeInstallRoot(entryUrl: string): string {
655
+ if (entryUrl.startsWith("file://")) {
656
+ const entryPath = fileURLToPath(entryUrl);
657
+ return path.join(path.dirname(path.resolve(entryPath)), ".telo", "npm");
658
+ }
653
659
  const schemeMatch = entryUrl.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):\/\//);
654
- if (schemeMatch) {
655
- // Env-missing rather than a hard error: the dispatcher should advance
656
- // to the next candidate when an HTTP-sourced manifest is paired with a
657
- // `pkg:cargo` (or other non-npm) fallback. Hard-failing would lock the
658
- // whole resolve chain to this branch.
659
- throw new ControllerEnvMissingError(
660
- `[telo] entry URL scheme '${schemeMatch[1]}' is not supported by the npm controller ` +
661
- `loader. The install root must live next to a local manifest; HTTP-sourced manifests ` +
662
- `have no such anchor. Resolve the manifest to disk first, or use file:// directly. ` +
663
- `(entryUrl: ${entryUrl})`,
664
- );
660
+ if (!schemeMatch) {
661
+ // Bare filesystem path: same anchor as `file://`.
662
+ return path.join(path.dirname(path.resolve(entryUrl)), ".telo", "npm");
663
+ }
664
+ const scheme = schemeMatch[1].toLowerCase();
665
+ if (scheme === "http" || scheme === "https") {
666
+ const cacheBase =
667
+ process.env.TELO_NPM_CACHE_DIR ||
668
+ (process.env.XDG_CACHE_HOME
669
+ ? path.join(process.env.XDG_CACHE_HOME, "telo", "remote")
670
+ : path.join(os.homedir(), ".cache", "telo", "remote"));
671
+ return path.join(cacheBase, sha256(entryUrl), "npm");
665
672
  }
666
- return entryUrl;
673
+ // Env-missing rather than a hard error: the dispatcher should advance to
674
+ // the next candidate when a non-npm scheme is paired with a `pkg:cargo`
675
+ // (or other) fallback.
676
+ throw new ControllerEnvMissingError(
677
+ `[telo] entry URL scheme '${schemeMatch[1]}' is not supported by the npm controller ` +
678
+ `loader. Supported schemes: file://, http://, https://, or a bare filesystem path. ` +
679
+ `(entryUrl: ${entryUrl})`,
680
+ );
667
681
  }
668
682
 
669
683
  /**
@@ -914,7 +928,8 @@ export const __testing__ = {
914
928
  resolvePackageExportTarget,
915
929
  resolveExportTargetValue,
916
930
  tryResolveFile,
917
- walkUpToPackageRoot,
931
+ computeInstallRoot,
918
932
  EXPORTS_MAX_DEPTH,
919
933
  DEFAULT_RESOLVER_CONDITIONS,
934
+ REALM_COLLAPSE_NAMES,
920
935
  };
@@ -1,26 +1,14 @@
1
1
  import { DiagnosticSeverity, StaticAnalyzer } from "@telorun/analyzer";
2
- import type { ResourceContext, ResourceInstance } from "@telorun/sdk";
2
+ import type { ResourceInstance } from "@telorun/sdk";
3
3
  import { RuntimeError } from "@telorun/sdk";
4
+ import type { BuiltinControllerContext } from "../../internal-context.js";
4
5
  import { ModuleContext } from "../../module-context.js";
5
6
  import { isDefaultPolicy, normalizeRuntime } from "../../runtime-registry.js";
6
7
 
7
- const importAnalysisCache = new Map<
8
- string,
9
- { signature: string; errors: string[] }
10
- >();
11
-
12
- // Only resolve relative/absolute-path sources against the importer's URL. Registry refs
13
- // (std/foo@1.2.3) and absolute URLs (https://, file://) must pass through unchanged so the
14
- // loader's source chain can dispatch them — otherwise `new URL("std/foo@1", "file:///srv/telo.yaml")`
15
- // turns a registry ref into a bogus file path and LocalFileSource ENOENTs on it.
16
- function resolveImportSource(source: string, baseSource: string): string {
17
- if (source.startsWith(".") || source.startsWith("/")) {
18
- return new URL(source, baseSource).toString();
19
- }
20
- return source;
21
- }
22
-
23
- export async function create(resource: any, ctx: ResourceContext): Promise<ResourceInstance> {
8
+ export async function create(
9
+ resource: any,
10
+ ctx: BuiltinControllerContext,
11
+ ): Promise<ResourceInstance> {
24
12
  const alias = resource.metadata.name as string;
25
13
 
26
14
  const moduleSource: string = resource.module ?? resource.source;
@@ -36,27 +24,36 @@ export async function create(resource: any, ctx: ResourceContext): Promise<Resou
36
24
  // Validate the imported module and all its transitive imports before loading for runtime.
37
25
  // loadManifests() follows Telo.Import chains so definitions from sub-imports are present,
38
26
  // preventing false UNDEFINED_KIND errors for kinds that come from the module's own imports.
39
- const resolvedUrl = resolveImportSource(moduleSource, base);
40
- const analysisManifests = await ctx.loadManifests(resolvedUrl);
41
- const signature = JSON.stringify(analysisManifests);
42
- const cached = importAnalysisCache.get(resolvedUrl);
43
- let errors: string[];
44
-
45
- if (cached && cached.signature === signature) {
46
- errors = cached.errors;
47
- } else {
27
+ //
28
+ // Route URL resolution through the kernel/loader's own helper rather than
29
+ // a hand-rolled `new URL(...).toString()`. For LocalFileSource the
30
+ // outputs match; for any custom `ManifestSource` with a non-trivial
31
+ // `resolveRelative`, only this path produces the canonical URL the
32
+ // loader keyed its caches under — without which fast paths like
33
+ // `isImportValidatedAtLoad` silently miss.
34
+ const resolvedUrl = ctx.resolveImportUrl(base, moduleSource);
35
+
36
+ // Fast path: when the kernel's load-time `analyzeErrors` already covered
37
+ // this import's subtree (the common case — every Telo.Import declared in
38
+ // the entry graph is walked by `loadGraph` and validated by
39
+ // `kernel.load`), skip the redundant per-import StaticAnalyzer pass.
40
+ // Falls through to the full analysis for URLs that arrived
41
+ // programmatically after `load()` (e.g. dynamically constructed imports
42
+ // in tests). Two Telo.Imports with the same source but distinct
43
+ // metadata.name would each re-run analysis here — a memoisation hook
44
+ // can be reintroduced if that turns into a measurable cost.
45
+ if (!ctx.isImportValidatedAtLoad(resolvedUrl)) {
46
+ const analysisManifests = await ctx.loadManifests(resolvedUrl);
48
47
  const diagnostics = new StaticAnalyzer().analyze(analysisManifests);
49
- errors = diagnostics
48
+ const errors = diagnostics
50
49
  .filter((d) => d.severity === DiagnosticSeverity.Error)
51
50
  .map((d) => d.message);
52
- importAnalysisCache.set(resolvedUrl, { signature, errors });
53
- }
54
-
55
- if (errors.length > 0) {
56
- throw new RuntimeError(
57
- "ERR_MANIFEST_VALIDATION_FAILED",
58
- errors.join("\n"),
59
- );
51
+ if (errors.length > 0) {
52
+ throw new RuntimeError(
53
+ "ERR_MANIFEST_VALIDATION_FAILED",
54
+ errors.join("\n"),
55
+ );
56
+ }
60
57
  }
61
58
 
62
59
  // Load target module manifests for runtime. Inject variables/secrets as compile context so
@@ -18,6 +18,7 @@ type ResourceDefinitionResource = RuntimeResource & {
18
18
  schema: Record<string, any>;
19
19
  capability?: string;
20
20
  controllers?: Array<string>;
21
+ provide?: unknown;
21
22
  };
22
23
 
23
24
  /**
@@ -31,6 +32,11 @@ class ResourceDefinition implements ResourceInstance {
31
32
 
32
33
  async init(ctx: ResourceContext) {
33
34
  if (!this.resource.controllers?.length) {
35
+ if (this.resource.capability === "Telo.Provider" && this.resource.provide == null) {
36
+ throw new Error(
37
+ `Telo.Definition '${this.resource.metadata.name}': 'capability: Telo.Provider' requires either 'controllers:' (TS-backed) or 'provide:' (template-backed).`,
38
+ );
39
+ }
34
40
  const controllerInstance = createTemplateController(this.resource as any);
35
41
  ctx.registerDefinition(this.resource);
36
42
  await ctx.registerController(
@@ -1,11 +1,32 @@
1
1
  import type { ControllerInstance, ResourceContext, ResourceInstance } from "@telorun/sdk";
2
2
  import { isCompiledValue } from "@telorun/sdk";
3
3
 
4
+ /** Reports the resources: entries available to dispatch against, by expanded
5
+ * name and kind. Used in error messages to guide the developer back to the
6
+ * template's `resources:` array when a dispatch target doesn't match. */
7
+ function describeAvailableTargets(
8
+ ctx: ResourceContext,
9
+ resources: any[] | undefined,
10
+ self: Record<string, unknown>,
11
+ ): string {
12
+ if (!resources || resources.length === 0) return "<none>";
13
+ return resources
14
+ .map((r) => {
15
+ const expanded = ctx.moduleContext.expandWith(r?.metadata?.name ?? "", { self }) as string;
16
+ const kind = typeof r?.kind === "string" ? r.kind : "<unknown-kind>";
17
+ return `'${expanded || "<unnamed>"}' (${kind})`;
18
+ })
19
+ .join(", ");
20
+ }
21
+
4
22
  export function createTemplateController(definition: {
5
23
  schema: Record<string, any>;
6
24
  resources?: any[];
7
- invoke?: string | { kind?: string; name: string; inputs?: Record<string, any> };
25
+ invoke?: string | { kind?: string; name: string };
26
+ inputs?: Record<string, any>;
8
27
  run?: string;
28
+ provide?: { kind: string; name: string };
29
+ result?: Record<string, any>;
9
30
  }): ControllerInstance {
10
31
  return {
11
32
  schema: definition.schema ?? { type: "object", additionalProperties: true },
@@ -13,13 +34,16 @@ export function createTemplateController(definition: {
13
34
  create: async (resource: any, ctx: ResourceContext): Promise<ResourceInstance> => {
14
35
  const self = { ...resource, name: resource.metadata.name };
15
36
 
16
- // Old string form: invoke is a plain string or a CompiledValue (after precompile).
17
- // New object form: invoke is a plain object (non-CompiledValue) with at least `name`.
37
+ // `invoke` describes the dispatch target: a string name template (legacy
38
+ // shorthand) or an object `{ kind?, name }` for explicit kind-typed
39
+ // dispatch. `inputs:` lives as a sibling on the definition (same shape
40
+ // as Run.Sequence steps) — the values passed to the dispatch target's
41
+ // invoke() after CEL expansion.
18
42
  const objectInvoke =
19
43
  definition.invoke !== null &&
20
44
  typeof definition.invoke === "object" &&
21
45
  !isCompiledValue(definition.invoke)
22
- ? (definition.invoke as { kind?: string; name: string; inputs?: Record<string, any> })
46
+ ? (definition.invoke as { kind?: string; name: string })
23
47
  : null;
24
48
  const invokeNameTemplate = objectInvoke ? objectInvoke.name : (definition.invoke ?? null);
25
49
  const invokeTarget = invokeNameTemplate
@@ -28,6 +52,9 @@ export function createTemplateController(definition: {
28
52
  const runTarget = definition.run
29
53
  ? (ctx.moduleContext.expandWith(definition.run, { self }) as string)
30
54
  : null;
55
+ const provideTarget = definition.provide?.name
56
+ ? (ctx.moduleContext.expandWith(definition.provide.name, { self }) as string)
57
+ : null;
31
58
 
32
59
  const persistentManifests: any[] = [];
33
60
  let ephemeralTemplate: any = null;
@@ -36,7 +63,10 @@ export function createTemplateController(definition: {
36
63
  const expandedName = ctx.moduleContext.expandWith(template.metadata?.name ?? "", {
37
64
  self,
38
65
  }) as string;
39
- const isTarget = expandedName === invokeTarget || expandedName === runTarget;
66
+ const isTarget =
67
+ expandedName === invokeTarget ||
68
+ expandedName === runTarget ||
69
+ expandedName === provideTarget;
40
70
  if (isTarget) {
41
71
  ephemeralTemplate = template;
42
72
  } else {
@@ -80,7 +110,8 @@ export function createTemplateController(definition: {
80
110
  invoke: async (inputs: any) => {
81
111
  if (!ephemeralTemplate) {
82
112
  throw new Error(
83
- `Template '${resource.metadata.name}': no ephemeral resource for invoke target '${invokeTarget}'`,
113
+ `Template '${resource.metadata.name}': 'invoke:' targets '${invokeTarget}' ` +
114
+ `but no entry in 'resources:' has that metadata.name. Available: ${describeAvailableTargets(ctx, definition.resources, self)}.`,
84
115
  );
85
116
  }
86
117
  const extraContext = { self, inputs };
@@ -91,18 +122,32 @@ export function createTemplateController(definition: {
91
122
  return withEphemeral(expanded, async (name) => {
92
123
  const entry = ctx.moduleContext.resourceInstances.get(name);
93
124
  if (!entry?.instance?.invoke) {
94
- throw new Error(`Ephemeral resource '${name}' is not invocable`);
125
+ const targetKind = (entry?.resource?.kind ?? expanded?.kind ?? "<unknown-kind>") as string;
126
+ const targetDef = ctx.moduleContext.getDefinition?.(targetKind);
127
+ const actualCap = typeof targetDef?.capability === "string" ? targetDef.capability : "<unknown>";
128
+ throw new Error(
129
+ `Template '${resource.metadata.name}': 'invoke:' target '${targetKind}/${invokeTarget}' ` +
130
+ `has capability '${actualCap}', not Telo.Invocable. Update 'invoke:' to a Telo.Invocable kind, or change the target's kind in 'resources:'.`,
131
+ );
95
132
  }
96
- // New object form: expand objectInvoke.inputs with invoke context and pass as arg.
97
- // Old string form: the manifest inputs were computed during template expansion;
98
- // pass expanded.inputs so Sql.Exec/Query controllers receive { sql, bindings }.
99
- const invokeInputs = objectInvoke?.inputs != null
133
+ // Top-level `inputs:` (sibling of `invoke:`) carries the values passed
134
+ // to the dispatch target's invoke(). When absent, fall back to the
135
+ // expanded resource entry's own `inputs` field (legacy string-form
136
+ // shape where the inputs live on the resource declaration), then
137
+ // finally to the caller's `inputs` arg.
138
+ const invokeInputs = definition.inputs != null
100
139
  ? ctx.moduleContext.expandWith(
101
- ctx.moduleContext.expandWith(objectInvoke.inputs, extraContext),
140
+ ctx.moduleContext.expandWith(definition.inputs, extraContext),
102
141
  extraContext,
103
142
  )
104
143
  : expanded.inputs ?? inputs;
105
- return entry.instance.invoke(invokeInputs);
144
+ const raw = await entry.instance.invoke(invokeInputs);
145
+ if (definition.result == null) return raw;
146
+ const resultContext = { self, result: raw };
147
+ return ctx.moduleContext.expandWith(
148
+ ctx.moduleContext.expandWith(definition.result, resultContext),
149
+ resultContext,
150
+ );
106
151
  });
107
152
  },
108
153
  }),
@@ -111,24 +156,73 @@ export function createTemplateController(definition: {
111
156
  run: async () => {
112
157
  if (!ephemeralTemplate) {
113
158
  throw new Error(
114
- `Template '${resource.metadata.name}': no ephemeral resource for run target '${runTarget}'`,
159
+ `Template '${resource.metadata.name}': 'run:' targets '${runTarget}' ` +
160
+ `but no entry in 'resources:' has that metadata.name. Available: ${describeAvailableTargets(ctx, definition.resources, self)}.`,
115
161
  );
116
162
  }
117
163
  const extraContext = { self };
118
164
  const expanded = ctx.moduleContext.expandWith(
119
165
  ctx.moduleContext.expandWith(ephemeralTemplate, extraContext),
120
166
  extraContext,
121
- );
167
+ ) as any;
122
168
  return withEphemeral(expanded, async (name) => {
123
169
  const entry = ctx.moduleContext.resourceInstances.get(name);
124
170
  if (!entry?.instance?.run) {
125
- throw new Error(`Ephemeral resource '${name}' is not runnable`);
171
+ const targetKind = (entry?.resource?.kind ?? expanded?.kind ?? "<unknown-kind>") as string;
172
+ const targetDef = ctx.moduleContext.getDefinition?.(targetKind);
173
+ const actualCap = typeof targetDef?.capability === "string" ? targetDef.capability : "<unknown>";
174
+ throw new Error(
175
+ `Template '${resource.metadata.name}': 'run:' target '${targetKind}/${runTarget}' ` +
176
+ `has capability '${actualCap}', not Telo.Runnable. Update 'run:' to a Telo.Runnable kind, or change the target's kind in 'resources:'.`,
177
+ );
126
178
  }
127
179
  return entry.instance.run();
128
180
  });
129
181
  },
130
182
  }),
131
183
 
184
+ ...(provideTarget && {
185
+ provide: async () => {
186
+ if (!ephemeralTemplate) {
187
+ throw new Error(
188
+ `Template '${resource.metadata.name}': 'provide:' targets '${provideTarget}' ` +
189
+ `but no entry in 'resources:' has that metadata.name. Available: ${describeAvailableTargets(ctx, definition.resources, self)}.`,
190
+ );
191
+ }
192
+ const extraContext = { self };
193
+ const expanded = ctx.moduleContext.expandWith(
194
+ ctx.moduleContext.expandWith(ephemeralTemplate, extraContext),
195
+ extraContext,
196
+ ) as any;
197
+ return withEphemeral(expanded, async (name) => {
198
+ const entry = ctx.moduleContext.resourceInstances.get(name);
199
+ if (!entry?.instance?.invoke) {
200
+ const targetKind = (entry?.resource?.kind ?? expanded?.kind ?? "<unknown-kind>") as string;
201
+ const targetDef = ctx.moduleContext.getDefinition?.(targetKind);
202
+ const actualCap = typeof targetDef?.capability === "string" ? targetDef.capability : "<unknown>";
203
+ throw new Error(
204
+ `Template '${resource.metadata.name}': 'provide:' target '${targetKind}/${provideTarget}' ` +
205
+ `has capability '${actualCap}', not Telo.Invocable. Update 'provide:' to a Telo.Invocable kind, or change the target's kind in 'resources:'.`,
206
+ );
207
+ }
208
+ const provideInputs: any =
209
+ definition.inputs != null
210
+ ? ctx.moduleContext.expandWith(
211
+ ctx.moduleContext.expandWith(definition.inputs, extraContext),
212
+ extraContext,
213
+ )
214
+ : {};
215
+ const raw = await entry.instance.invoke(provideInputs);
216
+ if (definition.result == null) return raw;
217
+ const resultContext = { self, result: raw };
218
+ return ctx.moduleContext.expandWith(
219
+ ctx.moduleContext.expandWith(definition.result, resultContext),
220
+ resultContext,
221
+ );
222
+ });
223
+ },
224
+ }),
225
+
132
226
  teardown: async () => {
133
227
  await childContext.teardownResources();
134
228
  },