@telorun/kernel 0.59.0 → 0.61.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 (120) 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/controllers/resource-definition/resource-inherited-controller.d.ts.map +1 -1
  29. package/dist/controllers/resource-definition/resource-inherited-controller.js +57 -10
  30. package/dist/controllers/resource-definition/resource-inherited-controller.js.map +1 -1
  31. package/dist/controllers/type/json-schema-controller.d.ts +8 -0
  32. package/dist/controllers/type/json-schema-controller.d.ts.map +1 -0
  33. package/dist/controllers/type/json-schema-controller.js +91 -0
  34. package/dist/controllers/type/json-schema-controller.js.map +1 -0
  35. package/dist/directory-lock.d.ts +27 -0
  36. package/dist/directory-lock.d.ts.map +1 -0
  37. package/dist/directory-lock.js +205 -0
  38. package/dist/directory-lock.js.map +1 -0
  39. package/dist/evaluation-context.d.ts +5 -0
  40. package/dist/evaluation-context.d.ts.map +1 -1
  41. package/dist/evaluation-context.js +63 -33
  42. package/dist/evaluation-context.js.map +1 -1
  43. package/dist/index.d.ts +5 -2
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +4 -1
  46. package/dist/index.js.map +1 -1
  47. package/dist/init-failure-diagnostics.d.ts +61 -0
  48. package/dist/init-failure-diagnostics.d.ts.map +1 -0
  49. package/dist/init-failure-diagnostics.js +141 -0
  50. package/dist/init-failure-diagnostics.js.map +1 -0
  51. package/dist/invocation-contract-binding.d.ts +105 -0
  52. package/dist/invocation-contract-binding.d.ts.map +1 -0
  53. package/dist/invocation-contract-binding.js +296 -0
  54. package/dist/invocation-contract-binding.js.map +1 -0
  55. package/dist/kernel.d.ts +49 -0
  56. package/dist/kernel.d.ts.map +1 -1
  57. package/dist/kernel.js +117 -6
  58. package/dist/kernel.js.map +1 -1
  59. package/dist/manifest-sources/local-manifest-cache-source.d.ts +1 -1
  60. package/dist/manifest-sources/local-manifest-cache-source.d.ts.map +1 -1
  61. package/dist/manifest-sources/local-manifest-cache-source.js +1 -1
  62. package/dist/manifest-sources/local-manifest-cache-source.js.map +1 -1
  63. package/dist/module-context.d.ts.map +1 -1
  64. package/dist/module-context.js +21 -0
  65. package/dist/module-context.js.map +1 -1
  66. package/dist/resource-context.d.ts +61 -0
  67. package/dist/resource-context.d.ts.map +1 -1
  68. package/dist/resource-context.js +131 -0
  69. package/dist/resource-context.js.map +1 -1
  70. package/dist/schema-compiled-values.d.ts +9 -1
  71. package/dist/schema-compiled-values.d.ts.map +1 -1
  72. package/dist/schema-compiled-values.js +55 -16
  73. package/dist/schema-compiled-values.js.map +1 -1
  74. package/dist/schema-validator.d.ts.map +1 -1
  75. package/dist/schema-validator.js +15 -1
  76. package/dist/schema-validator.js.map +1 -1
  77. package/dist/transports/oci/oci-client.d.ts +28 -1
  78. package/dist/transports/oci/oci-client.d.ts.map +1 -1
  79. package/dist/transports/oci/oci-client.js +24 -1
  80. package/dist/transports/oci/oci-client.js.map +1 -1
  81. package/dist/transports/oci/oci-transport.d.ts +20 -14
  82. package/dist/transports/oci/oci-transport.d.ts.map +1 -1
  83. package/dist/transports/oci/oci-transport.js +105 -46
  84. package/dist/transports/oci/oci-transport.js.map +1 -1
  85. package/dist/transports/registry-transport.d.ts +7 -2
  86. package/dist/transports/registry-transport.d.ts.map +1 -1
  87. package/dist/transports/registry-transport.js +8 -33
  88. package/dist/transports/registry-transport.js.map +1 -1
  89. package/dist/transports/transport-registry.d.ts +5 -4
  90. package/dist/transports/transport-registry.d.ts.map +1 -1
  91. package/dist/transports/transport-registry.js +4 -4
  92. package/dist/transports/transport-registry.js.map +1 -1
  93. package/dist/transports/transport.d.ts +27 -19
  94. package/dist/transports/transport.d.ts.map +1 -1
  95. package/package.json +3 -3
  96. package/src/bundle/files-integrity.ts +34 -14
  97. package/src/bundle/module-artifact.ts +375 -0
  98. package/src/bundle/module-manifest.ts +22 -6
  99. package/src/controller-loader.ts +9 -4
  100. package/src/controller-loaders/bundle-loader.ts +57 -11
  101. package/src/controller-loaders/npm-loader.ts +3 -209
  102. package/src/controllers/resource-definition/resource-definition-controller.ts +19 -0
  103. package/src/controllers/resource-definition/resource-inherited-controller.ts +69 -10
  104. package/src/controllers/type/json-schema-controller.ts +114 -0
  105. package/src/directory-lock.ts +225 -0
  106. package/src/evaluation-context.ts +81 -32
  107. package/src/index.ts +11 -2
  108. package/src/init-failure-diagnostics.ts +169 -0
  109. package/src/invocation-contract-binding.ts +392 -0
  110. package/src/kernel.ts +149 -6
  111. package/src/manifest-sources/local-manifest-cache-source.ts +1 -1
  112. package/src/module-context.ts +27 -0
  113. package/src/resource-context.ts +138 -0
  114. package/src/schema-compiled-values.ts +55 -15
  115. package/src/schema-validator.ts +15 -1
  116. package/src/transports/oci/oci-client.ts +32 -1
  117. package/src/transports/oci/oci-transport.ts +122 -51
  118. package/src/transports/registry-transport.ts +10 -38
  119. package/src/transports/transport-registry.ts +5 -5
  120. package/src/transports/transport.ts +32 -19
