@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.
- package/dist/dependency-injection.d.ts +1 -1
- package/dist/dependency-injection.d.ts.map +1 -1
- package/dist/dependency-injection.js +18 -4
- package/dist/dependency-injection.js.map +1 -1
- package/dist/evaluation-context.d.ts +58 -1
- package/dist/evaluation-context.d.ts.map +1 -1
- package/dist/evaluation-context.js +205 -30
- package/dist/evaluation-context.js.map +1 -1
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +6 -0
- package/dist/events.js.map +1 -1
- package/dist/kernel.d.ts +1 -0
- package/dist/kernel.d.ts.map +1 -1
- package/dist/kernel.js +16 -4
- package/dist/kernel.js.map +1 -1
- package/dist/module-context.d.ts +4 -1
- package/dist/module-context.d.ts.map +1 -1
- package/dist/module-context.js +77 -15
- package/dist/module-context.js.map +1 -1
- package/dist/resource-context.d.ts +16 -1
- package/dist/resource-context.d.ts.map +1 -1
- package/dist/resource-context.js +52 -1
- package/dist/resource-context.js.map +1 -1
- package/dist/tracing.d.ts +3 -0
- package/dist/tracing.d.ts.map +1 -1
- package/dist/tracing.js +6 -0
- package/dist/tracing.js.map +1 -1
- package/package.json +2 -2
- package/src/dependency-injection.ts +21 -3
- package/src/evaluation-context.ts +308 -45
- package/src/events.ts +5 -0
- package/src/kernel.ts +20 -4
- package/src/module-context.ts +100 -17
- package/src/resource-context.ts +58 -1
- package/src/tracing.ts +7 -0
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
type InstanceFactory,
|
|
11
11
|
type InvokeContext,
|
|
12
12
|
type LifecycleState,
|
|
13
|
+
type OpenSpan,
|
|
14
|
+
type OpenSpanOptions,
|
|
13
15
|
type PreInitHook,
|
|
14
16
|
type ResourceDefinition,
|
|
15
17
|
type ResourceInstance,
|
|
@@ -347,10 +349,13 @@ export class EvaluationContext implements IEvaluationContext {
|
|
|
347
349
|
if (this.resourceInstances.has(name)) continue;
|
|
348
350
|
try {
|
|
349
351
|
if (this.preInitHook) {
|
|
350
|
-
this.preInitHook(
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
352
|
+
this.preInitHook(
|
|
353
|
+
resource,
|
|
354
|
+
(n, alias) =>
|
|
355
|
+
alias && alias !== "Self"
|
|
356
|
+
? this.resolveImportedInstance(alias, n)
|
|
357
|
+
: this.resourceInstances.get(n)?.instance,
|
|
358
|
+
(n) => this.hasManifest(n) && !this.resourceInstances.has(n),
|
|
354
359
|
);
|
|
355
360
|
}
|
|
356
361
|
if (instance.init) await instance.init(ctx);
|
|
@@ -464,11 +469,17 @@ export class EvaluationContext implements IEvaluationContext {
|
|
|
464
469
|
// Propagate injection hook: extend getInstance to also resolve parent singleton instances.
|
|
465
470
|
if (parent.preInitHook) {
|
|
466
471
|
const parentHook = parent.preInitHook;
|
|
467
|
-
child.preInitHook = (resource, childGetInstance) => {
|
|
468
|
-
parentHook(
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
+
child.preInitHook = (resource, childGetInstance, childIsPending) => {
|
|
473
|
+
parentHook(
|
|
474
|
+
resource,
|
|
475
|
+
(name, alias) =>
|
|
476
|
+
alias && alias !== "Self"
|
|
477
|
+
? parent.resolveImportedInstance(alias, name)
|
|
478
|
+
: childGetInstance(name) ?? parent.resourceInstances.get(name)?.instance,
|
|
479
|
+
// A scoped ref resolves against the scope's own resources or an already-inited
|
|
480
|
+
// outer one; only a scope-local dependency can still be pending. The outer is
|
|
481
|
+
// live by the time a scope opens, so the child's own predicate suffices.
|
|
482
|
+
childIsPending,
|
|
472
483
|
);
|
|
473
484
|
};
|
|
474
485
|
}
|
|
@@ -604,6 +615,50 @@ export class EvaluationContext implements IEvaluationContext {
|
|
|
604
615
|
return this.runInvoke(kind, name, instance, inputs, ctx);
|
|
605
616
|
}
|
|
606
617
|
|
|
618
|
+
/**
|
|
619
|
+
* Build the structured trace payload every capability dispatch emits. The
|
|
620
|
+
* event *name* stays human-meaningful (`<name>.Invoked`) for bus subscribers;
|
|
621
|
+
* everything a debug consumer needs to rebuild the call tree rides here, so the
|
|
622
|
+
* consumer never parses the dotted name. `spanId`/`parentSpanId` are the
|
|
623
|
+
* tracer's `invocationId`/`parentInvocationId` (present only while tracing);
|
|
624
|
+
* `ref` carries the kind+name the name no longer encodes; `detail` is the
|
|
625
|
+
* per-capability data (inputs/outputs, error fields, cancellation reason).
|
|
626
|
+
*/
|
|
627
|
+
/**
|
|
628
|
+
* A redacted snapshot of the CEL root scope a debug consumer should see for a
|
|
629
|
+
* trace — `variables`, masked `secrets`, resource `snapshots`, `ports`. Attached
|
|
630
|
+
* to a trace's *root* span so the consumer can inspect what data the execution
|
|
631
|
+
* could reference (beyond its own inputs/outputs). Only a `ModuleContext` owns a
|
|
632
|
+
* root scope; child scopes return undefined. Host `env` is deliberately omitted
|
|
633
|
+
* (it is the raw process environment — too broad/sensitive to dump).
|
|
634
|
+
*/
|
|
635
|
+
protected traceRootScope(): Record<string, unknown> | undefined {
|
|
636
|
+
return undefined;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
protected tracePayload(
|
|
640
|
+
kind: string,
|
|
641
|
+
name: string,
|
|
642
|
+
spanId: number | undefined,
|
|
643
|
+
parentSpanId: number | undefined,
|
|
644
|
+
traceId: string | undefined,
|
|
645
|
+
capability: "invoke" | "run" | "provide" | "request",
|
|
646
|
+
phase: "start" | "end",
|
|
647
|
+
outcome: "ok" | "failed" | "rejected" | "cancelled" | undefined,
|
|
648
|
+
detail: Record<string, unknown>,
|
|
649
|
+
): Record<string, unknown> {
|
|
650
|
+
return {
|
|
651
|
+
traceId,
|
|
652
|
+
spanId,
|
|
653
|
+
parentSpanId,
|
|
654
|
+
capability,
|
|
655
|
+
phase,
|
|
656
|
+
...(outcome !== undefined ? { outcome } : {}),
|
|
657
|
+
ref: { kind, name },
|
|
658
|
+
...detail,
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
|
|
607
662
|
private async runInvoke<TInputs>(
|
|
608
663
|
kind: string,
|
|
609
664
|
name: string,
|
|
@@ -619,33 +674,60 @@ export class EvaluationContext implements IEvaluationContext {
|
|
|
619
674
|
const token = baseCtx.cancellation;
|
|
620
675
|
|
|
621
676
|
// Tracing gate (a debug consumer is attached): mint a monotonic id for this
|
|
622
|
-
// invocation, parent it to the ambient one, and ride both
|
|
623
|
-
//
|
|
624
|
-
// `
|
|
677
|
+
// invocation, parent it to the ambient one, and ride both in every event's
|
|
678
|
+
// payload so the consumer can rebuild the call tree. Off by default —
|
|
679
|
+
// `invokeCtx` stays `baseCtx` and the fast `=== ambient` skip is preserved.
|
|
625
680
|
const tracing = this.tracer?.enabled === true;
|
|
626
681
|
const invocationId = tracing ? this.tracer!.next() : undefined;
|
|
627
682
|
// Parent precedence mirrors the token: an explicit seed `ctx` wins, then the
|
|
628
683
|
// ambient (ALS) invocation. A caller threading its own `ctx.invocationId` thus
|
|
629
684
|
// has it honored as the parent, not silently dropped for the ALS value.
|
|
630
685
|
const parentInvocationId = ctx?.invocationId ?? ambient?.invocationId;
|
|
631
|
-
|
|
686
|
+
// Inherit the trace from the parent (explicit ctx wins, then ambient); mint a
|
|
687
|
+
// fresh one only at a root. Carried on every span so an OTel exporter groups
|
|
688
|
+
// the trace without walking the parent chain.
|
|
689
|
+
const traceId = tracing
|
|
690
|
+
? (ctx?.traceId ?? ambient?.traceId ?? this.tracer!.newTraceId())
|
|
691
|
+
: undefined;
|
|
692
|
+
// Capture the root CEL scope once, on the trace's root span's terminal event.
|
|
693
|
+
const rootScope = tracing && parentInvocationId === undefined ? this.traceRootScope() : undefined;
|
|
694
|
+
const span = (
|
|
695
|
+
phase: "start" | "end",
|
|
696
|
+
outcome: "ok" | "failed" | "rejected" | "cancelled" | undefined,
|
|
697
|
+
detail: Record<string, unknown>,
|
|
698
|
+
) =>
|
|
699
|
+
this.tracePayload(
|
|
700
|
+
kind,
|
|
701
|
+
name,
|
|
702
|
+
invocationId,
|
|
703
|
+
parentInvocationId,
|
|
704
|
+
traceId,
|
|
705
|
+
"invoke",
|
|
706
|
+
phase,
|
|
707
|
+
outcome,
|
|
708
|
+
phase === "end" && rootScope ? { ...detail, context: rootScope } : detail,
|
|
709
|
+
);
|
|
632
710
|
// When tracing, a fresh context carries the new id down the tree so nested
|
|
633
711
|
// invokes read it as their parent; it is never `=== ambient`, so the call
|
|
634
712
|
// always (re)establishes the ALS scope.
|
|
635
713
|
const invokeCtx: InvokeContext = tracing
|
|
636
|
-
? { cancellation: token, invocationId, parentInvocationId }
|
|
714
|
+
? { cancellation: token, invocationId, parentInvocationId, traceId }
|
|
637
715
|
: baseCtx;
|
|
638
716
|
|
|
639
717
|
// Pre-dispatch gate: a sub-invoke reached after the tree was cancelled is
|
|
640
718
|
// refused without ever touching the controller.
|
|
641
719
|
if (token.isCancelled) {
|
|
642
|
-
await this.emit(`${
|
|
720
|
+
await this.emit(`${name}.InvokeCancelled`, span("end", "cancelled", { inputs, reason: token.reason }));
|
|
643
721
|
throw new RuntimeError(
|
|
644
722
|
"ERR_INVOKE_CANCELLED",
|
|
645
723
|
`Invoke ${kind}.${name} was cancelled${token.reason ? `: ${token.reason}` : ""}`,
|
|
646
724
|
);
|
|
647
725
|
}
|
|
648
726
|
|
|
727
|
+
// Start span — only under tracing, so non-traced behaviour stays exactly
|
|
728
|
+
// one terminal event per call (subscribers to `<name>.Invoked` are unaffected).
|
|
729
|
+
if (tracing) await this.emit(`${name}.Invoking`, span("start", undefined, { inputs }));
|
|
730
|
+
|
|
649
731
|
try {
|
|
650
732
|
// Only (re)establish the ALS scope when the token differs from the ambient
|
|
651
733
|
// one — nested invokes that inherited it skip the redundant `run`.
|
|
@@ -661,36 +743,34 @@ export class EvaluationContext implements IEvaluationContext {
|
|
|
661
743
|
const outputs = await (invokeCtx === ambient
|
|
662
744
|
? call()
|
|
663
745
|
: cancellationStore.run(invokeCtx, call));
|
|
664
|
-
await this.emit(`${
|
|
746
|
+
await this.emit(`${name}.Invoked`, span("end", "ok", { inputs, outputs }));
|
|
665
747
|
return outputs;
|
|
666
748
|
} catch (err) {
|
|
667
749
|
// Cooperative mid-flight cancellation (`throwIfCancelled`) joins the same
|
|
668
750
|
// observable event family rather than masquerading as a rejection/failure.
|
|
669
751
|
if (isCancellationError(err)) {
|
|
670
752
|
const reason = err instanceof Error ? err.message : String(err);
|
|
671
|
-
await this.emit(`${
|
|
753
|
+
await this.emit(`${name}.InvokeCancelled`, span("end", "cancelled", { inputs, reason }));
|
|
672
754
|
throw err;
|
|
673
755
|
}
|
|
674
756
|
if (isInvokeError(err)) {
|
|
675
|
-
const
|
|
676
|
-
await this.emit(`${
|
|
757
|
+
const detail = { inputs, code: err.code, message: err.message, data: err.data };
|
|
758
|
+
await this.emit(`${name}.InvokeRejected`, span("end", "rejected", detail));
|
|
677
759
|
const declaredCodes = this.getDeclaredThrowCodes(kind);
|
|
678
760
|
if (declaredCodes && !declaredCodes.has(err.code)) {
|
|
679
|
-
await this.emit(`${
|
|
761
|
+
await this.emit(`${name}.InvokeRejected.Undeclared`, span("end", "rejected", detail));
|
|
680
762
|
}
|
|
681
763
|
throw err;
|
|
682
764
|
}
|
|
683
765
|
if (err instanceof Error) {
|
|
684
766
|
await this.emit(
|
|
685
|
-
`${
|
|
686
|
-
{ inputs, name: err.name, message: err.message },
|
|
687
|
-
meta,
|
|
767
|
+
`${name}.InvokeFailed`,
|
|
768
|
+
span("end", "failed", { inputs, name: err.name, message: err.message }),
|
|
688
769
|
);
|
|
689
770
|
} else {
|
|
690
771
|
await this.emit(
|
|
691
|
-
`${
|
|
692
|
-
{ inputs, name: "UnknownError", message: String(err) },
|
|
693
|
-
meta,
|
|
772
|
+
`${name}.InvokeFailed`,
|
|
773
|
+
span("end", "failed", { inputs, name: "UnknownError", message: String(err) }),
|
|
694
774
|
);
|
|
695
775
|
}
|
|
696
776
|
// Already enriched at an inner invoke: keep the innermost (most
|
|
@@ -712,6 +792,26 @@ export class EvaluationContext implements IEvaluationContext {
|
|
|
712
792
|
}
|
|
713
793
|
}
|
|
714
794
|
|
|
795
|
+
/**
|
|
796
|
+
* The declared capability of a kind, or undefined if unknown. Definitions are
|
|
797
|
+
* keyed by their canonical `<module>.<Kind>`, but a resource carries the alias
|
|
798
|
+
* kind it was written with (`Http.Server`), so resolve the alias first.
|
|
799
|
+
* Best-effort: `resolveKind` exists only on a `ModuleContext` and throws for
|
|
800
|
+
* unqualified / ungated kinds, so guard and fall back to the raw kind.
|
|
801
|
+
*/
|
|
802
|
+
/** Resolve an alias kind (`Http.Server`) to its canonical `<module>.<Kind>`.
|
|
803
|
+
* Only a `ModuleContext` carries the import-alias table; the base returns the
|
|
804
|
+
* kind unchanged. A typed seam (overridden in `ModuleContext`), matching the
|
|
805
|
+
* `traceRootScope()` pattern, rather than reaching across the boundary. */
|
|
806
|
+
protected resolveKindSafe(kind: string): string {
|
|
807
|
+
return kind;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
private capabilityOf(kind: string): string | undefined {
|
|
811
|
+
const resolved = this.resolveKindSafe(kind);
|
|
812
|
+
return this.getDefinition?.(resolved)?.capability ?? this.getDefinition?.(kind)?.capability;
|
|
813
|
+
}
|
|
814
|
+
|
|
715
815
|
private getDeclaredThrowCodes(kind: string): Set<string> | null {
|
|
716
816
|
if (!this.getDefinition) return null;
|
|
717
817
|
const def = this.getDefinition(kind);
|
|
@@ -730,28 +830,191 @@ export class EvaluationContext implements IEvaluationContext {
|
|
|
730
830
|
|
|
731
831
|
async run(name: string, ctx?: InvokeContext): Promise<void> {
|
|
732
832
|
const entry = this.resourceInstances.get(name);
|
|
733
|
-
if (entry && typeof entry.instance.run === "function") {
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
833
|
+
if (!(entry && typeof entry.instance.run === "function")) {
|
|
834
|
+
throw new RuntimeError(
|
|
835
|
+
"ERR_RESOURCE_NOT_RUNNABLE",
|
|
836
|
+
`Resource ${name} is not runnable or not found. Available resources: ${[...this.resourceInstances.keys()].join(", ")}`,
|
|
837
|
+
);
|
|
838
|
+
}
|
|
839
|
+
return this.runInstance(entry.resource.kind as string, name, entry.instance, ctx);
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* Like run(), but the caller has already resolved the instance (e.g. a
|
|
844
|
+
* Phase-5-injected `!ref` boot target). Shares the single span-emitting path so
|
|
845
|
+
* a pre-resolved runnable is instrumented exactly like a by-name dispatch
|
|
846
|
+
* instead of escaping the chokepoint with a direct `instance.run()` call.
|
|
847
|
+
*/
|
|
848
|
+
async runResolved(
|
|
849
|
+
kind: string,
|
|
850
|
+
name: string,
|
|
851
|
+
instance: ResourceInstance,
|
|
852
|
+
ctx?: InvokeContext,
|
|
853
|
+
): Promise<void> {
|
|
854
|
+
if (typeof instance.run !== "function") {
|
|
855
|
+
throw new RuntimeError(
|
|
856
|
+
"ERR_RESOURCE_NOT_RUNNABLE",
|
|
857
|
+
`Resource ${kind}.${name} does not have a run method`,
|
|
858
|
+
);
|
|
859
|
+
}
|
|
860
|
+
return this.runInstance(kind, name, instance, ctx);
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/**
|
|
864
|
+
* Open a trace span for an inbound boundary (an HTTP request). Mints a span
|
|
865
|
+
* that roots a fresh trace (or continues `opts.inbound`), emits its `start`,
|
|
866
|
+
* and returns a child context to thread into `invokeResolved` so the handler
|
|
867
|
+
* nests under it. A no-op pass-through when tracing is off.
|
|
868
|
+
*/
|
|
869
|
+
async openSpan(base: InvokeContext | undefined, opts: OpenSpanOptions): Promise<OpenSpan> {
|
|
870
|
+
const ctx = base ?? UNCANCELLABLE_CONTEXT;
|
|
871
|
+
if (this.tracer?.enabled !== true) {
|
|
872
|
+
return { context: ctx, settle: async () => {} };
|
|
873
|
+
}
|
|
874
|
+
const spanId = this.tracer.next();
|
|
875
|
+
const traceId = opts.inbound?.traceId ?? this.tracer.newTraceId();
|
|
876
|
+
const parentSpanId = opts.inbound?.parentSpanId;
|
|
877
|
+
// A root request span (not continuing an upstream trace) carries the root scope.
|
|
878
|
+
const rootScope = parentSpanId === undefined ? this.traceRootScope() : undefined;
|
|
879
|
+
const detail = {
|
|
880
|
+
...(opts.label !== undefined ? { label: opts.label } : {}),
|
|
881
|
+
...(opts.attributes !== undefined ? { attributes: opts.attributes } : {}),
|
|
882
|
+
};
|
|
883
|
+
const payload = (
|
|
884
|
+
phase: "start" | "end",
|
|
885
|
+
outcome: "ok" | "failed" | "rejected" | "cancelled" | undefined,
|
|
886
|
+
extra: Record<string, unknown> = {},
|
|
887
|
+
) =>
|
|
888
|
+
this.tracePayload(
|
|
889
|
+
opts.ref.kind,
|
|
890
|
+
opts.ref.name,
|
|
891
|
+
spanId,
|
|
892
|
+
parentSpanId,
|
|
893
|
+
traceId,
|
|
894
|
+
"request",
|
|
895
|
+
phase,
|
|
896
|
+
outcome,
|
|
897
|
+
{ ...detail, ...extra },
|
|
898
|
+
);
|
|
899
|
+
|
|
900
|
+
await this.emit(`${opts.ref.name}.Requesting`, payload("start", undefined));
|
|
901
|
+
const context: InvokeContext = {
|
|
902
|
+
cancellation: ctx.cancellation,
|
|
903
|
+
invocationId: spanId,
|
|
904
|
+
parentInvocationId: parentSpanId,
|
|
905
|
+
traceId,
|
|
906
|
+
};
|
|
907
|
+
let settled = false;
|
|
908
|
+
return {
|
|
909
|
+
context,
|
|
910
|
+
settle: async (outcome, extra) => {
|
|
911
|
+
if (settled) return;
|
|
912
|
+
settled = true;
|
|
913
|
+
await this.emit(
|
|
914
|
+
`${opts.ref.name}.Request`,
|
|
915
|
+
payload("end", outcome, rootScope ? { ...extra, context: rootScope } : extra),
|
|
743
916
|
);
|
|
917
|
+
},
|
|
918
|
+
};
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
private async runInstance(
|
|
922
|
+
kind: string,
|
|
923
|
+
name: string,
|
|
924
|
+
instance: ResourceInstance,
|
|
925
|
+
ctx?: InvokeContext,
|
|
926
|
+
): Promise<void> {
|
|
927
|
+
const ambient = cancellationStore.getStore();
|
|
928
|
+
const baseCtx = ctx ?? ambient ?? UNCANCELLABLE_CONTEXT;
|
|
929
|
+
const token = baseCtx.cancellation;
|
|
930
|
+
|
|
931
|
+
// A long-lived Service's `run()` is not a one-shot dispatch: it stays pending
|
|
932
|
+
// for the process lifetime, so wrapping it in the cancellation/trace ALS scope
|
|
933
|
+
// would leak that scope onto every async resource the service creates (e.g. an
|
|
934
|
+
// HTTP server's listening socket → every inbound request callback). Such a
|
|
935
|
+
// service must NOT establish an ambient: its token reaches it via the explicit
|
|
936
|
+
// `run(invokeCtx)` argument (how it observes shutdown), and its externally
|
|
937
|
+
// triggered work then starts with a clean ambient — separate traces, no
|
|
938
|
+
// inherited cancellation. Runnables (one-shot, e.g. `Run.Sequence`) keep the
|
|
939
|
+
// ALS scope so their steps nest and inherit cancellation.
|
|
940
|
+
const isService = this.capabilityOf(kind) === "Telo.Service";
|
|
941
|
+
|
|
942
|
+
// Span instrumentation mirrors `runInvoke` — minting an id here makes
|
|
943
|
+
// Runnables (a `Run.Sequence` boot target) appear in the trace and re-parents
|
|
944
|
+
// their nested invokes. A long-lived Service emits only the `start` span (its
|
|
945
|
+
// `run()` resolves at teardown), the "running" signal a debug consumer wants.
|
|
946
|
+
const tracing = this.tracer?.enabled === true;
|
|
947
|
+
const invocationId = tracing ? this.tracer!.next() : undefined;
|
|
948
|
+
const parentInvocationId = ctx?.invocationId ?? ambient?.invocationId;
|
|
949
|
+
const traceId = tracing
|
|
950
|
+
? (ctx?.traceId ?? ambient?.traceId ?? this.tracer!.newTraceId())
|
|
951
|
+
: undefined;
|
|
952
|
+
const rootScope = tracing && parentInvocationId === undefined ? this.traceRootScope() : undefined;
|
|
953
|
+
const span = (
|
|
954
|
+
phase: "start" | "end",
|
|
955
|
+
outcome: "ok" | "failed" | "cancelled" | undefined,
|
|
956
|
+
detail: Record<string, unknown>,
|
|
957
|
+
) =>
|
|
958
|
+
this.tracePayload(
|
|
959
|
+
kind,
|
|
960
|
+
name,
|
|
961
|
+
invocationId,
|
|
962
|
+
parentInvocationId,
|
|
963
|
+
traceId,
|
|
964
|
+
"run",
|
|
965
|
+
phase,
|
|
966
|
+
outcome,
|
|
967
|
+
phase === "end" && rootScope ? { ...detail, context: rootScope } : detail,
|
|
968
|
+
);
|
|
969
|
+
const invokeCtx: InvokeContext = tracing
|
|
970
|
+
? { cancellation: token, invocationId, parentInvocationId, traceId }
|
|
971
|
+
: baseCtx;
|
|
972
|
+
|
|
973
|
+
// Refuse a target reached after the boot run was cancelled.
|
|
974
|
+
if (token.isCancelled) {
|
|
975
|
+
await this.emit(`${name}.RunCancelled`, span("end", "cancelled", { reason: token.reason }));
|
|
976
|
+
throw new RuntimeError(
|
|
977
|
+
"ERR_INVOKE_CANCELLED",
|
|
978
|
+
`Run ${kind}.${name} was cancelled${token.reason ? `: ${token.reason}` : ""}`,
|
|
979
|
+
);
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
if (tracing) await this.emit(`${name}.Running`, span("start", undefined, {}));
|
|
983
|
+
|
|
984
|
+
try {
|
|
985
|
+
// Runnable: run inside the ALS scope so nested invokes inherit the token and
|
|
986
|
+
// trace id (skip the redundant `run` when the token is already ambient).
|
|
987
|
+
// Service: call directly with the explicit context and NO ambient scope, so
|
|
988
|
+
// its long-lived async work does not capture this scope.
|
|
989
|
+
const call = () => (instance.run as (c?: InvokeContext) => Promise<void>)(invokeCtx);
|
|
990
|
+
await (isService || invokeCtx === ambient ? call() : cancellationStore.run(invokeCtx, call));
|
|
991
|
+
await this.emit(`${name}.Run`, span("end", "ok", {}));
|
|
992
|
+
} catch (err) {
|
|
993
|
+
if (isCancellationError(err)) {
|
|
994
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
995
|
+
await this.emit(`${name}.RunCancelled`, span("end", "cancelled", { reason }));
|
|
996
|
+
throw err;
|
|
744
997
|
}
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
998
|
+
const detail =
|
|
999
|
+
err instanceof Error
|
|
1000
|
+
? { name: err.name, message: err.message }
|
|
1001
|
+
: { name: "UnknownError", message: String(err) };
|
|
1002
|
+
await this.emit(`${name}.RunFailed`, span("end", "failed", detail));
|
|
1003
|
+
throw err;
|
|
750
1004
|
}
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
/**
|
|
1008
|
+
* Run `fn` detached from the caller's cancellation/trace scope. The current
|
|
1009
|
+
* ambient `InvokeContext` (a request's token + span) is replaced with the
|
|
1010
|
+
* uncancellable root, so request-scope teardown cannot abort the work and it
|
|
1011
|
+
* does not nest under the request's trace. This is the bare scope primitive —
|
|
1012
|
+
* tracking/draining a detached task is the owning resource's concern (the
|
|
1013
|
+
* per-resource `ResourceContext` records the task and drains it in the
|
|
1014
|
+
* resource's teardown).
|
|
1015
|
+
*/
|
|
1016
|
+
runDetached<T>(fn: () => Promise<T>): Promise<T> {
|
|
1017
|
+
return cancellationStore.run(UNCANCELLABLE_CONTEXT, fn);
|
|
755
1018
|
}
|
|
756
1019
|
|
|
757
1020
|
/**
|
package/src/events.ts
CHANGED
|
@@ -70,6 +70,11 @@ export class EventBus {
|
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
async emit(event: string, payload?: any, metadata?: any): Promise<void> {
|
|
73
|
+
// O(1) idle short-circuit: with no subscriber at all — the common case when
|
|
74
|
+
// no debug consumer is attached — emitting costs a single integer compare,
|
|
75
|
+
// no map walk and no allocation. This is what keeps routing every dispatch
|
|
76
|
+
// through the instrumented chokepoint effectively free when nobody listens.
|
|
77
|
+
if (this.handlers.size === 0) return;
|
|
73
78
|
const handlers: EventHandler[] = [];
|
|
74
79
|
for (const [pattern, set] of this.handlers.entries()) {
|
|
75
80
|
if (!this.matchesPattern(pattern, event)) {
|
package/src/kernel.ts
CHANGED
|
@@ -132,6 +132,9 @@ export class Kernel implements IKernel {
|
|
|
132
132
|
// SIGINT (before runTargets) still has a source to cancel, which the run then
|
|
133
133
|
// observes via the pre-dispatch gate.
|
|
134
134
|
private _bootCancellation?: CancellationSource;
|
|
135
|
+
// Root application name — labels the boot `targets` trace span so the app
|
|
136
|
+
// appears as the trace root with its targets nested beneath.
|
|
137
|
+
private _appName?: string;
|
|
135
138
|
|
|
136
139
|
readonly stdin: NodeJS.ReadableStream;
|
|
137
140
|
readonly stdout: NodeJS.WritableStream;
|
|
@@ -327,8 +330,8 @@ export class Kernel implements IKernel {
|
|
|
327
330
|
await this.loadBuiltinDefinitions();
|
|
328
331
|
|
|
329
332
|
// Phase 5: attach injection hook — fires between create() and init() for every resource
|
|
330
|
-
this.rootContext.preInitHook = (resource, getInstance) =>
|
|
331
|
-
this._injectDependencies(resource, getInstance);
|
|
333
|
+
this.rootContext.preInitHook = (resource, getInstance, isPending) =>
|
|
334
|
+
this._injectDependencies(resource, getInstance, isPending);
|
|
332
335
|
|
|
333
336
|
// Expose definition lookup so invoke()/invokeResolved() can check thrown
|
|
334
337
|
// InvokeError.code against the declared throw union (rule 9). Propagates
|
|
@@ -511,6 +514,7 @@ export class Kernel implements IKernel {
|
|
|
511
514
|
this.rootContext.setTargets(rawTargets as BootTarget[]);
|
|
512
515
|
if (manifest.kind === "Telo.Application") {
|
|
513
516
|
rootApplicationManifest = manifest;
|
|
517
|
+
this._appName = (manifest.metadata as { name?: string } | undefined)?.name;
|
|
514
518
|
}
|
|
515
519
|
}
|
|
516
520
|
this.rootContext.registerManifest(manifest);
|
|
@@ -638,7 +642,7 @@ export class Kernel implements IKernel {
|
|
|
638
642
|
this._targetsRan = true;
|
|
639
643
|
|
|
640
644
|
await this.eventBus.emit("Kernel.Starting", {});
|
|
641
|
-
await this.rootContext.runTargets(this.bootCancellation.context);
|
|
645
|
+
await this.rootContext.runTargets(this.bootCancellation.context, this._appName);
|
|
642
646
|
await this.eventBus.emit("Kernel.Started", {});
|
|
643
647
|
}
|
|
644
648
|
|
|
@@ -1013,6 +1017,17 @@ export class Kernel implements IKernel {
|
|
|
1013
1017
|
const instance = await controller.create(processedResource, ctx);
|
|
1014
1018
|
if (!instance) return null;
|
|
1015
1019
|
|
|
1020
|
+
// Fold the resource's fire-and-forget drain into its own teardown: tearing
|
|
1021
|
+
// the resource down drains the background tasks it spawned (the kernel just
|
|
1022
|
+
// calls teardown() — it tracks no tasks itself). A drain with no pending
|
|
1023
|
+
// tasks is a no-op, so this is safe for every resource.
|
|
1024
|
+
const ownerCtx = ctx as ResourceContextImpl;
|
|
1025
|
+
const originalTeardown = instance.teardown?.bind(instance);
|
|
1026
|
+
instance.teardown = async () => {
|
|
1027
|
+
await ownerCtx.drainDetached();
|
|
1028
|
+
if (originalTeardown) await originalTeardown();
|
|
1029
|
+
};
|
|
1030
|
+
|
|
1016
1031
|
if (!runtime.length) return { instance, ctx, resource: processedResource };
|
|
1017
1032
|
|
|
1018
1033
|
// Override invoke in-place so all lifecycle methods (init/invoke/teardown/snapshot)
|
|
@@ -1039,10 +1054,11 @@ export class Kernel implements IKernel {
|
|
|
1039
1054
|
private _injectDependencies(
|
|
1040
1055
|
resource: ResourceManifest,
|
|
1041
1056
|
getInstance: (name: string, alias?: string) => ResourceInstance | undefined,
|
|
1057
|
+
isPending?: (name: string) => boolean,
|
|
1042
1058
|
): void {
|
|
1043
1059
|
this.registry.iterateFieldEntries(
|
|
1044
1060
|
resource,
|
|
1045
|
-
(fieldPath) => injectAtPath(resource, fieldPath, getInstance),
|
|
1061
|
+
(fieldPath) => injectAtPath(resource, fieldPath, getInstance, isPending),
|
|
1046
1062
|
(fieldPath) => {
|
|
1047
1063
|
const val = (resource as Record<string, unknown>)[fieldPath];
|
|
1048
1064
|
if (Array.isArray(val)) {
|