@telorun/kernel 0.12.0 → 0.13.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 (161) 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/base-definition.d.ts +14 -0
  7. package/dist/base-definition.d.ts.map +1 -0
  8. package/dist/base-definition.js +17 -0
  9. package/dist/base-definition.js.map +1 -0
  10. package/dist/capabilities/capabilities/component.yaml +4 -0
  11. package/dist/capabilities/capabilities/executable.yaml +8 -0
  12. package/dist/capabilities/capabilities/handler.yaml +4 -0
  13. package/dist/capabilities/capabilities/listener.yaml +4 -0
  14. package/dist/capabilities/capabilities/provider.yaml +4 -0
  15. package/dist/capabilities/capabilities/template.yaml +4 -0
  16. package/dist/capabilities/capabilities/type.yaml +4 -0
  17. package/dist/capabilities/component.d.ts +3 -0
  18. package/dist/capabilities/component.d.ts.map +1 -0
  19. package/dist/capabilities/component.js +4 -0
  20. package/dist/capabilities/component.js.map +1 -0
  21. package/dist/capabilities/component.yaml +3 -0
  22. package/dist/capabilities/executable.d.ts +3 -0
  23. package/dist/capabilities/executable.d.ts.map +1 -0
  24. package/dist/capabilities/executable.js +5 -0
  25. package/dist/capabilities/executable.js.map +1 -0
  26. package/dist/capabilities/executable.yaml +7 -0
  27. package/dist/capabilities/handler.d.ts +3 -0
  28. package/dist/capabilities/handler.d.ts.map +1 -0
  29. package/dist/capabilities/handler.js +4 -0
  30. package/dist/capabilities/handler.js.map +1 -0
  31. package/dist/capabilities/handler.yaml +3 -0
  32. package/dist/capabilities/invokable.d.ts +3 -0
  33. package/dist/capabilities/invokable.d.ts.map +1 -0
  34. package/dist/capabilities/invokable.js +5 -0
  35. package/dist/capabilities/invokable.js.map +1 -0
  36. package/dist/capabilities/listener.d.ts +3 -0
  37. package/dist/capabilities/listener.d.ts.map +1 -0
  38. package/dist/capabilities/listener.js +5 -0
  39. package/dist/capabilities/listener.js.map +1 -0
  40. package/dist/capabilities/listener.yaml +3 -0
  41. package/dist/capabilities/mount.d.ts +3 -0
  42. package/dist/capabilities/mount.d.ts.map +1 -0
  43. package/dist/capabilities/mount.js +5 -0
  44. package/dist/capabilities/mount.js.map +1 -0
  45. package/dist/capabilities/provider.d.ts +3 -0
  46. package/dist/capabilities/provider.d.ts.map +1 -0
  47. package/dist/capabilities/provider.js +8 -0
  48. package/dist/capabilities/provider.js.map +1 -0
  49. package/dist/capabilities/provider.yaml +3 -0
  50. package/dist/capabilities/runnable.d.ts +3 -0
  51. package/dist/capabilities/runnable.d.ts.map +1 -0
  52. package/dist/capabilities/runnable.js +5 -0
  53. package/dist/capabilities/runnable.js.map +1 -0
  54. package/dist/capabilities/service.d.ts +3 -0
  55. package/dist/capabilities/service.d.ts.map +1 -0
  56. package/dist/capabilities/service.js +5 -0
  57. package/dist/capabilities/service.js.map +1 -0
  58. package/dist/capabilities/template.d.ts +3 -0
  59. package/dist/capabilities/template.d.ts.map +1 -0
  60. package/dist/capabilities/template.js +5 -0
  61. package/dist/capabilities/template.js.map +1 -0
  62. package/dist/capabilities/template.yaml +3 -0
  63. package/dist/capabilities/type.d.ts +3 -0
  64. package/dist/capabilities/type.d.ts.map +1 -0
  65. package/dist/capabilities/type.js +5 -0
  66. package/dist/capabilities/type.js.map +1 -0
  67. package/dist/capabilities/type.yaml +3 -0
  68. package/dist/controller-loaders/npm-loader.d.ts +32 -8
  69. package/dist/controller-loaders/npm-loader.d.ts.map +1 -1
  70. package/dist/controller-loaders/npm-loader.js +74 -101
  71. package/dist/controller-loaders/npm-loader.js.map +1 -1
  72. package/dist/controllers/capability/capability-controller.d.ts +32 -0
  73. package/dist/controllers/capability/capability-controller.d.ts.map +1 -0
  74. package/dist/controllers/capability/capability-controller.js +26 -0
  75. package/dist/controllers/capability/capability-controller.js.map +1 -0
  76. package/dist/controllers/module/import-controller.d.ts +3 -2
  77. package/dist/controllers/module/import-controller.d.ts.map +1 -1
  78. package/dist/controllers/module/import-controller.js +23 -25
  79. package/dist/controllers/module/import-controller.js.map +1 -1
  80. package/dist/controllers/module/module.json +48 -0
  81. package/dist/controllers/resource-definition/resource-definition-controller.d.ts +1 -0
  82. package/dist/controllers/resource-definition/resource-definition-controller.d.ts.map +1 -1
  83. package/dist/controllers/resource-definition/resource-definition-controller.js +3 -0
  84. package/dist/controllers/resource-definition/resource-definition-controller.js.map +1 -1
  85. package/dist/controllers/resource-definition/resource-template-controller.d.ts +5 -0
  86. package/dist/controllers/resource-definition/resource-template-controller.d.ts.map +1 -1
  87. package/dist/controllers/resource-definition/resource-template-controller.js +67 -6
  88. package/dist/controllers/resource-definition/resource-template-controller.js.map +1 -1
  89. package/dist/internal-context.d.ts +25 -0
  90. package/dist/internal-context.d.ts.map +1 -0
  91. package/dist/internal-context.js +2 -0
  92. package/dist/internal-context.js.map +1 -0
  93. package/dist/kernel.d.ts +21 -1
  94. package/dist/kernel.d.ts.map +1 -1
  95. package/dist/kernel.js +109 -5
  96. package/dist/kernel.js.map +1 -1
  97. package/dist/loader.d.ts +18 -0
  98. package/dist/loader.d.ts.map +1 -0
  99. package/dist/loader.js +127 -0
  100. package/dist/loader.js.map +1 -0
  101. package/dist/manifest-adapters/http-adapter.d.ts +8 -0
  102. package/dist/manifest-adapters/http-adapter.d.ts.map +1 -0
  103. package/dist/manifest-adapters/http-adapter.js +31 -0
  104. package/dist/manifest-adapters/http-adapter.js.map +1 -0
  105. package/dist/manifest-adapters/local-file-adapter.d.ts +15 -0
  106. package/dist/manifest-adapters/local-file-adapter.d.ts.map +1 -0
  107. package/dist/manifest-adapters/local-file-adapter.js +95 -0
  108. package/dist/manifest-adapters/local-file-adapter.js.map +1 -0
  109. package/dist/manifest-adapters/manifest-adapter.d.ts +35 -0
  110. package/dist/manifest-adapters/manifest-adapter.d.ts.map +1 -0
  111. package/dist/manifest-adapters/manifest-adapter.js +2 -0
  112. package/dist/manifest-adapters/manifest-adapter.js.map +1 -0
  113. package/dist/manifest-adapters/registry-adapter.d.ts +9 -0
  114. package/dist/manifest-adapters/registry-adapter.d.ts.map +1 -0
  115. package/dist/manifest-adapters/registry-adapter.js +48 -0
  116. package/dist/manifest-adapters/registry-adapter.js.map +1 -0
  117. package/dist/manifest-schemas.d.ts +7 -23
  118. package/dist/manifest-schemas.d.ts.map +1 -1
  119. package/dist/manifest-schemas.js +18 -8
  120. package/dist/manifest-schemas.js.map +1 -1
  121. package/dist/manifest-sources/analysis-stamp.d.ts +25 -0
  122. package/dist/manifest-sources/analysis-stamp.d.ts.map +1 -0
  123. package/dist/manifest-sources/analysis-stamp.js +151 -0
  124. package/dist/manifest-sources/analysis-stamp.js.map +1 -0
  125. package/dist/module-context-registry.d.ts +48 -0
  126. package/dist/module-context-registry.d.ts.map +1 -0
  127. package/dist/module-context-registry.js +91 -0
  128. package/dist/module-context-registry.js.map +1 -0
  129. package/dist/resource-context.d.ts +2 -0
  130. package/dist/resource-context.d.ts.map +1 -1
  131. package/dist/resource-context.js +28 -0
  132. package/dist/resource-context.js.map +1 -1
  133. package/dist/schema-valiator.d.ts +15 -0
  134. package/dist/schema-valiator.d.ts.map +1 -0
  135. package/dist/schema-valiator.js +127 -0
  136. package/dist/schema-valiator.js.map +1 -0
  137. package/dist/schema-validator.d.ts +28 -0
  138. package/dist/schema-validator.d.ts.map +1 -1
  139. package/dist/schema-validator.js +161 -1
  140. package/dist/schema-validator.js.map +1 -1
  141. package/dist/snapshot-serializer.d.ts +62 -0
  142. package/dist/snapshot-serializer.d.ts.map +1 -0
  143. package/dist/snapshot-serializer.js +164 -0
  144. package/dist/snapshot-serializer.js.map +1 -0
  145. package/dist/types.d.ts +65 -0
  146. package/dist/types.d.ts.map +1 -0
  147. package/dist/types.js +8 -0
  148. package/dist/types.js.map +1 -0
  149. package/package.json +9 -6
  150. package/src/application-env.ts +216 -0
  151. package/src/controller-loaders/npm-loader.ts +78 -103
  152. package/src/controllers/module/import-controller.ts +33 -36
  153. package/src/controllers/resource-definition/resource-definition-controller.ts +6 -0
  154. package/src/controllers/resource-definition/resource-template-controller.ts +95 -7
  155. package/src/internal-context.ts +25 -0
  156. package/src/kernel.ts +130 -5
  157. package/src/manifest-schemas.ts +31 -11
  158. package/src/manifest-sources/analysis-stamp.ts +169 -0
  159. package/src/resource-context.ts +34 -0
  160. package/src/schema-validator.ts +178 -2
  161. package/dist/generated/runtime-deps.json +0 -6
