@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/src/index.ts
CHANGED
package/src/kernel.ts
CHANGED
|
@@ -40,8 +40,14 @@ import {
|
|
|
40
40
|
readAnalysisStamp,
|
|
41
41
|
writeAnalysisStamp,
|
|
42
42
|
} from "./manifest-sources/analysis-stamp.js";
|
|
43
|
-
import {
|
|
44
|
-
|
|
43
|
+
import {
|
|
44
|
+
resolveCacheRoot,
|
|
45
|
+
} from "./manifest-sources/local-manifest-cache-source.js";
|
|
46
|
+
import {
|
|
47
|
+
precompileApplicationEnvSchemas,
|
|
48
|
+
precompileDefinitionSchemas,
|
|
49
|
+
resolveApplicationEnv,
|
|
50
|
+
} from "./application-env.js";
|
|
45
51
|
import { policyFingerprint } from "./runtime-registry.js";
|
|
46
52
|
import { SchemaValidator } from "./schema-validator.js";
|
|
47
53
|
|
|
@@ -165,6 +171,9 @@ export class Kernel implements IKernel {
|
|
|
165
171
|
private rootContext!: ModuleContext;
|
|
166
172
|
private staticManifests: ResourceManifest[] = [];
|
|
167
173
|
private _entryUrl?: string;
|
|
174
|
+
/** The `.telo` cache root for this load, resolved once in `load()` and
|
|
175
|
+
* threaded to the validator, analysis stamp, and npm install root. */
|
|
176
|
+
private _cacheRoot?: string | null;
|
|
168
177
|
private _loadedGraph?: LoadedGraph;
|
|
169
178
|
// Lifecycle state — guards boot/runTargets/teardown/invoke transitions.
|
|
170
179
|
// teardown() is the only idempotent method; everything else throws on misuse.
|
|
@@ -315,18 +324,32 @@ export class Kernel implements IKernel {
|
|
|
315
324
|
* session rootfs — hits the stamp and skips the validation walk entirely
|
|
316
325
|
* instead of failing to write the caches on every boot.
|
|
317
326
|
*/
|
|
318
|
-
async load(
|
|
327
|
+
async load(
|
|
328
|
+
url: string,
|
|
329
|
+
options?: { analyzeOnly?: boolean; cacheDir?: string | null; writeCache?: boolean },
|
|
330
|
+
): Promise<void> {
|
|
319
331
|
const sourceUrl = await this.loader.resolveEntryPoint(url);
|
|
320
332
|
this._entryUrl = sourceUrl;
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
const
|
|
333
|
+
// Resolve the `.telo` cache root ONCE and thread it to every consumer
|
|
334
|
+
// (validators, analysis stamp, npm install root) — no consumer re-derives
|
|
335
|
+
// it or reads the env independently. A caller (the CLI) may pass `cacheDir`
|
|
336
|
+
// so the env is read exactly once per invocation; otherwise resolve here.
|
|
337
|
+
const cacheRoot =
|
|
338
|
+
options?.cacheDir !== undefined ? options.cacheDir : resolveCacheRoot(sourceUrl);
|
|
339
|
+
this._cacheRoot = cacheRoot;
|
|
340
|
+
const manifestsDir = cacheRoot ? `${cacheRoot}/manifests` : undefined;
|
|
341
|
+
// `writeCache: false` (`telo run --no-cache-write`) keeps the cache
|
|
342
|
+
// READ-only: compiled validators and the analysis stamp are still loaded
|
|
343
|
+
// from disk, but never written back — so an ephemeral, read-only session
|
|
344
|
+
// rootfs validates in-memory without touching the baked cache.
|
|
345
|
+
const writeCache = options?.writeCache !== false;
|
|
346
|
+
// Point the shared schema validator at the cache so compiled AJV validators
|
|
347
|
+
// are loaded (and, when writable, persisted) under
|
|
348
|
+
// `<cache-root>/manifests/__validators/`. Memory-/HTTP-rooted entries skip
|
|
349
|
+
// the cache; their schema compiles stay in-process only.
|
|
326
350
|
this.sharedSchemaValidator.setCacheDir(
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
: undefined,
|
|
351
|
+
manifestsDir ? `${manifestsDir}/__validators` : undefined,
|
|
352
|
+
{ write: writeCache },
|
|
330
353
|
);
|
|
331
354
|
this.rootContext = new ModuleContext(
|
|
332
355
|
sourceUrl,
|
|
@@ -389,9 +412,10 @@ export class Kernel implements IKernel {
|
|
|
389
412
|
// and inline-resource normalisation still runs — only the diagnostic
|
|
390
413
|
// passes are elided. Memory- / HTTP-rooted entries have no
|
|
391
414
|
// local stamp store and always re-validate.
|
|
392
|
-
const entryDir = resolveEntryDir(sourceUrl);
|
|
393
415
|
const analysisSignature = computeAnalysisSignature(analysisGraph);
|
|
394
|
-
const stamp =
|
|
416
|
+
const stamp = manifestsDir
|
|
417
|
+
? await readAnalysisStamp("", manifestsDir)
|
|
418
|
+
: undefined;
|
|
395
419
|
const skipValidation = stamp?.signature === analysisSignature;
|
|
396
420
|
const errors = this.analyzer.analyzeErrors(
|
|
397
421
|
staticManifests,
|
|
@@ -412,13 +436,13 @@ export class Kernel implements IKernel {
|
|
|
412
436
|
})),
|
|
413
437
|
);
|
|
414
438
|
}
|
|
415
|
-
if (
|
|
439
|
+
if (manifestsDir && writeCache && !skipValidation) {
|
|
416
440
|
// Best-effort: stamp the verdict so subsequent loads hit the fast
|
|
417
441
|
// path. A read-only filesystem (baked Docker image) reports the
|
|
418
442
|
// failure on stderr and keeps running — the lookup above will
|
|
419
|
-
// simply miss next time.
|
|
443
|
+
// simply miss next time. Skipped under `--no-cache-write`.
|
|
420
444
|
try {
|
|
421
|
-
await writeAnalysisStamp(
|
|
445
|
+
await writeAnalysisStamp("", analysisSignature, manifestsDir);
|
|
422
446
|
} catch (err) {
|
|
423
447
|
this.stderr.write(
|
|
424
448
|
`[telo:kernel] analysis stamp write failed: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
@@ -441,6 +465,25 @@ export class Kernel implements IKernel {
|
|
|
441
465
|
this.sharedSchemaValidator,
|
|
442
466
|
);
|
|
443
467
|
}
|
|
468
|
+
// Bake the per-kind resource-config validators too. `_createInstance`
|
|
469
|
+
// compiles `controller.schema` (which falls back to the definition's own
|
|
470
|
+
// `schema`) for every resource at runtime — work this analyze-only pass
|
|
471
|
+
// otherwise skips because it stops before instantiation, leaving the
|
|
472
|
+
// runtime to recompile and fail to persist them on a read-only image.
|
|
473
|
+
// Pre-compiling every `Telo.Definition` schema here writes them into the
|
|
474
|
+
// same content-addressed `__validators/` cache the runtime reads.
|
|
475
|
+
precompileDefinitionSchemas(staticManifests, this.sharedSchemaValidator);
|
|
476
|
+
// Framework/builtin controller schemas (`Telo.Import`, `Telo.Definition`,
|
|
477
|
+
// the module controller, …) aren't in the static manifests but are
|
|
478
|
+
// validated per-resource at runtime just the same. They're registered by
|
|
479
|
+
// `loadBuiltinDefinitions` above, so bake them from the registry.
|
|
480
|
+
precompileDefinitionSchemas(
|
|
481
|
+
this.controllers.getControllerSchemas().map((schema) => ({
|
|
482
|
+
kind: "Telo.Definition",
|
|
483
|
+
schema,
|
|
484
|
+
})),
|
|
485
|
+
this.sharedSchemaValidator,
|
|
486
|
+
);
|
|
444
487
|
return;
|
|
445
488
|
}
|
|
446
489
|
|
|
@@ -721,6 +764,12 @@ export class Kernel implements IKernel {
|
|
|
721
764
|
return this._entryUrl;
|
|
722
765
|
}
|
|
723
766
|
|
|
767
|
+
/** The npm install root for this load (`<cache-root>/npm`), threaded to the
|
|
768
|
+
* controller loader so it doesn't re-derive it from the entry URL. */
|
|
769
|
+
getInstallRoot(): string | undefined {
|
|
770
|
+
return this._cacheRoot ? `${this._cacheRoot}/npm` : undefined;
|
|
771
|
+
}
|
|
772
|
+
|
|
724
773
|
/** Authored `kind` of a declared resource by name, from the static manifest
|
|
725
774
|
* set. Init-order-independent (unlike `resourceInstances`), so a controller
|
|
726
775
|
* resolving a `!ref <name>` sentinel before the target initializes can still
|
|
@@ -136,9 +136,13 @@ export function computeAnalysisSignature(graph: LoadedGraph): string {
|
|
|
136
136
|
* reading a newer stamp (or vice versa) discard rather than misparse. */
|
|
137
137
|
export async function readAnalysisStamp(
|
|
138
138
|
entryDir: string,
|
|
139
|
+
manifestsDir?: string,
|
|
139
140
|
): Promise<AnalysisStamp | undefined> {
|
|
141
|
+
const stampPath = manifestsDir
|
|
142
|
+
? path.join(manifestsDir, ".validated.json")
|
|
143
|
+
: path.join(entryDir, ANALYSIS_STAMP_FILE);
|
|
140
144
|
try {
|
|
141
|
-
const text = await fs.readFile(
|
|
145
|
+
const text = await fs.readFile(stampPath, "utf-8");
|
|
142
146
|
const parsed = JSON.parse(text) as Partial<AnalysisStamp>;
|
|
143
147
|
if (
|
|
144
148
|
parsed?.version === ANALYSIS_STAMP_FORMAT_VERSION &&
|
|
@@ -158,12 +162,15 @@ export async function readAnalysisStamp(
|
|
|
158
162
|
export async function writeAnalysisStamp(
|
|
159
163
|
entryDir: string,
|
|
160
164
|
signature: string,
|
|
165
|
+
manifestsDir?: string,
|
|
161
166
|
): Promise<void> {
|
|
162
167
|
const stamp: AnalysisStamp = {
|
|
163
168
|
version: ANALYSIS_STAMP_FORMAT_VERSION,
|
|
164
169
|
signature,
|
|
165
170
|
};
|
|
166
|
-
const target =
|
|
171
|
+
const target = manifestsDir
|
|
172
|
+
? path.join(manifestsDir, ".validated.json")
|
|
173
|
+
: path.join(entryDir, ANALYSIS_STAMP_FILE);
|
|
167
174
|
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
168
175
|
await fs.writeFile(target, JSON.stringify(stamp), "utf-8");
|
|
169
176
|
}
|
|
@@ -133,8 +133,15 @@ export class LocalManifestCacheSource implements ManifestSource {
|
|
|
133
133
|
private readonly cacheRoot: string;
|
|
134
134
|
private readonly registryUrl: string;
|
|
135
135
|
|
|
136
|
-
constructor(
|
|
137
|
-
|
|
136
|
+
constructor(
|
|
137
|
+
entryDir: string,
|
|
138
|
+
registryUrl: string = DEFAULT_REGISTRY_URL,
|
|
139
|
+
manifestsDir?: string,
|
|
140
|
+
) {
|
|
141
|
+
// `manifestsDir` is the resolved manifest-cache directory threaded from a
|
|
142
|
+
// single `resolveCacheRoot` (honours `TELO_CACHE_DIR`); when absent we fall
|
|
143
|
+
// back to the entry-anchored default so library/test callers are unchanged.
|
|
144
|
+
this.cacheRoot = manifestsDir ?? path.join(entryDir, CACHE_SUBDIR);
|
|
138
145
|
this.registryUrl = registryUrl;
|
|
139
146
|
}
|
|
140
147
|
|
|
@@ -189,8 +196,9 @@ export function cachePathForCanonical(
|
|
|
189
196
|
canonicalSource: string,
|
|
190
197
|
entryDir: string,
|
|
191
198
|
registryUrl: string,
|
|
199
|
+
manifestsDir?: string,
|
|
192
200
|
): string | null {
|
|
193
|
-
const cacheRoot = path.join(entryDir, CACHE_SUBDIR);
|
|
201
|
+
const cacheRoot = manifestsDir ?? path.join(entryDir, CACHE_SUBDIR);
|
|
194
202
|
return cachePathForUrl(canonicalSource, cacheRoot, registryUrl);
|
|
195
203
|
}
|
|
196
204
|
|
|
@@ -210,6 +218,7 @@ export async function writeManifestCache(
|
|
|
210
218
|
graph: LoadedGraph,
|
|
211
219
|
entryDir: string,
|
|
212
220
|
registryUrl: string = DEFAULT_REGISTRY_URL,
|
|
221
|
+
manifestsDir?: string,
|
|
213
222
|
): Promise<string[]> {
|
|
214
223
|
const written: string[] = [];
|
|
215
224
|
const seen = new Set<string>();
|
|
@@ -220,7 +229,7 @@ export async function writeManifestCache(
|
|
|
220
229
|
if (seen.has(file.source)) continue;
|
|
221
230
|
seen.add(file.source);
|
|
222
231
|
|
|
223
|
-
const target = cachePathForCanonical(file.source, entryDir, registryUrl);
|
|
232
|
+
const target = cachePathForCanonical(file.source, entryDir, registryUrl, manifestsDir);
|
|
224
233
|
if (!target) continue;
|
|
225
234
|
|
|
226
235
|
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
@@ -254,3 +263,19 @@ export function resolveEntryDir(entryPath: string): string | null {
|
|
|
254
263
|
return path.dirname(absolute);
|
|
255
264
|
}
|
|
256
265
|
}
|
|
266
|
+
|
|
267
|
+
/** The single `.telo` cache root for an entry, resolved once and threaded to
|
|
268
|
+
* every consumer (manifest cache, compiled validators, analysis stamp, npm
|
|
269
|
+
* install root) so none of them re-derive it or read the env independently.
|
|
270
|
+
*
|
|
271
|
+
* `TELO_CACHE_DIR` (the relocated root a prebuilt image bakes its deps into)
|
|
272
|
+
* wins; otherwise the root sits beside the entry at `<entry-dir>/.telo`.
|
|
273
|
+
* Returns `null` for http(s) entries with no local anchor (disk cache skipped).
|
|
274
|
+
* Consumers append the conventional subdirs: `manifests/`, `manifests/__validators/`,
|
|
275
|
+
* `npm/`. */
|
|
276
|
+
export function resolveCacheRoot(entryPath: string): string | null {
|
|
277
|
+
const override = process.env.TELO_CACHE_DIR;
|
|
278
|
+
if (override && override.trim()) return path.resolve(override.trim());
|
|
279
|
+
const entryDir = resolveEntryDir(entryPath);
|
|
280
|
+
return entryDir ? path.join(entryDir, ".telo") : null;
|
|
281
|
+
}
|
package/src/module-context.ts
CHANGED
|
@@ -103,15 +103,41 @@ export class ModuleContext extends EvaluationContext implements IModuleContext {
|
|
|
103
103
|
/** Maps import alias → its child context's exported instances. Registered by the
|
|
104
104
|
* Telo.Import controller; read when resolving a cross-module `!ref Alias.name`
|
|
105
105
|
* reference (Phase 5 injection and boot targets). `names` is the import's
|
|
106
|
-
* `exports.resources` gate — only listed instances are reachable.
|
|
106
|
+
* `exports.resources` gate — only listed instances are reachable. `terminal`
|
|
107
|
+
* returns the child's pre-flattened terminal getter for a name (a closure that
|
|
108
|
+
* points directly at the OWNING context's instance, however many re-export hops
|
|
109
|
+
* away), so resolution stays O(1) regardless of re-export depth. */
|
|
107
110
|
private readonly importedScopes = new Map<
|
|
108
111
|
string,
|
|
109
112
|
{
|
|
110
113
|
names: Set<string>;
|
|
111
|
-
|
|
114
|
+
terminal: (
|
|
115
|
+
name: string,
|
|
116
|
+
) => (() => { kind: string; instance: ResourceInstance } | undefined) | undefined;
|
|
112
117
|
}
|
|
113
118
|
>();
|
|
114
119
|
|
|
120
|
+
/** This module's flattened export table: export name → terminal getter. A local
|
|
121
|
+
* export's getter reads this context's own `resourceInstances`; a re-export
|
|
122
|
+
* (`!ref Alias.name`) holds the SAME closure object as the owner's entry, copied
|
|
123
|
+
* by reference at build time so depth adds no resolution hops. Built once by the
|
|
124
|
+
* Telo.Import controller after this module's own imports have initialized. */
|
|
125
|
+
private readonly exportedGetters = new Map<
|
|
126
|
+
string,
|
|
127
|
+
() => { kind: string; instance: ResourceInstance } | undefined
|
|
128
|
+
>();
|
|
129
|
+
|
|
130
|
+
/** This module's flattened exported-KIND table: exported kind suffix → canonical
|
|
131
|
+
* `<owningModule>.<Kind>`. A local exported kind maps to `<thisModule>.<Kind>`; a
|
|
132
|
+
* re-export (`exports.kinds: [Alias.Kind]`) copies the source import's already-canonical
|
|
133
|
+
* string, so depth adds no resolution hops. Built by `buildExportTable`. */
|
|
134
|
+
private readonly exportedKinds = new Map<string, string>();
|
|
135
|
+
|
|
136
|
+
/** Maps import alias → a resolver into that import's exported-kind table. Registered by the
|
|
137
|
+
* Telo.Import controller; consulted by `resolveKind` so an imported library's kinds — local
|
|
138
|
+
* OR transitively re-exported — resolve to their true owning module in O(1). */
|
|
139
|
+
private readonly importedKindResolvers = new Map<string, (suffix: string) => string | undefined>();
|
|
140
|
+
|
|
115
141
|
/**
|
|
116
142
|
* Resolved controller-selection policy for this module's `Telo.Definition`s.
|
|
117
143
|
* Stamped by the parent `Telo.Import` controller from the import's `runtime:`
|
|
@@ -202,24 +228,127 @@ export class ModuleContext extends EvaluationContext implements IModuleContext {
|
|
|
202
228
|
}
|
|
203
229
|
|
|
204
230
|
/** Register an import alias's exported instances for cross-module reference resolution.
|
|
205
|
-
* `names` is the gate (the import's `exports.resources`); `
|
|
206
|
-
*
|
|
207
|
-
*
|
|
231
|
+
* `names` is the gate (the import's `exports.resources`); `terminal` returns the child
|
|
232
|
+
* context's pre-flattened terminal getter for a name (existing only after the import's
|
|
233
|
+
* `init()` built the child's export table). */
|
|
208
234
|
registerImportedScope(
|
|
209
235
|
alias: string,
|
|
210
236
|
names: string[],
|
|
211
|
-
|
|
237
|
+
terminal: (
|
|
238
|
+
name: string,
|
|
239
|
+
) => (() => { kind: string; instance: ResourceInstance } | undefined) | undefined,
|
|
212
240
|
): void {
|
|
213
|
-
this.importedScopes.set(alias, { names: new Set(names),
|
|
241
|
+
this.importedScopes.set(alias, { names: new Set(names), terminal });
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** This module's terminal getter for an exported `name`, or undefined. Read by a parent
|
|
245
|
+
* import building its own (re-)export table — copying this closure by reference flattens
|
|
246
|
+
* the chain so resolution never walks hops. */
|
|
247
|
+
getTerminalExport(
|
|
248
|
+
name: string,
|
|
249
|
+
): (() => { kind: string; instance: ResourceInstance } | undefined) | undefined {
|
|
250
|
+
return this.exportedGetters.get(name);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** O(1) resolution of an exported instance owned or re-exported by this module. */
|
|
254
|
+
getExported(name: string): { kind: string; instance: ResourceInstance } | undefined {
|
|
255
|
+
return this.exportedGetters.get(name)?.();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Register an import alias's exported-kind resolver (the child's `getExportedKind`). */
|
|
259
|
+
registerImportedKindScope(alias: string, resolve: (suffix: string) => string | undefined): void {
|
|
260
|
+
this.importedKindResolvers.set(alias, resolve);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** This module's canonical kind for an exported suffix (local or re-exported), or undefined. */
|
|
264
|
+
getExportedKind(suffix: string): string | undefined {
|
|
265
|
+
return this.exportedKinds.get(suffix);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Build this module's flattened export tables from `exports.resources` / `exports.kinds`.
|
|
269
|
+
* A resource entry is a local export (no alias) or a re-export `Alias.Name` (alias set); a
|
|
270
|
+
* kind entry is a local kind (no alias) or a re-export `Alias.Kind` (alias set). A local
|
|
271
|
+
* export gets a fresh terminal getter / `<module>.<Kind>` canonical; a re-export copies the
|
|
272
|
+
* source import's terminal getter / already-canonical kind by reference, collapsing the chain
|
|
273
|
+
* to a single hop. Called once by the Telo.Import controller after this module's own imports
|
|
274
|
+
* have initialized (leaves-first), so re-export sources are already registered — an
|
|
275
|
+
* unresolvable re-export is therefore a permanent misconfiguration and throws here. */
|
|
276
|
+
buildExportTable(
|
|
277
|
+
entries: ReadonlyArray<{ name: string; alias?: string }>,
|
|
278
|
+
kindEntries: ReadonlyArray<{ name: string; alias?: string }>,
|
|
279
|
+
moduleName: string,
|
|
280
|
+
): void {
|
|
281
|
+
for (const entry of entries) {
|
|
282
|
+
const name = entry.name;
|
|
283
|
+
if (entry.alias && entry.alias !== "Self") {
|
|
284
|
+
// A re-export resolves against this module's own imports, which have all initialized
|
|
285
|
+
// by now (leaves-first) — so an unresolved source is a permanent misconfiguration, not
|
|
286
|
+
// a transient miss. Surface it instead of silently dropping the export.
|
|
287
|
+
const scope = this.importedScopes.get(entry.alias);
|
|
288
|
+
if (!scope) {
|
|
289
|
+
throw new RuntimeError(
|
|
290
|
+
"ERR_INVALID_REEXPORT",
|
|
291
|
+
`Library '${moduleName}' re-exports '${entry.alias}.${name}' but declares no import ` +
|
|
292
|
+
`aliased '${entry.alias}'. Add it to this library's 'imports', or remove the ` +
|
|
293
|
+
`'exports.resources' entry.`,
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
const terminal = scope.terminal(name);
|
|
297
|
+
if (!terminal) {
|
|
298
|
+
throw new RuntimeError(
|
|
299
|
+
"ERR_INVALID_REEXPORT",
|
|
300
|
+
`Library '${moduleName}' re-exports '${entry.alias}.${name}', but the imported ` +
|
|
301
|
+
`library '${entry.alias}' exports no instance named '${name}'.`,
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
this.exportedGetters.set(name, terminal);
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
this.exportedGetters.set(name, () => {
|
|
308
|
+
const inst = this.resourceInstances.get(name);
|
|
309
|
+
if (!inst) return undefined;
|
|
310
|
+
// Canonicalize the authored kind to `<module>.<Kind>` so the cross-module ref shape
|
|
311
|
+
// and event naming are scope-independent (matches resolve-ref-sentinels.ts).
|
|
312
|
+
const rawKind = inst.resource.kind as string;
|
|
313
|
+
const kind = rawKind.startsWith("Self.")
|
|
314
|
+
? `${moduleName}.${rawKind.slice("Self.".length)}`
|
|
315
|
+
: rawKind;
|
|
316
|
+
return { kind, instance: inst.instance };
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
for (const k of kindEntries) {
|
|
320
|
+
if (k.alias && k.alias !== "Self") {
|
|
321
|
+
const resolver = this.importedKindResolvers.get(k.alias);
|
|
322
|
+
if (!resolver) {
|
|
323
|
+
throw new RuntimeError(
|
|
324
|
+
"ERR_INVALID_REEXPORT",
|
|
325
|
+
`Library '${moduleName}' re-exports kind '${k.alias}.${k.name}' but declares no ` +
|
|
326
|
+
`import aliased '${k.alias}'. Add it to this library's 'imports', or remove the ` +
|
|
327
|
+
`'exports.kinds' entry.`,
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
const canonical = resolver(k.name);
|
|
331
|
+
if (!canonical) {
|
|
332
|
+
throw new RuntimeError(
|
|
333
|
+
"ERR_INVALID_REEXPORT",
|
|
334
|
+
`Library '${moduleName}' re-exports kind '${k.alias}.${k.name}', but the imported ` +
|
|
335
|
+
`library '${k.alias}' exports no kind named '${k.name}'.`,
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
this.exportedKinds.set(k.name, canonical);
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
this.exportedKinds.set(k.name, `${moduleName}.${k.name}`);
|
|
342
|
+
}
|
|
214
343
|
}
|
|
215
344
|
|
|
216
345
|
/** Resolve `Alias.name` to a live exported instance, gated by `exports.resources`.
|
|
217
346
|
* Returns undefined when the alias is unknown, the name isn't exported, or the import
|
|
218
|
-
* hasn't
|
|
347
|
+
* hasn't built its export table yet (the injection path defers and retries). O(1). */
|
|
219
348
|
override resolveImportedInstance(alias: string, name: string): ResourceInstance | undefined {
|
|
220
349
|
const scope = this.importedScopes.get(alias);
|
|
221
350
|
if (!scope || !scope.names.has(name)) return undefined;
|
|
222
|
-
return scope.
|
|
351
|
+
return scope.terminal(name)?.()?.instance;
|
|
223
352
|
}
|
|
224
353
|
|
|
225
354
|
/** Like `resolveImportedInstance`, but returns the `{kind, name}` ref (canonical kind)
|
|
@@ -228,7 +357,7 @@ export class ModuleContext extends EvaluationContext implements IModuleContext {
|
|
|
228
357
|
resolveImportedRef(alias: string, name: string): { kind: string; name: string } | undefined {
|
|
229
358
|
const scope = this.importedScopes.get(alias);
|
|
230
359
|
if (!scope || !scope.names.has(name)) return undefined;
|
|
231
|
-
const entry = scope.
|
|
360
|
+
const entry = scope.terminal(name)?.();
|
|
232
361
|
return entry ? { kind: entry.kind, name } : undefined;
|
|
233
362
|
}
|
|
234
363
|
|
|
@@ -292,6 +421,14 @@ export class ModuleContext extends EvaluationContext implements IModuleContext {
|
|
|
292
421
|
`Exported kinds: ${[...allowed].join(", ")}`,
|
|
293
422
|
);
|
|
294
423
|
}
|
|
424
|
+
// Re-export override: if this import's exported-kind table maps the suffix to a DIFFERENT
|
|
425
|
+
// owning module, the kind is transitively re-exported (`exports.kinds: [Alias.Kind]`) —
|
|
426
|
+
// resolve to its true owner. A local kind maps to `${realModule}.${suffix}` (no override),
|
|
427
|
+
// and a module without `exports.kinds` has an empty table (unrestricted, unchanged). Built
|
|
428
|
+
// deferred, so before the import inits this returns the un-overridden kind, whose controller
|
|
429
|
+
// miss makes the init loop retry until the table is ready.
|
|
430
|
+
const reExported = this.importedKindResolvers.get(prefix)?.(suffix);
|
|
431
|
+
if (reExported && reExported !== `${realModule}.${suffix}`) return reExported;
|
|
295
432
|
return `${realModule}.${suffix}`;
|
|
296
433
|
}
|
|
297
434
|
|
|
@@ -363,7 +500,12 @@ export class ModuleContext extends EvaluationContext implements IModuleContext {
|
|
|
363
500
|
await this.run(target, ctx);
|
|
364
501
|
continue;
|
|
365
502
|
}
|
|
366
|
-
|
|
503
|
+
// A bare `!ref Alias.name` boot target is Phase-5-injected into the live
|
|
504
|
+
// instance, which for a Run.Sequence exposes both `run()` and `invoke()`.
|
|
505
|
+
// Guard against treating such an instance as an authored inline-invoke
|
|
506
|
+
// step — only a structural `{ invoke: <ref>, inputs }` spec (no `run`)
|
|
507
|
+
// belongs here; the live instance falls through to the runnable branch.
|
|
508
|
+
if (!isRunnableInstance(target) && "invoke" in target && target.invoke !== undefined) {
|
|
367
509
|
const step: InvokeStep = {
|
|
368
510
|
name: target.name ?? `Target${i}`,
|
|
369
511
|
when: target.when,
|
package/src/resource-context.ts
CHANGED
|
@@ -344,6 +344,10 @@ export class ResourceContextImpl implements ResourceContext {
|
|
|
344
344
|
return this.kernel.getEntryUrl();
|
|
345
345
|
}
|
|
346
346
|
|
|
347
|
+
getInstallRoot(): string | undefined {
|
|
348
|
+
return this.kernel.getInstallRoot();
|
|
349
|
+
}
|
|
350
|
+
|
|
347
351
|
on(event: string, handler: (payload?: any) => void | Promise<void>): void {
|
|
348
352
|
this.kernel.on(event, handler);
|
|
349
353
|
}
|
package/src/schema-validator.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { evaluate } from "@marcbachmann/cel-js";
|
|
2
|
-
import { DataValidator, RuntimeError, TypeRule } from "@telorun/sdk";
|
|
2
|
+
import { DataValidator, isCompiledValue, RuntimeError, TypeRule } from "@telorun/sdk";
|
|
3
3
|
import AjvModule, { type ValidateFunction } from "ajv";
|
|
4
4
|
import standaloneCodeMod from "ajv/dist/standalone/index.js";
|
|
5
5
|
import addFormats from "ajv-formats";
|
|
@@ -8,7 +8,7 @@ import * as fs from "node:fs";
|
|
|
8
8
|
import { createRequire } from "node:module";
|
|
9
9
|
import * as path from "node:path";
|
|
10
10
|
import { formatAjvErrors } from "./manifest-schemas.js";
|
|
11
|
-
import { ManifestRootSchema, normalizeRefSlots } from "@telorun/templating";
|
|
11
|
+
import { isTaggedSentinel, ManifestRootSchema, normalizeRefSlots } from "@telorun/templating";
|
|
12
12
|
|
|
13
13
|
const Ajv = AjvModule.default ?? AjvModule;
|
|
14
14
|
// AJV's standalone subpath is CJS — the default export shows up as either
|
|
@@ -78,12 +78,50 @@ function verifyAndExtractBody(text: string): string | null {
|
|
|
78
78
|
return actual === match[1] ? body : null;
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
/** Deep-clone `value` collapsing every CEL/template sentinel to its original
|
|
82
|
+
* `source` string, for use as the validator cache *key* only.
|
|
83
|
+
*
|
|
84
|
+
* The same `Telo.Definition` schema reaches `compile()` in two forms: the
|
|
85
|
+
* build-time validator warm feeds the raw analysis graph (parse-time
|
|
86
|
+
* `{__tagged, engine, source}` sentinels, or plain `${{ }}` strings for inline
|
|
87
|
+
* templates), while the runtime feeds the compile-loader output (`{__compiled,
|
|
88
|
+
* source, parts}`). A `!cel` / `!sql` tag embedded anywhere in a schema — most
|
|
89
|
+
* commonly inside `examples` / `description`, but the loader rewrites them at
|
|
90
|
+
* any position — therefore serialises differently between the two, so without
|
|
91
|
+
* normalisation the same kind hashes differently and the runtime misses the
|
|
92
|
+
* warmed cache (and fails to persist it read-only). Both sentinel shapes carry
|
|
93
|
+
* the same original `source`, so collapsing each to that string converges them.
|
|
94
|
+
*
|
|
95
|
+
* This rewrites only the hash key — AJV still compiles the full `injected`
|
|
96
|
+
* schema. Unlike dropping keys by name, it never removes a structural node, so
|
|
97
|
+
* a property literally named `description` / `examples` keeps its schema in the
|
|
98
|
+
* key and two genuinely different shapes never collide. */
|
|
99
|
+
function normalizeSentinelsForHash(value: unknown): unknown {
|
|
100
|
+
if (isCompiledValue(value) || isTaggedSentinel(value)) {
|
|
101
|
+
return typeof value.source === "string" ? value.source : { __teloSentinel: true };
|
|
102
|
+
}
|
|
103
|
+
if (Array.isArray(value)) return value.map(normalizeSentinelsForHash);
|
|
104
|
+
if (value && typeof value === "object") {
|
|
105
|
+
const out: Record<string, unknown> = {};
|
|
106
|
+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
107
|
+
out[k] = normalizeSentinelsForHash(v);
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
return value;
|
|
112
|
+
}
|
|
113
|
+
|
|
81
114
|
export class SchemaValidator {
|
|
82
115
|
private ajv: InstanceType<typeof Ajv>;
|
|
83
116
|
private typeRules = new Map<string, TypeRule[]>();
|
|
84
117
|
private rawSchemas = new Map<string, object>();
|
|
85
118
|
private compiledValidators = new WeakMap<object, DataValidator>();
|
|
86
119
|
private cacheDir: string | undefined;
|
|
120
|
+
/** When false, the disk cache is read-only: compiled validators are still
|
|
121
|
+
* loaded from `cacheDir` but never written back. `telo run --no-cache-write`
|
|
122
|
+
* sets this so an ephemeral, read-only session rootfs validates in-memory
|
|
123
|
+
* without touching (or failing to write) the baked cache. */
|
|
124
|
+
private cacheWritable = true;
|
|
87
125
|
/** Tracks (schema-hash → in-memory compiled validator) so two distinct
|
|
88
126
|
* but content-equal schema objects share one compile across the kernel
|
|
89
127
|
* process — `compiledValidators` is keyed by object identity and would
|
|
@@ -150,8 +188,9 @@ export class SchemaValidator {
|
|
|
150
188
|
* kernel anchors this under `<entry-dir>/.telo/manifests/__validators/`
|
|
151
189
|
* so it lives next to the manifest cache and rides along in
|
|
152
190
|
* `COPY --from=build /srv /srv` Docker images. */
|
|
153
|
-
setCacheDir(dir: string | undefined): void {
|
|
191
|
+
setCacheDir(dir: string | undefined, opts?: { write?: boolean }): void {
|
|
154
192
|
this.cacheDir = dir;
|
|
193
|
+
this.cacheWritable = opts?.write ?? true;
|
|
155
194
|
}
|
|
156
195
|
|
|
157
196
|
compile(schema: any): DataValidator {
|
|
@@ -194,7 +233,12 @@ export class SchemaValidator {
|
|
|
194
233
|
const injected = normalizeRefSlots(withImplicit) as typeof withImplicit;
|
|
195
234
|
|
|
196
235
|
const hash = createHash("sha256")
|
|
197
|
-
.update(
|
|
236
|
+
.update(
|
|
237
|
+
JSON.stringify({
|
|
238
|
+
runtime: VALIDATOR_RUNTIME_TAG,
|
|
239
|
+
schema: normalizeSentinelsForHash(injected),
|
|
240
|
+
}),
|
|
241
|
+
)
|
|
198
242
|
.digest("hex")
|
|
199
243
|
.slice(0, 32);
|
|
200
244
|
const cachedByHash = this.hashCache.get(hash);
|
|
@@ -277,7 +321,7 @@ export class SchemaValidator {
|
|
|
277
321
|
}
|
|
278
322
|
|
|
279
323
|
const validate = this.ajv.compile(schema) as ValidateFunction;
|
|
280
|
-
if (cacheDir) {
|
|
324
|
+
if (cacheDir && this.cacheWritable) {
|
|
281
325
|
try {
|
|
282
326
|
const body = standaloneCode(this.ajv, validate);
|
|
283
327
|
const integrity = createHash("sha256").update(body).digest("hex");
|