@telorun/kernel 0.9.2 → 0.11.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 (36) hide show
  1. package/dist/controller-loader.d.ts +11 -3
  2. package/dist/controller-loader.d.ts.map +1 -1
  3. package/dist/controller-loader.js +2 -2
  4. package/dist/controller-loader.js.map +1 -1
  5. package/dist/controller-loaders/npm-loader.d.ts +128 -13
  6. package/dist/controller-loaders/npm-loader.d.ts.map +1 -1
  7. package/dist/controller-loaders/npm-loader.js +764 -216
  8. package/dist/controller-loaders/npm-loader.js.map +1 -1
  9. package/dist/controllers/resource-definition/resource-definition-controller.d.ts.map +1 -1
  10. package/dist/controllers/resource-definition/resource-definition-controller.js +1 -0
  11. package/dist/controllers/resource-definition/resource-definition-controller.js.map +1 -1
  12. package/dist/generated/runtime-deps.json +6 -0
  13. package/dist/index.d.ts +1 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +1 -0
  16. package/dist/index.js.map +1 -1
  17. package/dist/kernel.d.ts +57 -5
  18. package/dist/kernel.d.ts.map +1 -1
  19. package/dist/kernel.js +137 -23
  20. package/dist/kernel.js.map +1 -1
  21. package/dist/manifest-sources/local-manifest-cache-source.d.ts +50 -0
  22. package/dist/manifest-sources/local-manifest-cache-source.d.ts.map +1 -0
  23. package/dist/manifest-sources/local-manifest-cache-source.js +227 -0
  24. package/dist/manifest-sources/local-manifest-cache-source.js.map +1 -0
  25. package/dist/resource-context.d.ts +1 -0
  26. package/dist/resource-context.d.ts.map +1 -1
  27. package/dist/resource-context.js +3 -0
  28. package/dist/resource-context.js.map +1 -1
  29. package/package.json +16 -5
  30. package/src/controller-loader.ts +13 -3
  31. package/src/controller-loaders/npm-loader.ts +843 -229
  32. package/src/controllers/resource-definition/resource-definition-controller.ts +1 -0
  33. package/src/index.ts +6 -0
  34. package/src/kernel.ts +157 -22
  35. package/src/manifest-sources/local-manifest-cache-source.ts +256 -0
  36. package/src/resource-context.ts +4 -0
