@telorun/kernel 0.78.0 → 0.80.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 (106) hide show
  1. package/dist/bundle/module-artifact.d.ts +15 -2
  2. package/dist/bundle/module-artifact.d.ts.map +1 -1
  3. package/dist/bundle/module-artifact.js +36 -13
  4. package/dist/bundle/module-artifact.js.map +1 -1
  5. package/dist/controller-loader.d.ts +62 -9
  6. package/dist/controller-loader.d.ts.map +1 -1
  7. package/dist/controller-loader.js +72 -64
  8. package/dist/controller-loader.js.map +1 -1
  9. package/dist/controller-loaders/bundle-loader.d.ts +2 -2
  10. package/dist/controller-loaders/bundle-loader.d.ts.map +1 -1
  11. package/dist/controller-loaders/bundle-loader.js +9 -3
  12. package/dist/controller-loaders/bundle-loader.js.map +1 -1
  13. package/dist/controller-loaders/napi-loader.d.ts +22 -1
  14. package/dist/controller-loaders/napi-loader.d.ts.map +1 -1
  15. package/dist/controller-loaders/napi-loader.js +35 -5
  16. package/dist/controller-loaders/napi-loader.js.map +1 -1
  17. package/dist/controller-loaders/npm-loader.d.ts +2 -1
  18. package/dist/controller-loaders/npm-loader.d.ts.map +1 -1
  19. package/dist/controller-loaders/npm-loader.js +81 -11
  20. package/dist/controller-loaders/npm-loader.js.map +1 -1
  21. package/dist/controller-loaders/source-bundle-builder.d.ts +2 -1
  22. package/dist/controller-loaders/source-bundle-builder.d.ts.map +1 -1
  23. package/dist/controller-loaders/source-bundle-builder.js +4 -1
  24. package/dist/controller-loaders/source-bundle-builder.js.map +1 -1
  25. package/dist/controllers/resource-definition/resource-definition-controller.d.ts.map +1 -1
  26. package/dist/controllers/resource-definition/resource-definition-controller.js +7 -3
  27. package/dist/controllers/resource-definition/resource-definition-controller.js.map +1 -1
  28. package/dist/controllers/resource-definition/resource-template-controller.d.ts.map +1 -1
  29. package/dist/controllers/resource-definition/resource-template-controller.js +12 -5
  30. package/dist/controllers/resource-definition/resource-template-controller.js.map +1 -1
  31. package/dist/dependency-injection.d.ts.map +1 -1
  32. package/dist/dependency-injection.js +7 -7
  33. package/dist/dependency-injection.js.map +1 -1
  34. package/dist/evaluation-context.d.ts +33 -13
  35. package/dist/evaluation-context.d.ts.map +1 -1
  36. package/dist/evaluation-context.js +145 -4
  37. package/dist/evaluation-context.js.map +1 -1
  38. package/dist/index.d.ts +2 -0
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +2 -0
  41. package/dist/index.js.map +1 -1
  42. package/dist/instance-declaration.d.ts +8 -0
  43. package/dist/instance-declaration.d.ts.map +1 -0
  44. package/dist/instance-declaration.js +34 -0
  45. package/dist/instance-declaration.js.map +1 -0
  46. package/dist/invocation-contract-binding.d.ts +15 -2
  47. package/dist/invocation-contract-binding.d.ts.map +1 -1
  48. package/dist/invocation-contract-binding.js +39 -4
  49. package/dist/invocation-contract-binding.js.map +1 -1
  50. package/dist/kernel.d.ts.map +1 -1
  51. package/dist/kernel.js +78 -14
  52. package/dist/kernel.js.map +1 -1
  53. package/dist/manifest-schemas.d.ts +1 -1
  54. package/dist/manifest-schemas.d.ts.map +1 -1
  55. package/dist/manifest-schemas.js +1 -1
  56. package/dist/manifest-schemas.js.map +1 -1
  57. package/dist/manifest-sources/analysis-stamp.d.ts +12 -8
  58. package/dist/manifest-sources/analysis-stamp.d.ts.map +1 -1
  59. package/dist/manifest-sources/analysis-stamp.js +50 -23
  60. package/dist/manifest-sources/analysis-stamp.js.map +1 -1
  61. package/dist/manifest-sources/local-manifest-cache-source.d.ts +45 -9
  62. package/dist/manifest-sources/local-manifest-cache-source.d.ts.map +1 -1
  63. package/dist/manifest-sources/local-manifest-cache-source.js +65 -12
  64. package/dist/manifest-sources/local-manifest-cache-source.js.map +1 -1
  65. package/dist/module-context.d.ts +5 -1
  66. package/dist/module-context.d.ts.map +1 -1
  67. package/dist/module-context.js +13 -1
  68. package/dist/module-context.js.map +1 -1
  69. package/dist/resource-context.d.ts +12 -1
  70. package/dist/resource-context.d.ts.map +1 -1
  71. package/dist/resource-context.js +15 -0
  72. package/dist/resource-context.js.map +1 -1
  73. package/dist/runtime-seam.d.ts.map +1 -1
  74. package/dist/runtime-seam.js +9 -1
  75. package/dist/runtime-seam.js.map +1 -1
  76. package/dist/workspace-marker.d.ts +37 -0
  77. package/dist/workspace-marker.d.ts.map +1 -0
  78. package/dist/workspace-marker.js +68 -0
  79. package/dist/workspace-marker.js.map +1 -0
  80. package/dist/zone-context.d.ts +16 -1
  81. package/dist/zone-context.d.ts.map +1 -1
  82. package/dist/zone-context.js +61 -1
  83. package/dist/zone-context.js.map +1 -1
  84. package/package.json +3 -3
  85. package/src/bundle/module-artifact.ts +47 -15
  86. package/src/controller-loader.ts +115 -81
  87. package/src/controller-loaders/bundle-loader.ts +10 -2
  88. package/src/controller-loaders/napi-loader.ts +42 -3
  89. package/src/controller-loaders/npm-loader.ts +86 -8
  90. package/src/controller-loaders/source-bundle-builder.ts +6 -0
  91. package/src/controllers/resource-definition/resource-definition-controller.ts +7 -3
  92. package/src/controllers/resource-definition/resource-template-controller.ts +12 -5
  93. package/src/dependency-injection.ts +7 -7
  94. package/src/evaluation-context.ts +195 -15
  95. package/src/index.ts +7 -0
  96. package/src/instance-declaration.ts +36 -0
  97. package/src/invocation-contract-binding.ts +49 -1
  98. package/src/kernel.ts +95 -14
  99. package/src/manifest-schemas.ts +1 -0
  100. package/src/manifest-sources/analysis-stamp.ts +53 -25
  101. package/src/manifest-sources/local-manifest-cache-source.ts +68 -13
  102. package/src/module-context.ts +24 -3
  103. package/src/resource-context.ts +18 -0
  104. package/src/runtime-seam.ts +9 -1
  105. package/src/workspace-marker.ts +68 -0
  106. package/src/zone-context.ts +70 -1
