@telorun/kernel 0.78.0 → 0.80.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 (106) hide show
  1. package/dist/bundle/module-artifact.d.ts +15 -2
  2. package/dist/bundle/module-artifact.d.ts.map +1 -1
  3. package/dist/bundle/module-artifact.js +36 -13
  4. package/dist/bundle/module-artifact.js.map +1 -1
  5. package/dist/controller-loader.d.ts +62 -9
  6. package/dist/controller-loader.d.ts.map +1 -1
  7. package/dist/controller-loader.js +72 -64
  8. package/dist/controller-loader.js.map +1 -1
  9. package/dist/controller-loaders/bundle-loader.d.ts +2 -2
  10. package/dist/controller-loaders/bundle-loader.d.ts.map +1 -1
  11. package/dist/controller-loaders/bundle-loader.js +9 -3
  12. package/dist/controller-loaders/bundle-loader.js.map +1 -1
  13. package/dist/controller-loaders/napi-loader.d.ts +22 -1
  14. package/dist/controller-loaders/napi-loader.d.ts.map +1 -1
  15. package/dist/controller-loaders/napi-loader.js +35 -5
  16. package/dist/controller-loaders/napi-loader.js.map +1 -1
  17. package/dist/controller-loaders/npm-loader.d.ts +2 -1
  18. package/dist/controller-loaders/npm-loader.d.ts.map +1 -1
  19. package/dist/controller-loaders/npm-loader.js +81 -11
  20. package/dist/controller-loaders/npm-loader.js.map +1 -1
  21. package/dist/controller-loaders/source-bundle-builder.d.ts +2 -1
  22. package/dist/controller-loaders/source-bundle-builder.d.ts.map +1 -1
  23. package/dist/controller-loaders/source-bundle-builder.js +4 -1
  24. package/dist/controller-loaders/source-bundle-builder.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 +7 -3
  27. package/dist/controllers/resource-definition/resource-definition-controller.js.map +1 -1
  28. package/dist/controllers/resource-definition/resource-template-controller.d.ts.map +1 -1
  29. package/dist/controllers/resource-definition/resource-template-controller.js +12 -5
  30. package/dist/controllers/resource-definition/resource-template-controller.js.map +1 -1
  31. package/dist/dependency-injection.d.ts.map +1 -1
  32. package/dist/dependency-injection.js +7 -7
  33. package/dist/dependency-injection.js.map +1 -1
  34. package/dist/evaluation-context.d.ts +33 -13
  35. package/dist/evaluation-context.d.ts.map +1 -1
  36. package/dist/evaluation-context.js +145 -4
  37. package/dist/evaluation-context.js.map +1 -1
  38. package/dist/index.d.ts +2 -0
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +2 -0
  41. package/dist/index.js.map +1 -1
  42. package/dist/instance-declaration.d.ts +8 -0
  43. package/dist/instance-declaration.d.ts.map +1 -0
  44. package/dist/instance-declaration.js +34 -0
  45. package/dist/instance-declaration.js.map +1 -0
  46. package/dist/invocation-contract-binding.d.ts +15 -2
  47. package/dist/invocation-contract-binding.d.ts.map +1 -1
  48. package/dist/invocation-contract-binding.js +39 -4
  49. package/dist/invocation-contract-binding.js.map +1 -1
  50. package/dist/kernel.d.ts.map +1 -1
  51. package/dist/kernel.js +78 -14
  52. package/dist/kernel.js.map +1 -1
  53. package/dist/manifest-schemas.d.ts +1 -1
  54. package/dist/manifest-schemas.d.ts.map +1 -1
  55. package/dist/manifest-schemas.js +1 -1
  56. package/dist/manifest-schemas.js.map +1 -1
  57. package/dist/manifest-sources/analysis-stamp.d.ts +12 -8
  58. package/dist/manifest-sources/analysis-stamp.d.ts.map +1 -1
  59. package/dist/manifest-sources/analysis-stamp.js +50 -23
  60. package/dist/manifest-sources/analysis-stamp.js.map +1 -1
  61. package/dist/manifest-sources/local-manifest-cache-source.d.ts +45 -9
  62. package/dist/manifest-sources/local-manifest-cache-source.d.ts.map +1 -1
  63. package/dist/manifest-sources/local-manifest-cache-source.js +65 -12
  64. package/dist/manifest-sources/local-manifest-cache-source.js.map +1 -1
  65. package/dist/module-context.d.ts +5 -1
  66. package/dist/module-context.d.ts.map +1 -1
  67. package/dist/module-context.js +13 -1
  68. package/dist/module-context.js.map +1 -1
  69. package/dist/resource-context.d.ts +12 -1
  70. package/dist/resource-context.d.ts.map +1 -1
  71. package/dist/resource-context.js +15 -0
  72. package/dist/resource-context.js.map +1 -1
  73. package/dist/runtime-seam.d.ts.map +1 -1
  74. package/dist/runtime-seam.js +9 -1
  75. package/dist/runtime-seam.js.map +1 -1
  76. package/dist/workspace-marker.d.ts +37 -0
  77. package/dist/workspace-marker.d.ts.map +1 -0
  78. package/dist/workspace-marker.js +68 -0
  79. package/dist/workspace-marker.js.map +1 -0
  80. package/dist/zone-context.d.ts +16 -1
  81. package/dist/zone-context.d.ts.map +1 -1
  82. package/dist/zone-context.js +61 -1
  83. package/dist/zone-context.js.map +1 -1
  84. package/package.json +3 -3
  85. package/src/bundle/module-artifact.ts +47 -15
  86. package/src/controller-loader.ts +115 -81
  87. package/src/controller-loaders/bundle-loader.ts +10 -2
  88. package/src/controller-loaders/napi-loader.ts +42 -3
  89. package/src/controller-loaders/npm-loader.ts +86 -8
  90. package/src/controller-loaders/source-bundle-builder.ts +6 -0
  91. package/src/controllers/resource-definition/resource-definition-controller.ts +7 -3
  92. package/src/controllers/resource-definition/resource-template-controller.ts +12 -5
  93. package/src/dependency-injection.ts +7 -7
  94. package/src/evaluation-context.ts +195 -15
  95. package/src/index.ts +7 -0
  96. package/src/instance-declaration.ts +36 -0
  97. package/src/invocation-contract-binding.ts +49 -1
  98. package/src/kernel.ts +95 -14
  99. package/src/manifest-schemas.ts +1 -0
  100. package/src/manifest-sources/analysis-stamp.ts +53 -25
  101. package/src/manifest-sources/local-manifest-cache-source.ts +68 -13
  102. package/src/module-context.ts +24 -3
  103. package/src/resource-context.ts +18 -0
  104. package/src/runtime-seam.ts +9 -1
  105. package/src/workspace-marker.ts +68 -0
  106. package/src/zone-context.ts +70 -1
