@telorun/kernel 0.27.0 → 0.29.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 (40) hide show
  1. package/dist/controller-loader.d.ts +27 -0
  2. package/dist/controller-loader.d.ts.map +1 -1
  3. package/dist/controller-loader.js +45 -3
  4. package/dist/controller-loader.js.map +1 -1
  5. package/dist/controller-loaders/bundle-loader.d.ts +10 -0
  6. package/dist/controller-loaders/bundle-loader.d.ts.map +1 -1
  7. package/dist/controller-loaders/bundle-loader.js +29 -14
  8. package/dist/controller-loaders/bundle-loader.js.map +1 -1
  9. package/dist/controller-loaders/napi-loader.d.ts +11 -0
  10. package/dist/controller-loaders/napi-loader.d.ts.map +1 -1
  11. package/dist/controller-loaders/napi-loader.js +49 -30
  12. package/dist/controller-loaders/napi-loader.js.map +1 -1
  13. package/dist/controller-loaders/npm-loader.d.ts +10 -0
  14. package/dist/controller-loaders/npm-loader.d.ts.map +1 -1
  15. package/dist/controller-loaders/npm-loader.js +15 -2
  16. package/dist/controller-loaders/npm-loader.js.map +1 -1
  17. package/dist/controller-registry.d.ts +17 -10
  18. package/dist/controller-registry.d.ts.map +1 -1
  19. package/dist/controller-registry.js +44 -10
  20. package/dist/controller-registry.js.map +1 -1
  21. package/dist/controllers/resource-definition/resource-definition-controller.d.ts.map +1 -1
  22. package/dist/controllers/resource-definition/resource-definition-controller.js +27 -8
  23. package/dist/controllers/resource-definition/resource-definition-controller.js.map +1 -1
  24. package/dist/kernel.d.ts +23 -0
  25. package/dist/kernel.d.ts.map +1 -1
  26. package/dist/kernel.js +40 -1
  27. package/dist/kernel.js.map +1 -1
  28. package/dist/resource-context.d.ts +7 -0
  29. package/dist/resource-context.d.ts.map +1 -1
  30. package/dist/resource-context.js +10 -0
  31. package/dist/resource-context.js.map +1 -1
  32. package/package.json +2 -2
  33. package/src/controller-loader.ts +72 -3
  34. package/src/controller-loaders/bundle-loader.ts +39 -20
  35. package/src/controller-loaders/napi-loader.ts +51 -30
  36. package/src/controller-loaders/npm-loader.ts +19 -2
  37. package/src/controller-registry.ts +63 -0
  38. package/src/controllers/resource-definition/resource-definition-controller.ts +44 -11
  39. package/src/kernel.ts +47 -1
  40. package/src/resource-context.ts +15 -0
@@ -20,6 +20,19 @@ export type ControllerResolveSource =
20
20
  | "cargo-build"
21
21
  | "bundle";
22
22
 
23
+ /**
24
+ * A controller candidate that has been *resolved* (verified hostable: package
25
+ * installed / bundle present / crate located) but not yet imported/evaluated.
26
+ * `importInstance` performs the deferred — and expensive — module load; lazy
27
+ * controller loading calls it on the kind's first instantiation. `purl`/`source`
28
+ * are known at resolve time and carried for the load-time events.
29
+ */
30
+ export interface ResolvedController {
31
+ purl: string;
32
+ source: ControllerResolveSource;
33
+ importInstance: () => Promise<ControllerInstance>;
34
+ }
35
+
23
36
  export type ControllerLoaderEvent =
24
37
  | { name: "ControllerLoading"; payload: { purl: string } }
