@telorun/kernel 0.57.0 → 0.59.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.
Files changed (39) hide show
  1. package/dist/controllers/module/import-controller.d.ts.map +1 -1
  2. package/dist/controllers/module/import-controller.js +7 -3
  3. package/dist/controllers/module/import-controller.js.map +1 -1
  4. package/dist/controllers/resource-definition/resource-definition-controller.d.ts +1 -0
  5. package/dist/controllers/resource-definition/resource-definition-controller.d.ts.map +1 -1
  6. package/dist/controllers/resource-definition/resource-definition-controller.js +10 -1
  7. package/dist/controllers/resource-definition/resource-definition-controller.js.map +1 -1
  8. package/dist/evaluation-context.d.ts +57 -2
  9. package/dist/evaluation-context.d.ts.map +1 -1
  10. package/dist/evaluation-context.js +229 -14
  11. package/dist/evaluation-context.js.map +1 -1
  12. package/dist/kernel.d.ts.map +1 -1
  13. package/dist/kernel.js +6 -3
  14. package/dist/kernel.js.map +1 -1
  15. package/dist/manifest-schemas.d.ts +8 -0
  16. package/dist/manifest-schemas.d.ts.map +1 -1
  17. package/dist/manifest-schemas.js +6 -0
  18. package/dist/manifest-schemas.js.map +1 -1
  19. package/dist/module-context.d.ts +8 -0
  20. package/dist/module-context.d.ts.map +1 -1
  21. package/dist/module-context.js +20 -5
  22. package/dist/module-context.js.map +1 -1
  23. package/dist/observed-state.d.ts +94 -0
  24. package/dist/observed-state.d.ts.map +1 -0
  25. package/dist/observed-state.js +148 -0
  26. package/dist/observed-state.js.map +1 -0
  27. package/dist/resource-context.d.ts +52 -1
  28. package/dist/resource-context.d.ts.map +1 -1
  29. package/dist/resource-context.js +76 -13
  30. package/dist/resource-context.js.map +1 -1
  31. package/package.json +3 -3
  32. package/src/controllers/module/import-controller.ts +12 -3
  33. package/src/controllers/resource-definition/resource-definition-controller.ts +14 -0
  34. package/src/evaluation-context.ts +270 -15
  35. package/src/kernel.ts +5 -0
  36. package/src/manifest-schemas.ts +6 -0
  37. package/src/module-context.ts +19 -5
  38. package/src/observed-state.ts +210 -0
  39. package/src/resource-context.ts +76 -12
@@ -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
+ }
@@ -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.moduleContext
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.moduleContext.openSpan(base, opts);
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.moduleContext.invoke(kind, name, inputs);
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.moduleContext.invokeResolved(kind, name, instance, inputs, ctx);
316
+ return this.owningContext.invokeResolved(kind, name, instance, inputs, ctx);
284
317
  }
285
318
 