@@ -39,10 +39,44 @@ export interface ResolvedController {
39
39
  purl: string;
40
40
  source: ControllerResolveSource;
41
41
  importInstance: () => Promise<ControllerInstance>;
42
+ /**
43
+ * How long the caller has waited for this controller, measured from the
44
+ * first {@link ControllerWorkKind} branch it entered — or, when it entered
45
+ * none, from the `importInstance()` call. Resolution is what installs,
46
+ * compiles and fetches, so timing only the import reports a 40-second
47
+ * install as a few milliseconds.
48
+ */
49
+ waitedMs: () => number;
42
50
  }
43
51
 
52
+ /**
53
+ * A branch that makes the caller wait: a registry install, a compile, or a
54
+ * layer transfer. Reported by the sub-loader that is about to enter it, which
55
+ * is the only place that knows — a resolve's `source` is a verdict available
56
+ * only afterwards, and a warm start enters no branch at all, so nothing is
57
+ * announced for it. Reported for work in the resolve *and* the import phase:
58
+ * a dev-mode `pkg:telo` build and a `cargo build` are both paid inside
59
+ * `importInstance`.
60
+ */
61
+ export type ControllerWorkKind = "npm-install" | "cargo-build" | "source-build" | "layer-fetch";
62
+
63
+ /**
64
+ * Sub-loader hook, invoked immediately before entering a {@link
65
+ * ControllerWorkKind} branch. Awaited, so a consumer that renders progress has
66
+ * its line on screen before the wait starts rather than after it.
67
+ */
68
+ export type ControllerWorkReporter = (work: ControllerWorkKind) => void | Promise<void>;
69
+
44
70
  export type ControllerLoaderEvent =
45
- | { name: "ControllerLoading"; payload: { purl: string } }
71
+ /**
72
+ * A resolved controller is about to be imported. `source` names the branch
73
+ * the resolve took, so a consumer can tell a warm start (`cache` / `local`)
74
+ * from work someone waited for (`npm-install`, `cargo-build`, `bundle`)
75
+ * *before* rendering anything. The event is emitted after resolution
76
+ * precisely so this is known: resolution is what fetches, installs and
77
+ * builds, and an event emitted ahead of it could only speculate.
78
+ */
79
+ | { name: "ControllerLoading"; payload: { purl: string; source: ControllerResolveSource } }
46
80
  | {
47
81
  name: "ControllerLoaded";
48
82
  payload: { purl: string; source: ControllerResolveSource; durationMs: number };
@@ -55,7 +89,15 @@ export type ControllerLoaderEvent =
55
89
  * which is non-recoverable. Consumers that opened a UI element on the
56
90
  * matching `ControllerLoading` should close it out here.
57
91
  */
58
- | { name: "ControllerLoadSkipped"; payload: { purl: string; reason: string } };
92
+ | { name: "ControllerLoadSkipped"; payload: { purl: string; reason: string } }
93
+ /**
94
+ * Real work has started for `purl` — an install, a compile or a transfer is
95
+ * about to run. This is the *only* in-progress signal, and it fires solely
96
+ * on the branch that does the work, so a warm start emits nothing rather
97
+ * than something a consumer has to take back. Closed by the matching
98
+ * `ControllerLoaded` / `ControllerLoadFailed`.
99
+ */
100
+ | { name: "ControllerWorkStarted"; payload: { purl: string; work: ControllerWorkKind } };
59
101
 
60
102
  /**
61
103
  * The dispatcher awaits each emission, so the callback may be async without
@@ -104,9 +146,13 @@ export interface ControllerLoaderOptions {
104
146
  * next candidate. User-code failures (`RuntimeError("ERR_CONTROLLER_BUILD_FAILED" | "ERR_CONTROLLER_INVALID")`)
105
147
  * fail hard regardless of remaining candidates.
106
148
  *
107
- * Lifecycle events are emitted per *attempt*, so a fallback chain produces one
108
- * `ControllerLoading` per candidate tried plus a final `ControllerLoaded` (or
109
- * `ControllerLoadFailed`) for the one that won.
149
+ * Lifecycle events follow resolution: a candidate this environment cannot host
150
+ * produces a `ControllerLoadSkipped` and nothing else, and only the candidate
151
+ * that resolved gets a `ControllerLoading` (carrying its resolve `source`)
152
+ * followed by `ControllerLoaded` / `ControllerLoadFailed`. In between, the
153
+ * sub-loader announces each branch that makes the caller wait as
154
+ * `ControllerWorkStarted` — the only in-progress signal, and the only one that
155
+ * cannot be emitted for a warm start.
110
156
  */
111
157
  export class ControllerLoader {
112
158
  private readonly emit: ControllerLoaderEmit | undefined;
@@ -121,7 +167,7 @@ export class ControllerLoader {
121
167
  installRoot: options.installRoot,
122
168
  });
123
169
  if (options.log) this.npmLoader.setLogger(options.log);
124
- this.napiLoader = new NapiControllerLoader();
170
+ this.napiLoader = new NapiControllerLoader(options.cacheRoot);
125
171
  this.bundleLoader = new BundleControllerLoader(options.cacheRoot, options.log);
126
172
  }
