@telorun/kernel 0.61.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 (41) 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/index.d.ts +2 -2
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js.map +1 -1
  17. package/dist/kernel.d.ts.map +1 -1
  18. package/dist/kernel.js +14 -20
  19. package/dist/kernel.js.map +1 -1
  20. package/dist/static-analysis-diagnostics.d.ts +19 -0
  21. package/dist/static-analysis-diagnostics.d.ts.map +1 -0
  22. package/dist/static-analysis-diagnostics.js +52 -0
  23. package/dist/static-analysis-diagnostics.js.map +1 -0
  24. package/dist/transports/oci/oci-transport.d.ts +17 -0
  25. package/dist/transports/oci/oci-transport.d.ts.map +1 -1
  26. package/dist/transports/oci/oci-transport.js +33 -8
  27. package/dist/transports/oci/oci-transport.js.map +1 -1
  28. package/dist/transports/transport-registry.d.ts +6 -4
  29. package/dist/transports/transport-registry.d.ts.map +1 -1
  30. package/dist/transports/transport-registry.js +6 -4
  31. package/dist/transports/transport-registry.js.map +1 -1
  32. package/package.json +4 -4
  33. package/src/bundle/module-artifact.ts +54 -12
  34. package/src/controller-loader.ts +7 -4
  35. package/src/controller-loaders/bundle-loader.ts +9 -4
  36. package/src/controller-loaders/npm-loader.ts +75 -13
  37. package/src/index.ts +2 -1
  38. package/src/kernel.ts +12 -17
  39. package/src/static-analysis-diagnostics.ts +51 -0
  40. package/src/transports/oci/oci-transport.ts +34 -8
  41. package/src/transports/transport-registry.ts +6 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telorun/kernel",
3
- "version": "0.61.0",
3
+ "version": "0.62.0",
4
4
  "description": "Telo Runtime - A lightweight, polyglot execution host.",
