@telorun/kernel 0.59.0 → 0.60.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 (83) hide show
  1. package/dist/bundle/files-integrity.d.ts +21 -12
  2. package/dist/bundle/files-integrity.d.ts.map +1 -1
  3. package/dist/bundle/files-integrity.js +27 -14
  4. package/dist/bundle/files-integrity.js.map +1 -1
  5. package/dist/bundle/module-artifact.d.ts +134 -0
  6. package/dist/bundle/module-artifact.d.ts.map +1 -0
  7. package/dist/bundle/module-artifact.js +300 -0
  8. package/dist/bundle/module-artifact.js.map +1 -0
  9. package/dist/bundle/module-manifest.d.ts +12 -4
  10. package/dist/bundle/module-manifest.d.ts.map +1 -1
  11. package/dist/bundle/module-manifest.js +8 -4
  12. package/dist/bundle/module-manifest.js.map +1 -1
  13. package/dist/controller-loader.d.ts +3 -2
  14. package/dist/controller-loader.d.ts.map +1 -1
  15. package/dist/controller-loader.js +8 -8
  16. package/dist/controller-loader.js.map +1 -1
  17. package/dist/controller-loaders/bundle-loader.d.ts +8 -6
  18. package/dist/controller-loaders/bundle-loader.d.ts.map +1 -1
  19. package/dist/controller-loaders/bundle-loader.js +52 -13
  20. package/dist/controller-loaders/bundle-loader.js.map +1 -1
  21. package/dist/controller-loaders/npm-loader.d.ts +0 -18
  22. package/dist/controller-loaders/npm-loader.d.ts.map +1 -1
  23. package/dist/controller-loaders/npm-loader.js +3 -190
  24. package/dist/controller-loaders/npm-loader.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 +6 -1
  27. package/dist/controllers/resource-definition/resource-definition-controller.js.map +1 -1
  28. package/dist/directory-lock.d.ts +27 -0
  29. package/dist/directory-lock.d.ts.map +1 -0
  30. package/dist/directory-lock.js +205 -0
  31. package/dist/directory-lock.js.map +1 -0
  32. package/dist/index.d.ts +4 -2
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +3 -1
  35. package/dist/index.js.map +1 -1
  36. package/dist/kernel.d.ts +32 -0
  37. package/dist/kernel.d.ts.map +1 -1
  38. package/dist/kernel.js +55 -1
  39. package/dist/kernel.js.map +1 -1
  40. package/dist/manifest-sources/local-manifest-cache-source.d.ts +1 -1
  41. package/dist/manifest-sources/local-manifest-cache-source.d.ts.map +1 -1
  42. package/dist/manifest-sources/local-manifest-cache-source.js +1 -1
  43. package/dist/manifest-sources/local-manifest-cache-source.js.map +1 -1
  44. package/dist/resource-context.d.ts +16 -0
  45. package/dist/resource-context.d.ts.map +1 -1
  46. package/dist/resource-context.js +52 -0
  47. package/dist/resource-context.js.map +1 -1
  48. package/dist/transports/oci/oci-client.d.ts +28 -1
  49. package/dist/transports/oci/oci-client.d.ts.map +1 -1
  50. package/dist/transports/oci/oci-client.js +24 -1
  51. package/dist/transports/oci/oci-client.js.map +1 -1
  52. package/dist/transports/oci/oci-transport.d.ts +20 -14
  53. package/dist/transports/oci/oci-transport.d.ts.map +1 -1
  54. package/dist/transports/oci/oci-transport.js +105 -46
  55. package/dist/transports/oci/oci-transport.js.map +1 -1
  56. package/dist/transports/registry-transport.d.ts +7 -2
  57. package/dist/transports/registry-transport.d.ts.map +1 -1
  58. package/dist/transports/registry-transport.js +8 -33
  59. package/dist/transports/registry-transport.js.map +1 -1
  60. package/dist/transports/transport-registry.d.ts +5 -4
  61. package/dist/transports/transport-registry.d.ts.map +1 -1
  62. package/dist/transports/transport-registry.js +4 -4
  63. package/dist/transports/transport-registry.js.map +1 -1
  64. package/dist/transports/transport.d.ts +27 -19
  65. package/dist/transports/transport.d.ts.map +1 -1
  66. package/package.json +3 -3
  67. package/src/bundle/files-integrity.ts +34 -14
  68. package/src/bundle/module-artifact.ts +375 -0
  69. package/src/bundle/module-manifest.ts +22 -6
  70. package/src/controller-loader.ts +9 -4
  71. package/src/controller-loaders/bundle-loader.ts +57 -11
  72. package/src/controller-loaders/npm-loader.ts +3 -209
  73. package/src/controllers/resource-definition/resource-definition-controller.ts +19 -0
  74. package/src/directory-lock.ts +225 -0
  75. package/src/index.ts +10 -2
  76. package/src/kernel.ts +66 -0
  77. package/src/manifest-sources/local-manifest-cache-source.ts +1 -1
  78. package/src/resource-context.ts +57 -0
  79. package/src/transports/oci/oci-client.ts +32 -1
  80. package/src/transports/oci/oci-transport.ts +122 -51
  81. package/src/transports/registry-transport.ts +10 -38
  82. package/src/transports/transport-registry.ts +5 -5
  83. package/src/transports/transport.ts +32 -19
