@telorun/kernel 0.57.0 → 0.58.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/dist/controllers/module/import-controller.d.ts.map +1 -1
- package/dist/controllers/module/import-controller.js +7 -3
- package/dist/controllers/module/import-controller.js.map +1 -1
- package/dist/controllers/resource-definition/resource-definition-controller.d.ts +1 -0
- package/dist/controllers/resource-definition/resource-definition-controller.d.ts.map +1 -1
- package/dist/controllers/resource-definition/resource-definition-controller.js +10 -1
- package/dist/controllers/resource-definition/resource-definition-controller.js.map +1 -1
- package/dist/evaluation-context.d.ts +57 -2
- package/dist/evaluation-context.d.ts.map +1 -1
- package/dist/evaluation-context.js +209 -8
- package/dist/evaluation-context.js.map +1 -1
- package/dist/kernel.d.ts.map +1 -1
- package/dist/kernel.js +6 -3
- package/dist/kernel.js.map +1 -1
- package/dist/manifest-schemas.d.ts +8 -0
- package/dist/manifest-schemas.d.ts.map +1 -1
- package/dist/manifest-schemas.js +6 -0
- package/dist/manifest-schemas.js.map +1 -1
- package/dist/module-context.d.ts +8 -0
- package/dist/module-context.d.ts.map +1 -1
- package/dist/module-context.js +20 -5
- package/dist/module-context.js.map +1 -1
- package/dist/observed-state.d.ts +94 -0
- package/dist/observed-state.d.ts.map +1 -0
- package/dist/observed-state.js +148 -0
- package/dist/observed-state.js.map +1 -0
- package/dist/resource-context.d.ts +50 -1
- package/dist/resource-context.d.ts.map +1 -1
- package/dist/resource-context.js +56 -12
- package/dist/resource-context.js.map +1 -1
- package/package.json +3 -3
- package/src/controllers/module/import-controller.ts +12 -3
- package/src/controllers/resource-definition/resource-definition-controller.ts +14 -0
- package/src/evaluation-context.ts +253 -8
- package/src/kernel.ts +5 -0
- package/src/manifest-schemas.ts +6 -0
- package/src/module-context.ts +19 -5
- package/src/observed-state.ts +210 -0
- package/src/resource-context.ts +55 -11
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import AjvModule from "ajv";
|
|
2
|
+
import { detachSnapshotValue, OBSERVED_STATE_KEY, RuntimeError } from "@telorun/sdk";
|
|
3
|
+
|
|
4
|
+
const Ajv = AjvModule.default ?? AjvModule;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Publication of a resource's **observed state** — what it learns while running,
|
|
8
|
+
* as opposed to what its author configured.
|
|
9
|
+
*
|
|
10
|
+
* The two arrive by different routes and this module keeps them apart.
|
|
11
|
+
* Configured state is pulled from `snapshot()`; observed state is pushed through
|
|
12
|
+
* `ResourceContext.setStatus()`, validated against the kind's `status:`, and
|
|
13
|
+
* held by the kernel until the resource is torn down. Publication then joins
|
|
14
|
+
* them: the flat half at `resources.<name>.<field>`, the reported half at
|
|
15
|
+
* `resources.<name>.status.<field>`.
|
|
16
|
+
*
|
|
17
|
+
* Holding the reported value (rather than re-deriving it) is what makes the
|
|
18
|
+
* reading *sticky*: a resource that learned its address once does not stop
|
|
19
|
+
* knowing it because a later dispatch had nothing new to say.
|
|
20
|
+
*
|
|
21
|
+
* A non-enumerable {@link OBSERVED_STATE_INFO} marker rides along on the
|
|
22
|
+
* published objects so a failed CEL read can say *why* the value is missing —
|
|
23
|
+
* "has not started", "still running" and "finished and never reported it" need
|
|
24
|
+
* different actions from the reader. It is a Symbol, so no CEL member access
|
|
25
|
+
* can reach it.
|
|
26
|
+
*/
|
|
27
|
+
export const OBSERVED_STATE_INFO = Symbol("telo.observedState");
|
|
28
|
+
|
|
29
|
+
export interface ObservedStateInfo {
|
|
30
|
+
/** The kind as written on the resource doc (`OAuthClient.RedirectListener`). */
|
|
31
|
+
kind: string;
|
|
32
|
+
name: string;
|
|
33
|
+
/** Owning module, named in the "defect in someone else's module" message. */
|
|
34
|
+
module?: string;
|
|
35
|
+
/** The fields the kind declares it reports. */
|
|
36
|
+
fields: string[];
|
|
37
|
+
/** True once the resource's `run()` has been dispatched. */
|
|
38
|
+
started: boolean;
|
|
39
|
+
/** True once its `run()` has RETURNED. Never true for a long-lived Service,
|
|
40
|
+
* whose `run()` stays pending — which is what separates "still coming up"
|
|
41
|
+
* from "finished and reported nothing". */
|
|
42
|
+
completed: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Read the marker off a published props / status object, if it carries one. */
|
|
46
|
+
export function observedStateInfo(value: unknown): ObservedStateInfo | undefined {
|
|
47
|
+
if (value === null || typeof value !== "object") return undefined;
|
|
48
|
+
return (value as Record<symbol, ObservedStateInfo>)[OBSERVED_STATE_INFO];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function mark(target: Record<string, unknown>, info: ObservedStateInfo): void {
|
|
52
|
+
Object.defineProperty(target, OBSERVED_STATE_INFO, {
|
|
53
|
+
value: info,
|
|
54
|
+
enumerable: false,
|
|
55
|
+
configurable: true,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const ajv = new Ajv({ allErrors: true, strict: false });
|
|
60
|
+
// Compiling a status schema costs ~ms and a resource may report repeatedly, so
|
|
61
|
+
// keep the validator keyed on the schema object it came from. The kind's folded
|
|
62
|
+
// `status:` is stamped once at registration, so this hits.
|
|
63
|
+
const validators = new WeakMap<object, ReturnType<typeof ajv.compile>>();
|
|
64
|
+
|
|
65
|
+
function validatorFor(schema: Record<string, any>): ReturnType<typeof ajv.compile> {
|
|
66
|
+
let compiled = validators.get(schema);
|
|
67
|
+
if (!compiled) {
|
|
68
|
+
compiled = ajv.compile(schema);
|
|
69
|
+
validators.set(schema, compiled);
|
|
70
|
+
}
|
|
71
|
+
return compiled;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The field names a `status:` schema declares, in declaration order. */
|
|
75
|
+
export function declaredStatusFields(schema: Record<string, any> | undefined): string[] {
|
|
76
|
+
const props = schema?.properties;
|
|
77
|
+
return props && typeof props === "object" ? Object.keys(props) : [];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Check a reported value against the kind's declared `status:` and detach it
|
|
82
|
+
* from the controller. Called by `setStatus`, so an invalid report is refused at
|
|
83
|
+
* the point it is made rather than at some later publication.
|
|
84
|
+
*/
|
|
85
|
+
export function acceptReportedStatus(
|
|
86
|
+
status: Record<string, unknown>,
|
|
87
|
+
opts: { kind: string; name: string; statusSchema?: Record<string, any> },
|
|
88
|
+
): Record<string, unknown> {
|
|
89
|
+
if (!opts.statusSchema) {
|
|
90
|
+
throw new RuntimeError(
|
|
91
|
+
"ERR_OBSERVED_STATE_UNDECLARED",
|
|
92
|
+
`${opts.kind} '${opts.name}' reported observed state, but the kind declares no 'status:' block. Declare it on the Telo.Definition (or on an abstract it extends) naming the fields this kind reports.`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
const validate = validatorFor(opts.statusSchema);
|
|
96
|
+
if (!validate(status)) {
|
|
97
|
+
const detail = (validate.errors ?? [])
|
|
98
|
+
.map((e) => `${e.instancePath || "/"} ${e.message ?? "is invalid"}`)
|
|
99
|
+
.join("; ");
|
|
100
|
+
throw new RuntimeError(
|
|
101
|
+
"ERR_OBSERVED_STATE_INVALID",
|
|
102
|
+
`${opts.kind} '${opts.name}' reported observed state that does not match its declared 'status:': ${detail}`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
return detachSnapshotValue(status) as Record<string, unknown>;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface PublishOptions {
|
|
109
|
+
kind: string;
|
|
110
|
+
name: string;
|
|
111
|
+
module?: string;
|
|
112
|
+
/** The kind's effective `status:` (folded through `extends`), or undefined
|
|
113
|
+
* when nothing in the chain declares one. */
|
|
114
|
+
statusSchema?: Record<string, any>;
|
|
115
|
+
/** The last value the resource reported, or undefined if it has reported
|
|
116
|
+
* none. Already validated and detached by {@link acceptReportedStatus}. */
|
|
117
|
+
status?: Record<string, unknown>;
|
|
118
|
+
/** Whether the resource's `run()` has been dispatched. */
|
|
119
|
+
started: boolean;
|
|
120
|
+
/** Whether its `run()` has returned. */
|
|
121
|
+
completed: boolean;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Turn a `snapshot()` result plus the last reported status into the value
|
|
126
|
+
* published at `resources.<name>`.
|
|
127
|
+
*
|
|
128
|
+
* Throws `ERR_OBSERVED_STATE_KEY_COLLISION` when a kind that declares `status:`
|
|
129
|
+
* also returns a flat field of that name — the two would land on the same key,
|
|
130
|
+
* and silently letting one win is worse than refusing. A kind that declares no
|
|
131
|
+
* `status:` may use the name freely.
|
|
132
|
+
*/
|
|
133
|
+
export function buildPublishedProps(
|
|
134
|
+
snapshot: Record<string, unknown> | undefined,
|
|
135
|
+
opts: PublishOptions,
|
|
136
|
+
): Record<string, unknown> {
|
|
137
|
+
const flat = (detachSnapshotValue(snapshot ?? {}) ?? {}) as Record<string, unknown>;
|
|
138
|
+
|
|
139
|
+
if (!opts.statusSchema) return flat;
|
|
140
|
+
|
|
141
|
+
if (OBSERVED_STATE_KEY in flat) {
|
|
142
|
+
throw new RuntimeError(
|
|
143
|
+
"ERR_OBSERVED_STATE_KEY_COLLISION",
|
|
144
|
+
`${opts.kind} '${opts.name}' returns a '${OBSERVED_STATE_KEY}' field from snapshot(), but the kind declares a 'status:' block, which publishes at that same key. Rename the snapshot field, or drop the 'status:' declaration if the value is configuration rather than something the resource observes.`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const info: ObservedStateInfo = {
|
|
149
|
+
kind: opts.kind,
|
|
150
|
+
name: opts.name,
|
|
151
|
+
module: opts.module,
|
|
152
|
+
fields: declaredStatusFields(opts.statusSchema),
|
|
153
|
+
started: opts.started,
|
|
154
|
+
completed: opts.completed,
|
|
155
|
+
};
|
|
156
|
+
mark(flat, info);
|
|
157
|
+
|
|
158
|
+
// Absent until the resource reports — so a read before then lands on a
|
|
159
|
+
// message that says which of the three things went wrong, rather than on a
|
|
160
|
+
// placeholder indistinguishable from a real reading.
|
|
161
|
+
if (!opts.status) return flat;
|
|
162
|
+
|
|
163
|
+
const published: Record<string, unknown> = { ...opts.status };
|
|
164
|
+
mark(published, info);
|
|
165
|
+
flat[OBSERVED_STATE_KEY] = published;
|
|
166
|
+
return flat;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The message for a failed `resources.<name>.status…` read, or null when the
|
|
171
|
+
* failure has nothing to do with observed state.
|
|
172
|
+
*
|
|
173
|
+
* `container` is the value the access chain reached, `missingKey` the key that
|
|
174
|
+
* was not there. Each cause gets its own message because they need different
|
|
175
|
+
* actions from the reader, and the kernel — which records both whether the
|
|
176
|
+
* resource started and whether its `run()` returned — is the only party that
|
|
177
|
+
* can tell them apart:
|
|
178
|
+
*
|
|
179
|
+
* - not started → order the `targets:` so it runs first;
|
|
180
|
+
* - started, still running → the read raced a resource that is still coming up
|
|
181
|
+
* (a Service binding asynchronously); reorder or read later;
|
|
182
|
+
* - `run()` returned and it never reported → the producing module declares
|
|
183
|
+
* something it does not report, and no edit here fixes that.
|
|
184
|
+
*/
|
|
185
|
+
export function diagnoseObservedStateAccess(
|
|
186
|
+
container: unknown,
|
|
187
|
+
missingKey: string,
|
|
188
|
+
): string | null {
|
|
189
|
+
const info = observedStateInfo(container);
|
|
190
|
+
if (!info) return null;
|
|
191
|
+
|
|
192
|
+
if (missingKey === OBSERVED_STATE_KEY) {
|
|
193
|
+
if (!info.started) {
|
|
194
|
+
return `'${info.name}' reports ${formatFields(info.fields)} only while it is running, and it has not started yet. Read reported values where the call happens — a step's inputs:, a request's url, a route handler, or a returns: expression — and make sure '${info.name}' is listed in the targets: that runs before this value is read.`;
|
|
195
|
+
}
|
|
196
|
+
return info.completed
|
|
197
|
+
? `'${info.name}' finished running without ever reporting its observed state, which ${info.kind} declares it reports (${formatFields(info.fields)}). This is a defect in the ${info.module ?? "producing"} module — it never called setStatus, and no change to this manifest will fix it.`
|
|
198
|
+
: `'${info.name}' has started but has not reported its observed state yet — it is still running, so this read raced it. Move the read after the point where '${info.name}' has reported.`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (info.fields.includes(missingKey)) {
|
|
202
|
+
return `'${info.name}' reported observed state without '${missingKey}', which ${info.kind} declares it reports. Every declared field is mandatory once the resource has run — this is a defect in the ${info.module ?? "producing"} module, and no change to this manifest will fix it.`;
|
|
203
|
+
}
|
|
204
|
+
return `'${info.name}' reports no '${missingKey}'. It reports ${formatFields(info.fields)}.`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function formatFields(fields: string[]): string {
|
|
208
|
+
if (fields.length === 0) return "no observed state";
|
|
209
|
+
return fields.map((f) => `'${f}'`).join(", ");
|
|
210
|
+
}
|
package/src/resource-context.ts
CHANGED
|
@@ -104,6 +104,23 @@ export class ResourceContextImpl implements ResourceContext {
|
|
|
104
104
|
stderr?: NodeJS.WritableStream,
|
|
105
105
|
args?: ParsedArgs,
|
|
106
106
|
ownerPrefix = "",
|
|
107
|
+
/**
|
|
108
|
+
* The context that OWNS this instance — the module context for a top-level
|
|
109
|
+
* resource, the per-run scope child for a `with:`-scoped one.
|
|
110
|
+
*
|
|
111
|
+
* Everything resource-scoped goes through here: registering a manifest,
|
|
112
|
+
* resolving a sibling by name, expanding CEL, spawning a child context,
|
|
113
|
+
* dispatching, publishing. `moduleContext` is reserved for what genuinely
|
|
114
|
+
* belongs to the MODULE rather than to this resource's context — imports
|
|
115
|
+
* (`registerImport` / `resolveImported*`), the controller policy, and the
|
|
116
|
+
* logging scope, all of which a scope child inherits rather than owns.
|
|
117
|
+
*
|
|
118
|
+
* Getting this wrong is not cosmetic: a scoped resource that registers an
|
|
119
|
+
* inline definition into the module lands it in a pending queue the module's
|
|
120
|
+
* init loop has already drained, so the resource is never created and the
|
|
121
|
+
* dispatch fails.
|
|
122
|
+
*/
|
|
123
|
+
private readonly owningContext: IEvaluationContext = moduleContext,
|
|
107
124
|
) {
|
|
108
125
|
// `ctx.env` is the sanctioned host-env channel for controllers — always the
|
|
109
126
|
// real environment (kernel passes its snapshot), never the locked Proxy.
|
|
@@ -115,6 +132,22 @@ export class ResourceContextImpl implements ResourceContext {
|
|
|
115
132
|
this.ownerPrefix = ownerPrefix;
|
|
116
133
|
}
|
|
117
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Where a NAME resolves from: the owning context first, then the enclosing
|
|
137
|
+
* module. Same order as `ScopeContext.getInstance` and the CEL `resources`
|
|
138
|
+
* layering — scope-local wins, outer is the fallback — so a `with:`-scoped
|
|
139
|
+
* resource can still dispatch a module-level one by name (an `Http.Server`
|
|
140
|
+
* whose `notFoundHandler` targets a module-level invocable).
|
|
141
|
+
*
|
|
142
|
+
* Registration deliberately does NOT use this: a new manifest belongs to the
|
|
143
|
+
* context that owns the resource creating it, never to the module.
|
|
144
|
+
*/
|
|
145
|
+
private contextForName(name: string): IEvaluationContext {
|
|
146
|
+
return this.owningContext.resourceInstances.has(name)
|
|
147
|
+
? this.owningContext
|
|
148
|
+
: this.moduleContext;
|
|
149
|
+
}
|
|
150
|
+
|
|
118
151
|
createSchemaValidator(schema: any) {
|
|
119
152
|
if (!schema) {
|
|
120
153
|
return new NoopValidator();
|
|
@@ -228,7 +261,7 @@ export class ResourceContextImpl implements ResourceContext {
|
|
|
228
261
|
// failure to the EventBus rather than letting it go unhandled. We track the
|
|
229
262
|
// error-handled chain (not the raw promise) so teardown drains a task whose
|
|
230
263
|
// settlement is already observed here.
|
|
231
|
-
const tracked = this.
|
|
264
|
+
const tracked = this.owningContext
|
|
232
265
|
.runDetached(fn) // bare scope-detach primitive
|
|
233
266
|
.catch(async (err: unknown) => {
|
|
234
267
|
const detail =
|
|
@@ -266,11 +299,11 @@ export class ResourceContextImpl implements ResourceContext {
|
|
|
266
299
|
}
|
|
267
300
|
|
|
268
301
|
openSpan(base: InvokeContext | undefined, opts: OpenSpanOptions): Promise<OpenSpan> {
|
|
269
|
-
return this.
|
|
302
|
+
return this.owningContext.openSpan(base, opts);
|
|
270
303
|
}
|
|
271
304
|
|
|
272
305
|
invoke<TInputs>(kind: string, name: string, inputs: TInputs): Promise<any> {
|
|
273
|
-
return this.
|
|
306
|
+
return this.contextForName(name).invoke(kind, name, inputs);
|
|
274
307
|
}
|
|
275
308
|
|
|
276
309
|
invokeResolved<TInputs>(
|
|
@@ -280,7 +313,7 @@ export class ResourceContextImpl implements ResourceContext {
|
|
|
280
313
|
inputs: TInputs,
|
|
281
314
|
ctx?: InvokeContext,
|
|
282
315
|
): Promise<any> {
|
|
283
|
-
return this.
|
|
316
|
+
return this.owningContext.invokeResolved(kind, name, instance, inputs, ctx);
|
|
284
317
|
}
|
|
285
318
|
|
|
286
319
|
resolveImportedInstance(alias: string, name: string): ResourceInstance | undefined {
|
|
@@ -297,11 +330,22 @@ export class ResourceContextImpl implements ResourceContext {
|
|
|
297
330
|
}
|
|
298
331
|
|
|
299
332
|
async run(name: string) {
|
|
300
|
-
await this.
|
|
333
|
+
await this.contextForName(name).run(name);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Report what this resource has observed. Configured state stays on
|
|
337
|
+
* `snapshot()`, which the kernel pulls; only what the resource LEARNS is
|
|
338
|
+
* pushed, because nothing but the controller knows when it learned it. */
|
|
339
|
+
async setStatus(status: Record<string, unknown>): Promise<void> {
|
|
340
|
+
// `metadata` is the resource's `metadata` block, not the resource — every
|
|
341
|
+
// other member here reads `this.metadata.name`.
|
|
342
|
+
const name = this.metadata?.name as string | undefined;
|
|
343
|
+
if (!name) return;
|
|
344
|
+
await this.owningContext.setResourceStatus(name, status);
|
|
301
345
|
}
|
|
302
346
|
|
|
303
347
|
registerManifest(resource: any): void {
|
|
304
|
-
this.
|
|
348
|
+
this.owningContext.registerManifest(resource);
|
|
305
349
|
}
|
|
306
350
|
|
|
307
351
|
loadModule(url: string, options?: LoadOptions): Promise<ResourceManifest[]> {
|
|
@@ -392,7 +436,7 @@ export class ResourceContextImpl implements ResourceContext {
|
|
|
392
436
|
// initialized yet when this resolves, and scope-local resources never enter
|
|
393
437
|
// `resourceInstances`).
|
|
394
438
|
const kind =
|
|
395
|
-
(this.
|
|
439
|
+
(this.contextForName(refName).resourceInstances.get(refName)?.resource.kind as string | undefined) ??
|
|
396
440
|
this.kernel.resourceKindByName(refName) ??
|
|
397
441
|
"";
|
|
398
442
|
return { kind, name: refName };
|
|
@@ -451,7 +495,7 @@ export class ResourceContextImpl implements ResourceContext {
|
|
|
451
495
|
}
|
|
452
496
|
|
|
453
497
|
getResourcesByName(_kind: string, name: string): RuntimeResource | null {
|
|
454
|
-
const entry = this.
|
|
498
|
+
const entry = this.contextForName(name).resourceInstances.get(name);
|
|
455
499
|
return (entry?.resource ?? null) as RuntimeResource | null;
|
|
456
500
|
}
|
|
457
501
|
|
|
@@ -526,7 +570,7 @@ export class ResourceContextImpl implements ResourceContext {
|
|
|
526
570
|
}
|
|
527
571
|
|
|
528
572
|
expandValue(value: any, context: Record<string, any>) {
|
|
529
|
-
return this.
|
|
573
|
+
return this.owningContext.expandWith(value, context);
|
|
530
574
|
}
|
|
531
575
|
|
|
532
576
|
async emitEvent(event: string, payload?: any) {
|
|
@@ -549,11 +593,11 @@ export class ResourceContextImpl implements ResourceContext {
|
|
|
549
593
|
// spawnChildContext(). Rooted on this resource's module context (the
|
|
550
594
|
// consumer scope); a templated definition that needs library-scoped
|
|
551
595
|
// resolution calls the defining library's context directly instead.
|
|
552
|
-
return this.
|
|
596
|
+
return this.owningContext.spawnChildContext();
|
|
553
597
|
}
|
|
554
598
|
|
|
555
599
|
transientChild(context: Record<string, any>): IEvaluationContext {
|
|
556
|
-
return this.
|
|
600
|
+
return this.owningContext.transientChild(context);
|
|
557
601
|
}
|
|
558
602
|
|
|
559
603
|
/**
|