@telorun/kernel 0.12.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/LICENSE +2 -2
  2. package/dist/application-env.d.ts +24 -0
  3. package/dist/application-env.d.ts.map +1 -0
  4. package/dist/application-env.js +156 -0
  5. package/dist/application-env.js.map +1 -0
  6. package/dist/controller-loaders/npm-loader.d.ts +1 -6
  7. package/dist/controller-loaders/npm-loader.d.ts.map +1 -1
  8. package/dist/controller-loaders/npm-loader.js +21 -62
  9. package/dist/controller-loaders/npm-loader.js.map +1 -1
  10. package/dist/controllers/module/import-controller.d.ts +3 -2
  11. package/dist/controllers/module/import-controller.d.ts.map +1 -1
  12. package/dist/controllers/module/import-controller.js +23 -25
  13. package/dist/controllers/module/import-controller.js.map +1 -1
  14. package/dist/controllers/resource-definition/resource-definition-controller.d.ts +1 -0
  15. package/dist/controllers/resource-definition/resource-definition-controller.d.ts.map +1 -1
  16. package/dist/controllers/resource-definition/resource-definition-controller.js +3 -0
  17. package/dist/controllers/resource-definition/resource-definition-controller.js.map +1 -1
  18. package/dist/controllers/resource-definition/resource-template-controller.d.ts +5 -0
  19. package/dist/controllers/resource-definition/resource-template-controller.d.ts.map +1 -1
  20. package/dist/controllers/resource-definition/resource-template-controller.js +67 -6
  21. package/dist/controllers/resource-definition/resource-template-controller.js.map +1 -1
  22. package/dist/internal-context.d.ts +25 -0
  23. package/dist/internal-context.d.ts.map +1 -0
  24. package/dist/internal-context.js +2 -0
  25. package/dist/internal-context.js.map +1 -0
  26. package/dist/kernel.d.ts +21 -1
  27. package/dist/kernel.d.ts.map +1 -1
  28. package/dist/kernel.js +91 -4
  29. package/dist/kernel.js.map +1 -1
  30. package/dist/manifest-schemas.d.ts +7 -23
  31. package/dist/manifest-schemas.d.ts.map +1 -1
  32. package/dist/manifest-schemas.js +13 -8
  33. package/dist/manifest-schemas.js.map +1 -1
  34. package/dist/manifest-sources/analysis-stamp.d.ts +25 -0
  35. package/dist/manifest-sources/analysis-stamp.d.ts.map +1 -0
  36. package/dist/manifest-sources/analysis-stamp.js +151 -0
  37. package/dist/manifest-sources/analysis-stamp.js.map +1 -0
  38. package/dist/resource-context.d.ts +2 -0
  39. package/dist/resource-context.d.ts.map +1 -1
  40. package/dist/resource-context.js +6 -0
  41. package/dist/resource-context.js.map +1 -1
  42. package/dist/schema-validator.d.ts +28 -0
  43. package/dist/schema-validator.d.ts.map +1 -1
  44. package/dist/schema-validator.js +156 -1
  45. package/dist/schema-validator.js.map +1 -1
  46. package/package.json +7 -6
  47. package/src/application-env.ts +216 -0
  48. package/src/controller-loaders/npm-loader.ts +21 -62
  49. package/src/controllers/module/import-controller.ts +33 -36
  50. package/src/controllers/resource-definition/resource-definition-controller.ts +6 -0
  51. package/src/controllers/resource-definition/resource-template-controller.ts +95 -7
  52. package/src/internal-context.ts +25 -0
  53. package/src/kernel.ts +110 -4
  54. package/src/manifest-schemas.ts +22 -12
  55. package/src/manifest-sources/analysis-stamp.ts +169 -0
  56. package/src/resource-context.ts +8 -0
  57. package/src/schema-validator.ts +173 -2
  58. package/dist/generated/runtime-deps.json +0 -6
@@ -0,0 +1,25 @@
1
+ import type { ResourceContext } from "@telorun/sdk";
2
+
3
+ /**
4
+ * Context interface used by built-in kernel controllers (Telo.Application /
5
+ * Telo.Library / Telo.Import / Telo.Definition / Telo.Abstract) that need
6
+ * privileged access to load-time graph identity. These methods are
7
+ * intentionally *not* on the public `ResourceContext` exposed to module
8
+ * authors — they couple the caller to the kernel's load-time view of the
9
+ * world, and the import-controller is the only consumer today.
10
+ *
11
+ * `ResourceContextImpl` in this package implements both interfaces, so a
12
+ * controller authored against this type still works under the generic
13
+ * `controller.create(resource, ctx)` dispatch — the kernel just types it
14
+ * locally as `BuiltinControllerContext` instead of `ResourceContext`.
15
+ */
16
+ export interface BuiltinControllerContext extends ResourceContext {
17
+ /** True when `url` resolved (via the loader's URL → canonical-source
18
+ * map) to a module that was part of the entry graph successfully
19
+ * analyzed during `Kernel.load()`. */
20
+ isImportValidatedAtLoad(url: string): boolean;
21
+ /** Resolve `importSource` against `fromSource` through the loader's
22
+ * source-chain `resolveRelative`. Identical to what `loadGraph` used
23
+ * internally — so the produced URL agrees with the loader's caches. */
24
+ resolveImportUrl(fromSource: string, importSource: string): string;
25
+ }
package/src/kernel.ts CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  isModuleKind,
6
6
  Loader,