@@ -1,4 +1,8 @@
1
- import { DEFAULT_MANIFEST_FILENAME, sha256Base64Url } from "@telorun/analyzer";
1
+ import {
2
+ DEFAULT_MANIFEST_FILENAME,
3
+ sha256Base64Url,
4
+ type ArtifactLayer,
5
+ } from "@telorun/analyzer";
2
6
 
3
7
  import { findOwnerDoc, parseManifestDocs } from "./module-manifest.js";
4
8
 
@@ -9,16 +13,18 @@ export interface PayloadFile {
9
13
  }
10
14
 
11
15
  /**
12
- * Canonical per-file content digest of a module's `files:` payload — the value
13
- * of `filesIntegrity` in a bundle's `telo.yaml`. SHA-256 over the sorted
14
- * `<path>\0<sha256(content)>` lines of every payload file, `telo.yaml`
15
- * excluded (the importer's `#sha256-...` hash already covers the manifest, and
16
- * excluding it breaks the self-reference the manifest embeds this value).
16
+ * Canonical per-file content digest of one **layer**'s files — the `integrity`
17
+ * value of that layer's entry in the published `layers:` index. SHA-256 over the
18
+ * sorted `<path>\0<sha256(content)>` lines of every file in the layer,
19
+ * `telo.yaml` excluded (it is the manifest layer, which carries the index and so
20
+ * cannot hash itself; the importer's `#sha256-...` pin covers it instead).
17
21
  *
18
22
  * Hashing file *contents* rather than the tar/gzip bytes makes the digest
19
23
  * independent of archive framing, so publisher and client compute the same
20
24
  * value from the same file set, and it can be re-derived from the extracted
21
- * files on disk. Returns `sha256-<base64url>`.
25
+ * files on disk which is what makes a per-layer cache marker checkable without
26
+ * re-tarring. Distinct from the layer's `blob` digest, which covers the pushed
27
+ * bytes and addresses the layer. Returns `sha256-<base64url>`.
22
28
  */
23
29
  export async function computeFilesIntegrity(files: PayloadFile[]): Promise<string> {
24
30
  const lines: string[] = [];
@@ -32,15 +38,29 @@ export async function computeFilesIntegrity(files: PayloadFile[]): Promise<strin
32
38
  return `sha256-${await sha256Base64Url(canonical)}`;
33
39
  }
34
40
 
