@telorun/kernel 0.24.2 → 0.26.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/application-env.d.ts +18 -0
- package/dist/application-env.d.ts.map +1 -1
- package/dist/application-env.js +36 -0
- package/dist/application-env.js.map +1 -1
- package/dist/controller-loader.d.ts +4 -0
- package/dist/controller-loader.d.ts.map +1 -1
- package/dist/controller-loader.js +4 -1
- package/dist/controller-loader.js.map +1 -1
- package/dist/controller-loaders/npm-loader.d.ts +51 -12
- package/dist/controller-loaders/npm-loader.d.ts.map +1 -1
- package/dist/controller-loaders/npm-loader.js +119 -52
- package/dist/controller-loaders/npm-loader.js.map +1 -1
- package/dist/controller-registry.d.ts +9 -0
- package/dist/controller-registry.d.ts.map +1 -1
- package/dist/controller-registry.js +18 -0
- package/dist/controller-registry.js.map +1 -1
- package/dist/controllers/module/import-controller.d.ts.map +1 -1
- package/dist/controllers/module/import-controller.js +50 -28
- package/dist/controllers/module/import-controller.js.map +1 -1
- package/dist/controllers/resource-definition/resource-definition-controller.d.ts.map +1 -1
- package/dist/controllers/resource-definition/resource-definition-controller.js +1 -0
- package/dist/controllers/resource-definition/resource-definition-controller.js.map +1 -1
- package/dist/evaluation-context.d.ts.map +1 -1
- package/dist/evaluation-context.js +13 -5
- package/dist/evaluation-context.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/kernel.d.ts +8 -0
- package/dist/kernel.d.ts.map +1 -1
- package/dist/kernel.js +46 -15
- package/dist/kernel.js.map +1 -1
- package/dist/manifest-sources/analysis-stamp.d.ts +2 -2
- package/dist/manifest-sources/analysis-stamp.d.ts.map +1 -1
- package/dist/manifest-sources/analysis-stamp.js +9 -4
- package/dist/manifest-sources/analysis-stamp.js.map +1 -1
- package/dist/manifest-sources/local-manifest-cache-source.d.ts +13 -3
- package/dist/manifest-sources/local-manifest-cache-source.d.ts.map +1 -1
- package/dist/manifest-sources/local-manifest-cache-source.js +25 -6
- package/dist/manifest-sources/local-manifest-cache-source.js.map +1 -1
- package/dist/module-context.d.ts +56 -7
- package/dist/module-context.d.ts.map +1 -1
- package/dist/module-context.js +121 -10
- package/dist/module-context.js.map +1 -1
- package/dist/resource-context.d.ts +1 -0
- package/dist/resource-context.d.ts.map +1 -1
- package/dist/resource-context.js +3 -0
- package/dist/resource-context.js.map +1 -1
- package/dist/schema-validator.d.ts +8 -1
- package/dist/schema-validator.d.ts.map +1 -1
- package/dist/schema-validator.js +47 -5
- package/dist/schema-validator.js.map +1 -1
- package/package.json +3 -3
- package/src/application-env.ts +37 -0
- package/src/controller-loader.ts +8 -1
- package/src/controller-loaders/npm-loader.ts +136 -59
- package/src/controller-registry.ts +18 -0
- package/src/controllers/module/import-controller.ts +58 -28
- package/src/controllers/resource-definition/resource-definition-controller.ts +1 -0
- package/src/evaluation-context.ts +15 -5
- package/src/index.ts +1 -0
- package/src/kernel.ts +65 -16
- package/src/manifest-sources/analysis-stamp.ts +9 -2
- package/src/manifest-sources/local-manifest-cache-source.ts +29 -4
- package/src/module-context.ts +153 -11
- package/src/resource-context.ts +4 -0
- package/src/schema-validator.ts +49 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@telorun/kernel",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.0",
|
|
4
4
|
"description": "Telo Runtime - A lightweight, polyglot execution host.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"telo",
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"dependencies": {
|
|
48
48
|
"@marcbachmann/cel-js": "^7.6.1",
|
|
49
49
|
"@sinclair/typebox": "^0.34.48",
|
|
50
|
-
"@telorun/analyzer": "0.
|
|
50
|
+
"@telorun/analyzer": "0.23.0",
|
|
51
51
|
"@telorun/templating": "0.8.0",
|
|
52
52
|
"ajv": "^8.17.1",
|
|
53
53
|
"ajv-formats": "^3.0.1",
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
"@types/node": "^20.0.0",
|
|
60
60
|
"typescript": "^5.0.0",
|
|
61
61
|
"vitest": "^2.1.8",
|
|
62
|
-
"@telorun/sdk": "0.
|
|
62
|
+
"@telorun/sdk": "0.26.0"
|
|
63
63
|
},
|
|
64
64
|
"peerDependencies": {
|
|
65
65
|
"@telorun/sdk": "*"
|
package/src/application-env.ts
CHANGED
|
@@ -124,6 +124,43 @@ export function precompileApplicationEnvSchemas(
|
|
|
124
124
|
}
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
/**
|
|
128
|
+
* Build-time cache warm for resource-config validators. The runtime
|
|
129
|
+
* `_createInstance` compiles `controller.schema` — which falls back to the
|
|
130
|
+
* declaring `Telo.Definition`'s own `schema` — to validate every resource's
|
|
131
|
+
* config, then validates inputs/outputs against `inputType` / `outputType`.
|
|
132
|
+
* The analyze-only warm pass stops before instantiation, so without this those
|
|
133
|
+
* validators are absent from the `__validators` cache and the runtime
|
|
134
|
+
* recompiles (and, on a read-only image, fails to persist) them on every boot.
|
|
135
|
+
*
|
|
136
|
+
* Compiling each definition's `schema` (plus any inline `inputType` /
|
|
137
|
+
* `outputType` object schemas) here writes them into the same content-addressed
|
|
138
|
+
* cache the runtime reads, keyed identically because the same schema object is
|
|
139
|
+
* fed to the same `validator.compile`. Definitions whose controller exports its
|
|
140
|
+
* own `schema` (rare) still recompile at runtime — that needs the controller
|
|
141
|
+
* loaded, which the warm pass does not do. Compile failures are swallowed; a
|
|
142
|
+
* genuinely broken schema surfaces through analysis / runtime, not here.
|
|
143
|
+
*/
|
|
144
|
+
export function precompileDefinitionSchemas(
|
|
145
|
+
manifests: Array<Record<string, any>>,
|
|
146
|
+
validator: SchemaValidator,
|
|
147
|
+
): void {
|
|
148
|
+
const compile = (schema: unknown): void => {
|
|
149
|
+
if (!schema || typeof schema !== "object") return;
|
|
150
|
+
try {
|
|
151
|
+
validator.compile(schema as any);
|
|
152
|
+
} catch {
|
|
153
|
+
// Broken schemas are reported by analysis / runtime, not the warm pass.
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
for (const m of manifests) {
|
|
157
|
+
if (m?.kind !== "Telo.Definition") continue;
|
|
158
|
+
compile(m.schema);
|
|
159
|
+
compile(m.inputType);
|
|
160
|
+
compile(m.outputType);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
127
164
|
/**
|
|
128
165
|
* Populate the root Application's `ports` namespace from host environment
|
|
129
166
|
* variables. Mirrors `resolveBlock` but fixes the value type to a port integer
|
package/src/controller-loader.ts
CHANGED
|
@@ -55,6 +55,10 @@ export interface ControllerLoaderOptions {
|
|
|
55
55
|
* (cargo loader has its own per-crate cache and does not need this).
|
|
56
56
|
*/
|
|
57
57
|
entryUrl?: string;
|
|
58
|
+
/** Explicit npm install root (`<cache-root>/npm`), threaded from the kernel's
|
|
59
|
+
* single `resolveCacheRoot`. Overrides the entry-anchored default so a
|
|
60
|
+
* relocated `TELO_CACHE_DIR` is honoured. */
|
|
61
|
+
installRoot?: string;
|
|
58
62
|
}
|
|
59
63
|
|
|
60
64
|
/**
|
|
@@ -82,7 +86,10 @@ export class ControllerLoader {
|
|
|
82
86
|
|
|
83
87
|
constructor(options: ControllerLoaderOptions = {}) {
|
|
84
88
|
this.emit = options.emit;
|
|
85
|
-
this.npmLoader = new NpmControllerLoader({
|
|
89
|
+
this.npmLoader = new NpmControllerLoader({
|
|
90
|
+
entryUrl: options.entryUrl,
|
|
91
|
+
installRoot: options.installRoot,
|
|
92
|
+
});
|
|
86
93
|
this.napiLoader = new NapiControllerLoader();
|
|
87
94
|
this.bundleLoader = new BundleControllerLoader();
|
|
88
95
|
}
|
|
@@ -97,6 +97,14 @@ export interface NpmControllerLoaderOptions {
|
|
|
97
97
|
* `pkg-name` cli command resolves it from its argument.
|
|
98
98
|
*/
|
|
99
99
|
entryUrl?: string;
|
|
100
|
+
/**
|
|
101
|
+
* Explicit install root, threaded from the kernel's single `resolveCacheRoot`
|
|
102
|
+
* (`<cache-root>/npm`). When set it overrides the entry-anchored
|
|
103
|
+
* `computeInstallRoot`, so a relocated `TELO_CACHE_DIR` (e.g. a prebuilt
|
|
104
|
+
* image baking deps at `/telo-cache`) is honoured without this loader reading
|
|
105
|
+
* the env itself.
|
|
106
|
+
*/
|
|
107
|
+
installRoot?: string;
|
|
100
108
|
}
|
|
101
109
|
|
|
102
110
|
/**
|
|
@@ -104,14 +112,17 @@ export interface NpmControllerLoaderOptions {
|
|
|
104
112
|
* local entry manifest (`file://` URL or bare path) the root lives at
|
|
105
113
|
* `<entry-manifest-dir>/.telo/npm/`; for an HTTP(S) entry URL it lives in a
|
|
106
114
|
* user-level cache keyed by `sha256(entryUrl)` (see `computeInstallRoot`).
|
|
107
|
-
* Every controller — registry tag or `local_path` — is installed
|
|
108
|
-
* `npm install <spec
|
|
109
|
-
* `<root>/node_modules/<
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
+
* Every controller — registry tag or `local_path` — is installed under a
|
|
116
|
+
* version-scoped npm alias (`npm install <alias>@<spec>`, see `installAlias`)
|
|
117
|
+
* into this root, then imported from `<root>/node_modules/<alias>`. The alias
|
|
118
|
+
* encodes `name@version`, so a graph that references one package at two
|
|
119
|
+
* versions keeps both in the single flat `node_modules` instead of the last
|
|
120
|
+
* `--save` clobbering the first. This still collapses two parallel module
|
|
121
|
+
* realms (kernel-side @telorun/sdk vs. controller-side @telorun/sdk) into one:
|
|
122
|
+
* `@telorun/sdk` is exempt from aliasing and wired in as a `file:` dep under
|
|
123
|
+
* its real name, npm/pnpm hoist a single copy, and Node's ESM resolver follows
|
|
124
|
+
* it to the same realpath as the kernel — so `Stream` (and any other
|
|
125
|
+
* class-identity-sensitive type) has a single constructor across the process.
|
|
115
126
|
*
|
|
116
127
|
* Per-kernel state lives on each `NpmControllerLoader` instance (one is
|
|
117
128
|
* constructed per `ControllerLoader`, which is itself constructed per
|
|
@@ -124,6 +135,8 @@ export interface NpmControllerLoaderOptions {
|
|
|
124
135
|
*/
|
|
125
136
|
export class NpmControllerLoader {
|
|
126
137
|
private readonly entryUrl?: string;
|
|
138
|
+
/** Threaded install root (`<cache-root>/npm`); overrides `computeInstallRoot`. */
|
|
139
|
+
private readonly installRootOverride?: string;
|
|
127
140
|
|
|
128
141
|
/**
|
|
129
142
|
* Per-process cache of "this controller's package + version is already
|
|
@@ -161,6 +174,7 @@ export class NpmControllerLoader {
|
|
|
161
174
|
|
|
162
175
|
constructor(options: NpmControllerLoaderOptions = {}) {
|
|
163
176
|
this.entryUrl = options.entryUrl;
|
|
177
|
+
this.installRootOverride = options.installRoot;
|
|
164
178
|
}
|
|
165
179
|
|
|
166
180
|
async load(purl: string, baseUri: string): Promise<NpmLoadResult> {
|
|
@@ -169,17 +183,21 @@ export class NpmControllerLoader {
|
|
|
169
183
|
throw new Error(`Invalid PURL '${purl}': missing package name`);
|
|
170
184
|
}
|
|
171
185
|
const packageName = parsed.namespace ? `${parsed.namespace}/${parsed.name}` : parsed.name;
|
|
186
|
+
const version = parsed.version ?? null;
|
|
187
|
+
// Version-scope the install under an alias so the same package at two
|
|
188
|
+
// versions can coexist in one flat node_modules (see installAlias).
|
|
189
|
+
const alias = installAlias(packageName, version);
|
|
172
190
|
|
|
173
191
|
const installRoot = await this.ensureInstallRoot();
|
|
174
192
|
const resolved = await resolveInstallSpec(parsed, packageName, baseUri);
|
|
175
193
|
const source = await this.installPackage(
|
|
176
194
|
installRoot,
|
|
177
|
-
|
|
195
|
+
alias,
|
|
178
196
|
resolved.spec,
|
|
179
197
|
resolved.kind,
|
|
180
|
-
|
|
198
|
+
version,
|
|
181
199
|
);
|
|
182
|
-
const instance = await loadFromInstall(installRoot,
|
|
200
|
+
const instance = await loadFromInstall(installRoot, alias, parsed.subpath ?? null, purl);
|
|
183
201
|
return { instance, source };
|
|
184
202
|
}
|
|
185
203
|
|
|
@@ -212,7 +230,7 @@ export class NpmControllerLoader {
|
|
|
212
230
|
);
|
|
213
231
|
}
|
|
214
232
|
const entryUrlStr = this.entryUrl;
|
|
215
|
-
const installRoot = computeInstallRoot(entryUrlStr);
|
|
233
|
+
const installRoot = this.installRootOverride ?? computeInstallRoot(entryUrlStr);
|
|
216
234
|
|
|
217
235
|
// Build the install-root package.json: kernel-runtime deps as `file:` refs
|
|
218
236
|
// pointing at the kernel-side realpath. Modules declare these names as
|
|
@@ -296,6 +314,11 @@ export class NpmControllerLoader {
|
|
|
296
314
|
installRoot: string,
|
|
297
315
|
dependencies: Record<string, string>,
|
|
298
316
|
): Promise<void> {
|
|
317
|
+
// `dependencies` is only the realm-collapse deps (`@telorun/sdk`), keyed by
|
|
318
|
+
// real `name@spec`. Controllers, by contrast, key `installedSpecs` by their
|
|
319
|
+
// version-scoped alias in `installPackage`. The two key spaces are
|
|
320
|
+
// intentionally disjoint — realm-collapse names are wired in here and never
|
|
321
|
+
// flow through `installPackage`, so an alias and a `name@spec` never clash.
|
|
299
322
|
for (const [name, spec] of Object.entries(dependencies)) {
|
|
300
323
|
this.installedSpecs.add(`${name}@${spec}`);
|
|
301
324
|
}
|
|
@@ -303,11 +326,14 @@ export class NpmControllerLoader {
|
|
|
303
326
|
}
|
|
304
327
|
|
|
305
328
|
/**
|
|
306
|
-
* Install one controller
|
|
307
|
-
*
|
|
308
|
-
* `npm install` call. If the
|
|
309
|
-
*
|
|
329
|
+
* Install one controller into the existing root under its version-scoped
|
|
330
|
+
* `alias` folder. Single-flight per alias within the process; cross-process
|
|
331
|
+
* safety is the fs-lock around the `npm install` call. If the alias is
|
|
332
|
+
* already installed (right version on disk, or a matching `file:` record),
|
|
333
|
+
* skip — reusing the previous install.
|
|
310
334
|
*
|
|
335
|
+
* `spec` is the source half of the install (`npm:<name>@<version>` or
|
|
336
|
+
* `file:<abs>`); the package manager is invoked with `<alias>@<spec>`.
|
|
311
337
|
* `kind` distinguishes a `local_path` source (loader synthesized `file:`
|
|
312
338
|
* spec) from a registry tag. The CLI silences progress for both `cache`
|
|
313
339
|
* and `local`; `npm-install` is the only event surfaced. Folding this
|
|
@@ -316,12 +342,12 @@ export class NpmControllerLoader {
|
|
|
316
342
|
*/
|
|
317
343
|
private async installPackage(
|
|
318
344
|
installRoot: string,
|
|
319
|
-
|
|
345
|
+
alias: string,
|
|
320
346
|
spec: string,
|
|
321
347
|
kind: SpecKind,
|
|
322
348
|
requestedVersion: string | null,
|
|
323
349
|
): Promise<NpmResolveSource> {
|
|
324
|
-
const cacheKey =
|
|
350
|
+
const cacheKey = alias;
|
|
325
351
|
if (this.installedSpecs.has(cacheKey)) return "cache";
|
|
326
352
|
|
|
327
353
|
const inFlight = this.inFlight.get(cacheKey);
|
|
@@ -330,17 +356,20 @@ export class NpmControllerLoader {
|
|
|
330
356
|
return "cache";
|
|
331
357
|
}
|
|
332
358
|
|
|
333
|
-
|
|
359
|
+
// The package is installed under its version-scoped alias, so its folder
|
|
360
|
+
// name in node_modules is the alias, not the bare package name.
|
|
361
|
+
const targetPath = path.join(installRoot, "node_modules", alias);
|
|
334
362
|
|
|
335
363
|
// Registry fast path: compare against the installed package's own
|
|
336
|
-
// `package.json` version field.
|
|
337
|
-
//
|
|
338
|
-
//
|
|
339
|
-
// never matches and every fresh `NpmControllerLoader`
|
|
340
|
-
// `Telo.Definition.init`) would fall through to a no-op but ~200ms
|
|
364
|
+
// `package.json` version field. The recorded dep spec can't be used here
|
|
365
|
+
// because npm rewrites registry specs on `--save` — we pass
|
|
366
|
+
// `<alias>@npm:@scope/pkg@0.3.4`, npm writes `npm:@scope/pkg@^0.3.4` — so a
|
|
367
|
+
// string comparison never matches and every fresh `NpmControllerLoader`
|
|
368
|
+
// (one per `Telo.Definition.init`) would fall through to a no-op but ~200ms
|
|
341
369
|
// `npm install`. Reading the installed package.json sidesteps that
|
|
342
|
-
// entirely: if the requested PURL version equals what's on disk
|
|
343
|
-
// already have the right thing.
|
|
370
|
+
// entirely: if the requested PURL version equals what's on disk under this
|
|
371
|
+
// alias, we already have the right thing. Because the alias encodes the
|
|
372
|
+
// version, a present alias folder is always the requested version.
|
|
344
373
|
//
|
|
345
374
|
// Ranges (`^1.0.0`, `~2.3.0`) fall through to a real `npm install` —
|
|
346
375
|
// they're rare in PURLs (which typically pin) and a proper range check
|
|
@@ -354,37 +383,47 @@ export class NpmControllerLoader {
|
|
|
354
383
|
this.installedSpecs.add(cacheKey);
|
|
355
384
|
return "cache";
|
|
356
385
|
}
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
386
|
+
} else {
|
|
387
|
+
// Local (`file:`) spec fast path: consult the in-process snapshot of the
|
|
388
|
+
// install root's `dependencies` map (seeded by `materializeInstallRoot`).
|
|
389
|
+
// Normalize because npm rewrites absolute `file:` deps to relative paths
|
|
390
|
+
// inside the install root's `package.json` — the loader passes the
|
|
391
|
+
// absolute path, the on-disk record is `file:../../foo`.
|
|
392
|
+
const cachedSpec = this.rootDeps[alias];
|
|
393
|
+
if (
|
|
394
|
+
cachedSpec !== undefined &&
|
|
395
|
+
normalizeFileSpec(cachedSpec, installRoot) === normalizeFileSpec(spec, installRoot) &&
|
|
396
|
+
(await pathExists(targetPath))
|
|
397
|
+
) {
|
|
398
|
+
this.installedSpecs.add(cacheKey);
|
|
399
|
+
return "cache";
|
|
400
|
+
}
|
|
372
401
|
}
|
|
373
402
|
|
|
374
403
|
const work = (async () => {
|
|
375
404
|
await withInstallLock(installRoot, async () => {
|
|
376
405
|
// Re-check inside the lock: a peer process may have installed the
|
|
377
|
-
// spec between the fast-path miss and our acquisition.
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
406
|
+
// spec between the fast-path miss and our acquisition.
|
|
407
|
+
if (kind === "registry") {
|
|
408
|
+
const installedVersion = await readInstalledVersion(targetPath);
|
|
409
|
+
if (
|
|
410
|
+
installedVersion !== null &&
|
|
411
|
+
(requestedVersion === null || requestedVersion === installedVersion)
|
|
412
|
+
) {
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
} else {
|
|
416
|
+
// Normalize the on-disk record to absolute form before comparing —
|
|
417
|
+
// npm rewrites `file:` deps to be relative to the install root.
|
|
418
|
+
const lockedSpec = await readDepSpec(installRoot, alias);
|
|
419
|
+
if (
|
|
420
|
+
lockedSpec !== undefined &&
|
|
421
|
+
normalizeFileSpec(lockedSpec, installRoot) === normalizeFileSpec(spec, installRoot) &&
|
|
422
|
+
(await pathExists(targetPath))
|
|
423
|
+
) {
|
|
424
|
+
this.rootDeps[alias] = lockedSpec;
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
388
427
|
}
|
|
389
428
|
|
|
390
429
|
await runPackageManager(installRoot, [
|
|
@@ -394,14 +433,17 @@ export class NpmControllerLoader {
|
|
|
394
433
|
"--silent",
|
|
395
434
|
...PEER_INSTALL_FLAGS,
|
|
396
435
|
"--save",
|
|
397
|
-
spec
|
|
436
|
+
// `<alias>@<source-spec>` installs the package under the alias folder
|
|
437
|
+
// (`npm:` for registry, `file:` for local) so multiple versions of
|
|
438
|
+
// one package name coexist in the single install root.
|
|
439
|
+
`${alias}@${spec}`,
|
|
398
440
|
]);
|
|
399
441
|
// Re-read what npm actually wrote (it normalizes `file:` paths to
|
|
400
442
|
// be relative to the install root). Caching the spec in its on-disk
|
|
401
443
|
// form keeps subsequent fast-path comparisons stable.
|
|
402
|
-
const written = await readDepSpec(installRoot,
|
|
403
|
-
if (written !== undefined) this.rootDeps[
|
|
404
|
-
else this.rootDeps[
|
|
444
|
+
const written = await readDepSpec(installRoot, alias);
|
|
445
|
+
if (written !== undefined) this.rootDeps[alias] = written;
|
|
446
|
+
else this.rootDeps[alias] = spec;
|
|
405
447
|
});
|
|
406
448
|
})();
|
|
407
449
|
|
|
@@ -693,6 +735,35 @@ function computeInstallRoot(entryUrl: string): string {
|
|
|
693
735
|
);
|
|
694
736
|
}
|
|
695
737
|
|
|
738
|
+
/**
|
|
739
|
+
* Version-qualified install alias for a controller package. The kernel's single
|
|
740
|
+
* flat install root can hold only one folder per package name, but a manifest
|
|
741
|
+
* graph may legitimately reference the same package at multiple versions (e.g.
|
|
742
|
+
* a library pins `@telorun/mcp-client@0.3.1` while the app uses `0.4.0`).
|
|
743
|
+
* Installing each `name@version` under a distinct npm alias
|
|
744
|
+
* (`npm install <alias>@npm:<name>@<version>`) lets every version coexist in
|
|
745
|
+
* one `node_modules`, mirroring the per-(name, version) identity of a Telo
|
|
746
|
+
* module singleton. `@telorun/sdk` is intentionally NOT routed through here —
|
|
747
|
+
* it stays under its real name so realm-collapse hoists a single copy across
|
|
748
|
+
* the kernel/controller boundary (see REALM_COLLAPSE_NAMES).
|
|
749
|
+
*
|
|
750
|
+
* The result is a valid unscoped npm package name: the scope `@` is dropped,
|
|
751
|
+
* `/` becomes `__`, and any character outside npm's name grammar is replaced
|
|
752
|
+
* with `-`. Because that sanitization is lossy (e.g. build metadata `1.0.0+x`
|
|
753
|
+
* and prerelease `1.0.0-x` both fold to `1.0.0-x`, and a `__` already in a
|
|
754
|
+
* package name overlaps the scope separator), the alias ends with a short hash
|
|
755
|
+
* of the *exact* `name@version` — so two distinct pairs can never collide onto
|
|
756
|
+
* one folder regardless of how their readable prefixes sanitize. The readable
|
|
757
|
+
* prefix is kept purely so the install tree is greppable.
|
|
758
|
+
*/
|
|
759
|
+
function installAlias(packageName: string, version: string | null): string {
|
|
760
|
+
const prefix = `${packageName.replace(/^@/, "").replace(/\//g, "__")}__${version ?? "latest"}`
|
|
761
|
+
.replace(/[^a-zA-Z0-9._-]/g, "-")
|
|
762
|
+
.toLowerCase();
|
|
763
|
+
const digest = sha256(`${packageName}@${version ?? ""}`).slice(0, 8);
|
|
764
|
+
return `${prefix}__${digest}`;
|
|
765
|
+
}
|
|
766
|
+
|
|
696
767
|
/**
|
|
697
768
|
* Normalize a `file:` spec to an absolute path so two specs that point at the
|
|
698
769
|
* same source — one absolute (what the loader synthesizes from `local_path`)
|
|
@@ -770,7 +841,10 @@ async function resolveInstallSpec(
|
|
|
770
841
|
return { kind: "local", spec: `file:${absolutePath}`, absolutePath };
|
|
771
842
|
}
|
|
772
843
|
}
|
|
773
|
-
|
|
844
|
+
// `npm:` source spec so the caller can install it under a version-scoped
|
|
845
|
+
// alias (`<alias>@npm:<name>@<version>`); both versions of one package name
|
|
846
|
+
// then coexist in the single flat install root.
|
|
847
|
+
const spec = parsed.version ? `npm:${packageName}@${parsed.version}` : `npm:${packageName}`;
|
|
774
848
|
return { kind: "registry", spec };
|
|
775
849
|
}
|
|
776
850
|
|
|
@@ -782,11 +856,13 @@ async function resolveInstallSpec(
|
|
|
782
856
|
*/
|
|
783
857
|
async function loadFromInstall(
|
|
784
858
|
installRoot: string,
|
|
785
|
-
|
|
859
|
+
alias: string,
|
|
786
860
|
subpath: string | null,
|
|
787
861
|
purl: string,
|
|
788
862
|
): Promise<ControllerInstance> {
|
|
789
|
-
|
|
863
|
+
// The package lives under its version-scoped alias folder (see installAlias),
|
|
864
|
+
// not its bare scoped name.
|
|
865
|
+
const packageRoot = path.join(installRoot, "node_modules", alias);
|
|
790
866
|
const entry = subpath ? `./${subpath}` : ".";
|
|
791
867
|
const entryFile = await resolvePackageEntry(packageRoot, entry);
|
|
792
868
|
// ESM dynamic `import()` accepts either a relative specifier or a `file://`
|
|
@@ -937,6 +1013,7 @@ function resolveExportTargetValue(
|
|
|
937
1013
|
* Not part of the kernel's public API — consumers should not import these.
|
|
938
1014
|
*/
|
|
939
1015
|
export const __testing__ = {
|
|
1016
|
+
installAlias,
|
|
940
1017
|
normalizeFileSpec,
|
|
941
1018
|
resolvePackageExportTarget,
|
|
942
1019
|
resolveExportTargetValue,
|
|
@@ -98,6 +98,24 @@ export class ControllerRegistry {
|
|
|
98
98
|
return Array.from(this.controllersByKind.keys());
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Distinct controller `schema` objects across all registered kinds (one per
|
|
103
|
+
* kind, default fingerprint preferred). Used by the build-time validator warm
|
|
104
|
+
* to pre-compile the framework/builtin controller schemas (`Telo.Import`,
|
|
105
|
+
* `Telo.Definition`, the module controller, …) the runtime validates
|
|
106
|
+
* resources against — module-defined kinds aren't registered here until
|
|
107
|
+
* instantiation, so those are warmed from the static manifests instead.
|
|
108
|
+
*/
|
|
109
|
+
getControllerSchemas(): object[] {
|
|
110
|
+
const schemas: object[] = [];
|
|
111
|
+
for (const byFp of this.controllersByKind.values()) {
|
|
112
|
+
const controller = byFp.get(DEFAULT_FINGERPRINT) ?? byFp.values().next().value;
|
|
113
|
+
const schema = controller?.schema;
|
|
114
|
+
if (schema && typeof schema === "object") schemas.push(schema);
|
|
115
|
+
}
|
|
116
|
+
return schemas;
|
|
117
|
+
}
|
|
118
|
+
|
|
101
119
|
/**
|
|
102
120
|
* Register a controller for a (kind, fingerprint). Multiple registrations
|
|
103
121
|
* for the same kind with different fingerprints coexist; same fingerprint
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AnalysisRegistry, DiagnosticSeverity, StaticAnalyzer } from "@telorun/analyzer";
|
|
1
|
+
import { AnalysisRegistry, DiagnosticSeverity, parseExportEntry, StaticAnalyzer } from "@telorun/analyzer";
|
|
2
2
|
import type { ResourceInstance } from "@telorun/sdk";
|
|
3
3
|
import { RuntimeError } from "@telorun/sdk";
|
|
4
4
|
import type { BuiltinControllerContext } from "../../internal-context.js";
|
|
@@ -108,12 +108,27 @@ export async function create(
|
|
|
108
108
|
validateRequiredInputs(moduleManifest.variables ?? {}, resource.variables ?? {}, "variables");
|
|
109
109
|
validateRequiredInputs(moduleManifest.secrets ?? {}, resource.secrets ?? {}, "secrets");
|
|
110
110
|
|
|
111
|
-
//
|
|
112
|
-
//
|
|
111
|
+
// Evaluate the import's variables/secrets ONCE against the IMPORTER's config
|
|
112
|
+
// scope, instead of baking the raw compiled-value objects verbatim. Resolution
|
|
113
|
+
// is eager and per-hop: each importer resolves its child's inputs from its own
|
|
114
|
+
// already-settled config, so config flows app -> lib -> lib at any nesting depth
|
|
115
|
+
// and a leaf reads `variables.X` as an O(1) concrete lookup with no chain-walk.
|
|
116
|
+
// The single concrete result seeds both the child scope and the snapshot value-
|
|
117
|
+
// flow surface, so they can never diverge.
|
|
118
|
+
//
|
|
119
|
+
// NOTE: `expandValue` evaluates against the importer's FULL context (variables /
|
|
120
|
+
// secrets, plus env/resources for a root app). The config-only import contract —
|
|
121
|
+
// no resources/env/ports in import inputs — is enforced by the analyzer, not here;
|
|
122
|
+
// a `${{ resources.X }}` slipping past analysis (skipValidation / programmatic
|
|
123
|
+
// load) would evaluate at runtime rather than being rejected.
|
|
124
|
+
const importVariables =
|
|
125
|
+
(ctx.expandValue(resource.variables, {}) as Record<string, unknown>) ?? {};
|
|
126
|
+
const importSecrets =
|
|
127
|
+
(ctx.expandValue(resource.secrets, {}) as Record<string, unknown>) ?? {};
|
|
113
128
|
const childCtx = new ModuleContext(
|
|
114
129
|
ctx.moduleContext.source,
|
|
115
|
-
|
|
116
|
-
|
|
130
|
+
importVariables,
|
|
131
|
+
importSecrets,
|
|
117
132
|
{},
|
|
118
133
|
[],
|
|
119
134
|
ctx.moduleContext.createInstance,
|
|
@@ -157,8 +172,26 @@ export async function create(
|
|
|
157
172
|
// Throws if resources.X is not yet populated — the kernel retry loop catches this and retries.
|
|
158
173
|
// const evaluatedExports: any = child.expand(moduleManifest.exports ?? {});
|
|
159
174
|
|
|
160
|
-
|
|
161
|
-
|
|
175
|
+
// `exports.kinds` entries are a bare kind name (locally defined) or `Alias.Kind` (a re-export
|
|
176
|
+
// of an imported library's kind). `parseExportEntry` (shared with the analyzer) yields
|
|
177
|
+
// `{name, alias?}` — `name` is the exported kind suffix, `alias` (when set) names this
|
|
178
|
+
// library's own import it re-exports from.
|
|
179
|
+
const kindEntries = ((moduleManifest.exports?.kinds ?? []) as string[]).map(parseExportEntry);
|
|
180
|
+
const exportedKindSuffixes = kindEntries.map((k) => k.name);
|
|
181
|
+
// `exports.resources` entries are a bare name (`Db`, a locally-owned export) or a dotted
|
|
182
|
+
// `Alias.Name` (re-export of an imported instance, under name `Name`) — same grammar as
|
|
183
|
+
// `exports.kinds`.
|
|
184
|
+
const exportEntries = ((moduleManifest.exports?.resources ?? []) as unknown[]).map((e) => {
|
|
185
|
+
if (typeof e !== "string") {
|
|
186
|
+
throw new RuntimeError(
|
|
187
|
+
"ERR_INVALID_EXPORT",
|
|
188
|
+
`Library '${targetModule}' exports.resources entries must be plain names ('Name' or ` +
|
|
189
|
+
`'Alias.Name'); the '!ref' tag is not allowed in exports.resources.`,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
return parseExportEntry(e);
|
|
193
|
+
});
|
|
194
|
+
const exportedResourceNames = exportEntries.map((e) => e.name);
|
|
162
195
|
for (const name of exportedResourceNames) {
|
|
163
196
|
if (name === "variables" || name === "secrets") {
|
|
164
197
|
throw new RuntimeError(
|
|
@@ -167,30 +200,22 @@ export async function create(
|
|
|
167
200
|
);
|
|
168
201
|
}
|
|
169
202
|
}
|
|
170
|
-
ctx.registerModuleImport(alias, targetModule,
|
|
203
|
+
ctx.registerModuleImport(alias, targetModule, exportedKindSuffixes);
|
|
171
204
|
|
|
172
205
|
// Publish the child's exported instances to the parent so cross-module `!ref Alias.name`
|
|
173
206
|
// (Phase 5 injection / boot targets) and `${{ resources.Alias.name }}` (CEL value-flow)
|
|
174
|
-
// resolve. The gate is `exports.resources`; the
|
|
175
|
-
// this import's init()
|
|
207
|
+
// resolve. The gate is `exports.resources`; the child's terminal getter is read lazily —
|
|
208
|
+
// it exists after this import's init() built the child's export table. Handing the parent
|
|
209
|
+
// the child's TERMINAL getter (not a wrapper) keeps resolution O(1) across re-export hops.
|
|
176
210
|
(ctx.moduleContext as ModuleContext).registerImportedScope(
|
|
177
211
|
alias,
|
|
178
212
|
exportedResourceNames,
|
|
179
|
-
(name) =>
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
// owning module directly, matching the analyzer's rule in resolve-ref-sentinels.ts. A
|
|
186
|
-
// re-exported foreign kind (authored via another import alias) is a future case with no
|
|
187
|
-
// current consumer and is left verbatim.
|
|
188
|
-
const rawKind = entry.resource.kind as string;
|
|
189
|
-
const kind = rawKind.startsWith("Self.")
|
|
190
|
-
? `${targetModule}.${rawKind.slice("Self.".length)}`
|
|
191
|
-
: rawKind;
|
|
192
|
-
return { kind, instance: entry.instance };
|
|
193
|
-
},
|
|
213
|
+
(name) => childCtx.getTerminalExport(name),
|
|
214
|
+
);
|
|
215
|
+
// Same for kinds: `kind: Alias.Kind` resolves through the child's exported-kind table,
|
|
216
|
+
// covering both locally-defined and transitively re-exported kinds in O(1).
|
|
217
|
+
(ctx.moduleContext as ModuleContext).registerImportedKindScope(alias, (suffix) =>
|
|
218
|
+
childCtx.getExportedKind(suffix),
|
|
194
219
|
);
|
|
195
220
|
|
|
196
221
|
// Return a ResourceInstance whose snapshot() surfaces the exported values under
|
|
@@ -201,19 +226,24 @@ export async function create(
|
|
|
201
226
|
snapshot: async () => {
|
|
202
227
|
const exported: Record<string, unknown> = {};
|
|
203
228
|
for (const name of exportedResourceNames) {
|
|
204
|
-
const inst = childCtx.
|
|
229
|
+
const inst = childCtx.getExported(name)?.instance;
|
|
205
230
|
if (inst && typeof inst.snapshot === "function") {
|
|
206
231
|
exported[name] = await Promise.resolve(inst.snapshot());
|
|
207
232
|
}
|
|
208
233
|
}
|
|
209
234
|
return {
|
|
210
|
-
variables:
|
|
211
|
-
secrets:
|
|
235
|
+
variables: importVariables,
|
|
236
|
+
secrets: importSecrets,
|
|
212
237
|
...exported,
|
|
213
238
|
};
|
|
214
239
|
},
|
|
215
240
|
init: async () => {
|
|
216
241
|
await child.initializeResources();
|
|
242
|
+
// Build this import's flattened export tables now that its own imports are
|
|
243
|
+
// registered (leaves-first), so a re-export (`!ref Alias.name` / `Alias.Kind`)
|
|
244
|
+
// copies the source import's terminal getter / canonical kind by reference —
|
|
245
|
+
// O(1) resolution at any depth.
|
|
246
|
+
childCtx.buildExportTable(exportEntries, kindEntries, targetModule);
|
|
217
247
|
},
|
|
218
248
|
teardown: async () => {
|
|
219
249
|
await child.teardownResources();
|
|
@@ -54,6 +54,7 @@ class ResourceDefinition implements ResourceInstance {
|
|
|
54
54
|
const loader = new ControllerLoader({
|
|
55
55
|
emit: (e) => ctx.emit(e.name, e.payload),
|
|
56
56
|
entryUrl: ctx.getEntryUrl(),
|
|
57
|
+
installRoot: ctx.getInstallRoot(),
|
|
57
58
|
});
|
|
58
59
|
const controllerInstance = await loader.load(
|
|
59
60
|
this.resource.controllers,
|
|
@@ -501,10 +501,13 @@ export class EvaluationContext implements IEvaluationContext {
|
|
|
501
501
|
// the resolved entry for event topics and error messages.
|
|
502
502
|
const effectiveKind = kind || (entry.resource.kind as string);
|
|
503
503
|
|
|
504
|
-
if (
|
|
504
|
+
if (
|
|
505
|
+
typeof entry.instance.invoke !== "function" &&
|
|
506
|
+
typeof entry.instance.run !== "function"
|
|
507
|
+
) {
|
|
505
508
|
throw new RuntimeError(
|
|
506
509
|
"ERR_RESOURCE_NOT_INVOKABLE",
|
|
507
|
-
`Resource ${effectiveKind}.${name} does not have an invoke method`,
|
|
510
|
+
`Resource ${effectiveKind}.${name} does not have an invoke or run method`,
|
|
508
511
|
);
|
|
509
512
|
}
|
|
510
513
|
|
|
@@ -524,10 +527,10 @@ export class EvaluationContext implements IEvaluationContext {
|
|
|
524
527
|
inputs: TInputs,
|
|
525
528
|
ctx?: InvokeContext,
|
|
526
529
|
): Promise<any> {
|
|
527
|
-
if (typeof instance.invoke !== "function") {
|
|
530
|
+
if (typeof instance.invoke !== "function" && typeof instance.run !== "function") {
|
|
528
531
|
throw new RuntimeError(
|
|
529
532
|
"ERR_RESOURCE_NOT_INVOKABLE",
|
|
530
|
-
`Resource ${kind}.${name} does not have an invoke method`,
|
|
533
|
+
`Resource ${kind}.${name} does not have an invoke or run method`,
|
|
531
534
|
);
|
|
532
535
|
}
|
|
533
536
|
return this.runInvoke(kind, name, instance, inputs, ctx);
|
|
@@ -560,8 +563,15 @@ export class EvaluationContext implements IEvaluationContext {
|
|
|
560
563
|
try {
|
|
561
564
|
// Only (re)establish the ALS scope when the token differs from the ambient
|
|
562
565
|
// one — nested invokes that inherited it skip the redundant `run`.
|
|
566
|
+
// A step / boot-target slot may reference a pure Runnable (the schema
|
|
567
|
+
// allows `telo#Runnable` alongside `telo#Invocable`); dispatch it via
|
|
568
|
+
// `run()` — side effects only, no outputs. Prefer `invoke()` when both
|
|
569
|
+
// exist so a dual-capability instance (e.g. Run.Sequence) keeps invoke
|
|
570
|
+
// semantics and returns its `steps`/`outputs`.
|
|
563
571
|
const call = () =>
|
|
564
|
-
|
|
572
|
+
typeof instance.invoke === "function"
|
|
573
|
+
? (instance.invoke as (i: any, c?: InvokeContext) => any)(inputs as any, invokeCtx)
|
|
574
|
+
: (instance.run as (c?: InvokeContext) => any)(invokeCtx);
|
|
565
575
|
const outputs = await (invokeCtx === ambient
|
|
566
576
|
? call()
|
|
567
577
|
: cancellationStore.run(invokeCtx, call));
|