package/src/kernel.ts CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  type ContractValidatorFactory,
18
18
  resolveBoundContract,
19
19
  } from "./invocation-contract-binding.js";
20
+ import { readProjectionRef, type ProjectionScope } from "@telorun/analyzer";
20
21
  import {
21
22
  ControllerContext,
22
23
  ControllerPolicy,
@@ -26,6 +27,7 @@ import {
26
27
  ResourceDefinition,
27
28
  ResourceInstance,
28
29
  ResourceManifest,
30
+ stampRefIdentity,
29
31
  RuntimeError,
30
32
  RuntimeEvent,
31
33
  type BootTarget,
@@ -49,6 +51,7 @@ import { ambientInvokeContext } from "./evaluation-context.js";
49
51
  import { ModuleContext } from "./module-context.js";
50
52
  import { ResourceContextImpl } from "./resource-context.js";
51
53
  import { mintResourceHandle } from "./resource-handle.js";
54
+ import { declarationOfInstance, recordInstanceDeclaration } from "./instance-declaration.js";
52
55
  import { nodeHostVersions } from "./host-versions.js";
53
56
  import { nodeCelHandlers } from "./cel-handlers.js";
54
57
  import { parseRef, seedInvokeSource } from "./invoke-dispatch.js";
@@ -62,6 +65,7 @@ import {
62
65
  } from "./manifest-sources/analysis-stamp.js";
63
66
  import {
64
67
  cachePathForCanonical,
68
+ legacyManifestsDirFallback,
65
69
  resolveCacheRoot,
66
70
  resolveEntryDir,
67
71
  } from "./manifest-sources/local-manifest-cache-source.js";
@@ -418,19 +422,20 @@ export class Kernel implements IKernel {
418
422
  options?.cacheDir !== undefined ? options.cacheDir : resolveCacheRoot(sourceUrl);
419
423
  this._cacheRoot = cacheRoot;
420
424
  const manifestsDir = cacheRoot ? `${cacheRoot}/manifests` : undefined;
425
+ const analysisDir = cacheRoot ? `${cacheRoot}/analysis` : undefined;
421
426
  // `writeCache: false` (`telo run --no-cache-write`) keeps the cache
422
427
  // READ-only: compiled validators and the analysis stamp are still loaded
423
428
  // from disk, but never written back — so an ephemeral, read-only session
424
429
  // rootfs validates in-memory without touching the baked cache.
425
430
  const writeCache = options?.writeCache !== false;
426
431
  // Point the shared schema validator at the cache so compiled AJV validators
427
- // are loaded (and, when writable, persisted) under
428
- // `<cache-root>/manifests/__validators/`. Memory-/HTTP-rooted entries skip
429
- // the cache; their schema compiles stay in-process only.
430
- this.sharedSchemaValidator.setCacheDir(
431
- manifestsDir ? `${manifestsDir}/__validators` : undefined,
432
- { write: writeCache },
433
- );
432
+ // are loaded (and, when writable, persisted) under `<cache-root>/validators/`.
433
+ // Beside `manifests/` rather than inside it: a compiled validator is not a
434
+ // cached module manifest. Memory-/HTTP-rooted entries skip the cache; their
435
+ // schema compiles stay in-process only.
436
+ this.sharedSchemaValidator.setCacheDir(cacheRoot ? `${cacheRoot}/validators` : undefined, {
437
+ write: writeCache,
438
+ });
434
439
  this.rootContext = new ModuleContext(
435
440
  sourceUrl,
436
441
  {},
@@ -548,9 +553,7 @@ export class Kernel implements IKernel {
548
553
  // passes are elided. Memory- / HTTP-rooted entries have no
549
554
  // local stamp store and always re-validate.
550
555
  const analysisSignature = computeAnalysisSignature(analysisGraph);
551
- const stamp = manifestsDir
552
- ? await readAnalysisStamp("", manifestsDir)
553
- : undefined;
556
+ const stamp = analysisDir ? await readAnalysisStamp(sourceUrl, analysisDir) : undefined;
554
557
  const skipValidation = stamp?.signature === analysisSignature;
555
558
  const errors = this.analyzer.analyzeErrors(
556
559
  staticManifests,
@@ -571,13 +574,13 @@ export class Kernel implements IKernel {
571
574
  errors.map(staticDiagnosticToRuntime),
572
575
  );
573
576
  }
574
- if (manifestsDir && writeCache && !skipValidation) {
577
+ if (analysisDir && writeCache && !skipValidation) {
575
578
  // Best-effort: stamp the verdict so subsequent loads hit the fast
576
579
  // path. A read-only filesystem (baked Docker image) reports the
577
580
  // failure on stderr and keeps running — the lookup above will
578
581
  // simply miss next time. Skipped under `--no-cache-write`.
579
582
  try {
580
- await writeAnalysisStamp("", analysisSignature, manifestsDir);
583
+ await writeAnalysisStamp(sourceUrl, analysisSignature, analysisDir);
581
584
  } catch (err) {
582
585
  this.logging.kernelLogger().warn("analysis stamp write failed", undefined, { error: err });
583
586
  }
@@ -1057,6 +1060,11 @@ export class Kernel implements IKernel {
1057
1060
  this.siblingLibraries.clear();
1058
1061
  const transports = defaultTransportRegistry(this.registryUrl);
1059
1062
  const entryDir = this._entryUrl ? resolveEntryDir(this._entryUrl) ?? "" : "";
1063
+ // The same pre-anchor root `LocalManifestCacheSource` falls back to. Layers
1064
+ // live beside the cached manifest, so both halves have to look in the same
1065
+ // place or an offline upgrade resolves a manifest from disk and then fetches
1066
+ // its controllers.
1067
+ const legacyDir = legacyManifestsDirFallback(entryDir, manifestsDir ?? null);
1060
1068
  // Parsed once per module and shared with the sibling-library join below: a
1061
1069
  // `telo.yaml` is a large multi-document file, and re-reading each target's
1062
1070
  // once per import edge put dozens of redundant full-YAML parses on the boot
@@ -1074,6 +1082,7 @@ export class Kernel implements IKernel {
1074
1082
  entryDir,
1075
1083
  this.registryUrl,
1076
1084
  manifestsDir,
1085
+ legacyDir,
1077
1086
  );
1078
1087
  directories.set(file.source, moduleDir ?? undefined);
1079
1088
  const artifact = moduleArtifactFor({
@@ -1417,6 +1426,18 @@ export class Kernel implements IKernel {
1417
1426
  resolvedKind,
1418
1427
  (processedResource.metadata?.name as string | undefined) ?? "<unnamed>",
1419
1428
  );
1429
+ recordInstanceDeclaration(instance, processedResource);
1430
+ // Stamp the DECLARATION SITE here rather than at Phase-5 injection, because
1431
+ // this is the only point where the instance and the context that declared it
1432
+ // are both in hand: injection sees the consumer's context, not the target's,
1433
+ // so a module stamped there would be whoever referenced it. Without it a
1434
+ // durable step could not name its target anywhere the instance does not
1435
+ // exist — the whole reason `step()` takes an identity at all.
1436
+ const originPointer = originPointerOf(processedResource);
1437
+ stampRefIdentity(instance, resolvedKind, processedResource.metadata?.name ?? "<unnamed>", {
1438
+ ...(moduleCtx?.source ? { module: moduleCtx.source } : {}),
1439
+ ...(originPointer === undefined ? {} : { pointer: originPointer }),
1440
+ });
1420
1441
  (ctx as ResourceContextImpl).bindResourceIdentity(handle, resolvedKind, processedResource);
1421
1442
 
1422
1443
  // Bind the resolved invocation contract to the instance, here at the kernel's
@@ -1491,8 +1512,43 @@ export class Kernel implements IKernel {
1491
1512
  const resolveDef: DefResolver = (kind, from) =>
1492
1513
  this.registry.resolveDefinitionIn(kind, from?.metadata?.module);
1493
1514
 
1494
- const input = resolveBoundContract("inputType", resource, definition, resolveDef, factory);
1495
- const output = resolveBoundContract("outputType", resource, definition, resolveDef, factory);
1515
+ // A DECLARATION-derived slot is resolved against the context that OWNS this
1516
+ // resource, so a bare name means the same thing here as it does to `!ref`
1517
+ // and to CEL — scope-local first, enclosing module as the fallback — and an
1518
+ // alias routes into that import's exported instances.
1519
+ const projections: ProjectionScope = {
1520
+ resolveDefinition: (kind) =>
1521
+ this.controllers.getDefinition(kind) as unknown as Record<string, any> | undefined,
1522
+ // The slot holds the LIVE INSTANCE by now — Phase-5 injection runs before
1523
+ // create — so the declaration is recovered by instance identity. The ref
1524
+ // shape is still accepted, because a ref slot on a `with:`-scoped resource
1525
+ // is not an injection site and reaches the controller unresolved.
1526
+ resolveManifest: (value) => {
1527
+ const injected = declarationOfInstance(value);
1528
+ if (injected) return { manifest: injected as unknown as Record<string, any> };
1529
+ const ref = readProjectionRef(value);
1530
+ if (!ref) return undefined;
1531
+ const found = impl.resolveDeclaredManifest(ref.name, ref.alias);
1532
+ return found ? { manifest: found as unknown as Record<string, any> } : undefined;
1533
+ },
1534
+ };
1535
+
1536
+ const input = resolveBoundContract(
1537
+ "inputType",
1538
+ resource,
1539
+ definition,
1540
+ resolveDef,
1541
+ factory,
1542
+ projections,
1543
+ );
1544
+ const output = resolveBoundContract(
1545
+ "outputType",
1546
+ resource,
1547
+ definition,
1548
+ resolveDef,
1549
+ factory,
1550
+ projections,
1551
+ );
1496
1552
  if (!input && !output) return;
1497
1553
 
1498
1554
  bindContract(instance, {
@@ -1576,3 +1632,28 @@ export class Kernel implements IKernel {
1576
1632
  );
1577
1633
  }
1578
1634
  }
1635
+
1636
+ /**
1637
+ * The JSON pointer to an inline declaration, from the origin the loader stamped.
1638
+ *
1639
+ * `xTeloOrigin.pathFromParent` is a dotted path with `[N]` indices — the loader's
1640
+ * own spelling, which addresses the manifest tree and nothing else. A pointer is
1641
+ * what crosses a boundary, so the conversion happens here rather than being left
1642
+ * to each recipient to reinvent: segments are identifiers and indices, so the
1643
+ * rewrite is mechanical and total.
1644
+ */
1645
+ function originPointerOf(resource: ResourceManifest): string | undefined {
1646
+ const origin = resource.metadata?.xTeloOrigin as
1647
+ | { parentName?: unknown; pathFromParent?: unknown }
1648
+ | undefined;
1649
+ if (typeof origin?.parentName !== "string" || typeof origin.pathFromParent !== "string") {
1650
+ return undefined;
1651
+ }
1652
+ const pointer = origin.pathFromParent
1653
+ .replace(/\[(\d+)\]/g, ".$1")
1654
+ .split(".")
1655
+ .filter((segment) => segment.length > 0)
1656
+ .map((segment) => segment.replace(/~/g, "~0").replace(/\//g, "~1"))
1657
+ .join("/");
1658
+ return `${origin.parentName}#/${pointer}`;
1659
+ }
@@ -22,6 +22,7 @@ export {
22
22
  manifestFragmentRef,
23
23
  ResourceRefSchema,
24
24
  RetryPolicySchema,
25
+ StepSchema,
25
26
  withSchemaFragments,
26
27
  } from "@telorun/analyzer";
27
28
 
@@ -7,19 +7,30 @@ import * as path from "path";
7
7
  import { fileURLToPath } from "url";
8
8
 
9
9
  /**
10
- * Hash-keyed analysis cache: a tiny JSON sidecar in `.telo/manifests/`
10
+ * Hash-keyed analysis cache: a tiny JSON sidecar under `.telo/analysis/`
11
11
  * recording that an exact set of manifest bytes — under specific
12
12
  * `@telorun/kernel` and `@telorun/analyzer` package versions — passed
13
13
  * `analyzer.analyzeErrors`. The next `kernel.load` reads the sidecar
14
14
  * and, if signatures match, skips the per-resource validation walk.
15
15
  *
16
- * Lives next to the manifest cache (`LocalManifestCacheSource`) but is
17
- * independent of it splitting both for grep-ability and because the
18
- * concerns (URL file content vs. content analyzer verdict) are
19
- * orthogonal.
16
+ * ONE STAMP PER ENTRY, in a directory keyed by a hash of the entry URL.
17
+ * A single file holding a single signature was per-app only because every app
18
+ * used to get its own `.telo` beside its manifest; once the cache root is shared
19
+ * across a workspace, one file means each app overwrites the last — A stamps, B
20
+ * misses and overwrites, forever. That is a permanent 100% miss with no error to
21
+ * show for it, worst in the test suite, where a kernel is spawned per manifest
22
+ * and the cache matters most.
23
+ *
24
+ * A DIRECTORY rather than several records in one file, because two kernels
25
+ * loading different manifests concurrently would otherwise read-modify-write the
26
+ * same JSON and lose each other's entry — and concurrent loads are the normal
27
+ * case, not the exception.
28
+ *
29
+ * NOT under `manifests/`, which holds cached module manifests keyed by transport.
30
+ * A verdict about an entry is not a manifest; filing it there made the directory
31
+ * mean two things.
20
32
  */
21
33
 
22
- const CACHE_SUBDIR = ".telo/manifests";
23
34
 
24
35
  /** File-format version of the analysis stamp envelope. Only bumped when
25
36
  * the on-disk *layout* changes (new fields, restructured payload). The
@@ -30,7 +41,6 @@ const CACHE_SUBDIR = ".telo/manifests";
30
41
  * disk. A hand-maintained integer for that purpose would silently mask
31
42
  * newly-stricter validation until the next manifest edit. */
32
43
  const ANALYSIS_STAMP_FORMAT_VERSION = 1;
33
- const ANALYSIS_STAMP_FILE = `${CACHE_SUBDIR}/.validated.json`;
34
44
 
35
45
  const localRequire = createRequire(import.meta.url);
36
46
 
@@ -128,21 +138,33 @@ export function computeAnalysisSignature(graph: LoadedGraph): string {
128
138
  .digest("hex");
129
139
  }
130
140
 
131
- /** Read the stamped analysis verdict for the entry at `entryDir`, or
132
- * `undefined` when missing / unreadable / format-mismatched. The
133
- * `version` field is the on-disk *format* version; semantic
134
- * invalidation flows through the signature (which embeds package
135
- * versions). A future format change bumps `version` so older kernels
136
- * reading a newer stamp (or vice versa) discard rather than misparse. */
141
+ /** Where one entry's stamp lives: `<analysisDir>/<hash of entry URL>.json`.
142
+ *
143
+ * Keyed by the entry URL rather than by its directory, because two manifests in
144
+ * one directory (an app and its test harness) are two entries with two verdicts,
145
+ * and a shared cache root makes the directory a far weaker discriminator than it
146
+ * used to be. */
147
+ function stampPath(analysisDir: string, entryUrl: string): string {
148
+ const id = createHash("sha256").update(entryUrl).digest("hex").slice(0, 32);
149
+ return path.join(analysisDir, `${id}.json`);
150
+ }
151
+
152
+ /** Read the stamped analysis verdict for `entryUrl`, or `undefined` when
153
+ * missing / unreadable / format-mismatched. The `version` field is the
154
+ * on-disk *format* version; semantic invalidation flows through the
155
+ * signature (which embeds package versions). A future format change bumps
156
+ * `version` so older kernels reading a newer stamp (or vice versa) discard
157
+ * rather than misparse.
158
+ *
159
+ * A pre-workspace-anchor `.telo/manifests/.validated.json` is simply never
160
+ * looked at: it is a file where this layout wants a directory, so neither
161
+ * version of the kernel can misread the other's. */
137
162
  export async function readAnalysisStamp(
138
- entryDir: string,
139
- manifestsDir?: string,
163
+ entryUrl: string,
164
+ analysisDir: string,
140
165
  ): Promise<AnalysisStamp | undefined> {
141
- const stampPath = manifestsDir
142
- ? path.join(manifestsDir, ".validated.json")
143
- : path.join(entryDir, ANALYSIS_STAMP_FILE);
144
166
  try {
145
- const text = await fs.readFile(stampPath, "utf-8");
167
+ const text = await fs.readFile(stampPath(analysisDir, entryUrl), "utf-8");
146
168
  const parsed = JSON.parse(text) as Partial<AnalysisStamp>;
147
169
  if (
148
170
  parsed?.version === ANALYSIS_STAMP_FORMAT_VERSION &&
@@ -160,17 +182,23 @@ export async function readAnalysisStamp(
160
182
  * per-resource validation walk when the manifest set is unchanged.
161
183
  * Idempotent; safe to call after every successful load. */
162
184
  export async function writeAnalysisStamp(
163
- entryDir: string,
185
+ entryUrl: string,
164
186
  signature: string,
165
- manifestsDir?: string,
187
+ analysisDir: string,
166
188
  ): Promise<void> {
167
189
  const stamp: AnalysisStamp = {
168
190
  version: ANALYSIS_STAMP_FORMAT_VERSION,
169
191
  signature,
170
192
  };
171
- const target = manifestsDir
172
- ? path.join(manifestsDir, ".validated.json")
173
- : path.join(entryDir, ANALYSIS_STAMP_FILE);
193
+ const target = stampPath(analysisDir, entryUrl);
174
194
  await fs.mkdir(path.dirname(target), { recursive: true });
175
- await fs.writeFile(target, JSON.stringify(stamp), "utf-8");
195
+ // Temp file + rename, as the controller-source cache does. Two kernels loading
196
+ // the SAME entry concurrently — the normal case for a shared root, where a test
197
+ // suite runs many manifests at once — would otherwise interleave writes to one
198
+ // path. A torn stamp is read as a miss rather than as a wrong verdict, so the
199
+ // cost is a silent re-validation, but rename makes it unrepresentable for the
200
+ // price of one syscall.
201
+ const tmp = `${target}.${process.pid}.tmp`;
202
+ await fs.writeFile(tmp, JSON.stringify(stamp), "utf-8");
203
+ await fs.rename(tmp, target);
176
204
  }
@@ -12,6 +12,7 @@ import { fileURLToPath, pathToFileURL } from "url";
12
12
 
13
13
  import { hostEnv } from "../host-env.js";
14
14
  import { TransportRegistry, defaultTransportRegistry } from "../transports/transport-registry.js";
15
+ import { findWorkspaceRoot } from "../workspace-marker.js";
15
16
 
16
17
  const CACHE_SUBDIR = ".telo/manifests";
17
18
  const DEFAULT_REGISTRY_URL = "https://registry.telo.run";
@@ -51,15 +52,51 @@ function cachePathForUrl(
51
52
  return joinUnder(cacheRoot, ...key.split("/"));
52
53
  }
53
54
 
55
+ /** The PRE-WORKSPACE-ANCHOR manifest cache for an entry: always
56
+ * `<entry-dir>/.telo/manifests`, regardless of marker or env override. `null`
57
+ * for an entry with no local anchor.
58
+ *
59
+ * A single definition because two things read the old location and they must
60
+ * agree: the manifest source serves `telo.yaml` from it, and `moduleDirectoryFor`
61
+ * places that module's LAYERS beside it. Deriving the path twice is how the two
62
+ * halves end up disagreeing — the manifest resolving from the old root while its
63
+ * controller layers are looked for under the new one, which is exactly the
64
+ * offline boot the fallback exists to keep working. */
65
+ export function legacyManifestsDir(entryDir: string): string | null {
66
+ return entryDir ? path.join(entryDir, CACHE_SUBDIR) : null;
67
+ }
68
+
69
+ /** `legacyManifestsDir`, or `null` when it would coincide with `current` — so the
70
+ * fallback is never a second lookup at the same place. */
71
+ export function legacyManifestsDirFallback(
72
+ entryDir: string,
73
+ current: string | null,
74
+ ): string | null {
75
+ const legacy = legacyManifestsDir(entryDir);
76
+ if (legacy === null) return null;
77
+ if (current !== null && path.resolve(legacy) === path.resolve(current)) return null;
78
+ return legacy;
79
+ }
80
+
54
81
  /**
55
- * Reads previously-cached manifest YAMLs from `<entry-dir>/.telo/manifests/`.
56
- * Sits ahead of `RegistrySource` / `HttpSource` in the source chain — a hit
57
- * makes boot hermetic, a miss falls through to the network source unchanged.
82
+ * Reads previously-cached manifest YAMLs from the resolved manifest cache. Sits
83
+ * ahead of `RegistrySource` / `HttpSource` in the source chain — a hit makes boot
84
+ * hermetic, a miss falls through to the network source unchanged.
58
85
  *
59
86
  * Populated by `writeManifestCache` at install time.
87
+ *
88
+ * On a miss it consults the PRE-WORKSPACE-ANCHOR location
89
+ * (`<entry-dir>/.telo/manifests/`) before giving up. Every other cache in `.telo`
90
+ * costs only CPU when it goes cold; this one costs network, so without the
91
+ * fallback the move to a workspace-anchored root would stop a hermetic setup from
92
+ * booting — its `telo install` output stranded at the old path, with the failure
93
+ * surfacing as a registry fetch on a machine that has no route to one. Read-only
94
+ * and one directory deep: writes always go to the current root, so the old copy
95
+ * ages out rather than being maintained.
60
96
  */
61
97
  export class LocalManifestCacheSource implements ManifestSource {
62
- private readonly cacheRoot: string;
98
+ private readonly cacheRoot: string | null;
99
+ private readonly legacyRoot: string | null;
63
100
  private readonly transports: TransportRegistry;
64
101
 
65
102
  constructor(
@@ -70,7 +107,8 @@ export class LocalManifestCacheSource implements ManifestSource {
70
107
  // `manifestsDir` is the resolved manifest-cache directory threaded from a
71
108
  // single `resolveCacheRoot` (honours `TELO_CACHE_DIR`); when absent we fall
72
109
  // back to the entry-anchored default so library/test callers are unchanged.
73
- this.cacheRoot = manifestsDir ?? path.join(entryDir, CACHE_SUBDIR);
110
+ this.cacheRoot = manifestsDir ?? legacyManifestsDir(entryDir);
111
+ this.legacyRoot = legacyManifestsDirFallback(entryDir, this.cacheRoot);
74
112
  this.transports = defaultTransportRegistry(registryUrl);
75
113
  }
76
114
 
@@ -108,7 +146,12 @@ export class LocalManifestCacheSource implements ManifestSource {
108
146
  }
109
147
 
110
148
  private tryMap(url: string): string | null {
111
- const candidate = cachePathForUrl(url, this.cacheRoot, this.transports);
149
+ return this.tryMapIn(url, this.cacheRoot) ?? this.tryMapIn(url, this.legacyRoot);
150
+ }
151
+
152
+ private tryMapIn(url: string, root: string | null): string | null {
153
+ if (root === null) return null;
154
+ const candidate = cachePathForUrl(url, root, this.transports);
112
155
  if (!candidate) return null;
113
156
  // Require a regular file. A directory, dangling symlink, or stat failure
114
157
  // (ENOENT, EACCES, EISDIR-on-component) all fall through as a cache miss
@@ -219,18 +262,30 @@ export function resolveEntryDir(entryPath: string): string | null {
219
262
  }
220
263
 
221
264
  /** The single `.telo` cache root for an entry, resolved once and threaded to
222
- * every consumer (manifest cache, compiled validators, analysis stamp, npm
223
- * install root) so none of them re-derive it or read the env independently.
265
+ * every consumer (manifest cache, compiled validators, analysis stamps, npm
266
+ * install root, cargo target dirs) so none of them re-derive it or read the env
267
+ * independently.
268
+ *
269
+ * Precedence: `TELO_CACHE_DIR` (the relocated root a prebuilt image bakes its
270
+ * deps into) wins; then the directory holding `telo-workspace.yaml`, so every
271
+ * app in one repo shares a cache instead of each carrying its own copy of the
272
+ * same manifests, validators, bundles and npm tree; then `<entry-dir>/.telo`.
273
+ *
274
+ * Anchoring on the marker's LOCATION only — never its `modules:` list, which is
275
+ * release scope — so a manifest in no release subtree (an example, a test
276
+ * fixture) shares the cache exactly as an app does. With no marker anywhere
277
+ * above, this collapses to what it did before the anchor existed, so the file
278
+ * enables the shared cache rather than gating one and deleting it cannot break
279
+ * a build.
224
280
  *
225
- * `TELO_CACHE_DIR` (the relocated root a prebuilt image bakes its deps into)
226
- * wins; otherwise the root sits beside the entry at `<entry-dir>/.telo`.
227
281
  * Returns `null` for an entry with no local anchor — an http(s), `memory://`
228
282
  * or any other non-`file:` scheme — in which case the disk cache is skipped.
229
- * Consumers append the conventional subdirs: `manifests/`, `manifests/__validators/`,
230
- * `npm/`. */
283
+ * Consumers append the conventional subdirs: `manifests/`, `analysis/`,
284
+ * `validators/`, `controller-src/`, `npm/`. */
231
285
  export function resolveCacheRoot(entryPath: string): string | null {
232
286
  const override = hostEnv().TELO_CACHE_DIR;
233
287
  if (override && override.trim()) return path.resolve(override.trim());
234
288
  const entryDir = resolveEntryDir(entryPath);
235
- return entryDir ? path.join(entryDir, ".telo") : null;
289
+ if (!entryDir) return null;
290
+ return path.join(findWorkspaceRoot(entryDir) ?? entryDir, ".telo");
236
291
  }
@@ -15,6 +15,7 @@ import type {
15
15
  InvokeStepContext,
16
16
  ModuleContext as IModuleContext,
17
17
  ResourceInstance,
18
+ ResourceManifest,
18
19
  } from "@telorun/sdk";
19
20
  import type { EmitEvent, InstanceFactory } from "@telorun/sdk";
20
21
  import { EvaluationContext } from "./evaluation-context.js";
@@ -106,7 +107,11 @@ export class ModuleContext extends EvaluationContext implements IModuleContext {
106
107
  names: Set<string>;
107
108
  terminal: (
108
109
  name: string,
109
- ) => (() => { kind: string; instance: ResourceInstance } | undefined) | undefined;
110
+ ) =>
111
+ | (() =>
112
+ | { kind: string; instance: ResourceInstance; manifest?: ResourceManifest }
113
+ | undefined)
114
+ | undefined;
110
115
  }
111
116
  >();
112
117
 
@@ -292,7 +297,11 @@ export class ModuleContext extends EvaluationContext implements IModuleContext {
292
297
  names: string[],
293
298
  terminal: (
294
299
  name: string,
295
- ) => (() => { kind: string; instance: ResourceInstance } | undefined) | undefined,
300
+ ) =>
301
+ | (() =>
302
+ | { kind: string; instance: ResourceInstance; manifest?: ResourceManifest }
303
+ | undefined)
304
+ | undefined,
296
305
  ): void {
297
306
  this.importedScopes.set(alias, { names: new Set(names), terminal });
298
307
  }
@@ -369,7 +378,11 @@ export class ModuleContext extends EvaluationContext implements IModuleContext {
369
378
  const kind = rawKind.startsWith("Self.")
370
379
  ? `${moduleName}.${rawKind.slice("Self.".length)}`
371
380
  : rawKind;
372
- return { kind, instance: inst.instance };
381
+ // The manifest rides along because a DECLARATION-derived contract
382
+ // (`x-telo-schema-projection-from`) reads the target's own declaration,
383
+ // and a re-export forwards this getter verbatim — so carrying it here
384
+ // makes it reachable at any depth with no second table.
385
+ return { kind, instance: inst.instance, manifest: inst.resource };
373
386
  });
374
387
  }
375
388
  for (const k of kindEntries) {
@@ -407,6 +420,14 @@ export class ModuleContext extends EvaluationContext implements IModuleContext {
407
420
  return scope.terminal(name)?.()?.instance;
408
421
  }
409
422
 
423
+ /** The manifest half of `resolveImportedInstance` — what a name was DECLARED
424
+ * with, for a contract typed from a referenced declaration. */
425
+ override resolveImportedManifest(alias: string, name: string): ResourceManifest | undefined {
426
+ const scope = this.importedScopes.get(alias);
427
+ if (!scope || !scope.names.has(name)) return undefined;
428
+ return scope.terminal(name)?.()?.manifest;
429
+ }
430
+
410
431
  /** Like `resolveImportedInstance`, but returns the `{kind, name}` ref (canonical kind)
411
432
  * for controllers that resolve step/handler invokes to refs rather than live instances
412
433
  * (e.g. Run.Sequence via `ensureKindRef`). The alias is reattached by the caller. */
@@ -26,6 +26,7 @@ import {
26
26
  type ResourceHandle,
27
27
  type RuntimeSeam,
28
28
  type TypeRule,
29
+ type OpenZoneAttributes,
29
30
  type ZoneEntry,
30
31
  } from "@telorun/sdk";
31
32
  import { registerTeloKeywords } from "@telorun/analyzer";
@@ -426,6 +427,10 @@ export class ResourceContextImpl implements ResourceContext {
426
427
  return this.zoneContext().zonesFor(instance, ctx);
427
428
  }
428
429
 
430
+ zoneAttributes(ctx?: InvokeContext): readonly OpenZoneAttributes[] {
431
+ return this.zoneContext().zoneAttributes(ctx);
432
+ }
433
+
429
434
  /** The root context for runtime-driven inbound work — inherits nothing from
430
435
  * whatever ambient happens to be live at the registration site. */
431
436
  rootContext(opts?: { cancellation?: CancellationSource }): InvokeContext {
@@ -514,6 +519,19 @@ export class ResourceContextImpl implements ResourceContext {
514
519
  return this.moduleContext.resolveImportedInstance(alias, name);
515
520
  }
516
521
 
522
+ /**
523
+ * The manifest a name was DECLARED with, resolved in the context that OWNS
524
+ * this resource — scope-local first, enclosing module as the fallback, an
525
+ * alias routing into that import's exports. The same order `resolveRef` and
526
+ * the CEL `resources` layering use, so a declaration lookup cannot disagree
527
+ * with them about what a name means.
528
+ *
529
+ * Used to resolve a DECLARATION-derived contract slot at bind time.
530
+ */
531
+ resolveDeclaredManifest(name: string, alias?: string): ResourceManifest | undefined {
532
+ return this.owningContext.resolveDeclaredManifest?.(name, alias);
533
+ }
534
+
517
535
  resolveRef<T>(
518
536
  value: unknown,
519
537
  guard: (candidate: unknown) => candidate is T,
@@ -176,7 +176,15 @@ export class KernelRuntimeSeam implements RuntimeSeam {
176
176
  // child down in its own `finally`, so this only has to close the channels.
177
177
  const exitCode = (async () => {
178
178
  try {
179
- await child.load(source);
179
+ // The child runs a manifest in the SAME workspace as its parent, so it
180
+ // shares the root the parent already resolved instead of deriving one
181
+ // beside the child manifest. Deriving put a `.telo` in every directory
182
+ // holding a test manifest and rebuilt every controller bundle once per
183
+ // test, since `Test.Suite` runs each test through here.
184
+ //
185
+ // `undefined` means "resolve one yourself" and `null` means "no cache" —
186
+ // a parent with no local anchor must yield the first, not the second.
187
+ await child.load(source, { cacheDir: this.kernel.getCacheRoot() ?? undefined });
180
188
  await child.start();
181
189
  return child.exitCode;
182
190
  } catch (err) {
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Finding `telo-workspace.yaml` on disk.
3
+ *
4
+ * Its LOCATION is a general anchor — the directory workspace-relative paths are
5
+ * measured from, the outer bound on how far a walk-up may look, and the root the
6
+ * `.telo` cache is anchored at — while its `modules:` list is release scope. So
7
+ * the finder is deliberately separate from `release/`: `telo run` bounds its
8
+ * `.env` collection with the marker and `resolveCacheRoot` anchors on it, and
9
+ * neither may reach into the release namespace to do so, where `loadWorkspace`
10
+ * (which parses `modules:` and throws when the file is absent) sits one import
11
+ * away.
12
+ *
13
+ * It lives in the KERNEL rather than the CLI because the cache root is resolved
14
+ * here, on a path a CLI-less embedder also takes (a child kernel started through
15
+ * the runtime seam, a test harness). The CLI re-exports it; nothing is duplicated.
16
+ *
17
+ * Parsing the file is the analyzer's (`release/workspace-config.ts`); finding it
18
+ * is the Node half, and this is all of it.
19
+ */
20
+
21
+ import { WORKSPACE_FILENAME } from "@telorun/analyzer";
22
+ import * as fs from "node:fs";
23
+ import * as path from "node:path";
24
+
25
+ export { WORKSPACE_FILENAME };
26
+
27
+ /**
28
+ * Walk up from `from` looking for the marker. Returns the directory holding it,
29
+ * or `undefined` — the file is optional. `telo release` requires one and reports
30
+ * its absence; `telo run` and `resolveCacheRoot` read `undefined` as "no parent
31
+ * lookup", falling back to what they did before the marker existed.
32
+ *
33
+ * `from` is resolved through symlinks first: a manifest reached through a linked
34
+ * directory would otherwise walk the LINK's parents, miss a marker that is right
35
+ * there in the real tree, and silently fall back to a narrower answer.
36
+ */
37
+ export function findWorkspaceRoot(from: string): string | undefined {
38
+ let dir = realPath(path.resolve(from));
39
+ for (;;) {
40
+ if (isFile(path.join(dir, WORKSPACE_FILENAME))) return dir;
41
+ const parent = path.dirname(dir);
42
+ if (parent === dir) return undefined;
43
+ dir = parent;
44
+ }
45
+ }
46
+
47
+ /** The marker must be a FILE. `existsSync` would accept a directory of that name
48
+ * and anchor the whole cache on it, and the Rust half tests `is_file()` — a
49
+ * workspace visible to one kernel and not the other is precisely the divergence
50
+ * a shared marker exists to prevent. */
51
+ function isFile(target: string): boolean {
52
+ try {
53
+ return fs.statSync(target).isFile();
54
+ } catch {
55
+ return false;
56
+ }
57
+ }
58
+
59
+ /** `fs.realpathSync`, falling back to the input when the path does not exist —
60
+ * a manifest path that is wrong is the caller's error to report, not this
61
+ * helper's to convert into a different one. */
62
+ export function realPath(target: string): string {
63
+ try {
64
+ return fs.realpathSync(target);
65
+ } catch {
66
+ return target;
67
+ }
68
+ }