@telorun/kernel 0.48.0 → 0.50.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 (194) hide show
  1. package/dist/controller-loader.d.ts +6 -1
  2. package/dist/controller-loader.d.ts.map +1 -1
  3. package/dist/controller-loader.js +2 -0
  4. package/dist/controller-loader.js.map +1 -1
  5. package/dist/controller-loaders/bundle-builder.d.ts +2 -1
  6. package/dist/controller-loaders/bundle-builder.d.ts.map +1 -1
  7. package/dist/controller-loaders/bundle-builder.js +14 -9
  8. package/dist/controller-loaders/bundle-builder.js.map +1 -1
  9. package/dist/controller-loaders/npm-loader.d.ts +8 -2
  10. package/dist/controller-loaders/npm-loader.d.ts.map +1 -1
  11. package/dist/controller-loaders/npm-loader.js +19 -10
  12. package/dist/controller-loaders/npm-loader.js.map +1 -1
  13. package/dist/controllers/logging/console-sink-controller.d.ts +15 -0
  14. package/dist/controllers/logging/console-sink-controller.d.ts.map +1 -0
  15. package/dist/controllers/logging/console-sink-controller.js +40 -0
  16. package/dist/controllers/logging/console-sink-controller.js.map +1 -0
  17. package/dist/controllers/logging/file-sink-controller.d.ts +11 -0
  18. package/dist/controllers/logging/file-sink-controller.d.ts.map +1 -0
  19. package/dist/controllers/logging/file-sink-controller.js +33 -0
  20. package/dist/controllers/logging/file-sink-controller.js.map +1 -0
  21. package/dist/controllers/logging/sink-identity.d.ts +37 -0
  22. package/dist/controllers/logging/sink-identity.d.ts.map +1 -0
  23. package/dist/controllers/logging/sink-identity.js +40 -0
  24. package/dist/controllers/logging/sink-identity.js.map +1 -0
  25. package/dist/controllers/module/import-controller.d.ts.map +1 -1
  26. package/dist/controllers/module/import-controller.js +25 -0
  27. package/dist/controllers/module/import-controller.js.map +1 -1
  28. package/dist/controllers/resource-definition/resource-definition-controller.d.ts.map +1 -1
  29. package/dist/controllers/resource-definition/resource-definition-controller.js +1 -0
  30. package/dist/controllers/resource-definition/resource-definition-controller.js.map +1 -1
  31. package/dist/evaluation-context.d.ts +27 -1
  32. package/dist/evaluation-context.d.ts.map +1 -1
  33. package/dist/evaluation-context.js +84 -16
  34. package/dist/evaluation-context.js.map +1 -1
  35. package/dist/index.d.ts +4 -0
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +5 -0
  38. package/dist/index.js.map +1 -1
  39. package/dist/internal-context.d.ts +4 -0
  40. package/dist/internal-context.d.ts.map +1 -1
  41. package/dist/kernel.d.ts +26 -0
  42. package/dist/kernel.d.ts.map +1 -1
  43. package/dist/kernel.js +78 -5
  44. package/dist/kernel.js.map +1 -1
  45. package/dist/logging/bootstrap-writer.d.ts +23 -0
  46. package/dist/logging/bootstrap-writer.d.ts.map +1 -0
  47. package/dist/logging/bootstrap-writer.js +31 -0
  48. package/dist/logging/bootstrap-writer.js.map +1 -0
  49. package/dist/logging/color-precedence.d.ts +27 -0
  50. package/dist/logging/color-precedence.d.ts.map +1 -0
  51. package/dist/logging/color-precedence.js +47 -0
  52. package/dist/logging/color-precedence.js.map +1 -0
  53. package/dist/logging/console-sink.d.ts +45 -0
  54. package/dist/logging/console-sink.d.ts.map +1 -0
  55. package/dist/logging/console-sink.js +56 -0
  56. package/dist/logging/console-sink.js.map +1 -0
  57. package/dist/logging/debug-wire-sink.d.ts +38 -0
  58. package/dist/logging/debug-wire-sink.d.ts.map +1 -0
  59. package/dist/logging/debug-wire-sink.js +85 -0
  60. package/dist/logging/debug-wire-sink.js.map +1 -0
  61. package/dist/logging/drop-accounting.d.ts +34 -0
  62. package/dist/logging/drop-accounting.d.ts.map +1 -0
  63. package/dist/logging/drop-accounting.js +98 -0
  64. package/dist/logging/drop-accounting.js.map +1 -0
  65. package/dist/logging/encode-json.d.ts +28 -0
  66. package/dist/logging/encode-json.d.ts.map +1 -0
  67. package/dist/logging/encode-json.js +86 -0
  68. package/dist/logging/encode-json.js.map +1 -0
  69. package/dist/logging/encode-pretty.d.ts +7 -0
  70. package/dist/logging/encode-pretty.d.ts.map +1 -0
  71. package/dist/logging/encode-pretty.js +104 -0
  72. package/dist/logging/encode-pretty.js.map +1 -0
  73. package/dist/logging/file-sink.d.ts +33 -0
  74. package/dist/logging/file-sink.d.ts.map +1 -0
  75. package/dist/logging/file-sink.js +72 -0
  76. package/dist/logging/file-sink.js.map +1 -0
  77. package/dist/logging/index.d.ts +39 -0
  78. package/dist/logging/index.d.ts.map +1 -0
  79. package/dist/logging/index.js +27 -0
  80. package/dist/logging/index.js.map +1 -0
  81. package/dist/logging/kernel-logging.d.ts +110 -0
  82. package/dist/logging/kernel-logging.d.ts.map +1 -0
  83. package/dist/logging/kernel-logging.js +199 -0
  84. package/dist/logging/kernel-logging.js.map +1 -0
  85. package/dist/logging/log-sink.d.ts +9 -0
  86. package/dist/logging/log-sink.d.ts.map +1 -0
  87. package/dist/logging/log-sink.js +8 -0
  88. package/dist/logging/log-sink.js.map +1 -0
  89. package/dist/logging/logging-host.d.ts +14 -0
  90. package/dist/logging/logging-host.d.ts.map +1 -0
  91. package/dist/logging/logging-host.js +27 -0
  92. package/dist/logging/logging-host.js.map +1 -0
  93. package/dist/logging/logging-pipeline.d.ts +89 -0
  94. package/dist/logging/logging-pipeline.d.ts.map +1 -0
  95. package/dist/logging/logging-pipeline.js +349 -0
  96. package/dist/logging/logging-pipeline.js.map +1 -0
  97. package/dist/logging/normalize-attributes.d.ts +48 -0
  98. package/dist/logging/normalize-attributes.d.ts.map +1 -0
  99. package/dist/logging/normalize-attributes.js +140 -0
  100. package/dist/logging/normalize-attributes.js.map +1 -0
  101. package/dist/logging/record-buffer.d.ts +24 -0
  102. package/dist/logging/record-buffer.d.ts.map +1 -0
  103. package/dist/logging/record-buffer.js +78 -0
  104. package/dist/logging/record-buffer.js.map +1 -0
  105. package/dist/logging/redact-attributes.d.ts +45 -0
  106. package/dist/logging/redact-attributes.d.ts.map +1 -0
  107. package/dist/logging/redact-attributes.js +152 -0
  108. package/dist/logging/redact-attributes.js.map +1 -0
  109. package/dist/logging/sampler.d.ts +34 -0
  110. package/dist/logging/sampler.d.ts.map +1 -0
  111. package/dist/logging/sampler.js +52 -0
  112. package/dist/logging/sampler.js.map +1 -0
  113. package/dist/logging/scope-config.d.ts +32 -0
  114. package/dist/logging/scope-config.d.ts.map +1 -0
  115. package/dist/logging/scope-config.js +2 -0
  116. package/dist/logging/scope-config.js.map +1 -0
  117. package/dist/logging/span-id.d.ts +24 -0
  118. package/dist/logging/span-id.d.ts.map +1 -0
  119. package/dist/logging/span-id.js +85 -0
  120. package/dist/logging/span-id.js.map +1 -0
  121. package/dist/logging/to-error-value.d.ts +3 -0
  122. package/dist/logging/to-error-value.d.ts.map +1 -0
  123. package/dist/logging/to-error-value.js +79 -0
  124. package/dist/logging/to-error-value.js.map +1 -0
  125. package/dist/logging/trace-parent.d.ts +54 -0
  126. package/dist/logging/trace-parent.d.ts.map +1 -0
  127. package/dist/logging/trace-parent.js +92 -0
  128. package/dist/logging/trace-parent.js.map +1 -0
  129. package/dist/manifest-schemas.d.ts +1 -1
  130. package/dist/manifest-schemas.d.ts.map +1 -1
  131. package/dist/manifest-schemas.js +12 -0
  132. package/dist/manifest-schemas.js.map +1 -1
  133. package/dist/module-context.d.ts +16 -0
  134. package/dist/module-context.d.ts.map +1 -1
  135. package/dist/module-context.js +6 -0
  136. package/dist/module-context.js.map +1 -1
  137. package/dist/resource-context.d.ts +15 -1
  138. package/dist/resource-context.d.ts.map +1 -1
  139. package/dist/resource-context.js +45 -0
  140. package/dist/resource-context.js.map +1 -1
  141. package/dist/schema-compiled-values.d.ts.map +1 -1
  142. package/dist/schema-compiled-values.js +7 -0
  143. package/dist/schema-compiled-values.js.map +1 -1
  144. package/dist/schema-validator.d.ts +8 -1
  145. package/dist/schema-validator.d.ts.map +1 -1
  146. package/dist/schema-validator.js +13 -3
  147. package/dist/schema-validator.js.map +1 -1
  148. package/dist/transports/oci/oci-client.d.ts.map +1 -1
  149. package/dist/transports/oci/oci-client.js +13 -3
  150. package/dist/transports/oci/oci-client.js.map +1 -1
  151. package/dist/transports/registry-transport.d.ts.map +1 -1
  152. package/dist/transports/registry-transport.js +15 -5
  153. package/dist/transports/registry-transport.js.map +1 -1
  154. package/package.json +3 -3
  155. package/src/controller-loader.ts +7 -1
  156. package/src/controller-loaders/bundle-builder.ts +18 -10
  157. package/src/controller-loaders/npm-loader.ts +32 -11
  158. package/src/controllers/logging/console-sink-controller.ts +46 -0
  159. package/src/controllers/logging/file-sink-controller.ts +40 -0
  160. package/src/controllers/logging/sink-identity.ts +53 -0
  161. package/src/controllers/module/import-controller.ts +27 -0
  162. package/src/controllers/resource-definition/resource-definition-controller.ts +1 -0
  163. package/src/evaluation-context.ts +93 -15
  164. package/src/index.ts +54 -0
  165. package/src/internal-context.ts +4 -0
  166. package/src/kernel.ts +98 -8
  167. package/src/logging/bootstrap-writer.ts +38 -0
  168. package/src/logging/color-precedence.ts +62 -0
  169. package/src/logging/console-sink.ts +86 -0
  170. package/src/logging/debug-wire-sink.ts +85 -0
  171. package/src/logging/drop-accounting.ts +108 -0
  172. package/src/logging/encode-json.ts +98 -0
  173. package/src/logging/encode-pretty.ts +117 -0
  174. package/src/logging/file-sink.ts +87 -0
  175. package/src/logging/index.ts +65 -0
  176. package/src/logging/kernel-logging.ts +266 -0
  177. package/src/logging/log-sink.ts +18 -0
  178. package/src/logging/logging-host.ts +33 -0
  179. package/src/logging/logging-pipeline.ts +465 -0
  180. package/src/logging/normalize-attributes.ts +228 -0
  181. package/src/logging/record-buffer.ts +72 -0
  182. package/src/logging/redact-attributes.ts +168 -0
  183. package/src/logging/sampler.ts +76 -0
  184. package/src/logging/scope-config.ts +32 -0
  185. package/src/logging/span-id.ts +84 -0
  186. package/src/logging/to-error-value.ts +89 -0
  187. package/src/logging/trace-parent.ts +110 -0
  188. package/src/manifest-schemas.ts +12 -0
  189. package/src/module-context.ts +23 -0
  190. package/src/resource-context.ts +48 -0
  191. package/src/schema-compiled-values.ts +6 -0
  192. package/src/schema-validator.ts +20 -5
  193. package/src/transports/oci/oci-client.ts +13 -3
  194. package/src/transports/registry-transport.ts +23 -5
