@telorun/kernel 0.14.0 → 0.16.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/src/kernel.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  ResourceManifest,
20
20
  RuntimeError,
21
21
  RuntimeEvent,
22
+ type BootTarget,
22
23
  type EvaluationContext as IEvaluationContext,
23
24
  type ModuleContext as IModuleContext,
24
25
  type LoadOptions,
@@ -380,7 +381,14 @@ export class Kernel implements IKernel {
380
381
  // into first-class named manifests and replace them in-place with {kind, name} references.
381
382
  // Update staticManifests so Phase 3 (validateReferences) and Phase 4 (DAG) see
382
383
  // the same normalized structure.
383
- const normalizedManifests = this.analyzer.normalize(allManifests, this.registry);
384
+ // Pass the analyzer-flattened set (entry + forwarded library exports) as cross-module
385
+ // resolution targets so `!ref Alias.name` in the entry-only runtime manifests resolves
386
+ // to imported libraries' exported instances.
387
+ const normalizedManifests = this.analyzer.normalize(
388
+ allManifests,
389
+ this.registry,
390
+ staticManifests,
391
+ );
384
392
  this.staticManifests = normalizedManifests;
385
393
 
386
394
  let rootApplicationManifest: ResourceManifest | undefined;
@@ -392,25 +400,32 @@ export class Kernel implements IKernel {
392
400
  // `process.env` after the manifest loop so imports can read
393
401
  // `${{ variables.X }}` during their own init.
394
402
  //
395
- // Targets normalize down to bare names regardless of source surface.
396
- // The analyzer's `resolveRefSentinels` pass already substituted any
397
- // `!ref <name>` to `{kind, name}`; bare-string forms pass through.
398
- // Anything else (e.g. an unresolved sentinel because the analyzer
399
- // couldn't see it, or a malformed manifest) is a hard error —
400
- // silently dropping the entry would leave the user staring at a
401
- // "no targets ran" outcome with no signal where it went wrong.
403
+ // Targets are preserved as their full shape so the boot runner can
404
+ // evaluate `when` guards and inline invoke steps. The analyzer's
405
+ // `resolveRefSentinels` pass already substituted any `!ref <name>` to
406
+ // `{kind, name}` (including inside an inline target's `invoke`).
407
+ // Recognized shapes: bare string ref, resolved `{kind, name}`, gated
408
+ // `{ ref, when? }`, and inline `{ name?, invoke, inputs?, when?, retry? }`.
409
+ // Anything else (e.g. an unresolved sentinel, or a malformed manifest)
410
+ // is a hard error — silently dropping the entry would leave the user
411
+ // staring at a "no targets ran" outcome with no signal.
402
412
  const rawTargets = (manifest.targets ?? []) as unknown[];
403
- const targetNames = rawTargets.map((t, index) => {
404
- if (typeof t === "string") return t;
405
- if (t && typeof t === "object" && typeof (t as { name?: unknown }).name === "string") {
406
- return (t as { name: string }).name;
413
+ rawTargets.forEach((t, index) => {
414
+ const ok =
415
+ typeof t === "string" ||
416
+ (!!t &&
417
+ typeof t === "object" &&
418
+ (typeof (t as { name?: unknown }).name === "string" ||
419
+ (t as { ref?: unknown }).ref != null ||
420
+ (t as { invoke?: unknown }).invoke !== undefined));
421
+ if (!ok) {
422
+ throw new RuntimeError(
423
+ "ERR_INVALID_VALUE",
424
+ `Telo.Application '${(manifest.metadata as { name?: string } | undefined)?.name ?? "(unnamed)"}' targets[${index}] is not a recognized target shape. Got: ${JSON.stringify(t)}`,
425
+ );
407
426
  }
408
- throw new RuntimeError(
409
- "ERR_INVALID_VALUE",
410
- `Telo.Application '${(manifest.metadata as { name?: string } | undefined)?.name ?? "(unnamed)"}' targets[${index}] could not be normalized to a resource name. Got: ${JSON.stringify(t)}`,
411
- );
412
427
  });
413
- this.rootContext.setTargets(targetNames);
428
+ this.rootContext.setTargets(rawTargets as BootTarget[]);
414
429
  if (manifest.kind === "Telo.Application") {
415
430
  rootApplicationManifest = manifest;
416
431
  }
@@ -847,7 +862,7 @@ export class Kernel implements IKernel {
847
862
  */
848
863
  private _injectDependencies(
849
864
  resource: ResourceManifest,
850
- getInstance: (name: string) => ResourceInstance | undefined,
865
+ getInstance: (name: string, alias?: string) => ResourceInstance | undefined,
851
866
  ): void {
852
867
  this.registry.iterateFieldEntries(
853
868
  resource,
@@ -1031,10 +1046,28 @@ function collectEvalPathsNode(
1031
1046
  function injectAtPath(
1032
1047
  resource: ResourceManifest,
1033
1048
  fieldPath: string,
1034
- getInstance: (name: string) => ResourceInstance | undefined,
1049
+ getInstance: (name: string, alias?: string) => ResourceInstance | undefined,
1035
1050
  ): void {
1036
1051
  const parts = fieldPath.split(".");
1037
1052
 
1053
+ // Resolve a {kind, name, alias?} reference to its live instance. A non-`Self` alias is a
1054
+ // cross-module reference into an import's published exports; if that import hasn't
1055
+ // finished init() yet the instance is absent, so we throw to defer this resource to a
1056
+ // later pass of the multi-pass init loop (which catches and retries) rather than leaving
1057
+ // the ref unresolved. Local refs (no alias) that miss are left for topo ordering / later
1058
+ // diagnostics, matching prior behaviour.
1059
+ function resolveInto(ref: Record<string, unknown>): ResourceInstance | undefined {
1060
+ const alias = typeof ref.alias === "string" ? ref.alias : undefined;
1061
+ const instance = getInstance(ref.name as string, alias);
1062
+ if (!instance && alias && alias !== "Self") {
1063
+ throw new RuntimeError(
1064
+ "ERR_CROSS_MODULE_REF_PENDING",
1065
+ `Cross-module reference '${alias}.${String(ref.name)}' is not available yet (import not initialized)`,
1066
+ );
1067
+ }
1068
+ return instance;
1069
+ }
1070
+
1038
1071
  function traverse(obj: unknown, partsLeft: string[]): void {
1039
1072
  if (!obj || typeof obj !== "object" || partsLeft.length === 0) return;
1040
1073
  const [head, ...rest] = partsLeft;
@@ -1049,7 +1082,7 @@ function injectAtPath(
1049
1082
  if (rest.length === 0) {
1050
1083
  const ref = elem as Record<string, unknown>;
1051
1084
  if (typeof ref.kind === "string" && typeof ref.name === "string") {
1052
- const instance = getInstance(ref.name);
1085
+ const instance = resolveInto(ref);
1053
1086
  if (instance) container[mapKey] = instance;
1054
1087
  }
1055
1088
  } else {
@@ -1073,7 +1106,7 @@ function injectAtPath(
1073
1106
  if (rest.length === 0) {
1074
1107
  const ref = elem as Record<string, unknown>;
1075
1108
  if (typeof ref.kind === "string" && typeof ref.name === "string") {
1076
- const instance = getInstance(ref.name);
1109
+ const instance = resolveInto(ref);
1077
1110
  if (instance) val[i] = instance;
1078
1111
  }
1079
1112
  } else {
@@ -1085,7 +1118,7 @@ function injectAtPath(
1085
1118
  if (val && typeof val === "object" && !Array.isArray(val)) {
1086
1119
  const ref = val as Record<string, unknown>;
1087
1120
  if (typeof ref.kind === "string" && typeof ref.name === "string") {
1088
- const instance = getInstance(ref.name);
1121
+ const instance = resolveInto(ref);
1089
1122
  if (instance) container[key] = instance;
1090
1123
  }
1091
1124
  }
@@ -1,7 +1,12 @@
1
+ import { executeInvokeStep } from "@telorun/sdk";
1
2
  import type {
3
+ BootTarget,
2
4
  ControllerPolicy,
3
5
  Invocable,
6
+ InvokeStep,
7
+ InvokeStepContext,
4
8
  ModuleContext as IModuleContext,
9
+ ResourceInstance,
5
10
  } from "@telorun/sdk";
6
11
  import type { EmitEvent, InstanceFactory } from "@telorun/sdk";
7
12
  import { EvaluationContext } from "./evaluation-context.js";
@@ -68,6 +73,18 @@ export class ModuleContext extends EvaluationContext implements IModuleContext {
68
73
  /** Maps import alias → allowed kind names. Absent entry = unrestricted (e.g. Kernel). */
69
74
  private readonly importedKinds = new Map<string, Set<string>>();
70
75
 
76
+ /** Maps import alias → its child context's exported instances. Registered by the
77
+ * Telo.Import controller; read when resolving a cross-module `!ref Alias.name`
78
+ * reference (Phase 5 injection and boot targets). `names` is the import's
79
+ * `exports.resources` gate — only listed instances are reachable. */
80
+ private readonly importedScopes = new Map<
81
+ string,
82
+ {
83
+ names: Set<string>;
84
+ get: (name: string) => { kind: string; instance: ResourceInstance } | undefined;
85
+ }
86
+ >();
87
+
71
88
  /**
72
89
  * Resolved controller-selection policy for this module's `Telo.Definition`s.
73
90
  * Stamped by the parent `Telo.Import` controller from the import's `runtime:`
@@ -82,7 +99,7 @@ export class ModuleContext extends EvaluationContext implements IModuleContext {
82
99
  variables: Record<string, unknown> = {},
83
100
  secrets: Record<string, unknown> = {},
84
101
  resources: Record<string, unknown> = {},
85
- private targets: string[] = [],
102
+ private targets: BootTarget[] = [],
86
103
  createInstance: InstanceFactory = async () => null,
87
104
  emit: EmitEvent,
88
105
  private readonly _hostEnv?: Record<string, string | undefined>,
@@ -120,7 +137,7 @@ export class ModuleContext extends EvaluationContext implements IModuleContext {
120
137
  this._rebuildContext();
121
138
  }
122
139
 
123
- setTargets(vars: string[]): void {
140
+ setTargets(vars: BootTarget[]): void {
124
141
  this.targets = vars;
125
142
  }
126
143
 
@@ -157,6 +174,37 @@ export class ModuleContext extends EvaluationContext implements IModuleContext {
157
174
  }
158
175
  }
159
176
 
177
+ /** Register an import alias's exported instances for cross-module reference resolution.
178
+ * `names` is the gate (the import's `exports.resources`); `get` reads the child context's
179
+ * live instance + its canonical kind lazily — they exist only after the import's `init()`
180
+ * runs. */
181
+ registerImportedScope(
182
+ alias: string,
183
+ names: string[],
184
+ get: (name: string) => { kind: string; instance: ResourceInstance } | undefined,
185
+ ): void {
186
+ this.importedScopes.set(alias, { names: new Set(names), get });
187
+ }
188
+
189
+ /** Resolve `Alias.name` to a live exported instance, gated by `exports.resources`.
190
+ * Returns undefined when the alias is unknown, the name isn't exported, or the import
191
+ * hasn't initialized its instances yet (the injection path defers and retries). */
192
+ override resolveImportedInstance(alias: string, name: string): ResourceInstance | undefined {
193
+ const scope = this.importedScopes.get(alias);
194
+ if (!scope || !scope.names.has(name)) return undefined;
195
+ return scope.get(name)?.instance;
196
+ }
197
+
198
+ /** Like `resolveImportedInstance`, but returns the `{kind, name}` ref (canonical kind)
199
+ * for controllers that resolve step/handler invokes to refs rather than live instances
200
+ * (e.g. Run.Sequence via `resolveChildren`). The alias is reattached by the caller. */
201
+ resolveImportedRef(alias: string, name: string): { kind: string; name: string } | undefined {
202
+ const scope = this.importedScopes.get(alias);
203
+ if (!scope || !scope.names.has(name)) return undefined;
204
+ const entry = scope.get(name);
205
+ return entry ? { kind: entry.kind, name } : undefined;
206
+ }
207
+
160
208
  hasImport(alias: string): boolean {
161
209
  return this.importAliases.has(alias);
162
210
  }
@@ -256,8 +304,57 @@ export class ModuleContext extends EvaluationContext implements IModuleContext {
256
304
  }
257
305
 
258
306
  async runTargets() {
259
- for (const target of this.targets) {
260
- await this.run(target);
307
+ const steps: Record<string, unknown> = {};
308
+ const stepCtx: InvokeStepContext = {
309
+ expandValue: (value, context) => this.expandWith(value, context),
310
+ invoke: (kind, name, inputs) => this.invoke(kind, name, inputs),
311
+ invokeResolved: (kind, name, instance, inputs) =>
312
+ this.invokeResolved(kind, name, instance, inputs),
313
+ resolveImportedInstance: (alias, name) => this.resolveImportedInstance(alias, name),
314
+ };
315
+ for (let i = 0; i < this.targets.length; i++) {
316
+ const target = this.targets[i]!;
317
+ if (typeof target === "string") {
318
+ await this.run(target);
319
+ continue;
320
+ }
321
+ if ("invoke" in target && target.invoke !== undefined) {
322
+ const step: InvokeStep = {
323
+ name: target.name ?? `Target${i}`,
324
+ when: target.when,
325
+ // A cross-module `!ref Alias.name` stays a `{kind, name, alias}` ref; the leaf's
326
+ // alias branch resolves it via `resolveImportedInstance` and dispatches through
327
+ // `invokeResolved` so invocation events and error wrapping still fire.
328
+ invoke: target.invoke,
329
+ inputs: target.inputs,
330
+ };
331
+ await executeInvokeStep(step, stepCtx, { steps });
332
+ continue;
333
+ }
334
+ if ("ref" in target && target.ref != null) {
335
+ if (target.when === undefined || this.expandWith(target.when, { steps })) {
336
+ const ref = target.ref as unknown;
337
+ const r =
338
+ ref && typeof ref === "object" ? (ref as { name: string; alias?: string }) : undefined;
339
+ if (r && typeof r.alias === "string" && r.alias !== "Self") {
340
+ const inst = this.resolveImportedInstance(r.alias, r.name);
341
+ if (!inst || typeof inst.run !== "function") {
342
+ throw new Error(
343
+ `Boot target '${r.alias}.${r.name}' is not a runnable exported instance`,
344
+ );
345
+ }
346
+ await inst.run();
347
+ } else {
348
+ await this.run(typeof ref === "string" ? ref : r!.name);
349
+ }
350
+ }
351
+ continue;
352
+ }
353
+ if ("name" in target && typeof target.name === "string") {
354
+ await this.run(target.name);
355
+ continue;
356
+ }
357
+ throw new Error(`Unrecognized target shape at index ${i}: ${JSON.stringify(target)}`);
261
358
  }
262
359
  }
263
360
  }
@@ -161,6 +161,10 @@ export class ResourceContextImpl implements ResourceContext {
161
161
  return this.moduleContext.invokeResolved(kind, name, instance, inputs);
162
162
  }
163
163
 
164
+ resolveImportedInstance(alias: string, name: string): ResourceInstance | undefined {
165
+ return this.moduleContext.resolveImportedInstance(alias, name);
166
+ }
167
+
164
168
  async run(name: string) {
165
169
  await this.moduleContext.run(name);
166
170
  }
@@ -195,7 +199,10 @@ export class ResourceContextImpl implements ResourceContext {
195
199
  * @returns Normalized {kind, name} reference
196
200
  * @throws RuntimeError if 'kind' is missing
197
201
  */
198
- resolveChildren(resource: any, resourceName?: string): { kind: string; name: string } {
202
+ resolveChildren(
203
+ resource: any,
204
+ resourceName?: string,
205
+ ): { kind: string; name: string; alias?: string } {
199
206
  if (!resource || typeof resource !== "object") {
200
207
  throw new RuntimeError(
201
208
  "ERR_INVALID_VALUE",
@@ -217,7 +224,25 @@ export class ResourceContextImpl implements ResourceContext {
217
224
  // this rescue — schemas exercising those hidden slots must use the
218
225
  // legacy string or `{kind, name}` forms for now.
219
226
  if (isRefSentinel(resource)) {
220
- const refName = resource.source;
227
+ const source = resource.source;
228
+ const dot = source.indexOf(".");
229
+ const alias = dot > 0 ? source.slice(0, dot) : undefined;
230
+ // Cross-module exported instance (`!ref Alias.name`) — resolve the {kind, name} ref
231
+ // from the import's exported scope and reattach the alias so downstream scope
232
+ // resolution (executeInvokeStep → ScopeContext.getInstance) routes into the import's
233
+ // child context rather than scope-local resources.
234
+ if (alias && alias !== "Self") {
235
+ const name = source.slice(dot + 1);
236
+ const ref = this.moduleContext.resolveImportedRef(alias, name);
237
+ if (!ref) {
238
+ throw new RuntimeError(
239
+ "ERR_RESOURCE_NOT_FOUND",
240
+ `[${this.metadata.name}] !ref '${source}' is not an exported instance of import '${alias}'.`,
241
+ );
242
+ }
243
+ return { kind: ref.kind, name: ref.name, alias };
244
+ }
245
+ const refName = alias === "Self" ? source.slice(dot + 1) : source;
221
246
  const entry = this.moduleContext.resourceInstances.get(refName);
222
247
  if (!entry) {
223
248
  throw new RuntimeError(