@telorun/kernel 0.68.0 → 0.70.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 (54) hide show
  1. package/dist/bigint-json.d.ts +45 -0
  2. package/dist/bigint-json.d.ts.map +1 -0
  3. package/dist/bigint-json.js +65 -0
  4. package/dist/bigint-json.js.map +1 -0
  5. package/dist/bigint-schema-view.d.ts +34 -0
  6. package/dist/bigint-schema-view.d.ts.map +1 -0
  7. package/dist/bigint-schema-view.js +85 -0
  8. package/dist/bigint-schema-view.js.map +1 -0
  9. package/dist/cel-handlers.d.ts.map +1 -1
  10. package/dist/cel-handlers.js +6 -8
  11. package/dist/cel-handlers.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/invocation-contract-binding.d.ts +0 -6
  17. package/dist/invocation-contract-binding.d.ts.map +1 -1
  18. package/dist/invocation-contract-binding.js +9 -64
  19. package/dist/invocation-contract-binding.js.map +1 -1
  20. package/dist/kernel.d.ts.map +1 -1
  21. package/dist/kernel.js +10 -3
  22. package/dist/kernel.js.map +1 -1
  23. package/dist/logging/encode-json.d.ts.map +1 -1
  24. package/dist/logging/encode-json.js +13 -9
  25. package/dist/logging/encode-json.js.map +1 -1
  26. package/dist/logging/encode-pretty.d.ts.map +1 -1
  27. package/dist/logging/encode-pretty.js +13 -4
  28. package/dist/logging/encode-pretty.js.map +1 -1
  29. package/dist/manifest-schemas.d.ts.map +1 -1
  30. package/dist/manifest-schemas.js +2 -1
  31. package/dist/manifest-schemas.js.map +1 -1
  32. package/dist/observed-state.d.ts.map +1 -1
  33. package/dist/observed-state.js +2 -0
  34. package/dist/observed-state.js.map +1 -1
  35. package/dist/resource-context.d.ts +10 -1
  36. package/dist/resource-context.d.ts.map +1 -1
  37. package/dist/resource-context.js +42 -11
  38. package/dist/resource-context.js.map +1 -1
  39. package/dist/schema-validator.d.ts.map +1 -1
  40. package/dist/schema-validator.js +42 -16
  41. package/dist/schema-validator.js.map +1 -1
  42. package/package.json +3 -3
  43. package/src/bigint-json.ts +69 -0
  44. package/src/bigint-schema-view.ts +76 -0
  45. package/src/cel-handlers.ts +6 -9
  46. package/src/index.ts +1 -0
  47. package/src/invocation-contract-binding.ts +9 -55
  48. package/src/kernel.ts +11 -0
  49. package/src/logging/encode-json.ts +14 -9
  50. package/src/logging/encode-pretty.ts +12 -3
  51. package/src/manifest-schemas.ts +2 -1
  52. package/src/observed-state.ts +2 -0
  53. package/src/resource-context.ts +46 -11
  54. package/src/schema-validator.ts +43 -16
@@ -1,4 +1,4 @@
1
- import { formatUnixNano, type AnyValue, type ErrorValue, type LogRecord } from "@telorun/sdk";
1
+ import { bigIntAt, formatUnixNano, type AnyValue, type ErrorValue, type LogRecord } from "@telorun/sdk";
2
2
 