127
173
 
@@ -132,56 +178,28 @@ export class ControllerLoader {
132
178
  artifact?: ModuleArtifact,
133
179
  libraries: SiblingLibraryMap = NO_SIBLING_LIBRARIES,
134
180
  ): Promise<ControllerInstance> {
135
- if (!purlCandidates || purlCandidates.length === 0) {
136
- throw new RuntimeError("ERR_CONTROLLER_NOT_FOUND", "Missing controller PURL candidates");
137
- }
138
- const effectivePolicy = policy ?? DEFAULT_POLICY;
139
- const ordered = orderCandidates(purlCandidates, effectivePolicy);
140
- if (ordered.length === 0) {
141
- throw new RuntimeError(
142
- "ERR_CONTROLLER_NOT_FOUND",
143
- `No controllers match runtime selection [${effectivePolicy.load.join(", ")}]; declared: ${purlCandidates.join(", ")}`,
144
- );
145
- }
146
-
147
- const errors: string[] = [];
148
- for (const purl of ordered) {
149
- await this.emit?.({ name: "ControllerLoading", payload: { purl } });
150
- const startedAt = Date.now();
151
- try {
152
- const { instance, source } = await this.dispatchOne(purl, baseUri, artifact, libraries);
153
- await this.emit?.({
154
- name: "ControllerLoaded",
155
- payload: { purl, source, durationMs: Date.now() - startedAt },
156
- });
157
- return instance;
158
- } catch (err) {
159
- if (err instanceof ControllerEnvMissingError) {
160
- errors.push(`${purl}: ${err.message}`);
161
- // Env-missing isn't a hard failure — the dispatcher will try the
162
- // next candidate. We still emit a terminal event for *this* attempt
163
- // so consumers (notably the CLI progress renderer) can close out
164
- // the UI state opened by the matching ControllerLoading. Without
165
- // this, every fallback attempt would leak a pending `⬇` line.
166
- await this.emit?.({
167
- name: "ControllerLoadSkipped",
168
- payload: { purl, reason: err.message },
169
- });
170
- continue;
171
- }
172
- await this.emit?.({
173
- name: "ControllerLoadFailed",
174
- payload: { purl, error: err instanceof Error ? err.message : String(err) },
175
- });
176
- throw err;
177
- }
178
- }
179
- const aggregated = `No controller resolved. Tried ${ordered.length} candidate(s):\n${errors.join("\n")}`;
181
+ // Resolution which is what installs, compiles and fetches — reports its
182
+ // own work and its own candidate fallthrough, so this is the import half
183
+ // and the announcement of what resolution decided.
184
+ const resolved = await this.resolve(purlCandidates, baseUri, policy, artifact, libraries);
180
185
  await this.emit?.({
181
- name: "ControllerLoadFailed",
182
- payload: { purl: ordered[ordered.length - 1], error: aggregated },
186
+ name: "ControllerLoading",
187
+ payload: { purl: resolved.purl, source: resolved.source },
183
188
  });
184
- throw new RuntimeError("ERR_CONTROLLER_NOT_FOUND", aggregated);
189
+ try {
190
+ const instance = await resolved.importInstance();
191
+ await this.emit?.({
192
+ name: "ControllerLoaded",
193
+ payload: { purl: resolved.purl, source: resolved.source, durationMs: resolved.waitedMs() },
194
+ });
195
+ return instance;
196
+ } catch (err) {
197
+ await this.emit?.({
198
+ name: "ControllerLoadFailed",
199
+ payload: { purl: resolved.purl, error: err instanceof Error ? err.message : String(err) },
200
+ });
201
+ throw err;
202
+ }
185
203
  }
