@telorun/kernel 0.60.0 → 0.62.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 (85) hide show
  1. package/dist/bundle/module-artifact.d.ts +28 -2
  2. package/dist/bundle/module-artifact.d.ts.map +1 -1
  3. package/dist/bundle/module-artifact.js +36 -9
  4. package/dist/bundle/module-artifact.js.map +1 -1
  5. package/dist/controller-loader.d.ts +7 -4
  6. package/dist/controller-loader.d.ts.map +1 -1
  7. package/dist/controller-loader.js.map +1 -1
  8. package/dist/controller-loaders/bundle-loader.d.ts.map +1 -1
  9. package/dist/controller-loaders/bundle-loader.js +9 -4
  10. package/dist/controller-loaders/bundle-loader.js.map +1 -1
  11. package/dist/controller-loaders/npm-loader.d.ts.map +1 -1
  12. package/dist/controller-loaders/npm-loader.js +62 -10
  13. package/dist/controller-loaders/npm-loader.js.map +1 -1
  14. package/dist/controllers/resource-definition/resource-inherited-controller.d.ts.map +1 -1
  15. package/dist/controllers/resource-definition/resource-inherited-controller.js +57 -10
  16. package/dist/controllers/resource-definition/resource-inherited-controller.js.map +1 -1
  17. package/dist/controllers/type/json-schema-controller.d.ts +8 -0
  18. package/dist/controllers/type/json-schema-controller.d.ts.map +1 -0
  19. package/dist/controllers/type/json-schema-controller.js +91 -0
  20. package/dist/controllers/type/json-schema-controller.js.map +1 -0
  21. package/dist/evaluation-context.d.ts +5 -0
  22. package/dist/evaluation-context.d.ts.map +1 -1
  23. package/dist/evaluation-context.js +63 -33
  24. package/dist/evaluation-context.js.map +1 -1
  25. package/dist/index.d.ts +3 -2
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +1 -0
  28. package/dist/index.js.map +1 -1
  29. package/dist/init-failure-diagnostics.d.ts +61 -0
  30. package/dist/init-failure-diagnostics.d.ts.map +1 -0
  31. package/dist/init-failure-diagnostics.js +141 -0
  32. package/dist/init-failure-diagnostics.js.map +1 -0
  33. package/dist/invocation-contract-binding.d.ts +105 -0
  34. package/dist/invocation-contract-binding.d.ts.map +1 -0
  35. package/dist/invocation-contract-binding.js +296 -0
  36. package/dist/invocation-contract-binding.js.map +1 -0
  37. package/dist/kernel.d.ts +17 -0
  38. package/dist/kernel.d.ts.map +1 -1
  39. package/dist/kernel.js +76 -25
  40. package/dist/kernel.js.map +1 -1
  41. package/dist/module-context.d.ts.map +1 -1
  42. package/dist/module-context.js +21 -0
  43. package/dist/module-context.js.map +1 -1
  44. package/dist/resource-context.d.ts +45 -0
  45. package/dist/resource-context.d.ts.map +1 -1
  46. package/dist/resource-context.js +79 -0
  47. package/dist/resource-context.js.map +1 -1
  48. package/dist/schema-compiled-values.d.ts +9 -1
  49. package/dist/schema-compiled-values.d.ts.map +1 -1
  50. package/dist/schema-compiled-values.js +55 -16
  51. package/dist/schema-compiled-values.js.map +1 -1
  52. package/dist/schema-validator.d.ts.map +1 -1
  53. package/dist/schema-validator.js +15 -1
  54. package/dist/schema-validator.js.map +1 -1
  55. package/dist/static-analysis-diagnostics.d.ts +19 -0
  56. package/dist/static-analysis-diagnostics.d.ts.map +1 -0
  57. package/dist/static-analysis-diagnostics.js +52 -0
  58. package/dist/static-analysis-diagnostics.js.map +1 -0
  59. package/dist/transports/oci/oci-transport.d.ts +17 -0
  60. package/dist/transports/oci/oci-transport.d.ts.map +1 -1
  61. package/dist/transports/oci/oci-transport.js +33 -8
  62. package/dist/transports/oci/oci-transport.js.map +1 -1
  63. package/dist/transports/transport-registry.d.ts +6 -4
  64. package/dist/transports/transport-registry.d.ts.map +1 -1
  65. package/dist/transports/transport-registry.js +6 -4
  66. package/dist/transports/transport-registry.js.map +1 -1
  67. package/package.json +4 -4
  68. package/src/bundle/module-artifact.ts +54 -12
  69. package/src/controller-loader.ts +7 -4
  70. package/src/controller-loaders/bundle-loader.ts +9 -4
  71. package/src/controller-loaders/npm-loader.ts +75 -13
  72. package/src/controllers/resource-definition/resource-inherited-controller.ts +69 -10
  73. package/src/controllers/type/json-schema-controller.ts +114 -0
  74. package/src/evaluation-context.ts +81 -32
  75. package/src/index.ts +3 -1
  76. package/src/init-failure-diagnostics.ts +169 -0
  77. package/src/invocation-contract-binding.ts +392 -0
  78. package/src/kernel.ts +95 -23
  79. package/src/module-context.ts +27 -0
  80. package/src/resource-context.ts +81 -0
  81. package/src/schema-compiled-values.ts +55 -15
  82. package/src/schema-validator.ts +15 -1
  83. package/src/static-analysis-diagnostics.ts +51 -0
  84. package/src/transports/oci/oci-transport.ts +34 -8
  85. package/src/transports/transport-registry.ts +6 -4
