@telorun/kernel 1.1.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.
- package/LICENSE +2 -2
- package/dist/controllers/module/import-controller.d.ts +3 -2
- package/dist/controllers/module/import-controller.d.ts.map +1 -1
- package/dist/controllers/module/import-controller.js +23 -25
- package/dist/controllers/module/import-controller.js.map +1 -1
- package/dist/internal-context.d.ts +25 -0
- package/dist/internal-context.d.ts.map +1 -0
- package/dist/internal-context.js +2 -0
- package/dist/internal-context.js.map +1 -0
- package/dist/kernel.d.ts +21 -1
- package/dist/kernel.d.ts.map +1 -1
- package/dist/kernel.js +72 -1
- package/dist/kernel.js.map +1 -1
- package/dist/manifest-schemas.d.ts +7 -23
- package/dist/manifest-schemas.d.ts.map +1 -1
- package/dist/manifest-schemas.js +13 -8
- package/dist/manifest-schemas.js.map +1 -1
- package/dist/manifest-sources/analysis-stamp.d.ts +25 -0
- package/dist/manifest-sources/analysis-stamp.d.ts.map +1 -0
- package/dist/manifest-sources/analysis-stamp.js +151 -0
- package/dist/manifest-sources/analysis-stamp.js.map +1 -0
- package/dist/resource-context.d.ts +2 -0
- package/dist/resource-context.d.ts.map +1 -1
- package/dist/resource-context.js +6 -0
- package/dist/resource-context.js.map +1 -1
- package/dist/schema-validator.d.ts +28 -0
- package/dist/schema-validator.d.ts.map +1 -1
- package/dist/schema-validator.js +156 -1
- package/dist/schema-validator.js.map +1 -1
- package/package.json +3 -3
- package/src/controllers/module/import-controller.ts +33 -36
- package/src/internal-context.ts +25 -0
- package/src/kernel.ts +86 -1
- package/src/manifest-schemas.ts +22 -12
- package/src/manifest-sources/analysis-stamp.ts +169 -0
- package/src/resource-context.ts +8 -0
- package/src/schema-validator.ts +173 -2
|
@@ -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,12 @@ 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";
|
|
33
40
|
import { resolveApplicationEnv } from "./application-env.js";
|
|
34
41
|
import { policyFingerprint } from "./runtime-registry.js";
|
|
35
42
|
import { SchemaValidator } from "./schema-validator.js";
|
|
@@ -118,6 +125,7 @@ export class Kernel implements IKernel {
|
|
|
118
125
|
private rootContext!: ModuleContext;
|
|
119
126
|
private staticManifests: ResourceManifest[] = [];
|
|
120
127
|
private _entryUrl?: string;
|
|
128
|
+
private _loadedGraph?: LoadedGraph;
|
|
121
129
|
// Lifecycle state — guards boot/runTargets/teardown/invoke transitions.
|
|
122
130
|
// teardown() is the only idempotent method; everything else throws on misuse.
|
|
123
131
|
private _bootCalled = false;
|
|
@@ -186,6 +194,37 @@ export class Kernel implements IKernel {
|
|
|
186
194
|
return this.registry;
|
|
187
195
|
}
|
|
188
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
|
+
|
|
189
228
|
/**
|
|
190
229
|
* Load built-in Runtime definitions (e.g., Telo.Application, Telo.Library).
|
|
191
230
|
* Also declares all known module namespaces upfront so that resources can be
|
|
@@ -226,6 +265,16 @@ export class Kernel implements IKernel {
|
|
|
226
265
|
async load(url: string): Promise<void> {
|
|
227
266
|
const sourceUrl = await this.loader.resolveEntryPoint(url);
|
|
228
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
|
+
);
|
|
229
278
|
this.rootContext = new ModuleContext(
|
|
230
279
|
sourceUrl,
|
|
231
280
|
{},
|
|
@@ -254,6 +303,7 @@ export class Kernel implements IKernel {
|
|
|
254
303
|
if (analysisGraph.errors.length > 0) {
|
|
255
304
|
throw analysisGraph.errors[0].error;
|
|
256
305
|
}
|
|
306
|
+
this._loadedGraph = analysisGraph;
|
|
257
307
|
const staticManifests = flattenForAnalyzer(analysisGraph);
|
|
258
308
|
this.staticManifests = staticManifests;
|
|
259
309
|
|
|
@@ -276,7 +326,22 @@ export class Kernel implements IKernel {
|
|
|
276
326
|
}
|
|
277
327
|
}
|
|
278
328
|
|
|
279
|
-
|
|
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
|
+
);
|
|
280
345
|
if (errors.length > 0) {
|
|
281
346
|
throw new RuntimeError(
|
|
282
347
|
"ERR_MANIFEST_VALIDATION_FAILED",
|
|
@@ -291,6 +356,19 @@ export class Kernel implements IKernel {
|
|
|
291
356
|
})),
|
|
292
357
|
);
|
|
293
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
|
+
}
|
|
294
372
|
|
|
295
373
|
// Load runtime configuration — root module gets access to host env.
|
|
296
374
|
// Imports are loaded separately via the import-controller; this load is
|
|
@@ -440,6 +518,13 @@ export class Kernel implements IKernel {
|
|
|
440
518
|
if (this.rootContext) {
|
|
441
519
|
await this.rootContext.teardownResources();
|
|
442
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 = [];
|
|
443
528
|
await this.eventBus.emit("Kernel.Stopped", { exitCode: this._exitCode });
|
|
444
529
|
}
|
|
445
530
|
|
package/src/manifest-schemas.ts
CHANGED
|
@@ -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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
+
}
|
package/src/resource-context.ts
CHANGED
|
@@ -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.
|
package/src/schema-validator.ts
CHANGED
|
@@ -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
|
-
|
|
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) => {
|