3
3
  /**
4
4
  * The `json` encoding — `kernel/specs/logging.md` §11.1. One JSON object per
@@ -83,16 +83,21 @@ function sortKeysDeep(value: AnyValue): AnyValue {
83
83
  }
84
84
 
85
85
  function makeReplacer(encodeBytes: BytesEncoder) {
86
- return function replacer(this: unknown, _key: string, value: unknown): unknown {
87
- if (typeof value === "bigint") {
88
- // Values beyond 2^53 lose precision in a JS receiver, so they degrade to a
89
- // decimal string rather than to a wrong number — the same reasoning OTLP
90
- // gives for quoting its 64-bit fields.
91
- return value >= BigInt(Number.MIN_SAFE_INTEGER) && value <= BigInt(Number.MAX_SAFE_INTEGER)
92
- ? Number(value)
93
- : value.toString();
86
+ return function replacer(this: unknown, key: string, value: unknown): unknown {
87
+ // Read the BigInt off the HOLDER, not off `value`: `BigInt.prototype.toJSON`
88
+ // has already rewritten it to the exact-digits form every other JSON boundary
89
+ // wants (see `enableBigIntJson`). A log record is the one destination where
90
+ // that is wrong a value beyond 2^53 loses precision in a JS receiver, so it
91
+ // degrades to a decimal string rather than to a wrong number, the same
92
+ // reasoning OTLP gives for quoting its 64-bit fields.
93
+ const source = bigIntAt(this, key);
94
+ if (source !== undefined) {
95
+ return source >= MIN_SAFE && source <= MAX_SAFE ? Number(source) : source.toString();
94
96
  }
95
97
  if (value instanceof Uint8Array) return encodeBytes(value);
96
98
  return value;
97
99
  };
98
100
  }
101
+
102
+ const MIN_SAFE = BigInt(Number.MIN_SAFE_INTEGER);
103
+ const MAX_SAFE = BigInt(Number.MAX_SAFE_INTEGER);
@@ -1,4 +1,4 @@
1
- import { severityFloor, type AnyValue, type ErrorValue, type LogRecord } from "@telorun/sdk";
1
+ import { bigIntAt, severityFloor, type AnyValue, type ErrorValue, type LogRecord } from "@telorun/sdk";
2
2
 
3
3
  /**
4
4
  * The `pretty` encoding — `kernel/specs/logging.md` §11.2. For humans on a
@@ -90,8 +90,17 @@ function quoteIfNeeded(text: string): string {
90
90
  return /[\s"=]/.test(text) ? JSON.stringify(text) : text;
91
91
  }
92
92
 
93
- function jsonSafe(_key: string, value: unknown): unknown {
94
- if (typeof value === "bigint") return value.toString();
93
+ function jsonSafe(this: unknown, key: string, value: unknown): unknown {
94
+ // Read the integer off the HOLDER, not off `value`: `BigInt.prototype.toJSON`
95
+ // (see `enableBigIntJson`) runs BEFORE a replacer, so by the time this is
96
+ // called a wide integer is already the exact-digits form every wire encoding
97
+ // wants. This console encoding renders it as a decimal string instead, and
98
+ // matching on `typeof value === "bigint"` would silently stop doing so —
99
+ // a changed encoding, not an error. Reading the holder keeps the rendering
100
+ // identical whether or not the patch is installed, which matters because a
101
+ // test can call this encoder outside a booted kernel.
102
+ const source = bigIntAt(this, key);
103
+ if (source !== undefined) return source.toString();
95
104
  if (value instanceof Uint8Array) return `<${value.byteLength} bytes>`;
96
105
  return value;
97
106
  }
@@ -2,7 +2,7 @@ import AjvModule from "ajv";
2
2
  import addFormats from "ajv-formats";
3
3
  // One definition of the `status:` block's shape, shared with `telo check` — the
4
4
  // `required:` restriction is reported by the analyzer, which can name the fix.
5
- import { OBSERVED_STATE_SCHEMA } from "@telorun/analyzer";
5
+ import { binaryKeyword, OBSERVED_STATE_SCHEMA } from "@telorun/analyzer";
6
6
  const Ajv = AjvModule.default ?? AjvModule;
7
7
 
8
8
  // Re-export the shared ResourceRef fragment from the templating package
@@ -177,6 +177,7 @@ export const ResourceAbstractSchema = {
177
177
  };
178
178
 
179
179
  const ajv = new Ajv({ allErrors: true, strict: false });
180
+ ajv.addKeyword(binaryKeyword());
180
181
  addFormats.default(ajv);
181
182
 
182
183
  // Lazy-compile validator: the AJV codegen cost (≈10–15 ms for these
@@ -1,3 +1,4 @@
1
+ import { binaryKeyword } from "@telorun/analyzer";
1
2
  import AjvModule from "ajv";
2
3
  import { detachSnapshotValue, OBSERVED_STATE_KEY, RuntimeError } from "@telorun/sdk";
3
4
 
@@ -57,6 +58,7 @@ function mark(target: Record<string, unknown>, info: ObservedStateInfo): void {
57
58
  }
58
59
 
59
60
  const ajv = new Ajv({ allErrors: true, strict: false });
61
+ ajv.addKeyword(binaryKeyword());
60
62
  // Compiling a status schema costs ~ms and a resource may report repeatedly, so
61
63
  // keep the validator keyed on the schema object it came from. The kind's folded
62
64
  // `status:` is stamped once at registration, so this hits.
@@ -28,10 +28,12 @@ import {
28
28
  type TypeRule,
29
29
  type ZoneEntry,
30
30
  } from "@telorun/sdk";
31
+ import { binaryKeyword } from "@telorun/analyzer";
31
32
  import { isRefSentinel } from "@telorun/templating";
32
33
  import { ZoneContext } from "./zone-context.js";
33
34
  import * as path from "path";
34
35
  import { pathToFileURL } from "url";
36
+ import { withBigIntsAsNumbers } from "./bigint-schema-view.js";
35
37
  import type { ModuleArtifact } from "./bundle/module-artifact.js";
36
38
  import { hostEnv } from "./host-env.js";
37
39
  import type { LoggingHost } from "./logging/logging-host.js";
@@ -73,6 +75,12 @@ export class ResourceContextImpl implements ResourceContext {
73
75
  * eagerly for every context would allocate per resource for nothing. */
