@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.
Files changed (63) hide show
  1. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  2. package/dist/cli/handlers/op-progress.d.ts +57 -0
  3. package/dist/cli/handlers/op-progress.d.ts.map +1 -0
  4. package/dist/cli/handlers/run-client.d.ts +21 -1
  5. package/dist/cli/handlers/run-client.d.ts.map +1 -1
  6. package/dist/cli/handlers/run-report.d.ts.map +1 -1
  7. package/dist/cli/handlers/run.d.ts.map +1 -1
  8. package/dist/cli/handlers/search.d.ts +22 -0
  9. package/dist/cli/handlers/search.d.ts.map +1 -1
  10. package/dist/cli/main.d.ts.map +1 -1
  11. package/dist/cli/mcp/op-tools.d.ts.map +1 -1
  12. package/dist/cli/mcp/resource-handlers.d.ts.map +1 -1
  13. package/dist/cli/registry.d.ts +33 -1
  14. package/dist/cli/registry.d.ts.map +1 -1
  15. package/dist/components/run-progress.d.ts +7 -5
  16. package/dist/components/run-progress.d.ts.map +1 -1
  17. package/dist/lexicon.d.ts +41 -0
  18. package/dist/lexicon.d.ts.map +1 -1
  19. package/dist/lifecycle/assert-live.d.ts +77 -0
  20. package/dist/lifecycle/assert-live.d.ts.map +1 -0
  21. package/dist/lifecycle/change-set.d.ts +17 -0
  22. package/dist/lifecycle/change-set.d.ts.map +1 -1
  23. package/dist/lifecycle/disruption.d.ts +96 -0
  24. package/dist/lifecycle/disruption.d.ts.map +1 -0
  25. package/dist/lifecycle/index.d.ts +2 -0
  26. package/dist/lifecycle/index.d.ts.map +1 -1
  27. package/dist/lifecycle/replay.d.ts +2 -0
  28. package/dist/lifecycle/replay.d.ts.map +1 -1
  29. package/dist/op/local-executor.d.ts +7 -1
  30. package/dist/op/local-executor.d.ts.map +1 -1
  31. package/dist/testing.d.ts +23 -2
  32. package/dist/testing.d.ts.map +1 -1
  33. package/package.json +1 -1
  34. package/src/cli/handlers/lifecycle.test.ts +90 -0
  35. package/src/cli/handlers/lifecycle.ts +18 -1
  36. package/src/cli/handlers/op-progress.test.ts +202 -0
  37. package/src/cli/handlers/op-progress.ts +192 -0
  38. package/src/cli/handlers/run-client.test.ts +82 -0
  39. package/src/cli/handlers/run-client.ts +85 -2
  40. package/src/cli/handlers/run-report.test.ts +62 -0
  41. package/src/cli/handlers/run-report.ts +20 -58
  42. package/src/cli/handlers/run.test.ts +144 -0
  43. package/src/cli/handlers/run.ts +40 -18
  44. package/src/cli/handlers/search-drift.test.ts +263 -0
  45. package/src/cli/handlers/search.ts +150 -1
  46. package/src/cli/main.ts +9 -0
  47. package/src/cli/mcp/op-tools.ts +17 -6
  48. package/src/cli/mcp/resource-handlers.ts +13 -5
  49. package/src/cli/registry.ts +33 -1
  50. package/src/components/run-progress.ts +9 -5
  51. package/src/lexicon.ts +51 -0
  52. package/src/lifecycle/assert-live.test.ts +125 -0
  53. package/src/lifecycle/assert-live.ts +154 -0
  54. package/src/lifecycle/change-set.ts +35 -3
  55. package/src/lifecycle/disruption.test.ts +186 -0
  56. package/src/lifecycle/disruption.ts +224 -0
  57. package/src/lifecycle/index.ts +2 -0
  58. package/src/lifecycle/replay.test.ts +25 -0
  59. package/src/lifecycle/replay.ts +11 -3
  60. package/src/op/local-executor.ts +7 -1
  61. package/src/op/local-output.ts +1 -1
  62. package/src/testing.test.ts +89 -2
  63. 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;AAGnD;;;;;;;;;;;;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,CAAC;IAAE,YAAY,EAAE,eAAe,EAAE,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CAyIpH"}
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
- args: Record<string, unknown>;
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;IAC9B,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"}
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 { Declarable } from "./declarable.js";
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
@@ -1 +1 @@
1
- {"version":3,"file":"testing.d.ts","sourceRoot":"","sources":["../src/testing.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AAMH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAC/C,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAMrD,OAAO,EAAsB,KAAK,eAAe,EAAE,MAAM,gBAAgB,CAAC;AAI1E,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,WAAW,CAAC;AAG/C,OAAO,EAAgC,KAAK,UAAU,EAAE,KAAK,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAE7G,OAAO,EAAmB,KAAK,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAgB5E,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;;;;;;;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,CAkHrF"}
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant",
3
- "version": "0.50.0",
3
+ "version": "0.51.0",
4
4
  "description": "Declarative infrastructure-as-code toolkit — TypeScript on Node.js",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -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
- merged.entries.push(...cs.entries);
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
+ });