@telorun/kernel 0.81.0 → 0.82.1

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 (60) hide show
  1. package/dist/controllers/logging/console-sink-controller.d.ts.map +1 -1
  2. package/dist/controllers/logging/console-sink-controller.js +19 -9
  3. package/dist/controllers/logging/console-sink-controller.js.map +1 -1
  4. package/dist/controllers/logging/file-sink-controller.d.ts.map +1 -1
  5. package/dist/controllers/logging/file-sink-controller.js +17 -6
  6. package/dist/controllers/logging/file-sink-controller.js.map +1 -1
  7. package/dist/controllers/module/import-controller.d.ts.map +1 -1
  8. package/dist/controllers/module/import-controller.js +36 -15
  9. package/dist/controllers/module/import-controller.js.map +1 -1
  10. package/dist/controllers/resource-definition/resource-template-controller.d.ts.map +1 -1
  11. package/dist/controllers/resource-definition/resource-template-controller.js +7 -5
  12. package/dist/controllers/resource-definition/resource-template-controller.js.map +1 -1
  13. package/dist/effect-scope.d.ts +132 -0
  14. package/dist/effect-scope.d.ts.map +1 -0
  15. package/dist/effect-scope.js +253 -0
  16. package/dist/effect-scope.js.map +1 -0
  17. package/dist/evaluation-context.d.ts +26 -1
  18. package/dist/evaluation-context.d.ts.map +1 -1
  19. package/dist/evaluation-context.js +156 -12
  20. package/dist/evaluation-context.js.map +1 -1
  21. package/dist/init-failure-diagnostics.d.ts +10 -0
  22. package/dist/init-failure-diagnostics.d.ts.map +1 -1
  23. package/dist/init-failure-diagnostics.js +7 -1
  24. package/dist/init-failure-diagnostics.js.map +1 -1
  25. package/dist/kernel.d.ts.map +1 -1
  26. package/dist/kernel.js +17 -12
  27. package/dist/kernel.js.map +1 -1
  28. package/dist/manifest-schemas.d.ts +4 -1
  29. package/dist/manifest-schemas.d.ts.map +1 -1
  30. package/dist/manifest-schemas.js +4 -10
  31. package/dist/manifest-schemas.js.map +1 -1
  32. package/dist/module-context.d.ts +10 -0
  33. package/dist/module-context.d.ts.map +1 -1
  34. package/dist/module-context.js +15 -0
  35. package/dist/module-context.js.map +1 -1
  36. package/dist/observed-state.d.ts.map +1 -1
  37. package/dist/observed-state.js +2 -4
  38. package/dist/observed-state.js.map +1 -1
  39. package/dist/resource-context.d.ts +18 -1
  40. package/dist/resource-context.d.ts.map +1 -1
  41. package/dist/resource-context.js +27 -0
  42. package/dist/resource-context.js.map +1 -1
  43. package/dist/schema-compiled-values.d.ts +2 -1
  44. package/dist/schema-compiled-values.d.ts.map +1 -1
  45. package/dist/schema-compiled-values.js +23 -20
  46. package/dist/schema-compiled-values.js.map +1 -1
  47. package/package.json +4 -4
  48. package/src/controllers/logging/console-sink-controller.ts +19 -9
  49. package/src/controllers/logging/file-sink-controller.ts +17 -6
  50. package/src/controllers/module/import-controller.ts +50 -27
  51. package/src/controllers/resource-definition/resource-template-controller.ts +14 -11
  52. package/src/effect-scope.ts +340 -0
  53. package/src/evaluation-context.ts +165 -10
  54. package/src/init-failure-diagnostics.ts +8 -1
  55. package/src/kernel.ts +20 -11
  56. package/src/manifest-schemas.ts +4 -9
  57. package/src/module-context.ts +16 -0
  58. package/src/observed-state.ts +2 -4
  59. package/src/resource-context.ts +32 -0
  60. package/src/schema-compiled-values.ts +35 -21
@@ -29,8 +29,10 @@ import {
29
29
  } from "@telorun/sdk";
30
30
  import { RuntimeError } from "@telorun/sdk";
31
31
  import { evalPathCovers } from "@telorun/analyzer";
