@telorun/kernel 0.31.0 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { executeInvokeStep, RuntimeError } from "@telorun/sdk";
1
+ import { executeInvokeStep, getRefIdentity, RuntimeError } from "@telorun/sdk";
2
2
  import type {
3
3
  BootTarget,
4
4
  ControllerPolicy,
@@ -432,6 +432,29 @@ export class ModuleContext extends EvaluationContext implements IModuleContext {
432
432
  return `${realModule}.${suffix}`;
433
433
  }
434
434
 
435
+ protected override resolveKindSafe(kind: string): string {
436
+ // `resolveKind` throws for unqualified / ungated kinds — an expected signal,
437
+ // not a failure: a capability probe that can't resolve falls back to the raw
438
+ // kind (lookup misses → treated as non-service → keeps its ALS scope).
439
+ try {
440
+ return this.resolveKind(kind);
441
+ } catch {
442
+ return kind;
443
+ }
444
+ }
445
+
446
+ protected override traceRootScope(): Record<string, unknown> {
447
+ // Mask secret values (keep keys so availability is visible, never the value).
448
+ const secrets: Record<string, unknown> = {};
449
+ for (const key of Object.keys(this._secrets)) secrets[key] = "[secret]";
450
+ return {
451
+ variables: this._variables,
452
+ secrets,
453
+ resources: this._resources,
454
+ ports: this._ports,
455
+ };
456
+ }
457
+
435
458
  private _rebuildContext(): void {
436
459
  this._context = {
437
460
  variables: this._variables,
@@ -473,26 +496,84 @@ export class ModuleContext extends EvaluationContext implements IModuleContext {
473
496
  await super.run(name, ctx);
474
497
  }
475
498
 
476
- async runTargets(ctx?: InvokeContext) {
499
+ async runTargets(ctx?: InvokeContext, appName?: string) {
500
+ // Open a trace span for the application's boot run so the app itself is a
501
+ // trace participant: every target dispatches under `targetCtx`, parenting to
502
+ // this span instead of becoming a detached root. The span is gated on tracing
503
+ // (zero cost otherwise) and rides the boot cancellation token unchanged.
504
+ const tracing = this.tracer?.enabled === true;
505
+ const appSpanId = tracing ? this.tracer!.next() : undefined;
506
+ // The boot run roots a fresh trace; targets inherit it via `targetCtx`.
507
+ const appTraceId = tracing ? this.tracer!.newTraceId() : undefined;
508
+ const appRootScope = tracing ? this.traceRootScope() : undefined;
509
+ const appLabel = appName ?? "application";
510
+ const appSpan = (
511
+ phase: "start" | "end",
512
+ outcome: "ok" | "failed" | "cancelled" | undefined,
513
+ ) =>
514
+ this.tracePayload(
515
+ "Telo.Application",
516
+ appLabel,
517
+ appSpanId,
518
+ undefined,
519
+ appTraceId,
520
+ "run",
521
+ phase,
522
+ outcome,
523
+ phase === "end" && appRootScope ? { context: appRootScope } : {},
524
+ );
525
+ const targetCtx: InvokeContext | undefined =
526
+ tracing && ctx
527
+ ? {
528
+ cancellation: ctx.cancellation,
529
+ invocationId: appSpanId,
530
+ parentInvocationId: undefined,
531
+ traceId: appTraceId,
532
+ }
533
+ : ctx;
534
+
477
535
  const steps: Record<string, unknown> = {};
478
536
  const stepCtx: InvokeStepContext = {
479
537
  expandValue: (value, context) => this.expandWith(value, context),
480
- invoke: (kind, name, inputs) => this.invoke(kind, name, inputs, ctx),
538
+ invoke: (kind, name, inputs) => this.invoke(kind, name, inputs, targetCtx),
481
539
  invokeResolved: (kind, name, instance, inputs) =>
482
- this.invokeResolved(kind, name, instance, inputs, ctx),
540
+ this.invokeResolved(kind, name, instance, inputs, targetCtx),
483
541
  resolveImportedInstance: (alias, name) => this.resolveImportedInstance(alias, name),
484
542
  };
485
- // Mirror the local-run gate: refuse a target reached after the boot run was
486
- // cancelled, then run the pre-resolved instance directly.
487
- const runResolvedInstance = async (inst: ResourceInstance, label: string) => {
488
- const token = ctx?.cancellation;
489
- if (token?.isCancelled) {
490
- throw new RuntimeError(
491
- "ERR_INVOKE_CANCELLED",
492
- `Run ${label} was cancelled${token.reason ? `: ${token.reason}` : ""}`,
543
+ // Route a pre-resolved boot target through the instrumented chokepoint
544
+ // (`runResolved`) instead of calling `instance.run()` directly, so it emits a
545
+ // run span nested under the app. Kind/name come from the `!ref` identity the
546
+ // kernel stamped at injection, falling back to a positional label.
547
+ const runResolvedInstance = async (inst: ResourceInstance, kind: string, name: string) =>
548
+ this.runResolved(kind, name, inst, targetCtx);
549
+
550
+ if (tracing) await this.emit(`${appLabel}.Running`, appSpan("start", undefined));
551
+ try {
552
+ await this.dispatchTargets(stepCtx, steps, runResolvedInstance, targetCtx);
553
+ if (tracing) await this.emit(`${appLabel}.Run`, appSpan("end", "ok"));
554
+ } catch (err) {
555
+ if (tracing) {
556
+ const cancelled = (err as { code?: unknown })?.code === "ERR_INVOKE_CANCELLED";
557
+ await this.emit(
558
+ `${appLabel}.${cancelled ? "RunCancelled" : "RunFailed"}`,
559
+ appSpan("end", cancelled ? "cancelled" : "failed"),
493
560
  );
494
561
  }
495
- await inst.run!(ctx);
562
+ throw err;
563
+ }
564
+ }
565
+
566
+ private async dispatchTargets(
567
+ stepCtx: InvokeStepContext,
568
+ steps: Record<string, unknown>,
569
+ runResolvedInstance: (inst: ResourceInstance, kind: string, name: string) => Promise<void>,
570
+ ctx?: InvokeContext,
571
+ ) {
572
+ // Recover the kind+name the kernel stamped onto a `!ref`-injected instance,
573
+ // so the run span is properly labelled; fall back to a positional name.
574
+ const idOf = (inst: ResourceInstance, fallbackName: string) => {
575
+ const id = getRefIdentity(inst as object);
576
+ return { kind: id?.kind ?? "", name: id?.name ?? fallbackName };
496
577
  };
497
578
  for (let i = 0; i < this.targets.length; i++) {
498
579
  const target = this.targets[i]!;
@@ -524,7 +605,8 @@ export class ModuleContext extends EvaluationContext implements IModuleContext {
524
605
  // Phase 5 injection may have replaced the ref slot with the live
525
606
  // instance; run it directly.
526
607
  if (isRunnableInstance(ref)) {
527
- await runResolvedInstance(ref, `target[${i}]`);
608
+ const id = idOf(ref, `target[${i}]`);
609
+ await runResolvedInstance(ref, id.kind, id.name);
528
610
  } else {
529
611
  const r =
530
612
  ref && typeof ref === "object"
@@ -537,7 +619,7 @@ export class ModuleContext extends EvaluationContext implements IModuleContext {
537
619
  `Boot target '${r.alias}.${r.name}' is not a runnable exported instance`,
538
620
  );
539
621
  }
540
- await runResolvedInstance(inst, `${r.alias}.${r.name}`);
622
+ await runResolvedInstance(inst, getRefIdentity(inst as object)?.kind ?? "", r.name);
541
623
  } else {
542
624
  await this.run(typeof ref === "string" ? ref : r!.name, ctx);
543
625
  }
@@ -550,7 +632,8 @@ export class ModuleContext extends EvaluationContext implements IModuleContext {
550
632
  // so the common runtime shape here is a pre-resolved ResourceInstance;
551
633
  // fall back to the structural ref forms when injection left them in place.
552
634
  if (isRunnableInstance(target)) {
553
- await runResolvedInstance(target, `target[${i}]`);
635
+ const id = idOf(target, `target[${i}]`);
636
+ await runResolvedInstance(target, id.kind, id.name);
554
637
  continue;
555
638
  }
556
639
  const bare = target as { name?: unknown; alias?: unknown };
@@ -561,7 +644,7 @@ export class ModuleContext extends EvaluationContext implements IModuleContext {
561
644
  `Boot target '${bare.alias}.${bare.name}' is not a runnable exported instance`,
562
645
  );
563
646
  }
564
- await runResolvedInstance(inst, `${bare.alias}.${bare.name}`);
647
+ await runResolvedInstance(inst, getRefIdentity(inst as object)?.kind ?? "", bare.name);
565
648
  continue;
566
649
  }
567
650
  if (typeof bare.name === "string") {
@@ -12,6 +12,8 @@ import {
12
12
  type InvokeContext,
13
13
  type LoadOptions,
14
14
  type ModuleContext,
15
+ type OpenSpan,
16
+ type OpenSpanOptions,
15
17
  type ParsedArgs,
16
18
  type TypeRule,
17
19
  } from "@telorun/sdk";
@@ -27,6 +29,10 @@ import { SchemaValidator } from "./schema-validator.js";
27
29
 
28
30
  const Ajv = AjvModule.default ?? AjvModule;
29
31
 
32
+ /** How long a resource's teardown waits for its in-flight detached tasks before
33
+ * abandoning them (with a logged event). */
34
+ const DETACHED_DRAIN_TIMEOUT_MS = 5000;
35
+
30
36
  export class ResourceContextImpl implements ResourceContext {
31
37
  readonly env: Record<string, string | undefined>;
32
38
  readonly stdin: NodeJS.ReadableStream;
@@ -155,6 +161,57 @@ export class ResourceContextImpl implements ResourceContext {
155
161
  return createCancellationSource();
156
162
  }
157
163
 
164
+ /** In-flight fire-and-forget tasks this resource spawned. Owned here, not by
165
+ * the kernel: the resource drains them in its own teardown (see
166
+ * `drainDetached`), so background work is bounded by the resource's lifetime. */
167
+ private readonly pendingDetached = new Set<Promise<unknown>>();
168
+
169
+ runDetached(fn: () => Promise<unknown>): void {
170
+ // Fire-and-forget: a detached task has no caller to throw to, so route a
171
+ // failure to the EventBus rather than letting it go unhandled. We track the
172
+ // error-handled chain (not the raw promise) so teardown drains a task whose
173
+ // settlement is already observed here.
174
+ const tracked = this.moduleContext
175
+ .runDetached(fn) // bare scope-detach primitive
176
+ .catch(async (err: unknown) => {
177
+ const detail =
178
+ err instanceof Error ? { name: err.name, message: err.message } : { message: String(err) };
179
+ await this.emitEvent("background.task.error", { resource: this.metadata.name, error: detail });
180
+ })
181
+ .finally(() => {
182
+ this.pendingDetached.delete(tracked);
183
+ });
184
+ this.pendingDetached.add(tracked);
185
+ }
186
+
187
+ /**
188
+ * Await this resource's in-flight detached tasks, bounded by
189
+ * `DETACHED_DRAIN_TIMEOUT_MS`. Folded into the resource's teardown by the
190
+ * kernel, so tearing the resource down drains its background work (and its
191
+ * dependencies, torn down later in reverse order, are still alive meanwhile).
192
+ * Past the bound, remaining tasks are abandoned with a logged event rather
193
+ * than blocking shutdown.
194
+ */
195
+ async drainDetached(): Promise<void> {
196
+ if (this.pendingDetached.size === 0) return;
197
+ let timer: ReturnType<typeof setTimeout> | undefined;
198
+ const timeout = new Promise<void>((resolve) => {
199
+ timer = setTimeout(resolve, DETACHED_DRAIN_TIMEOUT_MS);
200
+ });
201
+ await Promise.race([Promise.allSettled([...this.pendingDetached]), timeout]);
202
+ if (timer) clearTimeout(timer);
203
+ if (this.pendingDetached.size > 0) {
204
+ await this.emitEvent("background.task.abandoned", {
205
+ resource: this.metadata.name,
206
+ count: this.pendingDetached.size,
207
+ });
208
+ }
209
+ }
210
+
211
+ openSpan(base: InvokeContext | undefined, opts: OpenSpanOptions): Promise<OpenSpan> {
212
+ return this.moduleContext.openSpan(base, opts);
213
+ }
214
+
158
215
  invoke<TInputs>(kind: string, name: string, inputs: TInputs): Promise<any> {
159
216
  return this.moduleContext.invoke(kind, name, inputs);
160
217
  }
@@ -223,7 +280,7 @@ export class ResourceContextImpl implements ResourceContext {
223
280
  // analyzer's field-map walker descends `oneOf`/`anyOf` variant
224
281
  // properties but intentionally early-returns on `$ref` (see
225
282
  // `analyzer/nodejs/src/reference-field-map.ts`). Enabling the `$ref`
226
- // descent regresses the kernel's `<Kind>.<Name>.Invoked` event
283
+ // descent regresses the kernel's `<name>.Invoked` event
227
284
  // emission for kinds (notably `Run.Sequence`) whose controllers
228
285
  // call `instance.invoke()` directly on Phase-5-injected instances;
229
286
  // the walker fix needs to land together with routing those callers
package/src/tracing.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { randomBytes } from "node:crypto";
1
2
  import type { Tracer } from "@telorun/sdk";
2
3
 
3
4
  /**
@@ -17,4 +18,10 @@ export class KernelTracer implements Tracer {
17
18
  this.#next += 1;
18
19
  return this.#next;
19
20
  }
21
+
22
+ /** A fresh OTel-compatible 16-byte hex trace id. Globally unique, so a trace
23
+ * stays identifiable once it crosses process boundaries. */
24
+ newTraceId(): string {
25
+ return randomBytes(16).toString("hex");
26
+ }
20
27
  }