@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
@@ -2,8 +2,9 @@ import { AsyncLocalStorage } from "node:async_hooks";
2
2
  import { formatSpanCounter } from "./logging/span-id.js";
3
3
  import { deriveContext, getRefIdentity, isCompiledValue, isInvokeError, isCancellationError, isSuspension, resourceKey, UNCANCELLABLE_CONTEXT, } from "@telorun/sdk";
4
4
  import { RuntimeError } from "@telorun/sdk";
5
- import { evalPathCovers } from "@telorun/analyzer";
5
+ import { celResourceReads, evalPathCovers } from "@telorun/analyzer";
6
6
  import { effectOwnerOf, executeReturnedChain } from "./effect-scope.js";
7
+ import { impactClosure, reverseTopologicalOrder } from "./resource-edges.js";
7
8
  import { REDACTED, redactSensitive, sensitivePathsOfInstance, } from "./instance-sensitive-paths.js";
8
9
  import { classifyInitFailures, isDeferral, renderInitFailureText, summarizeInitFailures, } from "./init-failure-diagnostics.js";
9
10
  import { acceptReportedStatus, buildPublishedProps, diagnoseObservedStateAccess, } from "./observed-state.js";
@@ -521,9 +522,29 @@ export class EvaluationContext {
521
522
  declaredManifests = new Map();
522
523
  /** Per-resource dependency names, captured at create() time — BEFORE Phase-5
523
524
  * injection swaps refs for live instances, so the walk sees plain objects and
524
- * cannot wander into a controller's (possibly cyclic) object graph. Read only
525
- * when init fails, to attribute each failure to its cause. */
525
+ * cannot wander into a controller's (possibly cyclic) object graph.
526
+ *
527
+ * Read twice, and RETAINED for the context's lifetime because of the second
528
+ * reader: to attribute an init failure to its cause, and to order teardown
529
+ * (`teardownOrder`) so a consumer's inverses run while the resources it holds
530
+ * are still alive. */
526
531
  resourceDependencies = new Map();
532
+ /**
533
+ * Names resolved by NAME during initialization, rather than through a
534
+ * declared reference slot — so a resource somebody may be holding, with no
535
+ * edge recording who.
536
+ *
537
+ * The set is of TARGETS, not of pairs: the door that records
538
+ * (`ModuleContext.getInstance`) is reached as `ctx.moduleContext`, which every
539
+ * resource of the module shares, so there is no caller to attribute the read
540
+ * to. That is enough for the one decision that depends on it — see
541
+ * {@link impactedBy}.
542
+ */
543
+ opaquelyRead = new Set();
544
+ /** Record a by-name resolution. Called by the recording door only. */
545
+ recordOpaqueRead(name) {
546
+ this.opaquelyRead.add(name);
547
+ }
527
548
  /**
528
549
  * Optional hook called between create() and init() for each resource.
529
550
  * Set by the kernel to inject live instances into reference fields.
@@ -730,6 +751,32 @@ export class EvaluationContext {
730
751
  this.pendingResources.push(resource);
731
752
  this.declaredManifests.set(name, resource);
732
753
  }
754
+ /**
755
+ * Forget a declaration entirely — the inverse of {@link registerManifest}.
756
+ *
757
+ * Reconciliation's other half: {@link unwindResources} disposes the INSTANCE,
758
+ * and this clears everything keyed by the name so the same name can be
759
+ * declared again. Without it `registerManifest` refuses with
760
+ * `ERR_DUPLICATE_RESOURCE`, which is the right answer for a manifest
761
+ * declaring one name twice and the wrong one for a second load of the same
762
+ * manifest.
763
+ *
764
+ * Every per-name record goes, not just the declaration: a resource left in
765
+ * `withheldResources` would be skipped by the init loop for the life of the
766
+ * kernel, and a stale `createdInstances` entry would have the loop initialize
767
+ * the object built from the PREVIOUS declaration.
768
+ */
769
+ deregisterManifest(name) {
770
+ this.declaredManifests.delete(name);
771
+ this.resourceDependencies.delete(name);
772
+ this.createdInstances.delete(name);
773
+ this.withheldResources.delete(name);
774
+ this.recreatedResources.delete(name);
775
+ this.opaquelyRead.delete(name);
776
+ const pending = this.pendingResources.findIndex((r) => r.metadata?.name === name);
777
+ if (pending >= 0)
778
+ this.pendingResources.splice(pending, 1);
779
+ }
733
780
  /**
734
781
  * The manifest a name was DECLARED with, resolved scope-local first and then
735
782
  * up the enclosing chain — the order `getInstance` and the CEL `resources`
@@ -890,7 +937,12 @@ export class EvaluationContext {
890
937
  progress = true;
891
938
  const createdRes = created.resource;
892
939
  const refs = collectResourceRefs(createdRes);
893
- this.resourceDependencies.set(name, localDependencyNames(refs));
940
+ // `resource` rather than `createdRes`: the registered declaration
941
+ // still holds its expressions, while the created copy holds the
942
+ // values they were expanded to.
943
+ this.resourceDependencies.set(name, [
944
+ ...new Set([...localDependencyNames(refs), ...celResourceReads(resource)]),
945
+ ]);
894
946
  const payload = {
895
947
  resource: {
896
948
  kind: createdRes.kind,
@@ -1001,9 +1053,6 @@ export class EvaluationContext {
1001
1053
  await this.publishSnapshot(name);
1002
1054
  this.resourceInstances.set(name, { resource, instance });
1003
1055
  this.createdInstances.delete(name);
1004
- // Read only on failure, and this one succeeded — drop it rather than
1005
- // holding a dep-name array per resource for the context's lifetime.
1006
- this.resourceDependencies.delete(name);
1007
1056
  errors.delete(name);
1008
1057
  progress = true;
1009
1058
  await this.emit(`${resource.kind}.${resource.metadata.name}.Initialized`, {
@@ -1173,15 +1222,32 @@ export class EvaluationContext {
1173
1222
  };
1174
1223
  }
1175
1224
  /**
1176
- * Cascade teardown depth-first through the tree:
1177
- * 1. Tear down child contexts in reverse registration order.
1178
- * 2. Tear down own resource instances in reverse registration order,
1179
- * emitting a Teardown event for each via the injected emit callback.
1225
+ * Cascade teardown through the tree:
1226
+ * 1. Tear down own resource instances in {@link teardownOrder}, emitting a
1227
+ * Teardown event for each via the injected emit callback.
1228
+ * 2. Sweep any child context still standing, in {@link childTeardownOrder}.
1229
+ *
1230
+ * **Own resources go FIRST, and the child sweep is a backstop.** A child
1231
+ * context that belongs to a resource is torn down by that resource's own
1232
+ * inverse — an import's `init()` returns `child.teardownResources()`, and so
1233
+ * does a template's — so it already unwinds at its owner's position in step 1,
1234
+ * which is the position the edges put it at. Running a child cascade ahead of
1235
+ * step 1 tore every imported library down before any resource of THIS context,
1236
+ * so an app resource holding `!ref Alias.name` unwound after its provider was
1237
+ * already gone, and the owner's inverse then found nothing left to do.
1238
+ *
1239
+ * Sweeping afterwards rather than not at all is what still reclaims a context
1240
+ * no inverse claims: a `lifecycle: shared` library, which is spawned under the
1241
+ * root and deliberately gives no importer a claim on it, and an import whose
1242
+ * `init()` never ran to register one. `teardownResources` is idempotent, so a
1243
+ * context already taken down in step 1 costs the sweep nothing.
1180
1244
  */
1181
1245
  // eslint-disable-next-line @typescript-eslint/member-ordering
1182
1246
  async teardownResources() {
1183
1247
  this.state = "Draining";
1184
- const failures = [];
1248
+ const failures = await this.unwindEach(this.teardownOrder());
1249
+ // The backstop: whatever no resource's inverse claimed. Everything an import
1250
+ // or a template owns is already down, so this is a no-op for it.
1185
1251
  for (const child of this.childTeardownOrder()) {
1186
1252
  try {
1187
1253
  await child.teardownResources();
@@ -1190,7 +1256,48 @@ export class EvaluationContext {
1190
1256
  failures.push({ resource: "(child context)", error: err });
1191
1257
  }
1192
1258
  }
1193
- for (const [key, { resource, instance }] of this.teardownOrder()) {
1259
+ this.state = "Teardown";
1260
+ this.raiseTeardownFailures(failures);
1261
+ }
1262
+ /**
1263
+ * Unwind SOME of this context's resources and leave the rest running.
1264
+ *
1265
+ * The reconciliation half of teardown: a host that has decided which
1266
+ * declarations moved unwinds exactly {@link impactedBy}'s answer, then
1267
+ * re-registers and re-initializes. Ordering is {@link teardownOrder}
1268
+ * restricted to the selection, so a consumer still unwinds before what it
1269
+ * holds — and the selection being closed under holders is what makes that
1270
+ * true of the resources left standing as well, since none of them holds
1271
+ * anything in it.
1272
+ *
1273
+ * The context keeps its state: it is neither draining nor torn down, and no
1274
+ * child context is swept, because a child belonging to an unwound import goes
1275
+ * down with that import's own inverse exactly as it does at teardown.
1276
+ *
1277
+ * A name with no live instance is skipped rather than reported — a resource
1278
+ * that failed to initialize has nothing to unwind, and a host asking for it is
1279
+ * asking about a declaration, not about an instance.
1280
+ */
1281
+ // eslint-disable-next-line @typescript-eslint/member-ordering
1282
+ async unwindResources(names) {
1283
+ const selected = this.teardownOrder().filter(([, entry]) => names.has(entry.resource.metadata.name));
1284
+ this.raiseTeardownFailures(await this.unwindEach(selected));
1285
+ }
1286
+ raiseTeardownFailures(failures) {
1287
+ if (failures.length === 0)
1288
+ return;
1289
+ throw new RuntimeError("ERR_TEARDOWN_FAILED", `${failures.length} resource(s) failed during teardown`, failures.map(({ resource, error }) => ({
1290
+ severity: "error",
1291
+ message: error instanceof Error ? error.message : String(error),
1292
+ resource,
1293
+ })));
1294
+ }
1295
+ /** Unwind the given entries in the order supplied, aggregating what refused.
1296
+ * Failures are returned rather than thrown so one refusing inverse cannot
1297
+ * strand the resources after it — the log sinks above all. */
1298
+ async unwindEach(entries) {
1299
+ const failures = [];
1300
+ for (const [key, { resource, instance }] of entries) {
1194
1301
  const label = `${resource.kind}.${resource.metadata.name}`;
1195
1302
  // A reading belongs to the run that produced it. The WeakMap would drop it
1196
1303
  // with the instance anyway; clearing here also covers an instance something
@@ -1242,27 +1349,27 @@ export class EvaluationContext {
1242
1349
  failures.push({ resource: `${label} (Teardown event)`, error: err });
1243
1350
  }
1244
1351
  this.resourceInstances.delete(key);
1352
+ // A torn-down resource must stop being readable: a CEL expansion that still
1353
+ // found its reading would bake a value nothing is serving any more.
1354
+ this.clearPublishedReading(resource.metadata.name);
1245
1355
  }
1246
- this.state = "Teardown";
1247
- if (failures.length > 0) {
1248
- throw new RuntimeError("ERR_TEARDOWN_FAILED", `${failures.length} resource(s) failed during teardown`, failures.map(({ resource, error }) => ({
1249
- severity: "error",
1250
- message: error instanceof Error ? error.message : String(error),
1251
- resource,
1252
- })));
1253
- }
1356
+ return failures;
1254
1357
  }
1255
1358
  /**
1256
- * Child contexts in teardown order: ascending `teardownPriority` (default 0),
1257
- * with the base reverse-registration order preserved within each tier.
1359
+ * Child contexts for the backstop sweep: ascending `teardownPriority`
1360
+ * (default 0), reverse registration within a tier.
1361
+ *
1362
+ * What reaches the sweep is a context no resource's inverse claimed, which in
1363
+ * practice is the `lifecycle: shared` libraries. Those are spawned under the
1364
+ * ROOT and give no importer a claim, so nothing but this orders them — and
1365
+ * reverse registration alone does not, since a singleton is registered when
1366
+ * the FIRST import reaches it, which for an import declared inside another
1367
+ * library is after that library's own context. `TEARDOWN_LAST` on the context
1368
+ * is what keeps a singleton alive until the libraries borrowing it have gone.
1258
1369
  *
1259
- * The same rule `teardownOrder` applies to resource instances, and for the
1260
- * same reason: reverse registration is reverse init order in the happy path,
1261
- * but a node that must reliably outlive the rest has to say so rather than
1262
- * depend on when it happened to be created. A `lifecycle: shared` library is
1263
- * registered when the FIRST import reaches it — which, for an import declared
1264
- * inside another library, is after that library's own context — so reverse
1265
- * registration would tear the singleton down while a borrower still holds it.
1370
+ * Its protection now stops at library-against-library. Every resource of the
1371
+ * context that owns the sweep has already unwound by the time it runs, which
1372
+ * is the ordering the sweep used to invert.
1266
1373
  */
1267
1374
  childTeardownOrder() {
1268
1375
  return [...this.children]
@@ -1271,17 +1378,17 @@ export class EvaluationContext {
1271
1378
  (b.teardownPriority ?? 0));
1272
1379
  }
1273
1380
  /**
1274
- * Resource instances in teardown order: ascending `teardownPriority`, with the
1275
- * base reverse-insertion order preserved within each priority tier.
1381
+ * Resource instances in teardown order: ascending `teardownPriority` as a hard
1382
+ * tier, and within a tier a consumer before every resource it holds
1383
+ * ({@link reverseTopologicalOrder} over the create-time edges), with reverse
1384
+ * insertion as the tiebreak.
1276
1385
  *
1277
- * The base order is reverse *insertion*, which is reverse init order in the
1278
- * happy path but the init loop is a multi-pass retry, so a resource that
1279
- * failed its first pass lands later in the map than its topological rank
1280
- * implies. That makes the dependency graph an unreliable way to say "last".
1281
- * A resource that must reliably outlive the rest declares it directly via
1282
- * `teardownPriority` (log sinks set `TEARDOWN_LAST`), so the generic teardown
1283
- * path orders by a declared number rather than sniffing any one subsystem's
1284
- * instance shape.
1386
+ * `teardownPriority` is a TIER rather than another edge because it is the
1387
+ * author's statement about an edge nothing captured: a log sink is reached
1388
+ * through `ctx.log` rather than through a ref slot, so no walk of the manifest
1389
+ * can find the resources that will log on the way down. Letting topology
1390
+ * reorder across tiers would let one discovered edge override a declaration
1391
+ * made precisely because the edges are not all discoverable.
1285
1392
  */
1286
1393
  teardownOrder() {
1287
1394
  // A borrowed instance is torn down by the scope that declared it, never
@@ -1289,10 +1396,93 @@ export class EvaluationContext {
1289
1396
  const entries = [...this.resourceInstances.entries()]
1290
1397
  .filter(([name]) => !this.borrowedResources.has(name))
1291
1398
  .reverse();
1292
- // Stable sort by priority (default 0); Array.prototype.sort is stable, so
1293
- // the reverse-insertion order survives within each tier.
1294
- return entries.sort(([, a], [, b]) => (a.instance?.teardownPriority ?? 0) -
1295
- (b.instance?.teardownPriority ?? 0));
1399
+ const tiers = new Map();
1400
+ for (const entry of entries) {
1401
+ const priority = entry[1].instance?.teardownPriority ?? 0;
1402
+ const tier = tiers.get(priority);
1403
+ if (tier)
1404
+ tier.push(entry);
1405
+ else
1406
+ tiers.set(priority, [entry]);
1407
+ }
1408
+ return [...tiers.keys()]
1409
+ .sort((a, b) => a - b)
1410
+ .flatMap((priority) => reverseTopologicalOrder(tiers.get(priority), (value) => value.resource.metadata.name, (name) => this.resourceDependencies.get(name)));
1411
+ }
1412
+ /**
1413
+ * Every resource of this context that becomes invalid when `names` do — the
1414
+ * named resources plus everything that transitively holds one
1415
+ * ({@link impactClosure} over the same create-time edges teardown reads).
1416
+ *
1417
+ * What a reconciliation unwinds. A cross-module reference projects onto the
1418
+ * local `Telo.Import` (`localDependencyNames`), so a change inside an
1419
+ * imported library reaches this context as its import being impacted, and the
1420
+ * library goes down with that import's own inverse — which is why this
1421
+ * answers for one context rather than walking the tree.
1422
+ *
1423
+ * **`opaque` is what the answer cannot cover.** A name resolved by NAME during
1424
+ * initialization ({@link opaquelyRead}) may be held by a resource no edge
1425
+ * names, so a closure reaching one is not an answer at all. Those names are
1426
+ * reported rather than absorbed: expanding the set to "every resource here"
1427
+ * would sweep in the module document, which is not a resource a caller can
1428
+ * unwind and re-register, and would present a whole-context rebuild as a
1429
+ * narrowing. The caller escalates, and can say which resource forced it.
1430
+ */
1431
+ // eslint-disable-next-line @typescript-eslint/member-ordering
1432
+ impactedBy(names) {
1433
+ const impacted = impactClosure(names, this.resourceDependencies);
1434
+ const opaque = [...impacted].filter((name) => this.opaquelyRead.has(name));
1435
+ return { impacted, opaque };
1436
+ }
1437
+ /** Whether this resource's `run()` has been dispatched. A rebuilt resource
1438
+ * that had been started is one nothing will start again — boot targets run
1439
+ * once — so a caller reconciling has to escalate rather than leave it
1440
+ * constructed and idle. */
1441
+ wasStarted(name) {
1442
+ const instance = this.resourceInstances.get(name)?.instance;
1443
+ return instance !== undefined && startedInstances.has(instance);
1444
+ }
1445
+ /** Drop a resource's published reading. A context with no `resources` scope of
1446
+ * its own has nothing to drop; `ModuleContext` overrides. */
1447
+ clearPublishedReading(name) {
1448
+ void name;
1449
+ }
1450
+ /** The declaration registered under `name` in THIS context, without the
1451
+ * walk up the enclosing chain a name lookup does. */
1452
+ declaredManifestFor(name) {
1453
+ return this.declaredManifests.get(name);
1454
+ }
1455
+ /** Replace a declaration in place, without queueing the resource for
1456
+ * creation. For a survivor of a reconciliation: its declaration is
1457
+ * content-identical by construction, but the object carries fresh loader
1458
+ * stamps (`metadata.sourceLine` above all), and a diagnostic anchored on the
1459
+ * stale one points at a pre-edit line. */
1460
+ refreshManifest(name, resource) {
1461
+ if (this.declaredManifests.has(name))
1462
+ this.declaredManifests.set(name, resource);
1463
+ }
1464
+ /** Re-open an initialized context for another initialization pass.
1465
+ *
1466
+ * The transition is the context's own, not a field a caller assigns: while it
1467
+ * is open, a reference to a resource that has not been rebuilt yet must
1468
+ * produce the deferral the init loop retries on rather than a hard
1469
+ * not-found, and leaving it open after a failed pass turns every later
1470
+ * lookup into that deferral with no pass coming. */
1471
+ reopenForInitialization() {
1472
+ if (this.state === "Initialized")
1473
+ this.state = "Validated";
1474
+ }
1475
+ /** Close a pass opened by {@link reopenForInitialization} that did not reach
1476
+ * the end of `initializeResources`. */
1477
+ closeInitialization() {
1478
+ if (this.state === "Validated")
1479
+ this.state = "Initialized";
1480
+ }
1481
+ /** Names a resource resolved by NAME while this context was initializing, so
1482
+ * no edge records who is holding them. Read by {@link impactedBy}; exposed so
1483
+ * a host can report why a reconciliation could not be narrowed. */
1484
+ opaqueReads() {
1485
+ return this.opaquelyRead;
1296
1486
  }
1297
1487
  transientChild(context) {
1298
1488
  return new EvaluationContext(this.source, { ...this.context, ...context }, this._createInstance, this._secretValues, this.emit);