@@ -47,6 +47,7 @@ class ResourceDefinition implements ResourceInstance {
47
47
  // here at the call site.
48
48
  const loader = new ControllerLoader({
49
49
  emit: (e) => ctx.emit(e.name, e.payload),
50
+ entryUrl: ctx.getEntryUrl(),
50
51
  });
51
52
  const controllerInstance = await loader.load(
52
53
  this.resource.controllers,
package/src/index.ts CHANGED
@@ -2,6 +2,12 @@ export { ControllerLoader } from "./controller-loader.js";
2
2
  export { ControllerRegistry } from "./controller-registry.js";
3
3
  export { EvaluationContext } from "./evaluation-context.js";
4
4
  export { LocalFileSource } from "./manifest-sources/local-file-source.js";
5
+ export {
6
+ LocalManifestCacheSource,
7
+ cachePathForCanonical,
8
+ resolveEntryDir,
9
+ writeManifestCache,
10
+ } from "./manifest-sources/local-manifest-cache-source.js";
5
11
  export { MemorySource } from "./manifest-sources/memory-source.js";
6
12
  export { EventStream } from "./event-stream.js";
7
13
  export { ExecutionContext } from "./execution-context.js";
package/src/kernel.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import {
2
2
  AnalysisRegistry,
3
+ flattenForAnalyzer,
4
+ flattenLoadedModule,
3
5
  isModuleKind,
4
6
  Loader,
5
7
  StaticAnalyzer,
@@ -48,6 +50,24 @@ function findEnclosingPolicy(ctx: IEvaluationContext): ControllerPolicy | undefi
48
50
  return findEnclosingModule(ctx)?.getControllerPolicy();
49
51
  }
50
52
 
53
+ function throwInvalidState(operation: string, reason: string): never {
54
+ throw new RuntimeError(
55
+ "ERR_KERNEL_STATE_INVALID",
56
+ `Cannot ${operation}(): ${reason}`,
57
+ );
58
+ }
59
+
60
+ function parseRef(ref: string): { kind: string; name: string } {
61
+ const lastDot = ref.lastIndexOf(".");
62
+ if (lastDot <= 0 || lastDot === ref.length - 1) {
63
+ throw new RuntimeError(
64
+ "ERR_INVALID_VALUE",
65
+ `Invalid resource reference '${ref}': expected '<Kind>.<Name>' (e.g. 'Http.Server.Main') or pass { kind, name } directly.`,
66
+ );
67
+ }
68
+ return { kind: ref.slice(0, lastDot), name: ref.slice(lastDot + 1) };
69
+ }
70
+
51
71
  export interface KernelOptions {
52
72
  stdin?: NodeJS.ReadableStream;
53
73
  stdout?: NodeJS.WritableStream;
@@ -96,6 +116,13 @@ export class Kernel implements IKernel {
96
116
  private readonly sharedSchemaValidator = new SchemaValidator();
97
117
  private rootContext!: ModuleContext;
98
118
  private staticManifests: ResourceManifest[] = [];
119
+ private _entryUrl?: string;
120
+ // Lifecycle state — guards boot/runTargets/teardown/invoke transitions.
121
+ // teardown() is the only idempotent method; everything else throws on misuse.
122
+ private _bootCalled = false;
123
+ private _isBooted = false;
124
+ private _targetsRan = false;
125
+ private _isTornDown = false;
99
126
 
100
127
  readonly stdin: NodeJS.ReadableStream;
101
128
  readonly stdout: NodeJS.WritableStream;
@@ -140,12 +167,15 @@ export class Kernel implements IKernel {
140
167
  this.registry.registerDefinition(definition);
141
168
  }
142
169
 
143
- loadModule(url: string, options?: LoadOptions): Promise<ResourceManifest[]> {
144
- return this.loader.loadModule(url, options);
170
+ async loadModule(url: string, options?: LoadOptions): Promise<ResourceManifest[]> {
171
+ const lm = await this.loader.loadModule(url, options);
172
+ return flattenLoadedModule(lm);
145
173
  }
146
174
 
147
- loadManifests(url: string): Promise<ResourceManifest[]> {
148
- return this.loader.loadManifests(url);
175
+ async loadManifests(url: string): Promise<ResourceManifest[]> {
176
+ const graph = await this.loader.loadGraph(url);
177
+ if (graph.errors.length > 0) throw graph.errors[0].error;
178
+ return flattenForAnalyzer(graph);
149
179
  }
150
180
 
151
181
  /** Returns the live analysis registry backed by this kernel's known definitions and aliases.
@@ -194,6 +224,7 @@ export class Kernel implements IKernel {
194
224
  */
195
225
  async load(url: string): Promise<void> {
196
226
  const sourceUrl = await this.loader.resolveEntryPoint(url);
227
+ this._entryUrl = sourceUrl;
197
228
  this.rootContext = new ModuleContext(
198
229
  sourceUrl,
199
230
  {},
@@ -218,7 +249,11 @@ export class Kernel implements IKernel {
218
249
 
219
250
  // Static analysis pre-flight: validates schemas and invocation context compatibility.
220
251
  // All errors are fatal — kernel does not start if analysis fails.
221
- const staticManifests = await this.loader.loadManifests(sourceUrl);
252
+ const analysisGraph = await this.loader.loadGraph(sourceUrl);
253
+ if (analysisGraph.errors.length > 0) {
254
+ throw analysisGraph.errors[0].error;
255
+ }
256
+ const staticManifests = flattenForAnalyzer(analysisGraph);
222
257
  this.staticManifests = staticManifests;
223
258
 
224
259
  // Register module identities for x-telo-ref resolution (Phase 3 prerequisite).
@@ -256,8 +291,11 @@ export class Kernel implements IKernel {
256
291
  );
257
292
  }
258
293
 
259
- // Load runtime configuration — root module gets access to host env
260
- const allManifests = await this.loader.loadModule(sourceUrl, { compile: true });
294
+ // Load runtime configuration — root module gets access to host env.
295
+ // Imports are loaded separately via the import-controller; this load is
296
+ // entry-only with compile-time CEL.
297
+ const lm = await this.loader.loadModule(sourceUrl, { compile: true });
298
+ const allManifests = flattenLoadedModule(lm);
261
299
 
262
300
  // Phase 2: normalize inline resources — extract inline values from x-telo-ref slots
263
301
  // into first-class named manifests and replace them in-place with {kind, name} references.
@@ -278,9 +316,25 @@ export class Kernel implements IKernel {
278
316
  }
279
317
 
280
318
  /**
281
- * Phase 2: Start - Initialize resources
319
+ * Initialize every resource declared in the manifest. Does not run targets
320
+ * and does not wait — returns as soon as the kernel is ready to accept
321
+ * `invoke()` calls.
322
+ *
323
+ * Throws ERR_KERNEL_STATE_INVALID if `load()` was not called first, on
324
+ * second call, or after teardown.
282
325
  */
283
- async start(): Promise<void> {
326
+ async boot(): Promise<void> {
327
+ if (this._isTornDown) {
328
+ throwInvalidState("boot", "kernel has been torn down");
329
+ }
330
+ if (this._bootCalled) {
331
+ throwInvalidState("boot", "boot() already called");
332
+ }
333
+ if (this._entryUrl === undefined) {
334
+ throwInvalidState("boot", "load() has not been called");
335
+ }
336
+ this._bootCalled = true;
337
+
284
338
  // Call register hooks for controllers actually loaded at this point (built-ins).
285
339
  // User-module kinds load their controllers during Phase 3 (Telo.Definition.init),
286
340
  // and registerController() fires their register hook there.
@@ -321,25 +375,103 @@ export class Kernel implements IKernel {
321
375
  this.rootContext.setInitOrder(order);
322
376
  }
323
377
 
324
- // Initialize resources
378
+ await this.rootContext.initializeResources();
379
+ await this.eventBus.emit("Kernel.Initialized", {});
380
+
381
+ this._isBooted = true;
382
+ }
383
+
384
+ /**
385
+ * Run the manifest's `targets` (Telo.Service / Telo.Runnable instances).
386
+ * Emits Kernel.Starting before, Kernel.Started after.
387
+ *
388
+ * Throws ERR_KERNEL_STATE_INVALID if called before `boot()` completes, after
389
+ * teardown, or a second time.
390
+ */
391
+ async runTargets(): Promise<void> {
392
+ if (this._isTornDown) {
393
+ throwInvalidState("runTargets", "kernel has been torn down");
394
+ }
395
+ if (!this._isBooted) {
396
+ throwInvalidState("runTargets", "boot() has not completed");
397
+ }
398
+ if (this._targetsRan) {
399
+ throwInvalidState("runTargets", "runTargets() already called");
400
+ }
401
+ this._targetsRan = true;
402
+
403
+ await this.eventBus.emit("Kernel.Starting", {});
404
+ await this.rootContext.runTargets();
405
+ await this.eventBus.emit("Kernel.Started", {});
406
+ }
407
+
408
+ /**
409
+ * Tear down every initialized resource. Emits Kernel.Stopping before,
410
+ * Kernel.Stopped after. Idempotent — second call is a no-op (does not
411
+ * re-emit). Tolerates partial state — a boot() that threw mid-init still
412
+ * cleans up whichever resources had initialized.
413
+ */
414
+ async teardown(): Promise<void> {
415
+ if (this._isTornDown) return;
416
+ this._isTornDown = true;
417
+
418
+ await this.eventBus.emit("Kernel.Stopping", {});
419
+ if (this.rootContext) {
420
+ await this.rootContext.teardownResources();
421
+ }
422
+ await this.eventBus.emit("Kernel.Stopped", { exitCode: this._exitCode });
423
+ }
424
+
425
+ /**
426
+ * Convenience: boot → runTargets → waitForIdle → teardown. The try wraps
427
+ * boot() and runTargets() too — init-time failures still drive teardown and
428
+ * still emit Kernel.Stopping / Kernel.Stopped, matching pre-split semantics.
429
+ */
430
+ async start(): Promise<void> {
325
431
  try {
326
- await this.rootContext.initializeResources();
327
- await this.eventBus.emit("Kernel.Initialized", {});
328
- await this.eventBus.emit("Kernel.Starting", {});
329
- await this.rootContext.runTargets();
330
- await this.eventBus.emit("Kernel.Started", {});
432
+ await this.boot();
433
+ await this.runTargets();
331
434
  await this.waitForIdle();
332
435
  } finally {
333
- await this.eventBus.emit("Kernel.Stopping", {});
334
- await this.rootContext.teardownResources();
335
- await this.eventBus.emit("Kernel.Stopped", { exitCode: this._exitCode });
436
+ await this.teardown();
336
437
  }
337
438
  }
338
439
 
440
+ /**
441
+ * Invoke a Telo.Invocable resource by `<kind>.<name>` (dot-form) or
442
+ * `{ kind, name }`. Resolves through the root module context, so the same
443
+ * dispatch, error path, and event emission that controller-to-controller
444
+ * invokes use apply here too.
445
+ */
446
+ async invoke<TInputs = any, TOutput = any>(
447
+ ref: string | { kind: string; name: string },
448
+ inputs: TInputs,
449
+ ): Promise<TOutput> {
450
+ if (this._isTornDown) {
451
+ throwInvalidState("invoke", "kernel has been torn down");
452
+ }
453
+ if (!this._isBooted) {
454
+ throwInvalidState("invoke", "boot() has not completed");
455
+ }
456
+ const parsed = typeof ref === "string" ? parseRef(ref) : ref;
457
+ return (await this.rootContext.invoke(parsed.kind, parsed.name, inputs)) as TOutput;
458
+ }
459
+
339
460
  async emitRuntimeEvent(event: string, payload?: any): Promise<void> {
340
461
  await this.eventBus.emit(event, payload);
341
462
  }
342
463
 
464
+ /**
465
+ * URL of the entry manifest passed to `load()`, or `undefined` before
466
+ * `load()` has been called. Used by controllers and the controller-loader
467
+ * to anchor per-manifest install roots so every resource in the process
468
+ * shares a single `node_modules` tree (and therefore one realpath for
469
+ * `@telorun/sdk`).
470
+ */
471
+ getEntryUrl(): string | undefined {
472
+ return this._entryUrl;
473
+ }
474
+
343
475
  get exitCode(): number {
344
476
  return this._exitCode;
345
477
  }
@@ -383,11 +515,14 @@ export class Kernel implements IKernel {
383
515
  }
384
516
 
385
517
  /**
386
- * Force-resolve waitForIdle() regardless of active holds.
387
- * Used for graceful shutdown when external signals (e.g. SIGINT) should
388
- * bypass resource holds and proceed directly to teardown.
518
+ * Force-resolve any pending `waitForIdle()` even when holds are still active.
519
+ * Used by external signal handlers (SIGINT/SIGTERM) to unblock graceful exit
520
+ * so `start()`'s waitForIdle returns and its finally clause runs `teardown()`.
521
+ *
522
+ * Does not tear down on its own — call `teardown()` directly if you're not
523
+ * inside `start()`.
389
524
  */
390
- shutdown(): void {
525
+ forceIdle(): void {
391
526
  const resolvers = this.idleResolvers.splice(0);
392
527
  for (const resolve of resolvers) resolve();
393
528
  }
@@ -0,0 +1,256 @@
1
+ import type { LoadedGraph, ManifestSource } from "@telorun/analyzer";
2
+ import { createHash } from "crypto";
3
+ import { statSync } from "fs";
4
+ import * as fs from "fs/promises";
5
+ import * as path from "path";
6
+ import { fileURLToPath, pathToFileURL } from "url";
7
+
8
+ const CACHE_SUBDIR = ".telo/manifests";
9
+ const HTTP_NAMESPACE = "__http";
10
+ const DEFAULT_MANIFEST_FILENAME = "telo.yaml";
11
+ const DEFAULT_REGISTRY_URL = "https://registry.telo.run";
12
+ const QUERY_HASH_LENGTH = 12;
13
+
14
+ /** Verify that `candidate` resolves to a path under `root`. Returns the
15
+ * candidate path on success, `null` when any segment escapes the root.
16
+ * Guards against `..` segments inside module refs or HTTP pathnames. */
17
+ function joinUnder(root: string, ...segments: string[]): string | null {
18
+ if (segments.some((s) => s === "")) return null;
19
+ const candidate = path.join(root, ...segments);
20
+ const resolved = path.resolve(candidate);
21
+ const resolvedRoot = path.resolve(root);
22
+ if (resolved !== resolvedRoot && !resolved.startsWith(resolvedRoot + path.sep)) {
23
+ return null;
24
+ }
25
+ return candidate;
26
+ }
27
+
28
+ /** Mirror `HttpSource.read`'s `fetchUrl` derivation: when the URL does not
29
+ * already point at a YAML file, append `/telo.yaml`. Used by both the
30
+ * reader and writer so a raw import URL and the canonical source it
31
+ * resolves to map to the same cache path. */
32
+ function normalizePathname(url: string, parsed: URL): string {
33
+ let pathname = parsed.pathname;
34
+ if (!url.includes(".yaml")) {
35
+ pathname = pathname.endsWith("/")
36
+ ? `${pathname}${DEFAULT_MANIFEST_FILENAME}`
37
+ : `${pathname}/${DEFAULT_MANIFEST_FILENAME}`;
38
+ }
39
+ return pathname;
40
+ }
41
+
42
+ /** Compute a short hash of `search + hash` so two URLs that differ only in
43
+ * query / fragment do not collide at the same cache path. Inserted before
44
+ * the final extension so the filename stays readable. */
45
+ function disambiguatePath(pathname: string, search: string, hash: string): string {
46
+ if (!search && !hash) return pathname;
47
+ const digest = createHash("sha256")
48
+ .update(search + hash)
49
+ .digest("hex")
50
+ .slice(0, QUERY_HASH_LENGTH);
51
+ const ext = path.extname(pathname);
52
+ const base = pathname.slice(0, pathname.length - ext.length);
53
+ return `${base}.${digest}${ext}`;
54
+ }
55
+
56
+ /** Single source of truth for URL → cache path. Used identically by the
57
+ * reader (cache lookup) and writer (install-time persistence). For any
58
+ * given import URL — registry ref, direct registry URL, or arbitrary
59
+ * HTTP — both sides land on the same file.
60
+ *
61
+ * Returns `null` for unsupported URLs (file://, memory://, relative paths)
62
+ * or for path-traversal attempts that would escape `cacheRoot`. */
63
+ function cachePathForUrl(
64
+ url: string,
65
+ cacheRoot: string,
66
+ registryUrl: string,
67
+ ): string | null {
68
+ const trimmedRegistry = registryUrl.replace(/\/+$/, "");
69
+
70
+ // 1. Registry ref form: namespace/name@version
71
+ if (
72
+ !url.startsWith("http://") &&
73
+ !url.startsWith("https://") &&
74
+ !url.startsWith("/") &&
75
+ !url.startsWith(".") &&
76
+ !url.startsWith("file://") &&
77
+ !url.startsWith("memory://") &&
78
+ url.includes("@") &&
79
+ url.includes("/")
80
+ ) {
81
+ const atIdx = url.lastIndexOf("@");
82
+ if (atIdx <= 0 || atIdx === url.length - 1) return null;
83
+ const modulePath = url.slice(0, atIdx);
84
+ if (!modulePath.includes("/")) return null;
85
+ const version = url.slice(atIdx + 1).replace(/^v/, "");
86
+ if (!version) return null;
87
+ return joinUnder(cacheRoot, modulePath, version, DEFAULT_MANIFEST_FILENAME);
88
+ }
89
+
90
+ // 2. HTTP(S) URL — could be a direct registry URL or arbitrary external.
91
+ if (url.startsWith("http://") || url.startsWith("https://")) {
92
+ let parsed: URL;
93
+ try {
94
+ parsed = new URL(url);
95
+ } catch {
96
+ return null;
97
+ }
98
+ const pathname = normalizePathname(url, parsed);
99
+
100
+ // 2a. URL is on the configured registry, with no query/fragment:
101
+ // fold into the registry layout so the writer that received a
102
+ // registry-ref canonical and the reader that sees a direct URL
103
+ // both resolve to the same file.
104
+ const normalizedUrl = `${parsed.protocol}//${parsed.host}${pathname}`;
105
+ if (
106
+ !parsed.search &&
107
+ !parsed.hash &&
108
+ (normalizedUrl === trimmedRegistry || normalizedUrl.startsWith(`${trimmedRegistry}/`))
109
+ ) {
110
+ const rel = normalizedUrl.slice(trimmedRegistry.length + 1);
111
+ if (!rel) return null;
112
+ return joinUnder(cacheRoot, ...rel.split("/"));
113
+ }
114
+
115
+ // 2b. Arbitrary HTTP(S) URL → __http subtree, with a short query-hash
116
+ // suffix when query / fragment are present to prevent collisions.
117
+ const cleanPath = pathname.startsWith("/") ? pathname.slice(1) : pathname;
118
+ const disambiguated = disambiguatePath(cleanPath, parsed.search, parsed.hash);
119
+ return joinUnder(cacheRoot, HTTP_NAMESPACE, parsed.host, ...disambiguated.split("/"));
120
+ }
121
+
122
+ return null;
123
+ }
124
+
125
+ /**
126
+ * Reads previously-cached manifest YAMLs from `<entry-dir>/.telo/manifests/`.
127
+ * Sits ahead of `RegistrySource` / `HttpSource` in the source chain — a hit
128
+ * makes boot hermetic, a miss falls through to the network source unchanged.
129
+ *
130
+ * Populated by `writeManifestCache` at install time.
131
+ */
132
+ export class LocalManifestCacheSource implements ManifestSource {
133
+ private readonly cacheRoot: string;
134
+ private readonly registryUrl: string;
135
+
136
+ constructor(entryDir: string, registryUrl: string = DEFAULT_REGISTRY_URL) {
137
+ this.cacheRoot = path.join(entryDir, CACHE_SUBDIR);
138
+ this.registryUrl = registryUrl;
139
+ }
140
+
141
+ supports(url: string): boolean {
142
+ return this.tryMap(url) !== null;
143
+ }
144
+
145
+ async read(url: string): Promise<{ text: string; source: string }> {
146
+ const mapped = this.tryMap(url);
147
+ if (!mapped) {
148
+ throw new Error(
149
+ `LocalManifestCacheSource does not support '${url}' (cache miss or unsupported scheme)`,
150
+ );
151
+ }
152
+ const text = await fs.readFile(mapped, "utf-8");
153
+ return { text, source: pathToFileURL(mapped).href };
154
+ }
155
+
156
+ resolveRelative(base: string, relative: string): string {
157
+ // Once `read()` serves a file the canonical `source` is a file:// URL, so
158
+ // any further include: / sibling resolution flows through LocalFileSource.
159
+ // This method exists only for completeness; if the loader ever invokes it
160
+ // with a cache-mapped base, fall back to file-URL semantics.
161
+ const baseDir = base.endsWith("/") ? base : base.slice(0, base.lastIndexOf("/") + 1);
162
+ return new URL(relative, baseDir).href;
163
+ }
164
+
165
+ private tryMap(url: string): string | null {
166
+ const candidate = cachePathForUrl(url, this.cacheRoot, this.registryUrl);
167
+ if (!candidate) return null;
168
+ // Require a regular file. A directory, dangling symlink, or stat failure
169
+ // (ENOENT, EACCES, EISDIR-on-component) all fall through as a cache miss
170
+ // so the next source in the chain still gets a chance to serve the URL.
171
+ try {
172
+ return statSync(candidate).isFile() ? candidate : null;
173
+ } catch {
174
+ return null;
175
+ }
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Map a graph's canonical `source` URL to the on-disk cache file path it
181
+ * should be written to (writer side). Returns `null` for sources that do
182
+ * not need caching — file:// (already on disk), memory:// (transient), or
183
+ * any path that would escape the cache root.
184
+ *
185
+ * Uses the same mapping function as `LocalManifestCacheSource`, so the
186
+ * writer and reader always agree on where every URL lives.
187
+ */
188
+ export function cachePathForCanonical(
189
+ canonicalSource: string,
190
+ entryDir: string,
191
+ registryUrl: string,
192
+ ): string | null {
193
+ const cacheRoot = path.join(entryDir, CACHE_SUBDIR);
194
+ return cachePathForUrl(canonicalSource, cacheRoot, registryUrl);
195
+ }
196
+
197
+ /**
198
+ * Persist every manifest file reachable from `graph` (owners + partials) to
199
+ * `<entryDir>/.telo/manifests/`, except the entry manifest itself and any
200
+ * file:// or memory:// sources (already on disk or transient).
201
+ *
202
+ * Idempotent: rewrites any existing file with the freshly fetched bytes so
203
+ * a partial re-install converges. Never deletes entries — stale versions
204
+ * stay until `.telo/manifests/` is removed by hand, matching the
205
+ * `.telo/npm/` convention.
206
+ *
207
+ * Returns the list of paths written, for diagnostics.
208
+ */
209
+ export async function writeManifestCache(
210
+ graph: LoadedGraph,
211
+ entryDir: string,
212
+ registryUrl: string = DEFAULT_REGISTRY_URL,
213
+ ): Promise<string[]> {
214
+ const written: string[] = [];
215
+ const seen = new Set<string>();
216
+
217
+ for (const [, module] of graph.modules) {
218
+ for (const file of [module.owner, ...module.partials]) {
219
+ if (file.source === graph.rootSource) continue;
220
+ if (seen.has(file.source)) continue;
221
+ seen.add(file.source);
222
+
223
+ const target = cachePathForCanonical(file.source, entryDir, registryUrl);
224
+ if (!target) continue;
225
+
226
+ await fs.mkdir(path.dirname(target), { recursive: true });
227
+ await fs.writeFile(target, file.text, "utf-8");
228
+ written.push(target);
229
+ }
230
+ }
231
+
232
+ return written;
233
+ }
234
+
235
+ /** Resolve the entry-anchor directory for the manifest cache.
236
+ *
237
+ * For a file path or `file://` URL: returns the containing directory.
238
+ * For a directory path: returns the directory itself.
239
+ * For an HTTP(S) URL: returns `null` (no local anchor; cache writes skipped). */
240
+ export function resolveEntryDir(entryPath: string): string | null {
241
+ if (entryPath.startsWith("http://") || entryPath.startsWith("https://")) {
242
+ return null;
243
+ }
244
+ let absolute: string;
245
+ if (entryPath.startsWith("file://")) {
246
+ absolute = fileURLToPath(entryPath);
247
+ } else {
248
+ absolute = path.resolve(entryPath);
249
+ }
250
+ try {
251
+ const stat = statSync(absolute);
252
+ return stat.isDirectory() ? absolute : path.dirname(absolute);
253
+ } catch {
254
+ return path.dirname(absolute);
255
+ }
256
+ }
@@ -262,6 +262,10 @@ export class ResourceContextImpl implements ResourceContext {
262
262
  return this.moduleContext.getControllerPolicy();
263
263
  }
264
264
 
265
+ getEntryUrl(): string | undefined {
266
+ return this.kernel.getEntryUrl();
267
+ }
268
+
265
269
  on(event: string, handler: (payload?: any) => void | Promise<void>): void {
266
270
  this.kernel.on(event, handler);
267
271
  }