@telorun/kernel 0.35.0 → 0.36.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.
@@ -1,18 +1,49 @@
1
- import type { ControllerInstance, ResourceContext, ResourceInstance } from "@telorun/sdk";
1
+ import type {
2
+ CompiledValue,
3
+ ControllerInstance,
4
+ EvaluationContext,
5
+ ResourceContext,
6
+ ResourceInstance,
7
+ } from "@telorun/sdk";
2
8
  import { isCompiledValue } from "@telorun/sdk";
9
+ import { isRefSentinel } from "@telorun/templating";
10
+
11
+ /** CEL variables that are only bound at call time (request handling, step
12
+ * chaining, error branches) — never at a template's init(). A persistent
13
+ * child's body is expanded against `self` only, so a `${{ }}` node referencing
14
+ * any of these must survive untouched for the consuming controller (e.g. an
15
+ * Http.Api evaluating route CEL per request) to evaluate later. A node mixing
16
+ * `self` with a deferred variable in one expression is unsupported by design —
17
+ * keep `self`-derived literals and request-derived values in separate fields
18
+ * (e.g. a `self`-built SQL string vs. request-built `bindings`). */
19
+ const DEFERRED_VARS = new Set(["request", "result", "steps", "error"]);
20
+ const DEFERRED_RE = new RegExp(`(?<![.\\w])(?:${[...DEFERRED_VARS].join("|")})(?![\\w])`);
21
+
22
+ /** True when an expression reads a call-time-only variable. Prefers the AST-
23
+ * derived root identifiers stamped on the CompiledValue at compile time (exact —
24
+ * ignores string literals and substrings); falls back to a source-text scan for
25
+ * values produced by engines that surface no AST. */
26
+ function referencesDeferred(value: CompiledValue): boolean {
27
+ if (value.refs) return value.refs.some((r) => DEFERRED_VARS.has(r));
28
+ return typeof value.source === "string" && DEFERRED_RE.test(value.source);
29
+ }
30
+
31
+ /** Matches a CEL source that is exactly a `self.<path>` member access (capturing
32
+ * the `.<path>` tail) — the form resolved by direct navigation rather than CEL. */
33
+ const SELF_PATH = /^self((?:\.[A-Za-z_$][\w$]*)+)$/;
3
34
 
4
35
  /** Reports the resources: entries available to dispatch against, by expanded
5
36
  * name and kind. Used in error messages to guide the developer back to the
6
37
  * template's `resources:` array when a dispatch target doesn't match. */
