@telorun/kernel 0.5.0 → 0.6.1

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 (70) hide show
  1. package/dist/controller-loader.d.ts +57 -0
  2. package/dist/controller-loader.d.ts.map +1 -1
  3. package/dist/controller-loader.js +33 -3
  4. package/dist/controller-loader.js.map +1 -1
  5. package/dist/controller-loaders/napi-loader.d.ts +30 -1
  6. package/dist/controller-loaders/napi-loader.d.ts.map +1 -1
  7. package/dist/controller-loaders/napi-loader.js +52 -7
  8. package/dist/controller-loaders/napi-loader.js.map +1 -1
  9. package/dist/controller-loaders/npm-loader.d.ts +12 -1
  10. package/dist/controller-loaders/npm-loader.d.ts.map +1 -1
  11. package/dist/controller-loaders/npm-loader.js +9 -4
  12. package/dist/controller-loaders/npm-loader.js.map +1 -1
  13. package/dist/controllers/module/import-controller.js +2 -2
  14. package/dist/controllers/module/import-controller.js.map +1 -1
  15. package/dist/controllers/resource-definition/resource-definition-controller.d.ts +1 -3
  16. package/dist/controllers/resource-definition/resource-definition-controller.d.ts.map +1 -1
  17. package/dist/controllers/resource-definition/resource-definition-controller.js +13 -14
  18. package/dist/controllers/resource-definition/resource-definition-controller.js.map +1 -1
  19. package/dist/evaluation-context.d.ts +17 -2
  20. package/dist/evaluation-context.d.ts.map +1 -1
  21. package/dist/evaluation-context.js +106 -29
  22. package/dist/evaluation-context.js.map +1 -1
  23. package/dist/index.d.ts +2 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +2 -1
  26. package/dist/index.js.map +1 -1
  27. package/dist/kernel.d.ts +13 -15
  28. package/dist/kernel.d.ts.map +1 -1
  29. package/dist/kernel.js +25 -37
  30. package/dist/kernel.js.map +1 -1
  31. package/dist/{manifest-adapters/local-file-adapter.d.ts → manifest-sources/local-file-source.d.ts} +3 -3
  32. package/dist/manifest-sources/local-file-source.d.ts.map +1 -0
  33. package/dist/{manifest-adapters/local-file-adapter.js → manifest-sources/local-file-source.js} +2 -2
  34. package/dist/manifest-sources/local-file-source.js.map +1 -0
  35. package/dist/manifest-sources/memory-source.d.ts +23 -0
  36. package/dist/manifest-sources/memory-source.d.ts.map +1 -0
  37. package/dist/manifest-sources/memory-source.js +83 -0
  38. package/dist/manifest-sources/memory-source.js.map +1 -0
  39. package/dist/module-context.d.ts +6 -2
  40. package/dist/module-context.d.ts.map +1 -1
  41. package/dist/module-context.js +13 -1
  42. package/dist/module-context.js.map +1 -1
  43. package/dist/registry.d.ts.map +1 -1
  44. package/dist/registry.js +0 -1
  45. package/dist/registry.js.map +1 -1
  46. package/dist/resource-context.d.ts +0 -6
  47. package/dist/resource-context.d.ts.map +1 -1
  48. package/dist/resource-context.js +2 -27
  49. package/dist/resource-context.js.map +1 -1
  50. package/package.json +17 -6
  51. package/src/controller-loader.ts +82 -6
  52. package/src/controller-loaders/napi-loader.ts +82 -9
  53. package/src/controller-loaders/npm-loader.ts +27 -6
  54. package/src/controllers/module/import-controller.ts +2 -2
  55. package/src/controllers/resource-definition/resource-definition-controller.ts +21 -23
  56. package/src/evaluation-context.ts +108 -31
  57. package/src/index.ts +2 -1
  58. package/src/kernel.ts +45 -56
  59. package/src/{manifest-adapters/local-file-adapter.ts → manifest-sources/local-file-source.ts} +2 -2
  60. package/src/manifest-sources/memory-source.ts +104 -0
  61. package/src/module-context.ts +14 -2
  62. package/src/registry.ts +0 -1
  63. package/src/resource-context.ts +3 -35
  64. package/dist/manifest-adapters/local-file-adapter.d.ts.map +0 -1
  65. package/dist/manifest-adapters/local-file-adapter.js.map +0 -1
  66. package/dist/manifest-adapters/manifest-adapter.d.ts +0 -35
  67. package/dist/manifest-adapters/manifest-adapter.d.ts.map +0 -1
  68. package/dist/manifest-adapters/manifest-adapter.js +0 -2
  69. package/dist/manifest-adapters/manifest-adapter.js.map +0 -1
  70. package/src/manifest-adapters/manifest-adapter.ts +0 -35
