@intentius/chant 0.70.1 → 0.71.1

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 (84) hide show
  1. package/dist/cli/handlers/fan-out.d.ts +45 -0
  2. package/dist/cli/handlers/fan-out.d.ts.map +1 -0
  3. package/dist/cli/handlers/lifecycle.d.ts +13 -0
  4. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  5. package/dist/cli/handlers/operator.d.ts.map +1 -1
  6. package/dist/cli/handlers/run.d.ts +35 -0
  7. package/dist/cli/handlers/run.d.ts.map +1 -1
  8. package/dist/cli/main.d.ts.map +1 -1
  9. package/dist/cli/registry.d.ts +23 -0
  10. package/dist/cli/registry.d.ts.map +1 -1
  11. package/dist/components/deploy-units.d.ts +12 -2
  12. package/dist/components/deploy-units.d.ts.map +1 -1
  13. package/dist/components/fan-out-output.d.ts +70 -0
  14. package/dist/components/fan-out-output.d.ts.map +1 -0
  15. package/dist/components/fan-out-run.d.ts +80 -0
  16. package/dist/components/fan-out-run.d.ts.map +1 -0
  17. package/dist/components/fan-out-support.d.ts +65 -0
  18. package/dist/components/fan-out-support.d.ts.map +1 -0
  19. package/dist/components/fan-out.d.ts +194 -0
  20. package/dist/components/fan-out.d.ts.map +1 -0
  21. package/dist/components/index.d.ts +4 -0
  22. package/dist/components/index.d.ts.map +1 -1
  23. package/dist/discovery/fold-import.d.ts +36 -1
  24. package/dist/discovery/fold-import.d.ts.map +1 -1
  25. package/dist/fold/subset.d.ts +22 -0
  26. package/dist/fold/subset.d.ts.map +1 -1
  27. package/dist/index.d.ts +2 -1
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/lifecycle/affected.d.ts +26 -0
  30. package/dist/lifecycle/affected.d.ts.map +1 -1
  31. package/dist/op/activities/activity-contracts.d.ts +16 -1
  32. package/dist/op/activities/activity-contracts.d.ts.map +1 -1
  33. package/dist/op/activities/index.d.ts +1 -1
  34. package/dist/op/activities/index.d.ts.map +1 -1
  35. package/dist/op/activities/shell.d.ts +31 -2
  36. package/dist/op/activities/shell.d.ts.map +1 -1
  37. package/dist/op/activity-contract.d.ts +1 -1
  38. package/dist/op/activity-contract.d.ts.map +1 -1
  39. package/dist/op/activity-profiles.d.ts +19 -0
  40. package/dist/op/activity-profiles.d.ts.map +1 -1
  41. package/dist/op/builders.d.ts +21 -3
  42. package/dist/op/builders.d.ts.map +1 -1
  43. package/dist/op/gate-name.d.ts +10 -0
  44. package/dist/op/gate-name.d.ts.map +1 -1
  45. package/dist/op/step-output-ref.d.ts +25 -8
  46. package/dist/op/step-output-ref.d.ts.map +1 -1
  47. package/package.json +2 -1
  48. package/src/cli/handlers/fan-out.test.ts +394 -0
  49. package/src/cli/handlers/fan-out.ts +336 -0
  50. package/src/cli/handlers/lifecycle.test.ts +74 -1
  51. package/src/cli/handlers/lifecycle.ts +29 -1
  52. package/src/cli/handlers/operator.test.ts +22 -0
  53. package/src/cli/handlers/operator.ts +11 -3
  54. package/src/cli/handlers/run.ts +7 -1
  55. package/src/cli/main.ts +25 -3
  56. package/src/cli/registry.ts +23 -0
  57. package/src/components/deploy-units.ts +14 -4
  58. package/src/components/fan-out-output.test.ts +216 -0
  59. package/src/components/fan-out-output.ts +162 -0
  60. package/src/components/fan-out-run.test.ts +194 -0
  61. package/src/components/fan-out-run.ts +221 -0
  62. package/src/components/fan-out-support.test.ts +125 -0
  63. package/src/components/fan-out-support.ts +95 -0
  64. package/src/components/fan-out.test.ts +284 -0
  65. package/src/components/fan-out.ts +421 -0
  66. package/src/components/index.ts +33 -0
  67. package/src/discovery/fold-import.ts +81 -3
  68. package/src/fold/subset-public-export.test.ts +31 -0
  69. package/src/fold/subset.ts +23 -0
  70. package/src/index.ts +6 -0
  71. package/src/lifecycle/affected.test.ts +118 -0
  72. package/src/lifecycle/affected.ts +118 -14
  73. package/src/meta/declared-imports.test.ts +141 -0
  74. package/src/op/activities/activity-contracts.ts +17 -1
  75. package/src/op/activities/index.ts +1 -1
  76. package/src/op/activities/shell.test.ts +156 -0
  77. package/src/op/activities/shell.ts +84 -9
  78. package/src/op/activity-profiles.test.ts +16 -2
  79. package/src/op/activity-profiles.ts +18 -0
  80. package/src/op/builders.ts +22 -4
  81. package/src/op/gate-name.ts +11 -0
  82. package/src/op/op-ir.test.ts +4 -1
  83. package/src/op/op.test.ts +7 -2
  84. package/src/op/step-output-ref.ts +29 -8