5
5
  "keywords": [
6
6
  "telo",
@@ -61,9 +61,9 @@
61
61
  "dependencies": {
62
62
  "@marcbachmann/cel-js": "^7.6.1",
63
63
  "@sinclair/typebox": "^0.34.48",
64
- "@telorun/analyzer": "0.49.0",
64
+ "@telorun/analyzer": "0.49.1",
65
65
  "@telorun/glob": "0.2.0",
66
- "@telorun/templating": "0.11.0",
66
+ "@telorun/templating": "0.11.1",
67
67
  "ajv": "^8.17.1",
68
68
  "ajv-formats": "^3.0.1",
69
69
  "packageurl-js": "^2.0.1",
@@ -75,7 +75,7 @@
75
75
  "@types/tar-stream": "^3.1.3",
76
76
  "typescript": "^5.0.0",
77
77
  "vitest": "^2.1.8",
78
- "@telorun/sdk": "0.61.0"
78
+ "@telorun/sdk": "0.62.0"
79
79
  },
80
80
  "optionalDependencies": {
81
81
  "esbuild": "^0.28.1"
@@ -26,6 +26,17 @@ export interface MaterializedLayer {
26
26
  files: string[];
27
27
  }
28
28
 
29
+ /** A materialized controller layer plus whether reaching it cost a transfer.
30
+ *
31
+ * `transferred` is out of band rather than a field on `MaterializedLayer`
32
+ * because it describes THIS call, not the layer: the value is memoized and
33
+ * shared, so a flag on it would be meaningless to every other caller. Only the
34
+ * controller path reports progress, so only it asks. */
35
+ export interface ResolvedControllerLayer {
36
+ layer: MaterializedLayer;
37
+ transferred: boolean;
38
+ }
39
+
29
40
  /**
30
41
  * Map Node's platform vocabulary onto the canonical OCI/GOOS names selectors are
31
42
  * published with. Node says `win32`/`x64`; OCI descriptors say
@@ -123,8 +134,11 @@ export class ModuleArtifact {
123
134
  private readonly transports: TransportRegistry;
124
135
  private readonly log: Logger;
125
136
  /** In-flight / completed materializations, keyed by blob digest. Rejections
126
- * are dropped so a transient fetch failure retries on the next ask. */
127
- private readonly inFlight = new Map<string, Promise<MaterializedLayer>>();
137
+ * are dropped so a transient fetch failure retries on the next ask. Each
138
+ * records whether the work it wraps transferred the layer or read it off
139
+ * disk — the memoized entry is shared, so a joining caller reads the flag
140
+ * but never claims it. */
141
+ private readonly inFlight = new Map<string, Promise<ResolvedControllerLayer>>();
128
142
 
129
143
  constructor(opts: {
130
144
  pinnedRef: string;
@@ -165,14 +179,23 @@ export class ModuleArtifact {
165
179
  * Returns `undefined` when the artifact ships no layer for this selector, which
166
180
  * is how a loader learns to fall through to the next candidate.
167
181
  */
168
- async materializeController(selector: ArtifactSelector): Promise<MaterializedLayer | undefined> {
182
+ async materializeController(
183
+ selector: ArtifactSelector,
184
+ ): Promise<ResolvedControllerLayer | undefined> {
169
185
  const key = selectorKey(selector);
170
186
  const layer = this.layers.find(
171
187
  (l) => l.role === "controller" && l.selector !== undefined && selectorKey(l.selector) === key,
172
188
  );
173
189
  if (!layer) return undefined;
174
- await this.materializeCommon();
175
- return this.materialize(layer);
190
+ const common = await this.materializeCommonTracked();
191
+ const controller = await this.materializeTracked(layer);
192
+ // Either half is a transfer the caller waited on: the common layer's bytes
193
+ // come down on this call too, so they are as much of a wait as the
194
+ // controller layer's.
195
+ return {
196
+ layer: controller.layer,
197
+ transferred: controller.transferred || (common?.transferred ?? false),
198
+ };
176
199
  }
177
200
 
178
201
  /**
@@ -197,8 +220,15 @@ export class ModuleArtifact {
197
220
 
198
221
  /** Materialize the `common` layer, if the module ships one. */
199
222
  async materializeCommon(): Promise<MaterializedLayer | undefined> {
223
+ return (await this.materializeCommonTracked())?.layer;
224
+ }
225
+
226
+ /** As `materializeCommon`, reporting whether this call transferred it — the
227
+ * controller path rides the common layer along and has to count its bytes as
228
+ * part of the wait. One lookup, so "common comes too" is encoded once. */
229
+ private async materializeCommonTracked(): Promise<ResolvedControllerLayer | undefined> {
200
230
  const layer = singletonLayer(this.layers, "common");
201
- return layer ? this.materialize(layer) : undefined;
231
+ return layer ? this.materializeTracked(layer) : undefined;
202
232
  }
203
233
 
204
234
  /**
@@ -226,9 +256,21 @@ export class ModuleArtifact {
226
256
  .join(", ");
227
257
  }
228
258
 
229
- private materialize(layer: ArtifactLayer): Promise<MaterializedLayer> {
259
+ private async materialize(layer: ArtifactLayer): Promise<MaterializedLayer> {
260
+ return (await this.materializeTracked(layer)).layer;
261
+ }
262
+
263
+ /**
264
+ * As `materialize`, but also reporting whether THIS call transferred the layer.
265
+ *
266
+ * A caller that joins work already in flight reports `false`: several
267
+ * controller candidates of one module share a layer, and attributing the
268
+ * transfer to all of them would print one progress line per candidate for a
269
+ * single download. The call that started the work owns the report.
270
+ */
271
+ private materializeTracked(layer: ArtifactLayer): Promise<ResolvedControllerLayer> {
230
272
  const pending = this.inFlight.get(layer.blob);
231
- if (pending) return pending;
273
+ if (pending) return pending.then((r) => ({ layer: r.layer, transferred: false }));
232
274
  const work = this.materializeUncached(layer).catch((err) => {
233
275
  // Drop the rejection so a transient fetch failure is retried rather than
234
276
  // cached for the lifetime of the module.
@@ -247,10 +289,10 @@ export class ModuleArtifact {
247
289
  return path.join(this.dir, `.telo-layer-${layer.role}-${short}`);
248
290
  }
249
291
 
250
- private async materializeUncached(layer: ArtifactLayer): Promise<MaterializedLayer> {
292
+ private async materializeUncached(layer: ArtifactLayer): Promise<ResolvedControllerLayer> {
251
293
  const marker = this.markerPath(layer);
252
294
  if (existsSync(marker)) {
253
- return { dir: this.dir, files: await readMarker(marker) };
295
+ return { layer: { dir: this.dir, files: await readMarker(marker) }, transferred: false };
254
296
  }
255
297
 
256
298
  return withDirectoryLock(
@@ -260,7 +302,7 @@ export class ModuleArtifact {
260
302
  // Re-check inside the lock: a peer may have extracted this layer between
261
303
  // the fast-path miss and our acquisition.
262
304
  if (existsSync(marker)) {
263
- return { dir: this.dir, files: await readMarker(marker) };
305
+ return { layer: { dir: this.dir, files: await readMarker(marker) }, transferred: false };
264
306
  }
265
307
 
266
308
  const files = await this.transports.fetchLayer(this.pinnedRef, layer.blob);
@@ -283,7 +325,7 @@ export class ModuleArtifact {
283
325
  "telo.layer.role": layer.role,
284
326
  "telo.layer.files": written.length,
285
327
  });
286
- return { dir: this.dir, files: written };
328
+ return { layer: { dir: this.dir, files: written }, transferred: true };
287
329
  },
288
330
  this.log,
289
331
  );
@@ -8,10 +8,13 @@ import { ControllerPolicy, DEFAULT_POLICY, POLICY_WILDCARD } from "./runtime-reg
8
8
  export type { ControllerPolicy } from "./runtime-registry.js";
9
9
 
10
10
  /**
11
- * Which branch the per-scheme loader actually took. Cache/local hits resolve
12
- * in milliseconds; `npm-install` and `cargo-build` are the only branches that
13
- * do real (network or compile) work. The CLI uses this to decide whether a
14
- * "downloading…" line was honest or should be erased.
11
+ * Which branch the per-scheme loader actually took. Cache/local hits resolve in
12
+ * milliseconds; `npm-install`, `cargo-build` and `bundle` are the branches that
13
+ * do real (network or compile) work `bundle` is reported only when the resolve
14
+ * fetched the module's controller layer, never when it found it already
15
+ * extracted. The CLI uses this to decide whether a "downloading…" line was
16
+ * honest or should be erased, so a source that names work no one waited for
17
+ * turns every warm start into noise.
15
18
  */
16
19
  export type ControllerResolveSource =
17
20
  | "local"
@@ -220,17 +220,22 @@ export class BundleControllerLoader {
220
220
  // fetched here: the artifact handle owns the pinned ref and the verified
221
221
  // layer index, so an `oci://` module ref never reaches this loader as a path.
222
222
  let bundleDir: string;
223
+ // What this resolve actually cost. A module already on disk is `local`; an
224
+ // artifact layer found extracted is `cache`; only a layer this call pulled
225
+ // down reports `bundle`, the branch that made the user wait.
226
+ let source: ControllerResolveSource = "local";
223
227
  if (artifact) {
224
228
  // By its own selector, not by re-matching the host: this candidate IS one
225
229
  // selector, and it is exactly the key of the layer that carries it.
226
- const layer = await artifact.materializeController(selector);
227
- if (!layer) {
230
+ const resolved = await artifact.materializeController(selector);
231
+ if (!resolved) {
228
232
  throw new ControllerEnvMissingError(
229
233
  `pkg:telo controller "${purl}": the module artifact ships no layer for ` +
230
234
  `${describeSelector(selector)} (has: ${artifact.describeLayers()})`,
231
235
  );
232
236
  }
233
- bundleDir = layer.dir;
237
+ bundleDir = resolved.layer.dir;
238
+ source = resolved.transferred ? "bundle" : "cache";
234
239
  } else {
235
240
  // No artifact: a module already on disk (local development, or a manifest
236
241
  // served from the on-disk cache). Its files sit next to the manifest.
@@ -258,7 +263,7 @@ export class BundleControllerLoader {
258
263
 
259
264
  const fragment = parsed.subpath;
260
265
  return {
261
- source: "bundle",
266
+ source,
262
267
  importInstance: async () => {
263
268
  // A broken bundle (syntax / failed import) is a real user-code failure —
264
269
  // let it propagate rather than masking it as env-missing.
@@ -149,7 +149,7 @@ export class NpmControllerLoader {
149
149
  * same spec share one `npm install <spec>` invocation rather than each
150
150
  * acquiring the fs-lock and reinstalling.
151
151
  */
152
- private readonly inFlight = new Map<string, Promise<void>>();
152
+ private readonly inFlight = new Map<string, Promise<boolean>>();
153
153
 
154
154
  /**
155
155
  * Promise that resolves when the install root has been materialized
@@ -264,15 +264,20 @@ export class NpmControllerLoader {
264
264
  dependencies[name] = `file:${resolvedPkgRoot}`;
265
265
  }
266
266
 
267
- const packageJson = {
268
- name: "telo-runtime-install",
269
- private: true,
270
- version: "0.0.0",
271
- dependencies,
272
- };
273
267
  const packageJsonPath = path.join(installRoot, "package.json");
274
268
  const stateFile = path.join(installRoot, ".telo-state.json");
275
- const newHash = sha256(JSON.stringify(packageJson));
269
+ // Keyed on the realm deps alone — the only part this function owns. The
270
+ // controller aliases below join the file but never the identity: they are
271
+ // added one `--save` at a time, so hashing them would make every controller
272
+ // install invalidate the root and trigger another root install.
273
+ const newHash = sha256(
274
+ JSON.stringify({
275
+ name: "telo-runtime-install",
276
+ private: true,
277
+ version: "0.0.0",
278
+ dependencies,
279
+ }),
280
+ );
276
281
 
277
282
  await fs.mkdir(installRoot, { recursive: true });
278
283
 
@@ -301,7 +306,25 @@ export class NpmControllerLoader {
301
306
  return;
302
307
  }
303
308
 
304
- await fs.writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2) + "\n");
309
+ // Rewrite the root's package.json REALM DEPS ONLY, keeping every
310
+ // controller alias a previous `installPackage --save` recorded. Writing a
311
+ // fresh file instead drops them, and the install below then prunes their
312
+ // `node_modules` folders — so a realm change (a different kernel checkout,
313
+ // e.g. a globally installed CLI and a repo one addressing the same app)
314
+ // evicted every controller and reinstalled the lot, on every switch.
315
+ const existingDeps = (await readPackageDeps(installRoot)) ?? {};
316
+ const merged: Record<string, string> = {
317
+ ...(await liveControllerDeps(existingDeps, installRoot)),
318
+ ...dependencies,
319
+ };
320
+ await fs.writeFile(
321
+ packageJsonPath,
322
+ JSON.stringify(
323
+ { name: "telo-runtime-install", private: true, version: "0.0.0", dependencies: merged },
324
+ null,
325
+ 2,
326
+ ) + "\n",
327
+ );
305
328
  await runPackageManager(installRoot, [
306
329
  "install",
307
330
  "--no-audit",
@@ -414,8 +437,11 @@ export class NpmControllerLoader {
414
437
  }
415
438
  }
416
439
 
440
+ // Resolves to whether the package manager actually ran: the re-check below
441
+ // routinely wins the race, and reporting an install that never happened
442
+ // makes the CLI print a "downloading…" line nobody waited for.
417
443
  const work = (async () => {
418
- await withDirectoryLock(installRoot, "controller install", async () => {
444
+ return withDirectoryLock(installRoot, "controller install", async () => {
419
445
  // Re-check inside the lock: a peer process may have installed the
420
446
  // spec between the fast-path miss and our acquisition.
421
447
  if (kind === "registry") {
@@ -424,7 +450,7 @@ export class NpmControllerLoader {
424
450
  installedVersion !== null &&
425
451
  (requestedVersion === null || requestedVersion === installedVersion)
426
452
  ) {
427
- return;
453
+ return false;
428
454
  }
429
455
  } else {
430
456
  // Normalize the on-disk record to absolute form before comparing —
@@ -436,7 +462,7 @@ export class NpmControllerLoader {
436
462
  (await pathExists(targetPath))
437
463
  ) {
438
464
  this.rootDeps[alias] = lockedSpec;
439
- return;
465
+ return false;
440
466
  }
441
467
  }
442
468
 
@@ -458,16 +484,21 @@ export class NpmControllerLoader {
458
484
  const written = await readDepSpec(installRoot, alias);
459
485
  if (written !== undefined) this.rootDeps[alias] = written;
460
486
  else this.rootDeps[alias] = spec;
487
+ return true;
461
488
  }, this.log);
462
489
  })();
463
490
 
464
491
  this.inFlight.set(cacheKey, work);
492
+ let installed: boolean;
465
493
  try {
466
- await work;
494
+ installed = await work;
467
495
  } finally {
468
496
  this.inFlight.delete(cacheKey);
469
497
  }
470
498
  this.installedSpecs.add(cacheKey);
499
+ // A peer that won the lock had already put the package there, so nothing
500
+ // was installed — that is a cache hit, whatever the fast path thought.
501
+ if (!installed) return "cache";
471
502
  // First-touch local installs report `local` (silenced in CLI progress);
472
503
  // fresh registry installs report `npm-install` (the only branch a
473
504
  // user-facing "downloading…" line should ever surface for).
@@ -693,6 +724,37 @@ function normalizeFileSpec(spec: string, installRoot: string): string {
693
724
  * Returns null when the file is missing or unreadable; callers decide whether
694
725
  * to seed from a synthesized map instead.
695
726
  */
727
+ /**
728
+ * The subset of a root's recorded dependencies still worth carrying into a
729
+ * rewrite: the package manager has to resolve every entry, so one dead record
730
+ * fails the whole install.
731
+ *
732
+ * Dropped: a `file:` spec whose target no longer exists (the app was run from a
733
+ * checkout that has since moved, or the app directory was copied without it),
734
+ * and an alias with no folder in `node_modules`. Both are free to drop —
735
+ * `installPackage` reinstalls a controller the moment something asks for it —
736
+ * and keeping them is not: a dangling `file:` dep makes the root install fail
737
+ * with an ENOENT that names nothing in the manifest, and since the state file is
738
+ * only written after a successful install, every later run repeats it. This is
739
+ * also what stops aliases accumulating forever now that the rewrite preserves
740
+ * them: a version an app has moved off drops out on the next root install.
741
+ */
742
+ async function liveControllerDeps(
743
+ deps: Record<string, string>,
744
+ installRoot: string,
745
+ ): Promise<Record<string, string>> {
746
+ const live: Record<string, string> = {};
747
+ for (const [name, spec] of Object.entries(deps)) {
748
+ if (spec.startsWith("file:")) {
749
+ const target = normalizeFileSpec(spec, installRoot).slice("file:".length);
750
+ if (!(await pathExists(target))) continue;
751
+ }
752
+ if (!(await pathExists(path.join(installRoot, "node_modules", name)))) continue;
753
+ live[name] = spec;
754
+ }
755
+ return live;
756
+ }
757
+
696
758
  async function readPackageDeps(installRoot: string): Promise<Record<string, string> | null> {
697
759
  try {
698
760
  const text = await fs.readFile(path.join(installRoot, "package.json"), "utf8");
package/src/index.ts CHANGED
@@ -32,6 +32,7 @@ export {
32
32
  moduleDirectoryFor,
33
33
  hostPlatformTarget,
34
34
  type MaterializedLayer,
35
+ type ResolvedControllerLayer,
35
36
  } from "./bundle/module-artifact.js";
36
37
  export { readOwnerManifest, type OwnerManifest } from "./bundle/module-manifest.js";
37
38
  export type {
@@ -46,7 +47,7 @@ export { nodeCelHandlers } from "./cel-handlers.js";
46
47
  export { ModuleContext } from "./module-context.js";
47
48
  export { ManifestRegistry as Registry } from "./registry.js";
48
49
  export { ResourceURI } from "./resource-uri.js";
49
- export type { RuntimeDiagnostic } from "@telorun/sdk";
50
+ export type { DiagnosticOrigin, RuntimeDiagnostic } from "@telorun/sdk";
50
51
  export { describeBlockedGroup, groupBlockedResources } from "./init-failure-diagnostics.js";
51
52
 
52
53
  // Structured logging — the runtime half of kernel/specs/logging.md. The record
package/src/kernel.ts CHANGED
@@ -74,6 +74,7 @@ import {
74
74
  } from "./application-env.js";
75
75
  import { policyFingerprint } from "./runtime-registry.js";
76
76
  import { SchemaValidator } from "./schema-validator.js";
77
+ import { staticDiagnosticToRuntime } from "./static-analysis-diagnostics.js";
77
78
 
78
79
  /** Walks up the EvaluationContext parent chain to the nearest enclosing
79
80
  * ModuleContext and returns its controller policy (or undefined). Used to
@@ -435,20 +436,27 @@ export class Kernel implements IKernel {
435
436
  if (analysisGraph.errors.length > 0) {
436
437
  throw analysisGraph.errors[0].error;
437
438
  }
439
+ // Recorded before the first throw below: the graph is what a renderer
440
+ // resolves a static failure's `origin` against, and a parse error is
441
+ // exactly the failure that most needs to name a line.
442
+ this._loadedGraph = analysisGraph;
438
443
  // A YAML parse failure yields a mangled manifest tree — fatal before any
439
444
  // controller sees it, and more fundamental than a version conflict.
440
445
  if (analysisGraph.parseDiagnostics.length > 0) {
441
446
  throw new RuntimeError(
442
447
  "ERR_MANIFEST_VALIDATION_FAILED",
448
+ // The message keeps the whole failure for a consumer that only reads
449
+ // `error.message`; the diagnostics carry the same set structured, so a
450
+ // renderer can locate each one instead of re-parsing this text.
443
451
  analysisGraph.parseDiagnostics
444
452
  .map((d) => {
445
453
  const filePath = (d.data as { filePath?: string } | undefined)?.filePath;
446
454
  return filePath ? `${filePath}: ${d.message}` : d.message;
447
455
  })
448
456
  .join("\n"),
457
+ analysisGraph.parseDiagnostics.map(staticDiagnosticToRuntime),
449
458
  );
450
459
  }
451
- this._loadedGraph = analysisGraph;
452
460
  this.buildModuleArtifacts(analysisGraph, manifestsDir);
453
461
  // Version reconciliation: an incompatible major mismatch is fatal (the
454
462
  // hoist override would silently run the wrong major); a same-major hoist is
@@ -460,6 +468,7 @@ export class Kernel implements IKernel {
460
468
  throw new RuntimeError(
461
469
  "ERR_MANIFEST_VALIDATION_FAILED",
462
470
  versionConflicts.map((d) => d.message).join("\n"),
471
+ versionConflicts.map(staticDiagnosticToRuntime),
463
472
  );
464
473
  }
465
474
  for (const d of analysisGraph.versionDiagnostics) {
@@ -512,14 +521,7 @@ export class Kernel implements IKernel {
512
521
  throw new RuntimeError(
513
522
  "ERR_MANIFEST_VALIDATION_FAILED",
514
523
  "Manifest validation failed",
515
- errors.map((d) => ({
516
- severity: "error" as const,
517
- message: d.message,
518
- code: d.code !== undefined ? String(d.code) : undefined,
519
- resource: (d.data as any)?.resource
520
- ? `${(d.data as any).resource.kind}.${(d.data as any).resource.name}`
521
- : undefined,
522
- })),
524
+ errors.map(staticDiagnosticToRuntime),
523
525
  );
524
526
  }
525
527
  if (manifestsDir && writeCache && !skipValidation) {
@@ -764,14 +766,7 @@ export class Kernel implements IKernel {
764
766
  throw new RuntimeError(
765
767
  "ERR_MANIFEST_VALIDATION_FAILED",
766
768
  "Manifest validation failed",
767
- refErrors.map((d) => ({
768
- severity: "error" as const,
769
- message: d.message,
770
- code: d.code !== undefined ? String(d.code) : undefined,
771
- resource: (d.data as any)?.resource
772
- ? `${(d.data as any).resource.kind}.${(d.data as any).resource.name}`
773
- : undefined,
774
- })),
769
+ refErrors.map(staticDiagnosticToRuntime),
775
770
  );
776
771
  }
777
772
  if (cycleError) {
@@ -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);