@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.
Files changed (64) hide show
  1. package/dist/bundle/module-artifact.d.ts +1 -1
  2. package/dist/bundle/module-artifact.d.ts.map +1 -1
  3. package/dist/bundle/module-artifact.js +3 -3
  4. package/dist/bundle/module-artifact.js.map +1 -1
  5. package/dist/controller-loaders/bundle-loader.d.ts +5 -5
  6. package/dist/controller-loaders/bundle-loader.js +7 -7
  7. package/dist/controller-loaders/bundle-loader.js.map +1 -1
  8. package/dist/evaluation-context.d.ts +158 -25
  9. package/dist/evaluation-context.d.ts.map +1 -1
  10. package/dist/evaluation-context.js +234 -44
  11. package/dist/evaluation-context.js.map +1 -1
  12. package/dist/index.d.ts +1 -1
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +1 -1
  15. package/dist/index.js.map +1 -1
  16. package/dist/kernel.d.ts +60 -5
  17. package/dist/kernel.d.ts.map +1 -1
  18. package/dist/kernel.js +256 -9
  19. package/dist/kernel.js.map +1 -1
  20. package/dist/manifest-sources/local-manifest-cache-source.d.ts +6 -6
  21. package/dist/manifest-sources/local-manifest-cache-source.d.ts.map +1 -1
  22. package/dist/manifest-sources/local-manifest-cache-source.js +11 -12
  23. package/dist/manifest-sources/local-manifest-cache-source.js.map +1 -1
  24. package/dist/module-context.d.ts +31 -1
  25. package/dist/module-context.d.ts.map +1 -1
  26. package/dist/module-context.js +41 -1
  27. package/dist/module-context.js.map +1 -1
  28. package/dist/reconcile.d.ts +42 -0
  29. package/dist/reconcile.d.ts.map +1 -0
  30. package/dist/reconcile.js +58 -0
  31. package/dist/reconcile.js.map +1 -0
  32. package/dist/resource-edges.d.ts +58 -0
  33. package/dist/resource-edges.d.ts.map +1 -0
  34. package/dist/resource-edges.js +110 -0
  35. package/dist/resource-edges.js.map +1 -0
  36. package/dist/runtime-seam.d.ts.map +1 -1
  37. package/dist/runtime-seam.js +1 -2
  38. package/dist/runtime-seam.js.map +1 -1
  39. package/dist/transports/http-transport.d.ts +37 -0
  40. package/dist/transports/http-transport.d.ts.map +1 -0
  41. package/dist/transports/http-transport.js +168 -0
  42. package/dist/transports/http-transport.js.map +1 -0
  43. package/dist/transports/transport-registry.d.ts +13 -14
  44. package/dist/transports/transport-registry.d.ts.map +1 -1
  45. package/dist/transports/transport-registry.js +17 -24
  46. package/dist/transports/transport-registry.js.map +1 -1
  47. package/package.json +3 -3
  48. package/src/bundle/module-artifact.ts +2 -3
  49. package/src/controller-loaders/bundle-loader.ts +7 -7
  50. package/src/evaluation-context.ts +257 -53
  51. package/src/index.ts +1 -1
  52. package/src/kernel.ts +314 -12
  53. package/src/manifest-sources/local-manifest-cache-source.ts +9 -16
  54. package/src/module-context.ts +41 -1
  55. package/src/reconcile.ts +83 -0
  56. package/src/resource-edges.ts +114 -0
  57. package/src/runtime-seam.ts +1 -2
  58. package/src/transports/http-transport.ts +209 -0
  59. package/src/transports/transport-registry.ts +17 -24
  60. package/dist/transports/registry-transport.d.ts +0 -41
  61. package/dist/transports/registry-transport.d.ts.map +0 -1
  62. package/dist/transports/registry-transport.js +0 -282
  63. package/dist/transports/registry-transport.js.map +0 -1
  64. 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(this.registryUrl).sources(), {
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 registry ref (returned verbatim by
338
- // the loader) to the URL the graph walk already resolved it to.
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 (options?.analyzeOnly) {
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(this.registryUrl);
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 — registry ref, direct registry URL, arbitrary HTTP, or
37
- * `oci://` — both sides land on the same file: the owning transport supplies
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 `RegistrySource` / `HttpSource` in the source chain — a hit makes boot
84
- * hermetic, a miss falls through to the network source unchanged.
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 registry fetch on a machine that has no route to one. Read-only
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(registryUrl);
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(registryUrl));
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, registryUrl, manifestsDir);
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 });
@@ -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
- getInstance(name: string): unknown {
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
@@ -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
+ }