@telorun/sdk 0.2.5 → 0.2.7

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 (43) hide show
  1. package/dist/capabilities/invokable.d.ts +2 -2
  2. package/dist/capabilities/invokable.d.ts.map +1 -1
  3. package/dist/capability-definition.d.ts +8 -0
  4. package/dist/capability-definition.d.ts.map +1 -0
  5. package/dist/capability-definition.js +21 -0
  6. package/dist/cel-environment.d.ts +3 -0
  7. package/dist/cel-environment.d.ts.map +1 -0
  8. package/dist/cel-environment.js +13 -0
  9. package/dist/compiled-value.d.ts +9 -0
  10. package/dist/compiled-value.d.ts.map +1 -0
  11. package/dist/compiled-value.js +3 -0
  12. package/dist/evaluation-context.d.ts +85 -26
  13. package/dist/evaluation-context.d.ts.map +1 -1
  14. package/dist/evaluation-context.js +227 -89
  15. package/dist/index.d.ts +2 -0
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +2 -0
  18. package/dist/module-context.d.ts +6 -4
  19. package/dist/module-context.d.ts.map +1 -1
  20. package/dist/module-context.js +31 -4
  21. package/dist/ref.d.ts +47 -0
  22. package/dist/ref.d.ts.map +1 -0
  23. package/dist/ref.js +11 -0
  24. package/dist/resource-context.d.ts +19 -8
  25. package/dist/resource-context.d.ts.map +1 -1
  26. package/dist/resource-instance.d.ts +4 -9
  27. package/dist/resource-instance.d.ts.map +1 -1
  28. package/dist/runtime-error.d.ts +8 -1
  29. package/dist/runtime-error.d.ts.map +1 -1
  30. package/dist/types.d.ts +24 -10
  31. package/dist/types.d.ts.map +1 -1
  32. package/dist/types.js +3 -1
  33. package/package.json +2 -5
  34. package/src/capabilities/invokable.ts +2 -2
  35. package/src/compiled-value.ts +11 -0
  36. package/src/evaluation-context.ts +283 -101
  37. package/src/index.ts +2 -0
  38. package/src/module-context.ts +33 -5
  39. package/src/ref.ts +61 -0
  40. package/src/resource-context.ts +19 -8
  41. package/src/resource-instance.ts +11 -14
  42. package/src/runtime-error.ts +15 -1
  43. package/src/types.ts +24 -14
@@ -1,44 +1,55 @@
1
- import { evaluate } from "cel-js";
2
- import { ModuleContext } from "./module-context.js";
1
+ import { isCompiledValue } from "./compiled-value.js";
2
+ import type { ModuleContext } from "./module-context.js";
3
+ import type { ScopeContext, ScopeHandle } from "./ref.js";
3
4
  import { ResourceInstance } from "./resource-instance.js";
4
5
  import { ResourceManifest } from "./resource-manifest.js";
6
+ import { RuntimeDiagnostic } from "./runtime-error.js";
5
7
  import { RuntimeError } from "./types.js";
6
8
 
7
9
  export type EmitEvent = (event: string, payload?: any) => void | Promise<void>;
8
10
 
9
- const TEMPLATE_REGEX = /\$\{\{\s*([^}]+?)\s*\}\}/g;
10
- const EXACT_TEMPLATE_REGEX = /^\s*\$\{\{\s*([^}]+?)\s*\}\}\s*$/;
11
-
12
11
  /** Four-stage resource lifecycle defined in resource-lifecycle.md */
13
12
  export type LifecycleState = "Pending" | "Validated" | "Initialized" | "Draining" | "Teardown";
14
13
 
14
+ /**
15
+ * Result of the create phase: the instance and its bound ResourceContext.
16
+ * The ctx is typed as any to avoid a circular import with resource-context.ts.
17
+ */
18
+ export type CreatedResource = { instance: ResourceInstance; ctx: any };
19
+
15
20
  /**
16
21
  * Creates a ResourceInstance for the given manifest, or returns null if not yet
17
22
  * ready (e.g. a dependency is still initializing). Injected at construction so
18
23
  * every EvaluationContext node owns its full resource lifecycle.
24
+ * Returns a CreatedResource (instance + ctx) so initializeResources can run
25
+ * init() separately in a second phase.
19
26
  */