7
7
  StaticAnalyzer,
8
+ type LoadedGraph,
8
9
  type ManifestSource,
9
10
  } from "@telorun/analyzer";
10
11
  import {
@@ -30,6 +31,13 @@ import { EventStream } from "./event-stream.js";
30
31
  import { EventBus } from "./events.js";
31
32
  import { ModuleContext } from "./module-context.js";
32
33
  import { ResourceContextImpl } from "./resource-context.js";
34
+ import {
35
+ computeAnalysisSignature,
36
+ readAnalysisStamp,
37
+ writeAnalysisStamp,
38
+ } from "./manifest-sources/analysis-stamp.js";
39
+ import { resolveEntryDir } from "./manifest-sources/local-manifest-cache-source.js";
40
+ import { resolveApplicationEnv } from "./application-env.js";
33
41
  import { policyFingerprint } from "./runtime-registry.js";
34
42
  import { SchemaValidator } from "./schema-validator.js";
35
43
 
@@ -117,6 +125,7 @@ export class Kernel implements IKernel {
117
125
  private rootContext!: ModuleContext;
118
126
  private staticManifests: ResourceManifest[] = [];
119
127
  private _entryUrl?: string;
128
+ private _loadedGraph?: LoadedGraph;
120
129
  // Lifecycle state — guards boot/runTargets/teardown/invoke transitions.
121
130
  // teardown() is the only idempotent method; everything else throws on misuse.
122
131
  private _bootCalled = false;
@@ -185,6 +194,37 @@ export class Kernel implements IKernel {
185
194
  return this.registry;
186
195
  }
187
196
 
197
+ /** The full LoadedGraph captured during `load()`. Used by the CLI to
198
+ * feed `writeManifestCache` so a successful `telo run` populates
199
+ * `<entry-dir>/.telo/manifests/` for subsequent runs — the same on-disk
200
+ * layout `telo install` writes. Undefined before `load()` has been
201
+ * called or if it threw before the graph was captured. */
202
+ getLoadedGraph(): LoadedGraph | undefined {
203
+ return this._loadedGraph;
204
+ }
205
+
206
+ /** True when `url` resolves (via the loader's URL → canonical-source map)
207
+ * to a module that was already part of the entry graph successfully
208
+ * analyzed during `load()`. The import-controller uses it to skip its
209
+ * per-import `analyze()` pass when the kernel's load-time validation
210
+ * already covered the same subtree. */
211
+ isImportValidatedAtLoad(url: string): boolean {
212
+ if (!this._loadedGraph) return false;
213
+ const canonical = this.loader.canonicalize(url);
214
+ if (!canonical) return false;
215
+ return this._loadedGraph.modules.has(canonical);
216
+ }
217
+
218
+ /** Resolve a `Telo.Import.source` against the importing file's URL
219
+ * through the same source-chain `resolveRelative` the loader used at
220
+ * graph-walk time. The import-controller routes through here so its
221
+ * `resolveImportSource` no longer second-guesses the loader for
222
+ * custom `ManifestSource`s — `isImportValidatedAtLoad` etc. only hit
223
+ * when both sides agree on the canonical URL. */
224
+ resolveImportUrl(fromSource: string, importSource: string): string {
225
+ return this.loader.resolveImportUrl(fromSource, importSource);
226
+ }
227
+
188
228
  /**
189
229
  * Load built-in Runtime definitions (e.g., Telo.Application, Telo.Library).
190
230
  * Also declares all known module namespaces upfront so that resources can be
@@ -225,6 +265,16 @@ export class Kernel implements IKernel {
225
265
  async load(url: string): Promise<void> {
226
266
  const sourceUrl = await this.loader.resolveEntryPoint(url);
227
267
  this._entryUrl = sourceUrl;
268
+ // Point the shared schema validator at the entry-anchored cache so
269
+ // compiled AJV validators are persisted (standalone CJS) under
270
+ // `<entry-dir>/.telo/manifests/__validators/`. Memory- or HTTP-rooted
271
+ // entries skip the cache; their schema compiles stay in-process only.
272
+ const validatorCacheDir = resolveEntryDir(sourceUrl);
273
+ this.sharedSchemaValidator.setCacheDir(
274
+ validatorCacheDir
275
+ ? `${validatorCacheDir}/.telo/manifests/__validators`
276
+ : undefined,
277
+ );
228
278
  this.rootContext = new ModuleContext(
229
279
  sourceUrl,
230
280
  {},
@@ -253,6 +303,7 @@ export class Kernel implements IKernel {
253
303
  if (analysisGraph.errors.length > 0) {
254
304
  throw analysisGraph.errors[0].error;
255
305
  }
306
+ this._loadedGraph = analysisGraph;
256
307
  const staticManifests = flattenForAnalyzer(analysisGraph);
257
308
  this.staticManifests = staticManifests;
258
309
 
@@ -275,7 +326,22 @@ export class Kernel implements IKernel {
275
326
  }
276
327
  }
277
328
 
278
- const errors = this.analyzer.analyzeErrors(staticManifests, {}, this.registry);
329
+ // Hash-keyed analysis cache: when the entry's full LoadedGraph matches
330
+ // a previously-stamped successful run (same file bytes, same stamp
331
+ // protocol version), skip the per-resource validation walk inside
332
+ // `analyzeErrors`. Registration of identities / aliases / definitions
333
+ // and inline-resource normalisation still runs — only the diagnostic
334
+ // passes are elided. Memory- / HTTP-rooted entries have no
335
+ // local stamp store and always re-validate.
336
+ const entryDir = resolveEntryDir(sourceUrl);
337
+ const analysisSignature = computeAnalysisSignature(analysisGraph);
338
+ const stamp = entryDir ? await readAnalysisStamp(entryDir) : undefined;
339
+ const skipValidation = stamp?.signature === analysisSignature;
340
+ const errors = this.analyzer.analyzeErrors(
341
+ staticManifests,
342
+ { skipValidation },
343
+ this.registry,
344
+ );
279
345
  if (errors.length > 0) {
280
346
  throw new RuntimeError(
281
347
  "ERR_MANIFEST_VALIDATION_FAILED",
@@ -290,6 +356,19 @@ export class Kernel implements IKernel {
290
356
  })),
291
357
  );
292
358
  }
359
+ if (entryDir && !skipValidation) {
360
+ // Best-effort: stamp the verdict so subsequent loads hit the fast
361
+ // path. A read-only filesystem (baked Docker image) reports the
362
+ // failure on stderr and keeps running — the lookup above will
363
+ // simply miss next time.
364
+ try {
365
+ await writeAnalysisStamp(entryDir, analysisSignature);
366
+ } catch (err) {
367
+ this.stderr.write(
368
+ `[telo:kernel] analysis stamp write failed: ${err instanceof Error ? err.message : String(err)}\n`,
369
+ );
370
+ }
371
+ }
293
372
 
294
373
  // Load runtime configuration — root module gets access to host env.
295
374
  // Imports are loaded separately via the import-controller; this load is
@@ -304,15 +383,35 @@ export class Kernel implements IKernel {
304
383
  const normalizedManifests = this.analyzer.normalize(allManifests, this.registry);
305
384
  this.staticManifests = normalizedManifests;
306
385
 
386
+ let rootApplicationManifest: ResourceManifest | undefined;
307
387
  for (const manifest of normalizedManifests) {
308
388
  if (isModuleKind(manifest.kind)) {
309
- // Root is always Telo.Application (Library root rejected above). Applications
310
- // have no variables/secrets fields those are a Library-only contract, populated
311
- // by importers, not by the root manifest itself.
389
+ // Root is always Telo.Application (Library root rejected above).
390
+ // Application-level `variables` / `secrets` declarations carry an `env:`
391
+ // mapping per field; the kernel populates the root scope from
392
+ // `process.env` after the manifest loop so imports can read
393
+ // `${{ variables.X }}` during their own init.
312
394
  this.rootContext.setTargets(manifest.targets ?? []);
395
+ if (manifest.kind === "Telo.Application") {
396
+ rootApplicationManifest = manifest;
397
+ }
313
398
  }
314
399
  this.rootContext.registerManifest(manifest);
315
400
  }
401
+
402
+ if (rootApplicationManifest) {
403
+ const { variables, secrets } = resolveApplicationEnv(
404
+ rootApplicationManifest as Record<string, any>,
405
+ this.env,
406
+ this.sharedSchemaValidator,
407
+ );
408
+ if (Object.keys(variables).length > 0) {
409
+ this.rootContext.setVariables(variables);
410
+ }
411
+ if (Object.keys(secrets).length > 0) {
412
+ this.rootContext.setSecrets(secrets);
413
+ }
414
+ }
316
415
  }
317
416
 
318
417
  /**
@@ -419,6 +518,13 @@ export class Kernel implements IKernel {
419
518
  if (this.rootContext) {
420
519
  await this.rootContext.teardownResources();
421
520
  }
521
+ // Drop the load-time graph so a teardown'd kernel doesn't pin every
522
+ // manifest file's text in memory (LoadedFile retains the parsed
523
+ // documents + the original YAML bytes). Reusing the kernel after
524
+ // teardown is a hard error elsewhere, so this is purely a memory
525
+ // hygiene step.
526
+ this._loadedGraph = undefined;
527
+ this.staticManifests = [];
422
528
  await this.eventBus.emit("Kernel.Stopped", { exitCode: this._exitCode });
423
529
  }
424
530
 
@@ -1,16 +1,7 @@
1
- import { Type } from "@sinclair/typebox";
2
1
  import AjvModule from "ajv";
3
2
  import addFormats from "ajv-formats";
4
3
  const Ajv = AjvModule.default ?? AjvModule;
5
4
 
6
- export const RuntimeResourceSchema = Type.Object(
7
- {
8
- kind: Type.String(),
9
- metadata: Type.Object({ name: Type.String() }, { additionalProperties: true }),
10
- },
11
- { additionalProperties: true },
12
- );
13
-
14
5
  const metadataSchema = {
15
6
  type: "object",
16
7
  required: ["name"],
@@ -149,9 +140,28 @@ export const ResourceAbstractSchema = {
149
140
  const ajv = new Ajv({ allErrors: true, strict: false });
150
141
  addFormats.default(ajv);
151
142
 
152
- export const validateRuntimeResource = ajv.compile(RuntimeResourceSchema);
153
- export const validateResourceDefinition = ajv.compile(ResourceDefinitionSchema);
154
- export const validateResourceAbstract = ajv.compile(ResourceAbstractSchema);
143
+ // Lazy-compile validator: the AJV codegen cost (≈10–15 ms for these
144
+ // schemas) is only paid when a definition / abstract actually needs
145
+ // validating. A hello-world that loads no Telo.Definition or
146
+ // Telo.Abstract documents never triggers either compile; apps that
147
+ // do see them only pay once per process.
148
+ interface LazyValidator {
149
+ (data: unknown): boolean | Promise<unknown>;
150
+ errors?: any[] | null;
151
+ }
152
+ function lazyValidator(schema: object): LazyValidator {
153
+ let compiled: ReturnType<typeof ajv.compile> | undefined;
154
+ const fn: LazyValidator = (data: unknown) => {
155
+ if (!compiled) compiled = ajv.compile(schema);
156
+ const ok = compiled(data);
157
+ fn.errors = compiled.errors as any[] | null | undefined;
158
+ return ok;
159
+ };
160
+ return fn;
161
+ }
162
+
163
+ export const validateResourceDefinition = lazyValidator(ResourceDefinitionSchema);
164
+ export const validateResourceAbstract = lazyValidator(ResourceAbstractSchema);
155
165
 
156
166
  export function formatAjvErrors(errors: any[] | null | undefined): string {
157
167
  if (!errors || errors.length === 0) return "Unknown schema error";
@@ -0,0 +1,169 @@
1
+ import type { LoadedGraph } from "@telorun/analyzer";
2
+ import { createHash } from "crypto";
3
+ import { readFileSync } from "fs";
4
+ import * as fs from "fs/promises";
5
+ import { createRequire } from "module";
6
+ import * as path from "path";
7
+ import { fileURLToPath } from "url";
8
+
9
+ /**
10
+ * Hash-keyed analysis cache: a tiny JSON sidecar in `.telo/manifests/`
11
+ * recording that an exact set of manifest bytes — under specific
12
+ * `@telorun/kernel` and `@telorun/analyzer` package versions — passed
13
+ * `analyzer.analyzeErrors`. The next `kernel.load` reads the sidecar
14
+ * and, if signatures match, skips the per-resource validation walk.
15
+ *
16
+ * Lives next to the manifest cache (`LocalManifestCacheSource`) but is
17
+ * independent of it — splitting both for grep-ability and because the
18
+ * concerns (URL → file content vs. content → analyzer verdict) are
19
+ * orthogonal.
20
+ */
21
+
22
+ const CACHE_SUBDIR = ".telo/manifests";
23
+
24
+ /** File-format version of the analysis stamp envelope. Only bumped when
25
+ * the on-disk *layout* changes (new fields, restructured payload). The
26
+ * *semantic* invalidation — "did the analyzer's logic change?" — is
27
+ * handled by baking the resolved `@telorun/analyzer` / `@telorun/kernel`
28
+ * package versions into the signature itself, so any pnpm/npm install
29
+ * that bumps either package automatically invalidates every stamp on
30
+ * disk. A hand-maintained integer for that purpose would silently mask
31
+ * newly-stricter validation until the next manifest edit. */
32
+ const ANALYSIS_STAMP_FORMAT_VERSION = 1;
33
+ const ANALYSIS_STAMP_FILE = `${CACHE_SUBDIR}/.validated.json`;
34
+
35
+ const localRequire = createRequire(import.meta.url);
36
+
37
+ /** Read the kernel's own `package.json` — `createRequire` can't resolve
38
+ * `@telorun/kernel/package.json` from inside the kernel package itself
39
+ * (the self-reference loops in some node_modules layouts). The file
40
+ * sits two levels up from `dist/manifest-sources/`. */
41
+ function readKernelVersion(): string {
42
+ try {
43
+ const url = new URL("../../package.json", import.meta.url);
44
+ const pkg = JSON.parse(readFileSync(fileURLToPath(url), "utf-8"));
45
+ return typeof pkg.version === "string" ? pkg.version : "unknown";
46
+ } catch {
47
+ return "unknown";
48
+ }
49
+ }
50
+
51
+ function readDepVersion(spec: string): string {
52
+ // Fast path: direct `require("<pkg>/package.json")`. Fails (with
53
+ // ERR_PACKAGE_PATH_NOT_EXPORTED) when the dependency declares a strict
54
+ // `exports` map without listing `./package.json` — common for packages
55
+ // that consider package.json an implementation detail. Don't return
56
+ // "unknown" in that case; fall back to resolving the package's main
57
+ // entry and walking the filesystem up to its package.json.
58
+ const pkgJsonSpec = spec.endsWith("/package.json")
59
+ ? spec
60
+ : `${spec}/package.json`;
61
+ try {
62
+ const pkg = localRequire(pkgJsonSpec);
63
+ if (typeof pkg.version === "string") return pkg.version;
64
+ } catch {
65
+ // fall through to filesystem walk
66
+ }
67
+ try {
68
+ const mainSpec = spec.endsWith("/package.json") ? spec.slice(0, -13) : spec;
69
+ const entry = localRequire.resolve(mainSpec);
70
+ let dir = path.dirname(entry);
71
+ while (dir !== path.dirname(dir)) {
72
+ const candidate = path.join(dir, "package.json");
73
+ try {
74
+ const pkg = JSON.parse(readFileSync(candidate, "utf-8"));
75
+ // Guard against scoped-package interior package.json files (some
76
+ // packages stamp one in dist/) — match by name when the spec
77
+ // names a package.
78
+ const expectedName = mainSpec
79
+ .split("/")
80
+ .slice(0, mainSpec.startsWith("@") ? 2 : 1)
81
+ .join("/");
82
+ if (typeof pkg.name === "string" && pkg.name === expectedName) {
83
+ return typeof pkg.version === "string" ? pkg.version : "unknown";
84
+ }
85
+ } catch {
86
+ // not at the package root yet — keep walking
87
+ }
88
+ dir = path.dirname(dir);
89
+ }
90
+ } catch {
91
+ // resolution failed — package not installed at all
92
+ }
93
+ return "unknown";
94
+ }
95
+
96
+ const KERNEL_VERSION = readKernelVersion();
97
+ const ANALYZER_VERSION = readDepVersion("@telorun/analyzer");
98
+
99
+ export interface AnalysisStamp {
100
+ version: number;
101
+ signature: string;
102
+ }
103
+
104
+ /** Hash every owner + partial file in `graph` together with the resolved
105
+ * `@telorun/kernel` and `@telorun/analyzer` versions into one content
106
+ * signature. Two loads of the same manifest set under the same package
107
+ * versions produce the same signature; any edit to any reachable file —
108
+ * or any pnpm/npm install that bumps the kernel or analyzer — flips it.
109
+ * This is what the kernel uses to decide whether the previous analyzer
110
+ * run's verdict still applies. */
111
+ export function computeAnalysisSignature(graph: LoadedGraph): string {
112
+ const entries: Array<[string, string]> = [];
113
+ for (const [, mod] of graph.modules) {
114
+ for (const file of [mod.owner, ...mod.partials]) {
115
+ const digest = createHash("sha256").update(file.text).digest("hex");
116
+ entries.push([file.source, digest]);
117
+ }
118
+ }
119
+ entries.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
120
+ return createHash("sha256")
121
+ .update(
122
+ JSON.stringify({
123
+ kernel: KERNEL_VERSION,
124
+ analyzer: ANALYZER_VERSION,
125
+ files: entries,
126
+ }),
127
+ )
128
+ .digest("hex");
129
+ }
130
+
131
+ /** Read the stamped analysis verdict for the entry at `entryDir`, or
132
+ * `undefined` when missing / unreadable / format-mismatched. The
133
+ * `version` field is the on-disk *format* version; semantic
134
+ * invalidation flows through the signature (which embeds package
135
+ * versions). A future format change bumps `version` so older kernels
136
+ * reading a newer stamp (or vice versa) discard rather than misparse. */
137
+ export async function readAnalysisStamp(
138
+ entryDir: string,
139
+ ): Promise<AnalysisStamp | undefined> {
140
+ try {
141
+ const text = await fs.readFile(path.join(entryDir, ANALYSIS_STAMP_FILE), "utf-8");
142
+ const parsed = JSON.parse(text) as Partial<AnalysisStamp>;
143
+ if (
144
+ parsed?.version === ANALYSIS_STAMP_FORMAT_VERSION &&
145
+ typeof parsed?.signature === "string"
146
+ ) {
147
+ return parsed as AnalysisStamp;
148
+ }
149
+ } catch {
150
+ // missing / unreadable / unparseable — treat as cache miss
151
+ }
152
+ return undefined;
153
+ }
154
+
155
+ /** Persist the analysis verdict so the next `kernel.load` can skip the
156
+ * per-resource validation walk when the manifest set is unchanged.
157
+ * Idempotent; safe to call after every successful load. */
158
+ export async function writeAnalysisStamp(
159
+ entryDir: string,
160
+ signature: string,
161
+ ): Promise<void> {
162
+ const stamp: AnalysisStamp = {
163
+ version: ANALYSIS_STAMP_FORMAT_VERSION,
164
+ signature,
165
+ };
166
+ const target = path.join(entryDir, ANALYSIS_STAMP_FILE);
167
+ await fs.mkdir(path.dirname(target), { recursive: true });
168
+ await fs.writeFile(target, JSON.stringify(stamp), "utf-8");
169
+ }
@@ -176,6 +176,14 @@ export class ResourceContextImpl implements ResourceContext {
176
176
  return this.kernel.loadManifests(url);
177
177
  }
178
178
 
179
+ isImportValidatedAtLoad(url: string): boolean {
180
+ return this.kernel.isImportValidatedAtLoad(url);
181
+ }
182
+
183
+ resolveImportUrl(fromSource: string, importSource: string): string {
184
+ return this.kernel.resolveImportUrl(fromSource, importSource);
185
+ }
186
+
179
187
  /**
180
188
  * Resolves a resource into a normalized {kind, name} reference.
181
189
  * If the resource contains a definition (kind + properties), registers it as a manifest.
@@ -1,22 +1,103 @@
1
1
  import { evaluate } from "@marcbachmann/cel-js";
2
2
  import { DataValidator, RuntimeError, TypeRule } from "@telorun/sdk";
3
- import AjvModule from "ajv";
3
+ import AjvModule, { type ValidateFunction } from "ajv";
4
+ import standaloneCodeMod from "ajv/dist/standalone/index.js";
4
5
  import addFormats from "ajv-formats";
6
+ import { createHash } from "node:crypto";
7
+ import * as fs from "node:fs";
8
+ import { createRequire } from "node:module";
9
+ import * as path from "node:path";
5
10
  import { formatAjvErrors } from "./manifest-schemas.js";
6
11
 
7
12
  const Ajv = AjvModule.default ?? AjvModule;
13
+ // AJV's standalone subpath is CJS — the default export shows up as either
14
+ // the function itself or `.default` depending on how the bundler/loader
15
+ // rewrites it. Normalise once.
16
+ const standaloneCode: (...args: any[]) => string =
17
+ (standaloneCodeMod as any).default ?? (standaloneCodeMod as any);
18
+
19
+ /** `require` resolved from this file's URL — used to satisfy `ajv/dist/...`
20
+ * / `ajv-formats/...` imports embedded in standalone-compiled validators
21
+ * loaded back off disk. Anchored here so it always resolves through the
22
+ * kernel package's node_modules, regardless of where the cache file
23
+ * lives on disk. */
24
+ const kernelRequire = createRequire(import.meta.url);
25
+
26
+ /** Resolved AJV + ajv-formats versions, baked into every cache key so a
27
+ * pnpm/npm install that upgrades either package invalidates all stale
28
+ * `<hash>.cjs` files automatically. Standalone-compiled validators
29
+ * embed `require("ajv/dist/runtime/...")` — running a validator built
30
+ * against an older AJV against the current runtime is undefined
31
+ * behaviour, so the version pin must be part of the hash, not a manual
32
+ * bump. Falls back to walking up from the package's main entry when
33
+ * the dependency restricts subpath access via `exports`. */
34
+ function readDepVersion(spec: string): string {
35
+ try {
36
+ const pkg = kernelRequire(`${spec}/package.json`);
37
+ if (typeof pkg.version === "string") return pkg.version;
38
+ } catch {
39
+ // restricted exports — try the filesystem walk below
40
+ }
41
+ try {
42
+ const entry = kernelRequire.resolve(spec);
43
+ let dir = path.dirname(entry);
44
+ while (dir !== path.dirname(dir)) {
45
+ const candidate = path.join(dir, "package.json");
46
+ try {
47
+ const pkg = JSON.parse(fs.readFileSync(candidate, "utf-8"));
48
+ const expectedName = spec.split("/").slice(0, spec.startsWith("@") ? 2 : 1).join("/");
49
+ if (typeof pkg.name === "string" && pkg.name === expectedName) {
50
+ return typeof pkg.version === "string" ? pkg.version : "unknown";
51
+ }
52
+ } catch {
53
+ // keep walking — not at the package root yet
54
+ }
55
+ dir = path.dirname(dir);
56
+ }
57
+ } catch {
58
+ // package not installed
59
+ }
60
+ return "unknown";
61
+ }
62
+ const AJV_VERSION = readDepVersion("ajv");
63
+ const AJV_FORMATS_VERSION = readDepVersion("ajv-formats");
64
+ const VALIDATOR_RUNTIME_TAG = `ajv@${AJV_VERSION}+ajv-formats@${AJV_FORMATS_VERSION}`;
65
+
66
+ const SHA256_HEADER_PATTERN = /^\/\/ sha256:([0-9a-f]{64})\n/;
67
+
68
+ /** Verify a cached validator file's SHA-256 integrity header and return
69
+ * the body when the digest matches. Returns `null` on any mismatch /
70
+ * malformed header — the caller treats that as a cache miss and
71
+ * recompiles + overwrites the file. */
72
+ function verifyAndExtractBody(text: string): string | null {
73
+ const match = text.match(SHA256_HEADER_PATTERN);
74
+ if (!match) return null;
75
+ const body = text.slice(match[0].length);
76
+ const actual = createHash("sha256").update(body).digest("hex");
77
+ return actual === match[1] ? body : null;
78
+ }
8
79
 
9
80
  export class SchemaValidator {
10
81
  private ajv: InstanceType<typeof Ajv>;
11
82
  private typeRules = new Map<string, TypeRule[]>();
12
83
  private rawSchemas = new Map<string, object>();
13
84
  private compiledValidators = new WeakMap<object, DataValidator>();
85
+ private cacheDir: string | undefined;
86
+ /** Tracks (schema-hash → in-memory compiled validator) so two distinct
87
+ * but content-equal schema objects share one compile across the kernel
88
+ * process — `compiledValidators` is keyed by object identity and would
89
+ * miss those cases. */
90
+ private hashCache = new Map<string, DataValidator>();
14
91
 
15
92
  constructor() {
16
93
  this.ajv = new Ajv({
17
94
  strict: false,
18
95
  removeAdditional: false,
19
96
  useDefaults: true,
97
+ // Required for `standaloneCode` extraction — tells AJV to keep the
98
+ // generated validator's source available rather than wrapping it
99
+ // through `new Function`. The cost at compile time is negligible.
100
+ code: { source: true },
20
101
  });
21
102
  addFormats.default(this.ajv);
22
103
  for (const kw of [
@@ -54,6 +135,19 @@ export class SchemaValidator {
54
135
  return this.typeRules.get(name);
55
136
  }
56
137
 
138
+ /** Enable the on-disk validator cache rooted at `dir`. Compiled AJV
139
+ * validators are written as standalone CJS modules keyed by content
140
+ * hash, so subsequent process invocations skip the ≈2–10 ms AJV
141
+ * codegen for each unseen schema. Safe to call before or after
142
+ * `compile()` — already-compiled in-memory entries are unaffected.
143
+ * The caller is responsible for choosing a writable directory; the
144
+ * kernel anchors this under `<entry-dir>/.telo/manifests/__validators/`
145
+ * so it lives next to the manifest cache and rides along in
146
+ * `COPY --from=build /srv /srv` Docker images. */
147
+ setCacheDir(dir: string | undefined): void {
148
+ this.cacheDir = dir;
149
+ }
150
+
57
151
  compile(schema: any): DataValidator {
58
152
  if (schema && typeof schema === "object") {
59
153
  const cached = this.compiledValidators.get(schema as object);
@@ -85,7 +179,20 @@ export class SchemaValidator {
85
179
  },
86
180
  }
87
181
  : normalized;
88
- const validate = this.ajv.compile(injected);
182
+
183
+ const hash = createHash("sha256")
184
+ .update(JSON.stringify({ runtime: VALIDATOR_RUNTIME_TAG, schema: injected }))
185
+ .digest("hex")
186
+ .slice(0, 32);
187
+ const cachedByHash = this.hashCache.get(hash);
188
+ if (cachedByHash) {
189
+ if (schema && typeof schema === "object") {
190
+ this.compiledValidators.set(schema as object, cachedByHash);
191
+ }
192
+ return cachedByHash;
193
+ }
194
+
195
+ const validate = this.compileAjvOrLoadCached(injected, hash);
89
196
 
90
197
  const validator = {
91
198
  validate: (data: any) => {
@@ -102,6 +209,7 @@ export class SchemaValidator {
102
209
  },
103
210
  };
104
211
 
212
+ this.hashCache.set(hash, validator);
105
213
  if (schema && typeof schema === "object") {
106
214
  this.compiledValidators.set(schema as object, validator);
107
215
  }
@@ -109,6 +217,69 @@ export class SchemaValidator {
109
217
  return validator;
110
218
  }
111
219
 
220
+ /** Load `<cacheDir>/<hash>.cjs` if present, else compile via AJV and
221
+ * persist as standalone CJS. Cached files start with a
222
+ * `// sha256:<hex>\n` header covering the rest of the file; a
223
+ * mismatch (truncated write, FS corruption, tampering inside a baked
224
+ * Docker image) is treated as a cache miss and the validator is
225
+ * recompiled — and overwritten — so the cache self-heals. The cached
226
+ * body is wrapped so its embedded `require("ajv/...")` /
227
+ * `require("ajv-formats/...")` calls resolve against the kernel
228
+ * package; the cache file lives outside any `node_modules` tree, so a
229
+ * bare `require()` from its own path would fail. Read/write failures
230
+ * surface to stderr but never abort compilation. */
231
+ private compileAjvOrLoadCached(
232
+ schema: any,
233
+ hash: string,
234
+ ): ValidateFunction {
235
+ const cacheDir = this.cacheDir;
236
+ if (cacheDir) {
237
+ const cachePath = path.join(cacheDir, `${hash}.cjs`);
238
+ try {
239
+ const text = fs.readFileSync(cachePath, "utf-8");
240
+ const body = verifyAndExtractBody(text);
241
+ if (body !== null) {
242
+ const factory = new Function(
243
+ "require",
244
+ "module",
245
+ "exports",
246
+ `${body}\nreturn module.exports;`,
247
+ );
248
+ const mod: { exports: any } = { exports: {} };
249
+ const loaded = factory(kernelRequire, mod, mod.exports);
250
+ if (typeof loaded === "function") {
251
+ return loaded as ValidateFunction;
252
+ }
253
+ }
254
+ // Header missing / mismatched / non-function export — fall
255
+ // through and recompile. The write step below overwrites the
256
+ // stale file with a fresh hash header.
257
+ } catch (err) {
258
+ if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") {
259
+ process.stderr.write(
260
+ `[telo:kernel] validator cache load failed (${hash}): ${err instanceof Error ? err.message : String(err)}\n`,
261
+ );
262
+ }
263
+ }
264
+ }
265
+
266
+ const validate = this.ajv.compile(schema) as ValidateFunction;
267
+ if (cacheDir) {
268
+ try {
269
+ const body = standaloneCode(this.ajv, validate);
270
+ const integrity = createHash("sha256").update(body).digest("hex");
271
+ const payload = `// sha256:${integrity}\n${body}`;
272
+ fs.mkdirSync(cacheDir, { recursive: true });
273
+ fs.writeFileSync(path.join(cacheDir, `${hash}.cjs`), payload, "utf-8");
274
+ } catch (err) {
275
+ process.stderr.write(
276
+ `[telo:kernel] validator cache write failed (${hash}): ${err instanceof Error ? err.message : String(err)}\n`,
277
+ );
278
+ }
279
+ }
280
+ return validate;
281
+ }
282
+
112
283
  composeWithRules(base: DataValidator, typeName: string, rules: TypeRule[]): DataValidator {
113
284
  return {
114
285
  validate: (data: any) => {
@@ -1,6 +0,0 @@
1
- {
2
- "generated": "scripts/generate-runtime-deps.mjs",
3
- "names": [
4
- "@telorun/sdk"
5
- ]
6
- }