@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.
- package/dist/bundle/files-integrity.d.ts +21 -12
- package/dist/bundle/files-integrity.d.ts.map +1 -1
- package/dist/bundle/files-integrity.js +27 -14
- package/dist/bundle/files-integrity.js.map +1 -1
- package/dist/bundle/module-artifact.d.ts +134 -0
- package/dist/bundle/module-artifact.d.ts.map +1 -0
- package/dist/bundle/module-artifact.js +300 -0
- package/dist/bundle/module-artifact.js.map +1 -0
- package/dist/bundle/module-manifest.d.ts +12 -4
- package/dist/bundle/module-manifest.d.ts.map +1 -1
- package/dist/bundle/module-manifest.js +8 -4
- package/dist/bundle/module-manifest.js.map +1 -1
- package/dist/controller-loader.d.ts +3 -2
- package/dist/controller-loader.d.ts.map +1 -1
- package/dist/controller-loader.js +8 -8
- package/dist/controller-loader.js.map +1 -1
- package/dist/controller-loaders/bundle-loader.d.ts +8 -6
- package/dist/controller-loaders/bundle-loader.d.ts.map +1 -1
- package/dist/controller-loaders/bundle-loader.js +52 -13
- package/dist/controller-loaders/bundle-loader.js.map +1 -1
- package/dist/controller-loaders/npm-loader.d.ts +0 -18
- package/dist/controller-loaders/npm-loader.d.ts.map +1 -1
- package/dist/controller-loaders/npm-loader.js +3 -190
- package/dist/controller-loaders/npm-loader.js.map +1 -1
- package/dist/controllers/resource-definition/resource-definition-controller.d.ts.map +1 -1
- package/dist/controllers/resource-definition/resource-definition-controller.js +6 -1
- package/dist/controllers/resource-definition/resource-definition-controller.js.map +1 -1
- package/dist/controllers/resource-definition/resource-inherited-controller.d.ts.map +1 -1
- package/dist/controllers/resource-definition/resource-inherited-controller.js +57 -10
- package/dist/controllers/resource-definition/resource-inherited-controller.js.map +1 -1
- package/dist/controllers/type/json-schema-controller.d.ts +8 -0
- package/dist/controllers/type/json-schema-controller.d.ts.map +1 -0
- package/dist/controllers/type/json-schema-controller.js +91 -0
- package/dist/controllers/type/json-schema-controller.js.map +1 -0
- package/dist/directory-lock.d.ts +27 -0
- package/dist/directory-lock.d.ts.map +1 -0
- package/dist/directory-lock.js +205 -0
- package/dist/directory-lock.js.map +1 -0
- package/dist/evaluation-context.d.ts +5 -0
- package/dist/evaluation-context.d.ts.map +1 -1
- package/dist/evaluation-context.js +63 -33
- package/dist/evaluation-context.js.map +1 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/init-failure-diagnostics.d.ts +61 -0
- package/dist/init-failure-diagnostics.d.ts.map +1 -0
- package/dist/init-failure-diagnostics.js +141 -0
- package/dist/init-failure-diagnostics.js.map +1 -0
- package/dist/invocation-contract-binding.d.ts +105 -0
- package/dist/invocation-contract-binding.d.ts.map +1 -0
- package/dist/invocation-contract-binding.js +296 -0
- package/dist/invocation-contract-binding.js.map +1 -0
- package/dist/kernel.d.ts +49 -0
- package/dist/kernel.d.ts.map +1 -1
- package/dist/kernel.js +117 -6
- package/dist/kernel.js.map +1 -1
- package/dist/manifest-sources/local-manifest-cache-source.d.ts +1 -1
- package/dist/manifest-sources/local-manifest-cache-source.d.ts.map +1 -1
- package/dist/manifest-sources/local-manifest-cache-source.js +1 -1
- package/dist/manifest-sources/local-manifest-cache-source.js.map +1 -1
- package/dist/module-context.d.ts.map +1 -1
- package/dist/module-context.js +21 -0
- package/dist/module-context.js.map +1 -1
- package/dist/resource-context.d.ts +61 -0
- package/dist/resource-context.d.ts.map +1 -1
- package/dist/resource-context.js +131 -0
- package/dist/resource-context.js.map +1 -1
- package/dist/schema-compiled-values.d.ts +9 -1
- package/dist/schema-compiled-values.d.ts.map +1 -1
- package/dist/schema-compiled-values.js +55 -16
- package/dist/schema-compiled-values.js.map +1 -1
- package/dist/schema-validator.d.ts.map +1 -1
- package/dist/schema-validator.js +15 -1
- package/dist/schema-validator.js.map +1 -1
- package/dist/transports/oci/oci-client.d.ts +28 -1
- package/dist/transports/oci/oci-client.d.ts.map +1 -1
- package/dist/transports/oci/oci-client.js +24 -1
- package/dist/transports/oci/oci-client.js.map +1 -1
- package/dist/transports/oci/oci-transport.d.ts +20 -14
- package/dist/transports/oci/oci-transport.d.ts.map +1 -1
- package/dist/transports/oci/oci-transport.js +105 -46
- package/dist/transports/oci/oci-transport.js.map +1 -1
- package/dist/transports/registry-transport.d.ts +7 -2
- package/dist/transports/registry-transport.d.ts.map +1 -1
- package/dist/transports/registry-transport.js +8 -33
- package/dist/transports/registry-transport.js.map +1 -1
- package/dist/transports/transport-registry.d.ts +5 -4
- package/dist/transports/transport-registry.d.ts.map +1 -1
- package/dist/transports/transport-registry.js +4 -4
- package/dist/transports/transport-registry.js.map +1 -1
- package/dist/transports/transport.d.ts +27 -19
- package/dist/transports/transport.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/bundle/files-integrity.ts +34 -14
- package/src/bundle/module-artifact.ts +375 -0
- package/src/bundle/module-manifest.ts +22 -6
- package/src/controller-loader.ts +9 -4
- package/src/controller-loaders/bundle-loader.ts +57 -11
- package/src/controller-loaders/npm-loader.ts +3 -209
- package/src/controllers/resource-definition/resource-definition-controller.ts +19 -0
- package/src/controllers/resource-definition/resource-inherited-controller.ts +69 -10
- package/src/controllers/type/json-schema-controller.ts +114 -0
- package/src/directory-lock.ts +225 -0
- package/src/evaluation-context.ts +81 -32
- package/src/index.ts +11 -2
- package/src/init-failure-diagnostics.ts +169 -0
- package/src/invocation-contract-binding.ts +392 -0
- package/src/kernel.ts +149 -6
- package/src/manifest-sources/local-manifest-cache-source.ts +1 -1
- package/src/module-context.ts +27 -0
- package/src/resource-context.ts +138 -0
- package/src/schema-compiled-values.ts +55 -15
- package/src/schema-validator.ts +15 -1
- package/src/transports/oci/oci-client.ts +32 -1
- package/src/transports/oci/oci-transport.ts +122 -51
- package/src/transports/registry-transport.ts +10 -38
- package/src/transports/transport-registry.ts +5 -5
- 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,
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
* into the returned `importInstance` thunk.
|
|
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
|
-
//
|
|
194
|
-
//
|
|
195
|
-
|
|
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}"
|
|
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
|
-
|
|
202
|
-
|
|
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
|
|
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
|
|
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
|
|
70
|
-
* a pure `self.<path>` access is navigated directly so live
|
|
71
|
-
* through.
|
|
72
|
-
|
|
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,
|
|
89
|
+
return ctx.expandWith(value, scope);
|
|
84
90
|
}
|
|
85
|
-
if (Array.isArray(value)) return value.map((v) =>
|
|
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] =
|
|
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 =
|
|
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
|
-
|
|
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
|
+
}
|