@@ -183,6 +183,29 @@ export interface ParsedArgs {
183
183
  head?: string;
184
184
  /** `chant lifecycle affected --include-dependents` — add downstream consumers */
185
185
  includeDependents?: boolean;
186
+ /**
187
+ * `chant components fan-out --from-affected <file>` (#2420) — read the
188
+ * stack-level change signal from the JSON `chant lifecycle affected --json`
189
+ * wrote, instead of re-deriving it with `--base`. The composable half of the
190
+ * pair: a CI job that already ran the diff has no reason to build twice.
191
+ */
192
+ fromAffected?: string;
193
+ /**
194
+ * `chant components fan-out --gate <name>` (#2420) — the name of the single
195
+ * gate over the whole derived set, answered by `chant approve fan-out <name>
196
+ * --plan <digest>`. One approval covers the fan-out, bound to the plan's
197
+ * digest (#2300's mechanism, #2417's decision); omitted, the fan-out runs
198
+ * ungated. A component's own authored gates are unaffected either way.
199
+ */
200
+ gate?: string;
201
+ /**
202
+ * `chant components fan-out --resume <file>` (#2420) — the attempt record
203
+ * this fan-out reads before it starts and writes when it finishes. Repeating
204
+ * the identical command finishes what an interrupted attempt left, because
205
+ * the plan is narrowed by this file before the gate is decided and the
206
+ * digest is carried rather than recomputed, so the approval still stands.
207
+ */
208
+ resume?: string;
186
209
  /** `chant audit --tier merge-worthy|all` */
187
210
  tier?: string;
188
211
  /**
@@ -14,7 +14,17 @@
14
14
  * The registry is data, not a rule about what steps look like — a kind not
15
15
  * listed here contributes no unit, exactly as before.
16
16
  */
17
- import type { Phase } from "./component";
17
+ /**
18
+ * The only shape this walk reads: something with `steps`, whose entries may
19
+ * carry a `kind` and may themselves nest. Both `Phase` (./component.ts) and
20
+ * `DriverPhase` (./driver.ts) satisfy it — they differ on how a gate step is
21
+ * typed, which this module never looks at — so declaring the narrower of the
22
+ * two would force a cast on one caller for no gain in safety, given the walk
23
+ * discriminates structurally anyway.
24
+ */
25
+ export interface UnitBearingPhase {
26
+ steps: readonly unknown[];
27
+ }
18
28
 
19
29
  /** A deploy-family step kind that names a live unit, and who observes it. */
20
30
  export interface DeployUnitRule {
@@ -61,17 +71,17 @@ export interface DeployUnit {
61
71
  * order. A step may itself be a nested `Phase`, so the walk recurses; a
62
72
  * resolved component carries the unit as a concrete string. Pure.
63
73
  */
64
- export function deployUnits(deploy: Phase[]): DeployUnit[] {
74
+ export function deployUnits(deploy: readonly UnitBearingPhase[]): DeployUnit[] {
65
75
  const byKind = new Map(DEPLOY_UNIT_RULES.map((r) => [r.kind, r]));
66
76
  const seen = new Set<string>();
67
77
  const units: DeployUnit[] = [];
68
- const walkSteps = (steps: Phase["steps"]): void => {
78
+ const walkSteps = (steps: readonly unknown[]): void => {
69
79
  for (const step of steps) {
70
80
  // A step may itself be a nested Phase (it carries its own `steps`). Step
71
81
  // is open-typed (capability inputs), so discriminate structurally.
72
82
  const nested = (step as { steps?: unknown }).steps;
73
83
  if (Array.isArray(nested)) {
74
- walkSteps(nested as Phase["steps"]);
84
+ walkSteps(nested as readonly unknown[]);
75
85
  continue;
76
86
  }
77
87
  const s = step as { kind?: string } & Record<string, unknown>;
@@ -0,0 +1,216 @@
1
+ /**
2
+ * The printed derivation (#2420).
3
+ *
4
+ * #2417's eighth proof line is "the order was derived, with the derivation
5
+ * printed". These tests hold the render to what makes that claim checkable by
6
+ * a reader: the waves, a reason beside every component that is not running,
7
+ * the third outcome, the seeds, and the digest verbatim.
8
+ */
9
+
10
+ import { describe, test, expect } from "vitest";
11
+ import { planFanOut, type FanOutPlan } from "./fan-out";
12
+ import { renderFanOutPlan, renderFanOutHuman, renderFanOutJson } from "./fan-out-output";
13
+ import type { FanOutRunResult } from "./fan-out-run";
14
+ import type { DriverComponent } from "./driver";
15
+
16
+ const c = (name: string, dependsOn?: string[]): DriverComponent => ({
17
+ name,
18
+ deploy: [{ phase: "Deploy", steps: [{ kind: "ok-step" }] }],
19
+ ...(dependsOn ? { dependsOn } : {}),
20
+ });
21
+
22
+ /** net -> two clusters -> three apps, plus an unrelated branch. #2417's proof shape. */
23
+ const ESTATE: DriverComponent[] = [
24
+ c("net"),
25
+ c("cluster-a", ["net"]),
26
+ c("cluster-b", ["net"]),
27
+ c("app-one", ["cluster-a"]),
28
+ c("app-two", ["cluster-a"]),
29
+ c("app-three", ["cluster-b"]),
30
+ c("billing"),
31
+ c("nightly-etl"),
32
+ ];
33
+
34
+ function capture(): { lines: string[]; write: (line: string) => void } {
35
+ const lines: string[] = [];
36
+ return { lines, write: (line) => lines.push(line) };
37
+ }
38
+
39
+ function render(plan: FanOutPlan, gate?: { op: string; gate: string }): string {
40
+ const { lines, write } = capture();
41
+ renderFanOutPlan(plan, { write, ...(gate ? { gate } : {}) });
42
+ return lines.join("\n");
43
+ }
44
+
45
+ describe("the derivation", () => {
46
+ test("prints the waves, so the order reads as a graph rather than a list", () => {
47
+ const out = render(planFanOut({ components: ESTATE, changed: ["net"] }));
48
+ expect(out).toContain("wave 1: net");
49
+ expect(out).toContain("wave 2: cluster-a, cluster-b");
50
+ expect(out).toContain("wave 3: app-one, app-three, app-two");
51
+ });
52
+
53
+ test("counts all three outcomes, never two", () => {
54
+ const out = render(
55
+ planFanOut({ components: ESTATE, changed: ["cluster-b"], indeterminate: ["nightly-etl"] }),
56
+ );
57
+ expect(out).toMatch(/^fan-out: 2 selected, 5 unaffected, 1 indeterminate$/m);
58
+ });
59
+
60
+ test("every component that is not running carries its reason", () => {
61
+ const out = render(
62
+ planFanOut({ components: ESTATE, changed: ["cluster-b"], indeterminate: ["nightly-etl"] }),
63
+ );
64
+ expect(out).toContain("not running (6):");
65
+ expect(out).toMatch(/billing +unaffected/);
66
+ expect(out).toMatch(/nightly-etl +indeterminate/);
67
+ });
68
+
69
+ test("an indeterminate component the walk did not reach is named on its own line", () => {
70
+ const out = render(
71
+ planFanOut({ components: ESTATE, changed: ["cluster-b"], indeterminate: ["nightly-etl"] }),
72
+ );
73
+ expect(out).toContain("the walk did not reach them: nightly-etl");
74
+ });
75
+
76
+ test("an indeterminate component the walk did reach is selected, and is not reported as undecided", () => {
77
+ const out = render(
78
+ planFanOut({ components: ESTATE, changed: ["net"], indeterminate: ["app-two"] }),
79
+ );
80
+ expect(out).toContain("wave 3: app-one, app-three, app-two");
81
+ expect(out).not.toContain("the walk did not reach them");
82
+ expect(out).toMatch(/^fan-out: 6 selected, 2 unaffected, 0 indeterminate$/m);
83
+ });
84
+
85
+ test("names the dependencies whose outputs are taken on trust", () => {
86
+ const out = render(planFanOut({ components: ESTATE, changed: ["cluster-a"] }));
87
+ expect(out).toContain("seeded from an earlier run: net");
88
+ });
89
+
90
+ test("prints the digest verbatim, because it is what `chant approve --plan` takes", () => {
91
+ const plan = planFanOut({ components: ESTATE, changed: ["net"] });
92
+ const out = render(plan, { op: "fan-out", gate: "release" });
93
+ expect(out).toContain(`plan: ${plan.digest}`);
94
+ expect(out).toContain(`chant approve fan-out release --plan ${plan.digest}`);
95
+ // Nothing abbreviates it: the whole 64-hex string is on the line.
96
+ expect(plan.digest).toMatch(/^sha256:[0-9a-f]{64}$/);
97
+ });
98
+
99
+ test("says so plainly when a change propagates to nothing", () => {
100
+ const plan: FanOutPlan = {
101
+ order: [],
102
+ waves: [],
103
+ skipped: [],
104
+ seeds: [],
105
+ indeterminate: [],
106
+ digest: "sha256:" + "0".repeat(64),
107
+ };
108
+ expect(render(plan)).toContain("nothing to run");
109
+ });
110
+ });
111
+
112
+ describe("a dispatched fan-out", () => {
113
+ const plan = planFanOut({ components: ESTATE, changed: ["net"] });
114
+
115
+ const result = (over: Partial<FanOutRunResult>): FanOutRunResult => ({
116
+ plan,
117
+ status: "ok",
118
+ results: [],
119
+ completed: [],
120
+ failed: [],
121
+ blocked: [],
122
+ componentOutputs: {},
123
+ ...over,
124
+ });
125
+
126
+ test("a failure prints the blocked subtree naming the failure, not the nearest edge", () => {
127
+ const { lines, write } = capture();
128
+ renderFanOutHuman(
129
+ result({
130
+ status: "fail",
131
+ completed: ["cluster-b", "net", "app-three"],
132
+ failed: ["cluster-a"],
133
+ blocked: [
134
+ { component: "app-one", reason: "blocked", blockedBy: "cluster-a" },
135
+ { component: "app-two", reason: "blocked", blockedBy: "cluster-a" },
136
+ ],
137
+ }),
138
+ { write },
139
+ );
140
+ const out = lines.join("\n");
141
+ expect(out).toContain('app-one: blocked by "cluster-a", so it never ran');
142
+ expect(out).toContain('app-two: blocked by "cluster-a", so it never ran');
143
+ // The independent branch is still reported as applied.
144
+ expect(out).toContain("applied: cluster-b, net, app-three");
145
+ expect(out).toContain("fan-out failed: 3 applied, 1 failed, 2 blocked");
146
+ });
147
+
148
+ test("a gated run says nothing ran, and names the gate", () => {
149
+ const { lines, write } = capture();
150
+ renderFanOutHuman(
151
+ result({
152
+ status: "gated",
153
+ gate: {
154
+ version: 1,
155
+ kind: "pending",
156
+ op: "fan-out",
157
+ gate: "release",
158
+ timestamp: "2026-01-01T00:00:00Z",
159
+ expiresAt: "2026-01-03T00:00:00Z",
160
+ planDigest: plan.digest,
161
+ },
162
+ }),
163
+ { write },
164
+ );
165
+ const out = lines.join("\n");
166
+ expect(out).toContain("gated: nothing ran.");
167
+ expect(out).toContain('Waiting on "release" on "fan-out".');
168
+ expect(out).toContain("expires: 2026-01-03T00:00:00Z");
169
+ });
170
+
171
+ test("the plan printed is the one dispatched, so a resume shows what it is skipping", () => {
172
+ const { lines, write } = capture();
173
+ renderFanOutHuman(
174
+ result({
175
+ plan: {
176
+ ...plan,
177
+ order: ["app-one"],
178
+ waves: [["app-one"]],
179
+ skipped: [
180
+ { component: "billing", reason: "unaffected" },
181
+ { component: "cluster-a", reason: "already-applied" },
182
+ { component: "net", reason: "already-applied" },
183
+ ],
184
+ },
185
+ completed: ["app-one"],
186
+ }),
187
+ { write },
188
+ );
189
+ const out = lines.join("\n");
190
+ expect(out).toMatch(/net +already-applied/);
191
+ expect(out).toMatch(/cluster-a +already-applied/);
192
+ expect(out).toContain("fan-out completed: 1 applied, 0 failed, 0 blocked");
193
+ });
194
+ });
195
+
196
+ describe("the JSON render", () => {
197
+ test("emits one line, and the plan round-trips", () => {
198
+ const plan = planFanOut({ components: ESTATE, changed: ["net"] });
199
+ const { lines, write } = capture();
200
+ renderFanOutJson(plan, write);
201
+ expect(lines).toHaveLength(1);
202
+ expect(JSON.parse(lines[0])).toEqual(plan);
203
+ });
204
+
205
+ test("a run result carries its plan, which is how a consumer tells the two apart", () => {
206
+ const plan = planFanOut({ components: ESTATE, changed: ["net"] });
207
+ const { lines, write } = capture();
208
+ renderFanOutJson(
209
+ { plan, status: "ok", results: [], completed: [], failed: [], blocked: [], componentOutputs: {} },
210
+ write,
211
+ );
212
+ const parsed = JSON.parse(lines[0]) as FanOutRunResult;
213
+ expect(parsed.plan.digest).toBe(plan.digest);
214
+ expect(parsed.status).toBe("ok");
215
+ });
216
+ });
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Renderers for a derived fan-out (#2420) — the printed derivation #2417's
3
+ * proof list asks for and nothing produced.
4
+ *
5
+ * `./driver-output.ts` is the precedent and the shape is deliberately the
6
+ * same: a human renderer that logs to stderr, a JSON renderer that owns
7
+ * stdout and writes nothing else, both over the types the model already
8
+ * returns (`./fan-out.ts`'s `FanOutPlan`, `./fan-out-run.ts`'s
9
+ * `FanOutRunResult`). No new fields are computed here. A renderer that had to
10
+ * derive something to print it would be deriving it twice.
11
+ *
12
+ * ## What gets printed, and why each line is not optional
13
+ *
14
+ * A fan-out that applies the right components without saying why it picked
15
+ * them reads exactly like one that fanned out to everything, which is the
16
+ * failure #2417's precision decision exists to prevent. So:
17
+ *
18
+ * - **the waves**, because the claim is that the order was derived from the
19
+ * source rather than declared, and a flat list cannot show that
20
+ * - **every component that is not running, with its reason** — `unaffected`,
21
+ * `indeterminate`, `already-applied`, `blocked` — because "absent from the
22
+ * list" and "deliberately held back" look identical otherwise
23
+ * - **`indeterminate` on its own line**, because it is the third outcome and a
24
+ * two-state render would quietly drop it into one of the other two
25
+ * - **`seeds`**, because those values are being taken on trust from a run that
26
+ * happened earlier and somewhere else
27
+ * - **the `digest`, verbatim and unabbreviated**, because it is the string
28
+ * `chant approve --plan` takes. Truncating it for width would make the line
29
+ * unusable for the one job it has.
30
+ */
31
+
32
+ import type { FanOutPlan, FanOutSkip } from "./fan-out";
33
+ import type { FanOutRunResult } from "./fan-out-run";
34
+
35
+ type Writer = (line: string) => void;
36
+
37
+ const stderr: Writer = (line) => process.stderr.write(line + "\n");
38
+ const stdout: Writer = (line) => process.stdout.write(line + "\n");
39
+
40
+ /** The gate one fan-out is bound to, when the invocation asked for one. */
41
+ export interface FanOutGateRef {
42
+ op: string;
43
+ gate: string;
44
+ }
45
+
46
+ export interface FanOutRenderOptions {
47
+ write?: Writer;
48
+ /** Present when a gate covers the set, so the render can print the exact approve line. */
49
+ gate?: FanOutGateRef;
50
+ }
51
+
52
+ /** `unaffected` → `unaffected`; `blocked` also names the failure it was reached from. */
53
+ function reasonOf(skip: FanOutSkip): string {
54
+ return skip.reason === "blocked" && skip.blockedBy
55
+ ? `blocked by "${skip.blockedBy}"`
56
+ : skip.reason;
57
+ }
58
+
59
+ /** Longest component name in `skips`, for a column that lines up without padding every render. */
60
+ function widestName(skips: readonly FanOutSkip[]): number {
61
+ return skips.reduce((w, s) => Math.max(w, s.component.length), 0);
62
+ }
63
+
64
+ /**
65
+ * The derivation itself: the waves, what is not running and why, what is
66
+ * seeded, and the digest an approval binds to.
67
+ *
68
+ * This is the whole output of a plan-only invocation and the header of a run,
69
+ * so it is one function rather than two nearly identical ones.
70
+ */
71
+ export function renderFanOutPlan(plan: FanOutPlan, options: FanOutRenderOptions = {}): void {
72
+ const write = options.write ?? stderr;
73
+
74
+ // Three outcomes, never two (#2417). The counts come first because they are
75
+ // the claim the rest of the block substantiates.
76
+ const unaffected = plan.skipped.filter((s) => s.reason === "unaffected").length;
77
+ const held = plan.skipped.filter((s) => s.reason === "already-applied" || s.reason === "blocked").length;
78
+ write(
79
+ `fan-out: ${plan.order.length} selected, ${unaffected} unaffected, ` +
80
+ `${plan.indeterminate.length} indeterminate` +
81
+ (held > 0 ? `, ${held} held back` : ""),
82
+ );
83
+
84
+ if (plan.order.length === 0) {
85
+ write(" nothing to run");
86
+ } else {
87
+ for (const [index, wave] of plan.waves.entries()) {
88
+ write(` wave ${index + 1}: ${wave.join(", ")}`);
89
+ }
90
+ }
91
+
92
+ if (plan.seeds.length > 0) {
93
+ write(` seeded from an earlier run: ${plan.seeds.join(", ")}`);
94
+ }
95
+
96
+ if (plan.skipped.length > 0) {
97
+ write(` not running (${plan.skipped.length}):`);
98
+ const width = widestName(plan.skipped);
99
+ for (const skip of plan.skipped) {
100
+ write(` ${skip.component.padEnd(width)} ${reasonOf(skip)}`);
101
+ }
102
+ }
103
+
104
+ if (plan.indeterminate.length > 0) {
105
+ // Named separately from the `indeterminate` rows above because the two say
106
+ // different things: those rows are "not selected", this line is "a source
107
+ // diff could not judge it and the walk never reached it either", which is
108
+ // the one outcome nobody should read as a decision.
109
+ write(
110
+ ` a source diff cannot judge these, and the walk did not reach them: ` +
111
+ plan.indeterminate.join(", "),
112
+ );
113
+ }
114
+
115
+ // Verbatim: this is the string `chant approve --plan` takes.
116
+ write(` plan: ${plan.digest}`);
117
+ if (options.gate) {
118
+ write(` approve: chant approve ${options.gate.op} ${options.gate.gate} --plan ${plan.digest}`);
119
+ }
120
+ }
121
+
122
+ /**
123
+ * A dispatched fan-out: the derivation that ran, then what it did.
124
+ *
125
+ * `result.plan` is the plan as dispatched, so on a resume this prints the
126
+ * narrowed one, with everything an earlier attempt finished listed as
127
+ * `already-applied`. That is the point of printing the plan off the result
128
+ * rather than off whatever the caller derived.
129
+ */
130
+ export function renderFanOutHuman(result: FanOutRunResult, options: FanOutRenderOptions = {}): void {
131
+ const write = options.write ?? stderr;
132
+ renderFanOutPlan(result.plan, { ...options, write });
133
+
134
+ if (result.status === "gated") {
135
+ const gate = result.gate;
136
+ write(`gated: nothing ran. ${gate ? `Waiting on "${gate.gate}" on "${gate.op}".` : "Waiting on an approval."}`);
137
+ if (gate?.expiresAt) write(` expires: ${gate.expiresAt}`);
138
+ return;
139
+ }
140
+
141
+ if (result.completed.length > 0) write(`applied: ${result.completed.join(", ")}`);
142
+ if (result.failed.length > 0) write(`failed: ${result.failed.join(", ")}`);
143
+ for (const blocked of result.blocked) {
144
+ write(` ${blocked.component}: ${reasonOf(blocked)}, so it never ran`);
145
+ }
146
+
147
+ const counts =
148
+ `${result.completed.length} applied, ${result.failed.length} failed, ` +
149
+ `${result.blocked.length} blocked`;
150
+ write(result.status === "ok" ? `fan-out completed: ${counts}` : `fan-out failed: ${counts}`);
151
+ }
152
+
153
+ /**
154
+ * The plan or the run result as JSON on stdout, and nothing else on stdout.
155
+ *
156
+ * One function for both because the run result carries the plan under `plan`,
157
+ * so a consumer discriminates on that key rather than on a flag it was told
158
+ * about out of band.
159
+ */
160
+ export function renderFanOutJson(payload: FanOutPlan | FanOutRunResult, write: Writer = stdout): void {
161
+ write(JSON.stringify(payload));
162
+ }
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Dispatching a derived fan-out (#2417).
3
+ *
4
+ * The behaviours here are the ones the issue said should not be implicit: one
5
+ * approval over the whole set, a failure that skips its own subtree and leaves
6
+ * independent branches alone, and a re-run that finishes without hand repair.
7
+ */
8
+
9
+ import { describe, test, expect } from "vitest";
10
+ import { CapabilityRegistry, type DeployContext } from "./capability";
11
+ import { memoryGateLedgerPort } from "../op/gate";
12
+ import { planFanOut } from "./fan-out";
13
+ import { runFanOut } from "./fan-out-run";
14
+ import type { DriverComponent } from "./driver";
15
+
16
+ /** A component whose single deploy step calls `kind`. */
17
+ const c = (name: string, kind: string, dependsOn?: string[]): DriverComponent => ({
18
+ name,
19
+ deploy: [{ phase: "Deploy", steps: [{ kind }] }],
20
+ ...(dependsOn ? { dependsOn } : {}),
21
+ });
22
+
23
+ /** net → cluster-a, cluster-b → apps. The issue's proof shape. */
24
+ const ESTATE: DriverComponent[] = [
25
+ c("net", "ok-step"),
26
+ c("cluster-a", "ok-step", ["net"]),
27
+ c("cluster-b", "ok-step", ["net"]),
28
+ c("app-one", "ok-step", ["cluster-a"]),
29
+ c("app-two", "ok-step", ["cluster-a"]),
30
+ c("app-three", "ok-step", ["cluster-b"]),
31
+ ];
32
+
33
+ function registryWith(failing: string[] = []): { registry: CapabilityRegistry; ran: string[] } {
34
+ const ran: string[] = [];
35
+ const registry = new CapabilityRegistry();
36
+ registry.register({
37
+ kind: "ok-step",
38
+ async run(ctx: DeployContext) {
39
+ ran.push(ctx.component);
40
+ if (failing.includes(ctx.component)) throw new Error(`${ctx.component} failed`);
41
+ return { ok: true };
42
+ },
43
+ } as never);
44
+ return { registry, ran };
45
+ }
46
+
47
+ const opts = () => ({ env: "test", gates: memoryGateLedgerPort(), now: "2026-01-01T00:00:00Z" });
48
+
49
+ describe("dispatching the plan", () => {
50
+ test("every downstream component runs, in derived order, and nothing else", async () => {
51
+ const estate = [...ESTATE, c("billing", "ok-step")];
52
+ const plan = planFanOut({ components: estate, changed: ["net"] });
53
+ const { registry, ran } = registryWith();
54
+
55
+ const result = await runFanOut(plan, estate, registry, opts());
56
+
57
+ expect(result.status).toBe("ok");
58
+ expect(ran).toContain("net");
59
+ expect(ran).not.toContain("billing");
60
+ // No apply preceded its dependency.
61
+ expect(ran.indexOf("net")).toBeLessThan(ran.indexOf("cluster-a"));
62
+ expect(ran.indexOf("cluster-a")).toBeLessThan(ran.indexOf("app-one"));
63
+ expect(result.completed).toEqual(["app-one", "app-three", "app-two", "cluster-a", "cluster-b", "net"]);
64
+ });
65
+
66
+ test("a change to a leaf propagates to nothing", async () => {
67
+ const plan = planFanOut({ components: ESTATE, changed: ["app-three"] });
68
+ const { registry, ran } = registryWith();
69
+ await runFanOut(plan, ESTATE, registry, opts());
70
+ expect(ran).toEqual(["app-three"]);
71
+ });
72
+ });
73
+
74
+ describe("partial failure", () => {
75
+ test("a failure skips its own subtree and leaves independent branches alone", async () => {
76
+ const plan = planFanOut({ components: ESTATE, changed: ["net"] });
77
+ const { registry, ran } = registryWith(["cluster-a"]);
78
+
79
+ const result = await runFanOut(plan, ESTATE, registry, opts());
80
+
81
+ expect(result.status).toBe("fail");
82
+ expect(result.failed).toEqual(["cluster-a"]);
83
+ // cluster-a's two apps never ran; cluster-b's branch finished.
84
+ expect(ran).not.toContain("app-one");
85
+ expect(ran).not.toContain("app-two");
86
+ expect(ran).toContain("app-three");
87
+ expect(result.completed).toContain("app-three");
88
+ });
89
+
90
+ test("a blocked component is reported as blocked, naming the failure to fix", async () => {
91
+ const plan = planFanOut({ components: ESTATE, changed: ["net"] });
92
+ const { registry } = registryWith(["cluster-a"]);
93
+ const result = await runFanOut(plan, ESTATE, registry, opts());
94
+ expect(result.blocked).toEqual([
95
+ { component: "app-one", reason: "blocked", blockedBy: "cluster-a" },
96
+ { component: "app-two", reason: "blocked", blockedBy: "cluster-a" },
97
+ ]);
98
+ });
99
+ });
100
+
101
+ describe("resuming", () => {
102
+ test("a re-run finishes without redoing what already applied", async () => {
103
+ const plan = planFanOut({ components: ESTATE, changed: ["net"] });
104
+ const first = await runFanOut(plan, ESTATE, registryWith(["cluster-a"]).registry, opts());
105
+ expect(first.status).toBe("fail");
106
+
107
+ // Whatever was wrong with cluster-a is fixed; run the same plan again.
108
+ const { registry, ran } = registryWith();
109
+ const second = await runFanOut(plan, ESTATE, registry, {
110
+ ...opts(),
111
+ progress: { completed: first.completed, failed: [] },
112
+ });
113
+
114
+ expect(second.status).toBe("ok");
115
+ // Only the four that had not applied, and no hand repair.
116
+ expect(ran.sort()).toEqual(["app-one", "app-two", "cluster-a"]);
117
+ expect(second.plan.skipped).toContainEqual({ component: "net", reason: "already-applied" });
118
+ });
119
+ });
120
+
121
+ describe("one approval over the whole set", () => {
122
+ const gate = { op: "fan-out", gate: "approve-fan-out" };
123
+
124
+ test("an unapproved gate stops before anything runs", async () => {
125
+ const plan = planFanOut({ components: ESTATE, changed: ["net"] });
126
+ const { registry, ran } = registryWith();
127
+
128
+ const result = await runFanOut(plan, ESTATE, registry, { ...opts(), gate });
129
+
130
+ expect(result.status).toBe("gated");
131
+ expect(result.gate?.gate).toBe("approve-fan-out");
132
+ // Nothing is half-applied, so there is nothing to compensate for.
133
+ expect(ran).toEqual([]);
134
+ });
135
+
136
+ /** A ledger that already holds the pending fact from a first run, plus an approval of `digest`. */
137
+ const approvedPort = (digest: string | undefined) =>
138
+ memoryGateLedgerPort({
139
+ pending: [{
140
+ version: 1, kind: "pending", op: "fan-out", gate: "approve-fan-out",
141
+ timestamp: "2026-01-01T00:00:00.000Z", expiresAt: "2026-01-03T00:00:00.000Z",
142
+ ...(digest ? { planDigest: digest } : {}),
143
+ }],
144
+ resolutions: [{
145
+ version: 1, op: "fan-out", gate: "approve-fan-out",
146
+ resolvedBy: "alex", timestamp: "2026-01-01T00:01:00.000Z",
147
+ ...(digest ? { planDigest: digest } : {}),
148
+ }],
149
+ });
150
+
151
+ test("one approval covers every component in the set", async () => {
152
+ const plan = planFanOut({ components: ESTATE, changed: ["net"] });
153
+ const { registry, ran } = registryWith();
154
+
155
+ const result = await runFanOut(plan, ESTATE, registry, {
156
+ env: "test", now: "2026-01-01T00:02:00Z", gates: approvedPort(plan.digest), gate,
157
+ });
158
+
159
+ // Six components, one approval.
160
+ expect(result.status).toBe("ok");
161
+ expect(ran).toHaveLength(6);
162
+ });
163
+
164
+ test("the approval is bound to the fan-out that was derived, not to the next run", async () => {
165
+ const approved = planFanOut({ components: ESTATE, changed: ["net"] });
166
+ // A different change derives a different fan-out. The standing approval
167
+ // named the other one, so this stops rather than riding on it.
168
+ const different = planFanOut({ components: ESTATE, changed: ["cluster-b"] });
169
+ expect(different.digest).not.toBe(approved.digest);
170
+
171
+ const { registry, ran } = registryWith();
172
+ const result = await runFanOut(different, ESTATE, registry, {
173
+ env: "test", now: "2026-01-01T00:02:00Z", gates: approvedPort(approved.digest), gate,
174
+ });
175
+
176
+ expect(result.status).toBe("gated");
177
+ expect(ran).toEqual([]);
178
+ });
179
+
180
+ test("an approval survives a resume, because the digest is carried not recomputed", async () => {
181
+ const plan = planFanOut({ components: ESTATE, changed: ["net"] });
182
+ const { registry, ran } = registryWith();
183
+
184
+ // Half of it already applied in an earlier attempt. The gate must still
185
+ // pass on the same approval.
186
+ const result = await runFanOut(plan, ESTATE, registry, {
187
+ env: "test", now: "2026-01-01T00:02:00Z", gates: approvedPort(plan.digest), gate,
188
+ progress: { completed: ["net", "cluster-a"] },
189
+ });
190
+
191
+ expect(result.status).toBe("ok");
192
+ expect(ran.sort()).toEqual(["app-one", "app-three", "app-two", "cluster-b"]);
193
+ });
194
+ });