7
38
  function describeAvailableTargets(
8
- ctx: ResourceContext,
39
+ ctx: EvaluationContext,
9
40
  resources: any[] | undefined,
10
41
  self: Record<string, unknown>,
11
42
  ): string {
12
43
  if (!resources || resources.length === 0) return "<none>";
13
44
  return resources
14
45
  .map((r) => {
15
- const expanded = ctx.moduleContext.expandWith(r?.metadata?.name ?? "", { self }) as string;
46
+ const expanded = ctx.expandWith(r?.metadata?.name ?? "", { self }) as string;
16
47
  const kind = typeof r?.kind === "string" ? r.kind : "<unknown-kind>";
17
48
  return `'${expanded || "<unnamed>"}' (${kind})`;
18
49
  })
@@ -25,201 +56,198 @@ export function createTemplateController(definition: {
25
56
  invoke?: string | { kind?: string; name: string };
26
57
  inputs?: Record<string, any>;
27
58
  run?: string;
59
+ mount?: string | { kind?: string; name: string };
28
60
  provide?: { kind: string; name: string };
29
61
  result?: Record<string, any>;
30
- }): ControllerInstance {
62
+ }, definingContext: EvaluationContext): ControllerInstance {
31
63
  return {
32
64
  schema: definition.schema ?? { type: "object", additionalProperties: true },
33
65
 
34
66
  create: async (resource: any, ctx: ResourceContext): Promise<ResourceInstance> => {
35
- const self = { ...resource, name: resource.metadata.name };
36
-
37
- // `invoke` describes the dispatch target: a string name template (legacy
38
- // shorthand) or an object `{ kind?, name }` for explicit kind-typed
39
- // dispatch. `inputs:` lives as a sibling on the definition (same shape
40
- // as Run.Sequence steps) the values passed to the dispatch target's
41
- // invoke() after CEL expansion.
42
- const objectInvoke =
43
- definition.invoke !== null &&
44
- typeof definition.invoke === "object" &&
45
- !isCompiledValue(definition.invoke)
46
- ? (definition.invoke as { kind?: string; name: string })
67
+ void ctx; // child scope is rooted on definingContext, not the instance's ctx
68
+ // `self` is read lazily: Phase 5 injection mutates `resource`'s ref slots
69
+ // (e.g. `connection: !ref Db` the live instance) AFTER create() but before
70
+ // init(), so capturing self here would freeze the pre-injection refs. Every
71
+ // expansion reads the current resource state instead.
72
+ const getSelf = () => ({ ...resource, name: resource.metadata.name });
73
+
74
+ // A dispatch field names which `resources:` entry receives the call. It is
75
+ // a string name template (legacy shorthand) or an object `{ kind?, name }`
76
+ // for explicit kind-typed dispatch. Per-call data lives on the top-level
77
+ // `inputs:` sibling (same factoring as Run.Sequence steps), never in the
78
+ // target's resource body the body is `self`-only so every child can be
79
+ // created once at init and reused across calls.
80
+ const targetName = (field: string | { kind?: string; name: string } | undefined): string | null => {
81
+ if (field == null) return null;
82
+ const nameTemplate =
83
+ typeof field === "object" && !isCompiledValue(field) ? field.name : field;
84
+ return nameTemplate
85
+ ? (definingContext.expandWith(nameTemplate, { self: getSelf() }) as string)
47
86
  : null;
48
- const invokeNameTemplate = objectInvoke ? objectInvoke.name : (definition.invoke ?? null);
49
- const invokeTarget = invokeNameTemplate
50
- ? (ctx.moduleContext.expandWith(invokeNameTemplate, { self }) as string)
51
- : null;
52
- const runTarget = definition.run
53
- ? (ctx.moduleContext.expandWith(definition.run, { self }) as string)
54
- : null;
55
- const provideTarget = definition.provide?.name
56
- ? (ctx.moduleContext.expandWith(definition.provide.name, { self }) as string)
57
- : null;
58
-
59
- const persistentManifests: any[] = [];
60
- let ephemeralTemplate: any = null;
87
+ };
61
88
 
89
+ const invokeTarget = targetName(definition.invoke);
90
+ const runTarget = targetName(definition.run);
91
+ const mountTarget = targetName(definition.mount);
92
+ const provideTarget = targetName(definition.provide);
93
+
94
+ const childContext = definingContext.spawnChildContext();
95
+
96
+ // Resolves the live instance of a dispatch target from the child context.
97
+ // Every `resources:` entry is a persistent child created once at init(),
98
+ // so the target is looked up — never re-created — per call.
99
+ const dispatchEntry = (target: string, role: string) => {
100
+ const entry = childContext.resourceInstances.get(target);
101
+ if (!entry) {
102
+ throw new Error(
103
+ `Template '${resource.metadata.name}': '${role}:' targets '${target}' ` +
104
+ `but no entry in 'resources:' has that metadata.name. Available: ${describeAvailableTargets(definingContext, definition.resources, getSelf())}.`,
105
+ );
106
+ }
107
+ return entry;
108
+ };
109
+
110
+ const capabilityError = (entry: any, target: string, role: string, expected: string): Error => {
111
+ const targetKind = (entry?.resource?.kind ?? "<unknown-kind>") as string;
112
+ const targetDef = definingContext.getDefinition?.(targetKind);
113
+ const actualCap = typeof targetDef?.capability === "string" ? targetDef.capability : "<unknown>";
114
+ return new Error(
115
+ `Template '${resource.metadata.name}': '${role}:' target '${targetKind}/${target}' ` +
116
+ `has capability '${actualCap}', not ${expected}. Update '${role}:' to a ${expected} kind, ` +
117
+ `or change the target's kind in 'resources:'.`,
118
+ );
119
+ };
120
+
121
+ const expand = (value: any, extra: Record<string, unknown>) =>
122
+ definingContext.expandWith(definingContext.expandWith(value, extra), extra);
123
+
124
+ // A local `!ref` inside a template body names a sibling `resources:` entry.
125
+ // The entry carries the kind, so resolve each sibling's expanded name to its
126
+ // kind — used to stamp the ref's `{kind, name}` injection shape (an empty
127
+ // kind is rejected downstream as a malformed inline resource).
128
+ const siblingKinds = new Map<string, string>();
62
129
  for (const template of definition.resources ?? []) {
63
- const expandedName = ctx.moduleContext.expandWith(template.metadata?.name ?? "", {
64
- self,
130
+ const expandedName = definingContext.expandWith(template?.metadata?.name ?? "", {
131
+ self: getSelf(),
65
132
  }) as string;
66
- const isTarget =
67
- expandedName === invokeTarget ||
68
- expandedName === runTarget ||
69
- expandedName === provideTarget;
70
- if (isTarget) {
71
- ephemeralTemplate = template;
72
- } else {
73
- persistentManifests.push(ctx.moduleContext.expandWith(template, { self }));
133
+ if (expandedName && typeof template?.kind === "string") {
134
+ siblingKinds.set(expandedName, template.kind);
74
135
  }
75
136
  }
76
137
 
77
- const childContext = ctx.spawnChildContext();
78
-
79
- // Registers an ephemeral manifest on ctx.moduleContext so it shares the same
80
- // resource scope (and can access connections, etc. via getInstance).
81
- // Tears down and removes the resource after fn() completes.
82
- const withEphemeral = async (expandedManifest: any, fn: (name: string) => Promise<any>) => {
83
- const uniqueName = `${expandedManifest.metadata?.name ?? "eph"}__${Math.random().toString(16).slice(2, 8)}`;
84
- const manifest = {
85
- ...expandedManifest,
86
- metadata: {
87
- ...expandedManifest.metadata,
88
- name: uniqueName,
89
- module: resource.metadata.module,
90
- },
91
- };
92
- ctx.moduleContext.registerManifest(manifest);
93
- await ctx.moduleContext.initializeResources();
94
- const entry = ctx.moduleContext.resourceInstances.get(uniqueName);
95
- try {
96
- return await fn(uniqueName);
97
- } finally {
98
- if (entry?.instance?.teardown) await entry.instance.teardown();
99
- ctx.moduleContext.resourceInstances.delete(uniqueName);
138
+ // Expand a persistent child's body against `self`. Self-only CEL resolves
139
+ // to literals now; CEL bound only at call time (DEFERRED_VARS) passes
140
+ // through compiled for the child's own controller. `!ref` sentinels are
141
+ // rewritten to the `{kind, name, alias?}` injection shape here — Phase 2.5
142
+ // (`resolveRefSentinels`) does not descend into template bodies, so the
143
+ // child context's Phase 5 injection would otherwise see an unrecognized
144
+ // sentinel and leave the slot unresolved. Kind is left empty: injection
145
+ // dispatches by name and recovers the kind from the resolved instance.
146
+ const expandSelf = (value: any): any => {
147
+ if (isCompiledValue(value)) {
148
+ if (referencesDeferred(value)) return value;
149
+ // A pure `self.<path>` access (e.g. a `connection: !ref` passed down) is
150
+ // resolved by navigating the resource directly. Going through CEL would
151
+ // re-emit the value through CEL's output type-checker, which rejects live
152
+ // resource instances (unrecognized class constructors) — so the connection
153
+ // a consumer wired in could never reach a child's slot. Complex self
154
+ // expressions (string building) still evaluate via CEL, where they yield
155
+ // CEL-safe scalars.
156
+ const path = typeof value.source === "string" ? value.source.trim().match(SELF_PATH) : null;
157
+ if (path) {
158
+ let cur: any = getSelf();
159
+ for (const key of path[1].split(".").slice(1)) cur = cur?.[key];
160
+ return cur;
161
+ }
162
+ return definingContext.expandWith(value, { self: getSelf() });
100
163
  }
164
+ if (isRefSentinel(value)) {
165
+ const source = value.source;
166
+ const dot = source.indexOf(".");
167
+ const alias = dot > 0 ? source.slice(0, dot) : undefined;
168
+ if (alias && alias !== "Self") {
169
+ const name = source.slice(dot + 1);
170
+ return { kind: siblingKinds.get(name) ?? "", name, alias };
171
+ }
172
+ const name = alias === "Self" ? source.slice(dot + 1) : source;
173
+ return { kind: siblingKinds.get(name) ?? "", name };
174
+ }
175
+ if (Array.isArray(value)) return value.map(expandSelf);
176
+ if (value !== null && typeof value === "object") {
177
+ const out: Record<string, unknown> = {};
178
+ for (const [k, v] of Object.entries(value)) out[k] = expandSelf(v);
179
+ return out;
180
+ }
181
+ return value;
101
182
  };
102
183
 
184
+ // init() may run more than once: when a child's local ref names a sibling
185
+ // not yet initialized, child init defers with ERR_LOCAL_REF_PENDING and the
186
+ // outer multi-pass loop retries this resource. Registration must happen
187
+ // once; each retry only resumes the child init loop (already-initialized
188
+ // children are skipped, still-pending ones advance).
189
+ let registered = false;
190
+
103
191
  return {
104
192
  init: async () => {
105
- for (const m of persistentManifests) childContext.registerManifest(m);
193
+ if (!registered) {
194
+ for (const template of definition.resources ?? []) {
195
+ childContext.registerManifest(expandSelf(template));
196
+ }
197
+ registered = true;
198
+ }
106
199
  await childContext.initializeResources();
107
200
  },
108
201
 
109
202
  ...(invokeTarget && {
110
203
  invoke: async (inputs: any) => {
111
- if (!ephemeralTemplate) {
112
- throw new Error(
113
- `Template '${resource.metadata.name}': 'invoke:' targets '${invokeTarget}' ` +
114
- `but no entry in 'resources:' has that metadata.name. Available: ${describeAvailableTargets(ctx, definition.resources, self)}.`,
115
- );
204
+ const entry = dispatchEntry(invokeTarget, "invoke");
205
+ if (!entry.instance?.invoke) {
206
+ throw capabilityError(entry, invokeTarget, "invoke", "Telo.Invocable");
116
207
  }
117
- const extraContext = { self, inputs };
118
- const expanded = ctx.moduleContext.expandWith(
119
- ctx.moduleContext.expandWith(ephemeralTemplate, extraContext),
120
- extraContext,
121
- ) as any;
122
- return withEphemeral(expanded, async (name) => {
123
- const entry = ctx.moduleContext.resourceInstances.get(name);
124
- if (!entry?.instance?.invoke) {
125
- const targetKind = (entry?.resource?.kind ?? expanded?.kind ?? "<unknown-kind>") as string;
126
- const targetDef = ctx.moduleContext.getDefinition?.(targetKind);
127
- const actualCap = typeof targetDef?.capability === "string" ? targetDef.capability : "<unknown>";
128
- throw new Error(
129
- `Template '${resource.metadata.name}': 'invoke:' target '${targetKind}/${invokeTarget}' ` +
130
- `has capability '${actualCap}', not Telo.Invocable. Update 'invoke:' to a Telo.Invocable kind, or change the target's kind in 'resources:'.`,
131
- );
132
- }
133
- // Top-level `inputs:` (sibling of `invoke:`) carries the values passed
134
- // to the dispatch target's invoke(). When absent, fall back to the
135
- // expanded resource entry's own `inputs` field (legacy string-form
136
- // shape where the inputs live on the resource declaration), then
137
- // finally to the caller's `inputs` arg.
138
- const invokeInputs = definition.inputs != null
139
- ? ctx.moduleContext.expandWith(
140
- ctx.moduleContext.expandWith(definition.inputs, extraContext),
141
- extraContext,
142
- )
143
- : expanded.inputs ?? inputs;
144
- const raw = await entry.instance.invoke(invokeInputs);
145
- if (definition.result == null) return raw;
146
- const resultContext = { self, result: raw };
147
- return ctx.moduleContext.expandWith(
148
- ctx.moduleContext.expandWith(definition.result, resultContext),
149
- resultContext,
150
- );
151
- });
208
+ const invokeInputs =
209
+ definition.inputs != null ? expand(definition.inputs, { self: getSelf(), inputs }) : inputs;
210
+ const raw = await entry.instance.invoke(invokeInputs);
211
+ if (definition.result == null) return raw;
212
+ return expand(definition.result, { self: getSelf(), result: raw });
152
213
  },
153
214
  }),
154
215
 
155
216
  ...(runTarget && {
156
217
  run: async () => {
157
- if (!ephemeralTemplate) {
158
- throw new Error(
159
- `Template '${resource.metadata.name}': 'run:' targets '${runTarget}' ` +
160
- `but no entry in 'resources:' has that metadata.name. Available: ${describeAvailableTargets(ctx, definition.resources, self)}.`,
161
- );
218
+ const entry = dispatchEntry(runTarget, "run");
219
+ if (!entry.instance?.run) {
220
+ throw capabilityError(entry, runTarget, "run", "Telo.Runnable");
162
221
  }
163
- const extraContext = { self };
164
- const expanded = ctx.moduleContext.expandWith(
165
- ctx.moduleContext.expandWith(ephemeralTemplate, extraContext),
166
- extraContext,
167
- ) as any;
168
- return withEphemeral(expanded, async (name) => {
169
- const entry = ctx.moduleContext.resourceInstances.get(name);
170
- if (!entry?.instance?.run) {
171
- const targetKind = (entry?.resource?.kind ?? expanded?.kind ?? "<unknown-kind>") as string;
172
- const targetDef = ctx.moduleContext.getDefinition?.(targetKind);
173
- const actualCap = typeof targetDef?.capability === "string" ? targetDef.capability : "<unknown>";
174
- throw new Error(
175
- `Template '${resource.metadata.name}': 'run:' target '${targetKind}/${runTarget}' ` +
176
- `has capability '${actualCap}', not Telo.Runnable. Update 'run:' to a Telo.Runnable kind, or change the target's kind in 'resources:'.`,
177
- );
178
- }
179
- return entry.instance.run();
180
- });
222
+ return entry.instance.run();
181
223
  },
182
224
  }),
183
225
 
184
226
  ...(provideTarget && {
185
227
  provide: async () => {
186
- if (!ephemeralTemplate) {
187
- throw new Error(
188
- `Template '${resource.metadata.name}': 'provide:' targets '${provideTarget}' ` +
189
- `but no entry in 'resources:' has that metadata.name. Available: ${describeAvailableTargets(ctx, definition.resources, self)}.`,
190
- );
228
+ const entry = dispatchEntry(provideTarget, "provide");
229
+ if (!entry.instance?.invoke) {
230
+ throw capabilityError(entry, provideTarget, "provide", "Telo.Invocable");
231
+ }
232
+ const provideInputs: any =
233
+ definition.inputs != null ? expand(definition.inputs, { self: getSelf() }) : {};
234
+ const raw = await entry.instance.invoke(provideInputs);
235
+ if (definition.result == null) return raw;
236
+ return expand(definition.result, { self: getSelf(), result: raw });
237
+ },
238
+ }),
239
+
240
+ ...(mountTarget && {
241
+ // `register(app, prefix)` is the Telo.Mount contract a consuming
242
+ // Http.Server calls. It is not on the base ResourceInstance type, so
243
+ // the persistent mount child is accessed structurally.
244
+ register: (app: any, prefix?: string) => {
245
+ const entry = dispatchEntry(mountTarget, "mount");
246
+ const mountable = entry.instance as { register?: (app: any, prefix?: string) => unknown };
247
+ if (typeof mountable.register !== "function") {
248
+ throw capabilityError(entry, mountTarget, "mount", "Telo.Mount");
191
249
  }
192
- const extraContext = { self };
193
- const expanded = ctx.moduleContext.expandWith(
194
- ctx.moduleContext.expandWith(ephemeralTemplate, extraContext),
195
- extraContext,
196
- ) as any;
197
- return withEphemeral(expanded, async (name) => {
198
- const entry = ctx.moduleContext.resourceInstances.get(name);
199
- if (!entry?.instance?.invoke) {
200
- const targetKind = (entry?.resource?.kind ?? expanded?.kind ?? "<unknown-kind>") as string;
201
- const targetDef = ctx.moduleContext.getDefinition?.(targetKind);
202
- const actualCap = typeof targetDef?.capability === "string" ? targetDef.capability : "<unknown>";
203
- throw new Error(
204
- `Template '${resource.metadata.name}': 'provide:' target '${targetKind}/${provideTarget}' ` +
205
- `has capability '${actualCap}', not Telo.Invocable. Update 'provide:' to a Telo.Invocable kind, or change the target's kind in 'resources:'.`,
206
- );
207
- }
208
- const provideInputs: any =
209
- definition.inputs != null
210
- ? ctx.moduleContext.expandWith(
211
- ctx.moduleContext.expandWith(definition.inputs, extraContext),
212
- extraContext,
213
- )
214
- : {};
215
- const raw = await entry.instance.invoke(provideInputs);
216
- if (definition.result == null) return raw;
217
- const resultContext = { self, result: raw };
218
- return ctx.moduleContext.expandWith(
219
- ctx.moduleContext.expandWith(definition.result, resultContext),
220
- resultContext,
221
- );
222
- });
250
+ return mountable.register(app, prefix);
223
251
  },
224
252
  }),
225
253
 
@@ -274,6 +274,25 @@ export class EvaluationContext implements IEvaluationContext {
274
274
  return child;
275
275
  }
276
276
 
277
+ /** Spawn a fresh child context attached to this node — the isolated scope a
278
+ * templated definition registers its `resources:` into. Rooting it on the
279
+ * context that DEFINED the template (not the consumer that instantiated the
280
+ * kind) is what makes the template's internal kind aliases and `!ref`s
281
+ * resolve against the defining library's imports. Local sibling refs resolve
282
+ * in the child; a cross-module `!ref Alias.name` delegates to this context's
283
+ * imports (a plain child resolves none of its own). */
284
+ spawnChildContext(): EvaluationContext {
285
+ const child = new EvaluationContext(
286
+ this.source,
287
+ this.context,
288
+ this._createInstance,
289
+ this._secretValues,
290
+ this.emit,
291
+ );
292
+ child.resolveImportedInstance = (alias, name) => this.resolveImportedInstance(alias, name);
293
+ return this.spawnChild(child);
294
+ }
295
+
277
296
  /**
278
297
  * Resolve a cross-module exported instance (`!ref Alias.name`) to its live instance.
279
298
  * Overridable hook: the base context has no imports, so it returns undefined; ModuleContext
@@ -21,7 +21,6 @@ import { isRefSentinel } from "@telorun/templating";
21
21
  import { stripCompiledValues } from "./schema-compiled-values.js";
22
22
  import AjvModule from "ajv";
23
23
  import addFormats from "ajv-formats";
24
- import { EvaluationContext } from "./evaluation-context.js";
25
24
  import { Kernel } from "./kernel.js";
26
25
  import { formatAjvErrors } from "./manifest-schemas.js";
27
26
  import { policyFingerprint } from "./runtime-registry.js";
@@ -454,14 +453,11 @@ export class ResourceContextImpl implements ResourceContext {
454
453
  * call initializeResources() to initialize them in isolation.
455
454
  */
456
455
  spawnChildContext(): IEvaluationContext {
457
- const child = new EvaluationContext(
458
- this.moduleContext.source,
459
- this.moduleContext.context,
460
- this.moduleContext.createInstance,
461
- this.moduleContext.secretValues,
462
- this.moduleContext.emit,
463
- );
464
- return this.moduleContext.spawnChild(child);
456
+ // One construction path: defer to the module context's own
457
+ // spawnChildContext(). Rooted on this resource's module context (the
458
+ // consumer scope); a templated definition that needs library-scoped
459
+ // resolution calls the defining library's context directly instead.
460
+ return this.moduleContext.spawnChildContext();
465
461
  }
466
462
 
467
463
  transientChild(context: Record<string, any>): IEvaluationContext {