package/src/kernel.ts CHANGED
@@ -33,6 +33,10 @@ import { ControllerRegistry } from "./controller-registry.js";
33
33
  import { EventBus } from "./events.js";
34
34
  import { hostEnv, lockControllerEnv } from "./host-env.js";
35
35
  import { KernelTracer } from "./tracing.js";
36
+ import { KernelLogging, type LoggingManifestBlock } from "./logging/kernel-logging.js";
37
+ import type { ScopeConfig } from "./logging/scope-config.js";
38
+ import { formatSpanCounter } from "./logging/span-id.js";
39
+ import { ambientInvokeContext } from "./evaluation-context.js";
36
40
  import { ModuleContext } from "./module-context.js";
37
41
  import { ResourceContextImpl } from "./resource-context.js";
38
42
  import { nodeCelHandlers } from "./cel-handlers.js";
@@ -148,12 +152,36 @@ export class Kernel implements IKernel {
148
152
  readonly env: Record<string, string | undefined>;
149
153
  readonly argv: string[];
150
154
  readonly registryUrl: string | undefined;
155
+ /** Structured logging for this kernel — the pipeline, its sinks, and the
156
+ * scoped loggers handed to controllers as `ctx.log`. Live from construction
157
+ * so loader and parse diagnostics have somewhere to go (§12.3); a nested
158
+ * kernel inherits the parent's sink configuration through the injected
159
+ * streams, so a test harness can capture child output (§13.1). */
160
+ readonly logging: KernelLogging;
151
161
 
152
162
  constructor(options: KernelOptions) {
153
163
  this.stdin = options.stdin ?? process.stdin;
154
164
  this.stdout = options.stdout ?? process.stdout;
155
165
  this.stderr = options.stderr ?? process.stderr;
156
166
  this.env = options.env ?? hostEnv();
167
+ this.logging = new KernelLogging({
168
+ env: this.env,
169
+ stdout: this.stdout,
170
+ stderr: this.stderr,
171
+ });
172
+ // The validator is constructed outside the kernel's stdio scope, so it is
173
+ // handed the kernel logger explicitly rather than writing to process.stderr.
174
+ this.sharedSchemaValidator.setLogger(this.logging.kernelLogger());
175
+ // §7.2: a record emitted inside an active dispatch span carries that span's
176
+ // ids automatically — a controller never passes them. The ids come from the
177
+ // same counter the trace wire uses, rendered here at the encoding boundary.
178
+ this.logging.setTraceContextProvider(() => {
179
+ const ambient = ambientInvokeContext();
180
+ if (!ambient?.traceId || ambient.invocationId === undefined) return undefined;
181
+ const spanId = formatSpanCounter(ambient.invocationId);
182
+ if (!spanId) return undefined;
183
+ return { traceId: ambient.traceId, spanId };
184
+ });
157
185
  this.argv = options.argv ?? [];
158
186
  this.registryUrl = options.registryUrl;
159
187
  // Resolution sources come from the transport registry, so a scheme-owning
@@ -297,6 +325,18 @@ export class Kernel implements IKernel {
297
325
  "Telo.Import",
298
326
  await import("./controllers/module/import-controller.js"),
299
327
  );
328
+ // The mandatory sinks live in the kernel rather than in a standard-library
329
+ // module: §16 requires every conforming runtime to implement both, so
330
+ // shipping them as an installable module would make conformance depend on
331
+ // whether that module happened to be installed.
332
+ this.controllers.registerController(
333
+ "Telo.ConsoleSink",
334
+ await import("./controllers/logging/console-sink-controller.js"),
335
+ );
336
+ this.controllers.registerController(
337
+ "Telo.FileSink",
338
+ await import("./controllers/logging/file-sink-controller.js"),
339
+ );
300
340
  }
301
341
 
302
342
  /**
@@ -398,7 +438,9 @@ export class Kernel implements IKernel {
398
438
  );
399
439
  }
400
440
  for (const d of analysisGraph.versionDiagnostics) {
401
- if (d.code === "MODULE_VERSION_HOISTED") console.warn(`warning: ${d.message}`);
441
+ if (d.code === "MODULE_VERSION_HOISTED") {
442
+ this.logging.kernelLogger().warn(d.message, { "telo.diagnostic.code": d.code });
443
+ }
402
444
  }
403
445
  const staticManifests = flattenForAnalyzer(analysisGraph);
404
446
  this.staticManifests = staticManifests;
@@ -461,9 +503,7 @@ export class Kernel implements IKernel {
461
503
  try {
462
504
  await writeAnalysisStamp("", analysisSignature, manifestsDir);
463
505
  } catch (err) {
464
- this.stderr.write(
465
- `[telo:kernel] analysis stamp write failed: ${err instanceof Error ? err.message : String(err)}\n`,
466
- );
506
+ this.logging.kernelLogger().warn("analysis stamp write failed", undefined, { error: err });
467
507
  }
468
508
  }
469
509
 
@@ -601,9 +641,34 @@ export class Kernel implements IKernel {
601
641
  port,
602
642
  protocol: portDecls[name]?.protocol === "udp" ? "udp" : "tcp",
603
643
  }));
644
+
645
+ this.applyLoggingConfig(rootApplicationManifest as Record<string, any>);
604
646
  }
605
647
  }
606
648
 
649
+ /**
650
+ * Adopt the manifest's `logging:` block — §12.3's handover from the bootstrap
651
+ * default to the declared configuration.
652
+ *
653
+ * Deliberately runs *after* `variables` / `secrets` resolve, because the only
654
+ * sanctioned way to derive a level from the host environment is a `variables:`
655
+ * entry read with `!cel`: there is no `TELO_LOG_LEVEL`, so the expression must
656
+ * have something to resolve against by the time it is evaluated.
657
+ *
658
+ * Manifest secrets are handed to the pipeline here so they redact with no
659
+ * configuration at all (§14) — the same set, cascading down the same import
660
+ * graph, that supplies the scope threshold.
661
+ */
662
+ private applyLoggingConfig(rootApplicationManifest: Record<string, any>): void {
663
+ const raw = rootApplicationManifest["logging"];
664
+ if (raw === undefined) {
665
+ this.logging.applyRootConfig(undefined, this.rootContext.secretValues);
666
+ return;
667
+ }
668
+ const expanded = this.rootContext.expandWith(raw, {}) as LoggingManifestBlock;
669
+ this.logging.applyRootConfig(expanded, this.rootContext.secretValues);
670
+ }
671
+
607
672
  /**
608
673
  * Resolved inbound ports from the root Application's `ports:` block, available
609
674
  * after {@link load}. Each carries the resolved integer and its declared
@@ -642,10 +707,14 @@ export class Kernel implements IKernel {
642
707
  // return earlier — so analysis/editor are unaffected. The denied set is
643
708
  // process-global and additive across in-process kernels.
644
709
  lockControllerEnv(this._declaredEnvKeys, (key) => {
645
- this.stderr.write(
646
- `[telo] controller read process.env.${key} directly — ${key} is a declared ` +
647
- `binding; read it through ctx.env or its variable/secret, not raw process.env.\n`,
648
- );
710
+ this.logging
711
+ .kernelLogger()
712
+ .warn(
713
+ `controller read process.env.${key} directly — ${key} is a declared binding; ` +
714
+ `read it through ctx.env or its variable/secret, not raw process.env.`,
715
+ { "telo.env.key": key },
716
+ { eventName: "telo.env.declared_binding_bypassed" },
717
+ );
649
718
  });
650
719
 
651
720
  // Call register hooks for controllers actually loaded at this point (built-ins).
@@ -689,11 +758,26 @@ export class Kernel implements IKernel {
689
758
  }
690
759
 
691
760
  await this.rootContext.initializeResources();
761
+
762
+ // Every declared sink has now attached, so the bootstrap buffer has done its
763
+ // job. A consumer connecting later — the debug wire — wants the live stream,
764
+ // not the whole process history, so replay stops here rather than persisting
765
+ // for the process lifetime. The sink-counting and the tree walk are logging
766
+ // logic, so they live on KernelLogging; the kernel just hands it the graph.
767
+ this.logging.sealBootstrap(this.staticManifests);
768
+
692
769
  await this.eventBus.emit("Kernel.Initialized", {});
693
770
 
694
771
  this._isBooted = true;
695
772
  }
696
773
 
774
+ /** Every module context's resolved logging configuration, keyed by its dotted
775
+ * import-alias path. Delegates to {@link KernelLogging.scopesFrom}; see there
776
+ * for what §12.2 uses it for. */
777
+ loggingScopes(): Map<string, ScopeConfig> {
778
+ return this.logging.scopesFrom(this.rootContext);
779
+ }
780
+
697
781
  /**
698
782
  * Run the manifest's `targets` (Telo.Service / Telo.Runnable instances).
699
783
  * Emits Kernel.Starting before, Kernel.Started after.
@@ -749,6 +833,12 @@ export class Kernel implements IKernel {
749
833
  if (this.rootContext) {
750
834
  await this.rootContext.teardownResources();
751
835
  }
836
+ // Sinks tear down last (they are pinned in the teardown order), so by this
837
+ // point their own teardown has already flushed and closed them. This drains
838
+ // anything emitted during teardown itself and reports outstanding drop
839
+ // accounting, so a run that ends while still dropping does not lose its
840
+ // final count (§10.4, §10.5).
841
+ await this.logging.shutdown();
752
842
  // Drop the load-time graph so a teardown'd kernel doesn't pin every
753
843
  // manifest file's text in memory (LoadedFile retains the parsed
754
844
  // documents + the original YAML bytes). Reusing the kernel after
@@ -0,0 +1,38 @@
1
+ import { SEVERITY } from "@telorun/sdk";
2
+ import { ConsoleSink } from "./console-sink.js";
3
+ import type { LogSinkInstance } from "./log-sink.js";
4
+
5
+ /**
6
+ * The pre-manifest console writer — `kernel/specs/logging.md` §12.3.
7
+ *
8
+ * Records emitted before the manifest is parsed — loader and parse diagnostics —
9
+ * cannot consult a `logging:` block that does not yet exist. During that phase
10
+ * the runtime uses a fixed default of `info` on an internal writer, and switches
11
+ * to the declared configuration as soon as the manifest resolves. This is the
12
+ * only phase not manifest-governed, and it is not configurable by other means.
13
+ *
14
+ * The writer is kernel-internal and is deliberately **not** a
15
+ * `Telo.ConsoleSink` resource — it exists precisely because no resource can yet.
16
+ * It is also what makes D3 hold: because the pre-manifest window is covered
17
+ * unconditionally, declared sinks are free to be resources that attach later,
18
+ * with buffered records replayed into them.
19
+ */
20
+
21
+ export const BOOTSTRAP_SINK_ID = "<bootstrap>";
22
+
23
+ export function createBootstrapWriter(options: {
24
+ env: Record<string, string | undefined>;
25
+ stdout: NodeJS.WritableStream;
26
+ stderr: NodeJS.WritableStream;
27
+ }): LogSinkInstance {
28
+ return new ConsoleSink({
29
+ sinkId: BOOTSTRAP_SINK_ID,
30
+ level: SEVERITY.info,
31
+ destination: "stderr",
32
+ encoding: "auto",
33
+ color: "auto",
34
+ env: options.env,
35
+ stdout: options.stdout,
36
+ stderr: options.stderr,
37
+ });
38
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Console color decision — `kernel/specs/logging.md` §11.2.
3
+ *
4
+ * The precedence order is normative and must be implemented exactly. Steps 2–7
5
+ * apply only under `color: auto`; `always` / `never` short-circuit at step 1.
6
+ *
7
+ * These environment inputs are **not** a second configuration channel and do not
8
+ * contradict D6. `NO_COLOR`, `FORCE_COLOR`, `TERM`, and `isatty()` describe the
9
+ * terminal's capability and the operator's preference, not the application's
10
+ * desired state: `auto` means "detect the environment", and the manifest remains
11
+ * the sole authority over *what* is logged — these affect only how it is
12
+ * painted.
13
+ */
14
+
15
+ export type ColorSetting = "auto" | "always" | "never";
16
+
17
+ export interface ColorDecisionInput {
18
+ /** The manifest's `color:` setting. */
19
+ setting: ColorSetting;
20
+ /** The environment to consult. Always the real host environment, never the
21
+ * guardrail proxy — these are host capability signals, not bindings. */
22
+ env: Record<string, string | undefined>;
23
+ /** Whether the **sink's actual output descriptor** is a TTY. A console sink on
24
+ * `stdout` and another on `stderr` can decide differently, and that is
25
+ * correct — this is never the process's descriptor by proxy. */
26
+ isTTY: boolean;
27
+ }
28
+
29
+ export function decideColor(input: ColorDecisionInput): boolean {
30
+ const { setting, env, isTTY } = input;
31
+
32
+ // 1. An explicit manifest setting wins outright.
33
+ if (setting === "always") return true;
34
+ if (setting === "never") return false;
35
+
36
+ // 2. NO_COLOR: presence and non-emptiness matter, the value does not. Testing
37
+ // mere presence is a widespread bug — `NO_COLOR=""` must NOT disable color.
38
+ if (isNonEmpty(env["NO_COLOR"])) return false;
39
+
40
+ // 3. FORCE_COLOR: "0" disables, any other non-empty value enables.
41
+ const forceColor = env["FORCE_COLOR"];
42
+ if (isNonEmpty(forceColor)) return forceColor !== "0";
43
+
44
+ // 4. CLICOLOR_FORCE present and not "0" enables.
45
+ const clicolorForce = env["CLICOLOR_FORCE"];
46
+ if (clicolorForce !== undefined && clicolorForce !== "0") return true;
47
+
48
+ // 5. CLICOLOR=0 disables.
49
+ if (env["CLICOLOR"] === "0") return false;
50
+
51
+ // 6. A dumb terminal cannot render color.
52
+ if (env["TERM"] === "dumb") return false;
53
+
54
+ // 7. Otherwise follow the descriptor. Note there is deliberately no CI-variable
55
+ // branch: forcing color on merely because a CI variable is present is a
56
+ // widespread bug in existing libraries, not a convention to copy.
57
+ return isTTY;
58
+ }
59
+
60
+ function isNonEmpty(value: string | undefined): boolean {
61
+ return value !== undefined && value !== "";
62
+ }
@@ -0,0 +1,86 @@
1
+ import type { LogRecord } from "@telorun/sdk";
2
+ import { decideColor, type ColorSetting } from "./color-precedence.js";
3
+ import { encodeJsonLine, type BytesEncoder } from "./encode-json.js";
4
+ import { encodePrettyLine } from "./encode-pretty.js";
5
+ import type { LogSinkInstance } from "./log-sink.js";
6
+
7
+ /**
8
+ * `Telo.ConsoleSink` — a kernel built-in (§10.2).
9
+ *
10
+ * Console and file are built-ins rather than standard-library modules because
11
+ * §16 already requires every conforming runtime to implement both, along with
12
+ * the `pretty` and `json` encodings, byte-identically. Mandatory runtime
13
+ * behaviour belongs in the runtime; shipping it as an installable module would
14
+ * make conformance depend on whether that module happened to be installed.
15
+ *
16
+ * Synchronous by default: a developer-facing stream that silently reorders or
17
+ * drops is worse than a slow one, and `on_full` does not apply to a sink with no
18
+ * buffer to saturate.
19
+ */
20
+
21
+ export type ConsoleEncoding = "auto" | "pretty" | "json";
22
+ export type ConsoleDestination = "stderr" | "stdout";
23
+
24
+ export interface ConsoleSinkOptions {
25
+ sinkId: string;
26
+ level: number;
27
+ destination?: ConsoleDestination;
28
+ encoding?: ConsoleEncoding;
29
+ color?: ColorSetting;
30
+ /** The host environment — capability signals only, never a config channel. */
31
+ env: Record<string, string | undefined>;
32
+ stdout: NodeJS.WritableStream;
33
+ stderr: NodeJS.WritableStream;
34
+ encodeBytes?: BytesEncoder;
35
+ }
36
+
37
+ export class ConsoleSink implements LogSinkInstance {
38
+ readonly sinkId: string;
39
+ readonly level: number;
40
+ /** A file-descriptor write blocks until the bytes are handed to the OS, so a
41
+ * `fatal` record is durable by the time `log()` returns (§10.5). */
42
+ readonly syncFlushable = true;
43
+
44
+ readonly #stream: NodeJS.WritableStream;
45
+ readonly #pretty: boolean;
46
+ readonly #color: boolean;
47
+ readonly #encodeBytes: BytesEncoder | undefined;
48
+
49
+ constructor(options: ConsoleSinkOptions) {
50
+ this.sinkId = options.sinkId;
51
+ this.level = options.level;
52
+ const destination = options.destination ?? "stderr";
53
+ this.#stream = destination === "stdout" ? options.stdout : options.stderr;
54
+
55
+ // `auto` is evaluated against *this sink's* destination descriptor, not the
56
+ // process's: a console sink on stdout and another on stderr can resolve
57
+ // differently, and that is correct.
58
+ const isTTY = Boolean((this.#stream as { isTTY?: boolean }).isTTY);
59
+ const encoding = options.encoding ?? "auto";
60
+ this.#pretty = encoding === "pretty" || (encoding === "auto" && isTTY);
61
+ this.#color = this.#pretty
62
+ ? decideColor({ setting: options.color ?? "auto", env: options.env, isTTY })
63
+ : false;
64
+ this.#encodeBytes = options.encodeBytes;
65
+ }
66
+
67
+ write(record: LogRecord): void {
68
+ this.#stream.write(
69
+ this.#pretty
70
+ ? encodePrettyLine(record, { color: this.#color })
71
+ : encodeJsonLine(record, { encodeBytes: this.#encodeBytes }),
72
+ );
73
+ }
74
+
75
+ async flush(): Promise<void> {
76
+ // Nothing is held: every record is handed to the descriptor on write.
77
+ }
78
+
79
+ flushSync(): void {
80
+ // Same — the write already reached the OS.
81
+ }
82
+
83
+ async close(): Promise<void> {
84
+ // The process owns stdout/stderr; a sink never closes them.
85
+ }
86
+ }
@@ -0,0 +1,85 @@
1
+ import type { LogRecord } from "@telorun/sdk";
2
+ import { toJsonProfile, type BytesEncoder } from "./encode-json.js";
3
+ import type { LogSinkInstance } from "./log-sink.js";
4
+
5
+ /**
6
+ * The `debug-wire` sink — `kernel/specs/logging.md` §10.2 and §11.4.
7
+ *
8
+ * **Not declarable.** It is host-attached when a debug consumer connects
9
+ * (`--debug` / `--inspect`) and detached on disconnect, which makes it tooling
10
+ * attachment rather than application configuration — the same category as TTY
11
+ * detection, not a violation of D6.
12
+ *
13
+ * Not sync-flushable: delivery crosses an SSE/stream boundary owned by the host,
14
+ * so a `fatal` record's flush here is initiated but never awaited (§10.5).
15
+ */
16
+
17
+ export const DEBUG_WIRE_SINK_ID = "<debug-wire>";
18
+
19
+ export class DebugWireSink implements LogSinkInstance {
20
+ readonly sinkId = DEBUG_WIRE_SINK_ID;
21
+ readonly level: number;
22
+ readonly syncFlushable = false;
23
+
24
+ readonly #emit: (frame: { kind: "record"; timestamp: string; record: Record<string, unknown> }) => void;
25
+ readonly #encodeBytes: BytesEncoder | undefined;
26
+
27
+ constructor(options: {
28
+ level: number;
29
+ /** Hands a §11.4 `record` frame to the host's wire writer. */
30
+ emit: (frame: { kind: "record"; timestamp: string; record: Record<string, unknown> }) => void;
31
+ /** Offloads `bytes` attributes to the host's blob store when it has one —
32
+ * the debug wire does. Raw bytes are never inlined (§6.1). */
33
+ encodeBytes?: BytesEncoder;
34
+ }) {
35
+ this.level = options.level;
36
+ this.#emit = options.emit;
37
+ this.#encodeBytes = options.encodeBytes;
38
+ }
39
+
40
+ write(record: LogRecord): void {
41
+ const profile = toJsonProfile(record);
42
+ if (this.#encodeBytes) offloadBytes(profile, this.#encodeBytes);
43
+ this.#emit({
44
+ kind: "record",
45
+ timestamp: new Date(Number(record.timestamp / 1_000_000n)).toISOString(),
46
+ record: profile,
47
+ });
48
+ }
49
+
50
+ async flush(): Promise<void> {
51
+ // Frames are handed to the host synchronously; the host owns delivery.
52
+ }
53
+
54
+ flushSync(): void {
55
+ // Never synchronously drainable — see the class doc.
56
+ }
57
+
58
+ async close(): Promise<void> {}
59
+ }
60
+
61
+ /** Replace `Uint8Array` leaves with the host's pointer form, bounded by the
62
+ * attribute limits already applied upstream. */
63
+ function offloadBytes(value: unknown, encodeBytes: BytesEncoder): void {
64
+ const stack: unknown[] = [value];
65
+ let guard = 0;
66
+ while (stack.length > 0 && guard < 10_000) {
67
+ guard += 1;
68
+ const current = stack.pop();
69
+ if (!current || typeof current !== "object") continue;
70
+ if (Array.isArray(current)) {
71
+ for (let i = 0; i < current.length; i += 1) {
72
+ const item = current[i];
73
+ if (item instanceof Uint8Array) current[i] = encodeBytes(item);
74
+ else stack.push(item);
75
+ }
76
+ continue;
77
+ }
78
+ const record = current as Record<string, unknown>;
79
+ for (const key of Object.keys(record)) {
80
+ const item = record[key];
81
+ if (item instanceof Uint8Array) record[key] = encodeBytes(item);
82
+ else stack.push(item);
83
+ }
84
+ }
85
+ }
@@ -0,0 +1,108 @@
1
+ import type { DropCause } from "./log-sink.js";
2
+
3
+ /**
4
+ * Drop accounting — `kernel/specs/logging.md` §10.4.
5
+ *
6
+ * A runtime maintains a monotonic counter of records dropped per sink, per
7
+ * cause. When drops occur and then cease, it emits exactly one `warn` record
8
+ * reporting the count and the cause. **Dropping without accounting is
9
+ * non-conformant** — "nothing is silently lost" is a design principle, not a
10
+ * nice-to-have, so every drop, truncation, and sink failure is counted and
11
+ * surfaced.
12
+ */
13
+
14
+ /** Sampling drops happen before fan-out, so they are not attributable to any one
15
+ * sink and are counted against the pipeline itself. */
16
+ export const PIPELINE_SINK_ID = "<pipeline>";
17
+
18
+ /** How long a cause must go quiet before its recovery warning is emitted. */
19
+ const QUIESCE_MS = 1000;
20
+
21
+ interface Counter {
22
+ /** Monotonic across the process lifetime; never reset. */
23
+ total: number;
24
+ /** Total as of the last emitted recovery warning. */
25
+ reported: number;
26
+ timer: ReturnType<typeof setTimeout> | undefined;
27
+ }
28
+
29
+ export interface DropReport {
30
+ sinkId: string;
31
+ cause: DropCause;
32
+ /** Records dropped since the previous report. */
33
+ count: number;
34
+ /** Monotonic lifetime total for this (sink, cause) pair. */
35
+ total: number;
36
+ }
37
+
38
+ export class DropRegistry {
39
+ readonly #counters = new Map<string, Counter>();
40
+ readonly #onRecovered: (report: DropReport) => void;
41
+ /** Guards against a recovery warning that itself drops, which would otherwise
42
+ * re-arm the timer forever. */
43
+ #reporting = false;
44
+
45
+ constructor(onRecovered: (report: DropReport) => void) {
46
+ this.#onRecovered = onRecovered;
47
+ }
48
+
49
+ record(sinkId: string, cause: DropCause, count = 1): void {
50
+ const key = `${sinkId}\x00${cause}`;
51
+ let counter = this.#counters.get(key);
52
+ if (!counter) {
53
+ counter = { total: 0, reported: 0, timer: undefined };
54
+ this.#counters.set(key, counter);
55
+ }
56
+ // Always count — a drop caused by emitting the recovery warning itself is
57
+ // still a drop, and undercounting it is exactly the silent loss this class
58
+ // exists to prevent.
59
+ counter.total += count;
60
+
61
+ // The only thing suppressed while a recovery warning is in flight is the
62
+ // timer re-arm: re-arming here would loop forever (the warning's own drop
63
+ // re-arms the timer, which fires and drops again). The increment above keeps
64
+ // the total honest; the next ordinary drop — or `reportPending` at
65
+ // shutdown — surfaces it, since `total !== reported`.
66
+ if (this.#reporting) return;
67
+
68
+ if (counter.timer) clearTimeout(counter.timer);
69
+ counter.timer = setTimeout(() => this.#report(sinkId, cause, counter!), QUIESCE_MS);
70
+ // A pending drop report must never be the reason a process stays alive.
71
+ (counter.timer as { unref?: () => void }).unref?.();
72
+ }
73
+
74
+ /** Lifetime total for a (sink, cause) pair. */
75
+ total(sinkId: string, cause: DropCause): number {
76
+ return this.#counters.get(`${sinkId}\x00${cause}`)?.total ?? 0;
77
+ }
78
+
79
+ /** Emit any outstanding reports immediately — used at shutdown so a run that
80
+ * ends while still dropping does not lose its final accounting. */
81
+ reportPending(): void {
82
+ for (const [key, counter] of this.#counters) {
83
+ if (counter.total === counter.reported) continue;
84
+ const [sinkId, cause] = key.split("\x00") as [string, DropCause];
85
+ this.#report(sinkId, cause, counter);
86
+ }
87
+ }
88
+
89
+ dispose(): void {
90
+ for (const counter of this.#counters.values()) {
91
+ if (counter.timer) clearTimeout(counter.timer);
92
+ counter.timer = undefined;
93
+ }
94
+ }
95
+
96
+ #report(sinkId: string, cause: DropCause, counter: Counter): void {
97
+ counter.timer = undefined;
98
+ const count = counter.total - counter.reported;
99
+ if (count <= 0) return;
100
+ counter.reported = counter.total;
101
+ this.#reporting = true;
102
+ try {
103
+ this.#onRecovered({ sinkId, cause, count, total: counter.total });
104
+ } finally {
105
+ this.#reporting = false;
106
+ }
107
+ }
108
+ }
@@ -0,0 +1,98 @@
1
+ import { formatUnixNano, type AnyValue, type ErrorValue, type LogRecord } from "@telorun/sdk";
2
+
3
+ /**
4
+ * The `json` encoding — `kernel/specs/logging.md` §11.1. One JSON object per
5
+ * line, UTF-8, newline-terminated.
6
+ *
7
+ * No de-facto cross-ecosystem standard exists for key names, so Telo defines its
8
+ * own profile and keeps OTLP as a separate one. Keys are emitted in the §11.1
9
+ * order for diffability, and extension data belongs in `attributes` — unknown
10
+ * top-level keys are never emitted.
11
+ */
12
+
13
+ /** How a `bytes` attribute is rendered. Raw bytes are never inlined into a text
14
+ * encoding: a sink with a blob store offloads them to a pointer, and every
15
+ * other sink base64-encodes (§6.1). */
16
+ export type BytesEncoder = (bytes: Uint8Array) => AnyValue;
17
+
18
+ export const base64Bytes: BytesEncoder = (bytes) => Buffer.from(bytes).toString("base64");
19
+
20
+ export interface JsonEncodeOptions {
21
+ encodeBytes?: BytesEncoder;
22
+ }
23
+
24
+ /** Encode one record as a newline-terminated JSON line. */
25
+ export function encodeJsonLine(record: LogRecord, options: JsonEncodeOptions = {}): string {
26
+ return `${encodeJson(record, options)}\n`;
27
+ }
28
+
29
+ /** Encode one record as a JSON object, without the trailing newline — the shape
30
+ * the debug wire nests inside its `record` frame (§11.4). */
31
+ export function encodeJson(record: LogRecord, options: JsonEncodeOptions = {}): string {
32
+ return JSON.stringify(toJsonProfile(record), makeReplacer(options.encodeBytes ?? base64Bytes));
33
+ }
34
+
35
+ /** The §11.1 key profile as a plain object, in the recommended order. Exposed
36
+ * separately because the debug wire embeds the same profile rather than
37
+ * re-deriving it. */
38
+ export function toJsonProfile(record: LogRecord): Record<string, unknown> {
39
+ const out: Record<string, unknown> = {
40
+ time: formatUnixNano(record.timestamp),
41
+ level: record.severityText.toUpperCase(),
42
+ severity: record.severityNumber,
43
+ msg: record.message,
44
+ };
45
+
46
+ // `observed_timestamp` is deliberately NOT emitted: §11.1 defines a closed key
47
+ // set and forbids extra top-level keys, and §17's schema is
48
+ // `additionalProperties: false`. The field survives on the record model and in
49
+ // the `otlp` profile, which has a real `observedTimeUnixNano` slot for it.
50
+ if (record.traceId !== undefined) out["trace_id"] = record.traceId;
51
+ if (record.spanId !== undefined) out["span_id"] = record.spanId;
52
+ if (record.traceFlags !== undefined) out["trace_flags"] = record.traceFlags;
53
+ if (record.resource !== undefined) out["resource"] = record.resource;
54
+ if (record.module !== undefined) out["module"] = record.module;
55
+ if (record.scope !== undefined) out["scope"] = record.scope;
56
+ // Nested, never flattened — flattening risks collision with reserved keys.
57
+ // Emitted with keys in sorted (code-point) order so the `json` encoding is
58
+ // byte-identical across runtimes (§16 vector 18): attribute keys are arbitrary
59
+ // user data with no natural order, and sorted is the only order both this
60
+ // runtime and a Rust `BTreeMap`-backed one produce without extra config.
61
+ if (record.attributes !== undefined) out["attributes"] = sortKeysDeep(record.attributes);
62
+ if (record.error !== undefined) out["err"] = record.error satisfies ErrorValue;
63
+ if (record.eventName !== undefined) out["event_name"] = record.eventName;
64
+ if (record.droppedAttributesCount) {
65
+ out["dropped_attributes_count"] = record.droppedAttributesCount;
66
+ }
67
+
68
+ return out;
69
+ }
70
+
71
+ /** Rebuild an attribute value with every nested object's keys in sorted order.
72
+ * `JSON.stringify` emits string keys in insertion order, so reconstructing the
73
+ * object with sorted insertion is what pins the byte order. Arrays keep their
74
+ * index order; `Uint8Array` is a leaf. The attributes are already normalized
75
+ * (§6.3), so depth and breadth are bounded and this cannot diverge. */
76
+ function sortKeysDeep(value: AnyValue): AnyValue {
77
+ if (value === null || typeof value !== "object") return value;
78
+ if (value instanceof Uint8Array) return value;
79
+ if (Array.isArray(value)) return value.map(sortKeysDeep);
80
+ const sorted: Record<string, AnyValue> = {};
81
+ for (const key of Object.keys(value).sort()) sorted[key] = sortKeysDeep(value[key]!);
82
+ return sorted;
83
+ }
84
+
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();
94
+ }
95
+ if (value instanceof Uint8Array) return encodeBytes(value);
96
+ return value;
97
+ };
98
+ }