@telorun/kernel 0.4.0 → 0.5.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 (60) hide show
  1. package/dist/controller-loader.d.ts +19 -20
  2. package/dist/controller-loader.d.ts.map +1 -1
  3. package/dist/controller-loader.js +67 -247
  4. package/dist/controller-loader.js.map +1 -1
  5. package/dist/controller-loaders/napi-loader.d.ts +27 -0
  6. package/dist/controller-loaders/napi-loader.d.ts.map +1 -0
  7. package/dist/controller-loaders/napi-loader.js +158 -0
  8. package/dist/controller-loaders/napi-loader.js.map +1 -0
  9. package/dist/controller-loaders/npm-loader.d.ts +20 -0
  10. package/dist/controller-loaders/npm-loader.d.ts.map +1 -0
  11. package/dist/controller-loaders/npm-loader.js +256 -0
  12. package/dist/controller-loaders/npm-loader.js.map +1 -0
  13. package/dist/controller-registry.d.ts +30 -20
  14. package/dist/controller-registry.d.ts.map +1 -1
  15. package/dist/controller-registry.js +50 -99
  16. package/dist/controller-registry.js.map +1 -1
  17. package/dist/controllers/module/import-controller.d.ts +11 -0
  18. package/dist/controllers/module/import-controller.d.ts.map +1 -1
  19. package/dist/controllers/module/import-controller.js +28 -1
  20. package/dist/controllers/module/import-controller.js.map +1 -1
  21. package/dist/controllers/resource-definition/abstract-controller.d.ts +35 -0
  22. package/dist/controllers/resource-definition/abstract-controller.d.ts.map +1 -0
  23. package/dist/controllers/resource-definition/abstract-controller.js +34 -0
  24. package/dist/controllers/resource-definition/abstract-controller.js.map +1 -0
  25. package/dist/controllers/resource-definition/resource-definition-controller.d.ts.map +1 -1
  26. package/dist/controllers/resource-definition/resource-definition-controller.js +1 -1
  27. package/dist/controllers/resource-definition/resource-definition-controller.js.map +1 -1
  28. package/dist/kernel.d.ts +1 -1
  29. package/dist/kernel.d.ts.map +1 -1
  30. package/dist/kernel.js +35 -14
  31. package/dist/kernel.js.map +1 -1
  32. package/dist/manifest-schemas.d.ts +50 -0
  33. package/dist/manifest-schemas.d.ts.map +1 -1
  34. package/dist/manifest-schemas.js +31 -0
  35. package/dist/manifest-schemas.js.map +1 -1
  36. package/dist/module-context.d.ts +11 -1
  37. package/dist/module-context.d.ts.map +1 -1
  38. package/dist/module-context.js +6 -0
  39. package/dist/module-context.js.map +1 -1
  40. package/dist/resource-context.d.ts +2 -1
  41. package/dist/resource-context.d.ts.map +1 -1
  42. package/dist/resource-context.js +6 -1
  43. package/dist/resource-context.js.map +1 -1
  44. package/dist/runtime-registry.d.ts +50 -0
  45. package/dist/runtime-registry.d.ts.map +1 -0
  46. package/dist/runtime-registry.js +140 -0
  47. package/dist/runtime-registry.js.map +1 -0
  48. package/package.json +4 -4
  49. package/src/controller-loader.ts +77 -273
  50. package/src/controller-loaders/napi-loader.ts +191 -0
  51. package/src/controller-loaders/npm-loader.ts +285 -0
  52. package/src/controller-registry.ts +66 -129
  53. package/src/controllers/module/import-controller.ts +30 -1
  54. package/src/controllers/resource-definition/abstract-controller.ts +56 -0
  55. package/src/controllers/resource-definition/resource-definition-controller.ts +1 -0
  56. package/src/kernel.ts +43 -13
  57. package/src/manifest-schemas.ts +33 -0
  58. package/src/module-context.ts +22 -1
  59. package/src/resource-context.ts +8 -1
  60. package/src/runtime-registry.ts +170 -0
