@intentius/chant 0.70.0 → 0.71.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/fan-out.d.ts +45 -0
- package/dist/cli/handlers/fan-out.d.ts.map +1 -0
- package/dist/cli/handlers/lifecycle.d.ts +13 -0
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/run.d.ts +35 -0
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +23 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/deploy-units.d.ts +12 -2
- package/dist/components/deploy-units.d.ts.map +1 -1
- package/dist/components/fan-out-output.d.ts +70 -0
- package/dist/components/fan-out-output.d.ts.map +1 -0
- package/dist/components/fan-out-run.d.ts +80 -0
- package/dist/components/fan-out-run.d.ts.map +1 -0
- package/dist/components/fan-out-support.d.ts +65 -0
- package/dist/components/fan-out-support.d.ts.map +1 -0
- package/dist/components/fan-out.d.ts +194 -0
- package/dist/components/fan-out.d.ts.map +1 -0
- package/dist/components/index.d.ts +4 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/discovery/fold-import.d.ts +96 -1
- package/dist/discovery/fold-import.d.ts.map +1 -1
- package/dist/discovery/index.d.ts.map +1 -1
- package/dist/fold/subset.d.ts +22 -0
- package/dist/fold/subset.d.ts.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/lifecycle/affected.d.ts +26 -0
- package/dist/lifecycle/affected.d.ts.map +1 -1
- package/dist/op/activities/activity-contracts.d.ts +16 -1
- package/dist/op/activities/activity-contracts.d.ts.map +1 -1
- package/dist/op/activities/index.d.ts +1 -1
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/shell.d.ts +31 -2
- package/dist/op/activities/shell.d.ts.map +1 -1
- package/dist/op/activity-contract.d.ts +1 -1
- package/dist/op/activity-contract.d.ts.map +1 -1
- package/dist/op/activity-profiles.d.ts +19 -0
- package/dist/op/activity-profiles.d.ts.map +1 -1
- package/dist/op/builders.d.ts +21 -3
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/gate-name.d.ts +10 -0
- package/dist/op/gate-name.d.ts.map +1 -1
- package/dist/op/step-output-ref.d.ts +25 -8
- package/dist/op/step-output-ref.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/handlers/fan-out.test.ts +394 -0
- package/src/cli/handlers/fan-out.ts +336 -0
- package/src/cli/handlers/lifecycle.test.ts +74 -1
- package/src/cli/handlers/lifecycle.ts +29 -1
- package/src/cli/handlers/operator.test.ts +22 -0
- package/src/cli/handlers/operator.ts +11 -3
- package/src/cli/handlers/run.ts +7 -1
- package/src/cli/main.ts +25 -3
- package/src/cli/registry.ts +23 -0
- package/src/components/deploy-units.ts +14 -4
- package/src/components/fan-out-output.test.ts +216 -0
- package/src/components/fan-out-output.ts +162 -0
- package/src/components/fan-out-run.test.ts +194 -0
- package/src/components/fan-out-run.ts +221 -0
- package/src/components/fan-out-support.test.ts +125 -0
- package/src/components/fan-out-support.ts +95 -0
- package/src/components/fan-out.test.ts +284 -0
- package/src/components/fan-out.ts +421 -0
- package/src/components/index.ts +33 -0
- package/src/discovery/fold-import.ts +195 -5
- package/src/discovery/index.ts +13 -5
- package/src/fold/subset-public-export.test.ts +31 -0
- package/src/fold/subset.ts +23 -0
- package/src/index.ts +18 -0
- package/src/lifecycle/affected.test.ts +118 -0
- package/src/lifecycle/affected.ts +118 -14
- package/src/op/activities/activity-contracts.ts +17 -1
- package/src/op/activities/index.ts +1 -1
- package/src/op/activities/shell.test.ts +156 -0
- package/src/op/activities/shell.ts +84 -9
- package/src/op/activity-profiles.test.ts +16 -2
- package/src/op/activity-profiles.ts +18 -0
- package/src/op/builders.ts +22 -4
- package/src/op/gate-name.ts +11 -0
- package/src/op/op-ir.test.ts +4 -1
- package/src/op/op.test.ts +7 -2
- package/src/op/step-output-ref.ts +29 -8
package/src/cli/registry.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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:
|
|
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
|
|
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
|
+
});
|