@@ -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
+ }
@@ -13,6 +13,7 @@ import {
13
13
  type ParsedArgs,
14
14
  type TypeRule,
15
15
  } from "@telorun/sdk";
16
+ import { isRefSentinel } from "@telorun/templating";
16
17
  import AjvModule from "ajv";
17
18
  import addFormats from "ajv-formats";
18
19
  import { EvaluationContext } from "./evaluation-context.js";
@@ -176,6 +177,14 @@ export class ResourceContextImpl implements ResourceContext {
176
177
  return this.kernel.loadManifests(url);
177
178
  }
178
179
 
180
+ isImportValidatedAtLoad(url: string): boolean {
181
+ return this.kernel.isImportValidatedAtLoad(url);
182
+ }
183
+
184
+ resolveImportUrl(fromSource: string, importSource: string): string {
185
+ return this.kernel.resolveImportUrl(fromSource, importSource);
186
+ }
187
+
179
188
  /**
180
189
  * Resolves a resource into a normalized {kind, name} reference.
181
190
  * If the resource contains a definition (kind + properties), registers it as a manifest.
@@ -194,6 +203,31 @@ export class ResourceContextImpl implements ResourceContext {
194
203
  );
195
204
  }
196
205
 
206
+ // Stopgap: `!ref <name>` sentinels can reach the controller directly
207
+ // when the slot is hidden behind a local `$ref: "#/$defs/..."` — the
208
+ // analyzer's field-map walker descends `oneOf`/`anyOf` variant
209
+ // properties but intentionally early-returns on `$ref` (see
210
+ // `analyzer/nodejs/src/reference-field-map.ts`). Enabling the `$ref`
211
+ // descent regresses the kernel's `<Kind>.<Name>.Invoked` event
212
+ // emission for kinds (notably `Run.Sequence`) whose controllers
213
+ // call `instance.invoke()` directly on Phase-5-injected instances;
214
+ // the walker fix needs to land together with routing those callers
215
+ // through `EvaluationContext.invokeResolved`. Until then, the Node
216
+ // kernel resolves the sentinel here. Polyglot controllers don't get
217
+ // this rescue — schemas exercising those hidden slots must use the
218
+ // legacy string or `{kind, name}` forms for now.
219
+ if (isRefSentinel(resource)) {
220
+ const refName = resource.source;
221
+ const entry = this.moduleContext.resourceInstances.get(refName);
222
+ if (!entry) {
223
+ throw new RuntimeError(
224
+ "ERR_RESOURCE_NOT_FOUND",
225
+ `[${this.metadata.name}] !ref '${refName}' did not resolve to a registered resource.`,
226
+ );
227
+ }
228
+ return { kind: entry.resource.kind as string, name: refName };
229
+ }
230
+
197
231
  if (!resource.kind) {
198
232
  throw new RuntimeError(
199
233
  "ERR_INVALID_VALUE",
@@ -1,22 +1,104 @@
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";
11
+ import { ManifestRootSchema } from "@telorun/templating";
6
12
 
7
13
  const Ajv = AjvModule.default ?? AjvModule;
14
+ // AJV's standalone subpath is CJS — the default export shows up as either
15
+ // the function itself or `.default` depending on how the bundler/loader
16
+ // rewrites it. Normalise once.
17
+ const standaloneCode: (...args: any[]) => string =
18
+ (standaloneCodeMod as any).default ?? (standaloneCodeMod as any);
19
+
20
+ /** `require` resolved from this file's URL — used to satisfy `ajv/dist/...`
21
+ * / `ajv-formats/...` imports embedded in standalone-compiled validators
22
+ * loaded back off disk. Anchored here so it always resolves through the
23
+ * kernel package's node_modules, regardless of where the cache file
24
+ * lives on disk. */
25
+ const kernelRequire = createRequire(import.meta.url);
26
+
27
+ /** Resolved AJV + ajv-formats versions, baked into every cache key so a
28
+ * pnpm/npm install that upgrades either package invalidates all stale
29
+ * `<hash>.cjs` files automatically. Standalone-compiled validators
30
+ * embed `require("ajv/dist/runtime/...")` — running a validator built
31
+ * against an older AJV against the current runtime is undefined
32
+ * behaviour, so the version pin must be part of the hash, not a manual
33
+ * bump. Falls back to walking up from the package's main entry when
34
+ * the dependency restricts subpath access via `exports`. */
35
+ function readDepVersion(spec: string): string {
36
+ try {
37
+ const pkg = kernelRequire(`${spec}/package.json`);
38
+ if (typeof pkg.version === "string") return pkg.version;
39
+ } catch {
40
+ // restricted exports — try the filesystem walk below
41
+ }
42
+ try {
43
+ const entry = kernelRequire.resolve(spec);
44
+ let dir = path.dirname(entry);
45
+ while (dir !== path.dirname(dir)) {
46
+ const candidate = path.join(dir, "package.json");
47
+ try {
48
+ const pkg = JSON.parse(fs.readFileSync(candidate, "utf-8"));
49
+ const expectedName = spec.split("/").slice(0, spec.startsWith("@") ? 2 : 1).join("/");
50
+ if (typeof pkg.name === "string" && pkg.name === expectedName) {
51
+ return typeof pkg.version === "string" ? pkg.version : "unknown";
52
+ }
53
+ } catch {
54
+ // keep walking — not at the package root yet
55
+ }
56
+ dir = path.dirname(dir);
57
+ }
58
+ } catch {
59
+ // package not installed
60
+ }
61
+ return "unknown";
62
+ }
63
+ const AJV_VERSION = readDepVersion("ajv");
64
+ const AJV_FORMATS_VERSION = readDepVersion("ajv-formats");
65
+ const VALIDATOR_RUNTIME_TAG = `ajv@${AJV_VERSION}+ajv-formats@${AJV_FORMATS_VERSION}`;
66
+
67
+ const SHA256_HEADER_PATTERN = /^\/\/ sha256:([0-9a-f]{64})\n/;
68
+
69
+ /** Verify a cached validator file's SHA-256 integrity header and return
70
+ * the body when the digest matches. Returns `null` on any mismatch /
71
+ * malformed header — the caller treats that as a cache miss and
72
+ * recompiles + overwrites the file. */
73
+ function verifyAndExtractBody(text: string): string | null {
74
+ const match = text.match(SHA256_HEADER_PATTERN);
75
+ if (!match) return null;
76
+ const body = text.slice(match[0].length);
77
+ const actual = createHash("sha256").update(body).digest("hex");
78
+ return actual === match[1] ? body : null;
79
+ }
8
80
 
