@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
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fan a change out downstream, in an order derived from the source (#2417).
|
|
3
|
+
*
|
|
4
|
+
* The order was never the missing piece. `resolveComponentGraph` (./driver.ts)
|
|
5
|
+
* already Kahn-layers a component set by `dependsOn` into parallel-safe waves,
|
|
6
|
+
* flattens to a topological order, refuses a cycle by name and refuses a
|
|
7
|
+
* `dependsOn` it has not been given. What it cannot do is order a *subset*:
|
|
8
|
+
* hand it the affected components alone and it throws `UnknownDependencyError`,
|
|
9
|
+
* because a selected component still names a dependency that is not in the set.
|
|
10
|
+
*
|
|
11
|
+
* That is what this module is. Given every component in the project and the
|
|
12
|
+
* ones whose inputs moved, it derives who else has to run, in what order, what
|
|
13
|
+
* has to be seeded because it is deliberately not running, and one digest that
|
|
14
|
+
* identifies the whole derivation so a single gate can be bound to it.
|
|
15
|
+
*
|
|
16
|
+
* ## Why derived rather than declared
|
|
17
|
+
*
|
|
18
|
+
* The competing shape is a registry where you write down that component B
|
|
19
|
+
* depends on component A. That registry is a second statement of a relationship
|
|
20
|
+
* the source already makes, and it is wrong the first time somebody adds a
|
|
21
|
+
* reference without updating it. `dependsOn` is in the component; the graph is
|
|
22
|
+
* a walk over it.
|
|
23
|
+
*
|
|
24
|
+
* ## Three outcomes, never two
|
|
25
|
+
*
|
|
26
|
+
* A component is selected, skipped as unaffected, or **indeterminate** — the
|
|
27
|
+
* same third answer `../lifecycle/affected.ts` already refuses to collapse. A
|
|
28
|
+
* component whose inputs arrive at deploy time cannot be judged from a source
|
|
29
|
+
* diff, so it is reported rather than guessed at in either direction. Fanning
|
|
30
|
+
* out to everything on any change is easy to build, worth nothing, and reads
|
|
31
|
+
* exactly like diligence in a log.
|
|
32
|
+
*
|
|
33
|
+
* This plans; it does not run. Executing the plan is ./driver.ts's job.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { resolveComponentGraph, type DriverComponent } from "./driver";
|
|
37
|
+
import { deployUnits } from "./deploy-units";
|
|
38
|
+
import { computePlanDigest } from "../lifecycle/plan-digest";
|
|
39
|
+
|
|
40
|
+
/** Thrown when `changed` or `indeterminate` names a component the project does not have. */
|
|
41
|
+
export class UnknownComponentError extends Error {
|
|
42
|
+
constructor(
|
|
43
|
+
readonly field: "changed" | "indeterminate",
|
|
44
|
+
readonly component: string,
|
|
45
|
+
known: string[],
|
|
46
|
+
) {
|
|
47
|
+
super(
|
|
48
|
+
`${field} names "${component}", which is not a component in this project ` +
|
|
49
|
+
`(known: ${known.join(", ") || "none"})`,
|
|
50
|
+
);
|
|
51
|
+
this.name = "UnknownComponentError";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Why a component is in the plan but not running. */
|
|
56
|
+
export type FanOutSkipReason =
|
|
57
|
+
/** Nothing it depends on moved, and it did not move itself. */
|
|
58
|
+
| "unaffected"
|
|
59
|
+
/** Its inputs arrive at deploy time, so a source diff cannot judge it. */
|
|
60
|
+
| "indeterminate"
|
|
61
|
+
/** It already applied in an earlier attempt at this same fan-out. */
|
|
62
|
+
| "already-applied"
|
|
63
|
+
/** Something it depends on failed, so the value it would read never landed. */
|
|
64
|
+
| "blocked";
|
|
65
|
+
|
|
66
|
+
export interface FanOutSkip {
|
|
67
|
+
component: string;
|
|
68
|
+
reason: FanOutSkipReason;
|
|
69
|
+
/** For `blocked`, the failed component the walk reached this one from. */
|
|
70
|
+
blockedBy?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface FanOutRequest {
|
|
74
|
+
/** Every component in the project, `dependsOn` intact. The full graph is what makes a subset orderable. */
|
|
75
|
+
components: DriverComponent[];
|
|
76
|
+
/** Components whose own inputs moved. The walk starts here. */
|
|
77
|
+
changed: string[];
|
|
78
|
+
/**
|
|
79
|
+
* Components a source diff cannot judge (`../lifecycle/affected.ts`'s third
|
|
80
|
+
* category). One that the walk reaches anyway is selected like any other
|
|
81
|
+
* dependent — reachability is a fact about the graph, not about whether the
|
|
82
|
+
* component's own inputs could be read. One the walk does not reach is
|
|
83
|
+
* reported, never decided.
|
|
84
|
+
*/
|
|
85
|
+
indeterminate?: string[];
|
|
86
|
+
/**
|
|
87
|
+
* Per-component input identity, when the caller has it. Folded into
|
|
88
|
+
* {@link FanOutPlan.digest} so an approval is bound to *what* would be
|
|
89
|
+
* applied and not only to who would run. Absent, the digest still identifies
|
|
90
|
+
* the derivation — the selection, the order and the edges it came from.
|
|
91
|
+
*/
|
|
92
|
+
inputDigests?: Record<string, string>;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface FanOutPlan {
|
|
96
|
+
/** The components to run, every dependency before its dependents. */
|
|
97
|
+
order: string[];
|
|
98
|
+
/**
|
|
99
|
+
* Parallel-safe waves over the selected set. A dependency that is not
|
|
100
|
+
* selected is already satisfied — its outputs are seeded — so it does not
|
|
101
|
+
* hold its dependents back a wave.
|
|
102
|
+
*/
|
|
103
|
+
waves: string[][];
|
|
104
|
+
/** Components deliberately not running, with why. Sorted by name. */
|
|
105
|
+
skipped: FanOutSkip[];
|
|
106
|
+
/**
|
|
107
|
+
* Dependencies of selected components that are not themselves selected.
|
|
108
|
+
* Their outputs have to be seeded for a reference to resolve, which is the
|
|
109
|
+
* price of running a subset rather than the whole graph.
|
|
110
|
+
*/
|
|
111
|
+
seeds: string[];
|
|
112
|
+
/** Components a source diff could not judge and the walk did not reach. Reported, never decided. */
|
|
113
|
+
indeterminate: string[];
|
|
114
|
+
/** Identity of this derivation, for binding one approval to the whole fan-out (#2300's pattern). */
|
|
115
|
+
digest: string;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Reverse the `dependsOn` edges: who has to re-run when this one moves. */
|
|
119
|
+
function consumersOf(components: DriverComponent[]): Map<string, string[]> {
|
|
120
|
+
const consumers = new Map<string, string[]>();
|
|
121
|
+
for (const c of components) {
|
|
122
|
+
for (const dep of c.dependsOn ?? []) {
|
|
123
|
+
const existing = consumers.get(dep);
|
|
124
|
+
if (existing) existing.push(c.name);
|
|
125
|
+
else consumers.set(dep, [c.name]);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return consumers;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Derive the fan-out for a change.
|
|
133
|
+
*
|
|
134
|
+
* Refuses a cycle and an unknown `dependsOn` before selecting anything, by
|
|
135
|
+
* resolving the **full** graph first — a broken graph is a broken graph whether
|
|
136
|
+
* or not the change happens to touch the broken part, and finding out halfway
|
|
137
|
+
* through a fan-out is worse than finding out before it starts.
|
|
138
|
+
*/
|
|
139
|
+
export function planFanOut(request: FanOutRequest): FanOutPlan {
|
|
140
|
+
const { components, changed, indeterminate = [], inputDigests } = request;
|
|
141
|
+
|
|
142
|
+
// Refuses DependencyCycleError / UnknownDependencyError over the whole graph.
|
|
143
|
+
// Its `order` is deliberately not used: `topoSort` walks in declaration
|
|
144
|
+
// order, so two projects with the same graph and a different file layout
|
|
145
|
+
// would derive different-looking fan-outs and digest differently. The waves
|
|
146
|
+
// below are canonical, and this plan's order is their flattening.
|
|
147
|
+
resolveComponentGraph(components);
|
|
148
|
+
|
|
149
|
+
const byName = new Map(components.map((c) => [c.name, c]));
|
|
150
|
+
for (const name of changed) {
|
|
151
|
+
if (!byName.has(name)) throw new UnknownComponentError("changed", name, [...byName.keys()].sort());
|
|
152
|
+
}
|
|
153
|
+
for (const name of indeterminate) {
|
|
154
|
+
if (!byName.has(name)) throw new UnknownComponentError("indeterminate", name, [...byName.keys()].sort());
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Everything reachable downstream of a changed component, transitively. A
|
|
158
|
+
// component reached by two paths is added once, which is the diamond case.
|
|
159
|
+
const consumers = consumersOf(components);
|
|
160
|
+
const selected = new Set<string>(changed);
|
|
161
|
+
const queue = [...changed];
|
|
162
|
+
while (queue.length > 0) {
|
|
163
|
+
const node = queue.shift()!;
|
|
164
|
+
for (const consumer of consumers.get(node) ?? []) {
|
|
165
|
+
if (selected.has(consumer)) continue;
|
|
166
|
+
selected.add(consumer);
|
|
167
|
+
queue.push(consumer);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Waves over the selected subgraph only. An unselected dependency is already
|
|
172
|
+
// applied, so it is not a reason for its dependents to wait.
|
|
173
|
+
const remaining = new Set(selected);
|
|
174
|
+
const selectedDeps = new Map(
|
|
175
|
+
[...selected].map((name) => [name, new Set((byName.get(name)!.dependsOn ?? []).filter((d) => selected.has(d)))]),
|
|
176
|
+
);
|
|
177
|
+
const waves: string[][] = [];
|
|
178
|
+
while (remaining.size > 0) {
|
|
179
|
+
const wave = [...remaining]
|
|
180
|
+
.filter((n) => [...selectedDeps.get(n)!].every((d) => !remaining.has(d)))
|
|
181
|
+
.sort();
|
|
182
|
+
// resolveComponentGraph already refused every cycle in the full graph, and
|
|
183
|
+
// a subgraph of an acyclic graph is acyclic, so this cannot stall.
|
|
184
|
+
for (const n of wave) remaining.delete(n);
|
|
185
|
+
waves.push(wave);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Every dependency lands in an earlier wave than its dependents, so the
|
|
189
|
+
// flattening is a topological order, and a sorted one.
|
|
190
|
+
const order = waves.flat();
|
|
191
|
+
|
|
192
|
+
const seeds = [
|
|
193
|
+
...new Set(order.flatMap((name) => (byName.get(name)!.dependsOn ?? []).filter((d) => !selected.has(d)))),
|
|
194
|
+
].sort();
|
|
195
|
+
|
|
196
|
+
const unreachedIndeterminate = indeterminate.filter((name) => !selected.has(name)).sort();
|
|
197
|
+
const indeterminateSet = new Set(unreachedIndeterminate);
|
|
198
|
+
const skipped: FanOutSkip[] = [...byName.keys()]
|
|
199
|
+
.filter((name) => !selected.has(name))
|
|
200
|
+
.sort()
|
|
201
|
+
.map((component) => ({
|
|
202
|
+
component,
|
|
203
|
+
reason: indeterminateSet.has(component) ? ("indeterminate" as const) : ("unaffected" as const),
|
|
204
|
+
}));
|
|
205
|
+
|
|
206
|
+
// What the approver is approving: who runs, in what order, off which edges,
|
|
207
|
+
// and — when the caller knows it — what each one would apply. Never the run
|
|
208
|
+
// id or the moment, per ../lifecycle/plan-digest.ts's rules, so re-deriving
|
|
209
|
+
// an unchanged fan-out does not expire an approval.
|
|
210
|
+
const digest = computePlanDigest("component-fan-out", {
|
|
211
|
+
order,
|
|
212
|
+
waves,
|
|
213
|
+
seeds,
|
|
214
|
+
edges: order.map((name) => ({ component: name, dependsOn: [...(byName.get(name)!.dependsOn ?? [])].sort() })),
|
|
215
|
+
...(inputDigests
|
|
216
|
+
? { inputs: order.map((name) => ({ component: name, digest: inputDigests[name] ?? null })) }
|
|
217
|
+
: {}),
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
return { order, waves, skipped, seeds, indeterminate: unreachedIndeterminate, digest };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ── Joining the change signal to components ──────────────────────────────────
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* `../lifecycle/affected.ts`'s answer, which is about **stacks**.
|
|
227
|
+
*
|
|
228
|
+
* `AffectedResult` itself is not imported: this takes the two fields the join
|
|
229
|
+
* needs, so a caller can also hand in a signal that did not come from a git
|
|
230
|
+
* diff (a CI system's own changed-paths answer, an operator naming a stack by
|
|
231
|
+
* hand) without manufacturing the rest of that shape.
|
|
232
|
+
*/
|
|
233
|
+
export interface ChangedUnits {
|
|
234
|
+
/** Stacks whose built artifact moved between base and head. */
|
|
235
|
+
changed: string[];
|
|
236
|
+
/** Stacks a source diff could not judge, because their inputs arrive at deploy time. */
|
|
237
|
+
indeterminate?: string[];
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** What {@link componentsForUnits} resolved, ready to hand to {@link planFanOut}. */
|
|
241
|
+
export interface ComponentChangeSignal {
|
|
242
|
+
/** Components deploying at least one changed unit. */
|
|
243
|
+
changed: string[];
|
|
244
|
+
/** Components deploying no changed unit but at least one indeterminate one. */
|
|
245
|
+
indeterminate: string[];
|
|
246
|
+
/**
|
|
247
|
+
* Changed or indeterminate units no component claims. Reported rather than
|
|
248
|
+
* dropped: a stack that moved and belongs to nothing this project deploys is
|
|
249
|
+
* a hole in the fan-out's coverage, and a silent one is the failure mode
|
|
250
|
+
* this whole feature exists to avoid.
|
|
251
|
+
*/
|
|
252
|
+
unclaimed: string[];
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Join a stack-level change signal to the components that deploy those stacks.
|
|
257
|
+
*
|
|
258
|
+
* The key is `deployUnits` (./deploy-units.ts), which already answers "what
|
|
259
|
+
* live units does this component's composition target" for `chant components
|
|
260
|
+
* status --live`. Reusing it means a component's claim on a stack is stated
|
|
261
|
+
* once, by its deploy steps, rather than restated in a fan-out config — the
|
|
262
|
+
* same argument this module's doc makes about `dependsOn`.
|
|
263
|
+
*
|
|
264
|
+
* Changed beats indeterminate. A component deploying one stack that certainly
|
|
265
|
+
* moved and another that could not be judged is changed: the certainty already
|
|
266
|
+
* decides it, and reporting it as indeterminate would lose that.
|
|
267
|
+
*/
|
|
268
|
+
export function componentsForUnits(
|
|
269
|
+
components: DriverComponent[],
|
|
270
|
+
units: ChangedUnits,
|
|
271
|
+
): ComponentChangeSignal {
|
|
272
|
+
const changedUnits = new Set(units.changed);
|
|
273
|
+
const indeterminateUnits = new Set(units.indeterminate ?? []);
|
|
274
|
+
|
|
275
|
+
const changed: string[] = [];
|
|
276
|
+
const indeterminate: string[] = [];
|
|
277
|
+
const claimed = new Set<string>();
|
|
278
|
+
|
|
279
|
+
for (const component of components) {
|
|
280
|
+
const names = deployUnits(component.deploy).map((u) => u.unit);
|
|
281
|
+
let touchesChanged = false;
|
|
282
|
+
let touchesIndeterminate = false;
|
|
283
|
+
for (const name of names) {
|
|
284
|
+
if (changedUnits.has(name)) {
|
|
285
|
+
touchesChanged = true;
|
|
286
|
+
claimed.add(name);
|
|
287
|
+
}
|
|
288
|
+
if (indeterminateUnits.has(name)) {
|
|
289
|
+
touchesIndeterminate = true;
|
|
290
|
+
claimed.add(name);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if (touchesChanged) changed.push(component.name);
|
|
294
|
+
else if (touchesIndeterminate) indeterminate.push(component.name);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const unclaimed = [...changedUnits, ...indeterminateUnits].filter((u) => !claimed.has(u));
|
|
298
|
+
|
|
299
|
+
return {
|
|
300
|
+
changed: changed.sort(),
|
|
301
|
+
indeterminate: indeterminate.sort(),
|
|
302
|
+
unclaimed: [...new Set(unclaimed)].sort(),
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Components inside `within` that are transitively downstream of any of
|
|
308
|
+
* `roots`, each mapped to the **root** it was reached from rather than to its
|
|
309
|
+
* immediate parent — so a report names the one thing to fix instead of the
|
|
310
|
+
* nearest consequence of it.
|
|
311
|
+
*
|
|
312
|
+
* `roots` is sorted before the walk, so a component downstream of two separate
|
|
313
|
+
* roots always names the same one. Unsorted, the answer would depend on the
|
|
314
|
+
* order the caller listed them in, which is not a fact about anything.
|
|
315
|
+
*
|
|
316
|
+
* Shared by {@link remainingFanOut}, which needs it over a whole plan, and by
|
|
317
|
+
* the runner in ./fan-out-run.ts, which needs it as failures accumulate.
|
|
318
|
+
*/
|
|
319
|
+
export function downstreamWithin(
|
|
320
|
+
components: DriverComponent[],
|
|
321
|
+
within: Iterable<string>,
|
|
322
|
+
roots: readonly string[],
|
|
323
|
+
): Map<string, string> {
|
|
324
|
+
const inside = new Set(within);
|
|
325
|
+
const consumers = consumersOf(components);
|
|
326
|
+
const rootSet = new Set(roots);
|
|
327
|
+
const reached = new Map<string, string>();
|
|
328
|
+
const queue = [...roots].sort();
|
|
329
|
+
while (queue.length > 0) {
|
|
330
|
+
const node = queue.shift()!;
|
|
331
|
+
const blamed = rootSet.has(node) ? node : reached.get(node)!;
|
|
332
|
+
for (const consumer of consumers.get(node) ?? []) {
|
|
333
|
+
if (!inside.has(consumer) || rootSet.has(consumer) || reached.has(consumer)) continue;
|
|
334
|
+
reached.set(consumer, blamed);
|
|
335
|
+
queue.push(consumer);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return reached;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ── Finishing a fan-out that stopped ─────────────────────────────────────────
|
|
342
|
+
|
|
343
|
+
export interface FanOutProgress {
|
|
344
|
+
/** Components that reached `ok` in an earlier attempt at this same plan. */
|
|
345
|
+
completed?: string[];
|
|
346
|
+
/** Components that failed. Everything downstream of one is blocked, not failed. */
|
|
347
|
+
failed?: string[];
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Narrow a plan to what still has to run.
|
|
352
|
+
*
|
|
353
|
+
* **The digest does not change.** That is the whole point: an operator approved
|
|
354
|
+
* a fan-out, a component in the middle of it failed, and finishing the work
|
|
355
|
+
* they already approved must not ask them to approve it again. `remainingFanOut`
|
|
356
|
+
* returns the same `digest` the original derivation produced, so the standing
|
|
357
|
+
* resolution still satisfies the gate on the next attempt. Re-deriving with
|
|
358
|
+
* {@link planFanOut} would mint a new identity and invalidate the approval,
|
|
359
|
+
* which is why resume narrows a plan rather than recomputing one.
|
|
360
|
+
*
|
|
361
|
+
* **A component beneath a failure is `blocked`, never `failed`.** Nothing about
|
|
362
|
+
* it failed. It did not run because the value it would have read never landed,
|
|
363
|
+
* and the distinction is what makes the next attempt legible: an operator
|
|
364
|
+
* reading `blocked by "cluster-a"` knows to fix one thing, not fourteen.
|
|
365
|
+
*
|
|
366
|
+
* **Independent branches keep going.** The order was derived from the source,
|
|
367
|
+
* so a branch that shares no edge with the failure is *known* to be independent
|
|
368
|
+
* rather than assumed to be. Stopping it is the conservative-looking choice
|
|
369
|
+
* that throws away the reason for deriving the graph in the first place.
|
|
370
|
+
*/
|
|
371
|
+
export function remainingFanOut(
|
|
372
|
+
plan: FanOutPlan,
|
|
373
|
+
components: DriverComponent[],
|
|
374
|
+
progress: FanOutProgress,
|
|
375
|
+
): FanOutPlan {
|
|
376
|
+
const byName = new Map(components.map((c) => [c.name, c]));
|
|
377
|
+
const planned = new Set(plan.order);
|
|
378
|
+
const completed = new Set((progress.completed ?? []).filter((n) => planned.has(n)));
|
|
379
|
+
const failed = new Set((progress.failed ?? []).filter((n) => planned.has(n)));
|
|
380
|
+
|
|
381
|
+
const blockedBy = downstreamWithin(components, planned, [...failed]);
|
|
382
|
+
|
|
383
|
+
const runnable = new Set(
|
|
384
|
+
plan.order.filter((n) => !completed.has(n) && !failed.has(n) && !blockedBy.has(n)),
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
// Re-layer what is left. A dependency that already applied is satisfied, so
|
|
388
|
+
// it does not hold its dependents back — the same rule the original
|
|
389
|
+
// derivation applies to a dependency outside the selection.
|
|
390
|
+
const remaining = new Set(runnable);
|
|
391
|
+
const deps = new Map(
|
|
392
|
+
[...runnable].map((n) => [n, new Set((byName.get(n)?.dependsOn ?? []).filter((d) => runnable.has(d)))]),
|
|
393
|
+
);
|
|
394
|
+
const waves: string[][] = [];
|
|
395
|
+
while (remaining.size > 0) {
|
|
396
|
+
const wave = [...remaining].filter((n) => [...deps.get(n)!].every((d) => !remaining.has(d))).sort();
|
|
397
|
+
for (const n of wave) remaining.delete(n);
|
|
398
|
+
waves.push(wave);
|
|
399
|
+
}
|
|
400
|
+
const order = waves.flat();
|
|
401
|
+
|
|
402
|
+
// A completed component's outputs have to be seeded for a reference to
|
|
403
|
+
// resolve, exactly like a component that was never selected.
|
|
404
|
+
const seeds = [
|
|
405
|
+
...new Set([
|
|
406
|
+
...plan.seeds,
|
|
407
|
+
...order.flatMap((n) => (byName.get(n)?.dependsOn ?? []).filter((d) => !runnable.has(d))),
|
|
408
|
+
]),
|
|
409
|
+
].sort();
|
|
410
|
+
|
|
411
|
+
const carried = plan.skipped.filter((s) => !runnable.has(s.component));
|
|
412
|
+
const skipped: FanOutSkip[] = [
|
|
413
|
+
...carried,
|
|
414
|
+
...[...completed].sort().map((component) => ({ component, reason: "already-applied" as const })),
|
|
415
|
+
...[...blockedBy.entries()]
|
|
416
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
417
|
+
.map(([component, by]) => ({ component, reason: "blocked" as const, blockedBy: by })),
|
|
418
|
+
].sort((a, b) => a.component.localeCompare(b.component));
|
|
419
|
+
|
|
420
|
+
return { order, waves, skipped, seeds, indeterminate: plan.indeterminate, digest: plan.digest };
|
|
421
|
+
}
|
package/src/components/index.ts
CHANGED
|
@@ -100,6 +100,39 @@ export {
|
|
|
100
100
|
UnknownDependencyError,
|
|
101
101
|
DriverRunFailure,
|
|
102
102
|
} from "./driver";
|
|
103
|
+
export {
|
|
104
|
+
type FanOutRequest,
|
|
105
|
+
type FanOutPlan,
|
|
106
|
+
type FanOutSkip,
|
|
107
|
+
type FanOutSkipReason,
|
|
108
|
+
type ChangedUnits,
|
|
109
|
+
type ComponentChangeSignal,
|
|
110
|
+
type FanOutProgress,
|
|
111
|
+
planFanOut,
|
|
112
|
+
componentsForUnits,
|
|
113
|
+
remainingFanOut,
|
|
114
|
+
downstreamWithin,
|
|
115
|
+
UnknownComponentError,
|
|
116
|
+
} from "./fan-out";
|
|
117
|
+
export {
|
|
118
|
+
type FanOutGate,
|
|
119
|
+
type FanOutRunOptions,
|
|
120
|
+
type FanOutRunResult,
|
|
121
|
+
runFanOut,
|
|
122
|
+
} from "./fan-out-run";
|
|
123
|
+
export {
|
|
124
|
+
type FanOutGateRef,
|
|
125
|
+
type FanOutRenderOptions,
|
|
126
|
+
renderFanOutPlan,
|
|
127
|
+
renderFanOutHuman,
|
|
128
|
+
renderFanOutJson,
|
|
129
|
+
} from "./fan-out-output";
|
|
130
|
+
export {
|
|
131
|
+
type DeriveFanOutOptions,
|
|
132
|
+
type DerivedFanOut,
|
|
133
|
+
deriveFanOut,
|
|
134
|
+
fanOutRegistry,
|
|
135
|
+
} from "./fan-out-support";
|
|
103
136
|
export {
|
|
104
137
|
EcsFargateComponent,
|
|
105
138
|
type EcsFargateComponentConfig,
|