@@ -5,6 +5,48 @@ import { ControllerPolicy, DEFAULT_POLICY, POLICY_WILDCARD } from "./runtime-reg
5
5
 
6
6
  export type { ControllerPolicy } from "./runtime-registry.js";
7
7
 
8
+ /**
9
+ * Which branch the per-scheme loader actually took. Cache/local hits resolve
10
+ * in milliseconds; `npm-install` and `cargo-build` are the only branches that
11
+ * do real (network or compile) work. The CLI uses this to decide whether a
12
+ * "downloading…" line was honest or should be erased.
13
+ */
14
+ export type ControllerResolveSource =
15
+ | "local"
16
+ | "node_modules"
17
+ | "cache"
18
+ | "npm-install"
19
+ | "cargo-build";
20
+
21
+ export type ControllerLoaderEvent =
22
+ | { name: "ControllerLoading"; payload: { purl: string } }
23
+ | {
24
+ name: "ControllerLoaded";
25
+ payload: { purl: string; source: ControllerResolveSource; durationMs: number };
26
+ }
27
+ | { name: "ControllerLoadFailed"; payload: { purl: string; error: string } }
28
+ /**
29
+ * The candidate at `purl` couldn't be tried in this environment (e.g.
30
+ * `pkg:cargo` with no `rustc` on PATH, or an unsupported scheme) and the
31
+ * dispatcher has moved on to the next candidate. Distinct from `Failed`,
32
+ * which is non-recoverable. Consumers that opened a UI element on the
33
+ * matching `ControllerLoading` should close it out here.
34
+ */
35
+ | { name: "ControllerLoadSkipped"; payload: { purl: string; reason: string } };
36
+
37
+ /**
38
+ * The dispatcher awaits each emission, so the callback may be async without
39
+ * risking out-of-order delivery (concurrent definition loads emit in
40
+ * parallel; the await pins each pair of `Loading`/`Loaded` events to the
41
+ * same async chain). The kernel's `ctx.emit` is async, hence `Promise<void>`
42
+ * is allowed.
43
+ */
44
+ export type ControllerLoaderEmit = (event: ControllerLoaderEvent) => void | Promise<void>;
45
+
46
+ export interface ControllerLoaderOptions {
47
+ emit?: ControllerLoaderEmit;
48
+ }
49
+
8
50
  /**
9
51
  * Top-level controller-loader dispatcher. Picks a per-scheme sub-loader by
10
52
  * PURL type and applies the resolved selection policy:
@@ -17,10 +59,19 @@ export type { ControllerPolicy } from "./runtime-registry.js";
17
59
  * Recovery: env-missing failures (`ControllerEnvMissingError`) advance to the
18
60
  * next candidate. User-code failures (`RuntimeError("ERR_CONTROLLER_BUILD_FAILED" | "ERR_CONTROLLER_INVALID")`)
19
61
  * fail hard regardless of remaining candidates.
62
+ *
63
+ * Lifecycle events are emitted per *attempt*, so a fallback chain produces one
64
+ * `ControllerLoading` per candidate tried plus a final `ControllerLoaded` (or
65
+ * `ControllerLoadFailed`) for the one that won.
20
66
  */
