@telorun/kernel 0.4.1 → 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.
- package/dist/controller-loader.d.ts +19 -20
- package/dist/controller-loader.d.ts.map +1 -1
- package/dist/controller-loader.js +67 -247
- package/dist/controller-loader.js.map +1 -1
- package/dist/controller-loaders/napi-loader.d.ts +27 -0
- package/dist/controller-loaders/napi-loader.d.ts.map +1 -0
- package/dist/controller-loaders/napi-loader.js +158 -0
- package/dist/controller-loaders/napi-loader.js.map +1 -0
- package/dist/controller-loaders/npm-loader.d.ts +20 -0
- package/dist/controller-loaders/npm-loader.d.ts.map +1 -0
- package/dist/controller-loaders/npm-loader.js +256 -0
- package/dist/controller-loaders/npm-loader.js.map +1 -0
- package/dist/controller-registry.d.ts +30 -20
- package/dist/controller-registry.d.ts.map +1 -1
- package/dist/controller-registry.js +50 -99
- package/dist/controller-registry.js.map +1 -1
- package/dist/controllers/module/import-controller.d.ts +11 -0
- package/dist/controllers/module/import-controller.d.ts.map +1 -1
- package/dist/controllers/module/import-controller.js +28 -1
- package/dist/controllers/module/import-controller.js.map +1 -1
- package/dist/controllers/resource-definition/abstract-controller.d.ts +35 -0
- package/dist/controllers/resource-definition/abstract-controller.d.ts.map +1 -0
- package/dist/controllers/resource-definition/abstract-controller.js +34 -0
- package/dist/controllers/resource-definition/abstract-controller.js.map +1 -0
- package/dist/controllers/resource-definition/resource-definition-controller.d.ts.map +1 -1
- package/dist/controllers/resource-definition/resource-definition-controller.js +1 -1
- package/dist/controllers/resource-definition/resource-definition-controller.js.map +1 -1
- package/dist/kernel.d.ts +1 -1
- package/dist/kernel.d.ts.map +1 -1
- package/dist/kernel.js +35 -14
- package/dist/kernel.js.map +1 -1
- package/dist/manifest-schemas.d.ts +50 -0
- package/dist/manifest-schemas.d.ts.map +1 -1
- package/dist/manifest-schemas.js +31 -0
- package/dist/manifest-schemas.js.map +1 -1
- package/dist/module-context.d.ts +11 -1
- package/dist/module-context.d.ts.map +1 -1
- package/dist/module-context.js +6 -0
- package/dist/module-context.js.map +1 -1
- package/dist/resource-context.d.ts +2 -1
- package/dist/resource-context.d.ts.map +1 -1
- package/dist/resource-context.js +6 -1
- package/dist/resource-context.js.map +1 -1
- package/dist/runtime-registry.d.ts +50 -0
- package/dist/runtime-registry.d.ts.map +1 -0
- package/dist/runtime-registry.js +140 -0
- package/dist/runtime-registry.js.map +1 -0
- package/package.json +3 -3
- package/src/controller-loader.ts +77 -273
- package/src/controller-loaders/napi-loader.ts +191 -0
- package/src/controller-loaders/npm-loader.ts +285 -0
- package/src/controller-registry.ts +66 -129
- package/src/controllers/module/import-controller.ts +30 -1
- package/src/controllers/resource-definition/abstract-controller.ts +56 -0
- package/src/controllers/resource-definition/resource-definition-controller.ts +1 -0
- package/src/kernel.ts +43 -13
- package/src/manifest-schemas.ts +33 -0
- package/src/module-context.ts +22 -1
- package/src/resource-context.ts +8 -1
- 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,
|
|
2
|
-
|
|
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
|
|
16
|
+
private controllersByKind: Map<string, Map<string, ControllerInstance>> = new Map();
|
|
10
17
|
private definitionsByKind: Map<string, ResourceDefinition> = new Map();
|
|
11
|
-
|
|
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
|
-
*
|
|
38
|
-
*
|
|
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
|
-
|
|
42
|
-
if (
|
|
43
|
-
return
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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(
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
99
|
-
|
|
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(
|
|
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.
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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,
|
|
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);
|