25
38
  | {
@@ -151,18 +164,74 @@ export class ControllerLoader {
151
164
  throw new RuntimeError("ERR_CONTROLLER_NOT_FOUND", aggregated);
152
165
  }
153
166
 
167
+ /**
168
+ * Resolve a controller without importing it: pick the first candidate this
169
+ * environment can host (same ordering + env-missing fallback as {@link load}),
170
+ * verify it's present, and return a {@link ResolvedController} whose
171
+ * `importInstance` defers the actual import/eval. Used by lazy controller
172
+ * loading so a `Telo.Definition` fails fast at boot when its controller can't
173
+ * load at all, while the expensive import is paid only on first instantiation.
174
+ *
175
+ * Silent by design — no lifecycle events here; the caller emits
176
+ * ControllerLoading/Loaded around `importInstance` so the events fire when the
177
+ * load actually happens. A total resolution failure throws (the boot-time
178
+ * fail-fast), mirroring {@link load}'s aggregated error.
179
+ */
180
+ async resolve(
181
+ purlCandidates: string[],
182
+ baseUri: string,
183
+ policy?: ControllerPolicy,
184
+ ): Promise<ResolvedController> {
185
+ if (!purlCandidates || purlCandidates.length === 0) {
186
+ throw new RuntimeError("ERR_CONTROLLER_NOT_FOUND", "Missing controller PURL candidates");
187
+ }
188
+ const effectivePolicy = policy ?? DEFAULT_POLICY;
189
+ const ordered = orderCandidates(purlCandidates, effectivePolicy);
190
+ if (ordered.length === 0) {
191
+ throw new RuntimeError(
192
+ "ERR_CONTROLLER_NOT_FOUND",
193
+ `No controllers match runtime selection [${effectivePolicy.load.join(", ")}]; declared: ${purlCandidates.join(", ")}`,
194
+ );
195
+ }
196
+ const errors: string[] = [];
197
+ for (const purl of ordered) {
198
+ try {
199
+ const { source, importInstance } = await this.dispatchResolveOne(purl, baseUri);
200
+ return { purl, source, importInstance };
201
+ } catch (err) {
202
+ if (err instanceof ControllerEnvMissingError) {
203
+ errors.push(`${purl}: ${err.message}`);
204
+ continue;
205
+ }
206
+ throw err;
207
+ }
208
+ }
209
+ throw new RuntimeError(
210
+ "ERR_CONTROLLER_NOT_FOUND",
211
+ `No controller resolved. Tried ${ordered.length} candidate(s):\n${errors.join("\n")}`,
212
+ );
213
+ }
214
+
154
215
  private async dispatchOne(
155
216
  purl: string,
156
217
  baseUri: string,
157
218
  ): Promise<{ instance: ControllerInstance; source: ControllerResolveSource }> {
219
+ const { source, importInstance } = await this.dispatchResolveOne(purl, baseUri);
220
+ return { instance: await importInstance(), source };
221
+ }
222
+
223
+ private async dispatchResolveOne(
224
+ purl: string,
225
+ baseUri: string,
226
+ ): Promise<{ source: ControllerResolveSource; importInstance: () => Promise<ControllerInstance> }> {
158
227
  if (purl.startsWith("pkg:npm")) {
159
- return this.npmLoader.load(purl, baseUri);
228
+ return this.npmLoader.resolve(purl, baseUri);
160
229
  }
161
230
  if (purl.startsWith("pkg:cargo")) {
162
- return this.napiLoader.load(purl, baseUri);
231
+ return this.napiLoader.resolve(purl, baseUri);
163
232
  }
164
233
  if (purl.startsWith("pkg:telo")) {
165
- return this.bundleLoader.load(purl, baseUri);
234
+ return this.bundleLoader.resolve(purl, baseUri);
166
235
  }
167
236
  throw new ControllerEnvMissingError(`Unsupported PURL scheme: ${purl}`);
168
237
  }
@@ -141,6 +141,20 @@ export class BundleControllerLoader {
141
141
  purl: string,
142
142
  baseUri: string,
143
143
  ): Promise<{ instance: ControllerInstance; source: ControllerResolveSource }> {
144
+ const { source, importInstance } = await this.resolve(purl, baseUri);
145
+ return { instance: await importInstance(), source };
146
+ }
147
+
148
+ /**
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.
153
+ */
154
+ async resolve(
155
+ purl: string,
156
+ baseUri: string,
157
+ ): Promise<{ source: ControllerResolveSource; importInstance: () => Promise<ControllerInstance> }> {
144
158
  let parsed: PackageURL;
145
159
  try {
146
160
  parsed = PackageURL.fromString(purl);
@@ -196,26 +210,31 @@ export class BundleControllerLoader {
196
210
  // importing the bundle, so authors write normal imports.
197
211
  await ensureRealmSymlinks(path.dirname(absFile));
198
212
 
199
- // A broken bundle (syntax / failed import) is a real user-code failure —
200
- // let it propagate rather than masking it as env-missing.
201
- const mod = (await import(pathToFileURL(absFile).href)) as Record<string, ControllerInstance>;
202
213
  const fragment = parsed.subpath;
203
- // Distinguish "no such export" from "export isn't a controller" so the error
204
- // points at the actual problem (mirrors the napi loader's project()).
205
- if (fragment && !(fragment in mod)) {
206
- throw new RuntimeError(
207
- "ERR_CONTROLLER_INVALID",
208
- `Bundled controller "${purl}": module "${absFile}" has no export named "${fragment}"`,
209
- );
210
- }
211
- const instance = fragment ? mod[fragment] : (mod as unknown as ControllerInstance);
212
- if (!instance || (!instance.create && !instance.register)) {
213
- throw new RuntimeError(
214
- "ERR_CONTROLLER_INVALID",
215
- `Bundled controller "${purl}" exports neither create() nor register()` +
216
- (fragment ? ` at fragment "#${fragment}"` : ""),
217
- );
218
- }
219
- return { instance, source: "bundle" };
214
+ return {
215
+ source: "bundle",
216
+ importInstance: async () => {
217
+ // A broken bundle (syntax / failed import) is a real user-code failure —
218
+ // let it propagate rather than masking it as env-missing.
219
+ const mod = (await import(pathToFileURL(absFile).href)) as Record<string, ControllerInstance>;
220
+ // Distinguish "no such export" from "export isn't a controller" so the
221
+ // error points at the actual problem (mirrors the napi loader's project()).
222
+ if (fragment && !(fragment in mod)) {
223
+ throw new RuntimeError(
224
+ "ERR_CONTROLLER_INVALID",
225
+ `Bundled controller "${purl}": module "${absFile}" has no export named "${fragment}"`,
226
+ );
227
+ }
228
+ const instance = fragment ? mod[fragment] : (mod as unknown as ControllerInstance);
229
+ if (!instance || (!instance.create && !instance.register)) {
230
+ throw new RuntimeError(
231
+ "ERR_CONTROLLER_INVALID",
232
+ `Bundled controller "${purl}" exports neither create() nor register()` +
233
+ (fragment ? ` at fragment "#${fragment}"` : ""),
234
+ );
235
+ }
236
+ return instance;
237
+ },
238
+ };
220
239
  }
221
240
  }
@@ -123,6 +123,21 @@ export class NapiControllerLoader {
123
123
  * though all exports come from one linked dylib.
124
124
  */
125
125
  async load(purl: string, baseUri: string): Promise<NapiLoadResult> {
126
+ const { source, importInstance } = await this.resolve(purl, baseUri);
127
+ return { instance: await importInstance(), source };
128
+ }
129
+
130
+ /**
131
+ * Resolve without building: validate the PURL and confirm the crate's
132
+ * `local_path` exists (the fail-fast checks), then defer the cargo build +
133
+ * dylib load — the expensive part — into the returned `importInstance` thunk.
134
+ * A cache hit resolves instantly. Used by lazy controller loading so the build
135
+ * is paid on the kind's first instantiation, not at boot.
136
+ */
137
+ async resolve(
138
+ purl: string,
139
+ baseUri: string,
140
+ ): Promise<{ source: NapiResolveSource; importInstance: () => Promise<ControllerInstance> }> {
126
141
  const [, , name, , qualifiers, entry] = PackageURL.parseString(purl);
127
142
  const localPath = (qualifiers as any)?.get("local_path");
128
143
 
@@ -149,38 +164,44 @@ export class NapiControllerLoader {
149
164
  // can leave napi finalize callbacks racing and crash Node.
150
165
  const canonicalCratePath = await fs.realpath(cratePath);
151
166
  const cacheKey = canonicalCratePath;
152
- const cached = _napiModuleCache.get(cacheKey);
153
- if (cached) {
154
- return { instance: project(cached, entry, cratePath), source: "cache" };
167
+ if (_napiModuleCache.has(cacheKey)) {
168
+ return {
169
+ source: "cache",
170
+ importInstance: async () => project(_napiModuleCache.get(cacheKey), entry, cratePath),
171
+ };
155
172
  }
156
173
 
157
- // Concurrent callers for the same crate await one shared build. They
158
- // report `local` (not `cache`): they paid the same wall-clock cost as
159
- // the originator, just by sharing one cargo invocation rather than
160
- // running their own. Reporting `cache` here would mislead metrics/event
161
- // consumers into thinking it was a sub-ms hit.
162
- const existingInFlight = _napiInFlight.get(cacheKey);
163
- if (existingInFlight) {
164
- const { rawModule } = await existingInFlight;
165
- return { instance: project(rawModule, entry, cratePath), source: "local" };
166
- }
167
-
168
- const buildPromise = this.buildAndLoad(cratePath, name ?? "", cacheKey);
169
- _napiInFlight.set(cacheKey, buildPromise);
170
- let rawModule: any;
171
- let nodePath: string;
172
- try {
173
- ({ rawModule, nodePath } = await buildPromise);
174
- } finally {
175
- _napiInFlight.delete(cacheKey);
176
- }
177
- // `local` rather than `cargo-build` because the only mode currently
178
- // wired up is `local_path` dev-mode — cargo's incremental cache means
179
- // every run after the first is ~50ms of cargo-startup with no real
180
- // compilation, conceptually the same as the npm `local_path` branch
181
- // that just imports source already on disk. Distribution mode (when
182
- // implemented) will return `cargo-build` from its own branch.
183
- return { instance: project(rawModule, entry, nodePath), source: "local" };
174
+ // `local` rather than `cargo-build` because the only mode currently wired up
175
+ // is `local_path` dev-mode see the original note below. The thunk re-checks
176
+ // the module cache and the in-flight gate because resolve() and the deferred
177
+ // import can be separated in time (another instantiation may have built the
178
+ // crate in between).
179
+ const crateName = name ?? "";
180
+ const build = this.buildAndLoad.bind(this);
181
+ return {
182
+ source: "local",
183
+ importInstance: async () => {
184
+ const cached = _napiModuleCache.get(cacheKey);
185
+ if (cached) return project(cached, entry, cratePath);
186
+ // Concurrent callers for the same crate await one shared build, sharing
187
+ // one cargo invocation rather than each running their own.
188
+ const existingInFlight = _napiInFlight.get(cacheKey);
189
+ if (existingInFlight) {
190
+ const { rawModule } = await existingInFlight;
191
+ return project(rawModule, entry, cratePath);
192
+ }
193
+ const buildPromise = build(cratePath, crateName, cacheKey);
194
+ _napiInFlight.set(cacheKey, buildPromise);
195
+ let rawModule: any;
196
+ let nodePath: string;
197
+ try {
198
+ ({ rawModule, nodePath } = await buildPromise);
199
+ } finally {
200
+ _napiInFlight.delete(cacheKey);
201
+ }
202
+ return project(rawModule, entry, nodePath);
203
+ },
204
+ };
184
205
  }
185
206
 
186
207
  private async buildAndLoad(
@@ -178,6 +178,20 @@ export class NpmControllerLoader {
178
178
  }
179
179
 
180
180
  async load(purl: string, baseUri: string): Promise<NpmLoadResult> {
181
+ const { source, importInstance } = await this.resolve(purl, baseUri);
182
+ return { instance: await importInstance(), source };
183
+ }
184
+
185
+ /**
186
+ * Resolve without importing: materialize the install root and install/verify
187
+ * the package (the cheap, cache-hit-fast part that fails fast if the package
188
+ * is absent), but defer `loadFromInstall` — the actual `import()`/eval, which
189
+ * is the expensive cold-start cost — into the returned `importInstance` thunk.
190
+ */
191
+ async resolve(
192
+ purl: string,
193
+ baseUri: string,
194
+ ): Promise<{ source: NpmResolveSource; importInstance: () => Promise<ControllerInstance> }> {
181
195
  const parsed = PackageURL.fromString(purl);
182
196
  if (!parsed.name) {
183
197
  throw new Error(`Invalid PURL '${purl}': missing package name`);
@@ -197,8 +211,11 @@ export class NpmControllerLoader {
197
211
  resolved.kind,
198
212
  version,
199
213
  );
200
- const instance = await loadFromInstall(installRoot, alias, parsed.subpath ?? null, purl);
201
- return { instance, source };
214
+ const subpath = parsed.subpath ?? null;
215
+ return {
216
+ source,
217
+ importInstance: () => loadFromInstall(installRoot, alias, subpath, purl),
218
+ };
202
219
  }
203
220
 
204
221
  /**
@@ -12,9 +12,21 @@ const DEFAULT_FINGERPRINT = "default";
12
12
  * lock out the second. Definitions remain kind-only; only the loaded
13
13
  * controller instance is policy-scoped.
14
14
  */
15
+ /**
16
+ * A controller whose definition has been resolved but whose module is not yet
17
+ * imported. `load()` performs the import + `registerController` (firing the
18
+ * controller's `register()` hook); `loading` single-flights concurrent
19
+ * instantiations of the same kind through one import.
20
+ */
21
+ interface LazyControllerEntry {
22
+ load: () => Promise<void>;
23
+ loading?: Promise<void>;
24
+ }
25
+
15
26
  export class ControllerRegistry {
16
27
  private controllersByKind: Map<string, Map<string, ControllerInstance>> = new Map();
17
28
  private definitionsByKind: Map<string, ResourceDefinition> = new Map();
29
+ private lazyByKind: Map<string, Map<string, LazyControllerEntry>> = new Map();
18
30
 
19
31
  /**
20
32
  * Register a controller definition
@@ -143,4 +155,55 @@ export class ControllerRegistry {
143
155
  }
144
156
  byFp.set(fingerprint, wrappedController);
145
157
  }
158
+
159
+ /**
160
+ * Register a deferred controller for a (kind, fingerprint). The definition's
161
+ * metadata is already registered (so analysis/refs work); the controller
162
+ * module itself is imported lazily by {@link takeLazyController} on the kind's
163
+ * first instantiation. Mirrors `registerController`'s fingerprint keying.
164
+ */
165
+ registerLazyController(
166
+ kind: string,
167
+ fingerprint: string,
168
+ load: () => Promise<void>,
169
+ ): void {
170
+ if (!this.definitionsByKind.has(kind)) {
171
+ throw new Error(`Cannot register lazy controller for kind ${kind} without definition`);
172
+ }
173
+ let byFp = this.lazyByKind.get(kind);
174
+ if (!byFp) {
175
+ byFp = new Map();
176
+ this.lazyByKind.set(kind, byFp);
177
+ }
178
+ byFp.set(fingerprint, { load });
179
+ }
180
+
181
+ /**
182
+ * Drive the deferred import for a (kind, fingerprint) if one is registered,
183
+ * single-flighting concurrent callers through the same import. Returns true
184
+ * when a lazy entry existed (after its `load()` has completed and the
185
+ * controller is registered), false when there is none — letting the caller
186
+ * fall through to its existing "no controller" handling. Same fingerprint
187
+ * fallback order as `getControllerOrUndefined`.
188
+ */
189
+ async takeLazyController(
190
+ kind: string,
191
+ fingerprint: string = DEFAULT_FINGERPRINT,
192
+ ): Promise<boolean> {
193
+ const byFp = this.lazyByKind.get(kind);
194
+ if (!byFp) return false;
195
+ const entry =
196
+ byFp.get(fingerprint) ?? byFp.get(DEFAULT_FINGERPRINT) ?? byFp.values().next().value;
197
+ if (!entry) return false;
198
+ if (!entry.loading) {
199
+ // Reset on failure so a later instantiation re-attempts and re-surfaces
200
+ // the error rather than awaiting a cached rejection forever.
201
+ entry.loading = entry.load().catch((err) => {
202
+ entry.loading = undefined;
203
+ throw err;
204
+ });
205
+ }
206
+ await entry.loading;
207
+ return true;
208
+ }
146
209
  }
@@ -46,30 +46,63 @@ class ResourceDefinition implements ResourceInstance {
46
46
  );
47
47
  return;
48
48
  }
49
- // The loader owns ControllerLoading / ControllerLoaded / ControllerLoadFailed
50
- // emission so it can fire one event per attempted candidate (env-missing
51
- // fallback chains), and so the payload can include the actually-picked PURL,
52
- // which branch resolved it (`source`), and timing — none of which are known
53
- // here at the call site.
54
49
  const loader = new ControllerLoader({
55
- emit: (e) => ctx.emit(e.name, e.payload),
56
50
  entryUrl: ctx.getEntryUrl(),
57
51
  installRoot: ctx.getInstallRoot(),
58
52
  });
59
- const controllerInstance = await loader.load(
53
+ // Eager resolve verify the controller is hostable now (so a broken
54
+ // `controllers:` candidate fails fast at boot), but defer the expensive
55
+ // import/eval and the controller's `register()` to the kind's first
56
+ // instantiation. Definitions whose kind is never instantiated never import.
57
+ const resolved = await loader.resolve(
60
58
  this.resource.controllers,
61
59
  this.resource.metadata.source,
62
60
  ctx.getControllerPolicy(),
63
61
  );
64
62
  ctx.registerDefinition(this.resource);
65
- await ctx.registerController(
66
- this.resource.metadata.module,
67
- this.resource.metadata.name,
68
- controllerInstance,
63
+
64
+ const moduleName = this.resource.metadata.module;
65
+ const kindName = this.resource.metadata.name;
66
+ // Emitted here (not in the loader) so ControllerLoading / ControllerLoaded /
67
+ // ControllerLoadFailed — and the import duration — surface when the load
68
+ // actually happens (first instantiation), with the resolved PURL + source.
69
+ (ctx as unknown as LazyControllerHost).registerLazyController(
70
+ moduleName,
71
+ kindName,
72
+ async () => {
73
+ await ctx.emit("ControllerLoading", { purl: resolved.purl });
74
+ const startedAt = Date.now();
75
+ const instance = await resolved.importInstance().catch(async (err) => {
76
+ await ctx.emit("ControllerLoadFailed", {
77
+ purl: resolved.purl,
78
+ error: err instanceof Error ? err.message : String(err),
79
+ });
80
+ throw err;
81
+ });
82
+ await ctx.registerController(moduleName, kindName, instance);
83
+ await ctx.emit("ControllerLoaded", {
84
+ purl: resolved.purl,
85
+ source: resolved.source,
86
+ durationMs: Date.now() - startedAt,
87
+ });
88
+ },
69
89
  );
70
90
  }
71
91
  }
72
92
 
93
+ /**
94
+ * Kernel-internal hook the concrete `ResourceContextImpl` exposes for lazy
95
+ * controller loading — deliberately off the public SDK `ResourceContext`
96
+ * surface, since only this controller uses it.
97
+ */
98
+ interface LazyControllerHost {
99
+ registerLazyController(
100
+ moduleName: string,
101
+ kindName: string,
102
+ load: () => Promise<void>,
103
+ ): void;
104
+ }
105
+
73
106
  export function register(ctx: ControllerContext): void {
74
107
  // ResourceDefinition is a passive resource - no registration needed
75
108
  }
package/src/kernel.ts CHANGED
@@ -112,6 +112,10 @@ export class Kernel implements IKernel {
112
112
  private rootContext!: ModuleContext;
113
113
  private staticManifests: ResourceManifest[] = [];
114
114
  private _entryUrl?: string;
115
+ /** Root Application `ports:` resolved in `load()` — integer + declared protocol
116
+ * per name. Surfaced via {@link getResolvedPorts} so a host can advertise where
117
+ * the running app is reachable. */
118
+ private _resolvedPorts: Array<{ name: string; port: number; protocol: "tcp" | "udp" }> = [];
115
119
  /** The `.telo` cache root for this load, resolved once in `load()` and
116
120
  * threaded to the validator, analysis stamp, and npm install root. */
117
121
  private _cacheRoot?: string | null;
@@ -161,6 +165,21 @@ export class Kernel implements IKernel {
161
165
  await controllerInstance.register?.(this.createControllerContext(`${moduleName}.${kindName}`));
162
166
  }
163
167
 
168
+ /**
169
+ * Register a deferred controller. The definition is already registered; the
170
+ * controller module is imported (and `register()` fired, via the `load` thunk
171
+ * calling back into `registerController`) only on the kind's first
172
+ * instantiation — see `_createInstance` / `ControllerRegistry.takeLazyController`.
173
+ */
174
+ registerLazyController(
175
+ moduleName: string,
176
+ kindName: string,
177
+ fingerprint: string,
178
+ load: () => Promise<void>,
179
+ ): void {
180
+ this.controllers.registerLazyController(`${moduleName}.${kindName}`, fingerprint, load);
181
+ }
182
+
164
183
  /**
165
184
  * Register a resource definition with the controller registry
166
185
  */
@@ -509,9 +528,27 @@ export class Kernel implements IKernel {
509
528
  if (Object.keys(ports).length > 0) {
510
529
  this.rootContext.setPorts(ports);
511
530
  }
531
+ const portDecls = (rootApplicationManifest as { ports?: Record<string, { protocol?: string }> })
532
+ .ports ?? {};
533
+ this._resolvedPorts = Object.entries(ports).map(([name, port]) => ({
534
+ name,
535
+ port,
536
+ protocol: portDecls[name]?.protocol === "udp" ? "udp" : "tcp",
537
+ }));
512
538
  }
513
539
  }
514
540
 
541
+ /**
542
+ * Resolved inbound ports from the root Application's `ports:` block, available
543
+ * after {@link load}. Each carries the resolved integer and its declared
544
+ * protocol; empty when the root declares no ports (or isn't an Application).
545
+ * Surfaced so a host (e.g. the CLI inspection endpoint) can tell the debug UI
546
+ * where the running app is reachable.
547
+ */
548
+ getResolvedPorts(): ReadonlyArray<{ name: string; port: number; protocol: "tcp" | "udp" }> {
549
+ return this._resolvedPorts;
550
+ }
551
+
515
552
  /**
516
553
  * Initialize every resource declared in the manifest. Does not run targets
517
554
  * and does not wait — returns as soon as the kernel is ready to accept
@@ -880,7 +917,16 @@ export class Kernel implements IKernel {
880
917
  const resolvedKind = (findEnclosingModule(evalContext) ?? this.rootContext).resolveKind(kind);
881
918
 
882
919
  const fingerprint = policyFingerprint(findEnclosingPolicy(evalContext));
883
- const controller = this.controllers.getControllerOrUndefined(resolvedKind, fingerprint);
920
+ let controller = this.controllers.getControllerOrUndefined(resolvedKind, fingerprint);
921
+ if (!controller) {
922
+ // Lazy controller loading: the kind's Telo.Definition registered a deferred
923
+ // controller (resolved but not imported). Import + register it now, on this
924
+ // first instantiation, then re-resolve. A kind with no lazy entry (abstract,
925
+ // or genuinely unknown) falls through to the error below unchanged.
926
+ if (await this.controllers.takeLazyController(resolvedKind, fingerprint)) {
927
+ controller = this.controllers.getControllerOrUndefined(resolvedKind, fingerprint);
928
+ }
929
+ }
884
930
  if (!controller) {
885
931
  const kindInfo =
886
932
  resolvedKind !== kind ? `'${kind}' (resolved to '${resolvedKind}')` : `'${kind}'`;
@@ -332,6 +332,21 @@ export class ResourceContextImpl implements ResourceContext {
332
332
  await this.kernel.registerController(moduleName, kindName, controllerInstance, fingerprint);
333
333
  }
334
334
 
335
+ /**
336
+ * Register a deferred controller under the declaring module's policy
337
+ * fingerprint (same keying as `registerController`). Internal to the kernel's
338
+ * lazy controller loading — not part of the public SDK `ResourceContext`
339
+ * surface; the only caller is the `Telo.Definition` controller.
340
+ */
341
+ registerLazyController(
342
+ moduleName: string,
343
+ kindName: string,
344
+ load: () => Promise<void>,
345
+ ): void {
346
+ const fingerprint = policyFingerprint(this.moduleContext.getControllerPolicy());
347
+ this.kernel.registerLazyController(moduleName, kindName, fingerprint, load);
348
+ }
349
+
335
350
  registerDefinition(def: any) {
336
351
  this.kernel.registerResourceDefinition(def);
337
352
  }