@telorun/kernel 0.24.2 → 0.25.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-loaders/npm-loader.d.ts +41 -12
- package/dist/controller-loaders/npm-loader.d.ts.map +1 -1
- package/dist/controller-loaders/npm-loader.js +117 -51
- 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/evaluation-context.d.ts.map +1 -1
- package/dist/evaluation-context.js +13 -5
- package/dist/evaluation-context.js.map +1 -1
- package/dist/kernel.d.ts.map +1 -1
- package/dist/kernel.js +17 -1
- package/dist/kernel.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/schema-validator.d.ts.map +1 -1
- package/dist/schema-validator.js +39 -3
- package/dist/schema-validator.js.map +1 -1
- package/package.json +2 -2
- package/src/application-env.ts +37 -0
- package/src/controller-loaders/npm-loader.ts +124 -58
- package/src/controller-registry.ts +18 -0
- package/src/controllers/module/import-controller.ts +58 -28
- package/src/evaluation-context.ts +15 -5
- package/src/kernel.ts +24 -1
- package/src/module-context.ts +153 -11
- package/src/schema-validator.ts +41 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@telorun/kernel",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.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",
|
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
|
|
@@ -104,14 +104,17 @@ export interface NpmControllerLoaderOptions {
|
|
|
104
104
|
* local entry manifest (`file://` URL or bare path) the root lives at
|
|
105
105
|
* `<entry-manifest-dir>/.telo/npm/`; for an HTTP(S) entry URL it lives in a
|
|
106
106
|
* 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
|
-
*
|
|
107
|
+
* Every controller — registry tag or `local_path` — is installed under a
|
|
108
|
+
* version-scoped npm alias (`npm install <alias>@<spec>`, see `installAlias`)
|
|
109
|
+
* into this root, then imported from `<root>/node_modules/<alias>`. The alias
|
|
110
|
+
* encodes `name@version`, so a graph that references one package at two
|
|
111
|
+
* versions keeps both in the single flat `node_modules` instead of the last
|
|
112
|
+
* `--save` clobbering the first. This still collapses two parallel module
|
|
113
|
+
* realms (kernel-side @telorun/sdk vs. controller-side @telorun/sdk) into one:
|
|
114
|
+
* `@telorun/sdk` is exempt from aliasing and wired in as a `file:` dep under
|
|
115
|
+
* its real name, npm/pnpm hoist a single copy, and Node's ESM resolver follows
|
|
116
|
+
* it to the same realpath as the kernel — so `Stream` (and any other
|
|
117
|
+
* class-identity-sensitive type) has a single constructor across the process.
|
|
115
118
|
*
|
|
116
119
|
* Per-kernel state lives on each `NpmControllerLoader` instance (one is
|
|
117
120
|
* constructed per `ControllerLoader`, which is itself constructed per
|
|
@@ -169,17 +172,21 @@ export class NpmControllerLoader {
|
|
|
169
172
|
throw new Error(`Invalid PURL '${purl}': missing package name`);
|
|
170
173
|
}
|
|
171
174
|
const packageName = parsed.namespace ? `${parsed.namespace}/${parsed.name}` : parsed.name;
|
|
175
|
+
const version = parsed.version ?? null;
|
|
176
|
+
// Version-scope the install under an alias so the same package at two
|
|
177
|
+
// versions can coexist in one flat node_modules (see installAlias).
|
|
178
|
+
const alias = installAlias(packageName, version);
|
|
172
179
|
|
|
173
180
|
const installRoot = await this.ensureInstallRoot();
|
|
174
181
|
const resolved = await resolveInstallSpec(parsed, packageName, baseUri);
|
|
175
182
|
const source = await this.installPackage(
|
|
176
183
|
installRoot,
|
|
177
|
-
|
|
184
|
+
alias,
|
|
178
185
|
resolved.spec,
|
|
179
186
|
resolved.kind,
|
|
180
|
-
|
|
187
|
+
version,
|
|
181
188
|
);
|
|
182
|
-
const instance = await loadFromInstall(installRoot,
|
|
189
|
+
const instance = await loadFromInstall(installRoot, alias, parsed.subpath ?? null, purl);
|
|
183
190
|
return { instance, source };
|
|
184
191
|
}
|
|
185
192
|
|
|
@@ -296,6 +303,11 @@ export class NpmControllerLoader {
|
|
|
296
303
|
installRoot: string,
|
|
297
304
|
dependencies: Record<string, string>,
|
|
298
305
|
): Promise<void> {
|
|
306
|
+
// `dependencies` is only the realm-collapse deps (`@telorun/sdk`), keyed by
|
|
307
|
+
// real `name@spec`. Controllers, by contrast, key `installedSpecs` by their
|
|
308
|
+
// version-scoped alias in `installPackage`. The two key spaces are
|
|
309
|
+
// intentionally disjoint — realm-collapse names are wired in here and never
|
|
310
|
+
// flow through `installPackage`, so an alias and a `name@spec` never clash.
|
|
299
311
|
for (const [name, spec] of Object.entries(dependencies)) {
|
|
300
312
|
this.installedSpecs.add(`${name}@${spec}`);
|
|
301
313
|
}
|
|
@@ -303,11 +315,14 @@ export class NpmControllerLoader {
|
|
|
303
315
|
}
|
|
304
316
|
|
|
305
317
|
/**
|
|
306
|
-
* Install one controller
|
|
307
|
-
*
|
|
308
|
-
* `npm install` call. If the
|
|
309
|
-
*
|
|
318
|
+
* Install one controller into the existing root under its version-scoped
|
|
319
|
+
* `alias` folder. Single-flight per alias within the process; cross-process
|
|
320
|
+
* safety is the fs-lock around the `npm install` call. If the alias is
|
|
321
|
+
* already installed (right version on disk, or a matching `file:` record),
|
|
322
|
+
* skip — reusing the previous install.
|
|
310
323
|
*
|
|
324
|
+
* `spec` is the source half of the install (`npm:<name>@<version>` or
|
|
325
|
+
* `file:<abs>`); the package manager is invoked with `<alias>@<spec>`.
|
|
311
326
|
* `kind` distinguishes a `local_path` source (loader synthesized `file:`
|
|
312
327
|
* spec) from a registry tag. The CLI silences progress for both `cache`
|
|
313
328
|
* and `local`; `npm-install` is the only event surfaced. Folding this
|
|
@@ -316,12 +331,12 @@ export class NpmControllerLoader {
|
|
|
316
331
|
*/
|
|
317
332
|
private async installPackage(
|
|
318
333
|
installRoot: string,
|
|
319
|
-
|
|
334
|
+
alias: string,
|
|
320
335
|
spec: string,
|
|
321
336
|
kind: SpecKind,
|
|
322
337
|
requestedVersion: string | null,
|
|
323
338
|
): Promise<NpmResolveSource> {
|
|
324
|
-
const cacheKey =
|
|
339
|
+
const cacheKey = alias;
|
|
325
340
|
if (this.installedSpecs.has(cacheKey)) return "cache";
|
|
326
341
|
|
|
327
342
|
const inFlight = this.inFlight.get(cacheKey);
|
|
@@ -330,17 +345,20 @@ export class NpmControllerLoader {
|
|
|
330
345
|
return "cache";
|
|
331
346
|
}
|
|
332
347
|
|
|
333
|
-
|
|
348
|
+
// The package is installed under its version-scoped alias, so its folder
|
|
349
|
+
// name in node_modules is the alias, not the bare package name.
|
|
350
|
+
const targetPath = path.join(installRoot, "node_modules", alias);
|
|
334
351
|
|
|
335
352
|
// 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
|
|
353
|
+
// `package.json` version field. The recorded dep spec can't be used here
|
|
354
|
+
// because npm rewrites registry specs on `--save` — we pass
|
|
355
|
+
// `<alias>@npm:@scope/pkg@0.3.4`, npm writes `npm:@scope/pkg@^0.3.4` — so a
|
|
356
|
+
// string comparison never matches and every fresh `NpmControllerLoader`
|
|
357
|
+
// (one per `Telo.Definition.init`) would fall through to a no-op but ~200ms
|
|
341
358
|
// `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.
|
|
359
|
+
// entirely: if the requested PURL version equals what's on disk under this
|
|
360
|
+
// alias, we already have the right thing. Because the alias encodes the
|
|
361
|
+
// version, a present alias folder is always the requested version.
|
|
344
362
|
//
|
|
345
363
|
// Ranges (`^1.0.0`, `~2.3.0`) fall through to a real `npm install` —
|
|
346
364
|
// they're rare in PURLs (which typically pin) and a proper range check
|
|
@@ -354,37 +372,47 @@ export class NpmControllerLoader {
|
|
|
354
372
|
this.installedSpecs.add(cacheKey);
|
|
355
373
|
return "cache";
|
|
356
374
|
}
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
375
|
+
} else {
|
|
376
|
+
// Local (`file:`) spec fast path: consult the in-process snapshot of the
|
|
377
|
+
// install root's `dependencies` map (seeded by `materializeInstallRoot`).
|
|
378
|
+
// Normalize because npm rewrites absolute `file:` deps to relative paths
|
|
379
|
+
// inside the install root's `package.json` — the loader passes the
|
|
380
|
+
// absolute path, the on-disk record is `file:../../foo`.
|
|
381
|
+
const cachedSpec = this.rootDeps[alias];
|
|
382
|
+
if (
|
|
383
|
+
cachedSpec !== undefined &&
|
|
384
|
+
normalizeFileSpec(cachedSpec, installRoot) === normalizeFileSpec(spec, installRoot) &&
|
|
385
|
+
(await pathExists(targetPath))
|
|
386
|
+
) {
|
|
387
|
+
this.installedSpecs.add(cacheKey);
|
|
388
|
+
return "cache";
|
|
389
|
+
}
|
|
372
390
|
}
|
|
373
391
|
|
|
374
392
|
const work = (async () => {
|
|
375
393
|
await withInstallLock(installRoot, async () => {
|
|
376
394
|
// 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
|
-
|
|
395
|
+
// spec between the fast-path miss and our acquisition.
|
|
396
|
+
if (kind === "registry") {
|
|
397
|
+
const installedVersion = await readInstalledVersion(targetPath);
|
|
398
|
+
if (
|
|
399
|
+
installedVersion !== null &&
|
|
400
|
+
(requestedVersion === null || requestedVersion === installedVersion)
|
|
401
|
+
) {
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
} else {
|
|
405
|
+
// Normalize the on-disk record to absolute form before comparing —
|
|
406
|
+
// npm rewrites `file:` deps to be relative to the install root.
|
|
407
|
+
const lockedSpec = await readDepSpec(installRoot, alias);
|
|
408
|
+
if (
|
|
409
|
+
lockedSpec !== undefined &&
|
|
410
|
+
normalizeFileSpec(lockedSpec, installRoot) === normalizeFileSpec(spec, installRoot) &&
|
|
411
|
+
(await pathExists(targetPath))
|
|
412
|
+
) {
|
|
413
|
+
this.rootDeps[alias] = lockedSpec;
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
388
416
|
}
|
|
389
417
|
|
|
390
418
|
await runPackageManager(installRoot, [
|
|
@@ -394,14 +422,17 @@ export class NpmControllerLoader {
|
|
|
394
422
|
"--silent",
|
|
395
423
|
...PEER_INSTALL_FLAGS,
|
|
396
424
|
"--save",
|
|
397
|
-
spec
|
|
425
|
+
// `<alias>@<source-spec>` installs the package under the alias folder
|
|
426
|
+
// (`npm:` for registry, `file:` for local) so multiple versions of
|
|
427
|
+
// one package name coexist in the single install root.
|
|
428
|
+
`${alias}@${spec}`,
|
|
398
429
|
]);
|
|
399
430
|
// Re-read what npm actually wrote (it normalizes `file:` paths to
|
|
400
431
|
// be relative to the install root). Caching the spec in its on-disk
|
|
401
432
|
// form keeps subsequent fast-path comparisons stable.
|
|
402
|
-
const written = await readDepSpec(installRoot,
|
|
403
|
-
if (written !== undefined) this.rootDeps[
|
|
404
|
-
else this.rootDeps[
|
|
433
|
+
const written = await readDepSpec(installRoot, alias);
|
|
434
|
+
if (written !== undefined) this.rootDeps[alias] = written;
|
|
435
|
+
else this.rootDeps[alias] = spec;
|
|
405
436
|
});
|
|
406
437
|
})();
|
|
407
438
|
|
|
@@ -693,6 +724,35 @@ function computeInstallRoot(entryUrl: string): string {
|
|
|
693
724
|
);
|
|
694
725
|
}
|
|
695
726
|
|
|
727
|
+
/**
|
|
728
|
+
* Version-qualified install alias for a controller package. The kernel's single
|
|
729
|
+
* flat install root can hold only one folder per package name, but a manifest
|
|
730
|
+
* graph may legitimately reference the same package at multiple versions (e.g.
|
|
731
|
+
* a library pins `@telorun/mcp-client@0.3.1` while the app uses `0.4.0`).
|
|
732
|
+
* Installing each `name@version` under a distinct npm alias
|
|
733
|
+
* (`npm install <alias>@npm:<name>@<version>`) lets every version coexist in
|
|
734
|
+
* one `node_modules`, mirroring the per-(name, version) identity of a Telo
|
|
735
|
+
* module singleton. `@telorun/sdk` is intentionally NOT routed through here —
|
|
736
|
+
* it stays under its real name so realm-collapse hoists a single copy across
|
|
737
|
+
* the kernel/controller boundary (see REALM_COLLAPSE_NAMES).
|
|
738
|
+
*
|
|
739
|
+
* The result is a valid unscoped npm package name: the scope `@` is dropped,
|
|
740
|
+
* `/` becomes `__`, and any character outside npm's name grammar is replaced
|
|
741
|
+
* with `-`. Because that sanitization is lossy (e.g. build metadata `1.0.0+x`
|
|
742
|
+
* and prerelease `1.0.0-x` both fold to `1.0.0-x`, and a `__` already in a
|
|
743
|
+
* package name overlaps the scope separator), the alias ends with a short hash
|
|
744
|
+
* of the *exact* `name@version` — so two distinct pairs can never collide onto
|
|
745
|
+
* one folder regardless of how their readable prefixes sanitize. The readable
|
|
746
|
+
* prefix is kept purely so the install tree is greppable.
|
|
747
|
+
*/
|
|
748
|
+
function installAlias(packageName: string, version: string | null): string {
|
|
749
|
+
const prefix = `${packageName.replace(/^@/, "").replace(/\//g, "__")}__${version ?? "latest"}`
|
|
750
|
+
.replace(/[^a-zA-Z0-9._-]/g, "-")
|
|
751
|
+
.toLowerCase();
|
|
752
|
+
const digest = sha256(`${packageName}@${version ?? ""}`).slice(0, 8);
|
|
753
|
+
return `${prefix}__${digest}`;
|
|
754
|
+
}
|
|
755
|
+
|
|
696
756
|
/**
|
|
697
757
|
* Normalize a `file:` spec to an absolute path so two specs that point at the
|
|
698
758
|
* same source — one absolute (what the loader synthesizes from `local_path`)
|
|
@@ -770,7 +830,10 @@ async function resolveInstallSpec(
|
|
|
770
830
|
return { kind: "local", spec: `file:${absolutePath}`, absolutePath };
|
|
771
831
|
}
|
|
772
832
|
}
|
|
773
|
-
|
|
833
|
+
// `npm:` source spec so the caller can install it under a version-scoped
|
|
834
|
+
// alias (`<alias>@npm:<name>@<version>`); both versions of one package name
|
|
835
|
+
// then coexist in the single flat install root.
|
|
836
|
+
const spec = parsed.version ? `npm:${packageName}@${parsed.version}` : `npm:${packageName}`;
|
|
774
837
|
return { kind: "registry", spec };
|
|
775
838
|
}
|
|
776
839
|
|
|
@@ -782,11 +845,13 @@ async function resolveInstallSpec(
|
|
|
782
845
|
*/
|
|
783
846
|
async function loadFromInstall(
|
|
784
847
|
installRoot: string,
|
|
785
|
-
|
|
848
|
+
alias: string,
|
|
786
849
|
subpath: string | null,
|
|
787
850
|
purl: string,
|
|
788
851
|
): Promise<ControllerInstance> {
|
|
789
|
-
|
|
852
|
+
// The package lives under its version-scoped alias folder (see installAlias),
|
|
853
|
+
// not its bare scoped name.
|
|
854
|
+
const packageRoot = path.join(installRoot, "node_modules", alias);
|
|
790
855
|
const entry = subpath ? `./${subpath}` : ".";
|
|
791
856
|
const entryFile = await resolvePackageEntry(packageRoot, entry);
|
|
792
857
|
// ESM dynamic `import()` accepts either a relative specifier or a `file://`
|
|
@@ -937,6 +1002,7 @@ function resolveExportTargetValue(
|
|
|
937
1002
|
* Not part of the kernel's public API — consumers should not import these.
|
|
938
1003
|
*/
|
|
939
1004
|
export const __testing__ = {
|
|
1005
|
+
installAlias,
|
|
940
1006
|
normalizeFileSpec,
|
|
941
1007
|
resolvePackageExportTarget,
|
|
942
1008
|
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();
|
|
@@ -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));
|
package/src/kernel.ts
CHANGED
|
@@ -41,7 +41,11 @@ import {
|
|
|
41
41
|
writeAnalysisStamp,
|
|
42
42
|
} from "./manifest-sources/analysis-stamp.js";
|
|
43
43
|
import { resolveEntryDir } from "./manifest-sources/local-manifest-cache-source.js";
|
|
44
|
-
import {
|
|
44
|
+
import {
|
|
45
|
+
precompileApplicationEnvSchemas,
|
|
46
|
+
precompileDefinitionSchemas,
|
|
47
|
+
resolveApplicationEnv,
|
|
48
|
+
} from "./application-env.js";
|
|
45
49
|
import { policyFingerprint } from "./runtime-registry.js";
|
|
46
50
|
import { SchemaValidator } from "./schema-validator.js";
|
|
47
51
|
|
|
@@ -441,6 +445,25 @@ export class Kernel implements IKernel {
|
|
|
441
445
|
this.sharedSchemaValidator,
|
|
442
446
|
);
|
|
443
447
|
}
|
|
448
|
+
// Bake the per-kind resource-config validators too. `_createInstance`
|
|
449
|
+
// compiles `controller.schema` (which falls back to the definition's own
|
|
450
|
+
// `schema`) for every resource at runtime — work this analyze-only pass
|
|
451
|
+
// otherwise skips because it stops before instantiation, leaving the
|
|
452
|
+
// runtime to recompile and fail to persist them on a read-only image.
|
|
453
|
+
// Pre-compiling every `Telo.Definition` schema here writes them into the
|
|
454
|
+
// same content-addressed `__validators/` cache the runtime reads.
|
|
455
|
+
precompileDefinitionSchemas(staticManifests, this.sharedSchemaValidator);
|
|
456
|
+
// Framework/builtin controller schemas (`Telo.Import`, `Telo.Definition`,
|
|
457
|
+
// the module controller, …) aren't in the static manifests but are
|
|
458
|
+
// validated per-resource at runtime just the same. They're registered by
|
|
459
|
+
// `loadBuiltinDefinitions` above, so bake them from the registry.
|
|
460
|
+
precompileDefinitionSchemas(
|
|
461
|
+
this.controllers.getControllerSchemas().map((schema) => ({
|
|
462
|
+
kind: "Telo.Definition",
|
|
463
|
+
schema,
|
|
464
|
+
})),
|
|
465
|
+
this.sharedSchemaValidator,
|
|
466
|
+
);
|
|
444
467
|
return;
|
|
445
468
|
}
|
|
446
469
|
|