@telorun/kernel 0.80.0 → 0.82.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/controller-loaders/npm-install-root.d.ts +56 -0
- package/dist/controller-loaders/npm-install-root.d.ts.map +1 -0
- package/dist/controller-loaders/npm-install-root.js +109 -0
- package/dist/controller-loaders/npm-install-root.js.map +1 -0
- package/dist/controller-loaders/npm-loader.d.ts +3 -0
- package/dist/controller-loaders/npm-loader.d.ts.map +1 -1
- package/dist/controller-loaders/npm-loader.js +11 -1
- package/dist/controller-loaders/npm-loader.js.map +1 -1
- package/dist/controllers/logging/console-sink-controller.d.ts.map +1 -1
- package/dist/controllers/logging/console-sink-controller.js +19 -9
- package/dist/controllers/logging/console-sink-controller.js.map +1 -1
- package/dist/controllers/logging/file-sink-controller.d.ts.map +1 -1
- package/dist/controllers/logging/file-sink-controller.js +17 -6
- package/dist/controllers/logging/file-sink-controller.js.map +1 -1
- package/dist/controllers/module/import-controller.d.ts.map +1 -1
- package/dist/controllers/module/import-controller.js +36 -15
- package/dist/controllers/module/import-controller.js.map +1 -1
- package/dist/controllers/resource-definition/resource-template-controller.d.ts.map +1 -1
- package/dist/controllers/resource-definition/resource-template-controller.js +7 -5
- package/dist/controllers/resource-definition/resource-template-controller.js.map +1 -1
- package/dist/effect-scope.d.ts +132 -0
- package/dist/effect-scope.d.ts.map +1 -0
- package/dist/effect-scope.js +253 -0
- package/dist/effect-scope.js.map +1 -0
- package/dist/evaluation-context.d.ts +26 -1
- package/dist/evaluation-context.d.ts.map +1 -1
- package/dist/evaluation-context.js +156 -12
- package/dist/evaluation-context.js.map +1 -1
- package/dist/init-failure-diagnostics.d.ts +10 -0
- package/dist/init-failure-diagnostics.d.ts.map +1 -1
- package/dist/init-failure-diagnostics.js +7 -1
- package/dist/init-failure-diagnostics.js.map +1 -1
- package/dist/kernel.d.ts +6 -2
- package/dist/kernel.d.ts.map +1 -1
- package/dist/kernel.js +23 -14
- package/dist/kernel.js.map +1 -1
- package/dist/manifest-schemas.d.ts +4 -1
- package/dist/manifest-schemas.d.ts.map +1 -1
- package/dist/manifest-schemas.js +4 -10
- package/dist/manifest-schemas.js.map +1 -1
- package/dist/module-context.d.ts +10 -0
- package/dist/module-context.d.ts.map +1 -1
- package/dist/module-context.js +15 -0
- package/dist/module-context.js.map +1 -1
- package/dist/observed-state.d.ts.map +1 -1
- package/dist/observed-state.js +2 -4
- package/dist/observed-state.js.map +1 -1
- package/dist/resource-context.d.ts +18 -1
- package/dist/resource-context.d.ts.map +1 -1
- package/dist/resource-context.js +27 -0
- package/dist/resource-context.js.map +1 -1
- package/dist/schema-compiled-values.d.ts +2 -1
- package/dist/schema-compiled-values.d.ts.map +1 -1
- package/dist/schema-compiled-values.js +23 -20
- package/dist/schema-compiled-values.js.map +1 -1
- package/package.json +4 -3
- package/src/controller-loaders/npm-install-root.ts +131 -0
- package/src/controller-loaders/npm-loader.ts +12 -1
- package/src/controllers/logging/console-sink-controller.ts +19 -9
- package/src/controllers/logging/file-sink-controller.ts +17 -6
- package/src/controllers/module/import-controller.ts +50 -27
- package/src/controllers/resource-definition/resource-template-controller.ts +14 -11
- package/src/effect-scope.ts +340 -0
- package/src/evaluation-context.ts +165 -10
- package/src/init-failure-diagnostics.ts +8 -1
- package/src/kernel.ts +26 -13
- package/src/manifest-schemas.ts +4 -9
- package/src/module-context.ts +16 -0
- package/src/observed-state.ts +2 -4
- package/src/resource-context.ts +32 -0
- package/src/schema-compiled-values.ts +35 -21
|
@@ -29,8 +29,10 @@ import {
|
|
|
29
29
|
} from "@telorun/sdk";
|
|
30
30
|
import { RuntimeError } from "@telorun/sdk";
|
|
31
31
|
import { evalPathCovers } from "@telorun/analyzer";
|
|
32
|
+
import { effectOwnerOf, executeReturnedChain } from "./effect-scope.js";
|
|
32
33
|
import {
|
|
33
34
|
classifyInitFailures,
|
|
35
|
+
isDeferral,
|
|
34
36
|
renderInitFailureText,
|
|
35
37
|
summarizeInitFailures,
|
|
36
38
|
type FailedResource,
|
|
@@ -491,12 +493,28 @@ export class EvaluationContext implements IEvaluationContext {
|
|
|
491
493
|
{ resource: ResourceManifest; instance: ResourceInstance }
|
|
492
494
|
>();
|
|
493
495
|
|
|
494
|
-
/** Resources that have been created but not yet initialized (between phases).
|
|
496
|
+
/** Resources that have been created but not yet initialized (between phases).
|
|
497
|
+
* `source` is the manifest as REGISTERED, kept so a discarded instance is
|
|
498
|
+
* rebuilt from what the author wrote rather than from the create-time
|
|
499
|
+
* expansion of it. */
|
|
495
500
|
protected readonly createdInstances = new Map<
|
|
496
501
|
string,
|
|
497
|
-
{ resource: ResourceManifest; instance: ResourceInstance; ctx: any }
|
|
502
|
+
{ resource: ResourceManifest; instance: ResourceInstance; ctx: any; source: ResourceManifest }
|
|
498
503
|
>();
|
|
499
504
|
|
|
505
|
+
/**
|
|
506
|
+
* Resources whose failed `init()` could not be rolled back.
|
|
507
|
+
*
|
|
508
|
+
* Withheld rather than retried: re-running `init()` from a state an inverse
|
|
509
|
+
* refused to restore is worse than not retrying, so the loop skips them and
|
|
510
|
+
* they are reported with both the init error and the refusing inverse.
|
|
511
|
+
*/
|
|
512
|
+
private readonly withheldResources = new Set<string>();
|
|
513
|
+
|
|
514
|
+
/** Resources discarded after a failed `init()` and re-queued for creation.
|
|
515
|
+
* Their re-creation is not progress — see the create sub-phase. */
|
|
516
|
+
private readonly recreatedResources = new Set<string>();
|
|
517
|
+
|
|
500
518
|
/** Resources queued for initialization on this context node. */
|
|
501
519
|
private pendingResources: ResourceManifest[] = [];
|
|
502
520
|
|
|
@@ -746,6 +764,20 @@ export class EvaluationContext implements IEvaluationContext {
|
|
|
746
764
|
* Attach a child context to this node. The child's parent is set to this
|
|
747
765
|
* context and the child is registered under the given name.
|
|
748
766
|
*/
|
|
767
|
+
/**
|
|
768
|
+
* Detach a child from this node — the inverse of {@link spawnChild}.
|
|
769
|
+
*
|
|
770
|
+
* A child is normally torn down in place, so this exists for the one case
|
|
771
|
+
* that discards it instead: an import whose instance is dropped and rebuilt
|
|
772
|
+
* must not leave its child in `children`, or this context's teardown cascades
|
|
773
|
+
* into a context whose replacement is already live. Here rather than in the
|
|
774
|
+
* controller, because how children are tracked is this class's own fact.
|
|
775
|
+
*/
|
|
776
|
+
detachChild(child: IEvaluationContext): void {
|
|
777
|
+
const index = this.children.indexOf(child);
|
|
778
|
+
if (index >= 0) this.children.splice(index, 1);
|
|
779
|
+
}
|
|
780
|
+
|
|
749
781
|
spawnChild<T extends IEvaluationContext>(child: T): T {
|
|
750
782
|
child.parent = this;
|
|
751
783
|
this.children.push(child);
|
|
@@ -843,11 +875,18 @@ export class EvaluationContext implements IEvaluationContext {
|
|
|
843
875
|
resource: created.resource,
|
|
844
876
|
instance: created.instance,
|
|
845
877
|
ctx: created.ctx,
|
|
878
|
+
source: resource,
|
|
846
879
|
});
|
|
847
880
|
const idx = this.pendingResources.findIndex((m) => m.metadata.name === name);
|
|
848
881
|
if (idx >= 0) this.pendingResources.splice(idx, 1);
|
|
849
882
|
errors.delete(name);
|
|
850
|
-
progress
|
|
883
|
+
// A FIRST creation is progress; RE-creating a resource whose init
|
|
884
|
+
// failed is not. Counting it would keep the loop alive on a
|
|
885
|
+
// permanently failing resource for every remaining pass, re-running
|
|
886
|
+
// `create()` each time — ten module loads for a broken import, ten
|
|
887
|
+
// pools for an unreachable database. Its retry still happens; what
|
|
888
|
+
// it no longer does is claim the environment moved.
|
|
889
|
+
if (!this.recreatedResources.has(name)) progress = true;
|
|
851
890
|
const createdRes = created.resource;
|
|
852
891
|
const refs = collectResourceRefs(createdRes);
|
|
853
892
|
this.resourceDependencies.set(name, localDependencyNames(refs));
|
|
@@ -881,8 +920,8 @@ export class EvaluationContext implements IEvaluationContext {
|
|
|
881
920
|
}
|
|
882
921
|
|
|
883
922
|
// Init sub-phase
|
|
884
|
-
for (const [name, { resource, instance, ctx }] of [...this.createdInstances]) {
|
|
885
|
-
if (this.resourceInstances.has(name)) continue;
|
|
923
|
+
for (const [name, { resource, instance, ctx, source }] of [...this.createdInstances]) {
|
|
924
|
+
if (this.resourceInstances.has(name) || this.withheldResources.has(name)) continue;
|
|
886
925
|
try {
|
|
887
926
|
if (this.preInitHook) {
|
|
888
927
|
this.preInitHook(
|
|
@@ -895,7 +934,68 @@ export class EvaluationContext implements IEvaluationContext {
|
|
|
895
934
|
this,
|
|
896
935
|
);
|
|
897
936
|
}
|
|
898
|
-
|
|
937
|
+
const scope = effectOwnerOf(instance)?.effects;
|
|
938
|
+
scope?.openFrame("init");
|
|
939
|
+
try {
|
|
940
|
+
// What `init()` RETURNS is what undoes it. A controller that
|
|
941
|
+
// allocates nothing returns nothing; there is no `teardown()`, so an
|
|
942
|
+
// allocation outside the chain is one nothing will reclaim.
|
|
943
|
+
if (instance.init) await executeReturnedChain(await instance.init(ctx), scope);
|
|
944
|
+
} catch (error) {
|
|
945
|
+
// Recover before the next pass: the loop retries a failed init, and
|
|
946
|
+
// an init that registered a listener before it failed to connect
|
|
947
|
+
// would otherwise register that listener again on every pass.
|
|
948
|
+
//
|
|
949
|
+
// HOW MUCH unwinds depends on whether the instance survives. A
|
|
950
|
+
// deferral keeps it, so only the init frame goes — unwinding the
|
|
951
|
+
// create frame there would destroy what construction built (a
|
|
952
|
+
// connection's pool) and then re-init the same instance against it.
|
|
953
|
+
// A real failure discards the instance, so everything `create()`
|
|
954
|
+
// allocated on its behalf goes with it or nothing reclaims it.
|
|
955
|
+
const deferred = isDeferral(error);
|
|
956
|
+
const refused = (await (deferred ? scope?.unwindFrame() : scope?.unwindAll())) ?? [];
|
|
957
|
+
if (refused.length > 0) {
|
|
958
|
+
// Retrying from a state that could not be rolled back is worse
|
|
959
|
+
// than not retrying, so the resource is withheld with a cause
|
|
960
|
+
// naming both the init error and the inverse that refused. The
|
|
961
|
+
// entry stays in `createdInstances` so it is still reported as a
|
|
962
|
+
// failure; the loop skips it from here on.
|
|
963
|
+
this.withheldResources.add(name);
|
|
964
|
+
throw new RuntimeError(
|
|
965
|
+
"ERR_EFFECT_RECOVERY_FAILED",
|
|
966
|
+
`${resource.kind} '${name}' failed to initialize and could not be rolled back: ` +
|
|
967
|
+
`${refused.map((f) => `'${f.reason}' (${errorText(f.error)})`).join(", ")}`,
|
|
968
|
+
[
|
|
969
|
+
{ severity: "error", message: errorText(error), resource: name },
|
|
970
|
+
...refused.map((f) => ({
|
|
971
|
+
severity: "error" as const,
|
|
972
|
+
message: `inverse '${f.reason}' refused: ${errorText(f.error)}`,
|
|
973
|
+
resource: name,
|
|
974
|
+
})),
|
|
975
|
+
],
|
|
976
|
+
);
|
|
977
|
+
}
|
|
978
|
+
// A DEFERRAL is not a failure: it is the loop's own "your turn has
|
|
979
|
+
// not come" signal, raised when a ref names a resource that has not
|
|
980
|
+
// initialized yet. The instance stays — re-creating it would re-run
|
|
981
|
+
// `create()`, which for an import re-registers its alias and reloads
|
|
982
|
+
// its module, and for a template re-registers its children. Its init
|
|
983
|
+
// frame alone was unwound above, so the next pass re-inits a
|
|
984
|
+
// constructed resource rather than a dismantled one.
|
|
985
|
+
if (deferred) throw error;
|
|
986
|
+
// The inverses restored what init() touched OUTSIDE the instance;
|
|
987
|
+
// the instance's own half-built fields are beyond their reach, so
|
|
988
|
+
// the object goes too and the next pass builds a fresh one. That is
|
|
989
|
+
// what makes "retry from a clean state" literal rather than a
|
|
990
|
+
// convention each controller has to honour.
|
|
991
|
+
this.createdInstances.delete(name);
|
|
992
|
+
this.recreatedResources.add(name);
|
|
993
|
+
// Re-queued as REGISTERED, not as created: the create-time manifest
|
|
994
|
+
// has already been through compile-field expansion, and expanding
|
|
995
|
+
// it a second time would evaluate an author's expression twice.
|
|
996
|
+
this.pendingResources.push(source);
|
|
997
|
+
throw error;
|
|
998
|
+
}
|
|
899
999
|
// Publish BEFORE registering: publication can fail (a kind returning
|
|
900
1000
|
// the reserved `status` key without declaring it, a report that does
|
|
901
1001
|
// not match `status:`), and a resource that failed must not be left
|
|
@@ -1133,8 +1233,29 @@ export class EvaluationContext implements IEvaluationContext {
|
|
|
1133
1233
|
reportedStatus.delete(instance);
|
|
1134
1234
|
startedInstances.delete(instance);
|
|
1135
1235
|
completedInstances.delete(instance);
|
|
1236
|
+
// Tearing a resource down IS unwinding its effects — every frame, newest
|
|
1237
|
+
// first, LIFO within each. There is no `teardown()` to call: what undoes a
|
|
1238
|
+
// resource is what its `init()` and `run()` returned.
|
|
1239
|
+
const owner = effectOwnerOf(instance);
|
|
1240
|
+
const refused = (await owner?.effects.unwindAll()) ?? [];
|
|
1241
|
+
if (refused.length > 0) {
|
|
1242
|
+
// Aggregate and continue: one refusing inverse must not strand the log
|
|
1243
|
+
// sinks pinned last to outlive everything that might log on the way
|
|
1244
|
+
// down.
|
|
1245
|
+
failures.push({
|
|
1246
|
+
resource: label,
|
|
1247
|
+
error: new RuntimeError(
|
|
1248
|
+
"ERR_EFFECT_RECOVERY_FAILED",
|
|
1249
|
+
`${refused.length} inverse(s) refused: ` +
|
|
1250
|
+
refused.map((f) => `'${f.reason}' (${errorText(f.error)})`).join(", "),
|
|
1251
|
+
),
|
|
1252
|
+
});
|
|
1253
|
+
}
|
|
1254
|
+
// A drain waits for in-flight background work under a bound and then
|
|
1255
|
+
// abandons it — not an inverse, so it is not on a frame; it runs here,
|
|
1256
|
+
// once the resource's own effects are undone.
|
|
1136
1257
|
try {
|
|
1137
|
-
|
|
1258
|
+
await owner?.drainDetached();
|
|
1138
1259
|
} catch (err) {
|
|
1139
1260
|
// Aggregate rather than abort. A single throwing resource used to
|
|
1140
1261
|
// abandon every resource after it in the cascade — including the log
|
|
@@ -1681,12 +1802,27 @@ export class EvaluationContext implements IEvaluationContext {
|
|
|
1681
1802
|
// as started, so this has to happen first.
|
|
1682
1803
|
await this.markStarted(name, instance);
|
|
1683
1804
|
|
|
1805
|
+
// A frame of its own: what `run()` allocates (a listening socket, a kernel
|
|
1806
|
+
// hold) is undone when the run's frame unwinds, without disturbing what
|
|
1807
|
+
// `init()` built underneath it.
|
|
1808
|
+
const effects = effectOwnerOf(instance)?.effects;
|
|
1809
|
+
effects?.openFrame("run");
|
|
1810
|
+
|
|
1684
1811
|
try {
|
|
1685
1812
|
// Runnable: run inside the ALS scope so nested invokes inherit the token and
|
|
1686
1813
|
// trace id (skip the redundant `run` when the token is already ambient).
|
|
1687
1814
|
// Service: call directly with the explicit context and NO ambient scope, so
|
|
1688
1815
|
// its long-lived async work does not capture this scope.
|
|
1689
|
-
|
|
1816
|
+
// What `run()` returns is what undoes it — the socket it opened, the hold
|
|
1817
|
+
// it took. The chain executes INSIDE the same ambient scope as the call
|
|
1818
|
+
// that produced it: its bodies are the work `run()` would otherwise have
|
|
1819
|
+
// done inline, so running them outside would silently strip the
|
|
1820
|
+
// cancellation token and trace parent from exactly the code that moved
|
|
1821
|
+
// into a chain.
|
|
1822
|
+
const call = async () => {
|
|
1823
|
+
const returned = await (instance.run as (c?: InvokeContext) => Promise<unknown>)(invokeCtx);
|
|
1824
|
+
await executeReturnedChain(returned, effects);
|
|
1825
|
+
};
|
|
1690
1826
|
await (isService || invokeCtx === ambient ? call() : cancellationStore.run(invokeCtx, call));
|
|
1691
1827
|
// A one-shot Runnable that discovered something during run() publishes it
|
|
1692
1828
|
// without an explicit call; a Service never reaches this until teardown.
|
|
@@ -1694,16 +1830,30 @@ export class EvaluationContext implements IEvaluationContext {
|
|
|
1694
1830
|
await this.publishSnapshot(name);
|
|
1695
1831
|
await this.emit(`${name}.Run`, span("end", "ok", {}));
|
|
1696
1832
|
} catch (err) {
|
|
1833
|
+
// A run that did not complete leaves nothing of its own behind: its frame
|
|
1834
|
+
// unwinds here, so a `listen()` that threw releases the hold it took a
|
|
1835
|
+
// line earlier instead of holding the process open for a server that
|
|
1836
|
+
// never came up.
|
|
1837
|
+
const refused = (await effects?.unwindFrame()) ?? [];
|
|
1838
|
+
const recovery =
|
|
1839
|
+
refused.length > 0
|
|
1840
|
+
? {
|
|
1841
|
+
recoveryFailures: refused.map((f) => ({
|
|
1842
|
+
reason: f.reason,
|
|
1843
|
+
message: errorText(f.error),
|
|
1844
|
+
})),
|
|
1845
|
+
}
|
|
1846
|
+
: {};
|
|
1697
1847
|
if (isCancellationError(err)) {
|
|
1698
1848
|
const reason = err instanceof Error ? err.message : String(err);
|
|
1699
|
-
await this.emit(`${name}.RunCancelled`, span("end", "cancelled", { reason }));
|
|
1849
|
+
await this.emit(`${name}.RunCancelled`, span("end", "cancelled", { reason, ...recovery }));
|
|
1700
1850
|
throw err;
|
|
1701
1851
|
}
|
|
1702
1852
|
const detail =
|
|
1703
1853
|
err instanceof Error
|
|
1704
1854
|
? { name: err.name, message: err.message }
|
|
1705
1855
|
: { name: "UnknownError", message: String(err) };
|
|
1706
|
-
await this.emit(`${name}.RunFailed`, span("end", "failed", detail));
|
|
1856
|
+
await this.emit(`${name}.RunFailed`, span("end", "failed", { ...detail, ...recovery }));
|
|
1707
1857
|
throw err;
|
|
1708
1858
|
}
|
|
1709
1859
|
}
|
|
@@ -2053,6 +2203,11 @@ function locateFailedAccess(
|
|
|
2053
2203
|
return null;
|
|
2054
2204
|
}
|
|
2055
2205
|
|
|
2206
|
+
/** One line for an inverse's refusal, quoted into a recovery aggregate. */
|
|
2207
|
+
function errorText(err: unknown): string {
|
|
2208
|
+
return err instanceof Error ? err.message : String(err);
|
|
2209
|
+
}
|
|
2210
|
+
|
|
2056
2211
|
function describeMissingAccess(value: unknown, key: string): string {
|
|
2057
2212
|
if (value === null) return `cannot read '${key}' — value is null`;
|
|
2058
2213
|
if (value === undefined) return `cannot read '${key}' — value is undefined`;
|
|
@@ -6,11 +6,18 @@ import type { RuntimeDiagnostic } from "@telorun/sdk";
|
|
|
6
6
|
* failure of its own. This is the ONLY signal that an entry may be collapsed —
|
|
7
7
|
* see {@link classifyInitFailures}.
|
|
8
8
|
*/
|
|
9
|
-
const DEPENDENCY_PENDING_CODES = new Set([
|
|
9
|
+
export const DEPENDENCY_PENDING_CODES = new Set([
|
|
10
10
|
"ERR_LOCAL_REF_PENDING",
|
|
11
11
|
"ERR_CROSS_MODULE_REF_PENDING",
|
|
12
12
|
]);
|
|
13
13
|
|
|
14
|
+
/** Whether a thrown error is the loop's own "not your turn yet" signal rather
|
|
15
|
+
* than a failure of the resource. */
|
|
16
|
+
export function isDeferral(err: unknown): boolean {
|
|
17
|
+
const code = (err as { code?: string } | undefined)?.code;
|
|
18
|
+
return code !== undefined && DEPENDENCY_PENDING_CODES.has(code);
|
|
19
|
+
}
|
|
20
|
+
|
|
14
21
|
/** One resource that did not reach the `Initialized` state, with the outbound
|
|
15
22
|
* edges (names of resources in the SAME context) captured for it. */
|
|
16
23
|
export interface FailedResource {
|
package/src/kernel.ts
CHANGED
|
@@ -51,6 +51,7 @@ import { ambientInvokeContext } from "./evaluation-context.js";
|
|
|
51
51
|
import { ModuleContext } from "./module-context.js";
|
|
52
52
|
import { ResourceContextImpl } from "./resource-context.js";
|
|
53
53
|
import { mintResourceHandle } from "./resource-handle.js";
|
|
54
|
+
import { bindEffectOwner } from "./effect-scope.js";
|
|
54
55
|
import { declarationOfInstance, recordInstanceDeclaration } from "./instance-declaration.js";
|
|
55
56
|
import { nodeHostVersions } from "./host-versions.js";
|
|
56
57
|
import { nodeCelHandlers } from "./cel-handlers.js";
|
|
@@ -1017,8 +1018,12 @@ export class Kernel implements IKernel {
|
|
|
1017
1018
|
return this._entryUrl;
|
|
1018
1019
|
}
|
|
1019
1020
|
|
|
1020
|
-
/** The npm install
|
|
1021
|
-
* controller loader so it doesn't re-derive it from the entry URL.
|
|
1021
|
+
/** The npm install BASE for this load (`<cache-root>/npm`), threaded to the
|
|
1022
|
+
* controller loader so it doesn't re-derive it from the entry URL. The base
|
|
1023
|
+
* holds one root per (entry path, host platform); the loader picks which one
|
|
1024
|
+
* this runner uses, so two runners over one workspace — a host and a
|
|
1025
|
+
* bind-mounting container — never share a tree whose `package.json` can only
|
|
1026
|
+
* be true for one of them. */
|
|
1022
1027
|
getInstallRoot(): string | undefined {
|
|
1023
1028
|
return this._cacheRoot ? `${this._cacheRoot}/npm` : undefined;
|
|
1024
1029
|
}
|
|
@@ -1385,7 +1390,17 @@ export class Kernel implements IKernel {
|
|
|
1385
1390
|
try {
|
|
1386
1391
|
this.sharedSchemaValidator
|
|
1387
1392
|
.compile(configSchema)
|
|
1388
|
-
.validate(
|
|
1393
|
+
.validate(
|
|
1394
|
+
stripCompiledValues(resource, configSchema, undefined, (ref) =>
|
|
1395
|
+
// A kind may describe a slot with a shape declared elsewhere. The
|
|
1396
|
+
// strip walk has to see THROUGH that reference or the value under it
|
|
1397
|
+
// reads as undescribed, and every CEL leaf beneath it collapses to
|
|
1398
|
+
// `""` — which the shape then rejects, reporting a violation of a
|
|
1399
|
+
// value the author never wrote. AJV resolves it either way; this is
|
|
1400
|
+
// what lets the placeholder walk agree with it.
|
|
1401
|
+
this.sharedSchemaValidator.getSchema(ref) as Record<string, any> | undefined,
|
|
1402
|
+
),
|
|
1403
|
+
);
|
|
1389
1404
|
} catch (error) {
|
|
1390
1405
|
throw new RuntimeError(
|
|
1391
1406
|
"ERR_RESOURCE_SCHEMA_VALIDATION_FAILED",
|
|
@@ -1453,16 +1468,14 @@ export class Kernel implements IKernel {
|
|
|
1453
1468
|
// parent inputs → controller → parent result → mapping → child result.
|
|
1454
1469
|
this.bindInvocationContract(instance, processedResource, resolvedKind, ctx);
|
|
1455
1470
|
|
|
1456
|
-
//
|
|
1457
|
-
//
|
|
1458
|
-
//
|
|
1459
|
-
//
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
if (originalTeardown) await originalTeardown();
|
|
1465
|
-
};
|
|
1471
|
+
// Record the instance's inverse accumulator here, at the single
|
|
1472
|
+
// instance-production site, for the same reason the handle is minted here:
|
|
1473
|
+
// the teardown cascade holds an instance and nothing else, so this is the
|
|
1474
|
+
// one point where the instance and its context are both in hand. The
|
|
1475
|
+
// detached drain is NOT folded into teardown any more — it waits for
|
|
1476
|
+
// in-flight work rather than undoing anything, so the teardown cascade
|
|
1477
|
+
// calls it directly (see `EvaluationContext.teardownResources`).
|
|
1478
|
+
bindEffectOwner(instance, ctx as ResourceContextImpl);
|
|
1466
1479
|
|
|
1467
1480
|
if (!runtime.length) return { instance, ctx, resource: processedResource };
|
|
1468
1481
|
|
package/src/manifest-schemas.ts
CHANGED
|
@@ -214,12 +214,7 @@ function lazyValidator(schema: object): LazyValidator {
|
|
|
214
214
|
export const validateResourceDefinition = lazyValidator(ResourceDefinitionSchema);
|
|
215
215
|
export const validateResourceAbstract = lazyValidator(ResourceAbstractSchema);
|
|
216
216
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
const p = err.instancePath || "/";
|
|
222
|
-
return `${p} ${err.message ?? "is invalid"}`;
|
|
223
|
-
})
|
|
224
|
-
.join("; ");
|
|
225
|
-
}
|
|
217
|
+
/** Re-exported from the analyzer so a schema failure is phrased identically
|
|
218
|
+
* under `telo check` and at runtime — the kernel's own copy rendered the same
|
|
219
|
+
* failure as a raw `instancePath + message` join and handled no union. */
|
|
220
|
+
export { formatAjvErrors } from "@telorun/analyzer";
|
package/src/module-context.ts
CHANGED
|
@@ -277,6 +277,22 @@ export class ModuleContext extends EvaluationContext implements IModuleContext {
|
|
|
277
277
|
}
|
|
278
278
|
}
|
|
279
279
|
|
|
280
|
+
/**
|
|
281
|
+
* Undo every registration an alias made — the kind gate, the exported-instance
|
|
282
|
+
* scope and the exported-kind resolver — as ONE operation, because they are
|
|
283
|
+
* one act: an import controller registers all four in `create()` and an alias
|
|
284
|
+
* left half-registered would resolve kinds through a module whose instances
|
|
285
|
+
* are gone. This is the inverse of the create-frame effect that registers
|
|
286
|
+
* them, so an import whose instance is discarded does not leave its alias
|
|
287
|
+
* behind for the retry to collide with.
|
|
288
|
+
*/
|
|
289
|
+
unregisterImport(alias: string): void {
|
|
290
|
+
this.importAliases.delete(alias);
|
|
291
|
+
this.importedKinds.delete(alias);
|
|
292
|
+
this.importedScopes.delete(alias);
|
|
293
|
+
this.importedKindResolvers.delete(alias);
|
|
294
|
+
}
|
|
295
|
+
|
|
280
296
|
/**
|
|
281
297
|
* Register an alias that crosses no import boundary and is therefore never gated:
|
|
282
298
|
* `Self` (a library resolving its own kinds — `exports.kinds` gates importers, not
|
package/src/observed-state.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { registerTeloKeywords } from "@telorun/analyzer";
|
|
1
|
+
import { formatAjvErrors, registerTeloKeywords } from "@telorun/analyzer";
|
|
2
2
|
import AjvModule from "ajv";
|
|
3
3
|
import { detachSnapshotValue, OBSERVED_STATE_KEY, RuntimeError } from "@telorun/sdk";
|
|
4
4
|
|
|
@@ -96,9 +96,7 @@ export function acceptReportedStatus(
|
|
|
96
96
|
}
|
|
97
97
|
const validate = validatorFor(opts.statusSchema);
|
|
98
98
|
if (!validate(status)) {
|
|
99
|
-
const detail = (validate.errors
|
|
100
|
-
.map((e) => `${e.instancePath || "/"} ${e.message ?? "is invalid"}`)
|
|
101
|
-
.join("; ");
|
|
99
|
+
const detail = formatAjvErrors(validate.errors);
|
|
102
100
|
throw new RuntimeError(
|
|
103
101
|
"ERR_OBSERVED_STATE_INVALID",
|
|
104
102
|
`${opts.kind} '${opts.name}' reported observed state that does not match its declared 'status:': ${detail}`,
|
package/src/resource-context.ts
CHANGED
|
@@ -28,7 +28,10 @@ import {
|
|
|
28
28
|
type TypeRule,
|
|
29
29
|
type OpenZoneAttributes,
|
|
30
30
|
type ZoneEntry,
|
|
31
|
+
type EffectBody,
|
|
32
|
+
type EffectChain,
|
|
31
33
|
} from "@telorun/sdk";
|
|
34
|
+
import { EffectScope } from "./effect-scope.js";
|
|
32
35
|
import { registerTeloKeywords } from "@telorun/analyzer";
|
|
33
36
|
import { isRefSentinel } from "@telorun/templating";
|
|
34
37
|
import { ZoneContext } from "./zone-context.js";
|
|
@@ -829,6 +832,35 @@ export class ResourceContextImpl implements ResourceContext {
|
|
|
829
832
|
await this.kernel.emitRuntimeEvent(`${this.metadata.name}.${event}`, payload);
|
|
830
833
|
}
|
|
831
834
|
|
|
835
|
+
/**
|
|
836
|
+
* This resource's inverse accumulator. Opened with the context — `create()`
|
|
837
|
+
* is where template controllers and imports allocate, so an effect performed
|
|
838
|
+
* there is on the chain alongside `init()` and `run()`.
|
|
839
|
+
*/
|
|
840
|
+
#effects: EffectScope | undefined;
|
|
841
|
+
|
|
842
|
+
get effects(): EffectScope {
|
|
843
|
+
return (this.#effects ??= new EffectScope(String(this.metadata?.name ?? "<unnamed>")));
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
effect<T>(reason: string, body: EffectBody<void, T>): EffectChain<T> {
|
|
847
|
+
return this.effects.chain(reason, body);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* Hold the kernel open; the returned closure releases it.
|
|
852
|
+
*
|
|
853
|
+
* Deliberately NOT registered as an effect here. A hold IS an effect — it is
|
|
854
|
+
* an action with an inverse — but which FRAME owns it is a fact only the
|
|
855
|
+
* caller has: `run()`'s hold belongs to the run frame, a hold taken per
|
|
856
|
+
* durable run inside `invoke()` belongs to that operation and is disposed when
|
|
857
|
+
* the run settles. Registering here would put every hold on whichever frame
|
|
858
|
+
* happened to be open, and the four call sites that already wrap it in an
|
|
859
|
+
* effect would register the same release twice.
|
|
860
|
+
*
|
|
861
|
+
* So this returns the raw inverse, and a caller places it:
|
|
862
|
+
* `ctx.effect("kernel hold", async () => ({ result: undefined, inverse: ctx.acquireHold() }))`.
|
|
863
|
+
*/
|
|
832
864
|
acquireHold(reason?: string): () => void {
|
|
833
865
|
return this.kernel.acquireHold(reason);
|
|
834
866
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { isCompiledValue } from "@telorun/sdk";
|
|
2
|
-
import { selectUnionBranch } from "@telorun/analyzer";
|
|
2
|
+
import { type ExternalSchemaResolver, resolveRefIn, selectUnionBranch } from "@telorun/analyzer";
|
|
3
3
|
|
|
4
4
|
/** Returns a schema-appropriate placeholder value for a CompiledValue field. */
|
|
5
5
|
function placeholderForSchema(schema: Record<string, unknown>): unknown {
|
|
@@ -38,22 +38,25 @@ function placeholderForSchema(schema: Record<string, unknown>): unknown {
|
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* Resolve a `$ref` — document-local against `root`, everything else through the
|
|
43
|
+
* schema store when the caller supplies one.
|
|
44
|
+
*
|
|
45
|
+
* Without the store a named shape (`telo:<module>/<Type>`) stops the walk: the
|
|
46
|
+
* node reads as undescribed, so every CEL leaf beneath it collapses to `""` and
|
|
47
|
+
* is then rejected by the very schema that describes it. A kind referencing a
|
|
48
|
+
* shape declared elsewhere is exactly the case, and the failure surfaces as a
|
|
49
|
+
* violation of a value the author never wrote.
|
|
50
|
+
*/
|
|
42
51
|
function resolveSchemaRef(
|
|
43
52
|
schema: Record<string, unknown>,
|
|
44
53
|
root: Record<string, unknown>,
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const defName = (schema.$ref as string).slice("#/$defs/".length);
|
|
52
|
-
const defs = root.$defs as Record<string, Record<string, unknown>> | undefined;
|
|
53
|
-
const resolved = defs?.[defName];
|
|
54
|
-
if (resolved) return resolved;
|
|
55
|
-
}
|
|
56
|
-
return schema;
|
|
54
|
+
external?: ExternalSchemaResolver,
|
|
55
|
+
): { schema: Record<string, unknown>; root: Record<string, unknown> } {
|
|
56
|
+
return resolveRefIn(schema as Record<string, any>, root as Record<string, any>, external) as {
|
|
57
|
+
schema: Record<string, unknown>;
|
|
58
|
+
root: Record<string, unknown>;
|
|
59
|
+
};
|
|
57
60
|
}
|
|
58
61
|
|
|
59
62
|
/** Collect property schemas from top-level `properties` and all `oneOf`/`anyOf` sub-schemas. */
|
|
@@ -121,13 +124,18 @@ export function stripCompiledValues(
|
|
|
121
124
|
v: unknown,
|
|
122
125
|
schema: Record<string, unknown> = {},
|
|
123
126
|
rootSchema?: Record<string, unknown>,
|
|
127
|
+
external?: ExternalSchemaResolver,
|
|
124
128
|
): unknown {
|
|
125
129
|
const root = rootSchema ?? schema;
|
|
126
130
|
// Ancestors on the current path, so a genuine cycle stops while a sub-object
|
|
127
131
|
// that merely appears twice is still stripped both times.
|
|
128
132
|
const ancestors = new Set<object>();
|
|
129
133
|
|
|
130
|
-
const walk = (
|
|
134
|
+
const walk = (
|
|
135
|
+
value: unknown,
|
|
136
|
+
rawNodeSchema: Record<string, unknown>,
|
|
137
|
+
base: Record<string, unknown> = root,
|
|
138
|
+
): unknown => {
|
|
131
139
|
// A UNION carries no `type` / `items` / `properties` of its own, so descending
|
|
132
140
|
// through one hands every CEL leaf underneath the schema-unaware `""`
|
|
133
141
|
// placeholder — which the branches then reject, reporting violations against
|
|
@@ -136,12 +144,16 @@ export function stripCompiledValues(
|
|
|
136
144
|
// placed. Shared with the analyzer rather than reimplemented: the static and
|
|
137
145
|
// dispatch halves must choose the same branch, or one reports what the other
|
|
138
146
|
// accepts.
|
|
147
|
+
const entered = resolveSchemaRef(rawNodeSchema, base, external);
|
|
139
148
|
const nodeSchema = selectUnionBranch(
|
|
140
|
-
|
|
149
|
+
entered.schema,
|
|
141
150
|
value,
|
|
142
|
-
root as Record<string, any>,
|
|
151
|
+
entered.root as Record<string, any>,
|
|
152
|
+
external,
|
|
143
153
|
) as Record<string, unknown>;
|
|
144
|
-
const
|
|
154
|
+
const here = resolveSchemaRef(nodeSchema, entered.root, external);
|
|
155
|
+
const resolved = here.schema;
|
|
156
|
+
const nodeRoot = here.root;
|
|
145
157
|
|
|
146
158
|
if (isCompiledValue(value)) return placeholderForSchema(resolved);
|
|
147
159
|
// A slot the schema declares as a reference is never config when it HOLDS a
|
|
@@ -153,8 +165,10 @@ export function stripCompiledValues(
|
|
|
153
165
|
if (resolved["x-telo-ref"] !== undefined && !isConfigAtRefSlot(value)) return value;
|
|
154
166
|
|
|
155
167
|
if (Array.isArray(value)) {
|
|
156
|
-
const
|
|
157
|
-
return walkGuarded(value, () =>
|
|
168
|
+
const item = resolveSchemaRef((resolved.items ?? {}) as Record<string, unknown>, nodeRoot, external);
|
|
169
|
+
return walkGuarded(value, () =>
|
|
170
|
+
value.map((element) => walk(element, item.schema, item.root)),
|
|
171
|
+
);
|
|
158
172
|
}
|
|
159
173
|
if (value !== null && typeof value === "object") {
|
|
160
174
|
// A class instance (a client, a pool, a stream) carries no CompiledValues
|
|
@@ -166,7 +180,7 @@ export function stripCompiledValues(
|
|
|
166
180
|
return walkGuarded(value, () => {
|
|
167
181
|
const out: Record<string, unknown> = {};
|
|
168
182
|
for (const [k, val] of Object.entries(value as Record<string, unknown>)) {
|
|
169
|
-
out[k] = walk(val, props[k] ?? {});
|
|
183
|
+
out[k] = walk(val, props[k] ?? {}, nodeRoot);
|
|
170
184
|
}
|
|
171
185
|
return out;
|
|
172
186
|
});
|