@@ -174,6 +174,87 @@ export class ResourceContextImpl implements ResourceContext {
174
174
  return this.validator.getTypeRules(name);
175
175
  }
176
176
 
177
+ /** The JSON Schema behind a type field, resolved the same four ways
178
+ * {@link createTypeValidator} resolves it (named ref, `{kind, name}` ref
179
+ * object, inline `{kind, schema}`, raw schema), and then followed through a
180
+ * bare `telo://<module>/<Type>` `$ref` to the schema that type registered.
181
+ *
182
+ * Following the `$ref` matters because AJV resolves cross-schema references
183
+ * against its own registry at compile time, while the kernel's registry is
184
+ * what actually holds these — a `Type.JsonSchema` registers under the
185
+ * canonical URI as a *key*, which AJV does not treat as a resolvable id. A
186
+ * definition whose whole contract is `{ $ref: "telo://Self/TokenSet" }` (the
187
+ * sanctioned way to declare a shape once and reference it from several kinds)
188
+ * would otherwise be uncompilable at dispatch. Resolving here means AJV is
189
+ * handed the real schema and never has to resolve the reference at all.
190
+ *
191
+ * Contract binding needs the schema rather than just a compiled validator
192
+ * anyway, for the decisions a validator cannot answer: which properties carry
193
+ * `x-telo-stream` and must be exempt from the walk, and which paths a
194
+ * `default:` can be written to. Returns undefined when the reference resolves
195
+ * to nothing. */
196
+ resolveTypeSchema(typeRef: unknown): Record<string, any> | undefined {
197
+ return this.followTypeAlias(this.readTypeSchema(typeRef), new Set());
198
+ }
199
+
200
+ private readTypeSchema(typeRef: unknown): Record<string, any> | undefined {
201
+ if (!typeRef) return undefined;
202
+ if (typeof typeRef === "string") return this.validator.getSchema(typeRef) as any;
203
+ if (typeof typeRef !== "object") return undefined;
204
+ const ref = typeRef as Record<string, any>;
205
+ if (ref.schema && typeof ref.schema === "object") return ref.schema;
206
+ if (typeof ref.name === "string") return this.validator.getSchema(ref.name) as any;
207
+ if (ref.type || ref.properties || ref.$ref) return ref;
208
+ return undefined;
209
+ }
210
+
211
+ /**
212
+ * Follow a schema that is nothing but a `$ref` to a registered type, so the
213
+ * schema-level questions (which properties are streams, which paths carry a
214
+ * default) are asked of the real shape rather than of an alias.
215
+ *
216
+ * Only the whole-document alias form is followed, and only to READ it — the
217
+ * schema handed to AJV keeps its `$ref`s intact, because AJV resolves them
218
+ * itself against the registered ids and each type stays its own document with
219
+ * its own `$defs`. Inlining instead would move a `$ref: "#/$defs/X"` out of the
220
+ * document that defines `$defs.X`.
221
+ *
222
+ * `seen` guards a cycle two mutually-referencing types would otherwise spin on.
223
+ * A `$ref` alongside other keywords is left alone: that is a composition, not
224
+ * an alias.
225
+ */
226
+ private followTypeAlias(
227
+ schema: Record<string, any> | undefined,
228
+ seen: Set<string>,
229
+ ): Record<string, any> | undefined {
230
+ let current = schema;
231
+ while (
232
+ current &&
233
+ typeof current.$ref === "string" &&
234
+ Object.keys(current).length === 1 &&
235
+ !seen.has(current.$ref)
236
+ ) {
237
+ seen.add(current.$ref);
238
+ const target = this.validator.getSchema(current.$ref) as Record<string, any> | undefined;
239
+ if (!target) return current;
240
+ current = target;
241
+ }
242
+ return current;
243
+ }
244
+
245
+ /** Compile `schema` but compose the CEL `rules:` registered under `name`.
246
+ *
247
+ * A named type's rules are its business invariants, and they are reachable
248
+ * only through the name. {@link createTypeValidator} composes them when it is
249
+ * handed a bare name, but a caller that must adjust the schema first — the
250
+ * contract binding, which strips `x-telo-stream` properties before validating
251
+ * — would otherwise have to choose between the adjustment and the rules. */
252
+ createTypeValidatorWithRules(name: string | undefined, schema: Record<string, any>) {
253
+ const base = this.validator.compile(schema);
254
+ const rules = name ? this.validator.getTypeRules(name) : undefined;
255
+ return rules && rules.length > 0 ? this.validator.composeWithRules(base, name!, rules) : base;
256
+ }
257
+
177
258
  createTypeValidator(typeRef: string | Record<string, any> | undefined) {
178
259
  if (!typeRef) return new NoopValidator();
179
260
 
@@ -65,27 +65,67 @@ function collectSchemaProperties(
65
65
  * Template strings were compiled from YAML at load time; this restores a shape
66
66
  * that AJV can validate without evaluating expressions. When no schema is
67
67
  * supplied every compiled value collapses to `""` (the `default` branch of
68
- * `placeholderForSchema`), matching the schema-unaware strip. */
68
+ * `placeholderForSchema`), matching the schema-unaware strip.
69
+ *
70
+ * The walk stops short of anything that is not plain config, mirroring
71
+ * `buildResolvedProperties`: by the time a resource reaches validation its ref
72
+ * slots may already hold LIVE resource instances (a template passing the
73
+ * caller's client down with `client: !cel "self.client"` hands the child the
74
+ * injected instance, not a ref), and a controller's object graph is
75
+ * arbitrarily deep and routinely cyclic — walking one overflows the stack
76
+ * instead of producing a diagnostic, and there is nothing inside it to strip. */
69
77
  export function stripCompiledValues(
70
78
  v: unknown,
71
79
  schema: Record<string, unknown> = {},
72
80
  rootSchema?: Record<string, unknown>,
73
81
  ): unknown {
74
82
  const root = rootSchema ?? schema;
75
- const resolved = resolveSchemaRef(schema, root);
83
+ // Ancestors on the current path, so a genuine cycle stops while a sub-object
84
+ // that merely appears twice is still stripped both times.
85
+ const ancestors = new Set<object>();
76
86
 
77
- if (isCompiledValue(v)) return placeholderForSchema(resolved);
78
- if (Array.isArray(v)) {
79
- const itemSchema = resolveSchemaRef((resolved.items ?? {}) as Record<string, unknown>, root);
80
- return v.map((item) => stripCompiledValues(item, itemSchema, root));
81
- }
82
- if (v !== null && typeof v === "object") {
83
- const props = collectSchemaProperties(resolved);
84
- const out: Record<string, unknown> = {};
85
- for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
86
- out[k] = stripCompiledValues(val, props[k] ?? {}, root);
87
+ const walk = (value: unknown, nodeSchema: Record<string, unknown>): unknown => {
88
+ const resolved = resolveSchemaRef(nodeSchema, root);
89
+
90
+ if (isCompiledValue(value)) return placeholderForSchema(resolved);
91
+ // A slot the schema declares as a reference is never config: it holds a
92
+ // `{kind, name}` ref or the live instance Phase 5 replaced it with, and the
93
+ // schema declares no shape to validate against either way.
94
+ if (resolved["x-telo-ref"] !== undefined) return value;
95
+
96
+ if (Array.isArray(value)) {
97
+ const itemSchema = resolveSchemaRef((resolved.items ?? {}) as Record<string, unknown>, root);
98
+ return walkGuarded(value, () => value.map((item) => walk(item, itemSchema)));
87
99
  }
88
- return out;
89
- }
90
- return v;
100
+ if (value !== null && typeof value === "object") {
101
+ // A class instance (a client, a pool, a stream) carries no CompiledValues
102
+ // and is not described by the schema — copying it is pure risk.
103
+ const proto = Object.getPrototypeOf(value);
104
+ if (proto !== Object.prototype && proto !== null) return value;
105
+
106
+ const props = collectSchemaProperties(resolved);
107
+ return walkGuarded(value, () => {
108
+ const out: Record<string, unknown> = {};
109
+ for (const [k, val] of Object.entries(value as Record<string, unknown>)) {
110
+ out[k] = walk(val, props[k] ?? {});
111
+ }
112
+ return out;
113
+ });
114
+ }
115
+ return value;
116
+ };
117
+
118
+ /** Runs `fn` with `node` marked as an ancestor; a node already on the path is
119
+ * a cycle and is returned as-is rather than recursed into. */
120
+ const walkGuarded = (node: object, fn: () => unknown): unknown => {
121
+ if (ancestors.has(node)) return node;
122
+ ancestors.add(node);
123
+ try {
124
+ return fn();
125
+ } finally {
126
+ ancestors.delete(node);
127
+ }
128
+ };
129
+
130
+ return walk(v, schema);
91
131
  }
@@ -8,6 +8,20 @@ import * as fs from "node:fs";
8
8
  import { createRequire } from "node:module";
9
9
  import * as path from "node:path";
10
10
  import { formatAjvErrors } from "./manifest-schemas.js";
11
+
12
+ /** Render a value for an error message without ever throwing.
13
+ *
14
+ * `JSON.stringify` refuses BigInt, and CEL evaluates an integer literal to one —
15
+ * so serializing the offending data threw from inside the message template and
16
+ * the thrown stringify error REPLACED the validation failure. The author was
17
+ * told "cannot serialize BigInt" instead of which field was wrong. */
18
+ function describeValue(data: unknown): string {
19
+ try {
20
+ return JSON.stringify(data, (_k, v) => (typeof v === "bigint" ? `${v}` : v)) ?? String(data);
21
+ } catch {
22
+ return String(data);
23
+ }
24
+ }
11
25
  import {
12
26
  EXACT_TEMPLATE_REGEX,
13
27
  isTaggedSentinel,
@@ -297,7 +311,7 @@ export class SchemaValidator {
297
311
  if (!isValid) {
298
312
  throw new RuntimeError(
299
313
  "ERR_RESOURCE_SCHEMA_VALIDATION_FAILED",
300
- `Invalid value passed: ${JSON.stringify(data)}. Error: ${formatAjvErrors(validate.errors)}`,
314
+ `Invalid value passed: ${describeValue(data)}. Error: ${formatAjvErrors(validate.errors)}`,
301
315
  );
302
316
  }
303
317
  },
@@ -0,0 +1,51 @@
1
+ import { DiagnosticSeverity, type AnalysisDiagnostic } from "@telorun/analyzer";
2
+ import type { DiagnosticOrigin, RuntimeDiagnostic } from "@telorun/sdk";
3
+
4
+ /**
5
+ * Carry a static-analysis diagnostic into the runtime failure set.
6
+ *
7
+ * The analyzer's `data` — the file, the field path within it, and the owning
8
+ * resource — travels as `origin`, alongside the diagnostic's own `range` for
9
+ * the failures that have no field path to look up (a YAML parse error knows
10
+ * where the syntax broke but has no parsed tree to index). That is what lets a
11
+ * renderer resolve the same `file:line:col` `telo check` prints; flattening it
12
+ * into `message` leaves `telo run` pointing at nothing, which is the whole
13
+ * reason the two commands used to disagree about one error.
14
+ *
15
+ * The sibling of `init-failure-diagnostics.ts`: both turn a kernel failure set
16
+ * into `RuntimeDiagnostic[]`, one for what static analysis rejected and one for
17
+ * what failed to initialize.
18
+ */
19
+ export function staticDiagnosticToRuntime(d: AnalysisDiagnostic): RuntimeDiagnostic {
20
+ const data = d.data as DiagnosticOrigin | undefined;
21
+ const origin: DiagnosticOrigin = {};
22
+ if (data?.filePath !== undefined) origin.filePath = data.filePath;
23
+ if (data?.path !== undefined) origin.path = data.path;
24
+ if (data?.resource !== undefined) origin.resource = data.resource;
25
+ if (d.range !== undefined) origin.range = d.range;
26
+ return {
27
+ // Mapped, not assumed: every current caller passes a pre-filtered error set,
28
+ // but a warning routed through here must not be rendered in red and counted
29
+ // toward the exit code. `AnalysisDiagnostic.severity` is optional and its
30
+ // scale runs Error(1) → Hint(4), so anything looser than Error is a warning.
31
+ severity: (d.severity ?? DiagnosticSeverity.Warning) <= DiagnosticSeverity.Error
32
+ ? "error"
33
+ : "warning",
34
+ message: d.message,
35
+ code: d.code !== undefined ? String(d.code) : undefined,
36
+ resource: describeResource(data?.resource),
37
+ // Only when something is actually set, so `origin` stays usable as the
38
+ // "this came from static analysis" predicate its contract promises.
39
+ ...(Object.keys(origin).length > 0 ? { origin } : {}),
40
+ };
41
+ }
42
+
43
+ /** `Kind.name`, or whichever half is present — a diagnostic carrying only one
44
+ * of them used to render as `undefined.foo`. */
45
+ function describeResource(
46
+ resource: { kind?: string; name?: string } | undefined,
47
+ ): string | undefined {
48
+ if (!resource) return undefined;
49
+ const parts = [resource.kind, resource.name].filter((p): p is string => p !== undefined);
50
+ return parts.length > 0 ? parts.join(".") : undefined;
51
+ }
@@ -62,9 +62,8 @@ import {
62
62
  * payload gets a clear "republish" failure at the controller instead, while the
63
63
  * npm-backed majority, which ships none, is unaffected.
64
64
  */
65
- async function pullManifestLayer(ref: string): Promise<string> {
66
- const { host, repo, reference, integrity } = parseOciRef(ref);
67
- const client = new OciClient(host, repo);
65
+ async function pullManifestLayer(ref: string, client: OciClient): Promise<string> {
66
+ const { reference, integrity } = parseOciRef(ref);
68
67
  const manifest = await client.pullManifest(reference);
69
68
  const layer =
70
69
  manifest.layers.find((l) => l.mediaType === TELO_MANIFEST_LAYER_MEDIA_TYPE) ??
@@ -108,18 +107,44 @@ async function pullManifestLayer(ref: string): Promise<string> {
108
107
  export class OciTransport implements Transport {
109
108
  readonly source: ManifestSource;
110
109
 
110
+ /** One read-side `OciClient` per `(host, repo)`, for this transport's lifetime.
111
+ *
112
+ * The client caches bearer tokens per scope, but a client built per operation
113
+ * discards that cache immediately — so every manifest and every blob paid its
114
+ * own 401→challenge→token round trip, and with it a `~/.docker/config.json`
115
+ * read and possibly a credential-helper subprocess. Pooling collapses those
116
+ * to one handshake per repository. An expired token still self-heals:
117
+ * `authedFetch` re-runs the challenge on a 401 and replaces the entry.
118
+ *
119
+ * Owned by the instance rather than the module, so a second transport — a
120
+ * test, or a second in-process kernel — never inherits another's credentials.
121
+ * `defaultTransportRegistry` memoizes per registry URL, so the production
122
+ * lifetime is unchanged. Publishing keeps its own client: it already reuses
123
+ * one across the whole push, and a push-scoped token has no reason to
124
+ * outlive the command. */
125
+ private readonly readClients = new Map<string, OciClient>();
126
+
111
127
  constructor() {
112
128
  this.source = {
113
129
  supports: (url) => this.supports(url),
114
130
  read: async (url) => {
115
- const manifest = await pullManifestLayer(url);
116
131
  const { host, repo, reference } = parseOciRef(url);
132
+ const manifest = await pullManifestLayer(url, this.readClient(host, repo));
117
133
  return { text: manifest, source: `${OCI_SCHEME}${host}/${repo}@${reference}` };
118
134
  },
119
135
  resolveRelative: (base, relative) => this.resolveRelative(base, relative),
120
136
  };
121
137
  }
122
138
 
139
+ private readClient(host: string, repo: string): OciClient {
140
+ const key = `${host}/${repo}`;
141
+ const existing = this.readClients.get(key);
142
+ if (existing) return existing;
143
+ const client = new OciClient(host, repo);
144
+ this.readClients.set(key, client);
145
+ return client;
146
+ }
147
+
123
148
  supports(ref: string): boolean {
124
149
  return isOciRef(ref);
125
150
  }
@@ -159,7 +184,7 @@ export class OciTransport implements Transport {
159
184
 
160
185
  async listVersions(ref: string): Promise<string[] | null> {
161
186
  const { host, repo } = parseOciRef(ref);
162
- const tags = await new OciClient(host, repo).listTags();
187
+ const tags = await this.readClient(host, repo).listTags();
163
188
  return tags;
164
189
  }
165
190
 
@@ -178,7 +203,7 @@ export class OciTransport implements Transport {
178
203
 
179
204
  async digest(ref: string): Promise<string | null> {
180
205
  const { host, repo, reference } = parseOciRef(ref);
181
- return new OciClient(host, repo).headManifest(reference);
206
+ return this.readClient(host, repo).headManifest(reference);
182
207
  }
183
208
 
184
209
  /** Pull one payload layer by the `blob` digest the pinned index supplies. The
@@ -188,7 +213,7 @@ export class OciTransport implements Transport {
188
213
  * expected `integrity`. */
189
214
  async fetchLayer(ref: string, blobDigest: string): Promise<PayloadFile[]> {
190
215
  const { host, repo } = parseOciRef(ref);
191
- const tar = await new OciClient(host, repo).pullBlob(blobDigest);
216
+ const tar = await this.readClient(host, repo).pullBlob(blobDigest);
192
217
  // Verify the transfer against the digest that addressed it. A registry is
193
218
  // not trusted to return the blob that was asked for, and this is the only
194
219
  // place the pushed bytes exist — the content digest checked after extraction
@@ -212,7 +237,8 @@ export class OciTransport implements Transport {
212
237
  * blob per import rather than a full artifact pull, and a corrupt payload
213
238
  * upstream no longer surfaces here as a pinning failure. */
214
239
  async manifestHash(ref: string): Promise<string> {
215
- const manifest = await pullManifestLayer(ref);
240
+ const { host, repo } = parseOciRef(ref);
241
+ const manifest = await pullManifestLayer(ref, this.readClient(host, repo));
216
242
  return `sha256-${await sha256Base64Url(new TextEncoder().encode(manifest))}`;
217
243
  }
218
244
 
@@ -82,10 +82,12 @@ export function defaultTransports(registryUrl?: string): Transport[] {
82
82
  const defaultRegistryCache = new Map<string, TransportRegistry>();
83
83
 
84
84
  /** A `TransportRegistry` seeded with {@link defaultTransports}, memoized per
85
- * `registryUrl`. The default transports are stateless config (a fresh
86
- * `OciClient` with its own token cache is created per OCI operation), so one
87
- * shared instance per registry URL is safe and avoids re-instantiating the
88
- * whole set on hot paths like `cachePathForCanonical`. */
85
+ * `registryUrl`. The default transports hold no per-call state, so one shared
86
+ * instance per registry URL is safe and avoids re-instantiating the whole set
87
+ * on hot paths like `cachePathForCanonical`. It is also what gives
88
+ * `OciTransport`'s per-instance read-client pool a process-wide lifetime here,
89
+ * so the bearer-token cache survives across operations without the pool having
90
+ * to be global. */
89
91
  export function defaultTransportRegistry(registryUrl?: string): TransportRegistry {
90
92
  const key = registryUrl ?? "";
91
93
  let cached = defaultRegistryCache.get(key);