@intentius/chant 0.124.8 → 0.124.9
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/op/gate.d.ts +16 -1
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/op-waves-notify.d.ts +2 -1
- package/dist/op/op-waves-notify.d.ts.map +1 -1
- package/dist/op/op-waves-run.d.ts +57 -7
- package/dist/op/op-waves-run.d.ts.map +1 -1
- package/dist/op/op-waves.d.ts +42 -0
- package/dist/op/op-waves.d.ts.map +1 -1
- package/dist/workspace/identity.d.ts +3 -2
- package/dist/workspace/identity.d.ts.map +1 -1
- package/dist/workspace/write-scope.d.ts +3 -2
- package/dist/workspace/write-scope.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/pr-run.ts +1 -1
- package/src/lifecycle/converge-ledger.ts +1 -1
- package/src/lifecycle/gate-ledger.test.ts +37 -0
- package/src/lifecycle/gate-ledger.ts +1 -1
- package/src/lifecycle/git.ts +1 -1
- package/src/lifecycle/override-ledger.ts +1 -1
- package/src/lifecycle/run-ledger.ts +1 -1
- package/src/lifecycle/work-lease.ts +1 -1
- package/src/op/gate-review.test.ts +53 -1
- package/src/op/gate.ts +31 -9
- package/src/op/op-waves-notify.ts +13 -1
- package/src/op/op-waves-point.test.ts +254 -0
- package/src/op/op-waves-run.ts +231 -40
- package/src/op/op-waves.test.ts +95 -2
- package/src/op/op-waves.ts +68 -0
- package/src/workspace/identity.test.ts +4 -1
- package/src/workspace/identity.ts +4 -3
- package/src/workspace/write-scope.ts +4 -3
package/src/op/op-waves-run.ts
CHANGED
|
@@ -37,6 +37,15 @@
|
|
|
37
37
|
* {@link runOpWaveApplyHooks}, and their outcomes come back through
|
|
38
38
|
* `CHANT_HOOKS_RESULT`. A non-wave apply reads the same hooks at base with
|
|
39
39
|
* {@link opWaveHooksAtBase}.
|
|
40
|
+
* - Point (#3836): with `point` at base (the wave's, else the spec's), a wave
|
|
41
|
+
* that needs an approval asks that decision point through
|
|
42
|
+
* `evaluatePointGate` (`../point-decision.ts`) with `gate.planDigest` set to
|
|
43
|
+
* its set digest: an open question waits (exit 3, the pending fact cites
|
|
44
|
+
* it, `approve` is the `points answer` line), an answer the gate passes on
|
|
45
|
+
* records the resolution that cites it and the wave goes on, and any other
|
|
46
|
+
* answer refuses the wave (`refused.reason: "point"`, exit 8). Once the
|
|
47
|
+
* question is answered, `chant run resume --op <name>` starts the job again
|
|
48
|
+
* (`pointGateResumable`, #3835).
|
|
40
49
|
*/
|
|
41
50
|
|
|
42
51
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
@@ -44,8 +53,10 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node
|
|
|
44
53
|
import { dirname, isAbsolute, join, posix } from "node:path";
|
|
45
54
|
import { waveSetDigest, type WaveMember } from "../gated-waves";
|
|
46
55
|
import type { ReviewApproval } from "../gate-decision";
|
|
47
|
-
import { approveCommand, evaluateGate, gitGateLedgerPort, type GateDigestMismatch, type GateLedgerPort } from "./gate";
|
|
48
|
-
import type { PendingGateRecord } from "../lifecycle/gate-ledger";
|
|
56
|
+
import { approveCommand, evaluateGate, gitGateLedgerPort, type GateCheck, type GateDigestMismatch, type GateLedgerPort } from "./gate";
|
|
57
|
+
import type { GateAnswerRef, PendingGateRecord } from "../lifecycle/gate-ledger";
|
|
58
|
+
import type { GatePointAsker } from "../point-decision";
|
|
59
|
+
import type { WaitingPoint } from "./steward-points";
|
|
49
60
|
import { isApprovalSpent, type AppliedGateRecord } from "../lifecycle/gate-applied";
|
|
50
61
|
import type { HeadPlans, MergedReview, ReviewSource } from "./gate-review";
|
|
51
62
|
import type { TrustPolicy } from "../workspace/trust/policy";
|
|
@@ -60,6 +71,8 @@ import {
|
|
|
60
71
|
opWaveApplyHooksRunBy,
|
|
61
72
|
opWaveGateName,
|
|
62
73
|
opWaveHooksAt,
|
|
74
|
+
opWavePointAt,
|
|
75
|
+
opWavePointProblems,
|
|
63
76
|
opWaveRecordPath,
|
|
64
77
|
opWaveShare,
|
|
65
78
|
type GateApprovalSource,
|
|
@@ -68,6 +81,7 @@ import {
|
|
|
68
81
|
type OpWaveGatePolicy,
|
|
69
82
|
type OpWaveHook,
|
|
70
83
|
type OpWaveHookMoment,
|
|
84
|
+
type OpWavePoint,
|
|
71
85
|
type OpWaveRun,
|
|
72
86
|
type OpWavesSpec,
|
|
73
87
|
} from "./op-waves";
|
|
@@ -169,8 +183,14 @@ export interface OpWaveDecision {
|
|
|
169
183
|
*/
|
|
170
184
|
status: "approved" | "not-required" | "waiting" | "refused";
|
|
171
185
|
approvedBy?: string;
|
|
172
|
-
/** How the approval arrived: a resolution on the ledger,
|
|
173
|
-
via?: "ledger" | "pr-review";
|
|
186
|
+
/** How the approval arrived: a resolution on the ledger, the merged pull request's review (#3684), or a decision point's answer (#3836). */
|
|
187
|
+
via?: "ledger" | "pr-review" | "point";
|
|
188
|
+
/**
|
|
189
|
+
* The decision point the gate asks, as read at base (#3836), and what it
|
|
190
|
+
* said: the open `question` while the wave waits, the `answer` once it is
|
|
191
|
+
* answered (the one that passed, or the one that refused the wave).
|
|
192
|
+
*/
|
|
193
|
+
point?: OpWavePoint & { question?: WaitingPoint; answer?: GateAnswerRef };
|
|
174
194
|
/** The review that approved the wave, or why the review did not, under `pr-review`. */
|
|
175
195
|
review?: { pr?: number; head?: string; approvers?: string[]; url?: string; reason?: string };
|
|
176
196
|
/** The command that approves this digest, when waiting. */
|
|
@@ -208,8 +228,19 @@ export interface OpWaveDecision {
|
|
|
208
228
|
waitingSince?: string;
|
|
209
229
|
/** While waiting: the newest approval of this gate for another digest, which the plans moved away from. */
|
|
210
230
|
mismatch?: GateDigestMismatch;
|
|
211
|
-
/**
|
|
212
|
-
|
|
231
|
+
/**
|
|
232
|
+
* Why a `refused` wave applies nothing: the runs the policy denied, and
|
|
233
|
+
* whether an override stands for an earlier plan or other rules (#3821,
|
|
234
|
+
* exit 7); or `point`, the gate's decision point answered with an answer
|
|
235
|
+
* it does not pass on (#3836, exit 8), with every run and the `message`.
|
|
236
|
+
*/
|
|
237
|
+
refused?: {
|
|
238
|
+
reason: "policy" | "override-moved" | "point";
|
|
239
|
+
runs: string[];
|
|
240
|
+
message?: string;
|
|
241
|
+
/** Where the plans file said its overriders come from (#3848), when it did. */
|
|
242
|
+
overridersSource?: string;
|
|
243
|
+
};
|
|
213
244
|
/**
|
|
214
245
|
* A hook held the wave on another gate (#3821), such as a state migration
|
|
215
246
|
* behind its own: that gate, its digest, and how it came out. While it
|
|
@@ -267,6 +298,20 @@ export interface OpWaveHeldOn {
|
|
|
267
298
|
waitingSince?: string;
|
|
268
299
|
recorded?: boolean;
|
|
269
300
|
mismatch?: GateDigestMismatch;
|
|
301
|
+
/**
|
|
302
|
+
* The gates the same hook call held the wave on before this one, each
|
|
303
|
+
* approved, oldest first (#3847): its approved re-runs asked for this one.
|
|
304
|
+
*/
|
|
305
|
+
after?: OpWaveHeldOnApproval[];
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** An approved hold on another gate (#3847), as `CHANT_HOLD_APPROVED` and `CHANT_HOLDS_APPROVED` carry it. */
|
|
309
|
+
export interface OpWaveHeldOnApproval {
|
|
310
|
+
op: string;
|
|
311
|
+
gate: string;
|
|
312
|
+
digest: string;
|
|
313
|
+
approvedBy: string;
|
|
314
|
+
approvedAt: string;
|
|
270
315
|
}
|
|
271
316
|
|
|
272
317
|
/** One hook's run (#3762). */
|
|
@@ -426,6 +471,10 @@ export interface OpWaveBaseRules {
|
|
|
426
471
|
applyHooksRunBy: OpWaveApplyHooksRunner;
|
|
427
472
|
/** The spec at base sets `neverOverMcp` (#3821). */
|
|
428
473
|
neverOverMcp: boolean;
|
|
474
|
+
/** The decision point the wave's gate asks, as read at base (#3836): the wave's, else the spec's. */
|
|
475
|
+
point?: OpWavePoint;
|
|
476
|
+
/** Set when the point at base cannot be asked: the wave fails rather than fall back to the ledger (#3836). */
|
|
477
|
+
pointRefused?: string;
|
|
429
478
|
}
|
|
430
479
|
|
|
431
480
|
const noHooks = (): Record<OpWaveHookMoment, OpWaveHook[]> =>
|
|
@@ -457,12 +506,14 @@ export function readOpWavePolicy(
|
|
|
457
506
|
// The spend rule is the spec's, so it holds for a wave the base does not have yet.
|
|
458
507
|
const spend = (spec as { approvals?: { spend?: unknown } }).approvals?.spend === true;
|
|
459
508
|
const neverOverMcp = (spec as { neverOverMcp?: unknown }).neverOverMcp === true;
|
|
509
|
+
// #3836: the gate's decision point, the spec's for a wave the base does not have yet.
|
|
510
|
+
const asks = pointAtBase(spec, name, `${specFile} at ${sha.slice(0, 12)}`);
|
|
460
511
|
const wave = spec.waves.find((w) => w.name === name);
|
|
461
|
-
if (!wave) return { ...absent, spend, neverOverMcp };
|
|
512
|
+
if (!wave) return { ...absent, spend, neverOverMcp, ...asks };
|
|
462
513
|
const policy = wave.gate ?? "always";
|
|
463
514
|
// An approval mode this chant does not know reads as sealed: the strictest, so an unknown rule never loosens a gate.
|
|
464
515
|
const approval = wave.approval === undefined ? "ledger" : GATE_APPROVAL_SOURCES.includes(wave.approval) ? wave.approval : "sealed";
|
|
465
|
-
const rules: OpWaveBaseRules = { policy: OP_WAVE_GATE_POLICIES.includes(policy) ? policy : "always", approval, source: "base", base: sha, hooks: noHooks(), spend, applyHooksRunBy: "chant", neverOverMcp };
|
|
516
|
+
const rules: OpWaveBaseRules = { policy: OP_WAVE_GATE_POLICIES.includes(policy) ? policy : "always", approval, source: "base", base: sha, hooks: noHooks(), spend, applyHooksRunBy: "chant", neverOverMcp, ...asks };
|
|
466
517
|
try {
|
|
467
518
|
assertOpWaveHooks(`${specFile} at ${sha.slice(0, 12)}`, spec.hooks);
|
|
468
519
|
assertOpWaveHooks(`${specFile} at ${sha.slice(0, 12)}, wave "${name}"`, wave.hooks);
|
|
@@ -475,6 +526,15 @@ export function readOpWavePolicy(
|
|
|
475
526
|
return rules;
|
|
476
527
|
}
|
|
477
528
|
|
|
529
|
+
/** The point wave `name` asks at base (#3836), or why it cannot be asked. */
|
|
530
|
+
function pointAtBase(spec: OpWavesSpec, name: string, where: string): Pick<OpWaveBaseRules, "point" | "pointRefused"> {
|
|
531
|
+
const raw = spec.waves.find((w) => w.name === name)?.point ?? spec.point;
|
|
532
|
+
if (raw === undefined) return {};
|
|
533
|
+
const problems = opWavePointProblems(raw);
|
|
534
|
+
if (problems.length > 0) return { pointRefused: `the decision point at base cannot be asked (${where}: ${problems.join("; ")})` };
|
|
535
|
+
return { point: opWavePointAt(spec, name)! };
|
|
536
|
+
}
|
|
537
|
+
|
|
478
538
|
/** Fill one argv template. A placeholder whose value is not given is left as it is. */
|
|
479
539
|
export function opWaveArgv(
|
|
480
540
|
template: readonly string[],
|
|
@@ -533,6 +593,8 @@ export interface RunOpWaveOptions {
|
|
|
533
593
|
trust?: (base: string) => TrustPolicy;
|
|
534
594
|
/** The signers at base, for a sealed override of a policy denial (#3821). Default: `gateSignersAt` from `@intentius/chant/gate-decision`. */
|
|
535
595
|
signers?: (base: string) => GateSigners;
|
|
596
|
+
/** How a gate with a `point` asks it (#3836). Default: `workspaceGatePointAsker` over `cwd`. A tool that bundles chant passes `chantGatePointAsker`. */
|
|
597
|
+
points?: GatePointAsker;
|
|
536
598
|
}
|
|
537
599
|
|
|
538
600
|
export interface RunOpWaveResult {
|
|
@@ -541,7 +603,8 @@ export interface RunOpWaveResult {
|
|
|
541
603
|
* approval, 4 a plan moved since the decision, 5 the apply applied nothing
|
|
542
604
|
* because another run holds what it changes, 6 the apply stood down for a
|
|
543
605
|
* newer run, 7 the product's policy denied a run and no override counts
|
|
544
|
-
* (5, 6 and 7: #3821)
|
|
606
|
+
* (5, 6 and 7: #3821), 8 the gate's decision point was answered with an
|
|
607
|
+
* answer it does not pass on (#3836).
|
|
545
608
|
*/
|
|
546
609
|
exitCode: number;
|
|
547
610
|
/** Set when the apply applied nothing and stood down or was held by another run (#3821): not a failure. */
|
|
@@ -690,6 +753,10 @@ function planRuns(at: { spec: OpWavesSpec; k: number; wave: OpWave; cwd: string;
|
|
|
690
753
|
return m;
|
|
691
754
|
});
|
|
692
755
|
const overriders = Array.isArray(parsed) ? undefined : (parsed as { overriders?: unknown }).overriders;
|
|
756
|
+
const overridersSource = Array.isArray(parsed) ? undefined : (parsed as { overridersSource?: unknown }).overridersSource;
|
|
757
|
+
if (overridersSource !== undefined && (typeof overridersSource !== "string" || overridersSource.trim() === "")) {
|
|
758
|
+
throw new Error(`the plans file of wave ${k} (${plans}): \`overridersSource\` must be a label for where the overriders come from, such as "policy.override in terragucci.yml"`);
|
|
759
|
+
}
|
|
693
760
|
if (overriders !== undefined && (!Array.isArray(overriders) || !overriders.every((o) => typeof o === "string"))) {
|
|
694
761
|
throw new Error(`the plans file of wave ${k} (${plans}): \`overriders\` must be a list of principals`);
|
|
695
762
|
}
|
|
@@ -697,6 +764,7 @@ function planRuns(at: { spec: OpWavesSpec; k: number; wave: OpWave; cwd: string;
|
|
|
697
764
|
members,
|
|
698
765
|
...(pending ? { pending } : {}),
|
|
699
766
|
...(overriders ? { overriders: overriders as string[] } : {}),
|
|
767
|
+
...(overriders && overridersSource ? { overridersSource: overridersSource as string } : {}),
|
|
700
768
|
...(extras ? { extras } : {}),
|
|
701
769
|
...(count !== undefined ? { waves: count as number } : {}),
|
|
702
770
|
};
|
|
@@ -708,6 +776,8 @@ interface PlannedRuns {
|
|
|
708
776
|
pending?: Record<string, unknown>;
|
|
709
777
|
/** The plans file's overriders (#3821). */
|
|
710
778
|
overriders?: string[];
|
|
779
|
+
/** A label for where those overriders come from, used in refusals (#3848). */
|
|
780
|
+
overridersSource?: string;
|
|
711
781
|
/** Digest members that are not runs, such as the wave's cost (#3821). */
|
|
712
782
|
extras?: WaveMember[];
|
|
713
783
|
/** How many waves the product found (#3821), for a `rest` wave. */
|
|
@@ -870,7 +940,9 @@ const hookRuns = (spec: OpWavesSpec, k: number, runs: readonly OpWaveRun[], memb
|
|
|
870
940
|
* Run one moment's hooks in the wave job (#3762), recording each outcome on
|
|
871
941
|
* the result. A hook that held the wave on another gate (#3821) has that
|
|
872
942
|
* gate settled here: approved, the hook runs again with
|
|
873
|
-
* `CHANT_HOLD_APPROVED
|
|
943
|
+
* `CHANT_HOLD_APPROVED`; otherwise the wave waits on it. The re-run passes,
|
|
944
|
+
* or holds the wave on a further gate (#3847), settled the same way, up to
|
|
945
|
+
* {@link OP_WAVE_MAX_CHAINED_HOLDS} gates per hook call.
|
|
874
946
|
*/
|
|
875
947
|
async function runHooks(
|
|
876
948
|
ctx: WaveContext,
|
|
@@ -887,35 +959,74 @@ async function runHooks(
|
|
|
887
959
|
ctx.result.hooks.push(...out.outcomes);
|
|
888
960
|
const back: HooksOutcome = { holds: out.holds, ...(out.failed ? { failed: out.failed } : {}) };
|
|
889
961
|
if (out.failed) return back;
|
|
890
|
-
for (const
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
962
|
+
for (const first of out.otherGates) {
|
|
963
|
+
// #3847: an approved re-run may hold the wave on a further gate (a second state migration that plans against the first one's states), settled in turn.
|
|
964
|
+
let hold = first;
|
|
965
|
+
const approved: OpWaveHeldOnApproval[] = [];
|
|
966
|
+
for (;;) {
|
|
967
|
+
const held = await settleOtherGate(ctx, hold);
|
|
968
|
+
if (held.status === "waiting") {
|
|
969
|
+
if (approved.length > 0) held.after = approved;
|
|
970
|
+
back.waitingOn ??= held;
|
|
971
|
+
break;
|
|
972
|
+
}
|
|
973
|
+
const now: OpWaveHeldOnApproval = { op: held.op, gate: held.gate, digest: held.digest, approvedBy: held.approvedBy!, approvedAt: held.approvedAt! };
|
|
974
|
+
approved.push(now);
|
|
975
|
+
const approvedEnv = {
|
|
976
|
+
...hold.env,
|
|
977
|
+
CHANT_HOLD_APPROVED: JSON.stringify(now),
|
|
978
|
+
// Every hold this hook call was approved through so far, oldest first (#3847).
|
|
979
|
+
CHANT_HOLDS_APPROVED: JSON.stringify(approved),
|
|
980
|
+
};
|
|
981
|
+
const holdPath = join(ctx.cwd, hold.env.CHANT_HOLD!);
|
|
982
|
+
rmSync(holdPath, { force: true });
|
|
983
|
+
let code: number;
|
|
984
|
+
try {
|
|
985
|
+
code = ctx.exec(hold.argv, ctx.cwd, approvedEnv);
|
|
986
|
+
} catch (err) {
|
|
987
|
+
ctx.log(`${hold.label} did not run: ${err instanceof Error ? err.message : String(err)}`);
|
|
988
|
+
code = 127;
|
|
989
|
+
}
|
|
990
|
+
const target = hold.target ? { target: hold.target } : {};
|
|
991
|
+
if (code === 0) {
|
|
992
|
+
ctx.result.hooks.push({ moment, name: hold.name, ...target, exit: code, outcome: "passed" });
|
|
993
|
+
ctx.log(`${hold.label}: ${held.approvedBy} approved ${held.op} / ${held.gate} at ${held.digest}, and the hook ran again and passed`);
|
|
994
|
+
break;
|
|
995
|
+
}
|
|
996
|
+
const next = readHoldRequest(holdPath, ctx.log, hold.label);
|
|
997
|
+
const problem = !next
|
|
998
|
+
? `${hold.label} exited ${code} after ${held.approvedBy} approved ${held.gate}`
|
|
999
|
+
: approved.some((a) => a.op === (next.op ?? ctx.spec.name) && a.gate === next.gate && a.digest === next.digest)
|
|
1000
|
+
? `${hold.label} exited ${code} after ${held.approvedBy} approved ${held.gate}, and holds the wave again on ${next.op ?? ctx.spec.name} / ${next.gate} at ${next.digest}, which is already approved`
|
|
1001
|
+
: approved.length >= OP_WAVE_MAX_CHAINED_HOLDS
|
|
1002
|
+
? `${hold.label} held the wave on ${approved.length} gates in turn and asks for one more; a hook call holds on at most ${OP_WAVE_MAX_CHAINED_HOLDS}`
|
|
1003
|
+
: undefined;
|
|
1004
|
+
if (problem) {
|
|
1005
|
+
ctx.result.hooks.push({ moment, name: hold.name, ...target, exit: code, outcome: "failed" });
|
|
1006
|
+
back.failed = problem;
|
|
1007
|
+
ctx.log(back.failed);
|
|
1008
|
+
return back;
|
|
1009
|
+
}
|
|
1010
|
+
ctx.result.hooks.push({ moment, name: hold.name, ...target, exit: code, outcome: "held" });
|
|
1011
|
+
ctx.log(`${hold.label}: ${held.approvedBy} approved ${held.op} / ${held.gate}; the hook ran again, exited ${code} and holds wave ${ctx.k} (${ctx.wave.name}) on gate ${next!.op ?? ctx.spec.name} / ${next!.gate}, digest ${next!.digest}`);
|
|
1012
|
+
hold = { ...hold, request: next! };
|
|
909
1013
|
}
|
|
910
|
-
ctx.log(`${hold.label}: ${held.approvedBy} approved ${held.op} / ${held.gate} at ${held.digest}, and the hook ran again and passed`);
|
|
911
1014
|
}
|
|
912
1015
|
return back;
|
|
913
1016
|
}
|
|
914
1017
|
|
|
915
|
-
/**
|
|
1018
|
+
/** How many gates one hook call holds a wave on in turn (#3847): the first hold and the holds its approved re-runs ask for. */
|
|
1019
|
+
export const OP_WAVE_MAX_CHAINED_HOLDS = 10;
|
|
1020
|
+
|
|
1021
|
+
/**
|
|
1022
|
+
* The gate ledger a wave decides by: the job's port, its `identity.gates`
|
|
1023
|
+
* rule read at the wave's base (#3841), and the signers at base as a second
|
|
1024
|
+
* rule when `approval` is `sealed`.
|
|
1025
|
+
*/
|
|
916
1026
|
async function gatePort(options: RunOpWaveOptions, cwd: string, gate: string, approval: GateApprovalSource, base: string): Promise<{ ledger: GateLedgerPort; port: GateLedgerPort }> {
|
|
917
1027
|
const ledger = options.gates ?? gitGateLedgerPort({ cwd });
|
|
918
|
-
|
|
1028
|
+
const atBase = (g: string) => ledger.approvalRule?.(g, { base }) ?? Promise.resolve(null);
|
|
1029
|
+
if (approval !== "sealed") return { ledger, port: ledger.approvalRule ? { ...ledger, approvalRule: atBase } : ledger };
|
|
919
1030
|
// #3684: only an approval sealed by a signer listed at base counts.
|
|
920
1031
|
const review = await import("./gate-review");
|
|
921
1032
|
const sealed = review.sealedApprovalRule(gate, (options.trust ?? ((b) => review.trustPolicyAt(cwd, b)))(base));
|
|
@@ -924,13 +1035,19 @@ async function gatePort(options: RunOpWaveOptions, cwd: string, gate: string, ap
|
|
|
924
1035
|
port: {
|
|
925
1036
|
...ledger,
|
|
926
1037
|
async approvalRule(g) {
|
|
927
|
-
const own =
|
|
1038
|
+
const own = await atBase(g);
|
|
928
1039
|
return g === gate ? review.bothRules(own, sealed) : own;
|
|
929
1040
|
},
|
|
930
1041
|
},
|
|
931
1042
|
};
|
|
932
1043
|
}
|
|
933
1044
|
|
|
1045
|
+
/** Log each approval of the digest a rule did not count (#3841), so a wait says why. */
|
|
1046
|
+
function logRefused(log: (line: string) => void, label: string, check: GateCheck): void {
|
|
1047
|
+
if (check.satisfied) return;
|
|
1048
|
+
for (const { resolution: r, reason } of check.refused ?? []) log(`${label}: the approval by ${r.resolvedBy} at ${r.timestamp} does not count: ${reason}`);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
934
1051
|
/**
|
|
935
1052
|
* Settle a hold on another gate (#3821): an approval of that gate and digest
|
|
936
1053
|
* under the wave's approval mode (`pr-review` counts the ledger alone, since
|
|
@@ -954,6 +1071,7 @@ async function settleOtherGate(ctx: WaveContext, hold: OtherGateHold): Promise<O
|
|
|
954
1071
|
...(neverOverMcp ? { neverOverMcp: true } : {}),
|
|
955
1072
|
});
|
|
956
1073
|
const base = { op, gate: hold.request.gate, digest: hold.request.digest, hook: hold.label };
|
|
1074
|
+
logRefused(log, `${op} / ${hold.request.gate}`, check);
|
|
957
1075
|
if (check.satisfied) return { ...base, status: "approved", approvedBy: check.resolution.resolvedBy, approvedAt: check.resolution.timestamp };
|
|
958
1076
|
const approve = approveCommand(op, hold.request.gate, undefined, hold.request.digest, mode === "sealed");
|
|
959
1077
|
log(`wave ${k} (${wave.name}) waits on ${op} / ${hold.request.gate}, which ${hold.label} holds it on: nobody approved ${hold.request.digest}. Approve: ${approve}`);
|
|
@@ -1171,7 +1289,7 @@ async function runOneWave(options: RunOpWaveOptions, k: number, prior: OpWaveDec
|
|
|
1171
1289
|
ctx.gateRule = { approval: decision.approval, base: decision.base, neverOverMcp: decision.neverOverMcp === true };
|
|
1172
1290
|
if (decision.status === "waiting" || decision.status === "refused") {
|
|
1173
1291
|
log(`wave ${k} (${wave.name}) is ${decision.status === "refused" ? "refused by its policy" : `waiting on ${decision.heldOn?.gate ?? decision.gate}`}; nothing applied. ${decision.approve ?? ""}`.trim());
|
|
1174
|
-
return { ...result, exitCode: decision.status === "refused" ?
|
|
1292
|
+
return { ...result, exitCode: decision.status === "refused" ? refusedExit(decision) : 3 };
|
|
1175
1293
|
}
|
|
1176
1294
|
// The share runs the hooks its deciding job read at base, around its own slice.
|
|
1177
1295
|
if (wave.discover) {
|
|
@@ -1213,6 +1331,12 @@ async function runOneWave(options: RunOpWaveOptions, k: number, prior: OpWaveDec
|
|
|
1213
1331
|
const neverOverMcp = rules.neverOverMcp || spec.neverOverMcp === true;
|
|
1214
1332
|
ctx.gateRule = { approval, base, neverOverMcp };
|
|
1215
1333
|
noteHooksAtBase(ctx, rules);
|
|
1334
|
+
// #3836: a point at base that cannot be asked fails the wave; the ledger never stands in for it.
|
|
1335
|
+
if (rules.pointRefused) return failed(ctx, `wave ${k} (${wave.name}): ${rules.pointRefused}, so nothing in it was planned or applied`);
|
|
1336
|
+
const own = opWavePointAt(spec, wave.name);
|
|
1337
|
+
if (JSON.stringify(own) !== JSON.stringify(rules.point)) {
|
|
1338
|
+
log(`wave ${k} (${wave.name}): its gate's decision point is read at ${base.slice(0, 12)} (${rules.point?.name ?? "none"}), and this change's differs (${own?.name ?? "none"}); the one at base holds`);
|
|
1339
|
+
}
|
|
1216
1340
|
const holds: string[] = [];
|
|
1217
1341
|
if (rules.hooksRefused) {
|
|
1218
1342
|
holds.push(rules.hooksRefused);
|
|
@@ -1237,11 +1361,12 @@ async function runOneWave(options: RunOpWaveOptions, k: number, prior: OpWaveDec
|
|
|
1237
1361
|
...(rules.applyHooksRunBy === "apply" ? { applyHooksRunBy: "apply" as const } : {}),
|
|
1238
1362
|
...(spec.digest?.over === "changing" ? { digestOver: "changing" as const } : {}),
|
|
1239
1363
|
...(neverOverMcp ? { neverOverMcp: true as const } : {}),
|
|
1364
|
+
...(rules.point ? { point: { ...rules.point } } : {}),
|
|
1240
1365
|
};
|
|
1241
1366
|
const before = await runHooks(ctx, "before-plan", rules.hooks["before-plan"], wave.runs);
|
|
1242
1367
|
if (before.failed) return failed(ctx, `a hook before the plan failed, so nothing in wave ${k} was planned or applied`);
|
|
1243
1368
|
if (before.waitingOn) return decided(ctx, decision, recordPath, before.waitingOn);
|
|
1244
|
-
const { members, pending, overriders, extras, waves: found } = planRuns(ctx, wave.runs, wave.discover === true);
|
|
1369
|
+
const { members, pending, overriders, overridersSource, extras, waves: found } = planRuns(ctx, wave.runs, wave.discover === true);
|
|
1245
1370
|
if (wave.discover) {
|
|
1246
1371
|
wave = { ...wave, runs: members.map((m) => ({ target: m.member, ...(m.op !== spec.op ? { op: m.op } : {}) })) };
|
|
1247
1372
|
ctx.wave = wave;
|
|
@@ -1267,7 +1392,7 @@ async function runOneWave(options: RunOpWaveOptions, k: number, prior: OpWaveDec
|
|
|
1267
1392
|
for (const m of members) if (m.denied && byRun.get(m.member)) m.override = byRun.get(m.member);
|
|
1268
1393
|
if (prior.overridesSpentAt) decision.overridesSpentAt = prior.overridesSpentAt;
|
|
1269
1394
|
}
|
|
1270
|
-
if (members.some((m) => m.denied && !m.override)) await settleDenials(ctx, decision, overriders);
|
|
1395
|
+
if (members.some((m) => m.denied && !m.override)) await settleDenials(ctx, decision, overriders, overridersSource);
|
|
1271
1396
|
if (kept && prior.status === "approved" && decision.status !== "refused") {
|
|
1272
1397
|
// #3821: planned again after the apply asked to, to the same digest: the approval this job found still holds, and is not spent twice.
|
|
1273
1398
|
for (const key of ["via", "approvedBy", "approvedAt", "review", "spentAt"] as const) if (prior[key] !== undefined) (decision as unknown as Record<string, unknown>)[key] = prior[key];
|
|
@@ -1314,6 +1439,10 @@ async function decided(ctx: WaveContext, decision: OpWaveDecision, recordPath: s
|
|
|
1314
1439
|
log(`Nothing in wave ${k} or after it ran. ${decision.heldOn ? `It waits on ${decision.heldOn.op} / ${decision.heldOn.gate}.` : "Read the plans, then"} approve: ${decision.approve} (with --resume, the approval also starts this job again)`);
|
|
1315
1440
|
return { ...result, exitCode: 3 };
|
|
1316
1441
|
}
|
|
1442
|
+
if (decision.status === "refused" && decision.refused?.reason === "point") {
|
|
1443
|
+
log(`Nothing in wave ${k} (${wave.name}) or after it ran: ${decision.refused.message}`);
|
|
1444
|
+
return { ...result, exitCode: 8 };
|
|
1445
|
+
}
|
|
1317
1446
|
if (decision.status === "refused") {
|
|
1318
1447
|
log(`Nothing in wave ${k} (${wave.name}) or after it ran: the policy denied ${decision.refused?.runs.join(", ")}, and no override counts for ${decision.refused?.runs.length === 1 ? "it" : "them"}.`);
|
|
1319
1448
|
return { ...result, exitCode: 7 };
|
|
@@ -1381,7 +1510,7 @@ function relativeTo(cwd: string, path: string): string {
|
|
|
1381
1510
|
* decision is `refused`, and each such run gets a pending fact on the
|
|
1382
1511
|
* override ledger unless one stands.
|
|
1383
1512
|
*/
|
|
1384
|
-
async function settleDenials(ctx: WaveContext, decision: OpWaveDecision, listed: string[] | undefined): Promise<void> {
|
|
1513
|
+
async function settleDenials(ctx: WaveContext, decision: OpWaveDecision, listed: string[] | undefined, source?: string): Promise<void> {
|
|
1385
1514
|
const { spec, k, wave, cwd, log, options } = ctx;
|
|
1386
1515
|
const og = await import("../override-gate");
|
|
1387
1516
|
const port = options.gates ?? gitGateLedgerPort({ cwd });
|
|
@@ -1403,7 +1532,7 @@ async function settleDenials(ctx: WaveContext, decision: OpWaveDecision, listed:
|
|
|
1403
1532
|
planDigest: m.planDigest,
|
|
1404
1533
|
rules: m.denied.rules,
|
|
1405
1534
|
overriders,
|
|
1406
|
-
overridersPath: listed ? "the overriders the plans file names" : path,
|
|
1535
|
+
overridersPath: listed ? (source ?? "the overriders the plans file names") : path,
|
|
1407
1536
|
mode: sealed ? "sealed" : "ledger",
|
|
1408
1537
|
...(signers ? { signers } : {}),
|
|
1409
1538
|
now,
|
|
@@ -1439,7 +1568,7 @@ async function settleDenials(ctx: WaveContext, decision: OpWaveDecision, listed:
|
|
|
1439
1568
|
}
|
|
1440
1569
|
if (still.length > 0) {
|
|
1441
1570
|
decision.status = "refused";
|
|
1442
|
-
decision.refused = { reason: moved ? "override-moved" : "policy", runs: still.sort() };
|
|
1571
|
+
decision.refused = { reason: moved ? "override-moved" : "policy", runs: still.sort(), ...(listed && source ? { overridersSource: source } : {}) };
|
|
1443
1572
|
}
|
|
1444
1573
|
}
|
|
1445
1574
|
|
|
@@ -1471,6 +1600,11 @@ async function spendOverrides(ctx: WaveContext, decision: OpWaveDecision): Promi
|
|
|
1471
1600
|
return true;
|
|
1472
1601
|
}
|
|
1473
1602
|
|
|
1603
|
+
/** The exit code of a refused wave: 8 when its decision point refused it (#3836), 7 when the product's policy did (#3821). */
|
|
1604
|
+
function refusedExit(decision: OpWaveDecision): number {
|
|
1605
|
+
return decision.refused?.reason === "point" ? 8 : 7;
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1474
1608
|
function failed(ctx: WaveContext, line: string): RunOpWaveResult {
|
|
1475
1609
|
ctx.log(line);
|
|
1476
1610
|
ctx.result.exitCode = 1;
|
|
@@ -1483,6 +1617,7 @@ function failed(ctx: WaveContext, line: string): RunOpWaveResult {
|
|
|
1483
1617
|
* asks for one, or when a hook holds the wave (#3762).
|
|
1484
1618
|
*/
|
|
1485
1619
|
async function settleGate(options: RunOpWaveOptions, ctx: WaveContext, decision: OpWaveDecision): Promise<void> {
|
|
1620
|
+
if (decision.point) return settlePointGate(options, ctx, decision, decision.point);
|
|
1486
1621
|
const { spec, k, wave, cwd } = ctx;
|
|
1487
1622
|
const { gate, digest, approval, base, policy, members } = decision;
|
|
1488
1623
|
const { ledger, port } = await gatePort(options, cwd, gate, approval, base);
|
|
@@ -1507,6 +1642,7 @@ async function settleGate(options: RunOpWaveOptions, ctx: WaveContext, decision:
|
|
|
1507
1642
|
decision.approvedAt = check.resolution.timestamp;
|
|
1508
1643
|
return;
|
|
1509
1644
|
}
|
|
1645
|
+
logRefused(ctx.log, `wave ${k} (${wave.name})`, check);
|
|
1510
1646
|
// #3821: what the wait is, for the after-gate hooks and the record.
|
|
1511
1647
|
decision.pendingFact = check.pending;
|
|
1512
1648
|
decision.recorded = check.recorded;
|
|
@@ -1535,6 +1671,54 @@ async function settleGate(options: RunOpWaveOptions, ctx: WaveContext, decision:
|
|
|
1535
1671
|
decision.approve = approveCommand(spec.name, gate, undefined, digest, approval === "sealed");
|
|
1536
1672
|
}
|
|
1537
1673
|
|
|
1674
|
+
/**
|
|
1675
|
+
* Decide a gate that asks a decision point (#3836), as an Op gate with a
|
|
1676
|
+
* `point` is decided (#3170): ask it with `gate.planDigest` set to the set
|
|
1677
|
+
* digest, then wait on an open question, pass on an answer the gate passes
|
|
1678
|
+
* on, or refuse the wave on any other. The wave's `approval` mode and spent
|
|
1679
|
+
* approvals do not apply: the point's deciders and quorum decide, and a
|
|
1680
|
+
* `chant approve` of the digest does not pass it.
|
|
1681
|
+
*/
|
|
1682
|
+
async function settlePointGate(options: RunOpWaveOptions, ctx: WaveContext, decision: OpWaveDecision, point: NonNullable<OpWaveDecision["point"]>): Promise<void> {
|
|
1683
|
+
const { spec, k, wave, cwd, log } = ctx;
|
|
1684
|
+
const { gate, digest, policy, members } = decision;
|
|
1685
|
+
if (decision.approval !== "ledger") log(`wave ${k} (${wave.name}): its gate asks decision point ${point.name}, so its approval mode (${decision.approval}) does not apply`);
|
|
1686
|
+
const { evaluatePointGate } = await import("../point-decision");
|
|
1687
|
+
const asker = options.points ?? (await import("./gate-point-run")).workspaceGatePointAsker(cwd);
|
|
1688
|
+
const held = decision.heldBy?.length ? `, held by ${decision.heldBy.join(", ")}` : "";
|
|
1689
|
+
const check = await evaluatePointGate(options.gates ?? gitGateLedgerPort({ cwd }), asker, {
|
|
1690
|
+
op: spec.name,
|
|
1691
|
+
gate,
|
|
1692
|
+
point: { name: point.name, ...(point.pass ? { pass: point.pass } : {}), ...(point.store ? { store: point.store } : {}) },
|
|
1693
|
+
...(point.inputs ? { inputs: point.inputs } : {}),
|
|
1694
|
+
description: (typeof decision.pending?.description === "string" ? decision.pending.description : undefined) ?? `wave ${k} (${wave.name}): ${members.length} run${members.length === 1 ? "" : "s"}, gate ${policy}, decision point ${point.name}${held}`,
|
|
1695
|
+
...(point.timeout ? { timeout: point.timeout } : {}),
|
|
1696
|
+
planDigest: digest,
|
|
1697
|
+
...(options.now ? { now: options.now } : {}),
|
|
1698
|
+
});
|
|
1699
|
+
if (check.satisfied) {
|
|
1700
|
+
decision.status = "approved";
|
|
1701
|
+
decision.via = "point";
|
|
1702
|
+
decision.approvedBy = check.resolution.resolvedBy;
|
|
1703
|
+
decision.approvedAt = check.resolution.timestamp;
|
|
1704
|
+
point.answer = check.answer;
|
|
1705
|
+
return;
|
|
1706
|
+
}
|
|
1707
|
+
if ("refused" in check) {
|
|
1708
|
+
decision.status = "refused";
|
|
1709
|
+
decision.refused = { reason: "point", runs: members.map((m) => m.member), message: check.refused };
|
|
1710
|
+
point.answer = check.answer;
|
|
1711
|
+
return;
|
|
1712
|
+
}
|
|
1713
|
+
decision.status = "waiting";
|
|
1714
|
+
decision.pendingFact = check.pending;
|
|
1715
|
+
decision.recorded = check.recorded;
|
|
1716
|
+
decision.waitingSince = check.pending.timestamp;
|
|
1717
|
+
point.question = check.waiting;
|
|
1718
|
+
decision.approve = `chant workspace points answer ${check.waiting.id} --answer <answer> --by <your name>`;
|
|
1719
|
+
log(`wave ${k} (${wave.name}) asks decision point ${point.name} about ${digest}: question ${check.waiting.id} is ${check.waiting.state}`);
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1538
1722
|
/** Write the decision record, with every hook's outcome so far (#3804). */
|
|
1539
1723
|
function writeRecord(recordPath: string, decision: OpWaveDecision, result: RunOpWaveResult): void {
|
|
1540
1724
|
if (result.hooks.length > 0) decision.outcomes = [...result.hooks];
|
|
@@ -1557,6 +1741,13 @@ async function settleHolds(
|
|
|
1557
1741
|
decision.heldBy = [...(decision.heldBy ?? []), ...holds];
|
|
1558
1742
|
await settleGate(options, ctx, decision);
|
|
1559
1743
|
if (recordPath) writeRecord(recordPath, decision, result);
|
|
1744
|
+
if (decision.status === "refused") {
|
|
1745
|
+
// #3836: the gate's decision point refused the held wave.
|
|
1746
|
+
if (recordPath) await runGateHooks(ctx, decision, recordPath);
|
|
1747
|
+
log(`wave ${k} (${wave.name}) is held by ${holds.join(", ")}; nothing applied: ${decision.refused?.message ?? "refused"}`);
|
|
1748
|
+
result.exitCode = refusedExit(decision);
|
|
1749
|
+
return false;
|
|
1750
|
+
}
|
|
1560
1751
|
if (decision.status === "waiting") {
|
|
1561
1752
|
// #3821: the gate decided again, and the wave now waits: the after-gate hooks hear so.
|
|
1562
1753
|
if (recordPath) await runGateHooks(ctx, decision, recordPath);
|
|
@@ -1786,7 +1977,7 @@ async function applyStage(
|
|
|
1786
1977
|
return;
|
|
1787
1978
|
}
|
|
1788
1979
|
if (waiting) {
|
|
1789
|
-
result.exitCode = 3;
|
|
1980
|
+
result.exitCode = decision.status === "refused" ? refusedExit(decision) : 3;
|
|
1790
1981
|
return;
|
|
1791
1982
|
}
|
|
1792
1983
|
if (result.failed.length > 0) {
|
package/src/op/op-waves.test.ts
CHANGED
|
@@ -788,8 +788,8 @@ describe("gates and holds a product's wave needs (#3821)", () => {
|
|
|
788
788
|
const show: OpWaveShowAtBase = () => ({ sha: "b".repeat(40), text: JSON.stringify(opts.atBase ?? tg) });
|
|
789
789
|
return { cwd, calls, envs, exec, show, done: () => rmSync(cwd, { recursive: true, force: true }) };
|
|
790
790
|
}
|
|
791
|
-
const run = (h: ReturnType<typeof harness>, gates = memoryGateLedgerPort(), spec: OpWavesSpec = tg) =>
|
|
792
|
-
runOpWave({ spec, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates, now: "2026-10-11T00:00:00.000Z", log
|
|
791
|
+
const run = (h: ReturnType<typeof harness>, gates = memoryGateLedgerPort(), spec: OpWavesSpec = tg, log: (m: string) => void = () => {}) =>
|
|
792
|
+
runOpWave({ spec, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates, now: "2026-10-11T00:00:00.000Z", log });
|
|
793
793
|
|
|
794
794
|
test("the spec checks after.gate hooks and override", () => {
|
|
795
795
|
expect(() => assertOpWavesSpec(tg)).not.toThrow();
|
|
@@ -862,6 +862,21 @@ describe("gates and holds a product's wave needs (#3821)", () => {
|
|
|
862
862
|
other.done();
|
|
863
863
|
});
|
|
864
864
|
|
|
865
|
+
test("a plans file's overridersSource names the list in the refusal and the decision record (#3848)", async () => {
|
|
866
|
+
const mk = (extra: Record<string, unknown>) => harness({ vpc: { planDigest: digest("1"), denied: { rules: ["no-public-s3"] } }, dns: { planDigest: digest("2") } }, { extra });
|
|
867
|
+
const logs: string[] = [];
|
|
868
|
+
const h = mk({ overriders: ["github:dave"], overridersSource: "policy.override in terragucci.yml" });
|
|
869
|
+
const gates = memoryGateLedgerPort({ resolutions: [{ version: 1, op: "policy-override", gate: "vpc", resolvedBy: "github:carol", timestamp: "2026-10-10T00:00:00Z", planDigest: (await import("../override-gate")).overrideDigest("vpc", digest("1"), ["no-public-s3"]), note: "ok" }] });
|
|
870
|
+
const result = await run(h, gates, tg, (m) => logs.push(m));
|
|
871
|
+
expect(result.decision!.refused).toMatchObject({ reason: "policy", overridersSource: "policy.override in terragucci.yml" });
|
|
872
|
+
expect(logs.join("\n")).toContain("github:carol is not listed in policy.override in terragucci.yml at base");
|
|
873
|
+
h.done();
|
|
874
|
+
const d = mk({ overriders: ["github:dave"] });
|
|
875
|
+
const r2 = await run(d, memoryGateLedgerPort());
|
|
876
|
+
expect(r2.decision!.refused).not.toHaveProperty("overridersSource");
|
|
877
|
+
d.done();
|
|
878
|
+
});
|
|
879
|
+
|
|
865
880
|
test("a hook holds the wave on another gate: it waits on that gate's digest, then runs again once approved", async () => {
|
|
866
881
|
const migrate = { name: "migrate", command: ["migrate", "{runs}"], onFailure: "approve" as const };
|
|
867
882
|
const spec: OpWavesSpec = { ...tg, hooks: { before: { plan: [migrate] }, after: tg.hooks!.after } };
|
|
@@ -891,6 +906,84 @@ describe("gates and holds a product's wave needs (#3821)", () => {
|
|
|
891
906
|
expect(result.hooks.filter((o) => o.name === "migrate").map((o) => o.outcome)).toEqual(["held", "passed"]);
|
|
892
907
|
again.done();
|
|
893
908
|
});
|
|
909
|
+
|
|
910
|
+
test("an approved re-run holds the wave on a further gate: two migrations wait, then apply in turn (#3847)", async () => {
|
|
911
|
+
const migrate = { name: "migrate", command: ["migrate", "{runs}"], onFailure: "approve" as const };
|
|
912
|
+
const spec: OpWavesSpec = { ...tg, hooks: { before: { plan: [migrate] }, after: tg.hooks!.after } };
|
|
913
|
+
// The first migration holds on its gate; once approved it applies, and the second (whose digest is known only now) holds on its own.
|
|
914
|
+
const applied: string[] = [];
|
|
915
|
+
const hook = (argv: string[], env: Record<string, string>, dir: string) => {
|
|
916
|
+
if (argv[0] !== "migrate") return 0;
|
|
917
|
+
const done = env.CHANT_HOLDS_APPROVED ? (JSON.parse(env.CHANT_HOLDS_APPROVED) as Array<{ gate: string }>).map((a) => a.gate) : [];
|
|
918
|
+
for (const gate of done) if (!applied.includes(gate)) applied.push(gate);
|
|
919
|
+
const next = ["split-queues", "move-topics"].find((g) => !done.includes(g));
|
|
920
|
+
if (!next) return 0;
|
|
921
|
+
writeFileSync(join(dir, env.CHANT_HOLD!), JSON.stringify({ op: "tf-migrate", gate: next, digest: digest(next === "split-queues" ? "m" : "n") }));
|
|
922
|
+
return 3;
|
|
923
|
+
};
|
|
924
|
+
const plans = { vpc: { planDigest: digest("1"), empty: true }, dns: { planDigest: digest("2"), empty: true } };
|
|
925
|
+
const first = { version: 1 as const, op: "tf-migrate", gate: "split-queues", resolvedBy: "alice", timestamp: "2026-10-10T00:00:00Z", planDigest: digest("m") };
|
|
926
|
+
|
|
927
|
+
// Only the first is approved: it applies, and the wave waits on the second (exit 3), naming it.
|
|
928
|
+
const h = harness(plans, { hook, atBase: spec });
|
|
929
|
+
const gates = memoryGateLedgerPort({ resolutions: [first] });
|
|
930
|
+
const waiting = await run(h, gates, spec);
|
|
931
|
+
expect(waiting.exitCode).toBe(3);
|
|
932
|
+
expect(waiting.decision).toMatchObject({
|
|
933
|
+
status: "waiting",
|
|
934
|
+
approve: `chant approve tf-migrate move-topics --plan ${digest("n")}`,
|
|
935
|
+
heldOn: { op: "tf-migrate", gate: "move-topics", digest: digest("n"), status: "waiting", after: [{ gate: "split-queues", digest: digest("m"), approvedBy: "alice" }] },
|
|
936
|
+
});
|
|
937
|
+
expect(gates.appended).toEqual([expect.objectContaining({ op: "tf-migrate", gate: "move-topics", planDigest: digest("n") })]);
|
|
938
|
+
expect(applied).toEqual(["split-queues"]);
|
|
939
|
+
expect(h.calls.some((c) => c.startsWith("tg plan"))).toBe(false);
|
|
940
|
+
expect(waiting.hooks.filter((o) => o.name === "migrate").map((o) => o.outcome)).toEqual(["held", "held"]);
|
|
941
|
+
h.done();
|
|
942
|
+
|
|
943
|
+
// Both approved: the hook runs three times, the last with both approvals, and the wave applies.
|
|
944
|
+
applied.length = 0;
|
|
945
|
+
const again = harness(plans, { hook, atBase: spec });
|
|
946
|
+
const both = memoryGateLedgerPort({ resolutions: [first, { ...first, gate: "move-topics", resolvedBy: "bob", planDigest: digest("n") }] });
|
|
947
|
+
const result = await run(again, both, spec);
|
|
948
|
+
expect(result.exitCode).toBe(0);
|
|
949
|
+
expect(applied).toEqual(["split-queues", "move-topics"]);
|
|
950
|
+
const reruns = again.envs.filter((_, i) => again.calls[i]!.startsWith("migrate"));
|
|
951
|
+
expect(reruns).toHaveLength(3);
|
|
952
|
+
expect(JSON.parse(reruns[2]!.CHANT_HOLD_APPROVED!)).toMatchObject({ gate: "move-topics", approvedBy: "bob" });
|
|
953
|
+
expect(JSON.parse(reruns[2]!.CHANT_HOLDS_APPROVED!).map((a: { gate: string }) => a.gate)).toEqual(["split-queues", "move-topics"]);
|
|
954
|
+
expect(result.hooks.filter((o) => o.name === "migrate").map((o) => o.outcome)).toEqual(["held", "held", "passed"]);
|
|
955
|
+
again.done();
|
|
956
|
+
});
|
|
957
|
+
|
|
958
|
+
test("an approved re-run that holds again on the same gate and digest, or past the bound, fails the wave (#3847)", async () => {
|
|
959
|
+
const migrate = { name: "migrate", command: ["migrate", "{runs}"], onFailure: "approve" as const };
|
|
960
|
+
const spec: OpWavesSpec = { ...tg, hooks: { before: { plan: [migrate] }, after: tg.hooks!.after } };
|
|
961
|
+
const plans = { vpc: { planDigest: digest("1") }, dns: { planDigest: digest("2") } };
|
|
962
|
+
const same = (argv: string[], env: Record<string, string>, dir: string) => {
|
|
963
|
+
if (argv[0] !== "migrate") return 0;
|
|
964
|
+
writeFileSync(join(dir, env.CHANT_HOLD!), JSON.stringify({ op: "tf-migrate", gate: "split-queues", digest: digest("m") }));
|
|
965
|
+
return 3;
|
|
966
|
+
};
|
|
967
|
+
const h = harness(plans, { hook: same, atBase: spec });
|
|
968
|
+
const gates = memoryGateLedgerPort({ resolutions: [{ version: 1, op: "tf-migrate", gate: "split-queues", resolvedBy: "alice", timestamp: "2026-10-10T00:00:00Z", planDigest: digest("m") }] });
|
|
969
|
+
expect((await run(h, gates, spec)).exitCode).toBe(1);
|
|
970
|
+
expect(h.calls.filter((c) => c.startsWith("migrate"))).toHaveLength(2);
|
|
971
|
+
h.done();
|
|
972
|
+
|
|
973
|
+
const { OP_WAVE_MAX_CHAINED_HOLDS } = await import("./op-waves-run");
|
|
974
|
+
let n = 0;
|
|
975
|
+
const endless = (argv: string[], env: Record<string, string>, dir: string) => {
|
|
976
|
+
if (argv[0] !== "migrate") return 0;
|
|
977
|
+
writeFileSync(join(dir, env.CHANT_HOLD!), JSON.stringify({ op: "tf-migrate", gate: `step-${n}`, digest: digest("m") }));
|
|
978
|
+
n++;
|
|
979
|
+
return 3;
|
|
980
|
+
};
|
|
981
|
+
const many = harness(plans, { hook: endless, atBase: spec });
|
|
982
|
+
const all = memoryGateLedgerPort({ resolutions: Array.from({ length: 20 }, (_, i) => ({ version: 1 as const, op: "tf-migrate", gate: `step-${i}`, resolvedBy: "alice", timestamp: "2026-10-10T00:00:00Z", planDigest: digest("m") })) });
|
|
983
|
+
expect((await run(many, all, spec)).exitCode).toBe(1);
|
|
984
|
+
expect(many.calls.filter((c) => c.startsWith("migrate"))).toHaveLength(OP_WAVE_MAX_CHAINED_HOLDS + 1);
|
|
985
|
+
many.done();
|
|
986
|
+
});
|
|
894
987
|
});
|
|
895
988
|
|
|
896
989
|
describe("discovered waves, a cost member and applies that apply nothing (#3821)", () => {
|