286
319
  resolveImportedInstance(alias: string, name: string): ResourceInstance | undefined {
@@ -293,15 +326,46 @@ export class ResourceContextImpl implements ResourceContext {
293
326
  describe: () => string,
294
327
  expects?: string,
295
328
  ): T {
296
- return resolveRefInstance(value, this, guard, describe, expects);
329
+ // Two things the raw resolver cannot do from a `{ moduleContext }` slice:
330
+ //
331
+ // - A `!ref` can reach a controller as the raw SENTINEL. Phase-5 injection is
332
+ // field-map-driven, and the field map does not descend into the inline
333
+ // declarations inside an `x-telo-scope` array, so a ref slot on a scoped
334
+ // resource is not an injection site. (Phase 2.5 does rewrite such a
335
+ // sentinel to `{kind, name}` when it can name a target, so the shape that
336
+ // arrives varies — both are accepted.) `ensureKindRef` is the same rescue
337
+ // the sentinel path already performs for hidden slots.
338
+ // - A scope-local name lives in the OWNING context, not the module, so a
339
+ // `with:`-scoped resource referencing a scoped sibling has to resolve in
340
+ // the same order `contextForName` and `ScopeContext.getInstance` use —
341
+ // scope-local first, module as the fallback — or CEL and `!ref` disagree
342
+ // about what a name means inside a scope.
343
+ const normalized = isRefSentinel(value) ? this.ensureKindRef(value) : value;
344
+ return resolveRefInstance(normalized, this, guard, describe, expects);
345
+ }
346
+
347
+ /** Name lookup with scope-local precedence, for {@link resolveRefInstance}. */
348
+ resolveLocalInstance(name: string): ResourceInstance | undefined {
349
+ return this.contextForName(name).resourceInstances.get(name)?.instance;
297
350
  }
298
351
 
299
352
  async run(name: string) {
300
- await this.moduleContext.run(name);
353
+ await this.contextForName(name).run(name);
354
+ }
355
+
356
+ /** Report what this resource has observed. Configured state stays on
357
+ * `snapshot()`, which the kernel pulls; only what the resource LEARNS is
358
+ * pushed, because nothing but the controller knows when it learned it. */
359
+ async setStatus(status: Record<string, unknown>): Promise<void> {
360
+ // `metadata` is the resource's `metadata` block, not the resource — every
361
+ // other member here reads `this.metadata.name`.
362
+ const name = this.metadata?.name as string | undefined;
363
+ if (!name) return;
364
+ await this.owningContext.setResourceStatus(name, status);
301
365
  }
302
366
 
303
367
  registerManifest(resource: any): void {
304
- this.moduleContext.registerManifest(resource);
368
+ this.owningContext.registerManifest(resource);
305
369
  }
306
370
 
307
371
  loadModule(url: string, options?: LoadOptions): Promise<ResourceManifest[]> {
@@ -392,7 +456,7 @@ export class ResourceContextImpl implements ResourceContext {
392
456
  // initialized yet when this resolves, and scope-local resources never enter
393
457
  // `resourceInstances`).
394
458
  const kind =
395
- (this.moduleContext.resourceInstances.get(refName)?.resource.kind as string | undefined) ??
459
+ (this.contextForName(refName).resourceInstances.get(refName)?.resource.kind as string | undefined) ??
396
460
  this.kernel.resourceKindByName(refName) ??
397
461
  "";
398
462
  return { kind, name: refName };
@@ -451,7 +515,7 @@ export class ResourceContextImpl implements ResourceContext {
451
515
  }
452
516
 
453
517
  getResourcesByName(_kind: string, name: string): RuntimeResource | null {
454
- const entry = this.moduleContext.resourceInstances.get(name);
518
+ const entry = this.contextForName(name).resourceInstances.get(name);
455
519
  return (entry?.resource ?? null) as RuntimeResource | null;
456
520
  }
457
521
 
@@ -526,7 +590,7 @@ export class ResourceContextImpl implements ResourceContext {
526
590
  }
527
591
 
528
592
  expandValue(value: any, context: Record<string, any>) {
529
- return this.moduleContext.expandWith(value, context);
593
+ return this.owningContext.expandWith(value, context);
530
594
  }
531
595
 
532
596
  async emitEvent(event: string, payload?: any) {
@@ -549,11 +613,11 @@ export class ResourceContextImpl implements ResourceContext {
549
613
  // spawnChildContext(). Rooted on this resource's module context (the
550
614
  // consumer scope); a templated definition that needs library-scoped
551
615
  // resolution calls the defining library's context directly instead.
552
- return this.moduleContext.spawnChildContext();
616
+ return this.owningContext.spawnChildContext();
553
617
  }
554
618
 
555
619
  transientChild(context: Record<string, any>): IEvaluationContext {
556
- return this.moduleContext.transientChild(context);
620
+ return this.owningContext.transientChild(context);
557
621
  }
558
622
 
559
623
  /**