@telorun/kernel 0.62.0 → 0.64.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 (116) hide show
  1. package/dist/boot-context-registry.js +1 -3
  2. package/dist/boot-context-registry.js.map +1 -1
  3. package/dist/bundle/module-artifact.js +15 -8
  4. package/dist/bundle/module-artifact.js.map +1 -1
  5. package/dist/controller-loader.d.ts +4 -0
  6. package/dist/controller-loader.d.ts.map +1 -1
  7. package/dist/controller-loader.js +5 -1
  8. package/dist/controller-loader.js.map +1 -1
  9. package/dist/controller-loaders/bundle-loader.d.ts +21 -0
  10. package/dist/controller-loaders/bundle-loader.d.ts.map +1 -1
  11. package/dist/controller-loaders/bundle-loader.js +98 -17
  12. package/dist/controller-loaders/bundle-loader.js.map +1 -1
  13. package/dist/controller-loaders/napi-loader.d.ts.map +1 -1
  14. package/dist/controller-loaders/napi-loader.js +24 -8
  15. package/dist/controller-loaders/napi-loader.js.map +1 -1
  16. package/dist/controller-loaders/npm-loader.js +37 -28
  17. package/dist/controller-loaders/npm-loader.js.map +1 -1
  18. package/dist/controller-loaders/source-bundle-builder.d.ts +30 -0
  19. package/dist/controller-loaders/source-bundle-builder.d.ts.map +1 -0
  20. package/dist/controller-loaders/source-bundle-builder.js +302 -0
  21. package/dist/controller-loaders/source-bundle-builder.js.map +1 -0
  22. package/dist/controller-registry.js +3 -5
  23. package/dist/controller-registry.js.map +1 -1
  24. package/dist/controllers/resource-definition/abstract-controller.js +2 -1
  25. package/dist/controllers/resource-definition/abstract-controller.js.map +1 -1
  26. package/dist/controllers/resource-definition/resource-definition-controller.d.ts.map +1 -1
  27. package/dist/controllers/resource-definition/resource-definition-controller.js +11 -3
  28. package/dist/controllers/resource-definition/resource-definition-controller.js.map +1 -1
  29. package/dist/controllers/type/json-schema-controller.js +3 -0
  30. package/dist/controllers/type/json-schema-controller.js.map +1 -1
  31. package/dist/directory-lock.js +5 -5
  32. package/dist/directory-lock.js.map +1 -1
  33. package/dist/evaluation-context.js +60 -21
  34. package/dist/evaluation-context.js.map +1 -1
  35. package/dist/events.js +1 -3
  36. package/dist/events.js.map +1 -1
  37. package/dist/host-env.js +3 -2
  38. package/dist/host-env.js.map +1 -1
  39. package/dist/index.d.ts +1 -0
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +1 -0
  42. package/dist/index.js.map +1 -1
  43. package/dist/kernel.d.ts +9 -0
  44. package/dist/kernel.d.ts.map +1 -1
  45. package/dist/kernel.js +64 -26
  46. package/dist/kernel.js.map +1 -1
  47. package/dist/logging/console-sink.js +18 -29
  48. package/dist/logging/console-sink.js.map +1 -1
  49. package/dist/logging/debug-wire-sink.js +11 -23
  50. package/dist/logging/debug-wire-sink.js.map +1 -1
  51. package/dist/logging/drop-accounting.js +28 -41
  52. package/dist/logging/drop-accounting.js.map +1 -1
  53. package/dist/logging/file-sink.js +29 -39
  54. package/dist/logging/file-sink.js.map +1 -1
  55. package/dist/logging/index.d.ts +1 -1
  56. package/dist/logging/index.d.ts.map +1 -1
  57. package/dist/logging/index.js +1 -1
  58. package/dist/logging/index.js.map +1 -1
  59. package/dist/logging/kernel-logging.js +30 -41
  60. package/dist/logging/kernel-logging.js.map +1 -1
  61. package/dist/logging/log-sink.d.ts +1 -1
  62. package/dist/logging/log-sink.d.ts.map +1 -1
  63. package/dist/logging/log-sink.js +1 -1
  64. package/dist/logging/log-sink.js.map +1 -1
  65. package/dist/logging/logging-pipeline.js +197 -197
  66. package/dist/logging/logging-pipeline.js.map +1 -1
  67. package/dist/logging/sampler.js +21 -34
  68. package/dist/logging/sampler.js.map +1 -1
  69. package/dist/manifest-sources/local-manifest-cache-source.js +2 -0
  70. package/dist/manifest-sources/local-manifest-cache-source.js.map +1 -1
  71. package/dist/manifest-sources/memory-source.js +1 -3
  72. package/dist/manifest-sources/memory-source.js.map +1 -1
  73. package/dist/module-context.js +62 -37
  74. package/dist/module-context.js.map +1 -1
  75. package/dist/registry.js +5 -7
  76. package/dist/registry.js.map +1 -1
  77. package/dist/resource-context.d.ts +11 -1
  78. package/dist/resource-context.d.ts.map +1 -1
  79. package/dist/resource-context.js +44 -23
  80. package/dist/resource-context.js.map +1 -1
  81. package/dist/resource-uri.js +4 -0
  82. package/dist/resource-uri.js.map +1 -1
  83. package/dist/runtime-seam.d.ts +19 -0
  84. package/dist/runtime-seam.d.ts.map +1 -0
  85. package/dist/runtime-seam.js +198 -0
  86. package/dist/runtime-seam.js.map +1 -0
  87. package/dist/schema-validator.js +20 -18
  88. package/dist/schema-validator.js.map +1 -1
  89. package/dist/tracing.js +4 -19
  90. package/dist/tracing.js.map +1 -1
  91. package/dist/transports/oci/oci-client.js +3 -1
  92. package/dist/transports/oci/oci-client.js.map +1 -1
  93. package/dist/transports/oci/oci-transport.js +17 -16
  94. package/dist/transports/oci/oci-transport.js.map +1 -1
  95. package/dist/transports/registry-transport.js +4 -0
  96. package/dist/transports/registry-transport.js.map +1 -1
  97. package/dist/transports/transport-registry.js +1 -0
  98. package/dist/transports/transport-registry.js.map +1 -1
  99. package/package.json +4 -4
  100. package/src/controller-loader.ts +5 -1
  101. package/src/controller-loaders/bundle-loader.ts +108 -23
  102. package/src/controller-loaders/napi-loader.ts +28 -9
  103. package/src/controller-loaders/source-bundle-builder.ts +337 -0
  104. package/src/controllers/resource-definition/resource-definition-controller.ts +23 -17
  105. package/src/index.ts +1 -0
  106. package/src/kernel.ts +13 -0
  107. package/src/logging/file-sink.ts +1 -1
  108. package/src/logging/index.ts +1 -1
  109. package/src/logging/log-sink.ts +1 -0
  110. package/src/resource-context.ts +23 -0
  111. package/src/runtime-seam.ts +230 -0
  112. package/dist/logging/record-buffer.d.ts +0 -24
  113. package/dist/logging/record-buffer.d.ts.map +0 -1
  114. package/dist/logging/record-buffer.js +0 -78
  115. package/dist/logging/record-buffer.js.map +0 -1
  116. package/src/logging/record-buffer.ts +0 -72
