@telorun/kernel 0.60.0 → 0.61.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 (53) hide show
  1. package/dist/controllers/resource-definition/resource-inherited-controller.d.ts.map +1 -1
  2. package/dist/controllers/resource-definition/resource-inherited-controller.js +57 -10
  3. package/dist/controllers/resource-definition/resource-inherited-controller.js.map +1 -1
  4. package/dist/controllers/type/json-schema-controller.d.ts +8 -0
  5. package/dist/controllers/type/json-schema-controller.d.ts.map +1 -0
  6. package/dist/controllers/type/json-schema-controller.js +91 -0
  7. package/dist/controllers/type/json-schema-controller.js.map +1 -0
  8. package/dist/evaluation-context.d.ts +5 -0
  9. package/dist/evaluation-context.d.ts.map +1 -1
  10. package/dist/evaluation-context.js +63 -33
  11. package/dist/evaluation-context.js.map +1 -1
  12. package/dist/index.d.ts +1 -0
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +1 -0
  15. package/dist/index.js.map +1 -1
  16. package/dist/init-failure-diagnostics.d.ts +61 -0
  17. package/dist/init-failure-diagnostics.d.ts.map +1 -0
  18. package/dist/init-failure-diagnostics.js +141 -0
  19. package/dist/init-failure-diagnostics.js.map +1 -0
  20. package/dist/invocation-contract-binding.d.ts +105 -0
  21. package/dist/invocation-contract-binding.d.ts.map +1 -0
  22. package/dist/invocation-contract-binding.js +296 -0
  23. package/dist/invocation-contract-binding.js.map +1 -0
  24. package/dist/kernel.d.ts +17 -0
  25. package/dist/kernel.d.ts.map +1 -1
  26. package/dist/kernel.js +62 -5
  27. package/dist/kernel.js.map +1 -1
  28. package/dist/module-context.d.ts.map +1 -1
  29. package/dist/module-context.js +21 -0
  30. package/dist/module-context.js.map +1 -1
  31. package/dist/resource-context.d.ts +45 -0
  32. package/dist/resource-context.d.ts.map +1 -1
  33. package/dist/resource-context.js +79 -0
  34. package/dist/resource-context.js.map +1 -1
  35. package/dist/schema-compiled-values.d.ts +9 -1
  36. package/dist/schema-compiled-values.d.ts.map +1 -1
  37. package/dist/schema-compiled-values.js +55 -16
  38. package/dist/schema-compiled-values.js.map +1 -1
  39. package/dist/schema-validator.d.ts.map +1 -1
  40. package/dist/schema-validator.js +15 -1
  41. package/dist/schema-validator.js.map +1 -1
  42. package/package.json +3 -3
  43. package/src/controllers/resource-definition/resource-inherited-controller.ts +69 -10
  44. package/src/controllers/type/json-schema-controller.ts +114 -0
  45. package/src/evaluation-context.ts +81 -32
  46. package/src/index.ts +1 -0
  47. package/src/init-failure-diagnostics.ts +169 -0
  48. package/src/invocation-contract-binding.ts +392 -0
  49. package/src/kernel.ts +83 -6
  50. package/src/module-context.ts +27 -0
  51. package/src/resource-context.ts +81 -0
  52. package/src/schema-compiled-values.ts +55 -15
  53. package/src/schema-validator.ts +15 -1
@@ -66,33 +66,89 @@ function resolveRefSlot(value: unknown, ctx: ResourceContext): ResourceInstance
66
66
  return instance ?? undefined;
67
67
  }
68
68
 