32
+ import { effectOwnerOf, executeReturnedChain } from "./effect-scope.js";
32
33
  import {
33
34
  classifyInitFailures,
35
+ isDeferral,
34
36
  renderInitFailureText,
35
37
  summarizeInitFailures,
36
38
  type FailedResource,
@@ -491,12 +493,28 @@ export class EvaluationContext implements IEvaluationContext {
491
493
  { resource: ResourceManifest; instance: ResourceInstance }
492
494
  >();
493
495
 
494
- /** Resources that have been created but not yet initialized (between phases). */
496
+ /** Resources that have been created but not yet initialized (between phases).
497
+ * `source` is the manifest as REGISTERED, kept so a discarded instance is
498
+ * rebuilt from what the author wrote rather than from the create-time
499
+ * expansion of it. */
495
500
  protected readonly createdInstances = new Map<
496
501
  string,
497
- { resource: ResourceManifest; instance: ResourceInstance; ctx: any }
502
+ { resource: ResourceManifest; instance: ResourceInstance; ctx: any; source: ResourceManifest }
498
503
  >();
499
504
 
505
+ /**
506
+ * Resources whose failed `init()` could not be rolled back.
507
+ *
508
+ * Withheld rather than retried: re-running `init()` from a state an inverse
509
+ * refused to restore is worse than not retrying, so the loop skips them and
510
+ * they are reported with both the init error and the refusing inverse.
511
+ */
512
+ private readonly withheldResources = new Set<string>();
513
+
514
+ /** Resources discarded after a failed `init()` and re-queued for creation.
515
+ * Their re-creation is not progress — see the create sub-phase. */
516
+ private readonly recreatedResources = new Set<string>();
517
+
500
518
  /** Resources queued for initialization on this context node. */
501
519
  private pendingResources: ResourceManifest[] = [];
502
520
 
@@ -746,6 +764,20 @@ export class EvaluationContext implements IEvaluationContext {
746
764
  * Attach a child context to this node. The child's parent is set to this
747
765
  * context and the child is registered under the given name.
748
766
  */
767
+ /**
768
+ * Detach a child from this node — the inverse of {@link spawnChild}.
769
+ *
770
+ * A child is normally torn down in place, so this exists for the one case
771
+ * that discards it instead: an import whose instance is dropped and rebuilt
772
+ * must not leave its child in `children`, or this context's teardown cascades
773
+ * into a context whose replacement is already live. Here rather than in the
774
+ * controller, because how children are tracked is this class's own fact.
775
+ */
776
+ detachChild(child: IEvaluationContext): void {
777
+ const index = this.children.indexOf(child);
778
+ if (index >= 0) this.children.splice(index, 1);
779
+ }
780
+
749
781
  spawnChild<T extends IEvaluationContext>(child: T): T {
750
782
  child.parent = this;
751
783
  this.children.push(child);
@@ -843,11 +875,18 @@ export class EvaluationContext implements IEvaluationContext {
843
875
  resource: created.resource,
844
876
  instance: created.instance,
845
877
  ctx: created.ctx,
878
+ source: resource,
846
879
  });
847
880
  const idx = this.pendingResources.findIndex((m) => m.metadata.name === name);
848
881
  if (idx >= 0) this.pendingResources.splice(idx, 1);
849
882
  errors.delete(name);
850
- progress = true;
883
+ // A FIRST creation is progress; RE-creating a resource whose init
884
+ // failed is not. Counting it would keep the loop alive on a
885
+ // permanently failing resource for every remaining pass, re-running
886
+ // `create()` each time — ten module loads for a broken import, ten
887
+ // pools for an unreachable database. Its retry still happens; what
888
+ // it no longer does is claim the environment moved.
889
+ if (!this.recreatedResources.has(name)) progress = true;
851
890
  const createdRes = created.resource;
852
891
  const refs = collectResourceRefs(createdRes);
853
892
  this.resourceDependencies.set(name, localDependencyNames(refs));
@@ -881,8 +920,8 @@ export class EvaluationContext implements IEvaluationContext {
881
920
  }
882
921
 
883
922
  // Init sub-phase
884
- for (const [name, { resource, instance, ctx }] of [...this.createdInstances]) {
885
- if (this.resourceInstances.has(name)) continue;
923
+ for (const [name, { resource, instance, ctx, source }] of [...this.createdInstances]) {
924
+ if (this.resourceInstances.has(name) || this.withheldResources.has(name)) continue;
886
925
  try {
887
926
  if (this.preInitHook) {
888
927
  this.preInitHook(
@@ -895,7 +934,68 @@ export class EvaluationContext implements IEvaluationContext {
895
934
  this,
896
935
  );
897
936
  }
898
- if (instance.init) await instance.init(ctx);
937
+ const scope = effectOwnerOf(instance)?.effects;
938
+ scope?.openFrame("init");
939
+ try {
940
+ // What `init()` RETURNS is what undoes it. A controller that
941
+ // allocates nothing returns nothing; there is no `teardown()`, so an
942
+ // allocation outside the chain is one nothing will reclaim.
943
+ if (instance.init) await executeReturnedChain(await instance.init(ctx), scope);
944
+ } catch (error) {
945
+ // Recover before the next pass: the loop retries a failed init, and
946
+ // an init that registered a listener before it failed to connect
947
+ // would otherwise register that listener again on every pass.
948
+ //
949
+ // HOW MUCH unwinds depends on whether the instance survives. A
950
+ // deferral keeps it, so only the init frame goes — unwinding the
951
+ // create frame there would destroy what construction built (a
952
+ // connection's pool) and then re-init the same instance against it.
953
+ // A real failure discards the instance, so everything `create()`
954
+ // allocated on its behalf goes with it or nothing reclaims it.
955
+ const deferred = isDeferral(error);
956
+ const refused = (await (deferred ? scope?.unwindFrame() : scope?.unwindAll())) ?? [];
957
+ if (refused.length > 0) {
958
+ // Retrying from a state that could not be rolled back is worse
959
+ // than not retrying, so the resource is withheld with a cause
960
+ // naming both the init error and the inverse that refused. The
961
+ // entry stays in `createdInstances` so it is still reported as a
962
+ // failure; the loop skips it from here on.
963
+ this.withheldResources.add(name);
964
+ throw new RuntimeError(
965
+ "ERR_EFFECT_RECOVERY_FAILED",
966
+ `${resource.kind} '${name}' failed to initialize and could not be rolled back: ` +
967
+ `${refused.map((f) => `'${f.reason}' (${errorText(f.error)})`).join(", ")}`,
968
+ [
969
+ { severity: "error", message: errorText(error), resource: name },
970
+ ...refused.map((f) => ({
971
+ severity: "error" as const,
972
+ message: `inverse '${f.reason}' refused: ${errorText(f.error)}`,
973
+ resource: name,
974
+ })),
975
+ ],
976
+ );
977
+ }
978
+ // A DEFERRAL is not a failure: it is the loop's own "your turn has
979
+ // not come" signal, raised when a ref names a resource that has not
980
+ // initialized yet. The instance stays — re-creating it would re-run
981
+ // `create()`, which for an import re-registers its alias and reloads
982
+ // its module, and for a template re-registers its children. Its init
983
+ // frame alone was unwound above, so the next pass re-inits a
984
+ // constructed resource rather than a dismantled one.
985
+ if (deferred) throw error;
986
+ // The inverses restored what init() touched OUTSIDE the instance;
987
+ // the instance's own half-built fields are beyond their reach, so
988
+ // the object goes too and the next pass builds a fresh one. That is
989
+ // what makes "retry from a clean state" literal rather than a
990
+ // convention each controller has to honour.
991
+ this.createdInstances.delete(name);
992
+ this.recreatedResources.add(name);
993
+ // Re-queued as REGISTERED, not as created: the create-time manifest
994
+ // has already been through compile-field expansion, and expanding
995
+ // it a second time would evaluate an author's expression twice.
996
+ this.pendingResources.push(source);
997
+ throw error;
998
+ }
899
999
  // Publish BEFORE registering: publication can fail (a kind returning
900
1000
  // the reserved `status` key without declaring it, a report that does
901
1001
  // not match `status:`), and a resource that failed must not be left
@@ -1133,8 +1233,29 @@ export class EvaluationContext implements IEvaluationContext {
1133
1233
  reportedStatus.delete(instance);
1134
1234
  startedInstances.delete(instance);
1135
1235
  completedInstances.delete(instance);
1236
+ // Tearing a resource down IS unwinding its effects — every frame, newest
1237
+ // first, LIFO within each. There is no `teardown()` to call: what undoes a
1238
+ // resource is what its `init()` and `run()` returned.
1239
+ const owner = effectOwnerOf(instance);
1240
+ const refused = (await owner?.effects.unwindAll()) ?? [];
1241
+ if (refused.length > 0) {
1242
+ // Aggregate and continue: one refusing inverse must not strand the log
1243
+ // sinks pinned last to outlive everything that might log on the way
1244
+ // down.
1245
+ failures.push({
1246
+ resource: label,
1247
+ error: new RuntimeError(
1248
+ "ERR_EFFECT_RECOVERY_FAILED",
1249
+ `${refused.length} inverse(s) refused: ` +
1250
+ refused.map((f) => `'${f.reason}' (${errorText(f.error)})`).join(", "),
1251
+ ),
1252
+ });
1253
+ }
1254
+ // A drain waits for in-flight background work under a bound and then
1255
+ // abandons it — not an inverse, so it is not on a frame; it runs here,
1256
+ // once the resource's own effects are undone.
1136
1257
  try {
1137
- if (instance.teardown) await instance.teardown();
1258
+ await owner?.drainDetached();
1138
1259
  } catch (err) {
1139
1260
  // Aggregate rather than abort. A single throwing resource used to
1140
1261
  // abandon every resource after it in the cascade — including the log
@@ -1681,12 +1802,27 @@ export class EvaluationContext implements IEvaluationContext {
1681
1802
  // as started, so this has to happen first.
1682
1803
  await this.markStarted(name, instance);
1683
1804
 
1805
+ // A frame of its own: what `run()` allocates (a listening socket, a kernel
1806
+ // hold) is undone when the run's frame unwinds, without disturbing what
1807
+ // `init()` built underneath it.
1808
+ const effects = effectOwnerOf(instance)?.effects;
1809
+ effects?.openFrame("run");
1810
+
1684
1811
  try {
1685
1812
  // Runnable: run inside the ALS scope so nested invokes inherit the token and
1686
1813
  // trace id (skip the redundant `run` when the token is already ambient).
1687
1814
  // Service: call directly with the explicit context and NO ambient scope, so
1688
1815
  // its long-lived async work does not capture this scope.
1689
- const call = () => (instance.run as (c?: InvokeContext) => Promise<void>)(invokeCtx);
1816
+ // What `run()` returns is what undoes it the socket it opened, the hold
1817
+ // it took. The chain executes INSIDE the same ambient scope as the call
1818
+ // that produced it: its bodies are the work `run()` would otherwise have
1819
+ // done inline, so running them outside would silently strip the
1820
+ // cancellation token and trace parent from exactly the code that moved
1821
+ // into a chain.
1822
+ const call = async () => {
1823
+ const returned = await (instance.run as (c?: InvokeContext) => Promise<unknown>)(invokeCtx);
1824
+ await executeReturnedChain(returned, effects);
1825
+ };
1690
1826
  await (isService || invokeCtx === ambient ? call() : cancellationStore.run(invokeCtx, call));
1691
1827
  // A one-shot Runnable that discovered something during run() publishes it
1692
1828
  // without an explicit call; a Service never reaches this until teardown.
@@ -1694,16 +1830,30 @@ export class EvaluationContext implements IEvaluationContext {
1694
1830
  await this.publishSnapshot(name);
1695
1831
  await this.emit(`${name}.Run`, span("end", "ok", {}));
1696
1832
  } catch (err) {
1833
+ // A run that did not complete leaves nothing of its own behind: its frame
1834
+ // unwinds here, so a `listen()` that threw releases the hold it took a
1835
+ // line earlier instead of holding the process open for a server that
1836
+ // never came up.
1837
+ const refused = (await effects?.unwindFrame()) ?? [];
1838
+ const recovery =
1839
+ refused.length > 0
1840
+ ? {
1841
+ recoveryFailures: refused.map((f) => ({
1842
+ reason: f.reason,
1843
+ message: errorText(f.error),
1844
+ })),
1845
+ }
1846
+ : {};
1697
1847
  if (isCancellationError(err)) {
1698
1848
  const reason = err instanceof Error ? err.message : String(err);
1699
- await this.emit(`${name}.RunCancelled`, span("end", "cancelled", { reason }));
1849
+ await this.emit(`${name}.RunCancelled`, span("end", "cancelled", { reason, ...recovery }));
1700
1850
  throw err;
1701
1851
  }
1702
1852
  const detail =
1703
1853
  err instanceof Error
1704
1854
  ? { name: err.name, message: err.message }
1705
1855
  : { name: "UnknownError", message: String(err) };
1706
- await this.emit(`${name}.RunFailed`, span("end", "failed", detail));
1856
+ await this.emit(`${name}.RunFailed`, span("end", "failed", { ...detail, ...recovery }));
1707
1857
  throw err;
1708
1858
  }
1709
1859
  }
@@ -2053,6 +2203,11 @@ function locateFailedAccess(
2053
2203
  return null;
2054
2204
  }
2055
2205
 
2206
+ /** One line for an inverse's refusal, quoted into a recovery aggregate. */
2207
+ function errorText(err: unknown): string {
2208
+ return err instanceof Error ? err.message : String(err);
2209
+ }
2210
+
2056
2211
  function describeMissingAccess(value: unknown, key: string): string {
2057
2212
  if (value === null) return `cannot read '${key}' — value is null`;
2058
2213
  if (value === undefined) return `cannot read '${key}' — value is undefined`;
@@ -6,11 +6,18 @@ import type { RuntimeDiagnostic } from "@telorun/sdk";
6
6
  * failure of its own. This is the ONLY signal that an entry may be collapsed —
7
7
  * see {@link classifyInitFailures}.
8
8
  */
9
- const DEPENDENCY_PENDING_CODES = new Set([
9
+ export const DEPENDENCY_PENDING_CODES = new Set([
10
10
  "ERR_LOCAL_REF_PENDING",
11
11
  "ERR_CROSS_MODULE_REF_PENDING",
12
12
  ]);
13
13
 
14
+ /** Whether a thrown error is the loop's own "not your turn yet" signal rather
15
+ * than a failure of the resource. */
16
+ export function isDeferral(err: unknown): boolean {
17
+ const code = (err as { code?: string } | undefined)?.code;
18
+ return code !== undefined && DEPENDENCY_PENDING_CODES.has(code);
19
+ }
20
+
14
21
  /** One resource that did not reach the `Initialized` state, with the outbound
15
22
  * edges (names of resources in the SAME context) captured for it. */
16
23
  export interface FailedResource {
package/src/kernel.ts CHANGED
@@ -51,6 +51,7 @@ import { ambientInvokeContext } from "./evaluation-context.js";
51
51
  import { ModuleContext } from "./module-context.js";
52
52
  import { ResourceContextImpl } from "./resource-context.js";
53
53
  import { mintResourceHandle } from "./resource-handle.js";
54
+ import { bindEffectOwner } from "./effect-scope.js";
54
55
  import { declarationOfInstance, recordInstanceDeclaration } from "./instance-declaration.js";
55
56
  import { nodeHostVersions } from "./host-versions.js";
56
57
  import { nodeCelHandlers } from "./cel-handlers.js";
@@ -1389,7 +1390,17 @@ export class Kernel implements IKernel {
1389
1390
  try {
1390
1391
  this.sharedSchemaValidator
1391
1392
  .compile(configSchema)
1392
- .validate(stripCompiledValues(resource, configSchema));
1393
+ .validate(
1394
+ stripCompiledValues(resource, configSchema, undefined, (ref) =>
1395
+ // A kind may describe a slot with a shape declared elsewhere. The
1396
+ // strip walk has to see THROUGH that reference or the value under it
1397
+ // reads as undescribed, and every CEL leaf beneath it collapses to
1398
+ // `""` — which the shape then rejects, reporting a violation of a
1399
+ // value the author never wrote. AJV resolves it either way; this is
1400
+ // what lets the placeholder walk agree with it.
1401
+ this.sharedSchemaValidator.getSchema(ref) as Record<string, any> | undefined,
1402
+ ),
1403
+ );
1393
1404
  } catch (error) {
1394
1405
  throw new RuntimeError(
1395
1406
  "ERR_RESOURCE_SCHEMA_VALIDATION_FAILED",
@@ -1457,16 +1468,14 @@ export class Kernel implements IKernel {
1457
1468
  // parent inputs → controller → parent result → mapping → child result.
1458
1469
  this.bindInvocationContract(instance, processedResource, resolvedKind, ctx);
1459
1470
 
1460
- // Fold the resource's fire-and-forget drain into its own teardown: tearing
1461
- // the resource down drains the background tasks it spawned (the kernel just
1462
- // calls teardown() it tracks no tasks itself). A drain with no pending
1463
- // tasks is a no-op, so this is safe for every resource.
1464
- const ownerCtx = ctx as ResourceContextImpl;
1465
- const originalTeardown = instance.teardown?.bind(instance);
1466
- instance.teardown = async () => {
1467
- await ownerCtx.drainDetached();
1468
- if (originalTeardown) await originalTeardown();
1469
- };
1471
+ // Record the instance's inverse accumulator here, at the single
1472
+ // instance-production site, for the same reason the handle is minted here:
1473
+ // the teardown cascade holds an instance and nothing else, so this is the
1474
+ // one point where the instance and its context are both in hand. The
1475
+ // detached drain is NOT folded into teardown any more — it waits for
1476
+ // in-flight work rather than undoing anything, so the teardown cascade
1477
+ // calls it directly (see `EvaluationContext.teardownResources`).
1478
+ bindEffectOwner(instance, ctx as ResourceContextImpl);
1470
1479
 
1471
1480
  if (!runtime.length) return { instance, ctx, resource: processedResource };
1472
1481
 
@@ -214,12 +214,7 @@ function lazyValidator(schema: object): LazyValidator {
214
214
  export const validateResourceDefinition = lazyValidator(ResourceDefinitionSchema);
215
215
  export const validateResourceAbstract = lazyValidator(ResourceAbstractSchema);
216
216
 
217
- export function formatAjvErrors(errors: any[] | null | undefined): string {
218
- if (!errors || errors.length === 0) return "Unknown schema error";
219
- return errors
220
- .map((err) => {
221
- const p = err.instancePath || "/";
222
- return `${p} ${err.message ?? "is invalid"}`;
223
- })
224
- .join("; ");
225
- }
217
+ /** Re-exported from the analyzer so a schema failure is phrased identically
218
+ * under `telo check` and at runtime the kernel's own copy rendered the same
219
+ * failure as a raw `instancePath + message` join and handled no union. */
220
+ export { formatAjvErrors } from "@telorun/analyzer";
@@ -277,6 +277,22 @@ export class ModuleContext extends EvaluationContext implements IModuleContext {
277
277
  }
278
278
  }
279
279
 
280
+ /**
281
+ * Undo every registration an alias made — the kind gate, the exported-instance
282
+ * scope and the exported-kind resolver — as ONE operation, because they are
283
+ * one act: an import controller registers all four in `create()` and an alias
284
+ * left half-registered would resolve kinds through a module whose instances
285
+ * are gone. This is the inverse of the create-frame effect that registers
286
+ * them, so an import whose instance is discarded does not leave its alias
287
+ * behind for the retry to collide with.
288
+ */
289
+ unregisterImport(alias: string): void {
290
+ this.importAliases.delete(alias);
291
+ this.importedKinds.delete(alias);
292
+ this.importedScopes.delete(alias);
293
+ this.importedKindResolvers.delete(alias);
294
+ }
295
+
280
296
  /**
281
297
  * Register an alias that crosses no import boundary and is therefore never gated:
282
298
  * `Self` (a library resolving its own kinds — `exports.kinds` gates importers, not
@@ -1,4 +1,4 @@
1
- import { registerTeloKeywords } from "@telorun/analyzer";
1
+ import { formatAjvErrors, registerTeloKeywords } from "@telorun/analyzer";
2
2
  import AjvModule from "ajv";
3
3
  import { detachSnapshotValue, OBSERVED_STATE_KEY, RuntimeError } from "@telorun/sdk";
4
4
 
@@ -96,9 +96,7 @@ export function acceptReportedStatus(
96
96
  }
97
97
  const validate = validatorFor(opts.statusSchema);
98
98
  if (!validate(status)) {
99
- const detail = (validate.errors ?? [])
100
- .map((e) => `${e.instancePath || "/"} ${e.message ?? "is invalid"}`)
101
- .join("; ");
99
+ const detail = formatAjvErrors(validate.errors);
102
100
  throw new RuntimeError(
103
101
  "ERR_OBSERVED_STATE_INVALID",
104
102
  `${opts.kind} '${opts.name}' reported observed state that does not match its declared 'status:': ${detail}`,
@@ -28,7 +28,10 @@ import {
28
28
  type TypeRule,
29
29
  type OpenZoneAttributes,
30
30
  type ZoneEntry,
31
+ type EffectBody,
32
+ type EffectChain,
31
33
  } from "@telorun/sdk";
34
+ import { EffectScope } from "./effect-scope.js";
32
35
  import { registerTeloKeywords } from "@telorun/analyzer";
33
36
  import { isRefSentinel } from "@telorun/templating";
34
37
  import { ZoneContext } from "./zone-context.js";
@@ -829,6 +832,35 @@ export class ResourceContextImpl implements ResourceContext {
829
832
  await this.kernel.emitRuntimeEvent(`${this.metadata.name}.${event}`, payload);
830
833
  }
831
834
 
835
+ /**
836
+ * This resource's inverse accumulator. Opened with the context — `create()`
837
+ * is where template controllers and imports allocate, so an effect performed
838
+ * there is on the chain alongside `init()` and `run()`.
839
+ */
840
+ #effects: EffectScope | undefined;
841
+
842
+ get effects(): EffectScope {
843
+ return (this.#effects ??= new EffectScope(String(this.metadata?.name ?? "<unnamed>")));
844
+ }
845
+
846
+ effect<T>(reason: string, body: EffectBody<void, T>): EffectChain<T> {
847
+ return this.effects.chain(reason, body);
848
+ }
849
+
850
+ /**
851
+ * Hold the kernel open; the returned closure releases it.
852
+ *
853
+ * Deliberately NOT registered as an effect here. A hold IS an effect — it is
854
+ * an action with an inverse — but which FRAME owns it is a fact only the
855
+ * caller has: `run()`'s hold belongs to the run frame, a hold taken per
856
+ * durable run inside `invoke()` belongs to that operation and is disposed when
857
+ * the run settles. Registering here would put every hold on whichever frame
858
+ * happened to be open, and the four call sites that already wrap it in an
859
+ * effect would register the same release twice.
860
+ *
861
+ * So this returns the raw inverse, and a caller places it:
862
+ * `ctx.effect("kernel hold", async () => ({ result: undefined, inverse: ctx.acquireHold() }))`.
863
+ */
832
864
  acquireHold(reason?: string): () => void {
833
865
  return this.kernel.acquireHold(reason);
834
866
  }
@@ -1,5 +1,5 @@
1
1
  import { isCompiledValue } from "@telorun/sdk";
2
- import { selectUnionBranch } from "@telorun/analyzer";
2
+ import { type ExternalSchemaResolver, resolveRefIn, selectUnionBranch } from "@telorun/analyzer";
3
3
 
4
4
  /** Returns a schema-appropriate placeholder value for a CompiledValue field. */
5
5
  function placeholderForSchema(schema: Record<string, unknown>): unknown {
@@ -38,22 +38,25 @@ function placeholderForSchema(schema: Record<string, unknown>): unknown {
38
38
  }
39
39
  }
40
40
 
41
- /** Resolve a `$ref` (only `#/$defs/...` form) against the root schema. */
41
+ /**
42
+ * Resolve a `$ref` — document-local against `root`, everything else through the
43
+ * schema store when the caller supplies one.
44
+ *
45
+ * Without the store a named shape (`telo:<module>/<Type>`) stops the walk: the
46
+ * node reads as undescribed, so every CEL leaf beneath it collapses to `""` and
47
+ * is then rejected by the very schema that describes it. A kind referencing a
48
+ * shape declared elsewhere is exactly the case, and the failure surfaces as a
49
+ * violation of a value the author never wrote.
50
+ */
42
51
  function resolveSchemaRef(
43
52
  schema: Record<string, unknown>,
44
53
  root: Record<string, unknown>,
45
- ): Record<string, unknown> {
46
- if (
47
- schema.$ref &&
48
- typeof schema.$ref === "string" &&
49
- (schema.$ref as string).startsWith("#/$defs/")
50
- ) {
51
- const defName = (schema.$ref as string).slice("#/$defs/".length);
52
- const defs = root.$defs as Record<string, Record<string, unknown>> | undefined;
53
- const resolved = defs?.[defName];
54
- if (resolved) return resolved;
55
- }
56
- return schema;
54
+ external?: ExternalSchemaResolver,
55
+ ): { schema: Record<string, unknown>; root: Record<string, unknown> } {
56
+ return resolveRefIn(schema as Record<string, any>, root as Record<string, any>, external) as {
57
+ schema: Record<string, unknown>;
58
+ root: Record<string, unknown>;
59
+ };
57
60
  }
58
61
 
59
62
  /** Collect property schemas from top-level `properties` and all `oneOf`/`anyOf` sub-schemas. */
@@ -121,13 +124,18 @@ export function stripCompiledValues(
121
124
  v: unknown,
122
125
  schema: Record<string, unknown> = {},
123
126
  rootSchema?: Record<string, unknown>,
127
+ external?: ExternalSchemaResolver,
124
128
  ): unknown {
125
129
  const root = rootSchema ?? schema;
126
130
  // Ancestors on the current path, so a genuine cycle stops while a sub-object
127
131
  // that merely appears twice is still stripped both times.
128
132
  const ancestors = new Set<object>();
129
133
 
130
- const walk = (value: unknown, rawNodeSchema: Record<string, unknown>): unknown => {
134
+ const walk = (
135
+ value: unknown,
136
+ rawNodeSchema: Record<string, unknown>,
137
+ base: Record<string, unknown> = root,
138
+ ): unknown => {
131
139
  // A UNION carries no `type` / `items` / `properties` of its own, so descending
132
140
  // through one hands every CEL leaf underneath the schema-unaware `""`
133
141
  // placeholder — which the branches then reject, reporting violations against
@@ -136,12 +144,16 @@ export function stripCompiledValues(
136
144
  // placed. Shared with the analyzer rather than reimplemented: the static and
137
145
  // dispatch halves must choose the same branch, or one reports what the other
138
146
  // accepts.
147
+ const entered = resolveSchemaRef(rawNodeSchema, base, external);
139
148
  const nodeSchema = selectUnionBranch(
140
- resolveSchemaRef(rawNodeSchema, root),
149
+ entered.schema,
141
150
  value,
142
- root as Record<string, any>,
151
+ entered.root as Record<string, any>,
152
+ external,
143
153
  ) as Record<string, unknown>;
144
- const resolved = resolveSchemaRef(nodeSchema, root);
154
+ const here = resolveSchemaRef(nodeSchema, entered.root, external);
155
+ const resolved = here.schema;
156
+ const nodeRoot = here.root;
145
157
 
146
158
  if (isCompiledValue(value)) return placeholderForSchema(resolved);
147
159
  // A slot the schema declares as a reference is never config when it HOLDS a
@@ -153,8 +165,10 @@ export function stripCompiledValues(
153
165
  if (resolved["x-telo-ref"] !== undefined && !isConfigAtRefSlot(value)) return value;
154
166
 
155
167
  if (Array.isArray(value)) {
156
- const itemSchema = resolveSchemaRef((resolved.items ?? {}) as Record<string, unknown>, root);
157
- return walkGuarded(value, () => value.map((item) => walk(item, itemSchema)));
168
+ const item = resolveSchemaRef((resolved.items ?? {}) as Record<string, unknown>, nodeRoot, external);
169
+ return walkGuarded(value, () =>
170
+ value.map((element) => walk(element, item.schema, item.root)),
171
+ );
158
172
  }
159
173
  if (value !== null && typeof value === "object") {
160
174
  // A class instance (a client, a pool, a stream) carries no CompiledValues
@@ -166,7 +180,7 @@ export function stripCompiledValues(
166
180
  return walkGuarded(value, () => {
167
181
  const out: Record<string, unknown> = {};
168
182
  for (const [k, val] of Object.entries(value as Record<string, unknown>)) {
169
- out[k] = walk(val, props[k] ?? {});
183
+ out[k] = walk(val, props[k] ?? {}, nodeRoot);
170
184
  }
171
185
  return out;
172
186
  });