20
27
  export type InstanceFactory = (
21
28
  moduleContext: ModuleContext,
22
29
  resource: ResourceManifest,
23
- ) => Promise<ResourceInstance | null>;
30
+ ) => Promise<CreatedResource | null>;
31
+
32
+ /**
33
+ * Hook called after controller.create() and before controller.init() for each resource.
34
+ * Implementations (e.g. the kernel) use this to inject live instances into reference
35
+ * fields of the resource config before the controller sees them in init().
36
+ *
37
+ * @param resource The resource manifest whose config fields may be mutated in-place.
38
+ * @param getInstance Looks up an already-initialized instance by resource name.
39
+ * Returns undefined when the named resource is not yet initialized.
40
+ */
41
+ export type PreInitHook = (
42
+ resource: ResourceManifest,
43
+ getInstance: (name: string) => ResourceInstance | undefined,
44
+ ) => void;
24
45
 
25
46
  /** Canonical key for a resource instance: "<module>.<kind>.<name>" */
26
47
  export function resourceKey(r: ResourceManifest): string {
27
48
  return `${r.kind}.${r.metadata.name}`;
28
49
  }
29
50
 
30
- function redactSecrets(message: string, secretValues: Set<string>): string {
31
- if (secretValues.size === 0) return message;
32
- const sorted = Array.from(secretValues).sort((a, b) => b.length - a.length);
33
- let result = message;
34
- for (const secret of sorted) {
35
- result = result.split(secret).join("[REDACTED]");
36
- }
37
- return result;
38
- }
39
-
40
51
  /**
41
- * Base class for all evaluation contexts. Owns CEL evaluation, template
52
+ * Base class for all evaluation contexts. Owns template
42
53
  * expansion, secrets redaction, and the generic resource lifecycle tree.
43
54
  *
44
55
  * Every EvaluationContext node can:
@@ -68,9 +79,21 @@ export class EvaluationContext {
68
79
  { resource: ResourceManifest; instance: ResourceInstance }
69
80
  >();
70
81
 
82
+ /** Resources that have been created but not yet initialized (between phases). */
83
+ protected readonly createdInstances = new Map<
84
+ string,
85
+ { resource: ResourceManifest; instance: ResourceInstance; ctx: any }
86
+ >();
87
+
71
88
  /** Resources queued for initialization on this context node. */
72
89
  private pendingResources: ResourceManifest[] = [];
73
90
 