186
204
 
187
205
  /**
@@ -194,10 +212,12 @@ export class ControllerLoader {
194
212
  * registers fine and errors only when a resource of it is declared (matching
195
213
  * the Rust kernel's deferral).
196
214
  *
197
- * Silent by design no lifecycle events here; the caller emits
198
- * ControllerLoading/Loaded around `importInstance` so the events fire when the
199
- * load actually happens. A total resolution failure throws, mirroring
200
- * {@link load}'s aggregated error.
215
+ * Emits `ControllerWorkStarted` whenever a sub-loader enters a branch that
216
+ * makes the caller wait, and `ControllerLoadSkipped` per candidate this
217
+ * environment cannot host. It does NOT announce the load itself: the caller
218
+ * emits ControllerLoading/Loaded around `importInstance`, so those fire when
219
+ * the load actually happens, with the resolved `source` already in hand. A
220
+ * total resolution failure throws, mirroring {@link load}'s aggregated error.
201
221
  */
202
222
  async resolve(
203
223
  purlCandidates: string[],
@@ -219,41 +239,54 @@ export class ControllerLoader {
219
239
  }
220
240
  const errors: string[] = [];
221
241
  for (const purl of ordered) {
242
+ // First work only: an install followed by a compile is one wait, and the
243
+ // clock the caller reads has to start where that wait did. The reporter
244
+ // outlives resolution because the import phase does work too.
245
+ let workStartedAt: number | undefined;
246
+ const report: ControllerWorkReporter = async (work) => {
247
+ workStartedAt ??= Date.now();
248
+ await this.emit?.({ name: "ControllerWorkStarted", payload: { purl, work } });
249
+ };
222
250
  try {
223
251
  const { source, importInstance } = await this.dispatchResolveOne(
224
252
  purl,
225
253
  baseUri,
226
254
  artifact,
227
255
  libraries,
256
+ report,
228
257
  );
229
- return { purl, source, importInstance };
258
+ let importStartedAt: number | undefined;
259
+ return {
260
+ purl,
261
+ source,
262
+ importInstance: () => {
263
+ importStartedAt ??= Date.now();
264
+ return importInstance();
265
+ },
266
+ waitedMs: () => Date.now() - (workStartedAt ?? importStartedAt ?? Date.now()),
267
+ };
230
268
  } catch (err) {
231
269
  if (err instanceof ControllerEnvMissingError) {
232
270
  errors.push(`${purl}: ${err.message}`);
271
+ await this.emit?.({
272
+ name: "ControllerLoadSkipped",
273
+ payload: { purl, reason: err.message },
274
+ });
233
275
  continue;
234
276
  }
277
+ await this.emit?.({
278
+ name: "ControllerLoadFailed",
279
+ payload: { purl, error: err instanceof Error ? err.message : String(err) },
280
+ });
235
281
  throw err;
236
282
  }
237
283
  }
238
- throw new RuntimeError(
239
- "ERR_CONTROLLER_NOT_FOUND",
240
- `No controller resolved. Tried ${ordered.length} candidate(s):\n${errors.join("\n")}`,
241
- );
242
- }
243
-
244
- private async dispatchOne(
245
- purl: string,
246
- baseUri: string,
247
- artifact: ModuleArtifact | undefined,
248
- libraries: SiblingLibraryMap,
249
- ): Promise<{ instance: ControllerInstance; source: ControllerResolveSource }> {
250
- const { source, importInstance } = await this.dispatchResolveOne(
251
- purl,
252
- baseUri,
253
- artifact,
254
- libraries,
255
- );
256
- return { instance: await importInstance(), source };
284
+ const aggregated = `No controller resolved. Tried ${ordered.length} candidate(s):\n${errors.join("\n")}`;
285
+ await this.emit?.({
286
+ name: "ControllerLoadFailed",
287
+ payload: { purl: ordered[ordered.length - 1], error: aggregated },
288
+ });
289
+ throw new RuntimeError("ERR_CONTROLLER_NOT_FOUND", aggregated);
257
290
  }
258
291
 