@@ -0,0 +1,337 @@
1
+ import { RuntimeError } from "@telorun/sdk";
2
+ import { createHash } from "node:crypto";
3
+ import * as fs from "node:fs/promises";
4
+ import * as path from "node:path";
5
+
6
+ import { ControllerEnvMissingError } from "./napi-loader.js";
7
+ import { REALM_COLLAPSE_NAMES } from "./realm.js";
8
+
9
+ /**
10
+ * Build a **local** module's bundled controller from its TypeScript source, so a
11
+ * fresh clone runs `telo run ./manifest.yaml` with no build step.
12
+ *
13
+ * This is the dev half of `pkg:telo/local/js?path=…&local_path=…`: `path=` names
14
+ * the prebuilt `.mjs` that ships in a published artifact, `local_path=` names the
15
+ * source it was built from. When the declaring module is on disk with no artifact
16
+ * behind it, the source is what is authoritative — a stale checked-in bundle
17
+ * would otherwise shadow the edit the author just made.
18
+ *
19
+ * Same shape the kernel already runs twice: `NapiControllerLoader` builds a crate
20
+ * from `local_path`, and `bundle-builder.ts` runs esbuild at load time over an npm
21
+ * controller's dependency tree.
22
+ *
23
+ * ## How the cache is keyed
24
+ *
25
+ * A bundle's inputs are a graph — the module's own `src/**`, the shared TS
26
+ * libraries it inlines, its dependency tree — so a staleness check anchored on
27
+ * the entry point is wrong for the most common edit there is: a sibling file, or
28
+ * a shared library one directory over. The output path is therefore keyed on a
29
+ * signature over **every input esbuild reported**, which turns a changed input
30
+ * into a different key: nothing to invalidate, and nothing to get wrong.
31
+ *
32
+ * It is **stat-addressed, not content-addressed**: the signature is each input's
33
+ * path, size and mtime, because the set spans a whole dependency tree and a few
34
+ * thousand `stat`s cost less than the build they avoid, while hashing every byte
35
+ * would cost more. The trade is worth naming, because it is not free:
36
+ *
37
+ * - a checkout that restores byte-identical files mints a fresh key, so a branch
38
+ * switch rebuilds rather than hitting the cache;
39
+ * - two different contents with the same size, written inside the same
40
+ * millisecond, collide — vanishingly unlikely for hand edits, and bounded by
41
+ * the fact that this path only ever runs against a working copy.
42
+ *
43
+ * Neither costs correctness of what ships: a published artifact never takes this
44
+ * path at all.
45
+ *
46
+ * Keying the output path (rather than overwriting one file) also makes
47
+ * concurrency benign. The test suite spawns one kernel *process* per manifest, so
48
+ * the contention is between processes, where an in-process single-flight gate
49
+ * sees nothing. Two processes that race build identical bytes for identical keys,
50
+ * and each writes through a private temp file before an atomic rename — so a
51
+ * reader sees a whole bundle or no bundle, never a torn one.
52
+ *
53
+ * Superseded bundles are pruned on the build that replaces them, so a long-lived
54
+ * checkout does not accumulate one `.mjs` per save.
55
+ */
56
+
57
+ /** Cache layout under the kernel's cache root: the built bundles, plus one index
58
+ * entry per entry point recording the inputs its last build read. */
59
+ const CACHE_DIR = "controller-src";
60
+
61
+ /**
62
+ * The esbuild options a controller bundle is built with. They must match the
63
+ * flags each module's `build` script passes, because a bundle a contributor runs
64
+ * has to be the bundle that ships.
65
+ *
66
+ * The realm names stay external because the bundle loader symlinks them to the
67
+ * kernel's own copy at load time. Inlining them would duplicate the runtime and
68
+ * break the constructor identity `Stream` / `InvokeError` depend on.
69
+ *
70
+ * The banner defines `require` in module scope so esbuild's `__require` shim —
71
+ * emitted for `require(...)` calls inside a bundled CJS dependency — falls
72
+ * through to the real require instead of throwing "Dynamic require of X is not
73
+ * supported", which it would in a `.mjs` where `require` is otherwise undefined.
74
+ */
75
+ const CONTROLLER_BUNDLE_OPTIONS = {
76
+ bundle: true,
77
+ format: "esm",
78
+ platform: "node",
79
+ target: "node20",
80
+ // Inline a workspace TS library from its SOURCE, not from its `dist/`.
81
+ //
82
+ // Two reasons, and the first is load-bearing: `dist/` is a build output, so
83
+ // resolving through it would make building a controller depend on having built
84
+ // every library it inlines — which is exactly the build step this path exists
85
+ // to remove, and it fails on a fresh clone with "Could not resolve". The second
86
+ // is that the shipping build passes the same condition, so both inline the same
87
+ // bytes; resolving to `dist` in one and `src` in the other would mean two
88
+ // transpilers producing the bundle a contributor runs versus the one that ships.
89
+ //
90
+ // `source` is the conventional name for this and every inlined package declares
91
+ // it ahead of `import`. A package that does not simply resolves as before.
92
+ conditions: ["source"],
93
+ banner: {
94
+ js:
95
+ 'import { createRequire as __teloCreateRequire } from "node:module";' +
96
+ "const require = __teloCreateRequire(import.meta.url);",
97
+ },
98
+ external: [...REALM_COLLAPSE_NAMES],
99
+ } as const;
100
+
101
+ /**
102
+ * Fingerprint of the options above, folded into every cache key.
103
+ *
104
+ * The output is a function of the inputs *and* how they were built, so a change
105
+ * to the option set has to invalidate the cache the same way an edited source
106
+ * does — otherwise a kernel upgrade that changes the banner or the externals
107
+ * keeps serving bundles built the old way, which is the exact silent-stale-copy
108
+ * failure the content-addressing exists to prevent.
109
+ */
110
+ const OPTIONS_FINGERPRINT = createHash("sha256")
111
+ .update(JSON.stringify(CONTROLLER_BUNDLE_OPTIONS))
112
+ .digest("hex")
113
+ .slice(0, 8);
114
+
115
+ interface BuildIndexEntry {
116
+ /** Absolute paths of every file the last build read, from esbuild's metafile. */
117
+ inputs: string[];
118
+ /** Signature of those inputs at build time; the built bundle's cache key. */
119
+ key: string;
120
+ }
121
+
122
+ /** Memoized esbuild handle: `undefined` until first tried, `null` when absent. A
123
+ * failed dynamic import is not reliably cached by Node, so without this every
124
+ * controller load re-attempts (and re-fails) the import. */
125
+ let esbuildModule: typeof import("esbuild") | null | undefined;
126
+ async function loadEsbuild(): Promise<typeof import("esbuild") | null> {
127
+ if (esbuildModule !== undefined) return esbuildModule;
128
+ try {
129
+ esbuildModule = await import("esbuild");
130
+ } catch {
131
+ esbuildModule = null;
132
+ }
133
+ return esbuildModule;
134
+ }
135
+
136
+ /**
137
+ * Whether this host can build a controller from source at all.
138
+ *
139
+ * Asked at *resolve* time, not at build time, so the absence of esbuild selects
140
+ * the prebuilt `path=` file instead of failing the load. esbuild is an
141
+ * **optional** dependency precisely so an install that skips optionals still runs
142
+ * published artifacts — and a working copy that has run its build script has the
143
+ * same prebuilt file sitting there. Deciding this lazily inside the build would
144
+ * turn "no bundler" into a hard failure with a perfectly good bundle on disk.
145
+ */
146
+ export async function canBuildFromSource(): Promise<boolean> {
147
+ return (await loadEsbuild()) !== null;
148
+ }
149
+
150
+ async function pathExists(p: string): Promise<boolean> {
151
+ try {
152
+ await fs.access(p);
153
+ return true;
154
+ } catch {
155
+ return false;
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Signature of an input set: each file's path, size and mtime, hashed together.
161
+ * Stat rather than content because the set spans a whole dependency tree — a few
162
+ * thousand stats cost less than the build they are avoiding, while reading every
163
+ * byte would cost more.
164
+ *
165
+ * Returns `null` when any recorded input has disappeared, which is itself a
166
+ * change: the caller rebuilds rather than trusting a signature computed over a
167
+ * file set that no longer exists.
168
+ */
169
+ async function signInputs(inputs: string[]): Promise<string | null> {
170
+ const stats = await Promise.all(
171
+ inputs.map(async (file) => {
172
+ try {
173
+ const stat = await fs.stat(file);
174
+ return `${file}\0${stat.size}\0${stat.mtimeMs}`;
175
+ } catch {
176
+ return null;
177
+ }
178
+ }),
179
+ );
180
+ if (stats.some((entry) => entry === null)) return null;
181
+ return createHash("sha256")
182
+ .update(OPTIONS_FINGERPRINT)
183
+ .update("\n")
184
+ .update(stats.join("\n"))
185
+ .digest("hex")
186
+ .slice(0, 32);
187
+ }
188
+
189
+ function indexPath(cacheDir: string, entryFile: string): string {
190
+ const id = createHash("sha256").update(entryFile).digest("hex").slice(0, 32);
191
+ return path.join(cacheDir, `${id}.index.json`);
192
+ }
193
+
194
+ function bundlePath(cacheDir: string, key: string): string {
195
+ return path.join(cacheDir, `${key}.mjs`);
196
+ }
197
+
198
+ /**
199
+ * Every file the last build of `entryFile` actually read, from esbuild's own
200
+ * metafile — the module's sources, the shared TS libraries it inlines, and its
201
+ * dependency tree.
202
+ *
203
+ * Exported for watch mode, which needs exactly this set and cannot derive it:
204
+ * the bundle's inputs are a graph, so watching the entry point's directory both
205
+ * misses a shared library one directory over and sweeps in build output. Empty
206
+ * before the first build, when there is nothing to be stale about yet.
207
+ */
208
+ export async function lastBuildInputs(
209
+ entryFile: string,
210
+ cacheRoot: string,
211
+ ): Promise<string[]> {
212
+ const index = await readIndex(indexPath(path.join(cacheRoot, CACHE_DIR), entryFile));
213
+ return index?.inputs ?? [];
214
+ }
215
+
216
+ async function readIndex(file: string): Promise<BuildIndexEntry | null> {
217
+ try {
218
+ const parsed = JSON.parse(await fs.readFile(file, "utf8")) as BuildIndexEntry;
219
+ return Array.isArray(parsed.inputs) && typeof parsed.key === "string" ? parsed : null;
220
+ } catch {
221
+ return null;
222
+ }
223
+ }
224
+
225
+ /** In-process single-flight per entry point — the cheap common case, on top of
226
+ * the cross-process safety the content-addressed path already provides. */
227
+ const buildsInFlight = new Map<string, Promise<string>>();
228
+ let tmpCounter = 0;
229
+
230
+ /**
231
+ * Return the path of a current bundle for `entryFile`, building it if the cache
232
+ * has none. Throws `ERR_CONTROLLER_BUILD_FAILED` when the source does not build —
233
+ * that is broken user code and must surface, never fall through to another
234
+ * candidate.
235
+ */
236
+ export async function buildControllerFromSource(
237
+ entryFile: string,
238
+ cacheRoot: string,
239
+ ): Promise<string> {
240
+ const cacheDir = path.join(cacheRoot, CACHE_DIR);
241
+ const index = await readIndex(indexPath(cacheDir, entryFile));
242
+ if (index) {
243
+ const key = await signInputs(index.inputs);
244
+ if (key === index.key) {
245
+ const cached = bundlePath(cacheDir, key);
246
+ if (await pathExists(cached)) return cached;
247
+ }
248
+ }
249
+
250
+ const inFlight = buildsInFlight.get(entryFile);
251
+ if (inFlight) return inFlight;
252
+ const work = build(entryFile, cacheDir).finally(() => buildsInFlight.delete(entryFile));
253
+ buildsInFlight.set(entryFile, work);
254
+ return work;
255
+ }
256
+
257
+ async function build(entryFile: string, cacheDir: string): Promise<string> {
258
+ const esbuild = await loadEsbuild();
259
+ if (!esbuild) {
260
+ // Explicit rather than a silent fallthrough: esbuild is an *optional*
261
+ // dependency precisely so a production install that skips optionals still
262
+ // loads published artifacts, which ship prebuilt bundles. Only building a
263
+ // local module from source needs it, and that case has to say so.
264
+ throw new ControllerEnvMissingError(
265
+ `Cannot build controller from source "${entryFile}": esbuild is not installed. ` +
266
+ `Building a local module's controller needs it; a published module ships a ` +
267
+ `prebuilt bundle and does not.`,
268
+ );
269
+ }
270
+
271
+ let built: import("esbuild").BuildResult<{ write: false; metafile: true }>;
272
+ try {
273
+ built = await esbuild.build({
274
+ ...CONTROLLER_BUNDLE_OPTIONS,
275
+ // esbuild's options are mutable arrays; the shared constant is `as const`
276
+ // so it cannot be edited in place by one caller and read by another.
277
+ external: [...CONTROLLER_BUNDLE_OPTIONS.external],
278
+ conditions: [...CONTROLLER_BUNDLE_OPTIONS.conditions],
279
+ entryPoints: [entryFile],
280
+ write: false,
281
+ metafile: true,
282
+ logLevel: "silent",
283
+ });
284
+ } catch (err) {
285
+ throw new RuntimeError(
286
+ "ERR_CONTROLLER_BUILD_FAILED",
287
+ `Failed to build controller from source "${entryFile}":\n` +
288
+ (err instanceof Error ? err.message : String(err)),
289
+ );
290
+ }
291
+
292
+ const output = built.outputFiles?.[0];
293
+ if (!output) {
294
+ throw new RuntimeError(
295
+ "ERR_CONTROLLER_BUILD_FAILED",
296
+ `esbuild produced no output for controller source "${entryFile}"`,
297
+ );
298
+ }
299
+
300
+ // Absolute, so the signature is independent of the working directory the next
301
+ // kernel happens to run from.
302
+ const inputs = Object.keys(built.metafile.inputs).map((rel) => path.resolve(rel));
303
+ const key = (await signInputs(inputs)) ?? createHash("sha256")
304
+ .update(output.text)
305
+ .digest("hex")
306
+ .slice(0, 32);
307
+ const target = bundlePath(cacheDir, key);
308
+
309
+ await fs.mkdir(cacheDir, { recursive: true });
310
+ const index = indexPath(cacheDir, entryFile);
311
+ const superseded = (await readIndex(index))?.key;
312
+ const tmp = `${target}.${process.pid}.${tmpCounter++}.tmp`;
313
+ await fs.writeFile(tmp, output.text);
314
+ await fs.rename(tmp, target);
315
+ // Index last: a reader that finds it trusts the bundle it names to be on disk.
316
+ // A torn index self-heals — an unparseable one reads as a miss and rebuilds.
317
+ const tmpIndex = `${index}.${process.pid}.${tmpCounter++}.tmp`;
318
+ await fs.writeFile(tmpIndex, JSON.stringify({ inputs, key } satisfies BuildIndexEntry));
319
+ await fs.rename(tmpIndex, index);
320
+ await prune(cacheDir, superseded, key);
321
+ return target;
322
+ }
323
+
324
+ /**
325
+ * Drop the bundle this build replaced.
326
+ *
327
+ * Every save mints a new key, so without this a day of editing leaves one `.mjs`
328
+ * per save and the cache grows for the life of the checkout. Pruned *after* the
329
+ * new index is in place, so a concurrent reader is already being pointed at the
330
+ * replacement; on Linux a process that opened the old file keeps reading it
331
+ * through the open handle, and on Windows a failed unlink is swallowed — a stale
332
+ * file costs disk, never correctness.
333
+ */
334
+ async function prune(cacheDir: string, superseded: string | undefined, current: string): Promise<void> {
335
+ if (!superseded || superseded === current) return;
336
+ await fs.rm(bundlePath(cacheDir, superseded), { force: true }).catch(() => {});
337
+ }
@@ -154,9 +154,11 @@ class ResourceDefinition implements ResourceInstance {
154
154
  );
155
155
  return;
156
156
  }
157
+ const host = kernelContext(ctx);
157
158
  const loader = new ControllerLoader({
158
159
  entryUrl: ctx.getEntryUrl(),
159
160
  installRoot: ctx.getInstallRoot(),
161
+ cacheRoot: host.getCacheRoot?.(),
160
162
  log: ctx.log,
161
163
  });
162
164
  // Eager resolve — verify the controller is hostable now (so a broken
@@ -167,9 +169,7 @@ class ResourceDefinition implements ResourceInstance {
167
169
  // ships in its own module's payload, not the consumer's. It owns the pinned
168
170
  // ref and the verified layer index, so the loader picks a candidate and asks
169
171
  // it for that selector's directory rather than fetching anything itself.
170
- const artifact = (ctx as unknown as ModuleArtifactHost).getModuleArtifact?.(
171
- this.resource.metadata.source,
172
- );
172
+ const artifact = host.getModuleArtifact?.(this.resource.metadata.source);
173
173
  const resolved = await loader.resolve(
174
174
  this.resource.controllers,
175
175
  this.resource.metadata.source,
@@ -183,7 +183,7 @@ class ResourceDefinition implements ResourceInstance {
183
183
  // Emitted here (not in the loader) so ControllerLoading / ControllerLoaded /
184
184
  // ControllerLoadFailed — and the import duration — surface when the load
185
185
  // actually happens (first instantiation), with the resolved PURL + source.
186
- (ctx as unknown as LazyControllerHost).registerLazyController(
186
+ host.registerLazyController(
187
187
  moduleName,
188
188
  kindName,
189
189
  async () => {
@@ -208,21 +208,21 @@ class ResourceDefinition implements ResourceInstance {
208
208
  }
209
209
 
210
210
  /**
211
- * Kernel-internal hook for reaching a module's artifact handle. Off the SDK
212
- * surface deliberately: it hands back a kernel class, and only controller
213
- * resolution needs it — module authors reach a module's files through
214
- * `ctx.resolveModuleFile`, which returns a plain URI.
211
+ * What the concrete `ResourceContextImpl` offers this controller **beyond** the
212
+ * public SDK `ResourceContext`.
213
+ *
214
+ * One interface rather than one per need, and narrowed once at the top of
215
+ * `init()` rather than at each call site. Each of these is deliberately off the
216
+ * SDK surface — `getModuleArtifact` hands back a kernel class, `getCacheRoot`
217
+ * names a cache directory, `registerLazyController` is a scheduling detail — but
218
+ * "off the SDK surface" is a property of the members, not a reason to grow a
219
+ * fresh interface and a fresh double cast for every one of them. Module authors
220
+ * reach a module's files through `ctx.resolveModuleFile`, which returns a plain
221
+ * URI, and never see any of this.
215
222
  */
216
- interface ModuleArtifactHost {
223
+ interface KernelResourceContext {
217
224
  getModuleArtifact?(source: string | undefined): ModuleArtifact | undefined;
218
- }
219
-
220
- /**
221
- * Kernel-internal hook the concrete `ResourceContextImpl` exposes for lazy
222
- * controller loading — deliberately off the public SDK `ResourceContext`
223
- * surface, since only this controller uses it.
224
- */
225
- interface LazyControllerHost {
225
+ getCacheRoot?(): string | undefined;
226
226
  registerLazyController(
227
227
  moduleName: string,
228
228
  kindName: string,
@@ -230,6 +230,12 @@ interface LazyControllerHost {
230
230
  ): void;
231
231
  }
232
232
 
233
+ /** Narrow a `ResourceContext` to the kernel-internal surface its concrete
234
+ * implementation carries. The cast is the seam; it lives here once. */
235
+ function kernelContext(ctx: ResourceContext): KernelResourceContext {
236
+ return ctx as unknown as KernelResourceContext;
237
+ }
238
+
233
239
  export function register(ctx: ControllerContext): void {
234
240
  // ResourceDefinition is a passive resource - no registration needed
235
241
  }
package/src/index.ts CHANGED
@@ -35,6 +35,7 @@ export {
35
35
  type ResolvedControllerLayer,
36
36
  } from "./bundle/module-artifact.js";
37
37
  export { readOwnerManifest, type OwnerManifest } from "./bundle/module-manifest.js";
38
+ export { lastBuildInputs } from "./controller-loaders/source-bundle-builder.js";
38
39
  export type {
39
40
  PayloadLayer,
40
41
  PublishBundle,
package/src/kernel.ts CHANGED
@@ -170,6 +170,11 @@ export class Kernel implements IKernel {
170
170
  readonly env: Record<string, string | undefined>;
171
171
  readonly argv: string[];
172
172
  readonly registryUrl: string | undefined;
173
+ /** The sources this kernel was constructed with, kept so `ctx.runtime` can
174
+ * give a child manifest — or a static check of one — the same resolution
175
+ * chain this kernel runs on. The transports come from the registry and are
176
+ * rebuilt per loader; these are the host's own additions. */
177
+ readonly injectedSources: readonly ManifestSource[];
173
178
  /** Structured logging for this kernel — the pipeline, its sinks, and the
174
179
  * scoped loggers handed to controllers as `ctx.log`. Live from construction
175
180
  * so loader and parse diagnostics have somewhere to go (§12.3); a nested
@@ -208,6 +213,7 @@ export class Kernel implements IKernel {
208
213
  this.loader = new Loader(defaultTransportRegistry(this.registryUrl).sources(), {
209
214
  celHandlers: nodeCelHandlers,
210
215
  });
216
+ this.injectedSources = [...options.sources];
211
217
  for (const source of options.sources) {
212
218
  this.loader.register(source);
213
219
  }
@@ -938,6 +944,13 @@ export class Kernel implements IKernel {
938
944
  return this._cacheRoot ? `${this._cacheRoot}/npm` : undefined;
939
945
  }
940
946
 
947
+ /** The `.telo` cache root resolved for this load. The bundle loader builds a
948
+ * local module's controller source under it; `null`/`undefined` when the load
949
+ * resolved no cache root (a memory-source manifest, an explicit opt-out). */
950
+ getCacheRoot(): string | undefined {
951
+ return this._cacheRoot ?? undefined;
952
+ }
953
+
941
954
  /**
942
955
  * Build one {@link ModuleArtifact} per loaded module that ships a payload.
943
956
  *
@@ -3,7 +3,7 @@ import type { LogRecord } from "@telorun/sdk";
3
3
  import { encodeJsonLine, type BytesEncoder } from "./encode-json.js";
4
4
  import { encodePrettyLine } from "./encode-pretty.js";
5
5
  import { DEFAULT_BUFFER_POLICY, type LogSinkInstance, type SinkBufferPolicy } from "./log-sink.js";
6
- import { RecordBuffer } from "./record-buffer.js";
6
+ import { RecordBuffer } from "./log-sink.js";
7
7
 
8
8
  /**
9
9
  * `Telo.FileSink` — a kernel built-in (§10.2). Asynchronous, `json` by default.
@@ -43,7 +43,7 @@ export {
43
43
  redactError,
44
44
  } from "./redact-attributes.js";
45
45
  export type { CompiledRedactionPath, RedactionPolicy } from "./redact-attributes.js";
46
- export { RecordBuffer } from "./record-buffer.js";
46
+ export { RecordBuffer } from "./log-sink.js";
47
47
  export { Sampler } from "./sampler.js";
48
48
  export type { SamplingConfig } from "./sampler.js";
49
49
  export {
@@ -8,6 +8,7 @@ export {
8
8
  BLOCK_UNSUPPORTED,
9
9
  blockUnsupportedMessage,
10
10
  DEFAULT_BUFFER_POLICY,
11
+ RecordBuffer,
11
12
  } from "@telorun/sdk";
12
13
  export type {
13
14
  DropCause,
@@ -17,6 +17,7 @@ import {
17
17
  type OpenSpan,
18
18
  type OpenSpanOptions,
19
19
  type ParsedArgs,
20
+ type RuntimeSeam,
20
21
  type TypeRule,
21
22
  } from "@telorun/sdk";
22
23
  import { isRefSentinel } from "@telorun/templating";
@@ -38,6 +39,7 @@ import addFormats from "ajv-formats";
38
39
  import { Kernel } from "./kernel.js";
39
40
  import { formatAjvErrors } from "./manifest-schemas.js";
40
41
  import { policyFingerprint } from "./runtime-registry.js";
42
+ import { KernelRuntimeSeam } from "./runtime-seam.js";
41
43
  import { SchemaValidator } from "./schema-validator.js";
42
44
 
43
45
  const Ajv = AjvModule.default ?? AjvModule;
@@ -92,6 +94,19 @@ export class ResourceContextImpl implements ResourceContext {
92
94
  return this.kernel.logging.host;
93
95
  }
94
96
 
97
+ /** Built lazily and shared per resource: the seam holds no per-call state, and
98
+ * most controllers never run or analyze a manifest. */
99
+ #runtime: RuntimeSeam | undefined;
100
+
101
+ /** The host's own manifest machinery — see {@link RuntimeSeam}. Reached
102
+ * through the context so a module that needs it (`test` runs a child
103
+ * manifest, `assert` analyzes one) binds to a versioned contract instead of
104
+ * importing the kernel. */
105
+ get runtime(): RuntimeSeam {
106
+ if (!this.#runtime) this.#runtime = new KernelRuntimeSeam(this.kernel);
107
+ return this.#runtime;
108
+ }
109
+
95
110
  kernelLoggingRootScope(): ScopeConfig {
96
111
  return this.kernel.logging.rootScope;
97
112
  }
@@ -657,6 +672,14 @@ export class ResourceContextImpl implements ResourceContext {
657
672
  return this.kernel.getInstallRoot();
658
673
  }
659
674
 
675
+ /** The `.telo` cache root for this load. Kernel-only — the SDK surface has no
676
+ * business naming a cache directory — reached by the resource-definition
677
+ * controller through {@link ControllerCacheHost} so the bundle loader can
678
+ * cache a dev build of a local module's controller source. */
679
+ getCacheRoot(): string | undefined {
680
+ return this.kernel.getCacheRoot();
681
+ }
682
+
660
683
  /** The artifact of the module whose manifest resolved from `source`. Kernel-only
661
684
  * (it hands back a kernel class), reached by the resource-definition controller
662
685
  * through {@link ModuleArtifactHost} rather than the SDK surface. */