74
76
  #log: Logger | undefined;
75
77
 
78
+ /** The resolved kind. It cannot come from `metadata`, which is the resource's
79
+ * metadata BLOCK — `kind` is its sibling, not its member, so reading
80
+ * `metadata.kind` yielded `undefined` and every controller record went out
81
+ * with no resource identity at all (§7.3). */
82
+ #resolvedKind: string | undefined;
83
+
76
84
  /**
77
85
  * The resource's structured logger, stamped with its identity, module, and
78
86
  * import-alias scope so a record identifies *which instance* emitted it — the
@@ -81,8 +89,8 @@ export class ResourceContextImpl implements ResourceContext {
81
89
  */
82
90
  get log(): Logger {
83
91
  if (!this.#log) {
84
- const kind = this.metadata?.kind as string | undefined;
85
- const name = this.metadata?.metadata?.name as string | undefined;
92
+ const kind = this.#resolvedKind;
93
+ const name = this.metadata?.name as string | undefined;
86
94
  const resource =
87
95
  kind && name
88
96
  ? { kind, name, id: `${this.ownerPrefix}${kind}.${name}` }
@@ -149,7 +157,17 @@ export class ResourceContextImpl implements ResourceContext {
149
157
  * dispatch fails.
150
158
  */
151
159
  private readonly owningContext: IEvaluationContext = moduleContext,
160
+ /**
161
+ * The resolved kind, known long before `create()` runs. Passed here rather
162
+ * than waited for at `bindResourceIdentity` so `ctx.log` carries resource
163
+ * identity from the moment the context exists: a controller that captures
164
+ * `ctx.log` in its constructor — the natural thing to do when the logger is
165
+ * handed to a helper — would otherwise hold an identity-less logger for the
166
+ * resource's whole life, and nothing would report that it had.
167
+ */
168
+ resolvedKind?: string,
152
169
  ) {
170
+ this.#resolvedKind = resolvedKind;
153
171
  // `ctx.env` is the sanctioned host-env channel for controllers — always the
154
172
  // real environment (kernel passes its snapshot), never the locked Proxy.
155
173
  this.env = env ?? hostEnv();
@@ -301,6 +319,7 @@ export class ResourceContextImpl implements ResourceContext {
301
319
  for (const kw of ["x-telo-ref", "x-telo-scope", "x-telo-context", "x-telo-schema-from"]) {
302
320
  ajv.addKeyword(kw);
303
321
  }
322
+ ajv.addKeyword(binaryKeyword());
304
323
  const validate = ajv.compile(
305
324
  "type" in schema && typeof schema.type === "string"
306
325
  ? schema
@@ -311,7 +330,11 @@ export class ResourceContextImpl implements ResourceContext {
311
330
  additionalProperties: false,
312
331
  },
313
332
  );
314
- const isValid = validate(stripCompiledValues(value));
333
+ // A BigInt-normalized view: AJV reads `integer` as `typeof == "number"`, so a
334
+ // CEL integer (int64) would be rejected at a slot it satisfies. This validator
335
+ // runs without `useDefaults` and already checks a derived value, so there is
336
+ // nothing to merge back. See `bigint-schema-view.ts`.
337
+ const isValid = validate(withBigIntsAsNumbers(stripCompiledValues(value)));
315
338
  if (!isValid) {
316
339
  throw new RuntimeError(
317
340
  "ERR_INVALID_VALUE",
@@ -341,6 +364,10 @@ export class ResourceContextImpl implements ResourceContext {
341
364
  manifest: Record<string, unknown>,
342
365
  ): void {
343
366
  this.#self = handle;
367
+ // The kind is NOT restated here. It is set at construction — the single
368
+ // production site always has it — and `#log` is memoized on first access, so
369
+ // a late assignment could not reach a logger a controller already holds. A
370
+ // fallback here would read as a guarantee it cannot provide.
344
371
  this.#zones = new ZoneContext({
345
372
  resourceName: (this.metadata?.name as string) ?? "<unnamed>",
346
373
  resolvedKind,
@@ -418,10 +445,16 @@ export class ResourceContextImpl implements ResourceContext {
418
445
  // settlement is already observed here.
419
446
  const tracked = this.owningContext
420
447
  .runDetached(fn) // bare scope-detach primitive
421
- .catch(async (err: unknown) => {
422
- const detail =
423
- err instanceof Error ? { name: err.name, message: err.message } : { message: String(err) };
424
- await this.emitEvent("background.task.error", { resource: this.metadata.name, error: detail });
448
+ .catch((err: unknown) => {
449
+ // A detached task has no caller to throw to, so this record is the only
450
+ // report. It replaces the bus event rather than joining it: `eventName`
451
+ // IS the bridge to the event bus (§4), and a record already reaches every
452
+ // sink including the debug wire, so emitting both would ship two copies
453
+ // of one fact with two payload shapes to keep in step.
454
+ this.log.error("Detached task failed", undefined, {
455
+ error: err,
456
+ eventName: "background.task.error",
457
+ });
425
458
  })
426
459
  .finally(() => {
427
460
  this.pendingDetached.delete(tracked);
@@ -446,10 +479,12 @@ export class ResourceContextImpl implements ResourceContext {
446
479
  await Promise.race([Promise.allSettled([...this.pendingDetached]), timeout]);
447
480
  if (timer) clearTimeout(timer);
448
481
  if (this.pendingDetached.size > 0) {
449
- await this.emitEvent("background.task.abandoned", {
450
- resource: this.metadata.name,
451
- count: this.pendingDetached.size,
452
- });
482
+ this.log.warn(
483
+ `Abandoned ${this.pendingDetached.size} background task(s) after waiting ` +
484
+ `${DETACHED_DRAIN_TIMEOUT_MS}ms for them to drain`,
485
+ { "telo.detached.abandoned": this.pendingDetached.size },
486
+ { eventName: "background.task.abandoned" },
487
+ );
453
488
  }
454
489
  }
455
490
 
@@ -7,17 +7,16 @@ import { createHash } from "node:crypto";
7
7
  import * as fs from "node:fs";
8
8
  import { createRequire } from "node:module";
9
9
  import * as path from "node:path";
10
+ import { binaryKeyword, X_TELO_BINARY } from "@telorun/analyzer";
11
+ import { mergeFilledDefaults, withBigIntsAsNumbers } from "./bigint-schema-view.js";
10
12
  import { formatAjvErrors } from "./manifest-schemas.js";
11
13
 
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. */
14
+ /** Render a value for an error message without ever throwing — the offending
15
+ * data may be cyclic, and a throw here would REPLACE the validation failure
16
+ * with an unrelated error naming no field. */
18
17
  function describeValue(data: unknown): string {
19
18
  try {
20
- return JSON.stringify(data, (_k, v) => (typeof v === "bigint" ? `${v}` : v)) ?? String(data);
19
+ return JSON.stringify(data) ?? String(data);
21
20
  } catch {
22
21
  return String(data);
23
22
  }
@@ -165,13 +164,23 @@ const NAME_KEYED_SCHEMA_KEYWORDS = new Set([
165
164
  * two schemas that differ only there hash alike. */
166
165
  const DATA_VALUE_KEYWORDS = new Set(["const", "default", "enum", "examples"]);
167
166
 
167
+ /** Annotations that DO emit validation code, and so must survive the strip and stay
168
+ * in the cache key. `x-telo-binary` is the first: bytes have no JSON Schema type,
169
+ * so the keyword is the only thing standing between a byte slot and "accepts any
170
+ * object". Stripping it would silently reduce the slot to an empty schema — the
171
+ * precise regression the annotation was introduced to close — and, because the key
172
+ * is meant to describe the compiled validator, a keyword that changes the validator
173
+ * belongs in it. */
174
+ const VALIDATING_ANNOTATIONS = new Set([X_TELO_BINARY]);
175
+
168
176
  /** Deep-clone `schema` without its `x-telo-*` annotations — applied, like
169
177
  * {@link collapseSentinelsToSource}, before both AJV compilation and cache
170
178
  * hashing.
171
179
  *
172
- * Every `x-telo-*` keyword is analyzer/editor metadata: AJV runs `strict:
173
- * false` and registers the known ones as no-op keywords, so none of them emits
174
- * a single line of validation code. Leaving them in the hashed form makes the
180
+ * Almost every `x-telo-*` keyword is analyzer/editor metadata: AJV runs `strict:
181
+ * false` and registers the known ones as no-op keywords, so they emit no
182
+ * validation code. {@link VALIDATING_ANNOTATIONS} is the exception and is kept —
183
+ * see the note there. Leaving the rest in the hashed form makes the
175
184
  * cache key sensitive to differences that cannot change what the validator
176
185
  * does — and one such difference is real and systematic. The analyzer rewrites
177
186
  * `x-telo-ref.kind` to its canonical `<module>.<Kind>` in the declaring scope
@@ -193,7 +202,7 @@ function stripTeloAnnotations(value: unknown, nameKeyed = false): unknown {
193
202
  if (!value || typeof value !== "object") return value;
194
203
  const out: Record<string, unknown> = {};
195
204
  for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
196
- if (!nameKeyed && k.startsWith("x-telo-")) continue;
205
+ if (!nameKeyed && k.startsWith("x-telo-") && !VALIDATING_ANNOTATIONS.has(k)) continue;
197
206
  // A data-bearing keyword's value is carried over verbatim; a name-keyed
198
207
  // map's VALUES are schema nodes again, so only its keys are exempt.
199
208
  out[k] =
@@ -265,6 +274,11 @@ export class SchemaValidator {
265
274
  ]) {
266
275
  this.ajv.addKeyword(kw);
267
276
  }
277
+ // Not a no-op like the rest: bytes have no JSON Schema type, so this keyword IS
278
+ // the check. Defined as codegen in the analyzer so it inlines into the
279
+ // standalone validators compiled and cached below, rather than needing the
280
+ // implementation present at load.
281
+ this.ajv.addKeyword(binaryKeyword());
268
282
  // Register the shared manifest root so module schemas can
269
283
  // `$ref: "telo://manifest#/$defs/ResourceRef"` without each manifest
270
284
  // bundling its own copy. Mirrors the analyzer's createAjv().
@@ -383,19 +397,32 @@ export class SchemaValidator {
383
397
  const validate = this.compileAjvOrLoadCached(sanitized, hash, persist);
384
398
  if (persist) this.persistedHashes.add(hash);
385
399
 
400
+ // AJV's type check is `typeof data == "number"`, so a CEL integer — a BigInt —
401
+ // is rejected at an `integer` slot no matter what the author writes. Check a
402
+ // normalized VIEW instead and merge the `useDefaults` fills back, so the value
403
+ // that reaches the controller keeps its 64-bit range. `withBigIntsAsNumbers`
404
+ // returns the same reference when there was nothing to normalize, which is what
405
+ // keeps the BigInt-free path byte-identical to a plain `validate(data)`.
406
+ const check = (data: any): boolean => {
407
+ const view = withBigIntsAsNumbers(data);
408
+ const ok = validate(view);
409
+ if (ok && view !== data) mergeFilledDefaults(data, view);
410
+ return ok;
411
+ };
412
+
386
413
  const validator = {
387
414
  validate: (data: any) => {
388
- const isValid = validate(data);
389
- if (!isValid) {
415
+ if (!check(data)) {
416
+ // Reports `data`, not the normalized view: the view renders a wide
417
+ // integer through a double, so the digits it prints for the offending
418
+ // value would not be the ones the author wrote.
390
419
  throw new RuntimeError(
391
420
  "ERR_RESOURCE_SCHEMA_VALIDATION_FAILED",
392
421
  `Invalid value passed: ${describeValue(data)}. Error: ${formatAjvErrors(validate.errors)}`,
393
422
  );
394
423
  }
395
424
  },
396
- isValid: (data: any) => {
397
- return validate(data);
398
- },
425
+ isValid: (data: any) => check(data),
399
426
  };
400
427
 
401
428
  this.hashCache.set(hash, validator);