@@ -1,3 +1,4 @@
1
+ import { describeSelector, selectorFromQualifiers, selectorMatches } from "@telorun/analyzer";
1
2
  import { ControllerInstance, RuntimeError } from "@telorun/sdk";
2
3
  import { existsSync, readFileSync } from "fs";
3
4
  import * as fs from "fs/promises";
@@ -5,10 +6,18 @@ import { createRequire } from "module";
5
6
  import { PackageURL } from "packageurl-js";
6
7
  import * as path from "path";
7
8
  import { fileURLToPath, pathToFileURL } from "url";
9
+ import { hostPlatformTarget, type ModuleArtifact } from "../bundle/module-artifact.js";
8
10
  import type { ControllerResolveSource } from "../controller-loader.js";
9
11
  import { ControllerEnvMissingError } from "./napi-loader.js";
10
12
  import { REALM_COLLAPSE_NAMES } from "./realm.js";
11
13
 
14
+ /** A base URI whose files are already on disk: a `file://` URL or a bare
15
+ * absolute path. Everything else (`oci://`, `http(s)://`, `memory://`) names a
16
+ * module whose payload only exists inside an artifact. */
17
+ function isLocalBase(baseUri: string): boolean {
18
+ return baseUri.startsWith("file://") || path.isAbsolute(baseUri);
19
+ }
20
+
12
21
  async function pathExists(filePath: string): Promise<boolean> {
13
22
  try {
14
23
  await fs.access(filePath);
@@ -140,20 +149,23 @@ export class BundleControllerLoader {
140
149
  async load(
141
150
  purl: string,
142
151
  baseUri: string,
152
+ artifact?: ModuleArtifact,
143
153
  ): Promise<{ instance: ControllerInstance; source: ControllerResolveSource }> {
144
- const { source, importInstance } = await this.resolve(purl, baseUri);
154
+ const { source, importInstance } = await this.resolve(purl, baseUri, artifact);
145
155
  return { instance: await importInstance(), source };
146
156
  }
147
157
 
148
158
  /**
149
- * Resolve without importing: parse + validate the PURL, confirm the bundle
150
- * file exists and its format is hostable, and ensure the realm symlinks — all
151
- * cheap, fail-fast checks — but defer the bundle `import()` (the eval cost)
152
- * into the returned `importInstance` thunk. Used by lazy controller loading.
159
+ * Resolve without importing: parse + validate the PURL, reject a candidate this
160
+ * host cannot run, materialize the layer that carries it, confirm the file
161
+ * exists, and ensure the realm symlinks — all fail-fast checks — but defer the
162
+ * bundle `import()` (the eval cost) into the returned `importInstance` thunk.
163
+ * Used by lazy controller loading.
153
164
  */
154
165
  async resolve(
155
166
  purl: string,
156
167
  baseUri: string,
168
+ artifact?: ModuleArtifact,
157
169
  ): Promise<{ source: ControllerResolveSource; importInstance: () => Promise<ControllerInstance> }> {
158
170
  let parsed: PackageURL;
159
171
  try {
@@ -190,16 +202,50 @@ export class BundleControllerLoader {
190
202
  );
191
203
  }
192
204
 
193
- // Bundles are local files next to the manifest. A remote (http) baseUri
194
- // can't host one, so defer to the next candidate.
195
- if (baseUri.startsWith("http://") || baseUri.startsWith("https://")) {
205
+ // Platform gate, BEFORE any materialization. A candidate list names one
206
+ // binary per platform, so checking the host first is what keeps a fallthrough
207
+ // from downloading every platform's layer on the way to the right one.
208
+ const selector = selectorFromQualifiers(format, parsed.qualifiers, `controller "${purl}"`);
209
+ const host = hostPlatformTarget();
210
+ if (!selectorMatches(selector, host)) {
196
211
  throw new ControllerEnvMissingError(
197
- `pkg:telo controller "${purl}" requires a local manifest; baseUri is remote (${baseUri})`,
212
+ `pkg:telo controller "${purl}" targets ${describeSelector(selector)}, which does not ` +
213
+ `match this host (${host.os ?? "unknown os"}/${host.arch ?? "unknown arch"}` +
214
+ `${host.libc ? `/${host.libc}` : ""})`,
198
215
  );
199
216
  }
200
217
 
201
- const basePath = baseUri.startsWith("file://") ? fileURLToPath(baseUri) : baseUri;
202
- const absFile = path.resolve(path.dirname(basePath), relPath);
218
+ // A published module's payload lives in its artifact, so materialize the one
219
+ // layer carrying this candidate and resolve `path=` inside it. Nothing is
220
+ // fetched here: the artifact handle owns the pinned ref and the verified
221
+ // layer index, so an `oci://` module ref never reaches this loader as a path.
222
+ let bundleDir: string;
223
+ if (artifact) {
224
+ // By its own selector, not by re-matching the host: this candidate IS one
225
+ // selector, and it is exactly the key of the layer that carries it.
226
+ const layer = await artifact.materializeController(selector);
227
+ if (!layer) {
228
+ throw new ControllerEnvMissingError(
229
+ `pkg:telo controller "${purl}": the module artifact ships no layer for ` +
230
+ `${describeSelector(selector)} (has: ${artifact.describeLayers()})`,
231
+ );
232
+ }
233
+ bundleDir = layer.dir;
234
+ } else {
235
+ // No artifact: a module already on disk (local development, or a manifest
236
+ // served from the on-disk cache). Its files sit next to the manifest.
237
+ if (!isLocalBase(baseUri)) {
238
+ throw new ControllerEnvMissingError(
239
+ `pkg:telo controller "${purl}" cannot be located: the declaring module resolved from ` +
240
+ `"${baseUri}", which is neither a local path nor an artifact with a layer index. ` +
241
+ `A bundled controller ships in its module's artifact — republish the module, or ` +
242
+ `import it from a local path during development.`,
243
+ );
244
+ }
245
+ bundleDir = path.dirname(baseUri.startsWith("file://") ? fileURLToPath(baseUri) : baseUri);
246
+ }
247
+
248
+ const absFile = path.resolve(bundleDir, relPath);
203
249
  if (!(await pathExists(absFile))) {
204
250
  throw new ControllerEnvMissingError(
205
251
  `pkg:telo controller bundle not found at "${absFile}" (from "${purl}")`,
@@ -10,6 +10,7 @@ import { fileURLToPath, pathToFileURL } from "url";
10
10
  import { promisify } from "util";
11
11
  import { hostEnv } from "../host-env.js";
12
12
  import { tryBuildControllerBundle } from "./bundle-builder.js";
13
+ import { withDirectoryLock } from "../directory-lock.js";
13
14
  import { ControllerEnvMissingError } from "./napi-loader.js";
14
15
  import { REALM_COLLAPSE_NAMES } from "./realm.js";
15
16
 
@@ -43,39 +44,6 @@ const PEER_INSTALL_FLAGS: ReadonlyArray<string> =
43
44
  ? ["--no-strict-peer-dependencies"]
44
45
  : [];
45
46
 
46
- /**
47
- * A held lock is refreshed (its mtime bumped) every {@link LOCK_HEARTBEAT_MS}
48
- * by the holder. Staleness is judged purely by mtime age — a lock older than
49
- * this means the holder stopped heartbeating (crashed, was killed, or its
50
- * container vanished), so it is safe to reclaim. This deliberately does NOT
51
- * probe the recorded PID for liveness: PID identity is meaningless across
52
- * container restarts and PID namespaces (deterministic PID reuse makes an
53
- * unrelated process look like the dead holder on the same hostname), which is
54
- * exactly what deadlocked container boots. The `{pid, host}` in the lock body
55
- * is diagnostics for humans, never a reclaim signal. Must be comfortably
56
- * larger than the heartbeat interval so a briefly-descheduled holder (GC
57
- * pause, busy event loop) is not reclaimed out from under itself.
58
- */
59
- const LOCK_STALE_MS = 30_000;
60
-
61
- /** How often the holder refreshes the lock mtime while `fn` runs. Well under
62
- * {@link LOCK_STALE_MS} so several heartbeats are missed before a live holder
63
- * is ever judged stale. */
64
- const LOCK_HEARTBEAT_MS = 5_000;
65
-
66
- /**
67
- * Total wall-clock cap for waiting on the install lock — enough for a slow
68
- * first install on a peer process to finish, short enough that a genuinely
69
- * deadlocked CI job fails loudly rather than hanging for hours. The retry
70
- * interval trades wakeup latency vs. wasted polls; 500ms is well below the
71
- * lock holder's typical hold time.
72
- */
73
- const LOCK_WAIT_MAX_MS = 5 * 60_000;
74
- const LOCK_RETRY_MS = 500;
75
-
76
- /** After this long waiting on a lock, emit one stderr line so a slow wait is
77
- * visible instead of looking like a silent hang. */
78
- const LOCK_WAIT_NOTICE_MS = 2_000;
79
47
 
80
48
  /**
81
49
  * Tells the dispatcher (and any UI consumer downstream) which branch the
@@ -325,7 +293,7 @@ export class NpmControllerLoader {
325
293
  return installRoot;
326
294
  }
327
295
 
328
- await withInstallLock(installRoot, async () => {
296
+ await withDirectoryLock(installRoot, "controller install", async () => {
329
297
  // Re-check inside the lock: a peer may have completed the install
330
298
  // between the fast-path miss and our acquisition.
331
299
  const existingHash = await readJsonField(stateFile, "rootHash");
@@ -447,7 +415,7 @@ export class NpmControllerLoader {
447
415
  }
448
416
 
449
417
  const work = (async () => {
450
- await withInstallLock(installRoot, async () => {
418
+ await withDirectoryLock(installRoot, "controller install", async () => {
451
419
  // Re-check inside the lock: a peer process may have installed the
452
420
  // spec between the fast-path miss and our acquisition.
453
421
  if (kind === "registry") {
@@ -557,176 +525,6 @@ async function resolveKernelPackageRoot(name: string): Promise<string | null> {
557
525
  }
558
526
  }
559
527
 
560
- /**
561
- * Serialize same-process callers for one install root, in memory, ahead of the
562
- * filesystem lock.
563
- *
564
- * Package installs dedupe per alias, so N distinct controllers are N distinct
565
- * `withInstallLock` calls against the SAME root. Without this queue they all
566
- * contend through the filesystem: one wins and the rest poll `fs.open` every
567
- * {@link LOCK_RETRY_MS} for the whole install, each crossing
568
- * {@link LOCK_WAIT_NOTICE_MS} and printing the wait notice — a notice whose
569
- * whole point is "another Telo *process* holds this", which is misleading when
570
- * the holder is us. (`telo install` fanning 52 controllers out through one
571
- * `Promise.allSettled` printed 51 of them.)
572
- *
573
- * Queuing here means exactly one caller per process reaches the fs lock, so the
574
- * notice regains its cross-process meaning and the losers do no I/O at all. The
575
- * fs lock is untouched and still provides the cross-process guarantee.
576
- */
577
- const localInstallQueues = new Map<string, Promise<unknown>>();
578
-
579
- function withLocalInstallQueue<T>(installRoot: string, fn: () => Promise<T>): Promise<T> {
580
- const prev = localInstallQueues.get(installRoot) ?? Promise.resolve();
581
- // Run on both settle paths: one failed install must not wedge the queue.
582
- const run = prev.then(fn, fn);
583
- // The stored tail never rejects — a failure neither poisons followers nor
584
- // surfaces as an unhandled rejection on the chain copy.
585
- const tail = run.then(
586
- () => {},
587
- () => {},
588
- );
589
- localInstallQueues.set(installRoot, tail);
590
- // Drop the entry once nothing further is queued, so a long-lived process that
591
- // touches many entry dirs doesn't retain a promise per root forever.
592
- void tail.then(() => {
593
- if (localInstallQueues.get(installRoot) === tail) localInstallQueues.delete(installRoot);
594
- });
595
- return run;
596
- }
597
-
598
- /**
599
- * Acquire the install lock for `installRoot` and run `fn` under it: first the
600
- * in-process queue above, then the cross-process filesystem lock.
601
- */
602
- async function withInstallLock<T>(
603
- installRoot: string,
604
- fn: () => Promise<T>,
605
- log: Logger = NOOP_LOGGER,
606
- ): Promise<T> {
607
- return withLocalInstallQueue(installRoot, () => withFileInstallLock(installRoot, fn, log));
608
- }
609
-
610
- /**
611
- * Acquire a process-portable lock on `<root>/.lock` and execute fn while
612
- * holding it. `fs.open(path, 'wx')` is atomic on POSIX and Windows, so
613
- * concurrent processes serialize naturally.
614
- *
615
- * Liveness is a heartbeat: the holder bumps the lock's mtime every
616
- * {@link LOCK_HEARTBEAT_MS} while `fn` runs, and a waiter reclaims a lock whose
617
- * mtime is older than {@link LOCK_STALE_MS} (holder crashed/killed/vanished).
618
- * mtime age is the *only* reclaim signal — the recorded `{pid, host}` is
619
- * diagnostics, never probed for liveness, because PID identity is unreliable
620
- * across container restarts and PID namespaces (the failure that deadlocked
621
- * container boots). Reclaim is via atomic rename to a unique tombstone so two
622
- * waiters that both see the lock stale can't both win.
623
- *
624
- * The lock guards the install-root manifest write, the package-manager
625
- * invocation, and any state-file writes. It does NOT serialize *reads* of
626
- * already-installed controllers — those run lock-free against a stable tree.
627
- */
628
- async function withFileInstallLock<T>(
629
- installRoot: string,
630
- fn: () => Promise<T>,
631
- log: Logger = NOOP_LOGGER,
632
- ): Promise<T> {
633
- const lockPath = path.join(installRoot, ".lock");
634
-
635
- await fs.mkdir(installRoot, { recursive: true });
636
-
637
- const lockBody = JSON.stringify({ pid: process.pid, host: os.hostname(), startedAt: Date.now() });
638
- let handle: import("fs/promises").FileHandle | null = null;
639
- const waitedSince = Date.now();
640
- let noticed = false;
641
- while (true) {
642
- try {
643
- handle = await fs.open(lockPath, "wx");
644
- await handle.writeFile(lockBody);
645
- break;
646
- } catch (err: any) {
647
- if (err?.code !== "EEXIST") throw err;
648
- // Lock exists. Reclaim it only if its heartbeat has gone silent.
649
- if (await reclaimIfStale(lockPath)) continue;
650
- const waited = Date.now() - waitedSince;
651
- if (waited > LOCK_WAIT_MAX_MS) {
652
- throw new Error(
653
- `[telo] timed out waiting for install lock at ${lockPath} ` +
654
- `(held >${LOCK_WAIT_MAX_MS / 60_000} min with a live heartbeat). ` +
655
- `Inspect the lock file or remove it manually if no other Telo process is running.`,
656
- );
657
- }
658
- if (!noticed && waited > LOCK_WAIT_NOTICE_MS) {
659
- noticed = true;
660
- log.info("waiting for controller install lock", { "telo.install.lock_path": lockPath });
661
- }
662
- await sleep(LOCK_RETRY_MS);
663
- }
664
- }
665
-
666
- // Keep the lock fresh while `fn` runs so a slow-but-live install is never
667
- // reclaimed. `unref` so the heartbeat can't by itself keep the process alive.
668
- const heartbeat = setInterval(() => {
669
- const now = new Date();
670
- fs.utimes(lockPath, now, now).catch(() => {});
671
- }, LOCK_HEARTBEAT_MS);
672
- heartbeat.unref?.();
673
-
674
- try {
675
- return await fn();
676
- } finally {
677
- clearInterval(heartbeat);
678
- // The fd close races nothing important: if it fails, the FD is reaped on
679
- // process exit. The unlink is the dangerous one — a non-ENOENT failure
680
- // (permissions, read-only mount) means every subsequent kernel waits
681
- // LOCK_STALE_MS before reclaiming. Surface it so the cause is visible
682
- // rather than hiding behind a silent hang.
683
- await handle!.close().catch(() => {});
684
- try {
685
- await fs.rm(lockPath, { force: true });
686
- } catch (err: any) {
687
- if (err?.code !== "ENOENT") {
688
- log.warn(
689
- "failed to release install lock",
690
- { "telo.install.lock_path": lockPath },
691
- { error: err },
692
- );
693
- }
694
- }
695
- }
696
- }
697
-
698
- /**
699
- * If the lock at `lockPath` is stale (mtime older than {@link LOCK_STALE_MS}, so
700
- * its holder stopped heartbeating), atomically claim and remove it and return
701
- * true; otherwise return false. The claim is a `rename` to a unique tombstone:
702
- * `rename` is atomic and fails for all but one racer, so two processes that
703
- * both observe the same stale lock cannot both reclaim it — the loser's rename
704
- * throws ENOENT (the file is already gone) and it simply retries the open.
705
- */
706
- async function reclaimIfStale(lockPath: string): Promise<boolean> {
707
- let stat: import("fs").Stats;
708
- try {
709
- stat = await fs.stat(lockPath);
710
- } catch (err: any) {
711
- // Race: lock vanished while we inspected it. Retry the open immediately.
712
- if (err?.code === "ENOENT") return true;
713
- throw err;
714
- }
715
- if (Date.now() - stat.mtimeMs < LOCK_STALE_MS) return false;
716
-
717
- // Stale — the holder's heartbeat is silent. Claim via atomic rename; only one
718
- // racer wins, the rest get ENOENT and fall back to retrying the open.
719
- const tombstone = `${lockPath}.stale.${process.pid}.${stat.mtimeMs}`;
720
- try {
721
- await fs.rename(lockPath, tombstone);
722
- } catch (err: any) {
723
- if (err?.code === "ENOENT") return true; // another waiter reclaimed it first
724
- throw err;
725
- }
726
- await fs.rm(tombstone, { force: true });
727
- return true;
728
- }
729
-
730
528
  async function runPackageManager(cwd: string, args: string[]): Promise<void> {
731
529
  try {
732
530
  await execFileAsync(PACKAGE_MANAGER, args, { cwd, maxBuffer: 32 * 1024 * 1024, env: hostEnv() });
@@ -1135,10 +933,6 @@ export const __testing__ = {
1135
933
  resolveExportTargetValue,
1136
934
  tryResolveFile,
1137
935
  computeInstallRoot,
1138
- withInstallLock,
1139
- reclaimIfStale,
1140
- LOCK_STALE_MS,
1141
- LOCK_HEARTBEAT_MS,
1142
936
  EXPORTS_MAX_DEPTH,
1143
937
  DEFAULT_RESOLVER_CONDITIONS,
1144
938
  REALM_COLLAPSE_NAMES,
@@ -14,6 +14,7 @@ import {
14
14
  inheritedCapability,
15
15
  type DefResolver,
16
16
  } from "@telorun/analyzer";
17
+ import type { ModuleArtifact } from "../../bundle/module-artifact.js";
17
18
  import { ControllerLoader } from "../../controller-loader.js";
18
19
  import { formatAjvErrors, validateResourceDefinition } from "../../manifest-schemas.js";
19
20
  import { createTemplateController } from "./resource-template-controller.js";
@@ -162,10 +163,18 @@ class ResourceDefinition implements ResourceInstance {
162
163
  // `controllers:` candidate fails fast at boot), but defer the expensive
163
164
  // import/eval and the controller's `register()` to the kind's first
164
165
  // instantiation. Definitions whose kind is never instantiated never import.
166
+ // The artifact of the module that DECLARED this kind — a bundled controller
167
+ // ships in its own module's payload, not the consumer's. It owns the pinned
168
+ // ref and the verified layer index, so the loader picks a candidate and asks
169
+ // 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
+ );
165
173
  const resolved = await loader.resolve(
166
174
  this.resource.controllers,
167
175
  this.resource.metadata.source,
168
176
  ctx.getControllerPolicy(),
177
+ artifact,
169
178
  );
170
179
  ctx.registerDefinition(this.resource);
171
180
 
@@ -198,6 +207,16 @@ class ResourceDefinition implements ResourceInstance {
198
207
  }
199
208
  }
200
209
 
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.
215
+ */
216
+ interface ModuleArtifactHost {
217
+ getModuleArtifact?(source: string | undefined): ModuleArtifact | undefined;
218
+ }
219
+
201
220
  /**
202
221
  * Kernel-internal hook the concrete `ResourceContextImpl` exposes for lazy
203
222
  * controller loading — deliberately off the public SDK `ResourceContext`
@@ -66,33 +66,89 @@ function resolveRefSlot(value: unknown, ctx: ResourceContext): ResourceInstance
66
66
  return instance ?? undefined;
67
67
  }
68
68
 
69
- /** Expand a `base:` node against `self`. Self-only CEL resolves to literals now;
70
- * a pure `self.<path>` access is navigated directly so live instances pass
71
- * through. */
72
- function expandBaseNode(value: unknown, self: Record<string, unknown>, ctx: EvaluationContext): unknown {
69
+ /** Expand a mapping node against a CEL scope. `self`-only CEL resolves to
70
+ * literals now; a pure `self.<path>` access is navigated directly so live
71
+ * instances pass through (CEL's output type checker rejects them). Shared by
72
+ * `base:` (construction, scope `{ self }`) and the dispatch mappings
73
+ * `inputs:` / `result:` (scope `{ self, inputs }` / `{ self, result }`). */
74
+ function expandMappingNode(
75
+ value: unknown,
76
+ scope: Record<string, unknown>,
77
+ ctx: EvaluationContext,
78
+ ): unknown {
73
79
  if (isCompiledValue(value)) {
74
80
  const src = typeof value.source === "string" ? value.source.trim() : "";
75
81
  const m = src.match(SELF_PATH);
76
82
  if (m) {
77
- let cur: unknown = self;
83
+ let cur: unknown = scope.self;
78
84
  for (const key of m[1].split(".").slice(1)) {
79
85
  cur = (cur as Record<string, unknown> | undefined)?.[key];
80
86
  }
81
87
  return cur;
82
88
  }
83
- return ctx.expandWith(value, { self });
89
+ return ctx.expandWith(value, scope);
84
90
  }
85
- if (Array.isArray(value)) return value.map((v) => expandBaseNode(v, self, ctx));
91
+ if (Array.isArray(value)) return value.map((v) => expandMappingNode(v, scope, ctx));
86
92
  if (value !== null && typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype) {
87
93
  const out: Record<string, unknown> = {};
88
94
  for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
89
- out[k] = expandBaseNode(v, self, ctx);
95
+ out[k] = expandMappingNode(v, scope, ctx);
90
96
  }
91
97
  return out;
92
98
  }
93
99
  return value;
94
100
  }
95
101
 
102
+ /**
103
+ * Bind the dispatch mappings of a child that REPLACED its inherited contract.
104
+ *
105
+ * A contract resolves to the nearest declaration along `extends` and never
106
+ * merges, so a child declaring `inputType` presents a signature the inherited
107
+ * controller has never heard of. `inputs:` is the adapter that turns the child's
108
+ * signature into the parent's call, and `result:` turns the parent's result back
109
+ * into the child's declared output — the same top-level-sibling factoring a
110
+ * template definition uses for `invoke:`.
111
+ *
112
+ * Bound onto the parent instance rather than wrapping it, so the child still IS
113
+ * a parent instance: `init`, `snapshot`, `teardown` and status plumbing are
114
+ * untouched, and nothing new appears in a declared ref slot. It composes with
115
+ * the kernel's contract binding by position — the parent's contract was bound
116
+ * when the parent instance was produced, this mapping goes on next, and the
117
+ * child's own contract is bound outermost by the caller. One dispatch therefore
118
+ * checks the child's inputs, maps, checks the parent's inputs, runs, checks the
119
+ * parent's result, maps back, and checks the child's result.
120
+ */
121
+ function bindDispatchMapping(
122
+ instance: ResourceInstance,
123
+ definition: ResourceDefinition,
124
+ self: Record<string, unknown>,
125
+ ctx: EvaluationContext,
126
+ ): void {
127
+ const body = definition as unknown as { inputs?: unknown; result?: unknown };
128
+ const inputsMapping = body.inputs;
129
+ const resultMapping = body.result;
130
+ if (inputsMapping == null && resultMapping == null) return;
131
+ if (typeof instance.invoke !== "function") return;
132
+
133
+ // Every argument is forwarded — the mapping rewrites `inputs`, while the
134
+ // InvokeContext (cancellation, tracing) in later parameters belongs to the
135
+ // caller and must reach the inherited controller untouched.
136
+ const original = instance.invoke.bind(instance) as (
137
+ inputs: any,
138
+ ...rest: unknown[]
139
+ ) => Promise<unknown>;
140
+ instance.invoke = async (inputs: any, ...rest: unknown[]) => {
141
+ const mappedInputs =
142
+ inputsMapping != null
143
+ ? (expandMappingNode(inputsMapping, { self, inputs }, ctx) as Record<string, unknown>)
144
+ : inputs;
145
+ const result = await original(mappedInputs, ...rest);
146
+ return resultMapping != null
147
+ ? expandMappingNode(resultMapping, { self, result }, ctx)
148
+ : result;
149
+ };
150
+ }
151
+
96
152
  /**
97
153
  * Controller for a definition that inherits its controller by delegation — it
98
154
  * `extends` a concrete kind, declares no own `controllers:` / template body, and
@@ -143,7 +199,7 @@ export function createInheritedController(
143
199
  // instances in `self`), minus the reserved keys.
144
200
  let parentConfig: Record<string, unknown>;
145
201
  if (base != null) {
146
- parentConfig = expandBaseNode(base, self, definingContext) as Record<string, unknown>;
202
+ parentConfig = expandMappingNode(base, { self }, definingContext) as Record<string, unknown>;
147
203
  } else {
148
204
  const { kind: _kind, metadata: _metadata, name: _name, ...config } = self;
149
205
  parentConfig = config;
@@ -159,7 +215,10 @@ export function createInheritedController(
159
215
  `Telo.Definition '${definition.metadata.name}': inherited controller requires a ResourceContext host that implements createInheritedInstance().`,
160
216
  );
161
217
  }
162
- return host.createInheritedInstance(definingContext, parentResource);
218
+ const instance = await host.createInheritedInstance(definingContext, parentResource);
219
+ if (!instance) return null;
220
+ bindDispatchMapping(instance, definition, self, definingContext);
221
+ return instance;
163
222
  },
164
223
  };
165
224
  }
@@ -0,0 +1,114 @@
1
+ import { evaluate } from "@marcbachmann/cel-js";
2
+ import type {
3
+ ResourceContext,
4
+ ResourceInstance,
5
+ ResourceManifest,
6
+ TypeRule,
7
+ } from "@telorun/sdk";
8
+ import { canonicalTypeSchemaId, mergeTypeSchemas, RuntimeError } from "@telorun/sdk";
9
+
10
+ /**
11
+ * `Telo.JsonSchema` — a named data shape.
12
+ *
13
+ * It lives in the kernel rather than in an installable module for the same
14
+ * reason the mandatory log sinks do: declaring a shape is not optional. Every
15
+ * kind that carries an invocation contract needs one, so requiring an import to
16
+ * write `inputType:` would tax the exact thing contracts want authors to do —
17
+ * and a library declaring its own contract would have to import a module purely
18
+ * to describe itself.
19
+ *
20
+ * `type.JsonSchema` is retained as a deprecated alias with the same behaviour, so
21
+ * published manifests keep resolving.
22
+ */
23
+ class JsonSchemaType {
24
+ constructor(
25
+ private readonly qualifiedName: string,
26
+ private readonly rules: TypeRule[],
27
+ /** The fully-resolved (post-`extends`), self-contained JSON Schema. Read by
28
+ * consumers that need the effective shape — e.g. a templated resource
29
+ * threading `${{ self.model.schema }}` into a request validation schema. */
30
+ readonly schema: Record<string, unknown>,
31
+ ) {}
32
+
33
+ /**
34
+ * Validate data against this type's CEL rules — the invariant layer on top of
35
+ * the schema itself, which AJV enforces through the schema registry.
36
+ */
37
+ validateRules(data: unknown): void {
38
+ for (const rule of this.rules) {
39
+ let result: unknown;
40
+ try {
41
+ result = evaluate(rule.condition, { this: data });
42
+ } catch (err) {
43
+ throw new RuntimeError(
44
+ "ERR_TYPE_VALIDATION_FAILED",
45
+ `Type "${this.qualifiedName}" rule evaluation failed: ${err instanceof Error ? err.message : String(err)}`,
46
+ );
47
+ }
48
+ if (result !== true) {
49
+ throw new RuntimeError(
50
+ rule.code,
51
+ rule.message ??
52
+ `Type "${this.qualifiedName}" validation failed: rule "${rule.code}" not satisfied`,
53
+ );
54
+ }
55
+ }
56
+ }
57
+ }
58
+
59
+ /** A type resource is pure declaration: it registers a schema and holds the
60
+ * resolved shape, implementing none of the lifecycle verbs. `ResourceInstance`
61
+ * is entirely optional members, so a class with none of them satisfies it only
62
+ * by assertion — the same shape the module-loaded controller had, where the
63
+ * dynamic-import boundary erased the type instead of asserting it. */
64
+ export async function create(
65
+ resource: ResourceManifest,
66
+ ctx: ResourceContext,
67
+ ): Promise<ResourceInstance | null> {
68
+ const qualifiedName = `${resource.metadata.module}.${resource.metadata.name}`;
69
+ const ownSchema = resource.schema as Record<string, unknown>;
70
+
71
+ let schema: Record<string, unknown> = ownSchema;
72
+
73
+ const extendsField = resource.extends as string | string[] | undefined;
74
+ if (extendsField) {
75
+ const parents = Array.isArray(extendsField) ? extendsField : [extendsField];
76
+
77
+ const parentSchemas: Record<string, unknown>[] = [];
78
+ for (const parent of parents) {
79
+ const parentSchema = ctx.lookupSchema(parent);
80
+ // Defer if any parent schema isn't registered yet (multi-pass resolution).
81
+ if (!parentSchema) return null;
82
+ parentSchemas.push(parentSchema as Record<string, unknown>);
83
+ }
84
+
85
+ // Each parent's registered schema is itself already resolved, so merging
86
+ // them makes inheritance transitive through grandparents with no `$ref`s
87
+ // left in the result.
88
+ schema = mergeTypeSchemas([...parentSchemas, ownSchema]);
89
+ }
90
+
91
+ const rules = (Array.isArray(resource.rules) ? resource.rules : []) as TypeRule[];
92
+
93
+ ctx.registerSchema(qualifiedName, schema);
94
+ ctx.registerTypeRules(qualifiedName, rules);
95
+
96
+ // Also register under the short name so types can be referenced without a
97
+ // module prefix.
98
+ const shortName = resource.metadata.name;
99
+ if (shortName !== qualifiedName) {
100
+ ctx.registerSchema(shortName, schema);
101
+ ctx.registerTypeRules(shortName, rules);
102
+ }
103
+
104
+ // Canonical module-scoped id — the target of `$ref: "telo://Self/<name>"` (and
105
+ // `telo://<Alias>/<name>` across imports) once the loader resolves the
106
+ // authority to this module. Authority-free, so a validator can actually
107
+ // resolve the reference; see `canonicalTypeSchemaId`.
108
+ const moduleName = resource.metadata.module as string | undefined;
109
+ if (moduleName) {
110
+ ctx.registerSchema(canonicalTypeSchemaId(moduleName, shortName), schema);
111
+ }
112
+
113
+ return new JsonSchemaType(qualifiedName, rules, schema) as unknown as ResourceInstance;
114
+ }