@intentius/chant 0.50.0 → 0.51.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/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/op-progress.d.ts +57 -0
- package/dist/cli/handlers/op-progress.d.ts.map +1 -0
- package/dist/cli/handlers/run-client.d.ts +21 -1
- package/dist/cli/handlers/run-client.d.ts.map +1 -1
- package/dist/cli/handlers/run-report.d.ts.map +1 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/handlers/search.d.ts +22 -0
- package/dist/cli/handlers/search.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/op-tools.d.ts.map +1 -1
- package/dist/cli/mcp/resource-handlers.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +33 -1
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/run-progress.d.ts +7 -5
- package/dist/components/run-progress.d.ts.map +1 -1
- package/dist/lexicon.d.ts +41 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/assert-live.d.ts +77 -0
- package/dist/lifecycle/assert-live.d.ts.map +1 -0
- package/dist/lifecycle/change-set.d.ts +17 -0
- package/dist/lifecycle/change-set.d.ts.map +1 -1
- package/dist/lifecycle/disruption.d.ts +96 -0
- package/dist/lifecycle/disruption.d.ts.map +1 -0
- package/dist/lifecycle/index.d.ts +2 -0
- package/dist/lifecycle/index.d.ts.map +1 -1
- package/dist/lifecycle/replay.d.ts +2 -0
- package/dist/lifecycle/replay.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts +7 -1
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/testing.d.ts +23 -2
- package/dist/testing.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/handlers/lifecycle.test.ts +90 -0
- package/src/cli/handlers/lifecycle.ts +18 -1
- package/src/cli/handlers/op-progress.test.ts +202 -0
- package/src/cli/handlers/op-progress.ts +192 -0
- package/src/cli/handlers/run-client.test.ts +82 -0
- package/src/cli/handlers/run-client.ts +85 -2
- package/src/cli/handlers/run-report.test.ts +62 -0
- package/src/cli/handlers/run-report.ts +20 -58
- package/src/cli/handlers/run.test.ts +144 -0
- package/src/cli/handlers/run.ts +40 -18
- package/src/cli/handlers/search-drift.test.ts +263 -0
- package/src/cli/handlers/search.ts +150 -1
- package/src/cli/main.ts +9 -0
- package/src/cli/mcp/op-tools.ts +17 -6
- package/src/cli/mcp/resource-handlers.ts +13 -5
- package/src/cli/registry.ts +33 -1
- package/src/components/run-progress.ts +9 -5
- package/src/lexicon.ts +51 -0
- package/src/lifecycle/assert-live.test.ts +125 -0
- package/src/lifecycle/assert-live.ts +154 -0
- package/src/lifecycle/change-set.ts +35 -3
- package/src/lifecycle/disruption.test.ts +186 -0
- package/src/lifecycle/disruption.ts +224 -0
- package/src/lifecycle/index.ts +2 -0
- package/src/lifecycle/replay.test.ts +25 -0
- package/src/lifecycle/replay.ts +11 -3
- package/src/op/local-executor.ts +7 -1
- package/src/op/local-output.ts +1 -1
- package/src/testing.test.ts +89 -2
- package/src/testing.ts +63 -3
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-change disruption classification (#1665).
|
|
3
|
+
*
|
|
4
|
+
* The change set says WHAT a pending change is (`create`/`update`/`delete`/…).
|
|
5
|
+
* It says nothing about what applying it costs. An `update` that flips a tag
|
|
6
|
+
* and an `update` that rebuilds a database read identically, and the second one
|
|
7
|
+
* is the one that wakes somebody up.
|
|
8
|
+
*
|
|
9
|
+
* The knowledge that separates them is spec knowledge. CloudFormation's
|
|
10
|
+
* registry schema declares `createOnlyProperties` per type; Kubernetes' SSA
|
|
11
|
+
* schema knows which field changes roll a workload. Core owns neither, and
|
|
12
|
+
* hardcoding either here would put per-provider replacement rules in the tool
|
|
13
|
+
* — the same mistake `postSynthChecks` exists to avoid. So core defines the
|
|
14
|
+
* contract and the reporting, and the lexicon that compiled the spec supplies
|
|
15
|
+
* the answer, via {@link LexiconPlugin.classifyDisruption}.
|
|
16
|
+
*
|
|
17
|
+
* The invariant that makes the field trustworthy is that `unknown` is the
|
|
18
|
+
* default and the only fallback. No classifier, a classifier that says nothing
|
|
19
|
+
* about an entry, a classifier that throws, a classifier that returns a level
|
|
20
|
+
* outside the vocabulary — all of them land on `unknown`, never on `in-place`.
|
|
21
|
+
* A confident "this mutates in place" is only ever a lexicon's own claim.
|
|
22
|
+
*/
|
|
23
|
+
import type { AttributeChange } from "./live-diff.js";
|
|
24
|
+
import type { ChangeSet, ChangeSetEntry } from "./change-set.js";
|
|
25
|
+
/**
|
|
26
|
+
* How much applying one pending change hurts.
|
|
27
|
+
*
|
|
28
|
+
* - `in-place` — the provider mutates the existing resource. No new identity,
|
|
29
|
+
* no window where it is absent.
|
|
30
|
+
* - `rolling` — the resource survives, but its workload is replaced
|
|
31
|
+
* incrementally (a Deployment's pod template changing). Disruptive to what
|
|
32
|
+
* is running, not to the resource.
|
|
33
|
+
* - `replace` — a new resource is created and the old one removed. The
|
|
34
|
+
* physical id changes; anything holding the old one has to be updated.
|
|
35
|
+
* - `destroy` — replacement that removes the old resource FIRST. There is a
|
|
36
|
+
* window with nothing there, and whatever the old one held is gone.
|
|
37
|
+
* - `unknown` — nobody could say. The honest value, and the default: it is
|
|
38
|
+
* what a change gets when no lexicon classifies it, and it must never be
|
|
39
|
+
* read as "probably fine".
|
|
40
|
+
*/
|
|
41
|
+
export type Disruption = "in-place" | "rolling" | "replace" | "destroy" | "unknown";
|
|
42
|
+
/** Every level, most disruptive last — also the guard core validates a lexicon's answer against. */
|
|
43
|
+
export declare const DISRUPTION_LEVELS: readonly Disruption[];
|
|
44
|
+
/** One pending change put to a lexicon for classification. */
|
|
45
|
+
export interface DisruptionQuery {
|
|
46
|
+
/** The change set entry's `name` — the key a verdict comes back under. */
|
|
47
|
+
name: string;
|
|
48
|
+
/** Resource type, when the observation reported one. */
|
|
49
|
+
type?: string;
|
|
50
|
+
/** The attribute-level changes the entry carries. */
|
|
51
|
+
deltas: AttributeChange[];
|
|
52
|
+
}
|
|
53
|
+
/** A lexicon's answer for one query. */
|
|
54
|
+
export interface DisruptionVerdict {
|
|
55
|
+
disruption: Disruption;
|
|
56
|
+
/** The attribute paths that forced the verdict — empty or absent when none did. */
|
|
57
|
+
because?: string[];
|
|
58
|
+
/** One line of human-readable backing, naming the spec knowledge behind the call. */
|
|
59
|
+
detail?: string;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The shape of {@link LexiconPlugin.classifyDisruption}. Keyed by query `name`;
|
|
63
|
+
* a name the lexicon says nothing about degrades to `unknown`, so a partial
|
|
64
|
+
* answer is a valid answer.
|
|
65
|
+
*/
|
|
66
|
+
export type DisruptionClassifier = (options: {
|
|
67
|
+
environment: string;
|
|
68
|
+
changes: DisruptionQuery[];
|
|
69
|
+
}) => Record<string, DisruptionVerdict> | Promise<Record<string, DisruptionVerdict>>;
|
|
70
|
+
/** The verdict every fallback path produces. */
|
|
71
|
+
export declare function unknownDisruption(detail: string): DisruptionVerdict;
|
|
72
|
+
/**
|
|
73
|
+
* Annotate one lexicon's change set with a disruption verdict per `update`.
|
|
74
|
+
*
|
|
75
|
+
* Only `update` entries are asked about: every other action already carries its
|
|
76
|
+
* blast radius in the action itself. Called once per lexicon, before the plan
|
|
77
|
+
* merges the change sets, so `classify` is always the lexicon that produced the
|
|
78
|
+
* entries — the only party that can map its own observation's attribute paths
|
|
79
|
+
* back onto spec properties.
|
|
80
|
+
*
|
|
81
|
+
* Returns a new change set; the input is not mutated.
|
|
82
|
+
*/
|
|
83
|
+
export declare function annotateDisruption(cs: ChangeSet, environment: string, classify?: DisruptionClassifier): Promise<ChangeSet>;
|
|
84
|
+
/** Count `update` entries per level. Entries with no verdict at all are not counted. */
|
|
85
|
+
export declare function summarizeDisruption(cs: ChangeSet): Record<Disruption, number>;
|
|
86
|
+
/** The most disruptive verdict in the set, or undefined when nothing was classified. */
|
|
87
|
+
export declare function worstDisruption(cs: ChangeSet): Disruption | undefined;
|
|
88
|
+
/**
|
|
89
|
+
* Warnings a plan should print on stderr — so a `--json` or `--report gitlab-mr`
|
|
90
|
+
* consumer, whose shape has no column for disruption, still hears about the
|
|
91
|
+
* expensive rows. Same discipline as the unobserved warning (#1089).
|
|
92
|
+
*/
|
|
93
|
+
export declare function disruptionNotices(cs: ChangeSet): string[];
|
|
94
|
+
/** Render one entry's verdict for the human plan, or "" when there is none. */
|
|
95
|
+
export declare function renderDisruption(entry: ChangeSetEntry): string;
|
|
96
|
+
//# sourceMappingURL=disruption.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"disruption.d.ts","sourceRoot":"","sources":["../../src/lifecycle/disruption.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AACnD,OAAO,KAAK,EAAE,SAAS,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAE9D;;;;;;;;;;;;;;;GAeG;AACH,MAAM,MAAM,UAAU,GAAG,UAAU,GAAG,SAAS,GAAG,SAAS,GAAG,SAAS,GAAG,SAAS,CAAC;AAEpF,oGAAoG;AACpG,eAAO,MAAM,iBAAiB,EAAE,SAAS,UAAU,EAMlD,CAAC;AAWF,8DAA8D;AAC9D,MAAM,WAAW,eAAe;IAC9B,0EAA0E;IAC1E,IAAI,EAAE,MAAM,CAAC;IACb,wDAAwD;IACxD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,qDAAqD;IACrD,MAAM,EAAE,eAAe,EAAE,CAAC;CAC3B;AAED,wCAAwC;AACxC,MAAM,WAAW,iBAAiB;IAChC,UAAU,EAAE,UAAU,CAAC;IACvB,mFAAmF;IACnF,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;IACnB,qFAAqF;IACrF,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;;GAIG;AACH,MAAM,MAAM,oBAAoB,GAAG,CAAC,OAAO,EAAE;IAC3C,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,eAAe,EAAE,CAAC;CAC5B,KAAK,MAAM,CAAC,MAAM,EAAE,iBAAiB,CAAC,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,iBAAiB,CAAC,CAAC,CAAC;AAErF,gDAAgD;AAChD,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,MAAM,GAAG,iBAAiB,CAEnE;AAED;;;;;;;;;;GAUG;AACH,wBAAsB,kBAAkB,CACtC,EAAE,EAAE,SAAS,EACb,WAAW,EAAE,MAAM,EACnB,QAAQ,CAAC,EAAE,oBAAoB,GAC9B,OAAO,CAAC,SAAS,CAAC,CA0CpB;AAiBD,wFAAwF;AACxF,wBAAgB,mBAAmB,CAAC,EAAE,EAAE,SAAS,GAAG,MAAM,CAAC,UAAU,EAAE,MAAM,CAAC,CAY7E;AAED,wFAAwF;AACxF,wBAAgB,eAAe,CAAC,EAAE,EAAE,SAAS,GAAG,UAAU,GAAG,SAAS,CAOrE;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,EAAE,EAAE,SAAS,GAAG,MAAM,EAAE,CAkBzD;AAED,+EAA+E;AAC/E,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,cAAc,GAAG,MAAM,CAG9D"}
|
|
@@ -7,6 +7,7 @@ export * from "./deep-diff.js";
|
|
|
7
7
|
export * from "./deep-observe.js";
|
|
8
8
|
export * from "./observation-baseline.js";
|
|
9
9
|
export * from "./change-set.js";
|
|
10
|
+
export * from "./disruption.js";
|
|
10
11
|
export * from "./unobserved-gate.js";
|
|
11
12
|
export * from "./receipt-plan.js";
|
|
12
13
|
export * from "./affected.js";
|
|
@@ -16,6 +17,7 @@ export * from "./build-ledger-store.js";
|
|
|
16
17
|
export * from "./oras-referrer-lookup.js";
|
|
17
18
|
export * from "./status.js";
|
|
18
19
|
export * from "./teardown.js";
|
|
20
|
+
export * from "./assert-live.js";
|
|
19
21
|
export * from "./symptoms.js";
|
|
20
22
|
export * from "./converge-ledger.js";
|
|
21
23
|
export * from "./scenario.js";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/lifecycle/index.ts"],"names":[],"mappings":"AAAA,cAAc,SAAS,CAAC;AACxB,cAAc,OAAO,CAAC;AACtB,cAAc,UAAU,CAAC;AACzB,cAAc,YAAY,CAAC;AAC3B,cAAc,aAAa,CAAC;AAC5B,cAAc,aAAa,CAAC;AAC5B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,wBAAwB,CAAC;AACvC,cAAc,cAAc,CAAC;AAC7B,cAAc,mBAAmB,CAAC;AAClC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,YAAY,CAAC;AAC3B,cAAc,kBAAkB,CAAC;AACjC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,sBAAsB,CAAC;AACrC,cAAc,wBAAwB,CAAC;AACvC,cAAc,UAAU,CAAC;AACzB,cAAc,YAAY,CAAC;AAC3B,cAAc,YAAY,CAAC;AAC3B,cAAc,mBAAmB,CAAC;AAClC,cAAc,YAAY,CAAC;AAC3B,cAAc,iBAAiB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/lifecycle/index.ts"],"names":[],"mappings":"AAAA,cAAc,SAAS,CAAC;AACxB,cAAc,OAAO,CAAC;AACtB,cAAc,UAAU,CAAC;AACzB,cAAc,YAAY,CAAC;AAC3B,cAAc,aAAa,CAAC;AAC5B,cAAc,aAAa,CAAC;AAC5B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,wBAAwB,CAAC;AACvC,cAAc,cAAc,CAAC;AAC7B,cAAc,cAAc,CAAC;AAC7B,cAAc,mBAAmB,CAAC;AAClC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,YAAY,CAAC;AAC3B,cAAc,kBAAkB,CAAC;AACjC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,sBAAsB,CAAC;AACrC,cAAc,wBAAwB,CAAC;AACvC,cAAc,UAAU,CAAC;AACzB,cAAc,YAAY,CAAC;AAC3B,cAAc,eAAe,CAAC;AAC9B,cAAc,YAAY,CAAC;AAC3B,cAAc,mBAAmB,CAAC;AAClC,cAAc,YAAY,CAAC;AAC3B,cAAc,iBAAiB,CAAC"}
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* answer most questions but never all of them.
|
|
15
15
|
*/
|
|
16
16
|
import type { LiveObservation } from "../graph-ir.js";
|
|
17
|
+
import type { ObservationDepth } from "./types.js";
|
|
17
18
|
/**
|
|
18
19
|
* Rebuild observations from a recorded snapshot (#1266).
|
|
19
20
|
*
|
|
@@ -40,6 +41,7 @@ export declare function replaySnapshots(environment: string, ref: string, scoped
|
|
|
40
41
|
observations: LiveObservation[];
|
|
41
42
|
commit: string;
|
|
42
43
|
timestamp: string;
|
|
44
|
+
depth: ObservationDepth;
|
|
43
45
|
} | {
|
|
44
46
|
error: string;
|
|
45
47
|
hint?: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"replay.d.ts","sourceRoot":"","sources":["../../src/lifecycle/replay.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAIH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"replay.d.ts","sourceRoot":"","sources":["../../src/lifecycle/replay.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAIH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AACnD,OAAO,KAAK,EAAqB,gBAAgB,EAAE,MAAM,SAAS,CAAC;AAEnE;;;;;;;;;;;;GAYG;AACH;;;;;;;GAOG;AACH,wBAAsB,WAAW,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAMvE;AAED,wBAAsB,eAAe,CACnC,WAAW,EAAE,MAAM,EACnB,GAAG,EAAE,MAAM,EACX,YAAY,EAAE,GAAG,CAAC,MAAM,CAAC,GACxB,OAAO,CACN;IAAE,YAAY,EAAE,eAAe,EAAE,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,gBAAgB,CAAA;CAAE,GAC/F;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,CACnC,CA8IA"}
|
|
@@ -15,7 +15,13 @@ import { type ActivityFn, type ActivityProfile } from "./activity-registry.js";
|
|
|
15
15
|
export interface StepRecord {
|
|
16
16
|
phase: string;
|
|
17
17
|
fn: string;
|
|
18
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Present for a local-executor record. Absent for one reconstructed from
|
|
20
|
+
* Temporal workflow history (op-progress.ts) — an activity's scheduled
|
|
21
|
+
* input isn't decoded there, so the field is simply omitted rather than
|
|
22
|
+
* populated with a guess.
|
|
23
|
+
*/
|
|
24
|
+
args?: Record<string, unknown>;
|
|
19
25
|
status: "ok" | "fail" | "skipped";
|
|
20
26
|
durationMs: number;
|
|
21
27
|
outcome?: {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"local-executor.d.ts","sourceRoot":"","sources":["../../src/op/local-executor.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAE,QAAQ,EAAiC,QAAQ,EAA8B,MAAM,SAAS,CAAC;AAC7G,OAAO,EAAmB,KAAK,UAAU,EAAE,KAAK,eAAe,EAAE,MAAM,qBAAqB,CAAC;AAM7F,MAAM,WAAW,UAAU;IACzB,KAAK,EAAE,MAAM,CAAC;IACd,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;
|
|
1
|
+
{"version":3,"file":"local-executor.d.ts","sourceRoot":"","sources":["../../src/op/local-executor.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAE,QAAQ,EAAiC,QAAQ,EAA8B,MAAM,SAAS,CAAC;AAC7G,OAAO,EAAmB,KAAK,UAAU,EAAE,KAAK,eAAe,EAAE,MAAM,qBAAqB,CAAC;AAM7F,MAAM,WAAW,UAAU;IACzB,KAAK,EAAE,MAAM,CAAC;IACd,EAAE,EAAE,MAAM,CAAC;IACX;;;;;OAKG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC/B,MAAM,EAAE,IAAI,GAAG,MAAM,GAAG,SAAS,CAAC;IAClC,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,OAAO,CAAA;KAAE,CAAC;IAC3C,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,MAAM,CAAC;IACX,OAAO,EAAE,UAAU,EAAE,CAAC;IACtB,OAAO,EAAE,MAAM,CAAC;IAChB,EAAE,EAAE,OAAO,CAAC;CACb;AAID,kFAAkF;AAClF,qBAAa,yBAA0B,SAAQ,KAAK;aACtB,UAAU,EAAE,MAAM;gBAAlB,UAAU,EAAE,MAAM;CAO/C;AAED,mFAAmF;AACnF,qBAAa,YAAa,SAAQ,KAAK;aACT,MAAM,EAAE,WAAW;gBAAnB,MAAM,EAAE,WAAW;CAIhD;AAmBD,8EAA8E;AAC9E,wBAAgB,aAAa,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAU/C;AA8BD;gDACgD;AAChD,wBAAgB,QAAQ,CAAC,MAAM,EAAE,QAAQ,GAAG,QAAQ,GAAG,SAAS,CAY/D;AAiSD;;;;;GAKG;AACH,wBAAsB,YAAY,CAChC,MAAM,EAAE,QAAQ,EAChB,UAAU,EAAE,GAAG,CAAC,MAAM,EAAE,UAAU,CAAC,EACnC,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,eAAe,CAAC,EACzC,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,WAAW,CAAC,CAgDtB"}
|
package/dist/testing.d.ts
CHANGED
|
@@ -15,6 +15,12 @@
|
|
|
15
15
|
* in-process for exactly this suite's environment. Stateless by design:
|
|
16
16
|
* a crashed suite's environment is recovered by calling destroy again (or
|
|
17
17
|
* `chant lifecycle teardown <env> --yes`).
|
|
18
|
+
* - **assertLive** (#1857) — `describeResources()` against exactly one
|
|
19
|
+
* declared entity, turned into a pass or a thrown failure. Preserves
|
|
20
|
+
* #1089's tri-state rather than collapsing it: NOT-OBSERVED throws {@link
|
|
21
|
+
* UnobservedAssertionError}, never a silent pass and never an ordinary
|
|
22
|
+
* failure, because chant could not read the entity is not the same claim
|
|
23
|
+
* as chant read it and it is gone.
|
|
18
24
|
*
|
|
19
25
|
* ## Isolation
|
|
20
26
|
*
|
|
@@ -39,12 +45,14 @@
|
|
|
39
45
|
* exported, and a CI job pointing the identical suite at a real account just
|
|
40
46
|
* leaves both unset.
|
|
41
47
|
*/
|
|
42
|
-
import type
|
|
48
|
+
import { type Declarable } from "./declarable.js";
|
|
43
49
|
import type { SerializerResult } from "./serializer.js";
|
|
44
50
|
import { type BuildParamValue } from "./build-params.js";
|
|
45
|
-
import type { LexiconPlugin } from "./lexicon.js";
|
|
51
|
+
import type { LexiconPlugin, ResourceMetadata } from "./lexicon.js";
|
|
46
52
|
import { type ActivityFn, type ActivityProfile } from "./op/activity-registry.js";
|
|
47
53
|
import { type TeardownReport } from "./lifecycle/teardown.js";
|
|
54
|
+
import { LiveAssertionError, UnobservedAssertionError, type AssertLiveOptions } from "./lifecycle/assert-live.js";
|
|
55
|
+
export { LiveAssertionError, UnobservedAssertionError, type AssertLiveOptions };
|
|
48
56
|
export interface DeployStackOptions {
|
|
49
57
|
/** The chant project directory (where the infra source lives). */
|
|
50
58
|
dir: string;
|
|
@@ -86,6 +94,19 @@ export interface DeployedStack {
|
|
|
86
94
|
entities: Map<string, Declarable>;
|
|
87
95
|
/** The environment this deploy targeted — the teardown key. */
|
|
88
96
|
env: string;
|
|
97
|
+
/**
|
|
98
|
+
* Assert that a declared entity is live: observed present in this
|
|
99
|
+
* deploy's environment, not confirmed as another stack/env's resource,
|
|
100
|
+
* and — when `status` is given — reporting that status.
|
|
101
|
+
*
|
|
102
|
+
* Rides the observation contract (#1089): an entity `describeResources`
|
|
103
|
+
* could not cover throws {@link UnobservedAssertionError}, never a pass
|
|
104
|
+
* and never a plain failure — NOT-OBSERVED is not the same claim as
|
|
105
|
+
* absent. Observed-absent, a confirmed-foreign identity, or a status
|
|
106
|
+
* mismatch throws {@link LiveAssertionError}. Resolves to the entity's
|
|
107
|
+
* `ResourceMetadata` on success.
|
|
108
|
+
*/
|
|
109
|
+
assertLive(name: string, options?: AssertLiveOptions): Promise<ResourceMetadata>;
|
|
89
110
|
/**
|
|
90
111
|
* Tear down everything carrying this suite's marker `{ stack, env }`.
|
|
91
112
|
* Throws {@link TeardownIncompleteError} when any candidate failed to
|
package/dist/testing.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"testing.d.ts","sourceRoot":"","sources":["../src/testing.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"testing.d.ts","sourceRoot":"","sources":["../src/testing.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8CG;AAMH,OAAO,EAAwB,KAAK,UAAU,EAAE,MAAM,cAAc,CAAC;AACrE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAMrD,OAAO,EAAsB,KAAK,eAAe,EAAE,MAAM,gBAAgB,CAAC;AAI1E,OAAO,KAAK,EAAE,aAAa,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAGjE,OAAO,EAAgC,KAAK,UAAU,EAAE,KAAK,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAE7G,OAAO,EAAmB,KAAK,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAC5E,OAAO,EAEL,kBAAkB,EAClB,wBAAwB,EACxB,KAAK,iBAAiB,EACvB,MAAM,yBAAyB,CAAC;AAEjC,OAAO,EAAE,kBAAkB,EAAE,wBAAwB,EAAE,KAAK,iBAAiB,EAAE,CAAC;AAgBhF,MAAM,WAAW,kBAAkB;IACjC,kEAAkE;IAClE,GAAG,EAAE,MAAM,CAAC;IACZ;;;;;OAKG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC;IACb;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,iFAAiF;IACjF,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,eAAe,CAAC,CAAC;IACzC;;;OAGG;IACH,OAAO,CAAC,EAAE,aAAa,EAAE,CAAC;IAC1B,6EAA6E;IAC7E,UAAU,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC;IACrC,oFAAoF;IACpF,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,eAAe,CAAC,CAAC;IAC3C;;;;OAIG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACvC;AAED,mEAAmE;AACnE,MAAM,WAAW,aAAa;IAC5B,+DAA+D;IAC/D,OAAO,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,GAAG,gBAAgB,CAAC,CAAC;IAChD,8CAA8C;IAC9C,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC;IAClC,+DAA+D;IAC/D,GAAG,EAAE,MAAM,CAAC;IACZ;;;;;;;;;;;OAWG;IACH,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,iBAAiB,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAAC;IACjF;;;;;;;OAOG;IACH,OAAO,IAAI,OAAO,CAAC,cAAc,CAAC,CAAC;CACpC;AAED;;;;;;GAMG;AACH,qBAAa,uBAAwB,SAAQ,KAAK;aACpB,MAAM,EAAE,cAAc;gBAAtB,MAAM,EAAE,cAAc;CAkBnD;AAED;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAUjD;AA8BD;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAsB,WAAW,CAAC,OAAO,EAAE,kBAAkB,GAAG,OAAO,CAAC,aAAa,CAAC,CAmJrF"}
|
package/package.json
CHANGED
|
@@ -821,6 +821,96 @@ describe("runLifecyclePlan", () => {
|
|
|
821
821
|
expect(byName["sg-0abc123"].name).toBe("sg-0abc123");
|
|
822
822
|
});
|
|
823
823
|
|
|
824
|
+
// #1665 — per-change disruption. Core defines the contract and reports it;
|
|
825
|
+
// the lexicon that owns the spec answers, and every degradation is `unknown`.
|
|
826
|
+
describe("disruption classification (#1665)", () => {
|
|
827
|
+
const drifted = (lexicon: string, type: string) => {
|
|
828
|
+
readSnapshotMock.mockResolvedValue(JSON.stringify({
|
|
829
|
+
lexicon,
|
|
830
|
+
environment: "prod",
|
|
831
|
+
commit: "abc",
|
|
832
|
+
timestamp: "2026-04-01T00:00:00Z",
|
|
833
|
+
resources: { db: meta({ type, attributes: { Engine: "postgres" } }) },
|
|
834
|
+
}));
|
|
835
|
+
return staticDescribeResources({ db: meta({ type, attributes: { Engine: "mysql" } }) });
|
|
836
|
+
};
|
|
837
|
+
|
|
838
|
+
test("a lexicon's verdict reaches --json and the stderr warning", async () => {
|
|
839
|
+
buildMock.mockResolvedValue(makeBuildResult({ aws: ["db"] }));
|
|
840
|
+
const plugins: LexiconPlugin[] = [
|
|
841
|
+
createMockPlugin({
|
|
842
|
+
name: "aws",
|
|
843
|
+
emulator: awsEmulatorStub,
|
|
844
|
+
describeResources: drifted("aws", "AWS::RDS::DBInstance"),
|
|
845
|
+
classifyDisruption: ({ changes }) =>
|
|
846
|
+
Object.fromEntries(
|
|
847
|
+
changes.map((c) => [
|
|
848
|
+
c.name,
|
|
849
|
+
{ disruption: "destroy" as const, because: c.deltas.map((d) => d.path), detail: "Engine is create-only" },
|
|
850
|
+
]),
|
|
851
|
+
),
|
|
852
|
+
}),
|
|
853
|
+
];
|
|
854
|
+
const exit = await runLifecyclePlan({
|
|
855
|
+
args: makeArgs({ path: "plan", extraPositional: "prod", json: true }),
|
|
856
|
+
plugins,
|
|
857
|
+
serializers: plugins.map((p) => p.serializer),
|
|
858
|
+
});
|
|
859
|
+
expect(exit).toBe(0);
|
|
860
|
+
const plan = JSON.parse(stdoutBuf.join("\n"));
|
|
861
|
+
const db = plan.entries.find((e: { name: string }) => e.name === "db");
|
|
862
|
+
expect(db).toMatchObject({
|
|
863
|
+
action: "update",
|
|
864
|
+
disruption: "destroy",
|
|
865
|
+
disruptionDetail: "Engine is create-only",
|
|
866
|
+
});
|
|
867
|
+
expect(db.disruptionBecause).toContain("attributes.Engine");
|
|
868
|
+
// The --json shape has no column for it, so the warning is what a CI
|
|
869
|
+
// consumer hears.
|
|
870
|
+
expect(stderrBuf.join("\n")).toContain("replace the resource rather than mutating it in place");
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
test("a lexicon with no classifier leaves every update unknown, never in-place", async () => {
|
|
874
|
+
buildMock.mockResolvedValue(makeBuildResult({ k8s: ["db"] }));
|
|
875
|
+
const plugins: LexiconPlugin[] = [
|
|
876
|
+
createMockPlugin({ name: "k8s", describeResources: drifted("k8s", "K8s::Apps::Deployment") }),
|
|
877
|
+
];
|
|
878
|
+
const exit = await runLifecyclePlan({
|
|
879
|
+
args: makeArgs({ path: "plan", extraPositional: "prod", json: true }),
|
|
880
|
+
plugins,
|
|
881
|
+
serializers: plugins.map((p) => p.serializer),
|
|
882
|
+
});
|
|
883
|
+
expect(exit).toBe(0);
|
|
884
|
+
const plan = JSON.parse(stdoutBuf.join("\n"));
|
|
885
|
+
const db = plan.entries.find((e: { name: string }) => e.name === "db");
|
|
886
|
+
expect(db.disruption).toBe("unknown");
|
|
887
|
+
expect(db.disruptionDetail).toContain("k8s lexicon does not classify disruption");
|
|
888
|
+
expect(stderrBuf.join("\n")).toContain("could not be classified");
|
|
889
|
+
});
|
|
890
|
+
|
|
891
|
+
test("the human render carries the verdict", async () => {
|
|
892
|
+
buildMock.mockResolvedValue(makeBuildResult({ aws: ["db"] }));
|
|
893
|
+
const plugins: LexiconPlugin[] = [
|
|
894
|
+
createMockPlugin({
|
|
895
|
+
name: "aws",
|
|
896
|
+
emulator: awsEmulatorStub,
|
|
897
|
+
describeResources: drifted("aws", "AWS::RDS::DBInstance"),
|
|
898
|
+
classifyDisruption: () => ({ db: { disruption: "in-place" as const, detail: "no create-only property changed" } }),
|
|
899
|
+
}),
|
|
900
|
+
];
|
|
901
|
+
const exit = await runLifecyclePlan({
|
|
902
|
+
args: makeArgs({ path: "plan", extraPositional: "prod" }),
|
|
903
|
+
plugins,
|
|
904
|
+
serializers: plugins.map((p) => p.serializer),
|
|
905
|
+
});
|
|
906
|
+
expect(exit).toBe(0);
|
|
907
|
+
const out = stdoutBuf.join("\n");
|
|
908
|
+
expect(out).toContain("Disruption: 1 in-place");
|
|
909
|
+
expect(out).toContain("— in-place: no create-only property changed");
|
|
910
|
+
expect(stderrBuf.join("\n")).not.toContain("could not be classified");
|
|
911
|
+
});
|
|
912
|
+
});
|
|
913
|
+
|
|
824
914
|
// #1832 — effect receipts are declared, diffed, and observed like any
|
|
825
915
|
// resource, but observe-only to the generic apply path: the plan compares
|
|
826
916
|
// live value to resolved expectation and proposes the fire, never a create.
|
|
@@ -20,6 +20,7 @@ import { computeBuildDigest, diffDigests } from "../../lifecycle/digest";
|
|
|
20
20
|
import { diffLive, diffLiveArtifacts, diffSnapshots, type LiveDiffResult, type LiveArtifactDiffResult, type SnapshotDiffResult } from "../../lifecycle/live-diff";
|
|
21
21
|
import { buildChangeSet, renderChangeSet, gitlabMrReport, summarize, type ChangeSet } from "../../lifecycle/change-set";
|
|
22
22
|
import { mergeReceiptEntries, observedValueResolver, planReceipts, readReceiptValue, type ReceiptReading } from "../../lifecycle/receipt-plan";
|
|
23
|
+
import { annotateDisruption, disruptionNotices } from "../../lifecycle/disruption";
|
|
23
24
|
import { collectEffectReceipts, isEffectReceipt } from "../../effect-receipt";
|
|
24
25
|
import {
|
|
25
26
|
formatUnobserved,
|
|
@@ -1264,7 +1265,17 @@ export async function runLifecyclePlan(ctx: CommandContext): Promise<number> {
|
|
|
1264
1265
|
// Attribution survives the flat merge below (#1674).
|
|
1265
1266
|
lexicon: lexiconName,
|
|
1266
1267
|
});
|
|
1267
|
-
|
|
1268
|
+
|
|
1269
|
+
// Disruption (#1665) is asked of THIS lexicon, before the merge: the
|
|
1270
|
+
// deltas are paths into its own observation shape, and the replacement
|
|
1271
|
+
// rules are in the spec it compiled. A lexicon that answers nothing
|
|
1272
|
+
// leaves every update `unknown` — never `in-place`.
|
|
1273
|
+
const classified = await annotateDisruption(
|
|
1274
|
+
cs,
|
|
1275
|
+
environment,
|
|
1276
|
+
plugin.classifyDisruption ? (o) => plugin.classifyDisruption!(o) : undefined,
|
|
1277
|
+
);
|
|
1278
|
+
merged.entries.push(...classified.entries);
|
|
1268
1279
|
checked++;
|
|
1269
1280
|
}
|
|
1270
1281
|
} finally {
|
|
@@ -1299,6 +1310,12 @@ export async function runLifecyclePlan(ctx: CommandContext): Promise<number> {
|
|
|
1299
1310
|
}));
|
|
1300
1311
|
}
|
|
1301
1312
|
|
|
1313
|
+
// Same reason as above (#1665): the `--json` and `--report gitlab-mr` shapes
|
|
1314
|
+
// have no column for how much an update hurts.
|
|
1315
|
+
for (const notice of disruptionNotices(merged)) {
|
|
1316
|
+
console.error(formatWarning({ message: notice }));
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1302
1319
|
// `--report gitlab-mr` emits the GitLab MR plan-widget artifact instead of the
|
|
1303
1320
|
// human render. Write it to a file (`tfplan.json`) in CI and declare it as
|
|
1304
1321
|
// `artifacts:reports:terraform` to light up the merge-request widget.
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { extractStepRecords, countActivities, queryGateState } from "./op-progress";
|
|
3
|
+
import type { OpConfig } from "../../op/types";
|
|
4
|
+
import type { EffectReceiptRef } from "../../op/receipt-store";
|
|
5
|
+
import type { WorkflowHistoryRaw, HistoryEvent } from "./run-client";
|
|
6
|
+
|
|
7
|
+
// Minimal history-event builders. Field shapes match what `fetchNormalizedHistory`
|
|
8
|
+
// produces: short PascalCase `eventType`, decimal-string `eventId`/`scheduledEventId`.
|
|
9
|
+
function scheduled(eventId: string, fn: string, at: Date): HistoryEvent {
|
|
10
|
+
return {
|
|
11
|
+
eventId,
|
|
12
|
+
eventType: "ActivityTaskScheduled",
|
|
13
|
+
eventTime: at,
|
|
14
|
+
activityTaskScheduledEventAttributes: { activityId: eventId, activityType: { name: fn } },
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
function completed(scheduledEventId: string, at: Date): HistoryEvent {
|
|
18
|
+
return {
|
|
19
|
+
eventType: "ActivityTaskCompleted",
|
|
20
|
+
eventTime: at,
|
|
21
|
+
activityTaskCompletedEventAttributes: { scheduledEventId },
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
function failed(scheduledEventId: string, at: Date, message = "boom"): HistoryEvent {
|
|
25
|
+
return {
|
|
26
|
+
eventType: "ActivityTaskFailed",
|
|
27
|
+
eventTime: at,
|
|
28
|
+
activityTaskFailedEventAttributes: { scheduledEventId, failure: { message } },
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const T0 = new Date("2026-05-01T00:00:00Z");
|
|
33
|
+
const sec = (n: number) => new Date(T0.getTime() + n * 1000);
|
|
34
|
+
|
|
35
|
+
describe("extractStepRecords", () => {
|
|
36
|
+
test("joins two phases' activities to their declared phase, in order", () => {
|
|
37
|
+
const config: OpConfig = {
|
|
38
|
+
name: "op", overview: "o",
|
|
39
|
+
phases: [
|
|
40
|
+
{ name: "Build", steps: [{ kind: "activity", fn: "build" }] },
|
|
41
|
+
{ name: "Deploy", steps: [{ kind: "activity", fn: "deploy" }] },
|
|
42
|
+
],
|
|
43
|
+
};
|
|
44
|
+
const history: WorkflowHistoryRaw = {
|
|
45
|
+
events: [
|
|
46
|
+
scheduled("1", "build", sec(0)),
|
|
47
|
+
completed("1", sec(1)),
|
|
48
|
+
scheduled("2", "deploy", sec(2)),
|
|
49
|
+
completed("2", sec(4)),
|
|
50
|
+
],
|
|
51
|
+
};
|
|
52
|
+
const records = extractStepRecords(config, history);
|
|
53
|
+
expect(records).toEqual([
|
|
54
|
+
{ phase: "Build", fn: "build", status: "ok", durationMs: 1000 },
|
|
55
|
+
{ phase: "Deploy", fn: "deploy", status: "ok", durationMs: 2000 },
|
|
56
|
+
]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("two steps calling the same activity each get their own scheduled event (no last-wins collision)", () => {
|
|
60
|
+
const config: OpConfig = {
|
|
61
|
+
name: "op", overview: "o",
|
|
62
|
+
phases: [{ name: "P1", steps: [{ kind: "activity", fn: "dup" }, { kind: "activity", fn: "dup" }] }],
|
|
63
|
+
};
|
|
64
|
+
const history: WorkflowHistoryRaw = {
|
|
65
|
+
events: [
|
|
66
|
+
scheduled("1", "dup", sec(0)),
|
|
67
|
+
completed("1", sec(1)), // first "dup": 1000ms, ok
|
|
68
|
+
scheduled("2", "dup", sec(1)),
|
|
69
|
+
failed("2", sec(1.5), "second dup failed"), // second "dup": fails
|
|
70
|
+
],
|
|
71
|
+
};
|
|
72
|
+
const records = extractStepRecords(config, history);
|
|
73
|
+
expect(records).toEqual([
|
|
74
|
+
{ phase: "P1", fn: "dup", status: "ok", durationMs: 1000 },
|
|
75
|
+
{ phase: "P1", fn: "dup", status: "fail", durationMs: 500, error: "second dup failed" },
|
|
76
|
+
]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("a step not yet scheduled produces no record unless final", () => {
|
|
80
|
+
const config: OpConfig = {
|
|
81
|
+
name: "op", overview: "o",
|
|
82
|
+
phases: [
|
|
83
|
+
{ name: "P1", steps: [{ kind: "activity", fn: "a" }] },
|
|
84
|
+
{ name: "P2", steps: [{ kind: "activity", fn: "b" }] },
|
|
85
|
+
],
|
|
86
|
+
};
|
|
87
|
+
const history: WorkflowHistoryRaw = { events: [scheduled("1", "a", sec(0)), completed("1", sec(1))] };
|
|
88
|
+
|
|
89
|
+
expect(extractStepRecords(config, history)).toEqual([
|
|
90
|
+
{ phase: "P1", fn: "a", status: "ok", durationMs: 1000 },
|
|
91
|
+
]);
|
|
92
|
+
expect(extractStepRecords(config, history, { final: true })).toEqual([
|
|
93
|
+
{ phase: "P1", fn: "a", status: "ok", durationMs: 1000 },
|
|
94
|
+
{ phase: "P2", fn: "b", status: "skipped", durationMs: 0 },
|
|
95
|
+
]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("a scheduled-but-not-yet-settled step produces no record", () => {
|
|
99
|
+
const config: OpConfig = {
|
|
100
|
+
name: "op", overview: "o",
|
|
101
|
+
phases: [{ name: "P1", steps: [{ kind: "activity", fn: "a" }] }],
|
|
102
|
+
};
|
|
103
|
+
const history: WorkflowHistoryRaw = { events: [scheduled("1", "a", sec(0))] };
|
|
104
|
+
expect(extractStepRecords(config, history)).toEqual([]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("a retried activity resolves to its last outcome (Failed attempt then eventual Completed)", () => {
|
|
108
|
+
const config: OpConfig = {
|
|
109
|
+
name: "op", overview: "o",
|
|
110
|
+
phases: [{ name: "P1", steps: [{ kind: "activity", fn: "flaky" }] }],
|
|
111
|
+
};
|
|
112
|
+
const history: WorkflowHistoryRaw = {
|
|
113
|
+
events: [
|
|
114
|
+
scheduled("1", "flaky", sec(0)),
|
|
115
|
+
failed("1", sec(1), "attempt 1 failed"),
|
|
116
|
+
completed("1", sec(3)), // eventual success
|
|
117
|
+
],
|
|
118
|
+
};
|
|
119
|
+
expect(extractStepRecords(config, history)).toEqual([
|
|
120
|
+
{ phase: "P1", fn: "flaky", status: "ok", durationMs: 3000 },
|
|
121
|
+
]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("effect step: matched receipt — only receiptRead settles; nested + receiptWrite are skipped only when final", () => {
|
|
125
|
+
const receipt: EffectReceiptRef = { name: "r", effect: "e", flavor: "existence", inputs: {} };
|
|
126
|
+
const config: OpConfig = {
|
|
127
|
+
name: "op", overview: "o",
|
|
128
|
+
phases: [{
|
|
129
|
+
name: "Effect",
|
|
130
|
+
steps: [{ kind: "effect", receipt, steps: [{ kind: "activity", fn: "applyIt" }] }],
|
|
131
|
+
}],
|
|
132
|
+
};
|
|
133
|
+
const history: WorkflowHistoryRaw = {
|
|
134
|
+
events: [scheduled("1", "receiptRead", sec(0)), completed("1", sec(1))],
|
|
135
|
+
};
|
|
136
|
+
expect(extractStepRecords(config, history)).toEqual([
|
|
137
|
+
{ phase: "Effect", fn: "receiptRead", status: "ok", durationMs: 1000 },
|
|
138
|
+
]);
|
|
139
|
+
expect(extractStepRecords(config, history, { final: true })).toEqual([
|
|
140
|
+
{ phase: "Effect", fn: "receiptRead", status: "ok", durationMs: 1000 },
|
|
141
|
+
{ phase: "Effect", fn: "applyIt", status: "skipped", durationMs: 0 },
|
|
142
|
+
{ phase: "Effect", fn: "receiptWrite", status: "skipped", durationMs: 0 },
|
|
143
|
+
]);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("onFailure phases are matched in reverse declared order, matching how the workflow actually runs them", () => {
|
|
147
|
+
const config: OpConfig = {
|
|
148
|
+
name: "op", overview: "o",
|
|
149
|
+
phases: [{ name: "Main", steps: [{ kind: "activity", fn: "boom" }] }],
|
|
150
|
+
onFailure: [
|
|
151
|
+
{ name: "C1", steps: [{ kind: "activity", fn: "comp1" }] },
|
|
152
|
+
{ name: "C2", steps: [{ kind: "activity", fn: "comp2" }] },
|
|
153
|
+
],
|
|
154
|
+
};
|
|
155
|
+
const history: WorkflowHistoryRaw = {
|
|
156
|
+
events: [
|
|
157
|
+
scheduled("1", "boom", sec(0)),
|
|
158
|
+
failed("1", sec(1), "boom"),
|
|
159
|
+
// Compensation runs C2 then C1 (reverse of declared order).
|
|
160
|
+
scheduled("2", "comp2", sec(2)),
|
|
161
|
+
completed("2", sec(3)),
|
|
162
|
+
scheduled("3", "comp1", sec(3)),
|
|
163
|
+
completed("3", sec(4)),
|
|
164
|
+
],
|
|
165
|
+
};
|
|
166
|
+
expect(extractStepRecords(config, history)).toEqual([
|
|
167
|
+
{ phase: "Main", fn: "boom", status: "fail", durationMs: 1000, error: "boom" },
|
|
168
|
+
{ phase: "C2", fn: "comp2", status: "ok", durationMs: 1000 },
|
|
169
|
+
{ phase: "C1", fn: "comp1", status: "ok", durationMs: 1000 },
|
|
170
|
+
]);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe("countActivities", () => {
|
|
175
|
+
test("counts scheduled and completed events", () => {
|
|
176
|
+
const history: WorkflowHistoryRaw = {
|
|
177
|
+
events: [scheduled("1", "a", sec(0)), scheduled("2", "b", sec(0)), completed("1", sec(1))],
|
|
178
|
+
};
|
|
179
|
+
expect(countActivities(history)).toEqual({ completed: 1, scheduled: 2 });
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("empty history", () => {
|
|
183
|
+
expect(countActivities({})).toEqual({ completed: 0, scheduled: 0 });
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe("queryGateState", () => {
|
|
188
|
+
test("returns the query result when the handle supports it", async () => {
|
|
189
|
+
const handle = { query: async () => ({ signalName: "g", since: "t" }) } as unknown as Parameters<typeof queryGateState>[0];
|
|
190
|
+
expect(await queryGateState(handle)).toEqual({ signalName: "g", since: "t" });
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("returns null when the workflow reports no pending gate", async () => {
|
|
194
|
+
const handle = { query: async () => null } as unknown as Parameters<typeof queryGateState>[0];
|
|
195
|
+
expect(await queryGateState(handle)).toBeNull();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("returns undefined (not an error) when the query isn't registered — e.g. an Op with no gates", async () => {
|
|
199
|
+
const handle = { query: async () => { throw new Error("unregistered query"); } } as unknown as Parameters<typeof queryGateState>[0];
|
|
200
|
+
expect(await queryGateState(handle)).toBeUndefined();
|
|
201
|
+
});
|
|
202
|
+
});
|