@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.
- package/dist/capabilities/invokable.d.ts +2 -2
- package/dist/capabilities/invokable.d.ts.map +1 -1
- package/dist/capability-definition.d.ts +8 -0
- package/dist/capability-definition.d.ts.map +1 -0
- package/dist/capability-definition.js +21 -0
- package/dist/cel-environment.d.ts +3 -0
- package/dist/cel-environment.d.ts.map +1 -0
- package/dist/cel-environment.js +13 -0
- package/dist/compiled-value.d.ts +9 -0
- package/dist/compiled-value.d.ts.map +1 -0
- package/dist/compiled-value.js +3 -0
- package/dist/evaluation-context.d.ts +85 -26
- package/dist/evaluation-context.d.ts.map +1 -1
- package/dist/evaluation-context.js +227 -89
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/module-context.d.ts +6 -4
- package/dist/module-context.d.ts.map +1 -1
- package/dist/module-context.js +31 -4
- package/dist/ref.d.ts +47 -0
- package/dist/ref.d.ts.map +1 -0
- package/dist/ref.js +11 -0
- package/dist/resource-context.d.ts +19 -8
- package/dist/resource-context.d.ts.map +1 -1
- package/dist/resource-instance.d.ts +4 -9
- package/dist/resource-instance.d.ts.map +1 -1
- package/dist/runtime-error.d.ts +8 -1
- package/dist/runtime-error.d.ts.map +1 -1
- package/dist/types.d.ts +24 -10
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +3 -1
- package/package.json +2 -5
- package/src/capabilities/invokable.ts +2 -2
- package/src/compiled-value.ts +11 -0
- package/src/evaluation-context.ts +283 -101
- package/src/index.ts +2 -0
- package/src/module-context.ts +33 -5
- package/src/ref.ts +61 -0
- package/src/resource-context.ts +19 -8
- package/src/resource-instance.ts +11 -14
- package/src/runtime-error.ts +15 -1
- package/src/types.ts +24 -14
|
@@ -1,44 +1,55 @@
|
|
|
1
|
-
import {
|
|
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<
|
|
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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
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.
|
|
140
|
-
|
|
207
|
+
if (this.createdInstances.has(name)) continue;
|
|
141
208
|
try {
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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 ?
|
|
225
|
+
errors.set(name, error instanceof Error ? error.message : String(error));
|
|
151
226
|
}
|
|
152
227
|
}
|
|
153
228
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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 (
|
|
252
|
+
if (!progress) break;
|
|
162
253
|
} while (pass <= MAX_PASSES);
|
|
163
254
|
|
|
164
|
-
if (this.pendingResources.length > 0) {
|
|
165
|
-
const
|
|
166
|
-
.
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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,
|
|
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(
|
|
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
|
-
*
|
|
261
|
-
*
|
|
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 (
|
|
282
|
-
return this.
|
|
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
|
-
*
|
|
299
|
-
*
|
|
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
|
-
|
|
302
|
-
const
|
|
303
|
-
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
package/src/module-context.ts
CHANGED
|
@@ -1,6 +1,26 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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
|
|
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,
|
|
159
|
-
const result = await super.invoke(kind, name,
|
|
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());
|