91
+ /**
92
+ * Optional hook called between create() and init() for each resource.
93
+ * Set by the kernel to inject live instances into reference fields.
94
+ */
95
+ preInitHook?: PreInitHook;
96
+
74
97
  constructor(
75
98
  readonly source: string,
76
99
  context: Record<string, unknown>,
@@ -88,6 +111,9 @@ export class EvaluationContext {
88
111
  return this._createInstance;
89
112
  }
90
113
 
114
+ /** Called after init() when a resource snapshot is available. Overridden by ModuleContext. */
115
+ protected onResourceSnapshotted(_name: string, _snap: Record<string, unknown>): void {}
116
+
91
117
  get context(): Record<string, unknown> {
92
118
  return this._context;
93
119
  }
@@ -96,13 +122,40 @@ export class EvaluationContext {
96
122
  return this._secretValues;
97
123
  }
98
124
 
125
+ /**
126
+ * Reorder pending resources to match the given name sequence (topo order from Phase 4).
127
+ * Resources not present in `names` are left at the end in their original order.
128
+ * Call before initializeResources() so the create/init sub-phases run in dependency order,
129
+ * guaranteeing that Phase 5 injection always finds initialized dependencies.
130
+ */
131
+ setInitOrder(names: string[]): void {
132
+ const rank = new Map(names.map((n, i) => [n, i]));
133
+ this.pendingResources.sort((a, b) => {
134
+ const ra = rank.get(a.metadata.name as string) ?? Infinity;
135
+ const rb = rank.get(b.metadata.name as string) ?? Infinity;
136
+ return ra - rb;
137
+ });
138
+ }
139
+
99
140
  /**
100
141
  * Queue a resource manifest for initialization on this context.
101
142
  */
143
+ hasManifest(name: string): boolean {
144
+ return (
145
+ this.resourceInstances.has(name) ||
146
+ this.createdInstances.has(name) ||
147
+ this.pendingResources.some((r) => r.metadata.name === name)
148
+ );
149
+ }
150
+
102
151
  registerManifest(resource: ResourceManifest): void {
103
152
  if (!resource.metadata) {
104
153
  resource.metadata = { name: `__unnamed_${Math.random().toString(16).slice(2, 8)}` };
105
154
  }
155
+ const name = resource.metadata.name;
156
+ if (this.hasManifest(name)) {
157
+ throw new RuntimeError("ERR_DUPLICATE_RESOURCE", `Resource '${name}' is already registered`);
158
+ }
106
159
  this.pendingResources.push(resource);
107
160
  }
108
161
 
@@ -113,63 +166,107 @@ export class EvaluationContext {
113
166
  spawnChild<T extends EvaluationContext>(child: T): T {
114
167
  child.parent = this;
115
168
  this.children.push(child);
169
+ // Propagate injection hook so all child contexts (module imports, scopes) participate
170
+ // in Phase 5 injection. createScopeHandle overrides this with an extended version.
171
+ if (this.preInitHook && !child.preInitHook) {
172
+ child.preInitHook = this.preInitHook;
173
+ }
116
174
  return child;
117
175
  }
118
176
 
119
177
  /**
120
- * Multi-pass initialization loop. Processes pendingResources by calling the
121
- * supplied instantiator for each resource, retrying failures across up to 10
122
- * passes (handles dependency ordering without explicit topological sort).
178
+ * Interleaved create/init loop.
123
179
  *
124
- * ERR_VISIBILITY_DENIED errors are fatal and re-thrown immediately.
180
+ * Each pass has two sub-phases run back-to-back:
181
+ * 1. Create sub-phase: call controller.create() for each pending resource that
182
+ * hasn't been created yet. Successful results go into createdInstances.
183
+ * 2. Init sub-phase: call instance.init(ctx) for each created-but-not-inited
184
+ * resource. Successful results go into resourceInstances.
185
+ *
186
+ * Interleaving is necessary because some resources' create() depends on effects
187
+ * produced by other resources' init() (e.g. Kernel.Import.init() runs
188
+ * child.initializeResources() which registers controllers needed by sibling
189
+ * resources' create()). Running both sub-phases each pass lets those effects
190
+ * propagate before the next create attempt.
191
+ *
192
+ * Each resource is created at most once and inited at most once.
193
+ * ERR_VISIBILITY_DENIED is fatal and re-thrown immediately.
125
194
  * All other errors are tracked and retried until no progress is made.
126
195
  */
127
196
  async initializeResources(): Promise<void> {
128
197
  const MAX_PASSES = 10;
129
- let pass = 1;
130
198
  const errors = new Map<string, string>();
131
199
 
200
+ let pass = 1;
132
201
  do {
133
- const handled: string[] = [];
202
+ let progress = false;
134
203
 
204
+ // Create sub-phase
135
205
  for (const resource of [...this.pendingResources]) {
136
- // const rkey = resourceKey(resource);
137
- // const displayKey = rkey;
138
206
  const name = resource.metadata.name;
139
- if (this.resourceInstances.has(name)) continue;
140
-
207
+ if (this.createdInstances.has(name)) continue;
141
208
  try {
142
- const instance = await this._createInstance(this as any, resource);
143
- if (instance) {
144
- this.resourceInstances.set(name, { resource, instance });
145
- handled.push(name);
209
+ // const expanded = this.expand(resource) as ResourceManifest;
210
+ // FIXME: Cannot expand it for all resources, needs to be selective
211
+ const created = await this._createInstance(this as any, resource);
212
+ if (created) {
213
+ this.createdInstances.set(name, {
214
+ resource,
215
+ instance: created.instance,
216
+ ctx: created.ctx,
217
+ });
218
+ const idx = this.pendingResources.findIndex((m) => m.metadata.name === name);
219
+ if (idx >= 0) this.pendingResources.splice(idx, 1);
146
220
  errors.delete(name);
221
+ progress = true;
147
222
  }
148
223
  } catch (error) {
149
224
  if (error instanceof RuntimeError && error.code === "ERR_VISIBILITY_DENIED") throw error;
150
- errors.set(name, error instanceof Error ? (error.stack ?? error.message) : String(error));
225
+ errors.set(name, error instanceof Error ? error.message : String(error));
151
226
  }
152
227
  }
153
228
 
154
- for (const name of handled) {
155
- const resource = this.pendingResources.find((m) => m.metadata.name === name)!;
156
- const idx = this.pendingResources.indexOf(resource);
157
- if (idx >= 0) this.pendingResources.splice(idx, 1);
229
+ // Init sub-phase
230
+ for (const [name, { resource, instance, ctx }] of [...this.createdInstances]) {
231
+ if (this.resourceInstances.has(name)) continue;
232
+ try {
233
+ if (this.preInitHook) {
234
+ this.preInitHook(resource, (n) => this.resourceInstances.get(n)?.instance);
235
+ }
236
+ if (instance.init) await instance.init(ctx);
237
+ if (instance.snapshot) {
238
+ const snap = await Promise.resolve(instance.snapshot()).catch(() => ({}));
239
+ this.onResourceSnapshotted(name, (snap as Record<string, unknown>) ?? {});
240
+ }
241
+ this.resourceInstances.set(name, { resource, instance });
242
+ this.createdInstances.delete(name);
243
+ errors.delete(name);
244
+ progress = true;
245
+ } catch (error) {
246
+ if (error instanceof RuntimeError && error.code === "ERR_VISIBILITY_DENIED") throw error;
247
+ errors.set(name, error instanceof Error ? error.message : String(error));
248
+ }
158
249
  }
159
250
 
160
251
  pass++;
161
- if (handled.length === 0) break;
252
+ if (!progress) break;
162
253
  } while (pass <= MAX_PASSES);
163
254
 
164
- if (this.pendingResources.length > 0) {
165
- const unhandledList = this.pendingResources
166
- .reverse()
167
- .map((r) => `- ${r.metadata.name}: ${errors.get(r.metadata.name) ?? "Unknown error"}`)
168
- .join("\n");
169
-
255
+ if (this.pendingResources.length > 0 || this.createdInstances.size > 0) {
256
+ const diagnostics: RuntimeDiagnostic[] = [
257
+ ...this.pendingResources.map((r) => ({
258
+ resource: r.metadata.name,
259
+ message: errors.get(r.metadata.name) ?? "Unknown error",
260
+ })),
261
+ ...[...this.createdInstances.keys()].map((name) => ({
262
+ resource: name,
263
+ message: errors.get(name) ?? "Unknown error",
264
+ })),
265
+ ];
170
266
  throw new RuntimeError(
171
267
  "ERR_RESOURCE_INITIALIZATION_FAILED",
172
- `Unable to process resources:\n\n${unhandledList}`,
268
+ "Unable to process resources",
269
+ diagnostics,
173
270
  );
174
271
  }
175
272
 
@@ -199,13 +296,72 @@ export class EvaluationContext {
199
296
  }
200
297
  }
201
298
 
299
+ /**
300
+ * Returns a ScopeHandle that initializes `manifests` in a fresh child context each time
301
+ * `run()` is called, executes the callback with a ScopeContext, and tears down when done.
302
+ *
303
+ * The child inherits the parent's preInitHook (if any), extended so that `getInstance`
304
+ * also checks the parent's already-initialized singleton instances. This lets scoped
305
+ * resources hold x-telo-ref slots pointing to outer resources — those deps are already
306
+ * live when the scope opens.
307
+ */
308
+ createScopeHandle(manifests: ResourceManifest[]): ScopeHandle {
309
+ const parent = this;
310
+ return {
311
+ async run<T>(fn: (scope: ScopeContext) => Promise<T>): Promise<T> {
312
+ const child = parent.spawnChild(
313
+ new EvaluationContext(
314
+ parent.source,
315
+ parent._context,
316
+ parent._createInstance,
317
+ parent._secretValues,
318
+ parent.emit,
319
+ ),
320
+ );
321
+
322
+ // Propagate injection hook: extend getInstance to also resolve parent singleton instances.
323
+ if (parent.preInitHook) {
324
+ const parentHook = parent.preInitHook;
325
+ child.preInitHook = (resource, childGetInstance) => {
326
+ parentHook(
327
+ resource,
328
+ (name) => childGetInstance(name) ?? parent.resourceInstances.get(name)?.instance,
329
+ );
330
+ };
331
+ }
332
+
333
+ try {
334
+ for (const manifest of manifests) {
335
+ child.registerManifest(manifest);
336
+ }
337
+ await child.initializeResources();
338
+ const scope: ScopeContext = {
339
+ getInstance(name: string): ResourceInstance {
340
+ const childEntry = child.resourceInstances.get(name);
341
+ if (childEntry) return childEntry.instance;
342
+ const parentEntry = parent.resourceInstances.get(name);
343
+ if (parentEntry) return parentEntry.instance;
344
+ throw new RuntimeError(
345
+ "ERR_SCOPE_RESOURCE_NOT_FOUND",
346
+ `Resource '${name}' not found in scope or outer context. Available scoped: ${[...child.resourceInstances.keys()].join(", ")}`,
347
+ );
348
+ },
349
+ };
350
+ return await fn(scope);
351
+ } finally {
352
+ await child.teardownResources();
353
+ const idx = parent.children.indexOf(child);
354
+ if (idx >= 0) parent.children.splice(idx, 1);
355
+ }
356
+ },
357
+ };
358
+ }
359
+
202
360
  /**
203
361
  * Cascade teardown depth-first through the tree:
204
362
  * 1. Tear down child contexts in reverse registration order.
205
- * 2. Tear down own resource instances in reverse registration order.
206
- *
207
- * Note: Kernel-level events (e.g. Teardown events) are NOT emitted here —
208
- * they remain the Kernel's responsibility.
363
+ * 2. Tear down own resource instances in reverse registration order,
364
+ * emitting a Teardown event for each via the injected emit callback.
209
365
  */
210
366
  async teardownResources(): Promise<void> {
211
367
  this.state = "Draining";
@@ -213,18 +369,31 @@ export class EvaluationContext {
213
369
  await child.teardownResources();
214
370
  }
215
371
  const entries = [...this.resourceInstances.entries()].reverse();
216
- for (const [key, { instance }] of entries) {
372
+ for (const [key, { resource, instance }] of entries) {
217
373
  if (instance.teardown) await instance.teardown();
374
+ await this.emit(`${resource.kind}.${resource.metadata.name}.Teardown`, {
375
+ resource: { kind: resource.kind, name: resource.metadata.name },
376
+ });
218
377
  this.resourceInstances.delete(key);
219
378
  }
220
379
  this.state = "Teardown";
221
380
  }
222
381
 
382
+ transientChild(context: Record<string, any>): EvaluationContext {
383
+ return new EvaluationContext(
384
+ this.source,
385
+ { ...this.context, ...context },
386
+ this._createInstance,
387
+ this._secretValues,
388
+ this.emit,
389
+ );
390
+ }
391
+
223
392
  /**
224
393
  * Invoke a resource by kind and name within this context's resourceInstances.
225
394
  * Emits a scoped Invoked event via the injected emit callback after invocation.
226
395
  */
227
- async invoke(kind: string, name: string, ...args: any[]): Promise<any> {
396
+ async invoke<TInputs>(kind: string, name: string, inputs: TInputs): Promise<any> {
228
397
  const entry = this.resourceInstances.get(name);
229
398
 
230
399
  if (entry) {
@@ -234,7 +403,7 @@ export class EvaluationContext {
234
403
  `Resource ${kind}.${name} does not have an invoke method`,
235
404
  );
236
405
  }
237
- const outputs = await entry.instance.invoke(args[0]);
406
+ const outputs = await entry.instance.invoke(inputs as any);
238
407
  await this.emit(`${kind}.${name}.Invoked`, { outputs });
239
408
  return outputs;
240
409
  }
@@ -257,29 +426,12 @@ export class EvaluationContext {
257
426
  }
258
427
 
259
428
  /**
260
- * Evaluate a single CEL expression string against the context.
261
- * Secret values are redacted from any thrown error message.
262
- */
263
- evaluate(expression: string): unknown {
264
- try {
265
- return evaluate(expression, this._context);
266
- } catch (error) {
267
- const raw = error instanceof Error ? error.message : String(error);
268
- const safe = redactSecrets(raw, this._secretValues);
269
- throw new Error(`CEL evaluation failed: "${expression}": ${safe}`);
270
- }
271
- }
272
-
273
- /**
274
- * Expand a value that may contain ${{ }} templates.
275
- * Works recursively over strings, arrays, and objects.
276
- * Templates whose identifiers are not present in the context are left
277
- * unchanged (deferred) — they will be resolved at execution time when a
278
- * richer ExecutionContext is available. All other CEL errors are propagated.
429
+ * Expand a value that may contain precompiled ${{ }} templates.
430
+ * Works recursively over CompiledValues, arrays, and objects.
279
431
  */
280
432
  expand(value: unknown): unknown {
281
- if (typeof value === "string") {
282
- return this.expandString(value);
433
+ if (isCompiledValue(value)) {
434
+ return value.call(this._context);
283
435
  }
284
436
  if (Array.isArray(value)) {
285
437
  return value.map((entry) => this.expand(entry));
@@ -295,43 +447,73 @@ export class EvaluationContext {
295
447
  }
296
448
 
297
449
  /**
298
- * Merge another context on top of this one.
299
- * Returns a new base EvaluationContext 'other' wins on key conflict.
450
+ * Expand a value using this context merged with additional properties.
451
+ * Equivalent to merge(extraContext).expand(value) without allocating a context object.
300
452
  */
301
- merge(other: EvaluationContext | Record<string, unknown>): EvaluationContext {
302
- const otherCtx = other instanceof EvaluationContext ? other.context : other;
303
- const otherSecrets =
304
- other instanceof EvaluationContext ? other.secretValues : new Set<string>();
305
- const merged = Object.assign(Object.create(null), this._context, otherCtx) as Record<
453
+ expandWith(value: unknown, extraContext: Record<string, unknown>): unknown {
454
+ const saved = this._context;
455
+ this._context = Object.assign(Object.create(null), saved, extraContext) as Record<
306
456
  string,
307
457
  unknown
308
458
  >;
309
- const mergedSecrets = new Set<string>([...this._secretValues, ...otherSecrets]);
310
- return new EvaluationContext(
311
- this.source,
312
- merged,
313
- this._createInstance,
314
- mergedSecrets,
315
- this.emit,
316
- );
459
+ try {
460
+ return this.expand(value);
461
+ } finally {
462
+ this._context = saved;
463
+ }
317
464
  }
318
465
 
319
- private expandString(value: string): unknown {
320
- if (!value.includes("${{")) {
321
- return value;
466
+ /**
467
+ * Expand specific dot-paths within an object. '**' expands the entire object.
468
+ * Paths listed in excludePaths are left untouched (runtime takes precedence).
469
+ * Always throws if an expression cannot be resolved.
470
+ */
471
+ expandPaths(
472
+ value: Record<string, unknown>,
473
+ paths: string[],
474
+ excludePaths: string[] = [],
475
+ ): Record<string, unknown> {
476
+ if (paths.includes("**")) {
477
+ const result: Record<string, unknown> = {};
478
+ for (const [key, v] of Object.entries(value)) {
479
+ result[key] = isExcluded(key, excludePaths) ? v : this.expand(v);
480
+ }
481
+ return result;
322
482
  }
323
-
324
- const exact = value.match(EXACT_TEMPLATE_REGEX);
325
- if (exact) {
326
- return this.evaluate(exact[1]);
483
+ const result = { ...value };
484
+ for (const path of paths) {
485
+ if (isExcluded(path, excludePaths)) continue;
486
+ const parts = path.split(".");
487
+ const current = getNestedValue(result, parts);
488
+ if (current !== undefined) {
489
+ setNestedValue(result, parts, this.expand(current));
490
+ }
327
491
  }
492
+ return result;
493
+ }
494
+ }
328
495
 
329
- return value.replace(TEMPLATE_REGEX, (_match, expr: string) => {
330
- const resolved = this.evaluate(expr);
331
- if (resolved === null || resolved === undefined) {
332
- return "";
333
- }
334
- return String(resolved);
335
- });
496
+ function isExcluded(path: string, excludePaths: string[]): boolean {
497
+ return excludePaths.some(
498
+ (ep) => ep === path || ep === "**" || path.startsWith(ep + ".") || ep.startsWith(path + "."),
499
+ );
500
+ }
501
+
502
+ function getNestedValue(obj: Record<string, unknown>, parts: string[]): unknown {
503
+ let current: unknown = obj;
504
+ for (const part of parts) {
505
+ if (current === null || typeof current !== "object") return undefined;
506
+ current = (current as Record<string, unknown>)[part];
507
+ }
508
+ return current;
509
+ }
510
+
511
+ function setNestedValue(obj: Record<string, unknown>, parts: string[], value: unknown): void {
512
+ let current: Record<string, unknown> = obj;
513
+ for (let i = 0; i < parts.length - 1; i++) {
514
+ const next = current[parts[i]];
515
+ if (next === null || typeof next !== "object") return;
516
+ current = next as Record<string, unknown>;
336
517
  }
518
+ current[parts[parts.length - 1]] = value;
337
519
  }
package/src/index.ts CHANGED
@@ -1,4 +1,6 @@
1
+ export * from "./compiled-value.js";
1
2
  export * from "./capabilities/invokable.js";
3
+ export * from "./ref.js";
2
4
  export * from "./capabilities/provider.js";
3
5
  export * from "./capabilities/runnable.js";
4
6
  export * from "./context-provider.js";
@@ -1,6 +1,26 @@
1
- import { Invokable } from "./capabilities/invokable.js";
1
+ import { Invocable } from "./capabilities/invokable.js";
2
2
  import { EmitEvent, EvaluationContext, InstanceFactory } from "./evaluation-context.js";
3
3
 
4
+ /** Wraps process.env so that missing keys return null instead of throwing in CEL.
5
+ * cel-js uses Object.hasOwn(obj, key) before accessing obj[key], so we must
6
+ * intercept getOwnPropertyDescriptor to report every string key as "own". */
7
+ function lenientEnv(env: Record<string, string | undefined>): Record<string, string | null> {
8
+ return new Proxy(env as Record<string, string | null>, {
9
+ get(target, key) {
10
+ if (typeof key !== "string") return (target as any)[key];
11
+ return key in target ? (target[key] ?? null) : null;
12
+ },
13
+ has() {
14
+ return true;
15
+ },
16
+ getOwnPropertyDescriptor(target, key) {
17
+ if (typeof key !== "string") return Object.getOwnPropertyDescriptor(target, key);
18
+ const value = key in target ? (target[key] ?? null) : null;
19
+ return { configurable: true, enumerable: true, writable: true, value };
20
+ },
21
+ });
22
+ }
23
+
4
24
  function collectSecretValues(secrets: Record<string, unknown>): Set<string> {
5
25
  const values = new Set<string>();
6
26
  for (const value of Object.values(secrets)) {
@@ -42,6 +62,7 @@ export class ModuleContext extends EvaluationContext {
42
62
  private targets: string[] = [],
43
63
  createInstance: InstanceFactory = async () => null,
44
64
  emit: EmitEvent,
65
+ private readonly _hostEnv?: Record<string, string | undefined>,
45
66
  ) {
46
67
  super(source, {}, createInstance, new Set(), emit);
47
68
  this._variables = variables;
@@ -81,6 +102,10 @@ export class ModuleContext extends EvaluationContext {
81
102
  this._rebuildContext();
82
103
  }
83
104
 
105
+ protected override onResourceSnapshotted(name: string, snap: Record<string, unknown>): void {
106
+ this.setResource(name, snap);
107
+ }
108
+
84
109
  /**
85
110
  * Register an imported module under the given alias, with the list of kind names
86
111
  * it exports. An empty kinds array means no restriction (used for built-ins like Kernel).
@@ -102,7 +127,9 @@ export class ModuleContext extends EvaluationContext {
102
127
  return entry?.instance;
103
128
  }
104
129
 
105
- getInvokable(name: string): Invokable {
130
+ getInvocable<TInput = Record<string, any>, TOutput = any>(
131
+ name: string,
132
+ ): Invocable<TInput, TOutput> {
106
133
  const instance = this.getInstance(name);
107
134
 
108
135
  if (
@@ -113,7 +140,7 @@ export class ModuleContext extends EvaluationContext {
113
140
  ) {
114
141
  throw new Error(`Resource '${name}' does not have an invoke() method.`);
115
142
  }
116
- return instance as Invokable;
143
+ return instance as Invocable<TInput, TOutput>;
117
144
  }
118
145
 
119
146
  /**
@@ -151,12 +178,13 @@ export class ModuleContext extends EvaluationContext {
151
178
  variables: this._variables,
152
179
  secrets: this._secrets,
153
180
  resources: this._resources,
181
+ ...(this._hostEnv ? { env: lenientEnv(this._hostEnv) } : {}),
154
182
  };
155
183
  this._secretValues = collectSecretValues(this._secrets);
156
184
  }
157
185
 
158
- override async invoke(kind: string, name: string, ...args: any[]): Promise<any> {
159
- const result = await super.invoke(kind, name, ...args);
186
+ override async invoke<TInputs>(kind: string, name: string, inputs: TInputs): Promise<any> {
187
+ const result = await super.invoke(kind, name, inputs);
160
188
  const entry = this.resourceInstances.get(name);
161
189
  if (entry && typeof (entry.instance as any).snapshot === "function") {
162
190
  const snap = await Promise.resolve((entry.instance as any).snapshot());