@telorun/kernel 0.84.0 → 0.86.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/bundle/module-artifact.d.ts +1 -1
- package/dist/bundle/module-artifact.d.ts.map +1 -1
- package/dist/bundle/module-artifact.js +3 -3
- package/dist/bundle/module-artifact.js.map +1 -1
- package/dist/controller-loaders/bundle-loader.d.ts +5 -5
- package/dist/controller-loaders/bundle-loader.js +7 -7
- package/dist/controller-loaders/bundle-loader.js.map +1 -1
- package/dist/evaluation-context.d.ts +158 -25
- package/dist/evaluation-context.d.ts.map +1 -1
- package/dist/evaluation-context.js +234 -44
- package/dist/evaluation-context.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/kernel.d.ts +60 -5
- package/dist/kernel.d.ts.map +1 -1
- package/dist/kernel.js +256 -9
- package/dist/kernel.js.map +1 -1
- package/dist/manifest-sources/local-manifest-cache-source.d.ts +6 -6
- package/dist/manifest-sources/local-manifest-cache-source.d.ts.map +1 -1
- package/dist/manifest-sources/local-manifest-cache-source.js +11 -12
- package/dist/manifest-sources/local-manifest-cache-source.js.map +1 -1
- package/dist/module-context.d.ts +31 -1
- package/dist/module-context.d.ts.map +1 -1
- package/dist/module-context.js +41 -1
- package/dist/module-context.js.map +1 -1
- package/dist/reconcile.d.ts +42 -0
- package/dist/reconcile.d.ts.map +1 -0
- package/dist/reconcile.js +58 -0
- package/dist/reconcile.js.map +1 -0
- package/dist/resource-edges.d.ts +58 -0
- package/dist/resource-edges.d.ts.map +1 -0
- package/dist/resource-edges.js +110 -0
- package/dist/resource-edges.js.map +1 -0
- package/dist/runtime-seam.d.ts.map +1 -1
- package/dist/runtime-seam.js +1 -2
- package/dist/runtime-seam.js.map +1 -1
- package/dist/transports/http-transport.d.ts +37 -0
- package/dist/transports/http-transport.d.ts.map +1 -0
- package/dist/transports/http-transport.js +168 -0
- package/dist/transports/http-transport.js.map +1 -0
- package/dist/transports/transport-registry.d.ts +13 -14
- package/dist/transports/transport-registry.d.ts.map +1 -1
- package/dist/transports/transport-registry.js +17 -24
- package/dist/transports/transport-registry.js.map +1 -1
- package/package.json +3 -3
- package/src/bundle/module-artifact.ts +2 -3
- package/src/controller-loaders/bundle-loader.ts +7 -7
- package/src/evaluation-context.ts +257 -53
- package/src/index.ts +1 -1
- package/src/kernel.ts +314 -12
- package/src/manifest-sources/local-manifest-cache-source.ts +9 -16
- package/src/module-context.ts +41 -1
- package/src/reconcile.ts +83 -0
- package/src/resource-edges.ts +114 -0
- package/src/runtime-seam.ts +1 -2
- package/src/transports/http-transport.ts +209 -0
- package/src/transports/transport-registry.ts +17 -24
- package/dist/transports/registry-transport.d.ts +0 -41
- package/dist/transports/registry-transport.d.ts.map +0 -1
- package/dist/transports/registry-transport.js +0 -282
- package/dist/transports/registry-transport.js.map +0 -1
- package/src/transports/registry-transport.ts +0 -339
package/src/kernel.ts
CHANGED
|
@@ -3,12 +3,16 @@ import {
|
|
|
3
3
|
authoredModuleMetadata,
|
|
4
4
|
buildEvalPaths,
|
|
5
5
|
collectZoneModuleDocuments,
|
|
6
|
+
declarationSignature,
|
|
7
|
+
diffManifests,
|
|
6
8
|
flattenForAnalyzer,
|
|
7
9
|
flattenLoadedModule,
|
|
8
10
|
isModuleKind,
|
|
11
|
+
nodeIdFor,
|
|
9
12
|
Loader,
|
|
10
13
|
StaticAnalyzer,
|
|
11
14
|
type DefResolver,
|
|
15
|
+
type DiffEntry,
|
|
12
16
|
type LoadedGraph,
|
|
13
17
|
type ManifestSource,
|
|
14
18
|
} from "@telorun/analyzer";
|
|
@@ -47,6 +51,11 @@ import { KernelTracer } from "./tracing.js";
|
|
|
47
51
|
import { KernelLogging, type LoggingManifestBlock } from "./logging/kernel-logging.js";
|
|
48
52
|
import type { ScopeConfig } from "./logging/scope-config.js";
|
|
49
53
|
import { formatSpanCounter } from "./logging/span-id.js";
|
|
54
|
+
import {
|
|
55
|
+
graphFileSources,
|
|
56
|
+
modulesThatMoved,
|
|
57
|
+
type ReconcileOutcome,
|
|
58
|
+
} from "./reconcile.js";
|
|
50
59
|
import { ambientInvokeContext } from "./evaluation-context.js";
|
|
51
60
|
import { ModuleContext } from "./module-context.js";
|
|
52
61
|
import { ResourceContextImpl } from "./resource-context.js";
|
|
@@ -118,6 +127,11 @@ function throwInvalidState(operation: string, reason: string): never {
|
|
|
118
127
|
);
|
|
119
128
|
}
|
|
120
129
|
|
|
130
|
+
/** Docs that register a KIND. `DefinitionRegistry` only ever adds, so a change
|
|
131
|
+
* to one of these cannot be reconciled into a running kernel — the previous
|
|
132
|
+
* registration would survive it. */
|
|
133
|
+
const DEFINITION_KINDS: ReadonlySet<string> = new Set(["Telo.Definition", "Telo.Abstract"]);
|
|
134
|
+
|
|
121
135
|
export interface KernelOptions {
|
|
122
136
|
stdin?: NodeJS.ReadableStream;
|
|
123
137
|
stdout?: NodeJS.WritableStream;
|
|
@@ -129,10 +143,6 @@ export interface KernelOptions {
|
|
|
129
143
|
* fails to dispatch). Order matters — later entries take priority over
|
|
130
144
|
* earlier ones (sources are unshifted onto the dispatch chain). */
|
|
131
145
|
sources: ManifestSource[];
|
|
132
|
-
/** Base URL for the registry source. When unset, the `RegistrySource`
|
|
133
|
-
* default applies. Callers (e.g. the CLI) are responsible for resolving
|
|
134
|
-
* `TELO_REGISTRY_URL` or any other env-based fallback before passing. */
|
|
135
|
-
registryUrl?: string;
|
|
136
146
|
}
|
|
137
147
|
|
|
138
148
|
/**
|
|
@@ -176,6 +186,24 @@ export class Kernel implements IKernel {
|
|
|
176
186
|
* which module's library layer each resolves to. Rebuilt on every `load()`. */
|
|
177
187
|
private readonly siblingLibraries = new Map<string, SiblingLibraryMap>();
|
|
178
188
|
private _loadedGraph?: LoadedGraph;
|
|
189
|
+
/** Declaration signatures of the installed set, taken at load time — see
|
|
190
|
+
* `ManifestDiffOptions.previousSignatures` for why they cannot be taken
|
|
191
|
+
* later. */
|
|
192
|
+
private _declarationSignatures = new Map<string, string>();
|
|
193
|
+
|
|
194
|
+
/** Set while a reconciliation is in flight. Two overlapping calls would
|
|
195
|
+
* interleave unwind, deregister and re-initialize on one context, and a watch
|
|
196
|
+
* loop with fast saves is exactly the caller that would do it. */
|
|
197
|
+
private _reconciling = false;
|
|
198
|
+
|
|
199
|
+
/** The directories and cache policy `load()` produced with, replayed by
|
|
200
|
+
* {@link reconcile} so a second production cannot differ from the first. */
|
|
201
|
+
private _produceOptions?: {
|
|
202
|
+
manifestsDir: string | undefined;
|
|
203
|
+
analysisDir: string | undefined;
|
|
204
|
+
writeCache: boolean;
|
|
205
|
+
analyzeOnly: boolean;
|
|
206
|
+
};
|
|
179
207
|
// Lifecycle state — guards boot/runTargets/teardown/invoke transitions.
|
|
180
208
|
// teardown() is the only idempotent method; everything else throws on misuse.
|
|
181
209
|
private _bootCalled = false;
|
|
@@ -195,7 +223,6 @@ export class Kernel implements IKernel {
|
|
|
195
223
|
readonly stderr: NodeJS.WritableStream;
|
|
196
224
|
readonly env: Record<string, string | undefined>;
|
|
197
225
|
readonly argv: string[];
|
|
198
|
-
readonly registryUrl: string | undefined;
|
|
199
226
|
/** The sources this kernel was constructed with, kept so `ctx.runtime` can
|
|
200
227
|
* give a child manifest — or a static check of one — the same resolution
|
|
201
228
|
* chain this kernel runs on. The transports come from the registry and are
|
|
@@ -232,11 +259,10 @@ export class Kernel implements IKernel {
|
|
|
232
259
|
return { traceId: ambient.traceId, spanId };
|
|
233
260
|
});
|
|
234
261
|
this.argv = options.argv ?? [];
|
|
235
|
-
this.registryUrl = options.registryUrl;
|
|
236
262
|
// Resolution sources come from the transport registry, so a scheme-owning
|
|
237
263
|
// transport (OCI, later S3) joins the loader's dispatch chain by being
|
|
238
264
|
// registered — no source-chain edits here.
|
|
239
|
-
this.loader = new Loader(defaultTransportRegistry(
|
|
265
|
+
this.loader = new Loader(defaultTransportRegistry().sources(), {
|
|
240
266
|
celHandlers: nodeCelHandlers,
|
|
241
267
|
});
|
|
242
268
|
this.injectedSources = [...options.sources];
|
|
@@ -334,8 +360,8 @@ export class Kernel implements IKernel {
|
|
|
334
360
|
// import-controller's independent re-resolution onto the winning source so a
|
|
335
361
|
// sub-library importing a lower version loads the same controller/definition
|
|
336
362
|
// the analyzer registered — never a second, colliding copy. Keyed by
|
|
337
|
-
// canonical URL; `canonicalize` maps a
|
|
338
|
-
//
|
|
363
|
+
// canonical URL; `canonicalize` maps a ref returned verbatim by the loader
|
|
364
|
+
// to the URL the graph walk already resolved it to.
|
|
339
365
|
const overrides = this._loadedGraph?.overrides;
|
|
340
366
|
if (overrides && overrides.size > 0) {
|
|
341
367
|
const canonical = this.loader.canonicalize(resolved) ?? resolved;
|
|
@@ -460,6 +486,263 @@ export class Kernel implements IKernel {
|
|
|
460
486
|
// through spawnChild() to module imports and scoped handles.
|
|
461
487
|
this.rootContext.getDefinition = (kind) => this.controllers.getDefinition(kind);
|
|
462
488
|
|
|
489
|
+
// Kept so `reconcile()` re-produces against the same directories and cache
|
|
490
|
+
// policy this load used — a second load resolving them again could differ.
|
|
491
|
+
this._produceOptions = { manifestsDir, analysisDir, writeCache, analyzeOnly: false };
|
|
492
|
+
const produced = await this.produceManifests(sourceUrl, {
|
|
493
|
+
manifestsDir,
|
|
494
|
+
analysisDir,
|
|
495
|
+
writeCache,
|
|
496
|
+
analyzeOnly: options?.analyzeOnly === true,
|
|
497
|
+
});
|
|
498
|
+
// `analyzeOnly` stops before instantiation, so there is nothing to install.
|
|
499
|
+
if (!produced) return;
|
|
500
|
+
this._declarationSignatures = produced.signatures;
|
|
501
|
+
this.installManifests(produced.manifests);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Re-read the entry and bring the running kernel into line with it, rebuilding
|
|
506
|
+
* only what changed.
|
|
507
|
+
*
|
|
508
|
+
* Three things decide what happens, in order, and each escalates rather than
|
|
509
|
+
* narrowing on a guess:
|
|
510
|
+
*
|
|
511
|
+
* 1. **A module other than the entry moved.** The kernel's runtime manifest
|
|
512
|
+
* set is entry-only — an imported library's resources live in the child
|
|
513
|
+
* context its `Telo.Import` owns — so a library edit is invisible to the
|
|
514
|
+
* resource diff and cannot be narrowed here at all.
|
|
515
|
+
* 2. **A module doc changed.** `variables` / `secrets` / `ports` / `logging`
|
|
516
|
+
* are resolved once for the whole application and read by anything, so a
|
|
517
|
+
* change there has no bounded impact set.
|
|
518
|
+
* 3. **Otherwise**, the resources whose declarations moved are unwound
|
|
519
|
+
* together with everything transitively holding them, re-registered, and
|
|
520
|
+
* re-initialized. Everything else keeps running, its instances untouched.
|
|
521
|
+
*
|
|
522
|
+
* `restartRequired` is a REPORT, not a failure: the caller rebuilds the
|
|
523
|
+
* kernel, which is what it does for every edit today. Nothing has been
|
|
524
|
+
* unwound when it is set.
|
|
525
|
+
*/
|
|
526
|
+
async reconcile(): Promise<ReconcileOutcome> {
|
|
527
|
+
if (this._isTornDown) throwInvalidState("reconcile", "kernel has been torn down");
|
|
528
|
+
if (!this._isBooted) throwInvalidState("reconcile", "boot() has not completed");
|
|
529
|
+
const entryUrl = this._entryUrl;
|
|
530
|
+
const previousGraph = this._loadedGraph;
|
|
531
|
+
const produceOptions = this._produceOptions;
|
|
532
|
+
if (!entryUrl || !previousGraph || !produceOptions) {
|
|
533
|
+
throwInvalidState("reconcile", "load() has not been called");
|
|
534
|
+
}
|
|
535
|
+
if (this._reconciling) {
|
|
536
|
+
throwInvalidState("reconcile", "a reconciliation is already in progress");
|
|
537
|
+
}
|
|
538
|
+
const previousManifests = this.staticManifests;
|
|
539
|
+
const previousSignatures = this._declarationSignatures;
|
|
540
|
+
this._reconciling = true;
|
|
541
|
+
try {
|
|
542
|
+
return await this.reconcileLocked(
|
|
543
|
+
entryUrl,
|
|
544
|
+
previousGraph,
|
|
545
|
+
produceOptions,
|
|
546
|
+
previousManifests,
|
|
547
|
+
previousSignatures,
|
|
548
|
+
);
|
|
549
|
+
} finally {
|
|
550
|
+
this._reconciling = false;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
private async reconcileLocked(
|
|
555
|
+
entryUrl: string,
|
|
556
|
+
previousGraph: LoadedGraph,
|
|
557
|
+
produceOptions: NonNullable<Kernel["_produceOptions"]>,
|
|
558
|
+
previousManifests: ResourceManifest[],
|
|
559
|
+
previousSignatures: Map<string, string>,
|
|
560
|
+
): Promise<ReconcileOutcome> {
|
|
561
|
+
|
|
562
|
+
// The loader memoizes a file's parse on the assumption its contents do not
|
|
563
|
+
// change underneath one Loader — precisely what a reload breaks — so every
|
|
564
|
+
// file the previous graph read is dropped before it is asked for again.
|
|
565
|
+
for (const source of graphFileSources(previousGraph)) this.loader.forget(source);
|
|
566
|
+
|
|
567
|
+
const produced = await this.produceManifests(entryUrl, produceOptions);
|
|
568
|
+
if (!produced) throwInvalidState("reconcile", "produced no manifests");
|
|
569
|
+
|
|
570
|
+
// `produceManifests` writes the kernel's static half as it goes — the graph,
|
|
571
|
+
// the flattened set, the module artifacts, the definition registry. Those
|
|
572
|
+
// writes describe manifests that are only INSTALLED further down, so every
|
|
573
|
+
// exit before that point puts them back: a caller told to restart would
|
|
574
|
+
// otherwise hold a kernel whose static half had already moved, and a second
|
|
575
|
+
// reconcile would diff the new set against itself and report no change
|
|
576
|
+
// while the live instances are still the originals.
|
|
577
|
+
const restoreProduced = (): void => {
|
|
578
|
+
this._loadedGraph = previousGraph;
|
|
579
|
+
this.staticManifests = previousManifests;
|
|
580
|
+
this._declarationSignatures = previousSignatures;
|
|
581
|
+
};
|
|
582
|
+
const halted = (reason: string): ReconcileOutcome => {
|
|
583
|
+
restoreProduced();
|
|
584
|
+
return { reinitialized: [], removed: [], restartRequired: reason };
|
|
585
|
+
};
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
const moved = modulesThatMoved(previousGraph, produced.graph);
|
|
589
|
+
if (moved.length > 0) {
|
|
590
|
+
return halted(`an imported module changed: ${moved.join(", ")}`);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const diff = diffManifests(previousManifests, produced.manifests, { previousSignatures });
|
|
594
|
+
if (diff.entries.length === 0) {
|
|
595
|
+
// Nothing to install, so the new record describes exactly what is already
|
|
596
|
+
// running — keep it rather than restoring, so the next diff compares
|
|
597
|
+
// against the freshest read of the same file.
|
|
598
|
+
return { reinitialized: [], removed: [] };
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const movedDoc = diff.entries.find(
|
|
602
|
+
(entry: DiffEntry) => isModuleKind((entry.next ?? entry.previous)!.kind as string),
|
|
603
|
+
);
|
|
604
|
+
if (movedDoc) return halted("the application document changed");
|
|
605
|
+
|
|
606
|
+
// A kind registration is once per kernel: `DefinitionRegistry` only ever
|
|
607
|
+
// adds, so a deleted or edited `Telo.Definition` would leave the old kind
|
|
608
|
+
// registered and the running kernel enforcing a weaker contract than
|
|
609
|
+
// `telo check` does against the same file — a divergence nothing reports.
|
|
610
|
+
const movedKind = diff.entries.find((entry: DiffEntry) =>
|
|
611
|
+
DEFINITION_KINDS.has((entry.next ?? entry.previous)!.kind as string),
|
|
612
|
+
);
|
|
613
|
+
if (movedKind) return halted("a resource kind definition changed");
|
|
614
|
+
|
|
615
|
+
// Names rather than node ids from here on: the root context keys everything
|
|
616
|
+
// by the local name, and a manifest in this set is by definition the entry
|
|
617
|
+
// module's own.
|
|
618
|
+
const nameOf = (manifest: ResourceManifest): string => manifest.metadata.name as string;
|
|
619
|
+
const stale = diff.entries
|
|
620
|
+
.filter((entry: DiffEntry) => entry.change !== "added")
|
|
621
|
+
.map((entry: DiffEntry) => nameOf(entry.previous!));
|
|
622
|
+
// Closed under HOLDERS: a resource holding one of these has the live
|
|
623
|
+
// instance injected into its slot, so it cannot outlive the rebuild.
|
|
624
|
+
const { impacted, opaque } = this.rootContext.impactedBy(stale);
|
|
625
|
+
|
|
626
|
+
// A module document is a registered resource whose `targets:` and
|
|
627
|
+
// `logging.sinks` hold references, so it is a HOLDER of them and the closure
|
|
628
|
+
// reaches it whenever one of those moves. It is the one resource nothing
|
|
629
|
+
// here can rebuild: only `installManifests` re-applies what it carries —
|
|
630
|
+
// targets, module metadata, the resolved environment, logging — and
|
|
631
|
+
// unwinding it would report the application itself as a routine removal.
|
|
632
|
+
const impactedDoc = [...impacted].find((name) => {
|
|
633
|
+
const kind = this.rootContext.declaredManifestFor(name)?.kind as string | undefined;
|
|
634
|
+
return kind !== undefined && isModuleKind(kind);
|
|
635
|
+
});
|
|
636
|
+
if (impactedDoc) return halted("the application document is in the impact set");
|
|
637
|
+
|
|
638
|
+
if (opaque.length > 0) {
|
|
639
|
+
// Someone resolved these by name during initialization, so the set of
|
|
640
|
+
// holders is unknown and no closure over the declared edges is an answer.
|
|
641
|
+
return halted(`a resource is held through a by-name resolution: ${opaque.join(", ")}`);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// A resource that has been started is one nothing will start again: boot
|
|
645
|
+
// targets run once, and re-initializing a Service leaves it constructed and
|
|
646
|
+
// not listening while this call would report it as rebuilt.
|
|
647
|
+
const started = [...impacted].filter((name) => this.rootContext.wasStarted(name));
|
|
648
|
+
if (started.length > 0) return halted(`a running resource would be rebuilt: ${started.join(", ")}`);
|
|
649
|
+
|
|
650
|
+
// BEFORE the unwind: both are pure over the manifests plus the registry, so
|
|
651
|
+
// running them afterwards would turn a detectable condition — an invalid
|
|
652
|
+
// reference, a cycle the edit introduced — into a kernel whose resources are
|
|
653
|
+
// already gone.
|
|
654
|
+
const { diagnostics, order, cycleError } = this.analyzer.prepare(
|
|
655
|
+
produced.manifests,
|
|
656
|
+
this.registry,
|
|
657
|
+
);
|
|
658
|
+
if (diagnostics.length > 0) {
|
|
659
|
+
restoreProduced();
|
|
660
|
+
throw new RuntimeError(
|
|
661
|
+
"ERR_MANIFEST_VALIDATION_FAILED",
|
|
662
|
+
"Manifest validation failed",
|
|
663
|
+
diagnostics.map(staticDiagnosticToRuntime),
|
|
664
|
+
);
|
|
665
|
+
}
|
|
666
|
+
if (cycleError) {
|
|
667
|
+
restoreProduced();
|
|
668
|
+
throw new RuntimeError("ERR_CIRCULAR_DEPENDENCY", cycleError);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
const surviving = new Map<string, ResourceManifest>();
|
|
672
|
+
for (const manifest of produced.manifests) {
|
|
673
|
+
if (!isModuleKind(manifest.kind as string)) surviving.set(nameOf(manifest), manifest);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
await this.rootContext.unwindResources(impacted);
|
|
677
|
+
for (const name of impacted) this.rootContext.deregisterManifest(name);
|
|
678
|
+
|
|
679
|
+
const removed = [...impacted].filter((name) => !surviving.has(name));
|
|
680
|
+
const added = diff.entries
|
|
681
|
+
.filter((entry: DiffEntry) => entry.change === "added")
|
|
682
|
+
.map((entry: DiffEntry) => nameOf(entry.next!));
|
|
683
|
+
const reinitialized = [...new Set([...impacted, ...added])].filter((name) =>
|
|
684
|
+
surviving.has(name),
|
|
685
|
+
);
|
|
686
|
+
for (const name of reinitialized) this.rootContext.registerManifest(surviving.get(name)!);
|
|
687
|
+
// A survivor keeps its instance and its declaration, but the declaration
|
|
688
|
+
// object is the previous load's and carries that load's `sourceLine`. Swap
|
|
689
|
+
// in the fresh one so a diagnostic anchored on a resource nobody touched
|
|
690
|
+
// still points at the line it is on now.
|
|
691
|
+
const rebuilt = new Set(reinitialized);
|
|
692
|
+
for (const [name, manifest] of surviving) {
|
|
693
|
+
if (!rebuilt.has(name)) this.rootContext.refreshManifest(name, manifest);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// Re-open for another pass: a resource resolving a sibling that has not been
|
|
697
|
+
// rebuilt yet must get the deferral the init loop retries on. Closed in the
|
|
698
|
+
// `finally` so a failure cannot leave the context open, which would turn
|
|
699
|
+
// every later lookup into a deferral with no pass coming.
|
|
700
|
+
this.rootContext.reopenForInitialization();
|
|
701
|
+
try {
|
|
702
|
+
if (order) this.rootContext.setInitOrder(order);
|
|
703
|
+
await this.rootContext.initializeResources();
|
|
704
|
+
} catch (error) {
|
|
705
|
+
// The resources are already gone; there is no rollback to a state that no
|
|
706
|
+
// longer exists. Say so rather than letting the caller read a validation
|
|
707
|
+
// failure as though nothing had happened.
|
|
708
|
+
throw new RuntimeError(
|
|
709
|
+
"ERR_RECONCILE_FAILED",
|
|
710
|
+
`reconciliation failed after unwinding ${[...impacted].join(", ")} — the kernel is ` +
|
|
711
|
+
`degraded and must be rebuilt: ${error instanceof Error ? error.message : String(error)}`,
|
|
712
|
+
error instanceof RuntimeError ? error.diagnostics : undefined,
|
|
713
|
+
);
|
|
714
|
+
} finally {
|
|
715
|
+
this.rootContext.closeInitialization();
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
return { reinitialized, removed };
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* Everything between a URL and a set of manifests ready to install: load the
|
|
723
|
+
* graph, validate it, flatten it, and normalize inline resources.
|
|
724
|
+
*
|
|
725
|
+
* Split from {@link load} because it is the half that can run AGAIN. A
|
|
726
|
+
* reconciliation re-produces manifests against the same context to find what
|
|
727
|
+
* moved, where `load` also builds the context, the built-in definitions and
|
|
728
|
+
* the injection hooks — all of which exist once per kernel.
|
|
729
|
+
*
|
|
730
|
+
* Returns `undefined` under `analyzeOnly`, which deliberately stops before
|
|
731
|
+
* module instantiation and target wiring.
|
|
732
|
+
*/
|
|
733
|
+
private async produceManifests(
|
|
734
|
+
sourceUrl: string,
|
|
735
|
+
opts: {
|
|
736
|
+
manifestsDir: string | undefined;
|
|
737
|
+
analysisDir: string | undefined;
|
|
738
|
+
writeCache: boolean;
|
|
739
|
+
analyzeOnly: boolean;
|
|
740
|
+
},
|
|
741
|
+
): Promise<
|
|
742
|
+
| { graph: LoadedGraph; manifests: ResourceManifest[]; signatures: Map<string, string> }
|
|
743
|
+
| undefined
|
|
744
|
+
> {
|
|
745
|
+
const { manifestsDir, analysisDir, writeCache } = opts;
|
|
463
746
|
// Static analysis pre-flight: validates schemas and invocation context compatibility.
|
|
464
747
|
// All errors are fatal — kernel does not start if analysis fails.
|
|
465
748
|
// `desugarImports` expands each module's inline `imports:` map into synthetic
|
|
@@ -596,7 +879,7 @@ export class Kernel implements IKernel {
|
|
|
596
879
|
// before module instantiation / target wiring / application-env value
|
|
597
880
|
// resolution — those need a running environment (e.g. session secrets) the
|
|
598
881
|
// build does not have, and the runtime `load()` performs them anyway.
|
|
599
|
-
if (
|
|
882
|
+
if (opts.analyzeOnly) {
|
|
600
883
|
if (rootModuleDoc?.kind === "Telo.Application") {
|
|
601
884
|
precompileApplicationEnvSchemas(
|
|
602
885
|
rootModuleDoc as Record<string, any>,
|
|
@@ -673,7 +956,27 @@ export class Kernel implements IKernel {
|
|
|
673
956
|
staticManifests,
|
|
674
957
|
);
|
|
675
958
|
this.staticManifests = normalizedManifests;
|
|
959
|
+
// Signed HERE, while these are still declarations. Installing them hands
|
|
960
|
+
// the very same objects to the context, and resolving a reference writes a
|
|
961
|
+
// live instance into one — so a signature taken later is of something else.
|
|
962
|
+
const signatures = new Map<string, string>();
|
|
963
|
+
for (const manifest of normalizedManifests) {
|
|
964
|
+
signatures.set(nodeIdFor(manifest), declarationSignature(manifest));
|
|
965
|
+
}
|
|
966
|
+
return { graph: analysisGraph, manifests: normalizedManifests, signatures };
|
|
967
|
+
}
|
|
676
968
|
|
|
969
|
+
/**
|
|
970
|
+
* Install manifests into the root context: register each one, and apply what
|
|
971
|
+
* a module doc carries — boot targets, module metadata, the application's
|
|
972
|
+
* resolved environment and its logging configuration.
|
|
973
|
+
*
|
|
974
|
+
* The other half of the {@link load} split. A reconciliation registers only
|
|
975
|
+
* the declarations that moved, so it calls {@link EvaluationContext.registerManifest}
|
|
976
|
+
* itself rather than coming through here; what lives here is the whole-set
|
|
977
|
+
* install, which happens once.
|
|
978
|
+
*/
|
|
979
|
+
private installManifests(normalizedManifests: ResourceManifest[]): void {
|
|
677
980
|
let rootApplicationManifest: ResourceManifest | undefined;
|
|
678
981
|
for (const manifest of normalizedManifests) {
|
|
679
982
|
if (isModuleKind(manifest.kind)) {
|
|
@@ -1064,7 +1367,7 @@ export class Kernel implements IKernel {
|
|
|
1064
1367
|
private buildModuleArtifacts(graph: LoadedGraph, manifestsDir: string | undefined): void {
|
|
1065
1368
|
this.moduleArtifacts.clear();
|
|
1066
1369
|
this.siblingLibraries.clear();
|
|
1067
|
-
const transports = defaultTransportRegistry(
|
|
1370
|
+
const transports = defaultTransportRegistry();
|
|
1068
1371
|
const entryDir = this._entryUrl ? resolveEntryDir(this._entryUrl) ?? "" : "";
|
|
1069
1372
|
// The same pre-anchor root `LocalManifestCacheSource` falls back to. Layers
|
|
1070
1373
|
// live beside the cached manifest, so both halves have to look in the same
|
|
@@ -1086,7 +1389,6 @@ export class Kernel implements IKernel {
|
|
|
1086
1389
|
file.requestedUrl,
|
|
1087
1390
|
file.source,
|
|
1088
1391
|
entryDir,
|
|
1089
|
-
this.registryUrl,
|
|
1090
1392
|
manifestsDir,
|
|
1091
1393
|
legacyDir,
|
|
1092
1394
|
);
|
|
@@ -15,7 +15,6 @@ import { TransportRegistry, defaultTransportRegistry } from "../transports/trans
|
|
|
15
15
|
import { findWorkspaceRoot } from "../workspace-marker.js";
|
|
16
16
|
|
|
17
17
|
const CACHE_SUBDIR = ".telo/manifests";
|
|
18
|
-
const DEFAULT_REGISTRY_URL = "https://registry.telo.run";
|
|
19
18
|
|
|
20
19
|
/** Verify that `candidate` resolves to a path under `root`. Returns the
|
|
21
20
|
* candidate path on success, `null` when any segment escapes the root.
|
|
@@ -33,8 +32,8 @@ function joinUnder(root: string, ...segments: string[]): string | null {
|
|
|
33
32
|
|
|
34
33
|
/** Single source of truth for URL → cache path. Used identically by the
|
|
35
34
|
* reader (cache lookup) and writer (install-time persistence). For any
|
|
36
|
-
* given import ref —
|
|
37
|
-
*
|
|
35
|
+
* given import ref — an HTTP(S) URL or an `oci://` ref — both sides land on
|
|
36
|
+
* the same file: the owning transport supplies
|
|
38
37
|
* the coordinates and the analyzer's `manifestCacheKey` renders them, the same
|
|
39
38
|
* grammar the hub's static manifest bucket and the editor's read path use.
|
|
40
39
|
*
|
|
@@ -80,8 +79,8 @@ export function legacyManifestsDirFallback(
|
|
|
80
79
|
|
|
81
80
|
/**
|
|
82
81
|
* Reads previously-cached manifest YAMLs from the resolved manifest cache. Sits
|
|
83
|
-
* ahead of `
|
|
84
|
-
*
|
|
82
|
+
* ahead of `HttpSource` in the source chain — a hit makes boot hermetic, a miss
|
|
83
|
+
* falls through to the network source unchanged.
|
|
85
84
|
*
|
|
86
85
|
* Populated by `writeManifestCache` at install time.
|
|
87
86
|
*
|
|
@@ -90,7 +89,7 @@ export function legacyManifestsDirFallback(
|
|
|
90
89
|
* costs only CPU when it goes cold; this one costs network, so without the
|
|
91
90
|
* fallback the move to a workspace-anchored root would stop a hermetic setup from
|
|
92
91
|
* booting — its `telo install` output stranded at the old path, with the failure
|
|
93
|
-
* surfacing as a
|
|
92
|
+
* surfacing as a network fetch on a machine that has no route out. Read-only
|
|
94
93
|
* and one directory deep: writes always go to the current root, so the old copy
|
|
95
94
|
* ages out rather than being maintained.
|
|
96
95
|
*/
|
|
@@ -99,17 +98,13 @@ export class LocalManifestCacheSource implements ManifestSource {
|
|
|
99
98
|
private readonly legacyRoot: string | null;
|
|
100
99
|
private readonly transports: TransportRegistry;
|
|
101
100
|
|
|
102
|
-
constructor(
|
|
103
|
-
entryDir: string,
|
|
104
|
-
registryUrl: string = DEFAULT_REGISTRY_URL,
|
|
105
|
-
manifestsDir?: string,
|
|
106
|
-
) {
|
|
101
|
+
constructor(entryDir: string, manifestsDir?: string) {
|
|
107
102
|
// `manifestsDir` is the resolved manifest-cache directory threaded from a
|
|
108
103
|
// single `resolveCacheRoot` (honours `TELO_CACHE_DIR`); when absent we fall
|
|
109
104
|
// back to the entry-anchored default so library/test callers are unchanged.
|
|
110
105
|
this.cacheRoot = manifestsDir ?? legacyManifestsDir(entryDir);
|
|
111
106
|
this.legacyRoot = legacyManifestsDirFallback(entryDir, this.cacheRoot);
|
|
112
|
-
this.transports = defaultTransportRegistry(
|
|
107
|
+
this.transports = defaultTransportRegistry();
|
|
113
108
|
}
|
|
114
109
|
|
|
115
110
|
supports(url: string): boolean {
|
|
@@ -176,11 +171,10 @@ export class LocalManifestCacheSource implements ManifestSource {
|
|
|
176
171
|
export function cachePathForCanonical(
|
|
177
172
|
canonicalSource: string,
|
|
178
173
|
entryDir: string,
|
|
179
|
-
registryUrl: string | undefined = DEFAULT_REGISTRY_URL,
|
|
180
174
|
manifestsDir?: string,
|
|
181
175
|
): string | null {
|
|
182
176
|
const cacheRoot = manifestsDir ?? path.join(entryDir, CACHE_SUBDIR);
|
|
183
|
-
return cachePathForUrl(canonicalSource, cacheRoot, defaultTransportRegistry(
|
|
177
|
+
return cachePathForUrl(canonicalSource, cacheRoot, defaultTransportRegistry());
|
|
184
178
|
}
|
|
185
179
|
|
|
186
180
|
/**
|
|
@@ -198,7 +192,6 @@ export function cachePathForCanonical(
|
|
|
198
192
|
export async function writeManifestCache(
|
|
199
193
|
graph: LoadedGraph,
|
|
200
194
|
entryDir: string,
|
|
201
|
-
registryUrl: string = DEFAULT_REGISTRY_URL,
|
|
202
195
|
manifestsDir?: string,
|
|
203
196
|
): Promise<string[]> {
|
|
204
197
|
const written: string[] = [];
|
|
@@ -210,7 +203,7 @@ export async function writeManifestCache(
|
|
|
210
203
|
if (seen.has(file.source)) continue;
|
|
211
204
|
seen.add(file.source);
|
|
212
205
|
|
|
213
|
-
const target = cachePathForCanonical(file.source, entryDir,
|
|
206
|
+
const target = cachePathForCanonical(file.source, entryDir, manifestsDir);
|
|
214
207
|
if (!target) continue;
|
|
215
208
|
|
|
216
209
|
await fs.mkdir(path.dirname(target), { recursive: true });
|
package/src/module-context.ts
CHANGED
|
@@ -224,6 +224,23 @@ export class ModuleContext extends EvaluationContext implements IModuleContext {
|
|
|
224
224
|
this._rebuildContext();
|
|
225
225
|
}
|
|
226
226
|
|
|
227
|
+
/**
|
|
228
|
+
* Drop a resource's published reading, so `resources.<name>` reads as absent.
|
|
229
|
+
*
|
|
230
|
+
* What makes an unwind observable to CEL. A compile-eval field is expanded at
|
|
231
|
+
* create time, so a reader rebuilt while its provider's OLD reading was still
|
|
232
|
+
* published would bake that stale value in and succeed — where on a fresh boot
|
|
233
|
+
* the same expansion finds nothing and defers until the provider is back. This
|
|
234
|
+
* is what makes the two agree.
|
|
235
|
+
*/
|
|
236
|
+
override clearPublishedReading(name: string): void {
|
|
237
|
+
if (!(name in this._resources)) return;
|
|
238
|
+
const next = { ...this._resources };
|
|
239
|
+
delete next[name];
|
|
240
|
+
this._resources = next;
|
|
241
|
+
this._rebuildContext();
|
|
242
|
+
}
|
|
243
|
+
|
|
227
244
|
setControllerPolicy(policy: ControllerPolicy | undefined): void {
|
|
228
245
|
this._controllerPolicy = policy;
|
|
229
246
|
}
|
|
@@ -458,7 +475,30 @@ export class ModuleContext extends EvaluationContext implements IModuleContext {
|
|
|
458
475
|
return this.importAliases.has(alias);
|
|
459
476
|
}
|
|
460
477
|
|
|
461
|
-
|
|
478
|
+
/**
|
|
479
|
+
* The recording door — see the contract in `@telorun/sdk`.
|
|
480
|
+
*
|
|
481
|
+
* A resolution taken while this module is still initializing is recorded
|
|
482
|
+
* against the NAME, because that is all this context has: it is reached as
|
|
483
|
+
* `ctx.moduleContext`, which is shared by every resource of the module, so
|
|
484
|
+
* there is no caller to attribute the read to. Recording the target is enough
|
|
485
|
+
* for the only decision that depends on it — a resource somebody may be
|
|
486
|
+
* holding cannot be rebuilt on its own — and the escalation that follows is
|
|
487
|
+
* never worse than rebuilding the whole context, which is what happens today.
|
|
488
|
+
*
|
|
489
|
+
* `state !== "Initialized"` is the same discriminator the deferral below
|
|
490
|
+
* already turns on, and it is the right one: before that point a caller is
|
|
491
|
+
* running `create()` or `init()` and may keep what it gets, after it a caller
|
|
492
|
+
* is handling a dispatch and resolves again next time.
|
|
493
|
+
*/
|
|
494
|
+
getInstance(name: string, declaredBy?: { kind: string; name: string }): unknown {
|
|
495
|
+
// `declaredBy` means the name came out of a declared ref slot, so the edge
|
|
496
|
+
// is in the manifest and the host can already see it.
|
|
497
|
+
if (!declaredBy && this.state !== "Initialized") this.recordOpaqueRead(name);
|
|
498
|
+
return this.lookupInstance(name);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
private lookupInstance(name: string): unknown {
|
|
462
502
|
const entry = this.resourceInstances.get(name);
|
|
463
503
|
if (!entry) {
|
|
464
504
|
// A name this module DID declare but that has no instance is never an
|
package/src/reconcile.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pure half of reconciliation: what a second load of the same entry changed,
|
|
3
|
+
* at the granularity of whole modules.
|
|
4
|
+
*
|
|
5
|
+
* The per-RESOURCE answer is the analyzer's (`diffManifests`), and it is exact.
|
|
6
|
+
* This is the coarser question that has to be asked first, because the kernel's
|
|
7
|
+
* runtime manifest set is entry-only: an imported library's resources live in
|
|
8
|
+
* the child context its `Telo.Import` owns and never appear in the set the
|
|
9
|
+
* resource diff walks. A library edit is therefore invisible to that diff, and
|
|
10
|
+
* would silently reconcile to "nothing changed".
|
|
11
|
+
*
|
|
12
|
+
* So the modules are compared by content, and anything moving outside the entry
|
|
13
|
+
* escalates rather than being narrowed. That is the same posture the opaque-read
|
|
14
|
+
* escalation takes: a fallback to rebuilding, which is what a host does today.
|
|
15
|
+
*/
|
|
16
|
+
import type { LoadedGraph, LoadedModule } from "@telorun/analyzer";
|
|
17
|
+
|
|
18
|
+
/** What one {@link reconcile} pass did, or why it could not narrow. */
|
|
19
|
+
export interface ReconcileOutcome {
|
|
20
|
+
/** Resources rebuilt: the declarations that moved, plus everything that was
|
|
21
|
+
* holding one of them. */
|
|
22
|
+
readonly reinitialized: readonly string[];
|
|
23
|
+
/** Resources whose declaration is gone. Unwound, not replaced. */
|
|
24
|
+
readonly removed: readonly string[];
|
|
25
|
+
/** Set when the change had no bounded impact set and the caller must rebuild
|
|
26
|
+
* the kernel. Nothing has been unwound when this is present. */
|
|
27
|
+
readonly restartRequired?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Every file the graph read, so a caller can drop them from the loader's cache
|
|
31
|
+
* before asking for them again. `Loader.loadFile` assumes a file's contents do
|
|
32
|
+
* not change under one Loader, which is exactly the assumption a reload
|
|
33
|
+
* breaks. */
|
|
34
|
+
export function graphFileSources(graph: LoadedGraph): string[] {
|
|
35
|
+
const sources = new Set<string>();
|
|
36
|
+
for (const module of graph.modules.values()) {
|
|
37
|
+
sources.add(module.owner.source);
|
|
38
|
+
for (const partial of module.partials) sources.add(partial.source);
|
|
39
|
+
}
|
|
40
|
+
sources.add(graph.entry.owner.source);
|
|
41
|
+
for (const partial of graph.entry.partials) sources.add(partial.source);
|
|
42
|
+
return [...sources];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A module's content: every file it is made of, as text.
|
|
46
|
+
*
|
|
47
|
+
* Compared as a string rather than hashed — there is no collision to reason
|
|
48
|
+
* about, and a missed change here is a library that silently keeps running
|
|
49
|
+
* against source it no longer matches. */
|
|
50
|
+
function moduleSignature(module: LoadedModule): string {
|
|
51
|
+
return [module.owner, ...module.partials]
|
|
52
|
+
.map((file) => `${file.source}\u0000${file.text}`)
|
|
53
|
+
.join("\u0000\u0000");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Modules other than the entry whose content moved between two loads, including
|
|
58
|
+
* ones that appeared or disappeared.
|
|
59
|
+
*
|
|
60
|
+
* The entry is excluded because the resource diff answers for it precisely.
|
|
61
|
+
* Everything else is a library, whose resources this kernel cannot see
|
|
62
|
+
* individually.
|
|
63
|
+
*/
|
|
64
|
+
export function modulesThatMoved(previous: LoadedGraph, next: LoadedGraph): string[] {
|
|
65
|
+
const before = new Map<string, string>();
|
|
66
|
+
for (const [source, module] of previous.modules) {
|
|
67
|
+
if (source === previous.rootSource) continue;
|
|
68
|
+
before.set(source, moduleSignature(module));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const moved: string[] = [];
|
|
72
|
+
const seen = new Set<string>();
|
|
73
|
+
for (const [source, module] of next.modules) {
|
|
74
|
+
if (source === next.rootSource) continue;
|
|
75
|
+
seen.add(source);
|
|
76
|
+
const was = before.get(source);
|
|
77
|
+
if (was === undefined || was !== moduleSignature(module)) moved.push(source);
|
|
78
|
+
}
|
|
79
|
+
for (const source of before.keys()) {
|
|
80
|
+
if (!seen.has(source)) moved.push(source);
|
|
81
|
+
}
|
|
82
|
+
return moved;
|
|
83
|
+
}
|