35
- /** Write `filesIntegrity` onto the manifest's owner doc so the published
36
- * `telo.yaml` pins its payload transitively covered by importers'
37
- * `#sha256-...` hash. The digest excludes `telo.yaml`, so injecting it does
38
- * not change the digest. Returns the manifest unchanged when it has no owner
39
- * doc. */
40
- export function injectFilesIntegrity(manifest: string, hash: string): string {
41
+ /**
42
+ * Write the `layers:` index onto the manifest's owner doc so the published
43
+ * `telo.yaml` pins and addresses every payload layer transitively covered by
44
+ * importers' `#sha256-...` hash over this manifest.
45
+ *
46
+ * Called after the payload blobs are pushed and before the manifest blob is,
47
+ * which is what keeps the index non-circular: it names only layers other than
48
+ * the one carrying it. Each layer's own digest excludes `telo.yaml`, so
49
+ * injecting the index does not invalidate any of them. Returns the manifest
50
+ * unchanged when it has no owner doc.
51
+ */
52
+ export function injectLayerIndex(manifest: string, layers: readonly ArtifactLayer[]): string {
41
53
  const docs = parseManifestDocs(manifest);
42
54
  const owner = findOwnerDoc(docs);
43
55
  if (!owner) return manifest;
44
- owner.set("filesIntegrity", hash);
56
+ owner.set(
57
+ "layers",
58
+ layers.map((layer) => ({
59
+ role: layer.role,
60
+ ...(layer.selector ? { selector: { ...layer.selector } } : {}),
61
+ blob: layer.blob,
62
+ integrity: layer.integrity,
63
+ })),
64
+ );
45
65
  return docs.map((d) => d.toString()).join("---\n");
46
66
  }
@@ -0,0 +1,375 @@
1
+ import {
2
+ matchControllerLayers,
3
+ selectorKey,
4
+ singletonLayer,
5
+ splitIntegrity,
6
+ describeSelector,
7
+ type ArtifactLayer,
8
+ type ArtifactSelector,
9
+ type PlatformTarget,
10
+ } from "@telorun/analyzer";
11
+ import { NOOP_LOGGER, RuntimeError, type Logger } from "@telorun/sdk";
12
+ import { existsSync } from "fs";
13
+ import * as fs from "fs/promises";
14
+ import * as path from "path";
15
+ import { fileURLToPath } from "url";
16
+
17
+ import { withDirectoryLock } from "../directory-lock.js";
18
+ import { cachePathForCanonical } from "../manifest-sources/local-manifest-cache-source.js";
19
+ import type { TransportRegistry } from "../transports/transport-registry.js";
20
+ import { computeFilesIntegrity, type PayloadFile } from "./files-integrity.js";
21
+
22
+ /** A materialized layer: the directory its files were extracted into (the
23
+ * module's cache directory) and the manifest-relative paths it wrote. */
24
+ export interface MaterializedLayer {
25
+ dir: string;
26
+ files: string[];
27
+ }
28
+
29
+ /**
30
+ * Map Node's platform vocabulary onto the canonical OCI/GOOS names selectors are
31
+ * published with. Node says `win32`/`x64`; OCI descriptors say
32
+ * `windows`/`amd64`, and the published artifact is what has to be matched.
33
+ */
34
+ const NODE_OS_TO_OCI: Readonly<Record<string, string>> = {
35
+ win32: "windows",
36
+ darwin: "darwin",
37
+ linux: "linux",
38
+ freebsd: "freebsd",
39
+ openbsd: "openbsd",
40
+ sunos: "solaris",
41
+ aix: "aix",
42
+ };
43
+
44
+ const NODE_ARCH_TO_OCI: Readonly<Record<string, string>> = {
45
+ x64: "amd64",
46
+ ia32: "386",
47
+ arm64: "arm64",
48
+ arm: "arm",
49
+ ppc64: "ppc64le",
50
+ s390x: "s390x",
51
+ riscv64: "riscv64",
52
+ loong64: "loong64",
53
+ };
54
+
55
+ /**
56
+ * Which libc this process is linked against, or `undefined` when it cannot be
57
+ * determined. Undetermined is deliberately *not* guessed: a selector that
58
+ * constrains `libc` then matches nothing, so a glibc binary is never handed to
59
+ * an Alpine host on the assumption it will run. Node reports
60
+ * `glibcVersionRuntime` in its process report only on a glibc build, which makes
61
+ * its absence on Linux the musl signal.
62
+ */
63
+ function detectLibc(): string | undefined {
64
+ if (process.platform !== "linux") return undefined;
65
+ try {
66
+ const report = process.report?.getReport() as
67
+ | { header?: { glibcVersionRuntime?: string } }
68
+ | undefined;
69
+ return report?.header?.glibcVersionRuntime ? "gnu" : "musl";
70
+ } catch {
71
+ return undefined;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * The platform this kernel is running on, in the published vocabulary. An axis
77
+ * Node cannot name is left absent rather than guessed.
78
+ *
79
+ * Computed once: the host cannot change mid-process, and `detectLibc` generates a
80
+ * full Node diagnostic report (a heap and stack walk costing tens of milliseconds).
81
+ * The controller loader asks per candidate per definition — oauth-client alone has
82
+ * seventeen — so recomputing would put that squarely inside the init loop.
83
+ */
84
+ let cachedHostTarget: PlatformTarget | undefined;
85
+
86
+ export function hostPlatformTarget(): PlatformTarget {
87
+ cachedHostTarget ??= {
88
+ os: NODE_OS_TO_OCI[process.platform],
89
+ arch: NODE_ARCH_TO_OCI[process.arch],
90
+ libc: detectLibc(),
91
+ };
92
+ return cachedHostTarget;
93
+ }
94
+
95
+ /**
96
+ * A module's artifact, scoped to one loaded module.
97
+ *
98
+ * Built during module load, where the **pinned** import ref and the
99
+ * already-verified manifest are both in hand — which is the whole reason this
100
+ * exists as its own object rather than as logic inside a controller loader. A
101
+ * loader is handed only the canonical base URI, which carries no `#sha256-`, so
102
+ * a loader that fetched for itself would have to re-read the layer index off the
103
+ * cache directory: verification silently downgraded from "anchored at the
104
+ * importer's pin" to "trust whatever is already on disk".
105
+ *
106
+ * Materialization is per layer, memoized in-process, and guarded by the shared
107
+ * cross-process directory lock — oauth-client alone has seventeen definitions
108
+ * resolving concurrently against one controller layer, and several kernels may
109
+ * populate one cache directory at once.
110
+ *
111
+ * Verification runs before extraction: the transport checks the transfer against
112
+ * the layer's `blob` digest, and the extracted file set is checked against its
113
+ * `integrity` content digest here. A per-layer marker keyed by the blob digest
114
+ * records success, so a republish to different bytes re-extracts rather than
115
+ * being mistaken for an already-populated layer.
116
+ */
117
+ export class ModuleArtifact {
118
+ /** The importer's pinned ref — what the transport verifies the manifest layer
119
+ * against, and what keeps the Merkle chain anchored. */
120
+ private readonly pinnedRef: string;
121
+ private readonly layers: readonly ArtifactLayer[];
122
+ private readonly dir: string;
123
+ private readonly transports: TransportRegistry;
124
+ private readonly log: Logger;
125
+ /** 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>>();
128
+
129
+ constructor(opts: {
130
+ pinnedRef: string;
131
+ layers: readonly ArtifactLayer[];
132
+ dir: string;
133
+ transports: TransportRegistry;
134
+ log?: Logger;
135
+ }) {
136
+ this.pinnedRef = opts.pinnedRef;
137
+ this.layers = opts.layers;
138
+ this.dir = opts.dir;
139
+ this.transports = opts.transports;
140
+ this.log = opts.log ?? NOOP_LOGGER;
141
+ }
142
+
143
+ /** The module's local directory — where every materialized layer lands, and
144
+ * what a module-relative path resolves against. */
145
+ get directory(): string {
146
+ return this.dir;
147
+ }
148
+
149
+ /**
150
+ * Materialize the layer carrying `selector` exactly, plus the `common` layer.
151
+ *
152
+ * Looked up by exact selector key rather than by re-matching the host: the
153
+ * candidate being resolved already *is* one selector, and it is by construction
154
+ * the key of the layer that carries it. Re-matching would take the first layer
155
+ * in declaration order that the host satisfies, so a module shipping both a
156
+ * platform-neutral and a platform-constrained layer of one format would fetch
157
+ * whichever came first regardless of which candidate asked — materializing the
158
+ * wrong layer and then reporting "bundle not found".
159
+ *
160
+ * The `common` layer rides along because it is the sink for files no candidate
161
+ * claimed — an undeclared sidecar an entry point loads at runtime. Pulling it
162
+ * with any controller layer is what makes a forgotten declaration cost bytes
163
+ * instead of a module-not-found at import.
164
+ *
165
+ * Returns `undefined` when the artifact ships no layer for this selector, which
166
+ * is how a loader learns to fall through to the next candidate.
167
+ */
168
+ async materializeController(selector: ArtifactSelector): Promise<MaterializedLayer | undefined> {
169
+ const key = selectorKey(selector);
170
+ const layer = this.layers.find(
171
+ (l) => l.role === "controller" && l.selector !== undefined && selectorKey(l.selector) === key,
172
+ );
173
+ if (!layer) return undefined;
174
+ await this.materializeCommon();
175
+ return this.materialize(layer);
176
+ }
177
+
178
+ /**
179
+ * Materialize everything a module-relative file read could need: the `assets`
180
+ * layer **and** the `common` layer.
181
+ *
182
+ * Both, because `common` is where the sink rule puts a file the author did not
183
+ * claim via `assets:` — and a module that ships static files but has no bundled
184
+ * controller has no other path to its own payload. Assets alone would leave such
185
+ * a module's `Http.Static` root resolving into an empty directory, which is the
186
+ * exact silent failure this design promises not to have.
187
+ */
188
+ async materializeModuleFiles(): Promise<void> {
189
+ await Promise.all([this.materializeAssets(), this.materializeCommon()]);
190
+ }
191
+
192
+ /** Materialize the lazily-fetched `assets` layer, if the module ships one. */
193
+ async materializeAssets(): Promise<MaterializedLayer | undefined> {
194
+ const layer = singletonLayer(this.layers, "assets");
195
+ return layer ? this.materialize(layer) : undefined;
196
+ }
197
+
198
+ /** Materialize the `common` layer, if the module ships one. */
199
+ async materializeCommon(): Promise<MaterializedLayer | undefined> {
200
+ const layer = singletonLayer(this.layers, "common");
201
+ return layer ? this.materialize(layer) : undefined;
202
+ }
203
+
204
+ /**
205
+ * Materialize every layer a `target` platform could need — both singletons and
206
+ * each controller layer matching it. `telo install`'s make-this-offline pass,
207
+ * where being exhaustive for one platform is the point.
208
+ */
209
+ async materializeAll(target: PlatformTarget): Promise<MaterializedLayer[]> {
210
+ const wanted = [
211
+ ...matchControllerLayers(this.layers, target),
212
+ singletonLayer(this.layers, "assets"),
213
+ singletonLayer(this.layers, "common"),
214
+ ].filter((l): l is ArtifactLayer => l !== undefined);
215
+ const out: MaterializedLayer[] = [];
216
+ for (const layer of wanted) out.push(await this.materialize(layer));
217
+ return out;
218
+ }
219
+
220
+ /** Human-facing description of what this artifact ships, for diagnostics that
221
+ * have to explain why no layer matched. */
222
+ describeLayers(): string {
223
+ if (this.layers.length === 0) return "(no payload layers)";
224
+ return this.layers
225
+ .map((l) => (l.selector ? `${l.role} ${describeSelector(l.selector)}` : l.role))
226
+ .join(", ");
227
+ }
228
+
229
+ private materialize(layer: ArtifactLayer): Promise<MaterializedLayer> {
230
+ const pending = this.inFlight.get(layer.blob);
231
+ if (pending) return pending;
232
+ const work = this.materializeUncached(layer).catch((err) => {
233
+ // Drop the rejection so a transient fetch failure is retried rather than
234
+ // cached for the lifetime of the module.
235
+ this.inFlight.delete(layer.blob);
236
+ throw err;
237
+ });
238
+ this.inFlight.set(layer.blob, work);
239
+ return work;
240
+ }
241
+
242
+ private markerPath(layer: ArtifactLayer): string {
243
+ // Keyed by the blob digest, so a republish to different bytes gets a new
244
+ // marker and re-extracts instead of being read as already-populated. The
245
+ // role is in the name purely so a human can tell the markers apart.
246
+ const short = layer.blob.replace(/^sha256:/, "").slice(0, 16);
247
+ return path.join(this.dir, `.telo-layer-${layer.role}-${short}`);
248
+ }
249
+
250
+ private async materializeUncached(layer: ArtifactLayer): Promise<MaterializedLayer> {
251
+ const marker = this.markerPath(layer);
252
+ if (existsSync(marker)) {
253
+ return { dir: this.dir, files: await readMarker(marker) };
254
+ }
255
+
256
+ return withDirectoryLock(
257
+ this.dir,
258
+ "module layer",
259
+ async () => {
260
+ // Re-check inside the lock: a peer may have extracted this layer between
261
+ // the fast-path miss and our acquisition.
262
+ if (existsSync(marker)) {
263
+ return { dir: this.dir, files: await readMarker(marker) };
264
+ }
265
+
266
+ const files = await this.transports.fetchLayer(this.pinnedRef, layer.blob);
267
+ const actual = await computeFilesIntegrity(files);
268
+ if (actual !== layer.integrity) {
269
+ throw new RuntimeError(
270
+ "ERR_MODULE_LAYER_INTEGRITY",
271
+ `Integrity check failed for the ${layer.role} layer of ${this.pinnedRef}: ` +
272
+ `expected ${layer.integrity}, got ${actual}. The layer's contents do not match ` +
273
+ `the digest recorded in the module's pinned telo.yaml — it may have been ` +
274
+ `tampered with or republished.`,
275
+ );
276
+ }
277
+
278
+ const written = await this.extract(files, layer);
279
+ // Marker last, so a partial extraction leaves none and re-runs.
280
+ await fs.writeFile(marker, `${written.join("\n")}\n`, "utf-8");
281
+ this.log.debug("materialized module layer", {
282
+ "telo.module.ref": this.pinnedRef,
283
+ "telo.layer.role": layer.role,
284
+ "telo.layer.files": written.length,
285
+ });
286
+ return { dir: this.dir, files: written };
287
+ },
288
+ this.log,
289
+ );
290
+ }
291
+
292
+ private async extract(files: PayloadFile[], layer: ArtifactLayer): Promise<string[]> {
293
+ const root = path.resolve(this.dir) + path.sep;
294
+ const written: string[] = [];
295
+ for (const entry of files) {
296
+ const dest = path.resolve(this.dir, entry.name);
297
+ if (!dest.startsWith(root)) {
298
+ throw new RuntimeError(
299
+ "ERR_MODULE_LAYER_INVALID",
300
+ `The ${layer.role} layer of ${this.pinnedRef} contains entry '${entry.name}', which ` +
301
+ `resolves outside the module's cache directory.`,
302
+ );
303
+ }
304
+ await fs.mkdir(path.dirname(dest), { recursive: true });
305
+ await fs.writeFile(dest, entry.content);
306
+ written.push(entry.name);
307
+ }
308
+ return written.sort();
309
+ }
310
+ }
311
+
312
+ async function readMarker(marker: string): Promise<string[]> {
313
+ const text = await fs.readFile(marker, "utf-8");
314
+ return text.split("\n").filter((line) => line !== "");
315
+ }
316
+
317
+ /**
318
+ * The directory a module's layers live in.
319
+ *
320
+ * Derived from the **pinned** `requestedUrl`, not the canonical `source`, because
321
+ * those diverge exactly when the manifest cache is warm: a cache hit is served as
322
+ * a `file://` URL into `.telo/manifests/`, which no transport claims, so a
323
+ * `source`-derived path is `null` on every run after the first. The pinned ref is
324
+ * what the importer wrote and survives the hit, so it places the module
325
+ * identically cold and warm. A genuinely local `source` (development, or a module
326
+ * loaded straight off disk) resolves to its own directory.
327
+ *
328
+ * Returns `null` when neither route yields a directory — a `memory://` module, or
329
+ * a ref this cache has no coordinates for.
330
+ */
331
+ export function moduleDirectoryFor(
332
+ requestedUrl: string,
333
+ source: string,
334
+ entryDir: string,
335
+ registryUrl: string | undefined,
336
+ manifestsDir: string | undefined,
337
+ ): string | null {
338
+ const cacheFile = cachePathForCanonical(
339
+ splitIntegrity(requestedUrl).base,
340
+ entryDir,
341
+ registryUrl,
342
+ manifestsDir,
343
+ );
344
+ if (cacheFile) return path.dirname(cacheFile);
345
+ if (source.startsWith("file://")) return path.dirname(fileURLToPath(source));
346
+ if (path.isAbsolute(source)) return path.dirname(source);
347
+ return null;
348
+ }
349
+
350
+ /**
351
+ * Build the artifact handle for a loaded module, or `undefined` when the module
352
+ * has no payload to materialize.
353
+ *
354
+ * The trigger is the presence of a `layers:` index — a module that ships nothing
355
+ * needs no handle. `pinnedRef` is the ref **as the importer wrote it**, integrity
356
+ * fragment included, which is what keeps verification anchored to the importer's
357
+ * pin; `moduleDir` is where its layers extract to (see {@link moduleDirectoryFor}).
358
+ */
359
+ export function moduleArtifactFor(opts: {
360
+ pinnedRef: string;
361
+ layers: readonly ArtifactLayer[] | undefined;
362
+ moduleDir: string | null;
363
+ transports: TransportRegistry;
364
+ log?: Logger;
365
+ }): ModuleArtifact | undefined {
366
+ if (!opts.layers || opts.layers.length === 0) return undefined;
367
+ if (!opts.moduleDir) return undefined;
368
+ return new ModuleArtifact({
369
+ pinnedRef: opts.pinnedRef,
370
+ layers: opts.layers,
371
+ dir: opts.moduleDir,
372
+ transports: opts.transports,
373
+ log: opts.log,
374
+ });
375
+ }
@@ -1,3 +1,4 @@
1
+ import { parseLayerIndex, type ArtifactLayer } from "@telorun/analyzer";
1
2
  import { defaultCustomTags } from "@telorun/templating";
2
3
  import { parseAllDocuments, type Document } from "yaml";
3
4
 
@@ -9,8 +10,9 @@ export function parseManifestDocs(text: string): Document[] {
9
10
  }
10
11
 
11
12
  /** The owner document of a manifest — the single `Telo.Application` /
12
- * `Telo.Library` doc that carries its identity, `files:`, and `filesIntegrity`.
13
- * The one place that selects it, shared by every reader/writer. */
13
+ * `Telo.Library` doc that carries its identity, `files:` / `assets:`, and the
14
+ * published `layers:` index. The one place that selects it, shared by every
15
+ * reader/writer. */
14
16
  export function findOwnerDoc(docs: Document[]): Document | undefined {
15
17
  return docs.find((d) => {
16
18
  const kind = (d.toJSON() as { kind?: string } | null)?.kind;
@@ -23,7 +25,13 @@ export function findOwnerDoc(docs: Document[]): Document | undefined {
23
25
  export interface OwnerManifest {
24
26
  name?: string;
25
27
  version?: string;
26
- filesIntegrity?: string;
28
+ /** The published layer index, absent on an unpublished manifest. Parsed and
29
+ * validated through the analyzer's shared model, so a malformed index is a
30
+ * hard `LayerIndexError` rather than a silently ignored field. */
31
+ layers?: ArtifactLayer[];
32
+ /** Ordered `.gitignore`-style patterns the author claimed as the lazily
33
+ * materialized `assets` layer. */
34
+ assetPatterns: string[];
27
35
  /** True when the owner doc declares a non-empty `files:` list. */
28
36
  declaresFiles: boolean;
29
37
  /** Descriptive provenance a transport projects into its backend's metadata
@@ -36,18 +44,26 @@ export interface OwnerManifest {
36
44
 
37
45
  /** Read the owner doc's identity + payload fields from a manifest, parsing once
38
46
  * (never regex-scraping). The single source both transports call for the
39
- * module's name and version, `filesIntegrity`, and payload detection. */
47
+ * module's name and version, its layer index, and payload detection. */
40
48
  export function readOwnerManifest(text: string): OwnerManifest {
41
49
  const owner = findOwnerDoc(parseManifestDocs(text));
42
50
  const parsed = owner?.toJSON() as
43
- | { metadata?: Record<string, unknown>; files?: unknown; filesIntegrity?: unknown }
51
+ | {
52
+ metadata?: Record<string, unknown>;
53
+ files?: unknown;
54
+ assets?: unknown;
55
+ layers?: unknown;
56
+ }
44
57
  | undefined;
45
58
  const md = parsed?.metadata ?? {};
46
59
  const str = (v: unknown): string | undefined => (typeof v === "string" ? v : undefined);
60
+ const patterns = (v: unknown): string[] =>
61
+ Array.isArray(v) ? v.filter((p): p is string => typeof p === "string") : [];
47
62
  return {
48
63
  name: str(md.name),
49
64
  version: str(md.version),
50
- filesIntegrity: str(parsed?.filesIntegrity),
65
+ layers: parsed?.layers === undefined ? undefined : parseLayerIndex(parsed.layers),
66
+ assetPatterns: patterns(parsed?.assets),
51
67
  declaresFiles: Array.isArray(parsed?.files) && parsed.files.length > 0,
52
68
  description: str(md.description),
53
69
  repository: str(md.repository),
@@ -1,5 +1,6 @@
1
1
  import { ControllerInstance, RuntimeError, type Logger } from "@telorun/sdk";
2
2
  import { BundleControllerLoader } from "./controller-loaders/bundle-loader.js";
3
+ import type { ModuleArtifact } from "./bundle/module-artifact.js";
3
4
  import { ControllerEnvMissingError, NapiControllerLoader } from "./controller-loaders/napi-loader.js";
4
5
  import { NpmControllerLoader } from "./controller-loaders/npm-loader.js";
5
6
  import { ControllerPolicy, DEFAULT_POLICY, POLICY_WILDCARD } from "./runtime-registry.js";
@@ -117,6 +118,7 @@ export class ControllerLoader {
117
118
  purlCandidates: string[],
118
119
  baseUri: string,
119
120
  policy?: ControllerPolicy,
121
+ artifact?: ModuleArtifact,
120
122
  ): Promise<ControllerInstance> {
121
123
  if (!purlCandidates || purlCandidates.length === 0) {
122
124
  throw new RuntimeError("ERR_CONTROLLER_NOT_FOUND", "Missing controller PURL candidates");
@@ -135,7 +137,7 @@ export class ControllerLoader {
135
137
  await this.emit?.({ name: "ControllerLoading", payload: { purl } });
136
138
  const startedAt = Date.now();
137
139
  try {
138
- const { instance, source } = await this.dispatchOne(purl, baseUri);
140
+ const { instance, source } = await this.dispatchOne(purl, baseUri, artifact);
139
141
  await this.emit?.({
140
142
  name: "ControllerLoaded",
141
143
  payload: { purl, source, durationMs: Date.now() - startedAt },
@@ -187,6 +189,7 @@ export class ControllerLoader {
187
189
  purlCandidates: string[],
188
190
  baseUri: string,
189
191
  policy?: ControllerPolicy,
192
+ artifact?: ModuleArtifact,
190
193
  ): Promise<ResolvedController> {
191
194
  if (!purlCandidates || purlCandidates.length === 0) {
192
195
  throw new RuntimeError("ERR_CONTROLLER_NOT_FOUND", "Missing controller PURL candidates");
@@ -202,7 +205,7 @@ export class ControllerLoader {
202
205
  const errors: string[] = [];
203
206
  for (const purl of ordered) {
204
207
  try {
205
- const { source, importInstance } = await this.dispatchResolveOne(purl, baseUri);
208
+ const { source, importInstance } = await this.dispatchResolveOne(purl, baseUri, artifact);
206
209
  return { purl, source, importInstance };
207
210
  } catch (err) {
208
211
  if (err instanceof ControllerEnvMissingError) {
@@ -221,14 +224,16 @@ export class ControllerLoader {
221
224
  private async dispatchOne(
222
225
  purl: string,
223
226
  baseUri: string,
227
+ artifact?: ModuleArtifact,
224
228
  ): Promise<{ instance: ControllerInstance; source: ControllerResolveSource }> {
225
- const { source, importInstance } = await this.dispatchResolveOne(purl, baseUri);
229
+ const { source, importInstance } = await this.dispatchResolveOne(purl, baseUri, artifact);
226
230
  return { instance: await importInstance(), source };
227
231
  }
228
232
 
229
233
  private async dispatchResolveOne(
230
234
  purl: string,
231
235
  baseUri: string,
236
+ artifact?: ModuleArtifact,
232
237
  ): Promise<{ source: ControllerResolveSource; importInstance: () => Promise<ControllerInstance> }> {
233
238
  if (purl.startsWith("pkg:npm")) {
234
239
  return this.npmLoader.resolve(purl, baseUri);
@@ -237,7 +242,7 @@ export class ControllerLoader {
237
242
  return this.napiLoader.resolve(purl, baseUri);
238
243
  }
239
244
  if (purl.startsWith("pkg:telo")) {
240
- return this.bundleLoader.resolve(purl, baseUri);
245
+ return this.bundleLoader.resolve(purl, baseUri, artifact);
241
246
  }
242
247
  throw new ControllerEnvMissingError(`Unsupported PURL scheme: ${purl}`);
243
248
  }