@telorun/kernel 0.24.1 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/application-env.d.ts +34 -0
- package/dist/application-env.d.ts.map +1 -1
- package/dist/application-env.js +77 -0
- package/dist/application-env.js.map +1 -1
- package/dist/controller-loaders/npm-loader.d.ts +41 -12
- package/dist/controller-loaders/npm-loader.d.ts.map +1 -1
- package/dist/controller-loaders/npm-loader.js +117 -51
- package/dist/controller-loaders/npm-loader.js.map +1 -1
- package/dist/controller-registry.d.ts +9 -0
- package/dist/controller-registry.d.ts.map +1 -1
- package/dist/controller-registry.js +18 -0
- package/dist/controller-registry.js.map +1 -1
- package/dist/controllers/module/import-controller.d.ts.map +1 -1
- package/dist/controllers/module/import-controller.js +50 -28
- package/dist/controllers/module/import-controller.js.map +1 -1
- package/dist/evaluation-context.d.ts.map +1 -1
- package/dist/evaluation-context.js +13 -5
- package/dist/evaluation-context.js.map +1 -1
- package/dist/kernel.d.ts +13 -2
- package/dist/kernel.d.ts.map +1 -1
- package/dist/kernel.js +41 -2
- package/dist/kernel.js.map +1 -1
- package/dist/module-context.d.ts +56 -7
- package/dist/module-context.d.ts.map +1 -1
- package/dist/module-context.js +121 -10
- package/dist/module-context.js.map +1 -1
- package/dist/schema-validator.d.ts.map +1 -1
- package/dist/schema-validator.js +39 -3
- package/dist/schema-validator.js.map +1 -1
- package/package.json +2 -2
- package/src/application-env.ts +81 -0
- package/src/controller-loaders/npm-loader.ts +124 -58
- package/src/controller-registry.ts +18 -0
- package/src/controllers/module/import-controller.ts +58 -28
- package/src/evaluation-context.ts +15 -5
- package/src/kernel.ts +52 -2
- package/src/module-context.ts +153 -11
- package/src/schema-validator.ts +41 -3
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AnalysisRegistry, DiagnosticSeverity, StaticAnalyzer } from "@telorun/analyzer";
|
|
1
|
+
import { AnalysisRegistry, DiagnosticSeverity, parseExportEntry, StaticAnalyzer } from "@telorun/analyzer";
|
|
2
2
|
import type { ResourceInstance } from "@telorun/sdk";
|
|
3
3
|
import { RuntimeError } from "@telorun/sdk";
|
|
4
4
|
import type { BuiltinControllerContext } from "../../internal-context.js";
|
|
@@ -108,12 +108,27 @@ export async function create(
|
|
|
108
108
|
validateRequiredInputs(moduleManifest.variables ?? {}, resource.variables ?? {}, "variables");
|
|
109
109
|
validateRequiredInputs(moduleManifest.secrets ?? {}, resource.secrets ?? {}, "secrets");
|
|
110
110
|
|
|
111
|
-
//
|
|
112
|
-
//
|
|
111
|
+
// Evaluate the import's variables/secrets ONCE against the IMPORTER's config
|
|
112
|
+
// scope, instead of baking the raw compiled-value objects verbatim. Resolution
|
|
113
|
+
// is eager and per-hop: each importer resolves its child's inputs from its own
|
|
114
|
+
// already-settled config, so config flows app -> lib -> lib at any nesting depth
|
|
115
|
+
// and a leaf reads `variables.X` as an O(1) concrete lookup with no chain-walk.
|
|
116
|
+
// The single concrete result seeds both the child scope and the snapshot value-
|
|
117
|
+
// flow surface, so they can never diverge.
|
|
118
|
+
//
|
|
119
|
+
// NOTE: `expandValue` evaluates against the importer's FULL context (variables /
|
|
120
|
+
// secrets, plus env/resources for a root app). The config-only import contract —
|
|
121
|
+
// no resources/env/ports in import inputs — is enforced by the analyzer, not here;
|
|
122
|
+
// a `${{ resources.X }}` slipping past analysis (skipValidation / programmatic
|
|
123
|
+
// load) would evaluate at runtime rather than being rejected.
|
|
124
|
+
const importVariables =
|
|
125
|
+
(ctx.expandValue(resource.variables, {}) as Record<string, unknown>) ?? {};
|
|
126
|
+
const importSecrets =
|
|
127
|
+
(ctx.expandValue(resource.secrets, {}) as Record<string, unknown>) ?? {};
|
|
113
128
|
const childCtx = new ModuleContext(
|
|
114
129
|
ctx.moduleContext.source,
|
|
115
|
-
|
|
116
|
-
|
|
130
|
+
importVariables,
|
|
131
|
+
importSecrets,
|
|
117
132
|
{},
|
|
118
133
|
[],
|
|
119
134
|
ctx.moduleContext.createInstance,
|
|
@@ -157,8 +172,26 @@ export async function create(
|
|
|
157
172
|
// Throws if resources.X is not yet populated — the kernel retry loop catches this and retries.
|
|
158
173
|
// const evaluatedExports: any = child.expand(moduleManifest.exports ?? {});
|
|
159
174
|
|
|
160
|
-
|
|
161
|
-
|
|
175
|
+
// `exports.kinds` entries are a bare kind name (locally defined) or `Alias.Kind` (a re-export
|
|
176
|
+
// of an imported library's kind). `parseExportEntry` (shared with the analyzer) yields
|
|
177
|
+
// `{name, alias?}` — `name` is the exported kind suffix, `alias` (when set) names this
|
|
178
|
+
// library's own import it re-exports from.
|
|
179
|
+
const kindEntries = ((moduleManifest.exports?.kinds ?? []) as string[]).map(parseExportEntry);
|
|
180
|
+
const exportedKindSuffixes = kindEntries.map((k) => k.name);
|
|
181
|
+
// `exports.resources` entries are a bare name (`Db`, a locally-owned export) or a dotted
|
|
182
|
+
// `Alias.Name` (re-export of an imported instance, under name `Name`) — same grammar as
|
|
183
|
+
// `exports.kinds`.
|
|
184
|
+
const exportEntries = ((moduleManifest.exports?.resources ?? []) as unknown[]).map((e) => {
|
|
185
|
+
if (typeof e !== "string") {
|
|
186
|
+
throw new RuntimeError(
|
|
187
|
+
"ERR_INVALID_EXPORT",
|
|
188
|
+
`Library '${targetModule}' exports.resources entries must be plain names ('Name' or ` +
|
|
189
|
+
`'Alias.Name'); the '!ref' tag is not allowed in exports.resources.`,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
return parseExportEntry(e);
|
|
193
|
+
});
|
|
194
|
+
const exportedResourceNames = exportEntries.map((e) => e.name);
|
|
162
195
|
for (const name of exportedResourceNames) {
|
|
163
196
|
if (name === "variables" || name === "secrets") {
|
|
164
197
|
throw new RuntimeError(
|
|
@@ -167,30 +200,22 @@ export async function create(
|
|
|
167
200
|
);
|
|
168
201
|
}
|
|
169
202
|
}
|
|
170
|
-
ctx.registerModuleImport(alias, targetModule,
|
|
203
|
+
ctx.registerModuleImport(alias, targetModule, exportedKindSuffixes);
|
|
171
204
|
|
|
172
205
|
// Publish the child's exported instances to the parent so cross-module `!ref Alias.name`
|
|
173
206
|
// (Phase 5 injection / boot targets) and `${{ resources.Alias.name }}` (CEL value-flow)
|
|
174
|
-
// resolve. The gate is `exports.resources`; the
|
|
175
|
-
// this import's init()
|
|
207
|
+
// resolve. The gate is `exports.resources`; the child's terminal getter is read lazily —
|
|
208
|
+
// it exists after this import's init() built the child's export table. Handing the parent
|
|
209
|
+
// the child's TERMINAL getter (not a wrapper) keeps resolution O(1) across re-export hops.
|
|
176
210
|
(ctx.moduleContext as ModuleContext).registerImportedScope(
|
|
177
211
|
alias,
|
|
178
212
|
exportedResourceNames,
|
|
179
|
-
(name) =>
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
// owning module directly, matching the analyzer's rule in resolve-ref-sentinels.ts. A
|
|
186
|
-
// re-exported foreign kind (authored via another import alias) is a future case with no
|
|
187
|
-
// current consumer and is left verbatim.
|
|
188
|
-
const rawKind = entry.resource.kind as string;
|
|
189
|
-
const kind = rawKind.startsWith("Self.")
|
|
190
|
-
? `${targetModule}.${rawKind.slice("Self.".length)}`
|
|
191
|
-
: rawKind;
|
|
192
|
-
return { kind, instance: entry.instance };
|
|
193
|
-
},
|
|
213
|
+
(name) => childCtx.getTerminalExport(name),
|
|
214
|
+
);
|
|
215
|
+
// Same for kinds: `kind: Alias.Kind` resolves through the child's exported-kind table,
|
|
216
|
+
// covering both locally-defined and transitively re-exported kinds in O(1).
|
|
217
|
+
(ctx.moduleContext as ModuleContext).registerImportedKindScope(alias, (suffix) =>
|
|
218
|
+
childCtx.getExportedKind(suffix),
|
|
194
219
|
);
|
|
195
220
|
|
|
196
221
|
// Return a ResourceInstance whose snapshot() surfaces the exported values under
|
|
@@ -201,19 +226,24 @@ export async function create(
|
|
|
201
226
|
snapshot: async () => {
|
|
202
227
|
const exported: Record<string, unknown> = {};
|
|
203
228
|
for (const name of exportedResourceNames) {
|
|
204
|
-
const inst = childCtx.
|
|
229
|
+
const inst = childCtx.getExported(name)?.instance;
|
|
205
230
|
if (inst && typeof inst.snapshot === "function") {
|
|
206
231
|
exported[name] = await Promise.resolve(inst.snapshot());
|
|
207
232
|
}
|
|
208
233
|
}
|
|
209
234
|
return {
|
|
210
|
-
variables:
|
|
211
|
-
secrets:
|
|
235
|
+
variables: importVariables,
|
|
236
|
+
secrets: importSecrets,
|
|
212
237
|
...exported,
|
|
213
238
|
};
|
|
214
239
|
},
|
|
215
240
|
init: async () => {
|
|
216
241
|
await child.initializeResources();
|
|
242
|
+
// Build this import's flattened export tables now that its own imports are
|
|
243
|
+
// registered (leaves-first), so a re-export (`!ref Alias.name` / `Alias.Kind`)
|
|
244
|
+
// copies the source import's terminal getter / canonical kind by reference —
|
|
245
|
+
// O(1) resolution at any depth.
|
|
246
|
+
childCtx.buildExportTable(exportEntries, kindEntries, targetModule);
|
|
217
247
|
},
|
|
218
248
|
teardown: async () => {
|
|
219
249
|
await child.teardownResources();
|
|
@@ -501,10 +501,13 @@ export class EvaluationContext implements IEvaluationContext {
|
|
|
501
501
|
// the resolved entry for event topics and error messages.
|
|
502
502
|
const effectiveKind = kind || (entry.resource.kind as string);
|
|
503
503
|
|
|
504
|
-
if (
|
|
504
|
+
if (
|
|
505
|
+
typeof entry.instance.invoke !== "function" &&
|
|
506
|
+
typeof entry.instance.run !== "function"
|
|
507
|
+
) {
|
|
505
508
|
throw new RuntimeError(
|
|
506
509
|
"ERR_RESOURCE_NOT_INVOKABLE",
|
|
507
|
-
`Resource ${effectiveKind}.${name} does not have an invoke method`,
|
|
510
|
+
`Resource ${effectiveKind}.${name} does not have an invoke or run method`,
|
|
508
511
|
);
|
|
509
512
|
}
|
|
510
513
|
|
|
@@ -524,10 +527,10 @@ export class EvaluationContext implements IEvaluationContext {
|
|
|
524
527
|
inputs: TInputs,
|
|
525
528
|
ctx?: InvokeContext,
|
|
526
529
|
): Promise<any> {
|
|
527
|
-
if (typeof instance.invoke !== "function") {
|
|
530
|
+
if (typeof instance.invoke !== "function" && typeof instance.run !== "function") {
|
|
528
531
|
throw new RuntimeError(
|
|
529
532
|
"ERR_RESOURCE_NOT_INVOKABLE",
|
|
530
|
-
`Resource ${kind}.${name} does not have an invoke method`,
|
|
533
|
+
`Resource ${kind}.${name} does not have an invoke or run method`,
|
|
531
534
|
);
|
|
532
535
|
}
|
|
533
536
|
return this.runInvoke(kind, name, instance, inputs, ctx);
|
|
@@ -560,8 +563,15 @@ export class EvaluationContext implements IEvaluationContext {
|
|
|
560
563
|
try {
|
|
561
564
|
// Only (re)establish the ALS scope when the token differs from the ambient
|
|
562
565
|
// one — nested invokes that inherited it skip the redundant `run`.
|
|
566
|
+
// A step / boot-target slot may reference a pure Runnable (the schema
|
|
567
|
+
// allows `telo#Runnable` alongside `telo#Invocable`); dispatch it via
|
|
568
|
+
// `run()` — side effects only, no outputs. Prefer `invoke()` when both
|
|
569
|
+
// exist so a dual-capability instance (e.g. Run.Sequence) keeps invoke
|
|
570
|
+
// semantics and returns its `steps`/`outputs`.
|
|
563
571
|
const call = () =>
|
|
564
|
-
|
|
572
|
+
typeof instance.invoke === "function"
|
|
573
|
+
? (instance.invoke as (i: any, c?: InvokeContext) => any)(inputs as any, invokeCtx)
|
|
574
|
+
: (instance.run as (c?: InvokeContext) => any)(invokeCtx);
|
|
565
575
|
const outputs = await (invokeCtx === ambient
|
|
566
576
|
? call()
|
|
567
577
|
: cancellationStore.run(invokeCtx, call));
|
package/src/kernel.ts
CHANGED
|
@@ -41,7 +41,11 @@ import {
|
|
|
41
41
|
writeAnalysisStamp,
|
|
42
42
|
} from "./manifest-sources/analysis-stamp.js";
|
|
43
43
|
import { resolveEntryDir } from "./manifest-sources/local-manifest-cache-source.js";
|
|
44
|
-
import {
|
|
44
|
+
import {
|
|
45
|
+
precompileApplicationEnvSchemas,
|
|
46
|
+
precompileDefinitionSchemas,
|
|
47
|
+
resolveApplicationEnv,
|
|
48
|
+
} from "./application-env.js";
|
|
45
49
|
import { policyFingerprint } from "./runtime-registry.js";
|
|
46
50
|
import { SchemaValidator } from "./schema-validator.js";
|
|
47
51
|
|
|
@@ -305,8 +309,17 @@ export class Kernel implements IKernel {
|
|
|
305
309
|
* Load a manifest by URL. The URL is dispatched through the registered
|
|
306
310
|
* `ManifestSource` chain (file://, http://, pkg:, memory://, …); URL-shape
|
|
307
311
|
* normalization is each source's responsibility.
|
|
312
|
+
*
|
|
313
|
+
* `analyzeOnly` runs the static-analysis pre-flight and persists its
|
|
314
|
+
* caches (the `.validated.json` analysis stamp and the compiled
|
|
315
|
+
* `__validators/` schema cache) but stops before module instantiation,
|
|
316
|
+
* target wiring, and application-env/secret resolution. Build steps
|
|
317
|
+
* (`telo install`) use it to bake those caches into a prebuilt image on a
|
|
318
|
+
* writable filesystem, so the runtime `load()` — which runs on a read-only
|
|
319
|
+
* session rootfs — hits the stamp and skips the validation walk entirely
|
|
320
|
+
* instead of failing to write the caches on every boot.
|
|
308
321
|
*/
|
|
309
|
-
async load(url: string): Promise<void> {
|
|
322
|
+
async load(url: string, options?: { analyzeOnly?: boolean }): Promise<void> {
|
|
310
323
|
const sourceUrl = await this.loader.resolveEntryPoint(url);
|
|
311
324
|
this._entryUrl = sourceUrl;
|
|
312
325
|
// Point the shared schema validator at the entry-anchored cache so
|
|
@@ -417,6 +430,43 @@ export class Kernel implements IKernel {
|
|
|
417
430
|
}
|
|
418
431
|
}
|
|
419
432
|
|
|
433
|
+
// Build-time warm pass: the analysis caches are now on disk. Also bake the
|
|
434
|
+
// application-env residual validators — the runtime `resolveApplicationEnv`
|
|
435
|
+
// (below) compiles these on EVERY boot regardless of the stamp, so without
|
|
436
|
+
// pre-compiling them here an app with `variables`/`secrets`/`ports` would
|
|
437
|
+
// still hit the read-only `__validators` write on a baked image. Then stop
|
|
438
|
+
// before module instantiation / target wiring / application-env value
|
|
439
|
+
// resolution — those need a running environment (e.g. session secrets) the
|
|
440
|
+
// build does not have, and the runtime `load()` performs them anyway.
|
|
441
|
+
if (options?.analyzeOnly) {
|
|
442
|
+
if (rootModuleDoc?.kind === "Telo.Application") {
|
|
443
|
+
precompileApplicationEnvSchemas(
|
|
444
|
+
rootModuleDoc as Record<string, any>,
|
|
445
|
+
this.sharedSchemaValidator,
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
// Bake the per-kind resource-config validators too. `_createInstance`
|
|
449
|
+
// compiles `controller.schema` (which falls back to the definition's own
|
|
450
|
+
// `schema`) for every resource at runtime — work this analyze-only pass
|
|
451
|
+
// otherwise skips because it stops before instantiation, leaving the
|
|
452
|
+
// runtime to recompile and fail to persist them on a read-only image.
|
|
453
|
+
// Pre-compiling every `Telo.Definition` schema here writes them into the
|
|
454
|
+
// same content-addressed `__validators/` cache the runtime reads.
|
|
455
|
+
precompileDefinitionSchemas(staticManifests, this.sharedSchemaValidator);
|
|
456
|
+
// Framework/builtin controller schemas (`Telo.Import`, `Telo.Definition`,
|
|
457
|
+
// the module controller, …) aren't in the static manifests but are
|
|
458
|
+
// validated per-resource at runtime just the same. They're registered by
|
|
459
|
+
// `loadBuiltinDefinitions` above, so bake them from the registry.
|
|
460
|
+
precompileDefinitionSchemas(
|
|
461
|
+
this.controllers.getControllerSchemas().map((schema) => ({
|
|
462
|
+
kind: "Telo.Definition",
|
|
463
|
+
schema,
|
|
464
|
+
})),
|
|
465
|
+
this.sharedSchemaValidator,
|
|
466
|
+
);
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
|
|
420
470
|
// Load runtime configuration — root module gets access to host env.
|
|
421
471
|
// Imports are loaded separately via the import-controller; this load is
|
|
422
472
|
// entry-only with compile-time CEL.
|
package/src/module-context.ts
CHANGED
|
@@ -103,15 +103,41 @@ export class ModuleContext extends EvaluationContext implements IModuleContext {
|
|
|
103
103
|
/** Maps import alias → its child context's exported instances. Registered by the
|
|
104
104
|
* Telo.Import controller; read when resolving a cross-module `!ref Alias.name`
|
|
105
105
|
* reference (Phase 5 injection and boot targets). `names` is the import's
|
|
106
|
-
* `exports.resources` gate — only listed instances are reachable.
|
|
106
|
+
* `exports.resources` gate — only listed instances are reachable. `terminal`
|
|
107
|
+
* returns the child's pre-flattened terminal getter for a name (a closure that
|
|
108
|
+
* points directly at the OWNING context's instance, however many re-export hops
|
|
109
|
+
* away), so resolution stays O(1) regardless of re-export depth. */
|
|
107
110
|
private readonly importedScopes = new Map<
|
|
108
111
|
string,
|
|
109
112
|
{
|
|
110
113
|
names: Set<string>;
|
|
111
|
-
|
|
114
|
+
terminal: (
|
|
115
|
+
name: string,
|
|
116
|
+
) => (() => { kind: string; instance: ResourceInstance } | undefined) | undefined;
|
|
112
117
|
}
|
|
113
118
|
>();
|
|
114
119
|
|
|
120
|
+
/** This module's flattened export table: export name → terminal getter. A local
|
|
121
|
+
* export's getter reads this context's own `resourceInstances`; a re-export
|
|
122
|
+
* (`!ref Alias.name`) holds the SAME closure object as the owner's entry, copied
|
|
123
|
+
* by reference at build time so depth adds no resolution hops. Built once by the
|
|
124
|
+
* Telo.Import controller after this module's own imports have initialized. */
|
|
125
|
+
private readonly exportedGetters = new Map<
|
|
126
|
+
string,
|
|
127
|
+
() => { kind: string; instance: ResourceInstance } | undefined
|
|
128
|
+
>();
|
|
129
|
+
|
|
130
|
+
/** This module's flattened exported-KIND table: exported kind suffix → canonical
|
|
131
|
+
* `<owningModule>.<Kind>`. A local exported kind maps to `<thisModule>.<Kind>`; a
|
|
132
|
+
* re-export (`exports.kinds: [Alias.Kind]`) copies the source import's already-canonical
|
|
133
|
+
* string, so depth adds no resolution hops. Built by `buildExportTable`. */
|
|
134
|
+
private readonly exportedKinds = new Map<string, string>();
|
|
135
|
+
|
|
136
|
+
/** Maps import alias → a resolver into that import's exported-kind table. Registered by the
|
|
137
|
+
* Telo.Import controller; consulted by `resolveKind` so an imported library's kinds — local
|
|
138
|
+
* OR transitively re-exported — resolve to their true owning module in O(1). */
|
|
139
|
+
private readonly importedKindResolvers = new Map<string, (suffix: string) => string | undefined>();
|
|
140
|
+
|
|
115
141
|
/**
|
|
116
142
|
* Resolved controller-selection policy for this module's `Telo.Definition`s.
|
|
117
143
|
* Stamped by the parent `Telo.Import` controller from the import's `runtime:`
|
|
@@ -202,24 +228,127 @@ export class ModuleContext extends EvaluationContext implements IModuleContext {
|
|
|
202
228
|
}
|
|
203
229
|
|
|
204
230
|
/** Register an import alias's exported instances for cross-module reference resolution.
|
|
205
|
-
* `names` is the gate (the import's `exports.resources`); `
|
|
206
|
-
*
|
|
207
|
-
*
|
|
231
|
+
* `names` is the gate (the import's `exports.resources`); `terminal` returns the child
|
|
232
|
+
* context's pre-flattened terminal getter for a name (existing only after the import's
|
|
233
|
+
* `init()` built the child's export table). */
|
|
208
234
|
registerImportedScope(
|
|
209
235
|
alias: string,
|
|
210
236
|
names: string[],
|
|
211
|
-
|
|
237
|
+
terminal: (
|
|
238
|
+
name: string,
|
|
239
|
+
) => (() => { kind: string; instance: ResourceInstance } | undefined) | undefined,
|
|
212
240
|
): void {
|
|
213
|
-
this.importedScopes.set(alias, { names: new Set(names),
|
|
241
|
+
this.importedScopes.set(alias, { names: new Set(names), terminal });
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** This module's terminal getter for an exported `name`, or undefined. Read by a parent
|
|
245
|
+
* import building its own (re-)export table — copying this closure by reference flattens
|
|
246
|
+
* the chain so resolution never walks hops. */
|
|
247
|
+
getTerminalExport(
|
|
248
|
+
name: string,
|
|
249
|
+
): (() => { kind: string; instance: ResourceInstance } | undefined) | undefined {
|
|
250
|
+
return this.exportedGetters.get(name);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** O(1) resolution of an exported instance owned or re-exported by this module. */
|
|
254
|
+
getExported(name: string): { kind: string; instance: ResourceInstance } | undefined {
|
|
255
|
+
return this.exportedGetters.get(name)?.();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Register an import alias's exported-kind resolver (the child's `getExportedKind`). */
|
|
259
|
+
registerImportedKindScope(alias: string, resolve: (suffix: string) => string | undefined): void {
|
|
260
|
+
this.importedKindResolvers.set(alias, resolve);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** This module's canonical kind for an exported suffix (local or re-exported), or undefined. */
|
|
264
|
+
getExportedKind(suffix: string): string | undefined {
|
|
265
|
+
return this.exportedKinds.get(suffix);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Build this module's flattened export tables from `exports.resources` / `exports.kinds`.
|
|
269
|
+
* A resource entry is a local export (no alias) or a re-export `Alias.Name` (alias set); a
|
|
270
|
+
* kind entry is a local kind (no alias) or a re-export `Alias.Kind` (alias set). A local
|
|
271
|
+
* export gets a fresh terminal getter / `<module>.<Kind>` canonical; a re-export copies the
|
|
272
|
+
* source import's terminal getter / already-canonical kind by reference, collapsing the chain
|
|
273
|
+
* to a single hop. Called once by the Telo.Import controller after this module's own imports
|
|
274
|
+
* have initialized (leaves-first), so re-export sources are already registered — an
|
|
275
|
+
* unresolvable re-export is therefore a permanent misconfiguration and throws here. */
|
|
276
|
+
buildExportTable(
|
|
277
|
+
entries: ReadonlyArray<{ name: string; alias?: string }>,
|
|
278
|
+
kindEntries: ReadonlyArray<{ name: string; alias?: string }>,
|
|
279
|
+
moduleName: string,
|
|
280
|
+
): void {
|
|
281
|
+
for (const entry of entries) {
|
|
282
|
+
const name = entry.name;
|
|
283
|
+
if (entry.alias && entry.alias !== "Self") {
|
|
284
|
+
// A re-export resolves against this module's own imports, which have all initialized
|
|
285
|
+
// by now (leaves-first) — so an unresolved source is a permanent misconfiguration, not
|
|
286
|
+
// a transient miss. Surface it instead of silently dropping the export.
|
|
287
|
+
const scope = this.importedScopes.get(entry.alias);
|
|
288
|
+
if (!scope) {
|
|
289
|
+
throw new RuntimeError(
|
|
290
|
+
"ERR_INVALID_REEXPORT",
|
|
291
|
+
`Library '${moduleName}' re-exports '${entry.alias}.${name}' but declares no import ` +
|
|
292
|
+
`aliased '${entry.alias}'. Add it to this library's 'imports', or remove the ` +
|
|
293
|
+
`'exports.resources' entry.`,
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
const terminal = scope.terminal(name);
|
|
297
|
+
if (!terminal) {
|
|
298
|
+
throw new RuntimeError(
|
|
299
|
+
"ERR_INVALID_REEXPORT",
|
|
300
|
+
`Library '${moduleName}' re-exports '${entry.alias}.${name}', but the imported ` +
|
|
301
|
+
`library '${entry.alias}' exports no instance named '${name}'.`,
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
this.exportedGetters.set(name, terminal);
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
this.exportedGetters.set(name, () => {
|
|
308
|
+
const inst = this.resourceInstances.get(name);
|
|
309
|
+
if (!inst) return undefined;
|
|
310
|
+
// Canonicalize the authored kind to `<module>.<Kind>` so the cross-module ref shape
|
|
311
|
+
// and event naming are scope-independent (matches resolve-ref-sentinels.ts).
|
|
312
|
+
const rawKind = inst.resource.kind as string;
|
|
313
|
+
const kind = rawKind.startsWith("Self.")
|
|
314
|
+
? `${moduleName}.${rawKind.slice("Self.".length)}`
|
|
315
|
+
: rawKind;
|
|
316
|
+
return { kind, instance: inst.instance };
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
for (const k of kindEntries) {
|
|
320
|
+
if (k.alias && k.alias !== "Self") {
|
|
321
|
+
const resolver = this.importedKindResolvers.get(k.alias);
|
|
322
|
+
if (!resolver) {
|
|
323
|
+
throw new RuntimeError(
|
|
324
|
+
"ERR_INVALID_REEXPORT",
|
|
325
|
+
`Library '${moduleName}' re-exports kind '${k.alias}.${k.name}' but declares no ` +
|
|
326
|
+
`import aliased '${k.alias}'. Add it to this library's 'imports', or remove the ` +
|
|
327
|
+
`'exports.kinds' entry.`,
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
const canonical = resolver(k.name);
|
|
331
|
+
if (!canonical) {
|
|
332
|
+
throw new RuntimeError(
|
|
333
|
+
"ERR_INVALID_REEXPORT",
|
|
334
|
+
`Library '${moduleName}' re-exports kind '${k.alias}.${k.name}', but the imported ` +
|
|
335
|
+
`library '${k.alias}' exports no kind named '${k.name}'.`,
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
this.exportedKinds.set(k.name, canonical);
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
this.exportedKinds.set(k.name, `${moduleName}.${k.name}`);
|
|
342
|
+
}
|
|
214
343
|
}
|
|
215
344
|
|
|
216
345
|
/** Resolve `Alias.name` to a live exported instance, gated by `exports.resources`.
|
|
217
346
|
* Returns undefined when the alias is unknown, the name isn't exported, or the import
|
|
218
|
-
* hasn't
|
|
347
|
+
* hasn't built its export table yet (the injection path defers and retries). O(1). */
|
|
219
348
|
override resolveImportedInstance(alias: string, name: string): ResourceInstance | undefined {
|
|
220
349
|
const scope = this.importedScopes.get(alias);
|
|
221
350
|
if (!scope || !scope.names.has(name)) return undefined;
|
|
222
|
-
return scope.
|
|
351
|
+
return scope.terminal(name)?.()?.instance;
|
|
223
352
|
}
|
|
224
353
|
|
|
225
354
|
/** Like `resolveImportedInstance`, but returns the `{kind, name}` ref (canonical kind)
|
|
@@ -228,7 +357,7 @@ export class ModuleContext extends EvaluationContext implements IModuleContext {
|
|
|
228
357
|
resolveImportedRef(alias: string, name: string): { kind: string; name: string } | undefined {
|
|
229
358
|
const scope = this.importedScopes.get(alias);
|
|
230
359
|
if (!scope || !scope.names.has(name)) return undefined;
|
|
231
|
-
const entry = scope.
|
|
360
|
+
const entry = scope.terminal(name)?.();
|
|
232
361
|
return entry ? { kind: entry.kind, name } : undefined;
|
|
233
362
|
}
|
|
234
363
|
|
|
@@ -292,6 +421,14 @@ export class ModuleContext extends EvaluationContext implements IModuleContext {
|
|
|
292
421
|
`Exported kinds: ${[...allowed].join(", ")}`,
|
|
293
422
|
);
|
|
294
423
|
}
|
|
424
|
+
// Re-export override: if this import's exported-kind table maps the suffix to a DIFFERENT
|
|
425
|
+
// owning module, the kind is transitively re-exported (`exports.kinds: [Alias.Kind]`) —
|
|
426
|
+
// resolve to its true owner. A local kind maps to `${realModule}.${suffix}` (no override),
|
|
427
|
+
// and a module without `exports.kinds` has an empty table (unrestricted, unchanged). Built
|
|
428
|
+
// deferred, so before the import inits this returns the un-overridden kind, whose controller
|
|
429
|
+
// miss makes the init loop retry until the table is ready.
|
|
430
|
+
const reExported = this.importedKindResolvers.get(prefix)?.(suffix);
|
|
431
|
+
if (reExported && reExported !== `${realModule}.${suffix}`) return reExported;
|
|
295
432
|
return `${realModule}.${suffix}`;
|
|
296
433
|
}
|
|
297
434
|
|
|
@@ -363,7 +500,12 @@ export class ModuleContext extends EvaluationContext implements IModuleContext {
|
|
|
363
500
|
await this.run(target, ctx);
|
|
364
501
|
continue;
|
|
365
502
|
}
|
|
366
|
-
|
|
503
|
+
// A bare `!ref Alias.name` boot target is Phase-5-injected into the live
|
|
504
|
+
// instance, which for a Run.Sequence exposes both `run()` and `invoke()`.
|
|
505
|
+
// Guard against treating such an instance as an authored inline-invoke
|
|
506
|
+
// step — only a structural `{ invoke: <ref>, inputs }` spec (no `run`)
|
|
507
|
+
// belongs here; the live instance falls through to the runnable branch.
|
|
508
|
+
if (!isRunnableInstance(target) && "invoke" in target && target.invoke !== undefined) {
|
|
367
509
|
const step: InvokeStep = {
|
|
368
510
|
name: target.name ?? `Target${i}`,
|
|
369
511
|
when: target.when,
|
package/src/schema-validator.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { evaluate } from "@marcbachmann/cel-js";
|
|
2
|
-
import { DataValidator, RuntimeError, TypeRule } from "@telorun/sdk";
|
|
2
|
+
import { DataValidator, isCompiledValue, RuntimeError, TypeRule } from "@telorun/sdk";
|
|
3
3
|
import AjvModule, { type ValidateFunction } from "ajv";
|
|
4
4
|
import standaloneCodeMod from "ajv/dist/standalone/index.js";
|
|
5
5
|
import addFormats from "ajv-formats";
|
|
@@ -8,7 +8,7 @@ import * as fs from "node:fs";
|
|
|
8
8
|
import { createRequire } from "node:module";
|
|
9
9
|
import * as path from "node:path";
|
|
10
10
|
import { formatAjvErrors } from "./manifest-schemas.js";
|
|
11
|
-
import { ManifestRootSchema, normalizeRefSlots } from "@telorun/templating";
|
|
11
|
+
import { isTaggedSentinel, ManifestRootSchema, normalizeRefSlots } from "@telorun/templating";
|
|
12
12
|
|
|
13
13
|
const Ajv = AjvModule.default ?? AjvModule;
|
|
14
14
|
// AJV's standalone subpath is CJS — the default export shows up as either
|
|
@@ -78,6 +78,39 @@ function verifyAndExtractBody(text: string): string | null {
|
|
|
78
78
|
return actual === match[1] ? body : null;
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
/** Deep-clone `value` collapsing every CEL/template sentinel to its original
|
|
82
|
+
* `source` string, for use as the validator cache *key* only.
|
|
83
|
+
*
|
|
84
|
+
* The same `Telo.Definition` schema reaches `compile()` in two forms: the
|
|
85
|
+
* build-time validator warm feeds the raw analysis graph (parse-time
|
|
86
|
+
* `{__tagged, engine, source}` sentinels, or plain `${{ }}` strings for inline
|
|
87
|
+
* templates), while the runtime feeds the compile-loader output (`{__compiled,
|
|
88
|
+
* source, parts}`). A `!cel` / `!sql` tag embedded anywhere in a schema — most
|
|
89
|
+
* commonly inside `examples` / `description`, but the loader rewrites them at
|
|
90
|
+
* any position — therefore serialises differently between the two, so without
|
|
91
|
+
* normalisation the same kind hashes differently and the runtime misses the
|
|
92
|
+
* warmed cache (and fails to persist it read-only). Both sentinel shapes carry
|
|
93
|
+
* the same original `source`, so collapsing each to that string converges them.
|
|
94
|
+
*
|
|
95
|
+
* This rewrites only the hash key — AJV still compiles the full `injected`
|
|
96
|
+
* schema. Unlike dropping keys by name, it never removes a structural node, so
|
|
97
|
+
* a property literally named `description` / `examples` keeps its schema in the
|
|
98
|
+
* key and two genuinely different shapes never collide. */
|
|
99
|
+
function normalizeSentinelsForHash(value: unknown): unknown {
|
|
100
|
+
if (isCompiledValue(value) || isTaggedSentinel(value)) {
|
|
101
|
+
return typeof value.source === "string" ? value.source : { __teloSentinel: true };
|
|
102
|
+
}
|
|
103
|
+
if (Array.isArray(value)) return value.map(normalizeSentinelsForHash);
|
|
104
|
+
if (value && typeof value === "object") {
|
|
105
|
+
const out: Record<string, unknown> = {};
|
|
106
|
+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
107
|
+
out[k] = normalizeSentinelsForHash(v);
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
return value;
|
|
112
|
+
}
|
|
113
|
+
|
|
81
114
|
export class SchemaValidator {
|
|
82
115
|
private ajv: InstanceType<typeof Ajv>;
|
|
83
116
|
private typeRules = new Map<string, TypeRule[]>();
|
|
@@ -194,7 +227,12 @@ export class SchemaValidator {
|
|
|
194
227
|
const injected = normalizeRefSlots(withImplicit) as typeof withImplicit;
|
|
195
228
|
|
|
196
229
|
const hash = createHash("sha256")
|
|
197
|
-
.update(
|
|
230
|
+
.update(
|
|
231
|
+
JSON.stringify({
|
|
232
|
+
runtime: VALIDATOR_RUNTIME_TAG,
|
|
233
|
+
schema: normalizeSentinelsForHash(injected),
|
|
234
|
+
}),
|
|
235
|
+
)
|
|
198
236
|
.digest("hex")
|
|
199
237
|
.slice(0, 32);
|
|
200
238
|
const cachedByHash = this.hashCache.get(hash);
|