21
67
  export class ControllerLoader {
22
68
  private npmLoader = new NpmControllerLoader();
23
69
  private napiLoader = new NapiControllerLoader();
70
+ private emit?: ControllerLoaderEmit;
71
+
72
+ constructor(options: ControllerLoaderOptions = {}) {
73
+ this.emit = options.emit;
74
+ }
24
75
 
25
76
  async load(
26
77
  purlCandidates: string[],
@@ -41,23 +92,48 @@ export class ControllerLoader {
41
92
 
42
93
  const errors: string[] = [];
43
94
  for (const purl of ordered) {
95
+ await this.emit?.({ name: "ControllerLoading", payload: { purl } });
96
+ const startedAt = Date.now();
44
97
  try {
45
- return await this.dispatchOne(purl, baseUri);
98
+ const { instance, source } = await this.dispatchOne(purl, baseUri);
99
+ await this.emit?.({
100
+ name: "ControllerLoaded",
101
+ payload: { purl, source, durationMs: Date.now() - startedAt },
102
+ });
103
+ return instance;
46
104
  } catch (err) {
47
105
  if (err instanceof ControllerEnvMissingError) {
48
106
  errors.push(`${purl}: ${err.message}`);
107
+ // Env-missing isn't a hard failure — the dispatcher will try the
108
+ // next candidate. We still emit a terminal event for *this* attempt
109
+ // so consumers (notably the CLI progress renderer) can close out
110
+ // the UI state opened by the matching ControllerLoading. Without
111
+ // this, every fallback attempt would leak a pending `⬇` line.
112
+ await this.emit?.({
113
+ name: "ControllerLoadSkipped",
114
+ payload: { purl, reason: err.message },
115
+ });
49
116
  continue;
50
117
  }
118
+ await this.emit?.({
119
+ name: "ControllerLoadFailed",
120
+ payload: { purl, error: err instanceof Error ? err.message : String(err) },
121
+ });
51
122
  throw err;
52
123
  }
53
124
  }
54
- throw new RuntimeError(
55
- "ERR_CONTROLLER_NOT_FOUND",
56
- `No controller resolved. Tried ${ordered.length} candidate(s):\n${errors.join("\n")}`,
57
- );
125
+ const aggregated = `No controller resolved. Tried ${ordered.length} candidate(s):\n${errors.join("\n")}`;
126
+ await this.emit?.({
127
+ name: "ControllerLoadFailed",
128
+ payload: { purl: ordered[ordered.length - 1], error: aggregated },
129
+ });
130
+ throw new RuntimeError("ERR_CONTROLLER_NOT_FOUND", aggregated);
58
131
  }
59
132
 
60
- private async dispatchOne(purl: string, baseUri: string): Promise<ControllerInstance> {
133
+ private async dispatchOne(
134
+ purl: string,
135
+ baseUri: string,
136
+ ): Promise<{ instance: ControllerInstance; source: ControllerResolveSource }> {
61
137
  if (purl.startsWith("pkg:npm")) {
62
138
  return this.npmLoader.load(purl, baseUri);
63
139
  }
@@ -39,7 +39,35 @@ export class ControllerEnvMissingError extends Error {
39
39
  * crate path (not on the caller's baseUri) ensures two distinct paths that
40
40
  * point at the same crate share one cache entry.
41
41
  */
42
- const _napiModuleCache = new Map<string, ControllerInstance>();
42
+ /**
43
+ * Holds the *raw* `require()`'d module object — i.e. the flat export bag
44
+ * returned by the napi addon — keyed by canonical crate path. The PURL
45
+ * fragment (`#entry`) projects out a sub-export at call time; caching the
46
+ * raw module here means two PURLs that differ only by fragment share one
47
+ * cargo build / one mmap of the dylib, instead of paying for either twice.
48
+ */
49
+ const _napiModuleCache = new Map<string, any>();
50
+
51
+ /**
52
+ * Which branch of the resolver served this load.
53
+ *
54
+ * - `cache` — process-lifetime in-memory hit on `_napiModuleCache`.
55
+ * - `local` — `local_path` qualifier; cargo was invoked but the work
56
+ * is conceptually equivalent to "found source on disk and
57
+ * used it" (parallel to the npm `local_path` branch). The
58
+ * CLI silences these by default; the first cold build on
59
+ * a fresh checkout is silent for the same reason.
60
+ * - `cargo-build`— reserved for distribution-mode resolution (fetch from a
61
+ * registry + compile). Not produced today; the dispatcher
62
+ * errors with `ControllerEnvMissingError` for non-local
63
+ * PURLs (see [napi-loader.ts:62-66] in this file).
64
+ */
65
+ export type NapiResolveSource = "cache" | "local" | "cargo-build";
66
+
67
+ export interface NapiLoadResult {
68
+ instance: ControllerInstance;
69
+ source: NapiResolveSource;
70
+ }
43
71
 
44
72
  export class NapiControllerLoader {
45
73
  /**
@@ -52,9 +80,19 @@ export class NapiControllerLoader {
52
80
  *
53
81
  * Distribution mode (no local_path): out of scope for the PoC; the hook is
54
82
  * left in place so the dispatcher reports env-missing and falls through.
83
+ *
84
+ * Fragment (`#entry`) is optional. When absent, the whole `require()`'d
85
+ * module is returned as the controller — the legacy single-export
86
+ * convention. When present, the fragment is treated as a property name on
87
+ * the loaded module: e.g. `pkg:cargo/foo?local_path=...#bar` returns
88
+ * `module.bar` as the controller. This mirrors the npm `#entry` semantics
89
+ * but indexes into the flat napi export bag instead of opening a different
90
+ * file. The convention is "one source file per controller, top-level
91
+ * export name matches the file" — files-as-controllers in spirit, even
92
+ * though all exports come from one linked dylib.
55
93
  */
56
- async load(purl: string, baseUri: string): Promise<ControllerInstance> {
57
- const [, , name, , qualifiers] = PackageURL.parseString(purl);
94
+ async load(purl: string, baseUri: string): Promise<NapiLoadResult> {
95
+ const [, , name, , qualifiers, entry] = PackageURL.parseString(purl);
58
96
  const localPath = (qualifiers as any)?.get("local_path");
59
97
 
60
98
  const isLocalManifest =
@@ -82,7 +120,7 @@ export class NapiControllerLoader {
82
120
  const cacheKey = canonicalCratePath;
83
121
  const cached = _napiModuleCache.get(cacheKey);
84
122
  if (cached) {
85
- return cached;
123
+ return { instance: project(cached, entry, cratePath), source: "cache" };
86
124
  }
87
125
 
88
126
  try {
@@ -122,9 +160,9 @@ export class NapiControllerLoader {
122
160
  const nodePath = path.join(path.dirname(dylibPath), `${libName}.node`);
123
161
  await fs.copyFile(dylibPath, nodePath);
124
162
 
125
- let module: any;
163
+ let rawModule: any;
126
164
  try {
127
- module = requireFromHere(nodePath);
165
+ rawModule = requireFromHere(nodePath);
128
166
  } catch (err: any) {
129
167
  throw new RuntimeError(
130
168
  "ERR_CONTROLLER_INVALID",
@@ -132,15 +170,50 @@ export class NapiControllerLoader {
132
170
  );
133
171
  }
134
172
 
135
- if (!module || (!module.create && !module.register)) {
173
+ _napiModuleCache.set(cacheKey, rawModule);
174
+ // `local` rather than `cargo-build` because the only mode currently
175
+ // wired up is `local_path` dev-mode — cargo's incremental cache means
176
+ // every run after the first is ~50ms of cargo-startup with no real
177
+ // compilation, conceptually the same as the npm `local_path` branch
178
+ // that just imports source already on disk. Distribution mode (when
179
+ // implemented) will return `cargo-build` from its own branch.
180
+ return { instance: project(rawModule, entry, nodePath), source: "local" };
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Pick the controller out of the raw napi module. With no fragment, the
186
+ * whole module *is* the controller (legacy single-export shape). With a
187
+ * fragment, look up `module[entry]` — convention: one source file per
188
+ * controller, top-level export name matches the file.
189
+ */
190
+ function project(module: any, entry: string | undefined, where: string): ControllerInstance {
191
+ if (!module) {
192
+ throw new RuntimeError("ERR_CONTROLLER_INVALID", `napi module from ${where} is empty`);
193
+ }
194
+ if (!entry) {
195
+ if (!module.create && !module.register) {
136
196
  throw new RuntimeError(
137
197
  "ERR_CONTROLLER_INVALID",
138
- `pkg:cargo controller at ${nodePath} exports neither create nor register`,
198
+ `pkg:cargo controller at ${where} exports neither create nor register`,
139
199
  );
140
200
  }
141
- _napiModuleCache.set(cacheKey, module);
142
201
  return module;
143
202
  }
203
+ const sub = module[entry];
204
+ if (!sub) {
205
+ throw new RuntimeError(
206
+ "ERR_CONTROLLER_INVALID",
207
+ `pkg:cargo controller at ${where}#${entry}: module has no export named "${entry}"`,
208
+ );
209
+ }
210
+ if (!sub.create && !sub.register) {
211
+ throw new RuntimeError(
212
+ "ERR_CONTROLLER_INVALID",
213
+ `pkg:cargo controller at ${where}#${entry} exports neither create nor register`,
214
+ );
215
+ }
216
+ return sub;
144
217
  }
145
218
 
146
219
  async function resolveCrateMetadata(
@@ -14,13 +14,26 @@ const cacheRoot = process.env.TELO_CACHE_DIR
14
14
  const npmCacheRoot = path.join(cacheRoot, "npm");
15
15
  const isBun = typeof (globalThis as any).Bun !== "undefined";
16
16
 
17
+ /**
18
+ * Tells the dispatcher (and any UI consumer downstream) which branch the
19
+ * resolver actually took. `npm-install` is the only one that hits the network;
20
+ * the rest are sub-second cache/local lookups, so the CLI uses this to decide
21
+ * whether a "downloading…" line was honest or should be silently dropped.
22
+ */
23
+ export type NpmResolveSource = "local" | "node_modules" | "npm-install" | "cache";
24
+
25
+ export interface NpmLoadResult {
26
+ instance: ControllerInstance;
27
+ source: NpmResolveSource;
28
+ }
29
+
17
30
  export class NpmControllerLoader {
18
31
  /**
19
32
  * Resolve a `pkg:npm/...` PURL to a controller module instance. Tries, in order:
20
33
  * a relative `local_path` qualifier, the workspace's `node_modules`, and finally
21
34
  * an isolated install under `~/.cache/telo/npm/<hash>`.
22
35
  */
23
- async load(purl: string, baseUri: string): Promise<ControllerInstance> {
36
+ async load(purl: string, baseUri: string): Promise<NpmLoadResult> {
24
37
  const [, namespace, name, versionSpec, qualifiers, entry] = PackageURL.parseString(purl);
25
38
 
26
39
  const localPath = (qualifiers as any)?.get("local_path");
@@ -28,6 +41,7 @@ export class NpmControllerLoader {
28
41
  const installDir = path.join(npmCacheRoot, cacheKey);
29
42
 
30
43
  let packageRoot: string;
44
+ let source: NpmResolveSource;
31
45
  const isLocalManifest =
32
46
  baseUri && !baseUri.startsWith("http://") && !baseUri.startsWith("https://");
33
47
  if (localPath && isLocalManifest) {
@@ -36,12 +50,14 @@ export class NpmControllerLoader {
36
50
  const resolvedLocalPath = path.resolve(manifestDir, localPath);
37
51
  if (await this.pathExists(resolvedLocalPath)) {
38
52
  packageRoot = resolvedLocalPath;
53
+ source = "local";
39
54
  } else {
40
55
  const nodeModulesPath = await this.findInNodeModules(`${namespace}/${name}`);
41
56
  if (nodeModulesPath) {
42
57
  packageRoot = nodeModulesPath;
58
+ source = "node_modules";
43
59
  } else {
44
- await this.ensureNpmPackageInstalled(installDir, `${namespace}/${name}@${versionSpec}`);
60
+ source = await this.ensureNpmPackageInstalled(installDir, `${namespace}/${name}@${versionSpec}`);
45
61
  packageRoot = this.getInstalledPackageRoot(installDir, `${namespace}/${name}`);
46
62
  }
47
63
  }
@@ -49,8 +65,9 @@ export class NpmControllerLoader {
49
65
  const nodeModulesPath = await this.findInNodeModules(`${namespace}/${name}`);
50
66
  if (nodeModulesPath) {
51
67
  packageRoot = nodeModulesPath;
68
+ source = "node_modules";
52
69
  } else {
53
- await this.ensureNpmPackageInstalled(installDir, `${namespace}/${name}@${versionSpec}`);
70
+ source = await this.ensureNpmPackageInstalled(installDir, `${namespace}/${name}@${versionSpec}`);
54
71
  packageRoot = this.getInstalledPackageRoot(installDir, `${namespace}/${name}`);
55
72
  }
56
73
  }
@@ -62,10 +79,13 @@ export class NpmControllerLoader {
62
79
  `Invalid controller loaded from "${purl}": missing create or register function`,
63
80
  );
64
81
  }
65
- return instance;
82
+ return { instance, source };
66
83
  }
67
84
 
68
- private async ensureNpmPackageInstalled(installDir: string, packageSpec: string): Promise<void> {
85
+ private async ensureNpmPackageInstalled(
86
+ installDir: string,
87
+ packageSpec: string,
88
+ ): Promise<"cache" | "npm-install"> {
69
89
  const packageName = this.getPackageName(
70
90
  packageSpec.startsWith(".") || path.isAbsolute(packageSpec)
71
91
  ? await this.getLocalPackageName(packageSpec)
@@ -74,7 +94,7 @@ export class NpmControllerLoader {
74
94
  const packageRoot = this.getInstalledPackageRoot(installDir, packageName);
75
95
  const packageJsonPath = path.join(packageRoot, "package.json");
76
96
  if (await this.pathExists(packageJsonPath)) {
77
- return;
97
+ return "cache";
78
98
  }
79
99
 
80
100
  await fs.mkdir(installDir, { recursive: true });
@@ -98,6 +118,7 @@ export class NpmControllerLoader {
98
118
  ];
99
119
 
100
120
  await execFileAsync("npm", args);
121
+ return "npm-install";
101
122
  }
102
123
 
103
124
  private getPackageName(packageSpec: string): string {
@@ -11,8 +11,8 @@ const importAnalysisCache = new Map<
11
11
 
12
12
  // Only resolve relative/absolute-path sources against the importer's URL. Registry refs
13
13
  // (std/foo@1.2.3) and absolute URLs (https://, file://) must pass through unchanged so the
14
- // loader's adapter 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 LocalFileAdapter ENOENTs on it.
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
16
  function resolveImportSource(source: string, baseSource: string): string {
17
17
  if (source.startsWith(".") || source.startsWith("/")) {
18
18
  return new URL(source, baseSource).toString();
@@ -27,10 +27,7 @@ type ResourceDefinitionResource = RuntimeResource & {
27
27
  class ResourceDefinition implements ResourceInstance {
28
28
  readonly kind: "ResourceDefinition" = "ResourceDefinition";
29
29
 
30
- constructor(
31
- readonly resource: ResourceDefinitionResource,
32
- private controllerLoader: ControllerLoader,
33
- ) {}
30
+ constructor(readonly resource: ResourceDefinitionResource) {}
34
31
 
35
32
  async init(ctx: ResourceContext) {
36
33
  if (!this.resource.controllers?.length) {
@@ -43,24 +40,25 @@ class ResourceDefinition implements ResourceInstance {
43
40
  );
44
41
  return;
45
42
  }
46
- ctx.emit("ControllerLoading", { controllers: this.resource.controllers });
47
- try {
48
- const controllerInstance = await this.controllerLoader.load(
49
- this.resource.controllers,
50
- this.resource.metadata.source,
51
- ctx.getControllerPolicy(),
52
- );
53
- ctx.emit("ControllerLoaded", { schema: controllerInstance.schema });
54
- ctx.registerDefinition(this.resource);
55
- await ctx.registerController(
56
- this.resource.metadata.module,
57
- this.resource.metadata.name,
58
- controllerInstance,
59
- );
60
- } catch (err) {
61
- ctx.emit("ControllerLoadFailed", { error: (err as Error).message });
62
- throw err;
63
- }
43
+ // The loader owns ControllerLoading / ControllerLoaded / ControllerLoadFailed
44
+ // emission so it can fire one event per attempted candidate (env-missing
45
+ // fallback chains), and so the payload can include the actually-picked PURL,
46
+ // which branch resolved it (`source`), and timing — none of which are known
47
+ // here at the call site.
48
+ const loader = new ControllerLoader({
49
+ emit: (e) => ctx.emit(e.name, e.payload),
50
+ });
51
+ const controllerInstance = await loader.load(
52
+ this.resource.controllers,
53
+ this.resource.metadata.source,
54
+ ctx.getControllerPolicy(),
55
+ );
56
+ ctx.registerDefinition(this.resource);
57
+ await ctx.registerController(
58
+ this.resource.metadata.module,
59
+ this.resource.metadata.name,
60
+ controllerInstance,
61
+ );
64
62
  }
65
63
  }
66
64
 
@@ -78,7 +76,7 @@ export async function create(resource: any, ctx: ResourceContext): Promise<Resou
78
76
 
79
77
  // Return a fully-formed ResourceDefinition instance
80
78
  const definition = resource as unknown as ResourceDefinitionResource;
81
- return new ResourceDefinition(definition, new ControllerLoader());
79
+ return new ResourceDefinition(definition);
82
80
  }
83
81
 
84
82
  export const schema = {
@@ -18,6 +18,52 @@ import { RuntimeError } from "@telorun/sdk";
18
18
 
19
19
  export { resourceKey };
20
20
 
21
+ type Walker = (ctx: Record<string, unknown>) => unknown;
22
+
23
+ /** Compile a manifest subtree into a tightly-bound walker closure. The returned
24
+ * function takes an activation object and rebuilds a fresh container of the
25
+ * same shape with all `${{ }}` CompiledValues evaluated against the activation.
26
+ * Per-call overhead is one closure invocation per node — no isCompiledValue /
27
+ * Array.isArray / typeof / Object.entries checks at runtime. */
28
+ function compileWalker(value: unknown): Walker {
29
+ if (isCompiledValue(value)) {
30
+ const compiled = value;
31
+ return (ctx) => {
32
+ try {
33
+ return compiled.call(ctx);
34
+ } catch (error) {
35
+ const expr = compiled.source ? `\${{ ${compiled.source} }}` : "unknown expression";
36
+ const msg = error instanceof Error ? error.message : String(error);
37
+ throw new Error(`Expression ${expr} failed: ${msg}`);
38
+ }
39
+ };
40
+ }
41
+ if (Array.isArray(value)) {
42
+ const childWalkers = value.map(compileWalker);
43
+ const n = childWalkers.length;
44
+ return (ctx) => {
45
+ const out = new Array(n);
46
+ for (let i = 0; i < n; i++) out[i] = childWalkers[i]!(ctx);
47
+ return out;
48
+ };
49
+ }
50
+ if (value !== null && typeof value === "object") {
51
+ const entries = Object.entries(value as Record<string, unknown>).map(
52
+ ([k, v]) => [k, compileWalker(v)] as const,
53
+ );
54
+ const n = entries.length;
55
+ return (ctx) => {
56
+ const out: Record<string, unknown> = {};
57
+ for (let i = 0; i < n; i++) {
58
+ const [k, fn] = entries[i]!;
59
+ out[k] = fn(ctx);
60
+ }
61
+ return out;
62
+ };
63
+ }
64
+ return () => value;
65
+ }
66
+
21
67
  /**
22
68
  * Base class for all evaluation contexts. Owns template
23
69
  * expansion, secrets redaction, and the generic resource lifecycle tree.
@@ -215,7 +261,7 @@ export class EvaluationContext implements IEvaluationContext {
215
261
  }
216
262
  if (instance.init) await instance.init(ctx);
217
263
  if (instance.snapshot) {
218
- const snap = await Promise.resolve(instance.snapshot()).catch(() => ({}));
264
+ const snap = await Promise.resolve(instance.snapshot());
219
265
  this.onResourceSnapshotted(name, (snap as Record<string, unknown>) ?? {});
220
266
  }
221
267
  this.resourceInstances.set(name, { resource, instance });
@@ -453,7 +499,9 @@ export class EvaluationContext implements IEvaluationContext {
453
499
  if (declaredCodes && !declaredCodes.has(err.code)) {
454
500
  await this.emit(`${kind}.${name}.InvokeRejected.Undeclared`, payload);
455
501
  }
456
- } else if (err instanceof Error) {
502
+ throw err;
503
+ }
504
+ if (err instanceof Error) {
457
505
  await this.emit(`${kind}.${name}.InvokeFailed`, {
458
506
  name: err.name,
459
507
  message: err.message,
@@ -464,7 +512,22 @@ export class EvaluationContext implements IEvaluationContext {
464
512
  message: String(err),
465
513
  });
466
514
  }
467
- throw err;
515
+ // Already enriched at an inner invoke: keep the innermost (most
516
+ // specific) resource as the failure location.
517
+ if (err instanceof RuntimeError && err.diagnostics?.length) throw err;
518
+ const message = err instanceof Error ? err.message : String(err);
519
+ const code = err instanceof Error ? err.name : undefined;
520
+ // Keep `message` raw so callers (Run.Sequence catch blocks, assertions)
521
+ // see the original error text unchanged. Resource context lives only on
522
+ // the attached diagnostic, which the CLI's formatter renders as the
523
+ // location prefix. Attach the original error as `cause` so
524
+ // formatErrorForDiagnostic walks the chain and surfaces the underlying
525
+ // stack and well-known error fields (AWS, pg, Node system errors).
526
+ const wrapped = new RuntimeError("ERR_EXECUTION_FAILED", message, [
527
+ { kind, resource: name, message, code },
528
+ ]);
529
+ (wrapped as { cause?: unknown }).cause = err;
530
+ throw wrapped;
468
531
  }
469
532
  }
470
533
 
@@ -497,48 +560,62 @@ export class EvaluationContext implements IEvaluationContext {
497
560
 
498
561
  /**
499
562
  * Expand a value that may contain precompiled ${{ }} templates.
500
- * Works recursively over CompiledValues, arrays, and objects.
563
+ *
564
+ * Hot path: each unique manifest subtree is compiled once into a tightly-bound
565
+ * walker closure (no per-call `isCompiledValue` / `Array.isArray` / `typeof` /
566
+ * `Object.entries` overhead, no recursive method dispatch). The walker tree is
567
+ * cached by the input value's identity in `walkerCache`, so subsequent calls
568
+ * with the same manifest data reuse it. The walker reads from `this._context`
569
+ * — which `expandWith` mutates in place — and emits a fresh container per call
570
+ * to preserve the original recursive `expand`'s semantics.
501
571
  */
502
572
  expand(value: unknown): unknown {
503
- if (isCompiledValue(value)) {
504
- try {
505
- return value.call(this._context);
506
- } catch (error) {
507
- const expr = value.source ? `\${{ ${value.source} }}` : "unknown expression";
508
- const msg = error instanceof Error ? error.message : String(error);
509
- throw new Error(`Expression ${expr} failed: ${msg}`);
510
- }
511
- }
512
- if (Array.isArray(value)) {
513
- return value.map((entry) => this.expand(entry));
514
- }
515
- if (value !== null && typeof value === "object") {
516
- const resolved: Record<string, unknown> = {};
517
- for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
518
- resolved[key] = this.expand(entry);
519
- }
520
- return resolved;
521
- }
522
- return value;
573
+ if (value === null || typeof value !== "object") return value;
574
+ const cached = this.walkerCache.get(value as object);
575
+ if (cached) return cached(this._context);
576
+ const walker = compileWalker(value);
577
+ this.walkerCache.set(value as object, walker);
578
+ return walker(this._context);
523
579
  }
524
580
 
525
581
  /**
526
582
  * Expand a value using this context merged with additional properties.
527
- * Equivalent to merge(extraContext).expand(value) without allocating a context object.
583
+ *
584
+ * Hot path optimisation: rather than allocate a fresh prototype-less object
585
+ * per call (one allocation + N property copies for N keys in the saved
586
+ * context), we mutate `_context` in place — adding or overwriting only the
587
+ * `extraContext` keys — and restore the previous values on exit. Safe because
588
+ * `expand` is synchronous; cel-vm closures only read from the activation.
528
589
  */
529
590
  expandWith(value: unknown, extraContext: Record<string, unknown>): unknown {
530
- const saved = this._context;
531
- this._context = Object.assign(Object.create(null), saved, extraContext) as Record<
532
- string,
533
- unknown
534
- >;
591
+ const ctx = this._context as Record<string, unknown>;
592
+ const keys = Object.keys(extraContext);
593
+ const savedValues: unknown[] = new Array(keys.length);
594
+ const hadKey: boolean[] = new Array(keys.length);
595
+ for (let i = 0; i < keys.length; i++) {
596
+ const k = keys[i]!;
597
+ hadKey[i] = k in ctx;
598
+ if (hadKey[i]) savedValues[i] = ctx[k];
599
+ ctx[k] = extraContext[k];
600
+ }
535
601
  try {
536
602
  return this.expand(value);
537
603
  } finally {
538
- this._context = saved;
604
+ for (let i = 0; i < keys.length; i++) {
605
+ const k = keys[i]!;
606
+ if (hadKey[i]) ctx[k] = savedValues[i];
607
+ else delete ctx[k];
608
+ }
539
609
  }
540
610
  }
541
611
 
612
+ /** Cache of compiled walker closures keyed on the manifest subtree they walk.
613
+ * WeakMap so entries are GC'd if the manifest is reloaded. */
614
+ private readonly walkerCache = new WeakMap<
615
+ object,
616
+ (ctx: Record<string, unknown>) => unknown
617
+ >();
618
+
542
619
  /**
543
620
  * Expand specific dot-paths within an object. '**' expands the entire object.
544
621
  * Paths listed in excludePaths are left untouched (runtime takes precedence).
package/src/index.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  export { ControllerLoader } from "./controller-loader.js";
2
2
  export { ControllerRegistry } from "./controller-registry.js";
3
3
  export { EvaluationContext } from "./evaluation-context.js";
4
- export { LocalFileAdapter } from "./manifest-adapters/local-file-adapter.js";
4
+ export { LocalFileSource } from "./manifest-sources/local-file-source.js";
5
+ export { MemorySource } from "./manifest-sources/memory-source.js";
5
6
  export { EventStream } from "./event-stream.js";
6
7
  export { ExecutionContext } from "./execution-context.js";
7
8
  export { Kernel, type KernelOptions } from "./kernel.js";