@@ -0,0 +1,285 @@
1
+ import { ControllerInstance } from "@telorun/sdk";
2
+ import { execFile } from "child_process";
3
+ import { createHash } from "crypto";
4
+ import * as fs from "fs/promises";
5
+ import * as os from "os";
6
+ import { PackageURL } from "packageurl-js";
7
+ import * as path from "path";
8
+ import { promisify } from "util";
9
+
10
+ const homedir = os.homedir();
11
+ const cacheRoot = process.env.TELO_CACHE_DIR
12
+ ? path.resolve(process.env.TELO_CACHE_DIR)
13
+ : path.join(homedir, ".cache", "telo");
14
+ const npmCacheRoot = path.join(cacheRoot, "npm");
15
+ const isBun = typeof (globalThis as any).Bun !== "undefined";
16
+
17
+ export class NpmControllerLoader {
18
+ /**
19
+ * Resolve a `pkg:npm/...` PURL to a controller module instance. Tries, in order:
20
+ * a relative `local_path` qualifier, the workspace's `node_modules`, and finally
21
+ * an isolated install under `~/.cache/telo/npm/<hash>`.
22
+ */
23
+ async load(purl: string, baseUri: string): Promise<ControllerInstance> {
24
+ const [, namespace, name, versionSpec, qualifiers, entry] = PackageURL.parseString(purl);
25
+
26
+ const localPath = (qualifiers as any)?.get("local_path");
27
+ const cacheKey = createHash("sha256").update(purl).digest("hex").slice(0, 12);
28
+ const installDir = path.join(npmCacheRoot, cacheKey);
29
+
30
+ let packageRoot: string;
31
+ const isLocalManifest =
32
+ baseUri && !baseUri.startsWith("http://") && !baseUri.startsWith("https://");
33
+ if (localPath && isLocalManifest) {
34
+ const baseUriPath = baseUri.startsWith("file://") ? baseUri.slice("file://".length) : baseUri;
35
+ const manifestDir = path.dirname(baseUriPath);
36
+ const resolvedLocalPath = path.resolve(manifestDir, localPath);
37
+ if (await this.pathExists(resolvedLocalPath)) {
38
+ packageRoot = resolvedLocalPath;
39
+ } else {
40
+ const nodeModulesPath = await this.findInNodeModules(`${namespace}/${name}`);
41
+ if (nodeModulesPath) {
42
+ packageRoot = nodeModulesPath;
43
+ } else {
44
+ await this.ensureNpmPackageInstalled(installDir, `${namespace}/${name}@${versionSpec}`);
45
+ packageRoot = this.getInstalledPackageRoot(installDir, `${namespace}/${name}`);
46
+ }
47
+ }
48
+ } else {
49
+ const nodeModulesPath = await this.findInNodeModules(`${namespace}/${name}`);
50
+ if (nodeModulesPath) {
51
+ packageRoot = nodeModulesPath;
52
+ } else {
53
+ await this.ensureNpmPackageInstalled(installDir, `${namespace}/${name}@${versionSpec}`);
54
+ packageRoot = this.getInstalledPackageRoot(installDir, `${namespace}/${name}`);
55
+ }
56
+ }
57
+
58
+ const entryFile = await this.resolvePackageEntry(packageRoot, entry ? `./${entry}` : ".");
59
+ const instance = await import(entryFile);
60
+ if (!instance || (!instance.create && !instance.register)) {
61
+ throw new Error(
62
+ `Invalid controller loaded from "${purl}": missing create or register function`,
63
+ );
64
+ }
65
+ return instance;
66
+ }
67
+
68
+ private async ensureNpmPackageInstalled(installDir: string, packageSpec: string): Promise<void> {
69
+ const packageName = this.getPackageName(
70
+ packageSpec.startsWith(".") || path.isAbsolute(packageSpec)
71
+ ? await this.getLocalPackageName(packageSpec)
72
+ : packageSpec,
73
+ );
74
+ const packageRoot = this.getInstalledPackageRoot(installDir, packageName);
75
+ const packageJsonPath = path.join(packageRoot, "package.json");
76
+ if (await this.pathExists(packageJsonPath)) {
77
+ return;
78
+ }
79
+
80
+ await fs.mkdir(installDir, { recursive: true });
81
+ const rootPackageJson = path.join(installDir, "package.json");
82
+ if (!(await this.pathExists(rootPackageJson))) {
83
+ await fs.writeFile(
84
+ rootPackageJson,
85
+ JSON.stringify({ name: "telo-cache", private: true }, null, 2),
86
+ );
87
+ }
88
+
89
+ const execFileAsync = promisify(execFile);
90
+ const args = [
91
+ "install",
92
+ "--no-audit",
93
+ "--no-fund",
94
+ "--silent",
95
+ "--prefix",
96
+ installDir,
97
+ packageSpec,
98
+ ];
99
+
100
+ await execFileAsync("npm", args);
101
+ }
102
+
103
+ private getPackageName(packageSpec: string): string {
104
+ if (packageSpec.startsWith("@")) {
105
+ const lastAt = packageSpec.lastIndexOf("@");
106
+ return lastAt > 0 ? packageSpec.slice(0, lastAt) : packageSpec;
107
+ }
108
+ const [name] = packageSpec.split("@");
109
+ return name;
110
+ }
111
+
112
+ private getInstalledPackageRoot(installDir: string, packageName: string): string {
113
+ const nameParts = packageName.split("/");
114
+ return path.join(installDir, "node_modules", ...nameParts);
115
+ }
116
+
117
+ private async getLocalPackageName(packagePath: string): Promise<string> {
118
+ const packageJsonPath = path.join(packagePath, "package.json");
119
+ if (!(await this.pathExists(packageJsonPath))) {
120
+ throw new Error(`Local package missing package.json: ${packagePath}`);
121
+ }
122
+ const content = await fs.readFile(packageJsonPath, "utf8");
123
+ const parsed = JSON.parse(content);
124
+ if (!parsed?.name) {
125
+ throw new Error(`Local package missing name in package.json: ${packagePath}`);
126
+ }
127
+ return parsed.name;
128
+ }
129
+
130
+ private async resolvePackageEntry(
131
+ packageRoot: string,
132
+ entry: string,
133
+ packageName?: string,
134
+ ): Promise<string> {
135
+ const packageJsonPath = path.join(packageRoot, "package.json");
136
+ let resolvedPackageName = packageName;
137
+ let packageJson: any = null;
138
+ if (!resolvedPackageName && (await this.pathExists(packageJsonPath))) {
139
+ const content = await fs.readFile(packageJsonPath, "utf8");
140
+ try {
141
+ packageJson = JSON.parse(content);
142
+ resolvedPackageName = packageJson?.name;
143
+ } catch {
144
+ resolvedPackageName = packageName;
145
+ }
146
+ } else if (await this.pathExists(packageJsonPath)) {
147
+ try {
148
+ packageJson = JSON.parse(await fs.readFile(packageJsonPath, "utf8"));
149
+ } catch {
150
+ packageJson = null;
151
+ }
152
+ }
153
+
154
+ const entryValue = entry.trim();
155
+ const exportTarget = this.resolvePackageExportTarget(packageJson?.exports, entryValue);
156
+ if (exportTarget) {
157
+ const resolved = path.resolve(packageRoot, exportTarget);
158
+ if (await this.pathExists(resolved)) {
159
+ return this.resolveForRuntime(resolved, packageRoot);
160
+ }
161
+ if (!path.extname(resolved)) {
162
+ const withJs = `${resolved}.js`;
163
+ if (await this.pathExists(withJs)) {
164
+ return withJs;
165
+ }
166
+ }
167
+ }
168
+ if ((entryValue === "." || entryValue === "./") && packageJson) {
169
+ const mainFields = ["module", "main"];
170
+ for (const field of mainFields) {
171
+ const target = packageJson[field];
172
+ if (typeof target === "string") {
173
+ const resolved = path.resolve(packageRoot, target);
174
+ if (await this.pathExists(resolved)) {
175
+ return this.resolveForRuntime(resolved, packageRoot);
176
+ }
177
+ if (!path.extname(resolved)) {
178
+ const withJs = `${resolved}.js`;
179
+ if (await this.pathExists(withJs)) {
180
+ return withJs;
181
+ }
182
+ }
183
+ }
184
+ }
185
+ }
186
+
187
+ const directPath = path.resolve(packageRoot, entryValue);
188
+ if (await this.pathExists(directPath)) {
189
+ return this.resolveForRuntime(directPath, packageRoot);
190
+ }
191
+ if (!path.extname(directPath)) {
192
+ const withJs = `${directPath}.js`;
193
+ if (await this.pathExists(withJs)) {
194
+ return withJs;
195
+ }
196
+ }
197
+
198
+ throw new Error(`Controller entry "${entryValue}" could not be resolved in ${packageRoot}`);
199
+ }
200
+
201
+ private resolvePackageExportTarget(exportsField: any, entry: string): string | null {
202
+ if (!exportsField) {
203
+ return null;
204
+ }
205
+
206
+ const key = entry === "." || entry === "./" ? "." : entry;
207
+ const target = exportsField[key];
208
+ return this.resolveExportTargetValue(target);
209
+ }
210
+
211
+ private resolveExportTargetValue(target: any): string | null {
212
+ if (!target) {
213
+ return null;
214
+ }
215
+ if (typeof target === "string") {
216
+ return target;
217
+ }
218
+ if (Array.isArray(target)) {
219
+ for (const item of target) {
220
+ const resolved = this.resolveExportTargetValue(item);
221
+ if (resolved) {
222
+ return resolved;
223
+ }
224
+ }
225
+ return null;
226
+ }
227
+ if (typeof target === "object") {
228
+ const preferredKeys = isBun
229
+ ? ["bun", "import", "default", "require"]
230
+ : ["import", "default", "require"];
231
+ for (const key of preferredKeys) {
232
+ if (target[key]) {
233
+ const resolved = this.resolveExportTargetValue(target[key]);
234
+ if (resolved) {
235
+ return resolved;
236
+ }
237
+ }
238
+ }
239
+ }
240
+ return null;
241
+ }
242
+
243
+ private async resolveForRuntime(resolvedPath: string, packageRoot: string): Promise<string> {
244
+ if (isBun || !resolvedPath.endsWith(".ts")) {
245
+ return resolvedPath;
246
+ }
247
+ const relative = path.relative(packageRoot, resolvedPath);
248
+ const distEquivalent = path.resolve(
249
+ packageRoot,
250
+ relative.replace(/^src\//, "dist/").replace(/\.ts$/, ".js"),
251
+ );
252
+ if (await this.pathExists(distEquivalent)) {
253
+ return distEquivalent;
254
+ }
255
+ const jsPath = resolvedPath.replace(/\.ts$/, ".js");
256
+ if (await this.pathExists(jsPath)) {
257
+ return jsPath;
258
+ }
259
+ return resolvedPath;
260
+ }
261
+
262
+ private async findInNodeModules(packageName: string): Promise<string | null> {
263
+ const nameParts = packageName.split("/");
264
+ const candidates = [
265
+ path.join(process.cwd(), "node_modules", ...nameParts),
266
+ path.join(process.cwd(), "node_modules", ".pnpm", "node_modules", ...nameParts),
267
+ ];
268
+ for (const candidate of candidates) {
269
+ const packageJsonPath = path.join(candidate, "package.json");
270
+ if (await this.pathExists(packageJsonPath)) {
271
+ return candidate;
272
+ }
273
+ }
274
+ return null;
275
+ }
276
+
277
+ private async pathExists(filePath: string): Promise<boolean> {
278
+ try {
279
+ await fs.access(filePath);
280
+ return true;
281
+ } catch {
282
+ return false;
283
+ }
284
+ }
285
+ }
@@ -1,74 +1,76 @@
1
- import { ControllerInstance, ResourceDefinition, RuntimeResource } from "@telorun/sdk";
2
- import * as path from "path";
1
+ import { ControllerInstance, ResourceDefinition, RuntimeError } from "@telorun/sdk";
2
+
3
+ const DEFAULT_FINGERPRINT = "default";
3
4
 
4
5
  /**
5
6
  * ControllerRegistry: Manages controller loading and dispatch
6
- * Maps fully-qualified resource kinds to their controller implementations
7
+ * Maps fully-qualified resource kinds to their controller implementations.
8
+ *
9
+ * Controllers are keyed by `(kind, runtimeFingerprint)` so that two
10
+ * `Telo.Import`s of the same library with different `runtime:` selections
11
+ * each get their own cached controller instance — the first winner does not
12
+ * lock out the second. Definitions remain kind-only; only the loaded
13
+ * controller instance is policy-scoped.
7
14
  */
8
15
  export class ControllerRegistry {
9
- private controllersByKind: Map<string, ControllerInstance> = new Map();
16
+ private controllersByKind: Map<string, Map<string, ControllerInstance>> = new Map();
10
17
  private definitionsByKind: Map<string, ResourceDefinition> = new Map();
11
- private controllerLoaders: Map<string, () => Promise<ControllerInstance>> = new Map();
18
+
12
19
  /**
13
20
  * Register a controller definition
14
21
  */
15
- registerDefinition(
16
- definition: ResourceDefinition,
17
- // baseDir?: string,
18
- // namespace?: string | null,
19
- ): void {
20
- // Construct fully qualified kind: Namespace.Name
21
- // Only add namespace if name is not already qualified (doesn't contain a dot)
22
+ registerDefinition(definition: ResourceDefinition): void {
22
23
  const namespace = definition.metadata.module;
23
- const baseDir = null;
24
24
  const name = definition.metadata.name;
25
25
  const kind = namespace && !name.includes(".") ? `${namespace}.${name}` : name;
26
-
27
26
  this.definitionsByKind.set(kind, definition);
28
-
29
- // If definition has controllers, register loader for them
30
- if (definition.controllers && definition.controllers.length > 0 && baseDir) {
31
- this.registerControllerLoader(kind, definition, baseDir);
32
- }
33
27
  }
34
28
 
35
29
  /**
36
- * Get a controller instance for a kind
37
- * Lazy-loads controller code on first access
38
- * Throws if controller not found
30
+ * Get a controller instance for a (kind, fingerprint) pair. Lookup order:
31
+ * 1. Exact match for the requested fingerprint (the common path).
32
+ * 2. The "default" fingerprint kernel built-ins register here once and
33
+ * should be reachable from any module's fingerprinted lookup.
34
+ * 3. The first registered entry for this kind, regardless of fingerprint
35
+ * — handles the case of a root-context resource referencing a kind
36
+ * that an import loaded under its own runtime selection.
37
+ *
38
+ * Throws `ERR_CONTROLLER_NOT_LOADED` on full miss.
39
39
  */
40
- getController(kind: string): ControllerInstance {
41
- // Return cached instance if available
42
- if (this.controllersByKind.has(kind)) {
43
- return this.controllersByKind.get(kind)!;
40
+ getController(kind: string, fingerprint: string = DEFAULT_FINGERPRINT): ControllerInstance {
41
+ const cached = this.lookup(kind, fingerprint);
42
+ if (cached) {
43
+ return cached;
44
44
  }
45
-
46
- // Load controller if loader is registered
47
- // const loader = this.controllerLoaders.get(kind);
48
- // if (loader) {
49
- // const controller = await loader();
50
- // this.controllersByKind.set(kind, controller);
51
- // return controller;
52
- // }
53
- return {
54
- schema: { type: "object", additionalProperties: false },
55
- };
56
- // throw new Error(`No controller registered for kind: ${kind}`);
45
+ throw new RuntimeError(
46
+ "ERR_CONTROLLER_NOT_LOADED",
47
+ `No controller loaded for kind "${kind}" (runtime fingerprint "${fingerprint}"). The kind's Telo.Definition must init before its controller is consulted.`,
48
+ );
57
49
  }
58
50
 
59
51
  /**
60
- * Safe get - returns undefined if controller not found
52
+ * Safe get - returns undefined if controller not found. Same fallback
53
+ * order as `getController`.
61
54
  */
62
- getControllerOrUndefined(kind: string): ControllerInstance | undefined {
63
- // Return cached instance if available
64
- if (this.controllersByKind.has(kind)) {
65
- return this.controllersByKind.get(kind);
66
- }
67
- return undefined;
55
+ getControllerOrUndefined(
56
+ kind: string,
57
+ fingerprint: string = DEFAULT_FINGERPRINT,
58
+ ): ControllerInstance | undefined {
59
+ return this.lookup(kind, fingerprint);
60
+ }
61
+
62
+ private lookup(kind: string, fingerprint: string): ControllerInstance | undefined {
63
+ const byFp = this.controllersByKind.get(kind);
64
+ if (!byFp) return undefined;
65
+ return (
66
+ byFp.get(fingerprint) ??
67
+ byFp.get(DEFAULT_FINGERPRINT) ??
68
+ byFp.values().next().value
69
+ );
68
70
  }
69
71
 
70
72
  /**
71
- * Check if a controller exists for this kind (definition or directly registered)
73
+ * Check if any controller exists for this kind (any fingerprint, or just a definition).
72
74
  */
73
75
  hasController(kind: string): boolean {
74
76
  return this.controllersByKind.has(kind) || this.definitionsByKind.has(kind);
@@ -88,29 +90,27 @@ export class ControllerRegistry {
88
90
  return Array.from(this.definitionsByKind.keys());
89
91
  }
90
92
 
91
- getControllerKinds(): string[] {
92
- return Array.from(this.controllersByKind.keys());
93
- }
94
-
95
93
  /**
96
- * Create a resource instance using its controller
94
+ * Distinct kinds with at least one registered controller. Used by the boot
95
+ * register-hook loop, which fires once per kind regardless of fingerprint.
97
96
  */
98
- async create(kind: string, resource: RuntimeResource, ctx: any): Promise<any | null> {
99
- const controller = this.getController(kind);
100
- if (!controller || !controller.create) {
101
- return null;
102
- }
103
- return controller.create(resource, ctx);
97
+ getControllerKinds(): string[] {
98
+ return Array.from(this.controllersByKind.keys());
104
99
  }
105
100
 
106
101
  /**
107
- * Register a controller for a kind
102
+ * Register a controller for a (kind, fingerprint). Multiple registrations
103
+ * for the same kind with different fingerprints coexist; same fingerprint
104
+ * overwrites the prior entry.
108
105
  */
109
- registerController(kind: string, controller: ControllerInstance): void {
106
+ registerController(
107
+ kind: string,
108
+ controller: ControllerInstance,
109
+ fingerprint: string = DEFAULT_FINGERPRINT,
110
+ ): void {
110
111
  if (!this.definitionsByKind.has(kind)) {
111
112
  throw new Error(`Cannot register controller for kind ${kind} without definition`);
112
113
  }
113
- // Ensure controller has schema from definition
114
114
  const definition = this.definitionsByKind.get(kind);
115
115
  const wrappedController: ControllerInstance = {
116
116
  ...controller,
@@ -118,74 +118,11 @@ export class ControllerRegistry {
118
118
  inputType: controller.inputType,
119
119
  outputType: controller.outputType,
120
120
  };
121
- this.controllersByKind.set(kind, wrappedController);
122
- }
123
-
124
- /**
125
- * Private: Register controller loader
126
- */
127
- private registerControllerLoader(
128
- kind: string,
129
- definition: ResourceDefinition,
130
- moduleDir: string,
131
- ): void {
132
- const controllerDef = definition.controllers?.[0]; // Use first matching controller for now
133
- if (!controllerDef) return;
134
-
135
- this.controllerLoaders.set(kind, async () => {
136
- const modulePath = path.resolve(moduleDir, controllerDef.entry);
137
- const moduleRuntime = await import(modulePath);
138
- const exported = moduleRuntime.default || moduleRuntime.Module || moduleRuntime;
139
-
140
- const registerFn =
141
- typeof moduleRuntime.register === "function"
142
- ? moduleRuntime.register
143
- : typeof exported === "function" && !this.isModuleClass(exported)
144
- ? exported
145
- : null;
146
-
147
- const createFn =
148
- typeof moduleRuntime.create === "function"
149
- ? moduleRuntime.create
150
- : typeof exported?.create === "function"
151
- ? exported.create
152
- : null;
153
-
154
- const executeFn =
155
- typeof moduleRuntime.execute === "function"
156
- ? moduleRuntime.execute
157
- : typeof exported?.execute === "function"
158
- ? exported.execute
159
- : null;
160
-
161
- const compileFn =
162
- typeof moduleRuntime.compile === "function"
163
- ? moduleRuntime.compile
164
- : typeof exported?.compile === "function"
165
- ? exported.compile
166
- : null;
167
-
168
- if (!registerFn && !executeFn && !createFn && !compileFn) {
169
- throw new Error(`Controller for "${kind}" exports no usable handlers`);
170
- }
171
-
172
- if (!definition.schema) {
173
- throw new Error(`Definition for "${kind}" does not have schema`);
174
- }
175
-
176
- return {
177
- register: registerFn ?? undefined,
178
- create: createFn ?? undefined,
179
- execute: executeFn ?? undefined,
180
- compile: compileFn ?? undefined,
181
- schema: definition.schema,
182
- };
183
- });
184
- }
185
-
186
- private isModuleClass(obj: any): boolean {
187
- return (
188
- typeof obj === "function" && (obj.name === "Controller" || obj.toString().includes("class"))
189
- );
121
+ let byFp = this.controllersByKind.get(kind);
122
+ if (!byFp) {
123
+ byFp = new Map();
124
+ this.controllersByKind.set(kind, byFp);
125
+ }
126
+ byFp.set(fingerprint, wrappedController);
190
127
  }
191
128
  }
@@ -2,6 +2,7 @@ import { DiagnosticSeverity, StaticAnalyzer } from "@telorun/analyzer";
2
2
  import type { ResourceContext, ResourceInstance } from "@telorun/sdk";
3
3
  import { RuntimeError } from "@telorun/sdk";
4
4
  import { ModuleContext } from "../../module-context.js";
5
+ import { isDefaultPolicy, normalizeRuntime } from "../../runtime-registry.js";
5
6
 
6
7
  const importAnalysisCache = new Map<
7
8
  string,
@@ -24,10 +25,18 @@ export async function create(resource: any, ctx: ResourceContext): Promise<Resou
24
25
 
25
26
  const moduleSource: string = resource.module ?? resource.source;
26
27
 
28
+ // Resolve relative source paths against the manifest's OWN file URL (stamped onto
29
+ // `metadata.source` by the loader), not the parent module context's source. When a
30
+ // Telo.Library imports another library via a relative path, that path is written
31
+ // relative to the declaring library's file — not relative to whatever root manifest
32
+ // happens to have imported the chain. Falling back to ctx.moduleContext.source for
33
+ // manifests that somehow lack a stamped source keeps the old behaviour for edge cases.
34
+ const base = (resource.metadata?.source as string | undefined) ?? ctx.moduleContext.source;
35
+
27
36
  // Validate the imported module and all its transitive imports before loading for runtime.
28
37
  // loadManifests() follows Telo.Import chains so definitions from sub-imports are present,
29
38
  // preventing false UNDEFINED_KIND errors for kinds that come from the module's own imports.
30
- const resolvedUrl = resolveImportSource(moduleSource, ctx.moduleContext.source);
39
+ const resolvedUrl = resolveImportSource(moduleSource, base);
31
40
  const analysisManifests = await ctx.loadManifests(resolvedUrl);
32
41
  const signature = JSON.stringify(analysisManifests);
33
42
  const cached = importAnalysisCache.get(resolvedUrl);
@@ -88,6 +97,20 @@ export async function create(resource: any, ctx: ResourceContext): Promise<Resou
88
97
  ),
89
98
  );
90
99
 
100
+ // Stamp the resolved controller policy on the child only when the import
101
+ // specifies a `runtime:` field that resolves to something other than the
102
+ // canonical default. Omitted, `auto`, and any list that normalizes to the
103
+ // default shape (e.g. `[nodejs, any]` on the Node.js kernel) all leave the
104
+ // child policy unstamped — they are equivalent forms of "no preference"
105
+ // and stamping would make them observably distinct from the omitted form
106
+ // for no behavioral gain.
107
+ if (resource.runtime !== undefined) {
108
+ const policy = normalizeRuntime(resource.runtime as string | string[]);
109
+ if (!isDefaultPolicy(policy)) {
110
+ (child as ModuleContext).setControllerPolicy(policy);
111
+ }
112
+ }
113
+
91
114
  for (const manifest of manifests) {
92
115
  child.registerManifest(manifest);
93
116
  }
@@ -153,6 +176,12 @@ export const schema = {
153
176
  source: { type: "string" },
154
177
  variables: { type: "object" },
155
178
  secrets: { type: "object" },
179
+ runtime: {
180
+ oneOf: [
181
+ { type: "string" },
182
+ { type: "array", items: { type: "string" } },
183
+ ],
184
+ },
156
185
  },
157
186
  required: ["metadata", "source"],
158
187
  additionalProperties: false,
@@ -0,0 +1,56 @@
1
+ import type {
2
+ ControllerContext,
3
+ ResourceContext,
4
+ ResourceInstance,
5
+ RuntimeResource,
6
+ } from "@telorun/sdk";
7
+ import { formatAjvErrors, validateResourceAbstract } from "../../manifest-schemas.js";
8
+
9
+ type ResourceAbstractResource = RuntimeResource & {
10
+ kind: "Telo.Abstract";
11
+ metadata: {
12
+ [key: string]: any;
13
+ name: string;
14
+ module?: string;
15
+ };
16
+ schema?: Record<string, any>;
17
+ capability?: string;
18
+ };
19
+
20
+ /**
21
+ * Telo.Abstract meta-controller.
22
+ *
23
+ * An abstract declares a contract that other definitions may implement via `extends`
24
+ * (or the legacy `capability: <AbstractKind>` overload). It has no runtime instance
25
+ * of its own and no controller to load — the `init()` just registers the definition
26
+ * with the kernel's ControllerRegistry so `getDefinition(<abstractKind>)` returns it
27
+ * during capability-chain resolution and so `snapshot()` calls from the abstract's
28
+ * extendedBy children can resolve its schema for runtime validation.
29
+ */
30
+ class ResourceAbstract implements ResourceInstance {
31
+ readonly kind: "ResourceAbstract" = "ResourceAbstract";
32
+
33
+ constructor(readonly resource: ResourceAbstractResource) {}
34
+
35
+ async init(ctx: ResourceContext) {
36
+ ctx.registerDefinition(this.resource);
37
+ }
38
+ }
39
+
40
+ export function register(_ctx: ControllerContext): void {
41
+ // Abstract is passive — no registration side-effects.
42
+ }
43
+
44
+ export async function create(resource: any, _ctx: ResourceContext): Promise<ResourceAbstract> {
45
+ if (!validateResourceAbstract(resource)) {
46
+ throw new Error(
47
+ `Invalid Telo.Abstract "${resource.metadata?.name}": ${formatAjvErrors(validateResourceAbstract.errors)}`,
48
+ );
49
+ }
50
+ return new ResourceAbstract(resource as unknown as ResourceAbstractResource);
51
+ }
52
+
53
+ export const schema = {
54
+ type: "object",
55
+ additionalProperties: true,
56
+ };
@@ -48,6 +48,7 @@ class ResourceDefinition implements ResourceInstance {
48
48
  const controllerInstance = await this.controllerLoader.load(
49
49
  this.resource.controllers,
50
50
  this.resource.metadata.source,
51
+ ctx.getControllerPolicy(),
51
52
  );
52
53
  ctx.emit("ControllerLoaded", { schema: controllerInstance.schema });
53
54
  ctx.registerDefinition(this.resource);