9
81
  export class SchemaValidator {
10
82
  private ajv: InstanceType<typeof Ajv>;
11
83
  private typeRules = new Map<string, TypeRule[]>();
12
84
  private rawSchemas = new Map<string, object>();
13
85
  private compiledValidators = new WeakMap<object, DataValidator>();
86
+ private cacheDir: string | undefined;
87
+ /** Tracks (schema-hash → in-memory compiled validator) so two distinct
88
+ * but content-equal schema objects share one compile across the kernel
89
+ * process — `compiledValidators` is keyed by object identity and would
90
+ * miss those cases. */
91
+ private hashCache = new Map<string, DataValidator>();
14
92
 
15
93
  constructor() {
16
94
  this.ajv = new Ajv({
17
95
  strict: false,
18
96
  removeAdditional: false,
19
97
  useDefaults: true,
98
+ // Required for `standaloneCode` extraction — tells AJV to keep the
99
+ // generated validator's source available rather than wrapping it
100
+ // through `new Function`. The cost at compile time is negligible.
101
+ code: { source: true },
20
102
  });
21
103
  addFormats.default(this.ajv);
22
104
  for (const kw of [
@@ -33,6 +115,10 @@ export class SchemaValidator {
33
115
  ]) {
34
116
  this.ajv.addKeyword(kw);
35
117
  }
118
+ // Register the shared manifest root so module schemas can
119
+ // `$ref: "telo://manifest#/$defs/ResourceRef"` without each manifest
120
+ // bundling its own copy. Mirrors the analyzer's createAjv().
121
+ this.ajv.addSchema(ManifestRootSchema);
36
122
  }
37
123
 
38
124
  addSchema(name: string, schema: object): void {
@@ -54,6 +140,19 @@ export class SchemaValidator {
54
140
  return this.typeRules.get(name);
55
141
  }
56
142
 
143
+ /** Enable the on-disk validator cache rooted at `dir`. Compiled AJV
144
+ * validators are written as standalone CJS modules keyed by content
145
+ * hash, so subsequent process invocations skip the ≈2–10 ms AJV
146
+ * codegen for each unseen schema. Safe to call before or after
147
+ * `compile()` — already-compiled in-memory entries are unaffected.
148
+ * The caller is responsible for choosing a writable directory; the
149
+ * kernel anchors this under `<entry-dir>/.telo/manifests/__validators/`
150
+ * so it lives next to the manifest cache and rides along in
151
+ * `COPY --from=build /srv /srv` Docker images. */
152
+ setCacheDir(dir: string | undefined): void {
153
+ this.cacheDir = dir;
154
+ }
155
+
57
156
  compile(schema: any): DataValidator {
58
157
  if (schema && typeof schema === "object") {
59
158
  const cached = this.compiledValidators.get(schema as object);
@@ -85,7 +184,20 @@ export class SchemaValidator {
85
184
  },
86
185
  }
87
186
  : normalized;
88
- const validate = this.ajv.compile(injected);
187
+
188
+ const hash = createHash("sha256")
189
+ .update(JSON.stringify({ runtime: VALIDATOR_RUNTIME_TAG, schema: injected }))
190
+ .digest("hex")
191
+ .slice(0, 32);
192
+ const cachedByHash = this.hashCache.get(hash);
193
+ if (cachedByHash) {
194
+ if (schema && typeof schema === "object") {
195
+ this.compiledValidators.set(schema as object, cachedByHash);
196
+ }
197
+ return cachedByHash;
198
+ }
199
+
200
+ const validate = this.compileAjvOrLoadCached(injected, hash);
89
201
 
90
202
  const validator = {
91
203
  validate: (data: any) => {
@@ -102,6 +214,7 @@ export class SchemaValidator {
102
214
  },
103
215
  };
104
216
 
217
+ this.hashCache.set(hash, validator);
105
218
  if (schema && typeof schema === "object") {
106
219
  this.compiledValidators.set(schema as object, validator);
107
220
  }
@@ -109,6 +222,69 @@ export class SchemaValidator {
109
222
  return validator;
110
223
  }
111
224
 
225
+ /** Load `<cacheDir>/<hash>.cjs` if present, else compile via AJV and
226
+ * persist as standalone CJS. Cached files start with a
227
+ * `// sha256:<hex>\n` header covering the rest of the file; a
228
+ * mismatch (truncated write, FS corruption, tampering inside a baked
229
+ * Docker image) is treated as a cache miss and the validator is
230
+ * recompiled — and overwritten — so the cache self-heals. The cached
231
+ * body is wrapped so its embedded `require("ajv/...")` /
232
+ * `require("ajv-formats/...")` calls resolve against the kernel
233
+ * package; the cache file lives outside any `node_modules` tree, so a
234
+ * bare `require()` from its own path would fail. Read/write failures
235
+ * surface to stderr but never abort compilation. */
236
+ private compileAjvOrLoadCached(
237
+ schema: any,
238
+ hash: string,
239
+ ): ValidateFunction {
240
+ const cacheDir = this.cacheDir;
241
+ if (cacheDir) {
242
+ const cachePath = path.join(cacheDir, `${hash}.cjs`);
243
+ try {
244
+ const text = fs.readFileSync(cachePath, "utf-8");
245
+ const body = verifyAndExtractBody(text);
246
+ if (body !== null) {
247
+ const factory = new Function(
248
+ "require",
249
+ "module",
250
+ "exports",
251
+ `${body}\nreturn module.exports;`,
252
+ );
253
+ const mod: { exports: any } = { exports: {} };
254
+ const loaded = factory(kernelRequire, mod, mod.exports);
255
+ if (typeof loaded === "function") {
256
+ return loaded as ValidateFunction;
257
+ }
258
+ }
259
+ // Header missing / mismatched / non-function export — fall
260
+ // through and recompile. The write step below overwrites the
261
+ // stale file with a fresh hash header.
262
+ } catch (err) {
263
+ if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") {
264
+ process.stderr.write(
265
+ `[telo:kernel] validator cache load failed (${hash}): ${err instanceof Error ? err.message : String(err)}\n`,
266
+ );
267
+ }
268
+ }
269
+ }
270
+
271
+ const validate = this.ajv.compile(schema) as ValidateFunction;
272
+ if (cacheDir) {
273
+ try {
274
+ const body = standaloneCode(this.ajv, validate);
275
+ const integrity = createHash("sha256").update(body).digest("hex");
276
+ const payload = `// sha256:${integrity}\n${body}`;
277
+ fs.mkdirSync(cacheDir, { recursive: true });
278
+ fs.writeFileSync(path.join(cacheDir, `${hash}.cjs`), payload, "utf-8");
279
+ } catch (err) {
280
+ process.stderr.write(
281
+ `[telo:kernel] validator cache write failed (${hash}): ${err instanceof Error ? err.message : String(err)}\n`,
282
+ );
283
+ }
284
+ }
285
+ return validate;
286
+ }
287
+
112
288
  composeWithRules(base: DataValidator, typeName: string, rules: TypeRule[]): DataValidator {
113
289
  return {
114
290
  validate: (data: any) => {
@@ -1,6 +0,0 @@
1
- {
2
- "generated": "scripts/generate-runtime-deps.mjs",
3
- "names": [
4
- "@telorun/sdk"
5
- ]
6
- }