259
292
  private async dispatchResolveOne(
@@ -261,15 +294,16 @@ export class ControllerLoader {
261
294
  baseUri: string,
262
295
  artifact: ModuleArtifact | undefined,
263
296
  libraries: SiblingLibraryMap,
297
+ report: ControllerWorkReporter,
264
298
  ): Promise<{ source: ControllerResolveSource; importInstance: () => Promise<ControllerInstance> }> {
265
299
  if (purl.startsWith("pkg:npm")) {
266
- return this.npmLoader.resolve(purl, baseUri);
300
+ return this.npmLoader.resolve(purl, baseUri, report);
267
301
  }
268
302
  if (purl.startsWith("pkg:cargo")) {
269
- return this.napiLoader.resolve(purl, baseUri);
303
+ return this.napiLoader.resolve(purl, baseUri, report);
270
304
  }
271
305
  if (purl.startsWith("pkg:telo")) {
272
- return this.bundleLoader.resolve(purl, baseUri, artifact, libraries);
306
+ return this.bundleLoader.resolve(purl, baseUri, artifact, libraries, report);
273
307
  }
274
308
  throw new ControllerEnvMissingError(`Unsupported PURL scheme: ${purl}`);
275
309
  }
@@ -7,7 +7,7 @@ import { PackageURL } from "packageurl-js";
7
7
  import * as path from "path";
8
8
  import { fileURLToPath, pathToFileURL } from "url";
9
9
  import { hostPlatformTarget, type ModuleArtifact } from "../bundle/module-artifact.js";
10
- import type { ControllerResolveSource } from "../controller-loader.js";
10
+ import type { ControllerResolveSource, ControllerWorkReporter } from "../controller-loader.js";
11
11
  import { ControllerEnvMissingError } from "./napi-loader.js";
12
12
  import { REALM_COLLAPSE_NAMES } from "./realm.js";
13
13
  import {
@@ -485,6 +485,7 @@ export class BundleControllerLoader {
485
485
  baseUri: string,
486
486
  artifact?: ModuleArtifact,
487
487
  libraries: SiblingLibraryMap = NO_SIBLING_LIBRARIES,
488
+ report?: ControllerWorkReporter,
488
489
  ): Promise<{ source: ControllerResolveSource; importInstance: () => Promise<ControllerInstance> }> {
489
490
  let parsed: PackageURL;
490
491
  try {
@@ -578,10 +579,17 @@ export class BundleControllerLoader {
578
579
  importInstance: async () => {
579
580
  let built: string;
580
581
  try {
582
+ // A dev build is a compile the caller waits on, and it is paid here
583
+ // rather than at resolve — which is why the work signal is not tied
584
+ // to the resolve phase. The reporter goes INTO the builder rather
585
+ // than wrapping the call: only the builder knows whether its
586
+ // content-addressed cache answered, and reporting a cache hit would
587
+ // put a line on screen for work nobody waited for.
581
588
  built = await buildControllerFromSource(
582
589
  sourceFile!,
583
590
  cacheRoot!,
584
591
  buildExternals(libraries, format),
592
+ report,
585
593
  );
586
594
  } catch (err) {
587
595
  if (!(err instanceof ControllerEnvMissingError)) throw err;
@@ -607,7 +615,7 @@ export class BundleControllerLoader {
607
615
  if (artifact) {
608
616
  // By its own selector, not by re-matching the host: this candidate IS one
609
617
  // selector, and it is exactly the key of the layer that carries it.
610
- const resolved = await artifact.materializeController(selector);
618
+ const resolved = await artifact.materializeController(selector, report);
611
619
  if (!resolved) {
612
620
  throw new ControllerEnvMissingError(
613
621
  `pkg:telo controller "${purl}": the module artifact ships no layer for ` +
@@ -8,6 +8,8 @@ import { fileURLToPath } from "url";
8
8
  import { promisify } from "util";
9
9
 
10
10
  import { hostEnv } from "../host-env.js";
11
+ // Type-only, so the cycle with the dispatcher that imports this loader is erased.
12
+ import type { ControllerWorkReporter } from "../controller-loader.js";
11
13
 
12
14
  const execFileAsync = promisify(execFile);
13
15
  const requireFromHere = createRequire(import.meta.url);
@@ -104,6 +106,34 @@ export interface NapiLoadResult {
104
106
  }
105
107
 
106
108
  export class NapiControllerLoader {
109
+ /**
110
+ * The `.telo` cache root this kernel resolved, or `undefined` when it resolved
111
+ * none (a memory-rooted entry), in which case cargo's own default is used.
112
+ *
113
+ * The build is anchored here for the same reason the Rust kernel's is: one
114
+ * shared dependency build per workspace instead of one per crate, and — the
115
+ * load-bearing half — a directory KEYED BY SDK BACKEND. This loader builds a
116
+ * controller crate with `telorun-sdk/napi` while `telo-rs` builds the very same
117
+ * crate with `native`, and a plain `cargo build --workspace` builds it with
118
+ * neither. All three sharing one target directory means each alternation
119
+ * rebuilds the crate and its dependency tree; before this they were kept apart
120
+ * only by the accident that this loader took cargo's default.
121
+ */
122
+ private readonly cacheRoot?: string;
123
+
124
+ constructor(cacheRoot?: string) {
125
+ this.cacheRoot = cacheRoot;
126
+ }
127
+
128
+ /** `CARGO_TARGET_DIR` for this loader's builds, or `undefined` to leave cargo's
129
+ * own resolution alone. Applied to `cargo metadata` as well as `cargo build`,
130
+ * since `target_directory` is where the dylib is then looked for — setting it
131
+ * on only one of the two would build in one place and search in another. */
132
+ private targetDirEnv(): Record<string, string | undefined> {
133
+ const env = hostEnv();
134
+ if (!this.cacheRoot) return env;
135
+ return { ...env, CARGO_TARGET_DIR: path.join(this.cacheRoot, "cargo", "napi", "target") };
136
+ }
107
137
  /**
108
138
  * Resolve a `pkg:cargo/...` PURL to a controller module instance by building
109
139
  * the crate and loading the resulting native addon.
@@ -140,6 +170,7 @@ export class NapiControllerLoader {
140
170
  async resolve(
141
171
  purl: string,
142
172
  baseUri: string,
173
+ report?: ControllerWorkReporter,
143
174
  ): Promise<{ source: NapiResolveSource; importInstance: () => Promise<ControllerInstance> }> {
144
175
  const [, , name, , qualifiers, entry] = PackageURL.parseString(purl);
145
176
  const localPath = (qualifiers as any)?.get("local_path");
@@ -199,6 +230,9 @@ export class NapiControllerLoader {
199
230
  const { rawModule } = await existingInFlight;
200
231
  return project(rawModule, entry, cratePath);
201
232
  }
233
+ // Nothing above this line compiles — the cache and the in-flight gate
234
+ // both return without cargo running.
235
+ await report?.("cargo-build");
202
236
  const buildPromise = build(cratePath, crateName, cacheKey);
203
237
  _napiInFlight.set(cacheKey, buildPromise);
204
238
  let rawModule: any;
@@ -228,7 +262,11 @@ export class NapiControllerLoader {
228
262
  // Read before building: the metadata answers both "where did the dylib
229
263
  // land" and "does this crate use the SDK", and the second decides the build
230
264
  // flags.
231
- const { targetDir, libName, usesSdk } = await resolveCrateMetadata(cratePath, fallbackName);
265
+ const { targetDir, libName, usesSdk } = await resolveCrateMetadata(
266
+ cratePath,
267
+ fallbackName,
268
+ this.targetDirEnv(),
269
+ );
232
270
 
233
271
  try {
234
272
  // The backend is selected as a *dependency* feature of the SDK. A crate
@@ -247,7 +285,7 @@ export class NapiControllerLoader {
247
285
  await execFileAsync("cargo", ["build", "--release", ...featureArgs], {
248
286
  cwd: cratePath,
249
287
  maxBuffer: 32 * 1024 * 1024,
250
- env: hostEnv(),
288
+ env: this.targetDirEnv(),
251
289
  });
252
290
  } catch (err: any) {
253
291
  const stderr = err?.stderr ? `\n${err.stderr}` : "";
@@ -325,6 +363,7 @@ const SDK_CRATE_NAME = "telorun-sdk";
325
363
  async function resolveCrateMetadata(
326
364
  cratePath: string,
327
365
  fallbackName: string,
366
+ env: Record<string, string | undefined>,
328
367
  ): Promise<{ targetDir: string; libName: string; usesSdk: boolean }> {
329
368
  const result = await execFileAsync("cargo", [
330
369
  "metadata",
@@ -333,7 +372,7 @@ async function resolveCrateMetadata(
333
372
  "--manifest-path",
334
373
  path.join(cratePath, "Cargo.toml"),
335
374
  "--no-deps",
336
- ], { maxBuffer: 32 * 1024 * 1024, env: hostEnv() });
375
+ ], { maxBuffer: 32 * 1024 * 1024, env });
337
376
  const metadata = JSON.parse(result.stdout);
338
377
  const cratePackage = metadata.packages?.find(
339
378
  (p: any) => p.manifest_path === path.join(cratePath, "Cargo.toml"),
@@ -1,4 +1,5 @@
1
1
  import { ControllerInstance, NOOP_LOGGER, type Logger } from "@telorun/sdk";
2
+ import type { ControllerWorkReporter } from "../controller-loader.js";
2
3
  import { execFile } from "child_process";
3
4
  import * as crypto from "crypto";
4
5
  import * as fs from "fs/promises";
@@ -44,6 +45,21 @@ const PEER_INSTALL_FLAGS: ReadonlyArray<string> =
44
45
  ? ["--no-strict-peer-dependencies"]
45
46
  : [];
46
47
 
48
+ /**
49
+ * Quiet the installer WITHOUT quieting its failures. `--silent` does both: on
50
+ * npm 11 a failed install writes nothing at all — not the `ERESOLVE`/`ETARGET`
51
+ * code, not the offending spec — so the wrapper below could only report that
52
+ * some install failed, with no cause, for every failure class. `--loglevel`
53
+ * error keeps the diagnosis and drops the progress chatter, which is the half
54
+ * that was actually worth suppressing.
55
+ */
56
+ const QUIET_INSTALL_FLAGS: ReadonlyArray<string> =
57
+ PACKAGE_MANAGER === "npm"
58
+ ? ["--loglevel=error", "--no-progress"]
59
+ : PACKAGE_MANAGER === "pnpm"
60
+ ? ["--loglevel=error"]
61
+ : [];
62
+
47
63
 
48
64
  /**
49
65
  * Tells the dispatcher (and any UI consumer downstream) which branch the
@@ -188,6 +204,7 @@ export class NpmControllerLoader {
188
204
  async resolve(
189
205
  purl: string,
190
206
  baseUri: string,
207
+ report?: ControllerWorkReporter,
191
208
  ): Promise<{ source: NpmResolveSource; importInstance: () => Promise<ControllerInstance> }> {
192
209
  const parsed = PackageURL.fromString(purl);
193
210
  if (!parsed.name) {
@@ -199,7 +216,7 @@ export class NpmControllerLoader {
199
216
  // versions can coexist in one flat node_modules (see installAlias).
200
217
  const alias = installAlias(packageName, version);
201
218
 
202
- const installRoot = await this.ensureInstallRoot();
219
+ const installRoot = await this.ensureInstallRoot(report);
203
220
  const resolved = await resolveInstallSpec(parsed, packageName, baseUri);
204
221
  const source = await this.installPackage(
205
222
  installRoot,
@@ -207,6 +224,7 @@ export class NpmControllerLoader {
207
224
  resolved.spec,
208
225
  resolved.kind,
209
226
  version,
227
+ report,
210
228
  );
211
229
  const subpath = parsed.subpath ?? null;
212
230
  return {
@@ -221,9 +239,9 @@ export class NpmControllerLoader {
221
239
  * `npm install` once, and return the absolute root path. Memoized for the
222
240
  * lifetime of this loader instance.
223
241
  */
224
- private ensureInstallRoot(): Promise<string> {
242
+ private ensureInstallRoot(report?: ControllerWorkReporter): Promise<string> {
225
243
  if (this.rootReady) return this.rootReady;
226
- this.rootReady = this.materializeInstallRoot().catch((err) => {
244
+ this.rootReady = this.materializeInstallRoot(report).catch((err) => {
227
245
  // Reset on failure so a follow-up call retries rather than caches the rejection.
228
246
  this.rootReady = undefined;
229
247
  throw err;
@@ -231,7 +249,7 @@ export class NpmControllerLoader {
231
249
  return this.rootReady;
232
250
  }
233
251
 
234
- private async materializeInstallRoot(): Promise<string> {
252
+ private async materializeInstallRoot(report?: ControllerWorkReporter): Promise<string> {
235
253
  if (!this.entryUrl) {
236
254
  // Throw the env-missing variant so a mixed candidate list (e.g.
237
255
  // `pkg:npm/... + pkg:cargo/...`) still falls back to the next
@@ -325,11 +343,14 @@ export class NpmControllerLoader {
325
343
  2,
326
344
  ) + "\n",
327
345
  );
346
+ // Announced here rather than at the top of the resolve: everything above
347
+ // this line is a fast path that reuses the existing tree.
348
+ await report?.("npm-install");
328
349
  await runPackageManager(installRoot, [
329
350
  "install",
330
351
  "--no-audit",
331
352
  "--no-fund",
332
- "--silent",
353
+ ...QUIET_INSTALL_FLAGS,
333
354
  ...PEER_INSTALL_FLAGS,
334
355
  ]);
335
356
  await fs.writeFile(stateFile, JSON.stringify({ rootHash: newHash }, null, 2) + "\n");
@@ -383,6 +404,7 @@ export class NpmControllerLoader {
383
404
  spec: string,
384
405
  kind: SpecKind,
385
406
  requestedVersion: string | null,
407
+ report?: ControllerWorkReporter,
386
408
  ): Promise<NpmResolveSource> {
387
409
  const cacheKey = alias;
388
410
  if (this.installedSpecs.has(cacheKey)) return "cache";
@@ -466,11 +488,24 @@ export class NpmControllerLoader {
466
488
  }
467
489
  }
468
490
 
491
+ // Past every re-check: the package manager is about to run for real,
492
+ // which is the wait a caller wants reported.
493
+ await report?.("npm-install");
494
+ // The installer re-resolves the ROOT's whole dependency set, not just
495
+ // the alias being added, so one dead record left by another app fails
496
+ // an install that has nothing to do with it — and the root is shared
497
+ // workspace-wide, so that blast radius is every app. Prune here rather
498
+ // than only on a realm change (`materializeInstallRoot`), which a root
499
+ // that goes stale afterwards never reaches. Placed past every re-check
500
+ // so it costs 2 stats per recorded dep only on the branch that was
501
+ // already going to spawn the package manager, never on a cache hit.
502
+ const pruned = await pruneDeadControllerDeps(installRoot);
503
+ if (pruned) this.rootDeps = pruned;
469
504
  await runPackageManager(installRoot, [
470
505
  "install",
471
506
  "--no-audit",
472
507
  "--no-fund",
473
- "--silent",
508
+ ...QUIET_INSTALL_FLAGS,
474
509
  ...PEER_INSTALL_FLAGS,
475
510
  "--save",
476
511
  // `<alias>@<source-spec>` installs the package under the alias folder
@@ -640,9 +675,15 @@ async function runPackageManager(cwd: string, args: string[]): Promise<void> {
640
675
  `TELO_PKG_MANAGER to a different binary name.`,
641
676
  );
642
677
  }
643
- const stderr = err?.stderr ? `\n${err.stderr}` : "";
678
+ // Both streams: npm writes its diagnosis to stderr, pnpm and bun put parts
679
+ // of theirs on stdout. Reporting one of them is how a real cause becomes a
680
+ // failure with no message.
681
+ const output = [err?.stderr, err?.stdout]
682
+ .map((s) => (typeof s === "string" ? s.trim() : ""))
683
+ .filter(Boolean)
684
+ .join("\n");
644
685
  throw new Error(
645
- `[telo] '${PACKAGE_MANAGER} ${args.join(" ")}' failed in ${cwd}:${stderr}`,
686
+ `[telo] '${PACKAGE_MANAGER} ${args.join(" ")}' failed in ${cwd}:${output ? `\n${output}` : ""}`,
646
687
  );
647
688
  }
648
689
  }
@@ -825,6 +866,43 @@ async function liveControllerDeps(
825
866
  return live;
826
867
  }
827
868
 
869
+ /**
870
+ * Rewrite the install root's `package.json` with its dead controller records
871
+ * dropped, returning the surviving map — or null when nothing had to change,
872
+ * so the common case writes no file.
873
+ *
874
+ * Realm-collapse names are never candidates: they are wired in by
875
+ * `materializeInstallRoot` from the kernel's own resolved paths and are the one
876
+ * thing whose absence must be reported rather than repaired away.
877
+ *
878
+ * Must be called under the install root's lock — it is a read-modify-write of a
879
+ * file every kernel sharing the root writes to.
880
+ */
881
+ async function pruneDeadControllerDeps(
882
+ installRoot: string,
883
+ ): Promise<Record<string, string> | null> {
884
+ const deps = await readPackageDeps(installRoot);
885
+ if (!deps) return null;
886
+ const realm = new Set<string>(REALM_COLLAPSE_NAMES);
887
+ const controllers: Record<string, string> = {};
888
+ const preserved: Record<string, string> = {};
889
+ for (const [name, spec] of Object.entries(deps)) {
890
+ if (realm.has(name)) preserved[name] = spec;
891
+ else controllers[name] = spec;
892
+ }
893
+ const live = await liveControllerDeps(controllers, installRoot);
894
+ if (Object.keys(live).length === Object.keys(controllers).length) return null;
895
+
896
+ const packageJsonPath = path.join(installRoot, "package.json");
897
+ const pkg = JSON.parse(await fs.readFile(packageJsonPath, "utf8"));
898
+ const merged = { ...live, ...preserved };
899
+ await fs.writeFile(
900
+ packageJsonPath,
901
+ JSON.stringify({ ...pkg, dependencies: merged }, null, 2) + "\n",
902
+ );
903
+ return merged;
904
+ }
905
+
828
906
  async function readPackageDeps(installRoot: string): Promise<Record<string, string> | null> {
829
907
  try {
830
908
  const text = await fs.readFile(path.join(installRoot, "package.json"), "utf8");
@@ -4,6 +4,8 @@ import { existsSync, readFileSync } from "node:fs";
4
4
  import { createHash } from "node:crypto";
5
5
  import * as fs from "node:fs/promises";
6
6
  import * as path from "node:path";
7
+ // Type-only, so this builder stays free of a runtime edge to the dispatcher.
8
+ import type { ControllerWorkReporter } from "../controller-loader.js";
7
9
 
8
10
  import { readOwnerManifest } from "../bundle/module-manifest.js";
9
11
  import { ControllerEnvMissingError } from "./napi-loader.js";
@@ -295,6 +297,7 @@ export async function buildControllerFromSource(
295
297
  entryFile: string,
296
298
  cacheRoot: string,
297
299
  libraries: readonly SiblingLibrary[] = [],
300
+ report?: ControllerWorkReporter,
298
301
  ): Promise<string> {
299
302
  const cacheDir = path.join(cacheRoot, CACHE_DIR);
300
303
  const externals = externalSpecifiers(libraries);
@@ -309,6 +312,9 @@ export async function buildControllerFromSource(
309
312
 
310
313
  const inFlight = buildsInFlight.get(entryFile);
311
314
  if (inFlight) return inFlight;
315
+ // Below the content-addressed cache and the in-flight gate: from here esbuild
316
+ // really runs, which is the only branch worth reporting as a wait.
317
+ await report?.("source-build");
312
318
  const work = build(entryFile, cacheDir, libraries).finally(() =>
313
319
  buildsInFlight.delete(entryFile),
314
320
  );
@@ -161,6 +161,11 @@ class ResourceDefinition implements ResourceInstance {
161
161
  installRoot: ctx.getInstallRoot(),
162
162
  cacheRoot: host.getCacheRoot?.(),
163
163
  log: ctx.log,
164
+ // What resolution reports on its own: the work branches it enters, and
165
+ // the candidates it skipped. Routed through `ctx.emit` so they carry the
166
+ // same `<kind>.` namespace as the load events emitted below — a consumer
167
+ // pairs a wait with its outcome by that namespace.
168
+ emit: (event) => ctx.emit(event.name, event.payload as Record<string, unknown>),
164
169
  });
165
170
  // The artifact of the module that DECLARED this kind — a bundled controller
166
171
  // ships in its own module's payload, not the consumer's. It owns the pinned
@@ -199,8 +204,7 @@ class ResourceDefinition implements ResourceInstance {
199
204
  }
200
205
  throw err;
201
206
  });
202
- await ctx.emit("ControllerLoading", { purl: resolved.purl });
203
- const startedAt = Date.now();
207
+ await ctx.emit("ControllerLoading", { purl: resolved.purl, source: resolved.source });
204
208
  const instance = await resolved.importInstance().catch(async (err) => {
205
209
  await ctx.emit("ControllerLoadFailed", {
206
210
  purl: resolved.purl,
@@ -212,7 +216,7 @@ class ResourceDefinition implements ResourceInstance {
212
216
  await ctx.emit("ControllerLoaded", {
213
217
  purl: resolved.purl,
214
218
  source: resolved.source,
215
- durationMs: Date.now() - startedAt,
219
+ durationMs: resolved.waitedMs(),
216
220
  });
217
221
  },
218
222
  );