69
- /** Expand a `base:` node against `self`. Self-only CEL resolves to literals now;
70
- * a pure `self.<path>` access is navigated directly so live instances pass
71
- * through. */
72
- function expandBaseNode(value: unknown, self: Record<string, unknown>, ctx: EvaluationContext): unknown {
69
+ /** Expand a mapping node against a CEL scope. `self`-only CEL resolves to
70
+ * literals now; a pure `self.<path>` access is navigated directly so live
71
+ * instances pass through (CEL's output type checker rejects them). Shared by
72
+ * `base:` (construction, scope `{ self }`) and the dispatch mappings
73
+ * `inputs:` / `result:` (scope `{ self, inputs }` / `{ self, result }`). */
74
+ function expandMappingNode(
75
+ value: unknown,
76
+ scope: Record<string, unknown>,
77
+ ctx: EvaluationContext,
78
+ ): unknown {
73
79
  if (isCompiledValue(value)) {
74
80
  const src = typeof value.source === "string" ? value.source.trim() : "";
75
81
  const m = src.match(SELF_PATH);
76
82
  if (m) {
77
- let cur: unknown = self;
83
+ let cur: unknown = scope.self;
78
84
  for (const key of m[1].split(".").slice(1)) {
79
85
  cur = (cur as Record<string, unknown> | undefined)?.[key];
80
86
  }
81
87
  return cur;
82
88
  }
83
- return ctx.expandWith(value, { self });
89
+ return ctx.expandWith(value, scope);
84
90
  }
85
- if (Array.isArray(value)) return value.map((v) => expandBaseNode(v, self, ctx));
91
+ if (Array.isArray(value)) return value.map((v) => expandMappingNode(v, scope, ctx));
86
92
  if (value !== null && typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype) {
87
93
  const out: Record<string, unknown> = {};
88
94
  for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
89
- out[k] = expandBaseNode(v, self, ctx);
95
+ out[k] = expandMappingNode(v, scope, ctx);
90
96
  }
91
97
  return out;
92
98
  }
93
99
  return value;
94
100
  }
95
101
 
102
+ /**
103
+ * Bind the dispatch mappings of a child that REPLACED its inherited contract.
104
+ *
105
+ * A contract resolves to the nearest declaration along `extends` and never
106
+ * merges, so a child declaring `inputType` presents a signature the inherited
107
+ * controller has never heard of. `inputs:` is the adapter that turns the child's
108
+ * signature into the parent's call, and `result:` turns the parent's result back
109
+ * into the child's declared output — the same top-level-sibling factoring a
110
+ * template definition uses for `invoke:`.
111
+ *
112
+ * Bound onto the parent instance rather than wrapping it, so the child still IS
113
+ * a parent instance: `init`, `snapshot`, `teardown` and status plumbing are
114
+ * untouched, and nothing new appears in a declared ref slot. It composes with
115
+ * the kernel's contract binding by position — the parent's contract was bound
116
+ * when the parent instance was produced, this mapping goes on next, and the
117
+ * child's own contract is bound outermost by the caller. One dispatch therefore
118
+ * checks the child's inputs, maps, checks the parent's inputs, runs, checks the
119
+ * parent's result, maps back, and checks the child's result.
120
+ */
121
+ function bindDispatchMapping(
122
+ instance: ResourceInstance,
123
+ definition: ResourceDefinition,
124
+ self: Record<string, unknown>,
125
+ ctx: EvaluationContext,
126
+ ): void {
127
+ const body = definition as unknown as { inputs?: unknown; result?: unknown };
128
+ const inputsMapping = body.inputs;
129
+ const resultMapping = body.result;
130
+ if (inputsMapping == null && resultMapping == null) return;
131
+ if (typeof instance.invoke !== "function") return;
132
+
133
+ // Every argument is forwarded — the mapping rewrites `inputs`, while the
134
+ // InvokeContext (cancellation, tracing) in later parameters belongs to the
135
+ // caller and must reach the inherited controller untouched.
136
+ const original = instance.invoke.bind(instance) as (
137
+ inputs: any,
138
+ ...rest: unknown[]
139
+ ) => Promise<unknown>;
140
+ instance.invoke = async (inputs: any, ...rest: unknown[]) => {
141
+ const mappedInputs =
142
+ inputsMapping != null
143
+ ? (expandMappingNode(inputsMapping, { self, inputs }, ctx) as Record<string, unknown>)
144
+ : inputs;
145
+ const result = await original(mappedInputs, ...rest);
146
+ return resultMapping != null
147
+ ? expandMappingNode(resultMapping, { self, result }, ctx)
148
+ : result;
149
+ };
150
+ }
151
+
96
152
  /**
97
153
  * Controller for a definition that inherits its controller by delegation — it
98
154
  * `extends` a concrete kind, declares no own `controllers:` / template body, and
@@ -143,7 +199,7 @@ export function createInheritedController(
143
199
  // instances in `self`), minus the reserved keys.
144
200
  let parentConfig: Record<string, unknown>;
145
201
  if (base != null) {
146
- parentConfig = expandBaseNode(base, self, definingContext) as Record<string, unknown>;
202
+ parentConfig = expandMappingNode(base, { self }, definingContext) as Record<string, unknown>;
147
203
  } else {
148
204
  const { kind: _kind, metadata: _metadata, name: _name, ...config } = self;
149
205
  parentConfig = config;
@@ -159,7 +215,10 @@ export function createInheritedController(
159
215
  `Telo.Definition '${definition.metadata.name}': inherited controller requires a ResourceContext host that implements createInheritedInstance().`,
160
216
  );
161
217
  }
162
- return host.createInheritedInstance(definingContext, parentResource);
218
+ const instance = await host.createInheritedInstance(definingContext, parentResource);
219
+ if (!instance) return null;
220
+ bindDispatchMapping(instance, definition, self, definingContext);
221
+ return instance;
163
222
  },
164
223
  };
165
224
  }
@@ -0,0 +1,114 @@
1
+ import { evaluate } from "@marcbachmann/cel-js";
2
+ import type {
3
+ ResourceContext,
4
+ ResourceInstance,
5
+ ResourceManifest,
6
+ TypeRule,
7
+ } from "@telorun/sdk";
8
+ import { canonicalTypeSchemaId, mergeTypeSchemas, RuntimeError } from "@telorun/sdk";
9
+
10
+ /**
11
+ * `Telo.JsonSchema` — a named data shape.
12
+ *
13
+ * It lives in the kernel rather than in an installable module for the same
14
+ * reason the mandatory log sinks do: declaring a shape is not optional. Every
15
+ * kind that carries an invocation contract needs one, so requiring an import to
16
+ * write `inputType:` would tax the exact thing contracts want authors to do —
17
+ * and a library declaring its own contract would have to import a module purely
18
+ * to describe itself.
19
+ *
20
+ * `type.JsonSchema` is retained as a deprecated alias with the same behaviour, so
21
+ * published manifests keep resolving.
22
+ */
23
+ class JsonSchemaType {
24
+ constructor(
25
+ private readonly qualifiedName: string,
26
+ private readonly rules: TypeRule[],
27
+ /** The fully-resolved (post-`extends`), self-contained JSON Schema. Read by
28
+ * consumers that need the effective shape — e.g. a templated resource
29
+ * threading `${{ self.model.schema }}` into a request validation schema. */
30
+ readonly schema: Record<string, unknown>,
31
+ ) {}
32
+
33
+ /**
34
+ * Validate data against this type's CEL rules — the invariant layer on top of
35
+ * the schema itself, which AJV enforces through the schema registry.
36
+ */
37
+ validateRules(data: unknown): void {
38
+ for (const rule of this.rules) {
39
+ let result: unknown;
40
+ try {
41
+ result = evaluate(rule.condition, { this: data });
42
+ } catch (err) {
43
+ throw new RuntimeError(
44
+ "ERR_TYPE_VALIDATION_FAILED",
45
+ `Type "${this.qualifiedName}" rule evaluation failed: ${err instanceof Error ? err.message : String(err)}`,
46
+ );
47
+ }
48
+ if (result !== true) {
49
+ throw new RuntimeError(
50
+ rule.code,
51
+ rule.message ??
52
+ `Type "${this.qualifiedName}" validation failed: rule "${rule.code}" not satisfied`,
53
+ );
54
+ }
55
+ }
56
+ }
57
+ }
58
+
59
+ /** A type resource is pure declaration: it registers a schema and holds the
60
+ * resolved shape, implementing none of the lifecycle verbs. `ResourceInstance`
61
+ * is entirely optional members, so a class with none of them satisfies it only
62
+ * by assertion — the same shape the module-loaded controller had, where the
63
+ * dynamic-import boundary erased the type instead of asserting it. */
64
+ export async function create(
65
+ resource: ResourceManifest,
66
+ ctx: ResourceContext,
67
+ ): Promise<ResourceInstance | null> {
68
+ const qualifiedName = `${resource.metadata.module}.${resource.metadata.name}`;
69
+ const ownSchema = resource.schema as Record<string, unknown>;
70
+
71
+ let schema: Record<string, unknown> = ownSchema;
72
+
73
+ const extendsField = resource.extends as string | string[] | undefined;
74
+ if (extendsField) {
75
+ const parents = Array.isArray(extendsField) ? extendsField : [extendsField];
76
+
77
+ const parentSchemas: Record<string, unknown>[] = [];
78
+ for (const parent of parents) {
79
+ const parentSchema = ctx.lookupSchema(parent);
80
+ // Defer if any parent schema isn't registered yet (multi-pass resolution).
81
+ if (!parentSchema) return null;
82
+ parentSchemas.push(parentSchema as Record<string, unknown>);
83
+ }
84
+
85
+ // Each parent's registered schema is itself already resolved, so merging
86
+ // them makes inheritance transitive through grandparents with no `$ref`s
87
+ // left in the result.
88
+ schema = mergeTypeSchemas([...parentSchemas, ownSchema]);
89
+ }
90
+
91
+ const rules = (Array.isArray(resource.rules) ? resource.rules : []) as TypeRule[];
92
+
93
+ ctx.registerSchema(qualifiedName, schema);
94
+ ctx.registerTypeRules(qualifiedName, rules);
95
+
96
+ // Also register under the short name so types can be referenced without a
97
+ // module prefix.
98
+ const shortName = resource.metadata.name;
99
+ if (shortName !== qualifiedName) {
100
+ ctx.registerSchema(shortName, schema);
101
+ ctx.registerTypeRules(shortName, rules);
102
+ }
103
+
104
+ // Canonical module-scoped id — the target of `$ref: "telo://Self/<name>"` (and
105
+ // `telo://<Alias>/<name>` across imports) once the loader resolves the
106
+ // authority to this module. Authority-free, so a validator can actually
107
+ // resolve the reference; see `canonicalTypeSchemaId`.
108
+ const moduleName = resource.metadata.module as string | undefined;
109
+ if (moduleName) {
110
+ ctx.registerSchema(canonicalTypeSchemaId(moduleName, shortName), schema);
111
+ }
112
+
113
+ return new JsonSchemaType(qualifiedName, rules, schema) as unknown as ResourceInstance;
114
+ }
@@ -26,6 +26,12 @@ import {
26
26
  } from "@telorun/sdk";
27
27
  import { RuntimeError } from "@telorun/sdk";
28
28
  import { evalPathCovers } from "@telorun/analyzer";
29
+ import {
30
+ classifyInitFailures,
31
+ renderInitFailureText,
32
+ summarizeInitFailures,
33
+ type FailedResource,
34
+ } from "./init-failure-diagnostics.js";
29
35
  import {
30
36
  acceptReportedStatus,
31
37
  buildPublishedProps,
@@ -93,6 +99,16 @@ function collectResourceRefs(resource: ResourceManifest): ResourceRef[] {
93
99
  return [...found.values()];
94
100
  }
95
101
 
102
+ /**
103
+ * Project resource refs onto the names they depend on IN THIS CONTEXT. A local
104
+ * ref is its own name; a cross-module `Alias.name` ref depends on the local
105
+ * `Telo.Import` resource named by the alias, since that is the resource whose
106
+ * failure would strand it. Used to attribute an init failure to its cause.
107
+ */
108
+ function localDependencyNames(refs: ResourceRef[]): string[] {
109
+ return refs.map((r) => (r.alias && r.alias !== "Self" ? r.alias : r.name));
110
+ }
111
+
96
112
  /**
97
113
  * Build a resource's resolved properties for the debug stream — its config "after
98
114
  * templating", with `${{ }}` / `!cel` reduced to concrete values. The manifest is
@@ -343,6 +359,12 @@ export class EvaluationContext implements IEvaluationContext {
343
359
  /** Resources queued for initialization on this context node. */
344
360
  private pendingResources: ResourceManifest[] = [];
345
361
 
362
+ /** Per-resource dependency names, captured at create() time — BEFORE Phase-5
363
+ * injection swaps refs for live instances, so the walk sees plain objects and
364
+ * cannot wander into a controller's (possibly cyclic) object graph. Read only
365
+ * when init fails, to attribute each failure to its cause. */
366
+ private readonly resourceDependencies = new Map<string, string[]>();
367
+
346
368
  /**
347
369
  * Optional hook called between create() and init() for each resource.
348
370
  * Set by the kernel to inject live instances into reference fields.
@@ -616,7 +638,10 @@ export class EvaluationContext implements IEvaluationContext {
616
638
  */
617
639
  async initializeResources(): Promise<void> {
618
640
  const MAX_PASSES = 10;
619
- const errors = new Map<string, { message: string; code?: string; details?: string }>();
641
+ const errors = new Map<
642
+ string,
643
+ { message: string; code?: string; details?: string; children?: RuntimeDiagnostic[] }
644
+ >();
620
645
 
621
646
  let pass = 1;
622
647
  do {
@@ -641,6 +666,8 @@ export class EvaluationContext implements IEvaluationContext {
641
666
  errors.delete(name);
642
667
  progress = true;
643
668
  const createdRes = created.resource;
669
+ const refs = collectResourceRefs(createdRes);
670
+ this.resourceDependencies.set(name, localDependencyNames(refs));
644
671
  const payload: Record<string, unknown> = {
645
672
  resource: {
646
673
  kind: createdRes.kind,
@@ -649,7 +676,7 @@ export class EvaluationContext implements IEvaluationContext {
649
676
  id: this.resourceId(createdRes.kind, createdRes.metadata.name),
650
677
  },
651
678
  ...(this.owner ? { owner: this.owner } : {}),
652
- dependencies: this.qualifyDeps(collectResourceRefs(createdRes)),
679
+ dependencies: this.qualifyDeps(refs),
653
680
  };
654
681
  // `properties` (the resolved config) is a second full config walk plus
655
682
  // a secret scrub. Build it lazily: the EventBus short-circuits when
@@ -682,6 +709,7 @@ export class EvaluationContext implements IEvaluationContext {
682
709
  ? this.resolveImportedInstance(alias, n)
683
710
  : this.resourceInstances.get(n)?.instance,
684
711
  (n) => this.hasManifest(n) && !this.resourceInstances.has(n),
712
+ this,
685
713
  );
686
714
  }
687
715
  if (instance.init) await instance.init(ctx);
@@ -694,6 +722,9 @@ export class EvaluationContext implements IEvaluationContext {
694
722
  await this.publishSnapshot(name);
695
723
  this.resourceInstances.set(name, { resource, instance });
696
724
  this.createdInstances.delete(name);
725
+ // Read only on failure, and this one succeeded — drop it rather than
726
+ // holding a dep-name array per resource for the context's lifetime.
727
+ this.resourceDependencies.delete(name);
697
728
  errors.delete(name);
698
729
  progress = true;
699
730
  await this.emit(`${resource.kind}.${resource.metadata.name}.Initialized`, {
@@ -715,38 +746,33 @@ export class EvaluationContext implements IEvaluationContext {
715
746
  } while (pass <= MAX_PASSES);
716
747
 
717
748
  if (this.pendingResources.length > 0 || this.createdInstances.size > 0) {
718
- const diagnostics: RuntimeDiagnostic[] = [
719
- ...this.pendingResources.map((r) => {
720
- const info = errors.get(r.metadata.name) ?? { message: "Unknown error" };
721
- return {
722
- resource: r.metadata.name,
723
- kind: r.kind,
724
- message: info.message,
725
- details: info.details,
726
- code: info.code,
727
- };
728
- }),
729
- ...[...this.createdInstances].map(([name, { resource }]) => {
730
- const info = errors.get(name) ?? { message: "Unknown error" };
731
- return {
732
- resource: name,
733
- kind: resource.kind,
734
- message: info.message,
735
- details: info.details,
736
- code: info.code,
737
- };
738
- }),
749
+ const toFailure = (name: string, kind: string, deps: string[]): FailedResource => {
750
+ const info = errors.get(name) ?? { message: "Unknown error" };
751
+ return {
752
+ resource: name,
753
+ kind,
754
+ message: info.message,
755
+ details: info.details,
756
+ code: info.code,
757
+ children: info.children,
758
+ deps,
759
+ };
760
+ };
761
+ const failures: FailedResource[] = [
762
+ // A resource that never got created was never injected either, so its
763
+ // manifest still carries plain `{kind, name}` refs — walk it here rather
764
+ // than relying on the create-time capture it never reached.
765
+ ...this.pendingResources.map((r) =>
766
+ toFailure(r.metadata.name, r.kind, localDependencyNames(collectResourceRefs(r))),
767
+ ),
768
+ ...[...this.createdInstances].map(([name, { resource }]) =>
769
+ toFailure(name, resource.kind, this.resourceDependencies.get(name) ?? []),
770
+ ),
739
771
  ];
740
- const textDetails = diagnostics
741
- .map((d) => {
742
- const head = ` ${d.kind ? `${d.kind} ` : ""}${d.resource}: ${d.message}${d.code ? ` [${d.code}]` : ""}`;
743
- const extra = d.details ? "\n" + d.details.split("\n").map((l) => ` ${l}`).join("\n") : "";
744
- return head + extra;
745
- })
746
- .join("\n");
772
+ const diagnostics = classifyInitFailures(failures);
747
773
  throw new RuntimeError(
748
774
  "ERR_RESOURCE_INITIALIZATION_FAILED",
749
- `Unable to process resources:\n${textDetails}`,
775
+ `${summarizeInitFailures(diagnostics)}:\n${renderInitFailureText(diagnostics)}`,
750
776
  diagnostics,
751
777
  );
752
778
  }
@@ -815,7 +841,7 @@ export class EvaluationContext implements IEvaluationContext {
815
841
  // Propagate injection hook: extend getInstance to also resolve parent singleton instances.
816
842
  if (parent.preInitHook) {
817
843
  const parentHook = parent.preInitHook;
818
- child.preInitHook = (resource, childGetInstance, childIsPending) => {
844
+ child.preInitHook = (resource, childGetInstance, childIsPending, owner) => {
819
845
  parentHook(
820
846
  resource,
821
847
  (name, alias) => {
@@ -836,6 +862,9 @@ export class EvaluationContext implements IEvaluationContext {
836
862
  // Only a scope-local dependency can still be pending — an outer resource
837
863
  // is live by the time a scope opens — so the child's own predicate suffices.
838
864
  childIsPending,
865
+ // Forwarded, not replaced by `child`: a `with:` nested inside a scoped
866
+ // resource must still resolve kinds against the scope it opened in.
867
+ owner,
839
868
  );
840
869
  };
841
870
  }
@@ -1761,11 +1790,31 @@ function formatErrorForDiagnostic(err: unknown): {
1761
1790
  message: string;
1762
1791
  code?: string;
1763
1792
  details?: string;
1793
+ children?: RuntimeDiagnostic[];
1764
1794
  } {
1765
1795
  if (!(err instanceof Error)) {
1766
1796
  return { message: String(err) };
1767
1797
  }
1768
1798
 
1799
+ // A nested context's aggregate (an import initializing its library's
1800
+ // resources) already carries a classified diagnostic list. Keep it structured
1801
+ // instead of letting the cause-chain walk flatten it into this entry's
1802
+ // message: the child's root causes stay distinguishable from the child's own
1803
+ // cascade, and the error count sees the real leaves rather than one import.
1804
+ // The headline is re-derived from the diagnostics, never recovered by parsing
1805
+ // the message the child already rendered from them.
1806
+ if (
1807
+ err instanceof RuntimeError &&
1808
+ err.code === "ERR_RESOURCE_INITIALIZATION_FAILED" &&
1809
+ err.diagnostics?.length
1810
+ ) {
1811
+ return {
1812
+ message: summarizeInitFailures(err.diagnostics),
1813
+ code: err.code,
1814
+ children: err.diagnostics,
1815
+ };
1816
+ }
1817
+
1769
1818
  const detailLines: string[] = [];
1770
1819
  const seen = new Set<unknown>();
1771
1820
  let current: unknown = err;
package/src/index.ts CHANGED
@@ -47,6 +47,7 @@ export { ModuleContext } from "./module-context.js";
47
47
  export { ManifestRegistry as Registry } from "./registry.js";
48
48
  export { ResourceURI } from "./resource-uri.js";
49
49
  export type { RuntimeDiagnostic } from "@telorun/sdk";
50
+ export { describeBlockedGroup, groupBlockedResources } from "./init-failure-diagnostics.js";
50
51
 
51
52
  // Structured logging — the runtime half of kernel/specs/logging.md. The record
52
53
  // model, severity scale, and `Logger` surface live in `@telorun/sdk`; these are
@@ -0,0 +1,169 @@
1
+ import type { RuntimeDiagnostic } from "@telorun/sdk";
2
+
3
+ /**
4
+ * Codes that mean "this resource never got its turn": the multi-pass init loop
5
+ * deferred it because a dependency had not initialized, so it never produced a
6
+ * failure of its own. This is the ONLY signal that an entry may be collapsed —
7
+ * see {@link classifyInitFailures}.
8
+ */
9
+ const DEPENDENCY_PENDING_CODES = new Set([
10
+ "ERR_LOCAL_REF_PENDING",
11
+ "ERR_CROSS_MODULE_REF_PENDING",
12
+ ]);
13
+
14
+ /** One resource that did not reach the `Initialized` state, with the outbound
15
+ * edges (names of resources in the SAME context) captured for it. */
16
+ export interface FailedResource {
17
+ resource: string;
18
+ kind?: string;
19
+ message: string;
20
+ details?: string;
21
+ code?: string;
22
+ children?: RuntimeDiagnostic[];
23
+ deps: string[];
24
+ }
25
+
26
+ /**
27
+ * Split a failed-resource set into the ones that failed on their own (root
28
+ * causes) and the ones that only failed because something else in the set did.
29
+ *
30
+ * A dependency chain of any length produces one real error and N shadows of it,
31
+ * and the shadows outnumber the cause — reporting them flat buries the only
32
+ * line a reader can act on.
33
+ *
34
+ * **What makes an entry derived is its CODE, never its edges.** Only a
35
+ * {@link DEPENDENCY_PENDING_CODES} deferral says "this resource never ran, so
36
+ * it has nothing of its own to report". A reference edge into the failure set
37
+ * proves an edge exists, not that this entry's failure came from it: a resource
38
+ * can reference a failed dependency AND fail its own schema validation, and
39
+ * collapsing it there would swallow a real, independent error the author has to
40
+ * fix — the second half of a two-error session they would only discover on the
41
+ * next run. (Nor is the edge trustworthy on its own terms: `collectResourceRefs`
42
+ * walks `with:`-scoped inline declarations, whose names resolve scope-locally,
43
+ * so a scoped `!ref Db` can collide with a failed module-level `Db`.)
44
+ *
45
+ * Edges are used for ATTRIBUTION only — they name which failure a deferred
46
+ * entry is waiting on. `blockedBy` is the ROOT of the chain, not the immediate
47
+ * blocker, since that is the name a reader has to go fix; the walk stops at the
48
+ * first entry that is not itself derived. A deferral with no visible edge (a
49
+ * `${{ resources.X }}` read the ref walk cannot see) is still derived, just
50
+ * unattributed.
51
+ *
52
+ * Classification never hides everything: if no entry survives as a root (every
53
+ * failure is a deferral), the whole set is reported unclassified.
54
+ *
55
+ * Returns diagnostics ordered root causes first, then the derived entries.
56
+ */
57
+ export function classifyInitFailures(failures: FailedResource[]): RuntimeDiagnostic[] {
58
+ const failed = new Set(failures.map((f) => f.resource));
59
+
60
+ const derived = new Set(
61
+ failures.filter((f) => f.code && DEPENDENCY_PENDING_CODES.has(f.code)).map((f) => f.resource),
62
+ );
63
+ // Attribution edges are collected for EVERY entry, derived or not: a chain
64
+ // walk has to pass through an entry to reach the root beyond it.
65
+ const edgeBlocker = new Map<string, string>();
66
+ for (const f of failures) {
67
+ const dep = f.deps.find((n) => n !== f.resource && failed.has(n));
68
+ if (dep !== undefined) edgeBlocker.set(f.resource, dep);
69
+ }
70
+
71
+ const rootCauseOf = (name: string): string | undefined => {
72
+ const seen = new Set<string>([name]);
73
+ let current = edgeBlocker.get(name);
74
+ while (current !== undefined && !seen.has(current)) {
75
+ if (!derived.has(current)) return current;
76
+ seen.add(current);
77
+ current = edgeBlocker.get(current);
78
+ }
79
+ return undefined;
80
+ };
81
+
82
+ const toDiagnostic = (f: FailedResource, isDerived: boolean): RuntimeDiagnostic => {
83
+ const blockedBy = isDerived ? rootCauseOf(f.resource) : undefined;
84
+ return {
85
+ resource: f.resource,
86
+ kind: f.kind,
87
+ message: f.message,
88
+ details: f.details,
89
+ code: f.code,
90
+ ...(f.children?.length ? { children: f.children } : {}),
91
+ ...(isDerived ? { derived: true, ...(blockedBy ? { blockedBy } : {}) } : {}),
92
+ };
93
+ };
94
+
95
+ const roots = failures.filter((f) => !derived.has(f.resource));
96
+ if (roots.length === 0) return failures.map((f) => toDiagnostic(f, false));
97
+
98
+ return [
99
+ ...roots.map((f) => toDiagnostic(f, false)),
100
+ ...failures.filter((f) => derived.has(f.resource)).map((f) => toDiagnostic(f, true)),
101
+ ];
102
+ }
103
+
104
+ /** Group the derived entries by the root cause they hang off, so a renderer can
105
+ * collapse each chain to a single line instead of repeating one failure N
106
+ * times. Entries whose blocker could not be named group under `undefined`. */
107
+ export function groupBlockedResources(
108
+ diagnostics: RuntimeDiagnostic[],
109
+ ): Map<string | undefined, string[]> {
110
+ const groups = new Map<string | undefined, string[]>();
111
+ for (const d of diagnostics) {
112
+ if (!d.derived) continue;
113
+ const key = d.blockedBy;
114
+ const names = groups.get(key) ?? [];
115
+ names.push(d.resource ?? "(unnamed)");
116
+ groups.set(key, names);
117
+ }
118
+ return groups;
119
+ }
120
+
121
+ /** One collapsed line per blocked chain, e.g.
122
+ * `9 resources blocked by GrantDb: GrantStore, GoogleTokens, ...`. */
123
+ export function describeBlockedGroup(blockedBy: string | undefined, names: string[]): string {
124
+ const subject = `${names.length} resource${names.length !== 1 ? "s" : ""}`;
125
+ const blocker = blockedBy ?? "an uninitialized dependency";
126
+ return `${subject} blocked by ${blocker}: ${names.join(", ")}`;
127
+ }
128
+
129
+ /** The headline for a classified failure set. Shared by the aggregate error's
130
+ * own message and by the entry an importing context builds for it, so the two
131
+ * are never recovered by re-parsing each other's rendered text. */
132
+ export function summarizeInitFailures(diagnostics: RuntimeDiagnostic[]): string {
133
+ const total = diagnostics.length;
134
+ const roots = diagnostics.filter((d) => !d.derived).length;
135
+ const blocked = total - roots;
136
+ return (
137
+ `${total} resource${total !== 1 ? "s" : ""} failed to initialize` +
138
+ (blocked > 0 ? ` (${roots} root cause${roots !== 1 ? "s" : ""}, rest blocked)` : "")
139
+ );
140
+ }
141
+
142
+ /** Render a classified failure set as the text body of the aggregate error
143
+ * message — root causes in full, each blocked chain collapsed to one line.
144
+ * Nested children recurse through this same function so a child list is
145
+ * traversed exactly once, groups included. */
146
+ export function renderInitFailureText(diagnostics: RuntimeDiagnostic[]): string {
147
+ const lines: string[] = [];
148
+ for (const d of diagnostics) {
149
+ if (!d.derived) {
150
+ lines.push(
151
+ ` ${d.kind ? `${d.kind} ` : ""}${d.resource}: ${d.message}${d.code ? ` [${d.code}]` : ""}`,
152
+ );
153
+ if (d.details) lines.push(...d.details.split("\n").map((l) => ` ${l}`));
154
+ }
155
+ // A derived entry contributes no line of its own, but a nested context's
156
+ // root causes are not shadows of THIS context's failure — they still report.
157
+ if (d.children?.length) {
158
+ lines.push(
159
+ ...renderInitFailureText(d.children)
160
+ .split("\n")
161
+ .map((l) => ` ${l}`),
162
+ );
163
+ }
164
+ }
165
+ for (const [blockedBy, names] of groupBlockedResources(diagnostics)) {
166
+ lines.push(` ${describeBlockedGroup(blockedBy, names)}`);
167
+ }
168
+ return lines.join("\n");
169
+ }