@intentius/chant 0.124.3 → 0.124.5
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/operator.d.ts.map +1 -1
- package/dist/op/activities/activity-contracts.d.ts +4 -4
- package/dist/op/index.d.ts +3 -3
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/op-waves-notify.d.ts +4 -1
- package/dist/op/op-waves-notify.d.ts.map +1 -1
- package/dist/op/op-waves-run.d.ts +143 -6
- package/dist/op/op-waves-run.d.ts.map +1 -1
- package/dist/op/op-waves.d.ts +49 -2
- package/dist/op/op-waves.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/discovery-skip.e2e.test.ts +4 -1
- package/src/cli/handlers/operator.ts +25 -11
- package/src/notify-bundle.test.ts +20 -0
- package/src/op/index.ts +6 -1
- package/src/op/op-waves-notify.ts +34 -2
- package/src/op/op-waves-run.ts +676 -78
- package/src/op/op-waves.test.ts +268 -0
- package/src/op/op-waves.ts +83 -7
package/src/op/op-waves-run.ts
CHANGED
|
@@ -44,10 +44,12 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node
|
|
|
44
44
|
import { dirname, isAbsolute, join, posix } from "node:path";
|
|
45
45
|
import { waveSetDigest, type WaveMember } from "../gated-waves";
|
|
46
46
|
import type { ReviewApproval } from "../gate-decision";
|
|
47
|
-
import { approveCommand, evaluateGate, gitGateLedgerPort, type GateLedgerPort } from "./gate";
|
|
47
|
+
import { approveCommand, evaluateGate, gitGateLedgerPort, type GateDigestMismatch, type GateLedgerPort } from "./gate";
|
|
48
|
+
import type { PendingGateRecord } from "../lifecycle/gate-ledger";
|
|
48
49
|
import { isApprovalSpent, type AppliedGateRecord } from "../lifecycle/gate-applied";
|
|
49
50
|
import type { HeadPlans, MergedReview, ReviewSource } from "./gate-review";
|
|
50
51
|
import type { TrustPolicy } from "../workspace/trust/policy";
|
|
52
|
+
import type { GateSigners } from "../workspace/trust/gate-seal";
|
|
51
53
|
import {
|
|
52
54
|
OP_WAVE_DEFAULT_APPLY,
|
|
53
55
|
OP_WAVE_GATE_POLICIES,
|
|
@@ -77,6 +79,12 @@ export interface OpWavePlanFile {
|
|
|
77
79
|
destructive?: boolean;
|
|
78
80
|
/** The plan changes nothing. */
|
|
79
81
|
empty?: boolean;
|
|
82
|
+
/**
|
|
83
|
+
* The product's policy denied this plan (#3821): the ids of the rules that
|
|
84
|
+
* denied it. The wave applies nothing unless an override of this run, its
|
|
85
|
+
* plan digest and these rules counts (`@intentius/chant/override-gate`).
|
|
86
|
+
*/
|
|
87
|
+
denied?: { rules: string[]; reason?: string };
|
|
80
88
|
}
|
|
81
89
|
|
|
82
90
|
/** What a wave-level plan command writes to `{plans}` (#3762): one plan per run, by target. The array alone is read too. */
|
|
@@ -89,6 +97,20 @@ export interface OpWavePlansFile {
|
|
|
89
97
|
* ...) is refused, except `description`.
|
|
90
98
|
*/
|
|
91
99
|
pending?: Record<string, unknown>;
|
|
100
|
+
/**
|
|
101
|
+
* Who may override a denial (#3821), for a product that keeps the list in
|
|
102
|
+
* its own config at base and read it there. Absent: the spec's
|
|
103
|
+
* `override.overriders` file at base, else `.chant/overriders` at base.
|
|
104
|
+
*/
|
|
105
|
+
overriders?: string[];
|
|
106
|
+
/**
|
|
107
|
+
* Digest members that are not runs (#3821), such as the wave's monthly cost
|
|
108
|
+
* when a cost threshold applies, so an approval at one cost does not apply
|
|
109
|
+
* at another. Each needs a name no run has.
|
|
110
|
+
*/
|
|
111
|
+
members?: WaveMember[];
|
|
112
|
+
/** On a discovering wave (#3821): how many waves the product found, which a `rest` wave walks to. */
|
|
113
|
+
waves?: number;
|
|
92
114
|
}
|
|
93
115
|
|
|
94
116
|
/** Pending-fact keys chant writes itself, which a plans file's `pending` may not set (#3821). */
|
|
@@ -105,6 +127,8 @@ export interface OpWaveRunsFile {
|
|
|
105
127
|
number: number;
|
|
106
128
|
/** The wave's set digest, once it is decided. On `applyWave`, the digest the set was approved or let through at. */
|
|
107
129
|
digest?: string;
|
|
130
|
+
/** Set when the wave discovers its runs (#3821): `runs` is empty, and the plan command plans the runs it finds for this wave. */
|
|
131
|
+
discover?: true;
|
|
108
132
|
/** Each run, with its plan once planned. On `applyWave`, the runs to apply. */
|
|
109
133
|
runs: Array<{ target: string; op: string; planDigest?: string; destructive?: boolean; empty?: boolean }>;
|
|
110
134
|
}
|
|
@@ -114,6 +138,10 @@ export interface OpWaveMember extends WaveMember {
|
|
|
114
138
|
op: string;
|
|
115
139
|
destructive?: boolean;
|
|
116
140
|
empty?: boolean;
|
|
141
|
+
/** The product's policy denied the plan (#3821). */
|
|
142
|
+
denied?: { rules: string[]; reason?: string };
|
|
143
|
+
/** The override that lets the denied plan through (#3821). */
|
|
144
|
+
override?: { by: string; at: string; digest: string; reason: string };
|
|
117
145
|
}
|
|
118
146
|
|
|
119
147
|
/** Where a wave's policy came from. */
|
|
@@ -134,8 +162,12 @@ export interface OpWaveDecision {
|
|
|
134
162
|
base: string;
|
|
135
163
|
digest: string;
|
|
136
164
|
members: OpWaveMember[];
|
|
137
|
-
/**
|
|
138
|
-
|
|
165
|
+
/**
|
|
166
|
+
* `approved`: an approval of this digest stands. `not-required`: the policy
|
|
167
|
+
* lets it through. `waiting`: neither. `refused`: the product's policy
|
|
168
|
+
* denied a run and no override counts (#3821, exit 7).
|
|
169
|
+
*/
|
|
170
|
+
status: "approved" | "not-required" | "waiting" | "refused";
|
|
139
171
|
approvedBy?: string;
|
|
140
172
|
/** How the approval arrived: a resolution on the ledger, or the merged pull request's review (#3684). */
|
|
141
173
|
via?: "ledger" | "pr-review";
|
|
@@ -168,6 +200,73 @@ export interface OpWaveDecision {
|
|
|
168
200
|
pending?: Record<string, unknown>;
|
|
169
201
|
/** The pending fact carries `neverOverMcp`, set by the spec here or at base (#3821). */
|
|
170
202
|
neverOverMcp?: true;
|
|
203
|
+
/** While waiting (#3821): the pending fact that stands for the digest, this run's own when `recorded`. */
|
|
204
|
+
pendingFact?: PendingGateRecord;
|
|
205
|
+
/** While waiting: whether this run recorded the pending fact, rather than finding one standing. */
|
|
206
|
+
recorded?: boolean;
|
|
207
|
+
/** While waiting: when the wait began, the standing pending fact's time. */
|
|
208
|
+
waitingSince?: string;
|
|
209
|
+
/** While waiting: the newest approval of this gate for another digest, which the plans moved away from. */
|
|
210
|
+
mismatch?: GateDigestMismatch;
|
|
211
|
+
/** Why a `refused` wave applies nothing (#3821): the runs the policy denied, and whether an override stands for an earlier plan or other rules. */
|
|
212
|
+
refused?: { reason: "policy" | "override-moved"; runs: string[] };
|
|
213
|
+
/**
|
|
214
|
+
* A hook held the wave on another gate (#3821), such as a state migration
|
|
215
|
+
* behind its own: that gate, its digest, and how it came out. While it
|
|
216
|
+
* waits, the wave waits (exit 3) and `approve` approves it.
|
|
217
|
+
*/
|
|
218
|
+
heldOn?: OpWaveHeldOn;
|
|
219
|
+
/** When the wave recorded the overrides it applies under (#3821), which spent them. */
|
|
220
|
+
overridesSpentAt?: string;
|
|
221
|
+
/** Digest members that are no run, from the plans file (#3821), such as the wave's cost. */
|
|
222
|
+
extras?: WaveMember[];
|
|
223
|
+
/** On a discovering wave (#3821): how many waves the plan command found. */
|
|
224
|
+
discovered?: { waves?: number };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* What an apply call writes to `CHANT_APPLY_OUTCOME` when it applied nothing
|
|
229
|
+
* for a reason that is not a failure (#3821). `stood-down`: a newer run (a
|
|
230
|
+
* newer push on the branch) applies instead, exit 6. `held`: another run
|
|
231
|
+
* holds what the apply changes and the apply does not wait for it, exit 5.
|
|
232
|
+
* `replan`: the run it waited for let go, so the plans may have moved and
|
|
233
|
+
* the wave plans again in the same job. An apply that applied writes nothing.
|
|
234
|
+
*/
|
|
235
|
+
export interface OpWaveApplyOutcome {
|
|
236
|
+
outcome: "stood-down" | "held" | "replan";
|
|
237
|
+
reason?: string;
|
|
238
|
+
/** The run holding what the apply changes, for `held`. */
|
|
239
|
+
holder?: { run: string; url?: string };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export const OP_WAVE_APPLY_OUTCOMES = ["stood-down", "held", "replan"] as const;
|
|
243
|
+
|
|
244
|
+
/** What a hook asks for when it holds a wave on another gate (#3821): it writes this to `CHANT_HOLD` and exits non-zero. */
|
|
245
|
+
export interface OpWaveHoldRequest {
|
|
246
|
+
/** The op the gate is recorded under. Default: the spec's name. */
|
|
247
|
+
op?: string;
|
|
248
|
+
gate: string;
|
|
249
|
+
/** The digest an approval binds. */
|
|
250
|
+
digest: string;
|
|
251
|
+
description?: string;
|
|
252
|
+
/** Fields for the pending fact, as a plans file's `pending`. */
|
|
253
|
+
pending?: Record<string, unknown>;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** A hold on another gate, as the decision records it (#3821). */
|
|
257
|
+
export interface OpWaveHeldOn {
|
|
258
|
+
op: string;
|
|
259
|
+
gate: string;
|
|
260
|
+
digest: string;
|
|
261
|
+
/** The hook that asked. */
|
|
262
|
+
hook: string;
|
|
263
|
+
status: "approved" | "waiting";
|
|
264
|
+
approvedBy?: string;
|
|
265
|
+
approvedAt?: string;
|
|
266
|
+
approve?: string;
|
|
267
|
+
waitingSince?: string;
|
|
268
|
+
recorded?: boolean;
|
|
269
|
+
mismatch?: GateDigestMismatch;
|
|
171
270
|
}
|
|
172
271
|
|
|
173
272
|
/** One hook's run (#3762). */
|
|
@@ -432,11 +531,25 @@ export interface RunOpWaveOptions {
|
|
|
432
531
|
headPlans?: (name: string, head: string) => Promise<HeadPlans | null>;
|
|
433
532
|
/** The signers policy at base, for a `sealed` gate. Default: read from the base commit. */
|
|
434
533
|
trust?: (base: string) => TrustPolicy;
|
|
534
|
+
/** The signers at base, for a sealed override of a policy denial (#3821). Default: `gateSignersAt` from `@intentius/chant/gate-decision`. */
|
|
535
|
+
signers?: (base: string) => GateSigners;
|
|
435
536
|
}
|
|
436
537
|
|
|
437
538
|
export interface RunOpWaveResult {
|
|
438
|
-
/**
|
|
539
|
+
/**
|
|
540
|
+
* 0 applied (or decided), 1 a run or a hook failed, 3 waiting for an
|
|
541
|
+
* approval, 4 a plan moved since the decision, 5 the apply applied nothing
|
|
542
|
+
* because another run holds what it changes, 6 the apply stood down for a
|
|
543
|
+
* newer run, 7 the product's policy denied a run and no override counts
|
|
544
|
+
* (5, 6 and 7: #3821).
|
|
545
|
+
*/
|
|
439
546
|
exitCode: number;
|
|
547
|
+
/** Set when the apply applied nothing and stood down or was held by another run (#3821): not a failure. */
|
|
548
|
+
outcome?: OpWaveApplyOutcome;
|
|
549
|
+
/** Set when the apply asked to plan again (#3821); {@link runOpWave} does, so a caller sees it only on a pass that gave up. */
|
|
550
|
+
replan?: { reason?: string };
|
|
551
|
+
/** The waves a `rest` wave's job ran (#3821), in order, with how each ended. */
|
|
552
|
+
rest?: Array<{ wave: number; exitCode: number; digest?: string }>;
|
|
440
553
|
decision?: OpWaveDecision;
|
|
441
554
|
applied: string[];
|
|
442
555
|
failed: string[];
|
|
@@ -455,6 +568,9 @@ interface WaveContext {
|
|
|
455
568
|
exec: OpWaveExec;
|
|
456
569
|
log: (line: string) => void;
|
|
457
570
|
result: RunOpWaveResult;
|
|
571
|
+
options: RunOpWaveOptions;
|
|
572
|
+
/** How another gate a hook holds the wave on is decided (#3821): the approval mode and base the wave's rules were read at, and whether pending facts carry neverOverMcp. */
|
|
573
|
+
gateRule: { approval: GateApprovalSource; base: string; neverOverMcp: boolean };
|
|
458
574
|
}
|
|
459
575
|
|
|
460
576
|
function planRun(opts: { spec: OpWavesSpec; wave: number; cwd: string; exec: OpWaveExec }, waveName: string, run: OpWaveRun): OpWaveMember {
|
|
@@ -476,12 +592,20 @@ function planRun(opts: { spec: OpWavesSpec; wave: number; cwd: string; exec: OpW
|
|
|
476
592
|
}
|
|
477
593
|
|
|
478
594
|
function member(target: string, op: string, file: OpWavePlanFile): OpWaveMember {
|
|
595
|
+
const denied = file.denied as unknown;
|
|
596
|
+
if (denied !== undefined) {
|
|
597
|
+
const d = denied as { rules?: unknown; reason?: unknown } | null;
|
|
598
|
+
if (!d || typeof d !== "object" || !Array.isArray(d.rules) || d.rules.length === 0 || !d.rules.every((r) => typeof r === "string" && r.trim() !== "") || (d.reason !== undefined && typeof d.reason !== "string")) {
|
|
599
|
+
throw new Error(`the plan of ${target} has denied ${JSON.stringify(denied)}; a denial is { "rules": ["<rule id>", ...], "reason"?: "<text>" }`);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
479
602
|
return {
|
|
480
603
|
member: target,
|
|
481
604
|
planDigest: file.planDigest,
|
|
482
605
|
op,
|
|
483
606
|
...(file.destructive === true ? { destructive: true } : {}),
|
|
484
607
|
...(file.empty === true ? { empty: true } : {}),
|
|
608
|
+
...(file.denied ? { denied: { rules: file.denied.rules, ...(file.denied.reason ? { reason: file.denied.reason } : {}) } } : {}),
|
|
485
609
|
};
|
|
486
610
|
}
|
|
487
611
|
|
|
@@ -492,11 +616,13 @@ function writeRunsFile(
|
|
|
492
616
|
members?: ReadonlyMap<string, OpWaveMember>,
|
|
493
617
|
digest?: string,
|
|
494
618
|
path: string = opWaveRunsPath(at.spec, at.k),
|
|
619
|
+
discover = false,
|
|
495
620
|
): string {
|
|
496
621
|
const file: OpWaveRunsFile = {
|
|
497
622
|
wave: at.wave.name,
|
|
498
623
|
number: at.k,
|
|
499
624
|
...(digest ? { digest } : {}),
|
|
625
|
+
...(discover ? { discover: true as const } : {}),
|
|
500
626
|
runs: runs.map((run) => {
|
|
501
627
|
const m = members?.get(run.target);
|
|
502
628
|
return {
|
|
@@ -516,10 +642,10 @@ function writeRunsFile(
|
|
|
516
642
|
* for all of them (#3762). A wave plan is written out as each run's plan file
|
|
517
643
|
* too, so a per-run `apply` or hook reads `{plan}` either way.
|
|
518
644
|
*/
|
|
519
|
-
function planRuns(at: { spec: OpWavesSpec; k: number; wave: OpWave; cwd: string; exec: OpWaveExec }, runs: readonly OpWaveRun[]): PlannedRuns {
|
|
645
|
+
function planRuns(at: { spec: OpWavesSpec; k: number; wave: OpWave; cwd: string; exec: OpWaveExec }, runs: readonly OpWaveRun[], discover = false): PlannedRuns {
|
|
520
646
|
const { spec, k, wave, cwd, exec } = at;
|
|
521
647
|
if (!spec.planWave) return { members: runs.map((run) => planRun({ spec, wave: k, cwd, exec }, wave.name, run)) };
|
|
522
|
-
const runsPath = writeRunsFile(at, runs);
|
|
648
|
+
const runsPath = writeRunsFile(at, runs, undefined, undefined, undefined, discover);
|
|
523
649
|
const plans = opWavePlansPath(spec, k);
|
|
524
650
|
rmSync(join(cwd, plans), { force: true });
|
|
525
651
|
const code = exec(opWaveArgv(spec.planWave, { op: spec.op, wave: wave.name, runs: runsPath, plans }), cwd);
|
|
@@ -534,8 +660,15 @@ function planRuns(at: { spec: OpWavesSpec; k: number; wave: OpWave; cwd: string;
|
|
|
534
660
|
if (!Array.isArray(entries)) throw new Error(`the plans file of wave ${k} (${plans}) must hold { "runs": [{ "target", "planDigest" }] }`);
|
|
535
661
|
const wanted = new Set(runs.map((r) => r.target));
|
|
536
662
|
const byTarget = new Map<string, OpWavePlanFile>();
|
|
537
|
-
|
|
663
|
+
// #3821: a discovering wave's runs are the ones the plans file names.
|
|
664
|
+
const found: OpWaveRun[] = [];
|
|
665
|
+
for (const entry of entries as Array<Partial<OpWavePlanFile & { target: string; op: string }>>) {
|
|
538
666
|
const target = entry?.target;
|
|
667
|
+
if (discover && typeof target === "string" && target !== "" && !byTarget.has(target)) {
|
|
668
|
+
if (entry.op !== undefined && (typeof entry.op !== "string" || entry.op === "")) throw new Error(`the plans file of wave ${k} (${plans}) gives ${target} an op that is not a name`);
|
|
669
|
+
found.push({ target, ...(entry.op && entry.op !== spec.op ? { op: entry.op } : {}) });
|
|
670
|
+
wanted.add(target);
|
|
671
|
+
}
|
|
539
672
|
if (typeof target !== "string" || !wanted.has(target)) {
|
|
540
673
|
throw new Error(`the plans file of wave ${k} (${plans}) plans ${JSON.stringify(target)}, which is not a run it was asked to plan`);
|
|
541
674
|
}
|
|
@@ -545,20 +678,56 @@ function planRuns(at: { spec: OpWavesSpec; k: number; wave: OpWave; cwd: string;
|
|
|
545
678
|
}
|
|
546
679
|
const missing = runs.filter((r) => !byTarget.has(r.target)).map((r) => r.target);
|
|
547
680
|
if (missing.length > 0) throw new Error(`the plans file of wave ${k} (${plans}) has no plan for ${missing.join(", ")}`);
|
|
681
|
+
if (discover) runs = found;
|
|
682
|
+
const extras = Array.isArray(parsed) ? undefined : extraMembers((parsed as { members?: unknown }).members, new Set(runs.map((r) => r.target)), `the plans file of wave ${k} (${plans})`);
|
|
683
|
+
const count = Array.isArray(parsed) ? undefined : (parsed as { waves?: unknown }).waves;
|
|
684
|
+
if (count !== undefined && (!Number.isInteger(count) || (count as number) < 0)) throw new Error(`the plans file of wave ${k} (${plans}): \`waves\` must be the number of waves found, a whole number`);
|
|
548
685
|
const pending = Array.isArray(parsed) ? undefined : pendingFields((parsed as { pending?: unknown }).pending, `the plans file of wave ${k} (${plans})`);
|
|
549
686
|
const members = runs.map((run) => {
|
|
550
687
|
const m = member(run.target, run.op ?? spec.op, byTarget.get(run.target)!);
|
|
551
688
|
const path = join(cwd, opWavePlanPath(spec, k, run.target));
|
|
552
|
-
writeFileSync(path, JSON.stringify({ planDigest: m.planDigest, ...(m.destructive ? { destructive: true } : {}), ...(m.empty ? { empty: true } : {}) }) + "\n");
|
|
689
|
+
writeFileSync(path, JSON.stringify({ planDigest: m.planDigest, ...(m.destructive ? { destructive: true } : {}), ...(m.empty ? { empty: true } : {}), ...(m.denied ? { denied: m.denied } : {}) }) + "\n");
|
|
553
690
|
return m;
|
|
554
691
|
});
|
|
555
|
-
|
|
692
|
+
const overriders = Array.isArray(parsed) ? undefined : (parsed as { overriders?: unknown }).overriders;
|
|
693
|
+
if (overriders !== undefined && (!Array.isArray(overriders) || !overriders.every((o) => typeof o === "string"))) {
|
|
694
|
+
throw new Error(`the plans file of wave ${k} (${plans}): \`overriders\` must be a list of principals`);
|
|
695
|
+
}
|
|
696
|
+
return {
|
|
697
|
+
members,
|
|
698
|
+
...(pending ? { pending } : {}),
|
|
699
|
+
...(overriders ? { overriders: overriders as string[] } : {}),
|
|
700
|
+
...(extras ? { extras } : {}),
|
|
701
|
+
...(count !== undefined ? { waves: count as number } : {}),
|
|
702
|
+
};
|
|
556
703
|
}
|
|
557
704
|
|
|
558
705
|
/** What planning a wave's runs gave: each run's plan, and the product's pending-fact fields (#3821). */
|
|
559
706
|
interface PlannedRuns {
|
|
560
707
|
members: OpWaveMember[];
|
|
561
708
|
pending?: Record<string, unknown>;
|
|
709
|
+
/** The plans file's overriders (#3821). */
|
|
710
|
+
overriders?: string[];
|
|
711
|
+
/** Digest members that are not runs, such as the wave's cost (#3821). */
|
|
712
|
+
extras?: WaveMember[];
|
|
713
|
+
/** How many waves the product found (#3821), for a `rest` wave. */
|
|
714
|
+
waves?: number;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/** A plans file's `members` (#3821): digest members that are no run, each named once and by no run's target. */
|
|
718
|
+
function extraMembers(value: unknown, targets: ReadonlySet<string>, where: string): WaveMember[] | undefined {
|
|
719
|
+
if (value === undefined) return undefined;
|
|
720
|
+
if (!Array.isArray(value)) throw new Error(`${where}: \`members\` must be a list of { "member", "planDigest" }`);
|
|
721
|
+
const seen = new Set<string>();
|
|
722
|
+
const out: WaveMember[] = [];
|
|
723
|
+
for (const m of value as Array<Partial<WaveMember>>) {
|
|
724
|
+
if (!m || typeof m.member !== "string" || m.member === "" || typeof m.planDigest !== "string" || m.planDigest === "") throw new Error(`${where}: each of \`members\` needs a member and a planDigest`);
|
|
725
|
+
if (targets.has(m.member)) throw new Error(`${where}: member ${m.member} is also a run's target; a digest member that is no run needs a name of its own`);
|
|
726
|
+
if (seen.has(m.member)) throw new Error(`${where}: names member ${m.member} twice`);
|
|
727
|
+
seen.add(m.member);
|
|
728
|
+
out.push({ member: m.member, planDigest: m.planDigest });
|
|
729
|
+
}
|
|
730
|
+
return out.length > 0 ? out : undefined;
|
|
562
731
|
}
|
|
563
732
|
|
|
564
733
|
/** A plans file's `pending` (#3821): an object whose keys chant does not write itself, `description` aside. Undefined when absent or empty. */
|
|
@@ -574,11 +743,12 @@ function pendingFields(value: unknown, where: string): Record<string, unknown> |
|
|
|
574
743
|
/**
|
|
575
744
|
* A wave's set digest (#3821): {@link waveSetDigest} over its runs' plans,
|
|
576
745
|
* or under `digest.over: "changing"` over the runs whose plan is not empty,
|
|
577
|
-
* falling back to every run when all of them are empty.
|
|
746
|
+
* falling back to every run when all of them are empty. `extras`, the plans
|
|
747
|
+
* file's members that are no run (a cost), are always in it.
|
|
578
748
|
*/
|
|
579
|
-
export function opWaveSetDigest(spec: Pick<OpWavesSpec, "digest">, members: ReadonlyArray<WaveMember & { empty?: boolean }
|
|
749
|
+
export function opWaveSetDigest(spec: Pick<OpWavesSpec, "digest">, members: ReadonlyArray<WaveMember & { empty?: boolean }>, extras: readonly WaveMember[] = []): string {
|
|
580
750
|
const changing = spec.digest?.over === "changing" ? members.filter((m) => m.empty !== true) : [];
|
|
581
|
-
return waveSetDigest(changing.length > 0 ? changing : members);
|
|
751
|
+
return waveSetDigest([...(changing.length > 0 ? changing : members), ...extras]);
|
|
582
752
|
}
|
|
583
753
|
|
|
584
754
|
/** What one moment's hooks came to. */
|
|
@@ -587,6 +757,32 @@ interface HooksOutcome {
|
|
|
587
757
|
failed?: string;
|
|
588
758
|
/** The `onFailure: approve` hooks that failed. */
|
|
589
759
|
holds: string[];
|
|
760
|
+
/** A hook held the wave on another gate that nobody approved yet (#3821): the wave waits. */
|
|
761
|
+
waitingOn?: OpWaveHeldOn;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/** A hook call that held the wave on another gate (#3821), kept so it can run again once that gate is approved. */
|
|
765
|
+
interface OtherGateHold {
|
|
766
|
+
label: string;
|
|
767
|
+
name: string;
|
|
768
|
+
target?: string;
|
|
769
|
+
argv: string[];
|
|
770
|
+
env: Record<string, string>;
|
|
771
|
+
request: OpWaveHoldRequest;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/** Read the hold file a hook wrote (#3821), or undefined when it wrote none or one that names no gate and digest. */
|
|
775
|
+
function readHoldRequest(path: string, log: (line: string) => void, label: string): OpWaveHoldRequest | undefined {
|
|
776
|
+
if (!existsSync(path)) return undefined;
|
|
777
|
+
try {
|
|
778
|
+
const r = JSON.parse(readFileSync(path, "utf-8")) as Partial<OpWaveHoldRequest>;
|
|
779
|
+
if (typeof r.gate !== "string" || r.gate === "" || typeof r.digest !== "string" || r.digest === "") throw new Error("it names no gate and digest");
|
|
780
|
+
if (r.op !== undefined && (typeof r.op !== "string" || r.op === "")) throw new Error("its op is not a name");
|
|
781
|
+
return { gate: r.gate, digest: r.digest, ...(r.op ? { op: r.op } : {}), ...(typeof r.description === "string" ? { description: r.description } : {}), ...(r.pending ? { pending: pendingFields(r.pending, `the hold ${label} wrote`) ?? {} } : {}) };
|
|
782
|
+
} catch (err) {
|
|
783
|
+
log(`${label} wrote a hold file that cannot be read (${err instanceof Error ? err.message : String(err)}), so it holds the wave on its set digest`);
|
|
784
|
+
return undefined;
|
|
785
|
+
}
|
|
590
786
|
}
|
|
591
787
|
|
|
592
788
|
/** Where hooks run: the wave they belong to and the job's tools. */
|
|
@@ -612,11 +808,14 @@ function runHookList(
|
|
|
612
808
|
hooks: readonly OpWaveHook[] | undefined,
|
|
613
809
|
runs: readonly OpWaveHookRun[],
|
|
614
810
|
runsPath: string,
|
|
615
|
-
|
|
616
|
-
|
|
811
|
+
extraEnv: Record<string, string> = {},
|
|
812
|
+
): HooksOutcome & { outcomes: OpWaveHookOutcome[]; otherGates: OtherGateHold[] } {
|
|
813
|
+
const out: HooksOutcome & { outcomes: OpWaveHookOutcome[]; otherGates: OtherGateHold[] } = { holds: [], outcomes: [], otherGates: [] };
|
|
617
814
|
if (!hooks || hooks.length === 0) return out;
|
|
618
815
|
const { name: specName, op, waveName, k, cwd, exec, log } = at;
|
|
619
|
-
|
|
816
|
+
// #3821: a hook that holds the wave on another gate names it in this file.
|
|
817
|
+
const holdPath = `.chant/op-waves/${specName}/wave-${k}/hold.json`;
|
|
818
|
+
const env = { CHANT_OP_WAVES: specName, CHANT_WAVE: waveName, CHANT_WAVE_NUMBER: String(k), CHANT_HOOK: moment, CHANT_RUNS: runsPath, CHANT_HOLD: holdPath, ...extraEnv };
|
|
620
819
|
for (const hook of hooks) {
|
|
621
820
|
const name = hook.name ?? (hook.command.join(" ").length > 60 ? `${hook.command.join(" ").slice(0, 57)}...` : hook.command.join(" "));
|
|
622
821
|
const calls = hook.perRun
|
|
@@ -629,6 +828,7 @@ function runHookList(
|
|
|
629
828
|
for (const call of calls) {
|
|
630
829
|
const label = `${moment} hook ${name}${call.target ? ` (${call.target})` : ""}`;
|
|
631
830
|
let code: number;
|
|
831
|
+
rmSync(join(cwd, holdPath), { force: true });
|
|
632
832
|
try {
|
|
633
833
|
code = exec(call.argv, cwd, call.env);
|
|
634
834
|
} catch (err) {
|
|
@@ -642,6 +842,12 @@ function runHookList(
|
|
|
642
842
|
}
|
|
643
843
|
if (hook.onFailure === "approve") {
|
|
644
844
|
out.outcomes.push({ ...base, outcome: "held" });
|
|
845
|
+
const request = readHoldRequest(join(cwd, holdPath), log, label);
|
|
846
|
+
if (request) {
|
|
847
|
+
out.otherGates.push({ label, name, ...(call.target ? { target: call.target } : {}), argv: call.argv, env: call.env, request });
|
|
848
|
+
log(`${label} exited ${code} and holds wave ${k} (${waveName}) on gate ${request.op ?? specName} / ${request.gate}, digest ${request.digest}`);
|
|
849
|
+
continue;
|
|
850
|
+
}
|
|
645
851
|
out.holds.push(label);
|
|
646
852
|
log(`${label} exited ${code}, so wave ${k} (${waveName}) waits for an approval of its set digest`);
|
|
647
853
|
continue;
|
|
@@ -660,20 +866,105 @@ const placeOf = (ctx: WaveContext): HookPlace => ({ name: ctx.spec.name, op: ctx
|
|
|
660
866
|
const hookRuns = (spec: OpWavesSpec, k: number, runs: readonly OpWaveRun[], members?: ReadonlyMap<string, OpWaveMember>): OpWaveHookRun[] =>
|
|
661
867
|
runs.map((run) => ({ target: run.target, op: run.op ?? spec.op, ...(members?.has(run.target) ? { plan: opWavePlanPath(spec, k, run.target) } : {}) }));
|
|
662
868
|
|
|
663
|
-
/**
|
|
664
|
-
|
|
869
|
+
/**
|
|
870
|
+
* Run one moment's hooks in the wave job (#3762), recording each outcome on
|
|
871
|
+
* the result. A hook that held the wave on another gate (#3821) has that
|
|
872
|
+
* gate settled here: approved, the hook runs again with
|
|
873
|
+
* `CHANT_HOLD_APPROVED` and must pass; otherwise the wave waits on it.
|
|
874
|
+
*/
|
|
875
|
+
async function runHooks(
|
|
665
876
|
ctx: WaveContext,
|
|
666
877
|
moment: OpWaveHookMoment,
|
|
667
878
|
hooks: readonly OpWaveHook[] | undefined,
|
|
668
879
|
runs: readonly OpWaveRun[],
|
|
669
880
|
members?: ReadonlyMap<string, OpWaveMember>,
|
|
670
881
|
digest?: string,
|
|
671
|
-
|
|
882
|
+
extraEnv: Record<string, string> = {},
|
|
883
|
+
): Promise<HooksOutcome> {
|
|
672
884
|
if (!hooks || hooks.length === 0) return { holds: [] };
|
|
673
885
|
const runsPath = writeRunsFile(ctx, runs, members, digest);
|
|
674
|
-
const out = runHookList(placeOf(ctx), moment, hooks, hookRuns(ctx.spec, ctx.k, runs, members), runsPath);
|
|
886
|
+
const out = runHookList(placeOf(ctx), moment, hooks, hookRuns(ctx.spec, ctx.k, runs, members), runsPath, extraEnv);
|
|
675
887
|
ctx.result.hooks.push(...out.outcomes);
|
|
676
|
-
|
|
888
|
+
const back: HooksOutcome = { holds: out.holds, ...(out.failed ? { failed: out.failed } : {}) };
|
|
889
|
+
if (out.failed) return back;
|
|
890
|
+
for (const hold of out.otherGates) {
|
|
891
|
+
const held = await settleOtherGate(ctx, hold);
|
|
892
|
+
if (held.status === "waiting") {
|
|
893
|
+
back.waitingOn ??= held;
|
|
894
|
+
continue;
|
|
895
|
+
}
|
|
896
|
+
const approvedEnv = { ...hold.env, CHANT_HOLD_APPROVED: JSON.stringify({ op: held.op, gate: held.gate, digest: held.digest, approvedBy: held.approvedBy, approvedAt: held.approvedAt }) };
|
|
897
|
+
let code: number;
|
|
898
|
+
try {
|
|
899
|
+
code = ctx.exec(hold.argv, ctx.cwd, approvedEnv);
|
|
900
|
+
} catch (err) {
|
|
901
|
+
ctx.log(`${hold.label} did not run: ${err instanceof Error ? err.message : String(err)}`);
|
|
902
|
+
code = 127;
|
|
903
|
+
}
|
|
904
|
+
ctx.result.hooks.push({ moment, name: hold.name, ...(hold.target ? { target: hold.target } : {}), exit: code, outcome: code === 0 ? "passed" : "failed" });
|
|
905
|
+
if (code !== 0) {
|
|
906
|
+
back.failed = `${hold.label} exited ${code} after ${held.approvedBy} approved ${held.gate}`;
|
|
907
|
+
ctx.log(back.failed);
|
|
908
|
+
return back;
|
|
909
|
+
}
|
|
910
|
+
ctx.log(`${hold.label}: ${held.approvedBy} approved ${held.op} / ${held.gate} at ${held.digest}, and the hook ran again and passed`);
|
|
911
|
+
}
|
|
912
|
+
return back;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/** The gate ledger a wave decides by: the job's port, with the signers at base as the rule when `approval` is `sealed`. */
|
|
916
|
+
async function gatePort(options: RunOpWaveOptions, cwd: string, gate: string, approval: GateApprovalSource, base: string): Promise<{ ledger: GateLedgerPort; port: GateLedgerPort }> {
|
|
917
|
+
const ledger = options.gates ?? gitGateLedgerPort({ cwd });
|
|
918
|
+
if (approval !== "sealed") return { ledger, port: ledger };
|
|
919
|
+
// #3684: only an approval sealed by a signer listed at base counts.
|
|
920
|
+
const review = await import("./gate-review");
|
|
921
|
+
const sealed = review.sealedApprovalRule(gate, (options.trust ?? ((b) => review.trustPolicyAt(cwd, b)))(base));
|
|
922
|
+
return {
|
|
923
|
+
ledger,
|
|
924
|
+
port: {
|
|
925
|
+
...ledger,
|
|
926
|
+
async approvalRule(g) {
|
|
927
|
+
const own = (await ledger.approvalRule?.(g)) ?? null;
|
|
928
|
+
return g === gate ? review.bothRules(own, sealed) : own;
|
|
929
|
+
},
|
|
930
|
+
},
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
/**
|
|
935
|
+
* Settle a hold on another gate (#3821): an approval of that gate and digest
|
|
936
|
+
* under the wave's approval mode (`pr-review` counts the ledger alone, since
|
|
937
|
+
* no review planned that digest), else a pending fact for it.
|
|
938
|
+
*/
|
|
939
|
+
async function settleOtherGate(ctx: WaveContext, hold: OtherGateHold): Promise<OpWaveHeldOn> {
|
|
940
|
+
const { spec, k, wave, log } = ctx;
|
|
941
|
+
const rules = ctx.gateRule;
|
|
942
|
+
const op = hold.request.op ?? spec.name;
|
|
943
|
+
const mode: GateApprovalSource = rules.approval === "sealed" ? "sealed" : "ledger";
|
|
944
|
+
const { port } = await gatePort(ctx.options, ctx.cwd, hold.request.gate, mode, rules.base);
|
|
945
|
+
const neverOverMcp = rules.neverOverMcp;
|
|
946
|
+
const now = ctx.options.now ?? new Date().toISOString();
|
|
947
|
+
const check = await evaluateGate(port, {
|
|
948
|
+
op,
|
|
949
|
+
gate: hold.request.gate,
|
|
950
|
+
planDigest: hold.request.digest,
|
|
951
|
+
description: hold.request.description ?? `wave ${k} (${wave.name}) of ${spec.name} is held on it by ${hold.label}`,
|
|
952
|
+
now,
|
|
953
|
+
...(hold.request.pending ? { fields: hold.request.pending } : {}),
|
|
954
|
+
...(neverOverMcp ? { neverOverMcp: true } : {}),
|
|
955
|
+
});
|
|
956
|
+
const base = { op, gate: hold.request.gate, digest: hold.request.digest, hook: hold.label };
|
|
957
|
+
if (check.satisfied) return { ...base, status: "approved", approvedBy: check.resolution.resolvedBy, approvedAt: check.resolution.timestamp };
|
|
958
|
+
const approve = approveCommand(op, hold.request.gate, undefined, hold.request.digest, mode === "sealed");
|
|
959
|
+
log(`wave ${k} (${wave.name}) waits on ${op} / ${hold.request.gate}, which ${hold.label} holds it on: nobody approved ${hold.request.digest}. Approve: ${approve}`);
|
|
960
|
+
return {
|
|
961
|
+
...base,
|
|
962
|
+
status: "waiting",
|
|
963
|
+
approve,
|
|
964
|
+
waitingSince: check.pending.timestamp,
|
|
965
|
+
recorded: check.recorded,
|
|
966
|
+
...(check.mismatch ? { mismatch: check.mismatch } : {}),
|
|
967
|
+
};
|
|
677
968
|
}
|
|
678
969
|
|
|
679
970
|
/**
|
|
@@ -725,12 +1016,14 @@ export function runOpWaveApplyHooks(file: OpWaveHooksFile, moment: OpWaveHookMom
|
|
|
725
1016
|
} else {
|
|
726
1017
|
const place: HookPlace = { name: file.opWaves, op: file.op, waveName: file.wave, k: file.number, cwd, exec: options.exec ?? spawnExec, log };
|
|
727
1018
|
const out = runHookList(place, moment, file.hooks[moment], file.runs, file.runsFile);
|
|
1019
|
+
// A hold on another gate (#3821) is settled only by the wave job; under the apply's lock it stops the apply.
|
|
1020
|
+
const others = out.otherGates.map((o) => `${o.label} on ${o.request.op ?? file.opWaves} / ${o.request.gate}`);
|
|
728
1021
|
run = {
|
|
729
1022
|
moment,
|
|
730
1023
|
outcomes: out.outcomes,
|
|
731
1024
|
...(out.failed ? { failed: out.failed } : {}),
|
|
732
|
-
held: out.holds,
|
|
733
|
-
proceed: !out.failed && (out.holds.length === 0 || file.approved),
|
|
1025
|
+
held: [...out.holds, ...others],
|
|
1026
|
+
proceed: !out.failed && others.length === 0 && (out.holds.length === 0 || file.approved),
|
|
734
1027
|
};
|
|
735
1028
|
}
|
|
736
1029
|
if (file.result) {
|
|
@@ -814,12 +1107,49 @@ function noteHooksAtBase(ctx: WaveContext, rules: OpWaveBaseRules): void {
|
|
|
814
1107
|
|
|
815
1108
|
/** Plan, decide and apply one wave, or one part of a wide wave. */
|
|
816
1109
|
export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveResult> {
|
|
817
|
-
const { spec
|
|
1110
|
+
const { spec } = options;
|
|
818
1111
|
assertOpWavesSpec(spec);
|
|
1112
|
+
const log = options.log ?? ((line: string) => console.error(line));
|
|
1113
|
+
const last = spec.waves[spec.waves.length - 1]!;
|
|
1114
|
+
const applied: string[] = [];
|
|
1115
|
+
const rest: NonNullable<RunOpWaveResult["rest"]> = [];
|
|
1116
|
+
for (let k = options.wave; ; k++) {
|
|
1117
|
+
let result: RunOpWaveResult | undefined;
|
|
1118
|
+
let prior: OpWaveDecision | undefined;
|
|
1119
|
+
// #3821: an apply that came back "replan" plans the wave again, a bounded number of times.
|
|
1120
|
+
for (let pass = 0; ; pass++) {
|
|
1121
|
+
result = await runOneWave(options, k, prior, pass);
|
|
1122
|
+
if (!result.replan) break;
|
|
1123
|
+
if (pass + 1 >= OP_WAVE_MAX_REPLANS) {
|
|
1124
|
+
log(`wave ${k}: its apply asked to plan again ${OP_WAVE_MAX_REPLANS} times, so the wave stops here`);
|
|
1125
|
+
result.exitCode = 1;
|
|
1126
|
+
break;
|
|
1127
|
+
}
|
|
1128
|
+
log(`wave ${k}: the apply applied nothing and asks to plan again${result.replan.reason ? ` (${result.replan.reason})` : ""}; planning again`);
|
|
1129
|
+
prior = result.decision;
|
|
1130
|
+
}
|
|
1131
|
+
applied.push(...result.applied);
|
|
1132
|
+
// #3821: a `rest` wave goes on through the waves its plan command found.
|
|
1133
|
+
const found = result.decision?.discovered?.waves;
|
|
1134
|
+
const onward = k >= spec.waves.length && last.rest === true && options.share === undefined && !options.decide && result.exitCode === 0 && found !== undefined && k < found;
|
|
1135
|
+
if (rest.length > 0 || onward) rest.push({ wave: k, exitCode: result.exitCode, ...(result.decision ? { digest: result.decision.digest } : {}) });
|
|
1136
|
+
if (!onward) return { ...result, applied, ...(rest.length > 0 ? { rest } : {}) };
|
|
1137
|
+
log(`wave ${k} applied; the plan found ${found} waves, so this job goes on to wave ${k + 1}`);
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
/** How many times one wave job plans again when its apply asks to (#3821). */
|
|
1142
|
+
export const OP_WAVE_MAX_REPLANS = 10;
|
|
1143
|
+
|
|
1144
|
+
/** Plan, decide and apply wave `k`, or one part of a wide wave. `prior`: the decision of the pass whose apply asked to plan again. */
|
|
1145
|
+
async function runOneWave(options: RunOpWaveOptions, k: number, prior: OpWaveDecision | undefined, pass: number): Promise<RunOpWaveResult> {
|
|
1146
|
+
const { spec } = options;
|
|
819
1147
|
const cwd = options.cwd ?? process.cwd();
|
|
820
1148
|
const exec = options.exec ?? spawnExec;
|
|
821
1149
|
const log = options.log ?? ((line: string) => console.error(line));
|
|
822
|
-
const
|
|
1150
|
+
const last = spec.waves[spec.waves.length - 1]!;
|
|
1151
|
+
// #3821: past the spec's waves, a `rest` wave stands for each wave its plan command found.
|
|
1152
|
+
let wave = spec.waves[k - 1] ?? (last.rest ? last : undefined);
|
|
823
1153
|
if (!wave) throw new Error(`Op waves "${spec.name}" has ${spec.waves.length} waves; there is no wave ${k}.`);
|
|
824
1154
|
const shares = wave.shares ?? 1;
|
|
825
1155
|
if (options.share !== undefined && (options.share < 1 || options.share > shares)) {
|
|
@@ -827,7 +1157,8 @@ export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveRes
|
|
|
827
1157
|
}
|
|
828
1158
|
const recordPath = join(cwd, opWaveRecordPath(spec, k));
|
|
829
1159
|
const result: RunOpWaveResult = { exitCode: 0, applied: [], failed: [], moved: [], hooks: [] };
|
|
830
|
-
const ctx: WaveContext = { spec, k, wave, cwd, exec, log, result };
|
|
1160
|
+
const ctx: WaveContext = { spec, k, wave, cwd, exec, log, result, options, gateRule: { approval: "ledger", base: "HEAD", neverOverMcp: spec.neverOverMcp === true } };
|
|
1161
|
+
if (pass > 0) log(`wave ${k} (${wave.name}): plan ${pass + 1}`);
|
|
831
1162
|
|
|
832
1163
|
if (options.share !== undefined) {
|
|
833
1164
|
let decision: OpWaveDecision;
|
|
@@ -837,18 +1168,26 @@ export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveRes
|
|
|
837
1168
|
throw new Error(`share ${options.share} of wave ${k} (${wave.name}) found no decision at ${opWaveRecordPath(spec, k)}. Run the deciding job first.`);
|
|
838
1169
|
}
|
|
839
1170
|
result.decision = decision;
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
1171
|
+
ctx.gateRule = { approval: decision.approval, base: decision.base, neverOverMcp: decision.neverOverMcp === true };
|
|
1172
|
+
if (decision.status === "waiting" || decision.status === "refused") {
|
|
1173
|
+
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" ? 7 : 3 };
|
|
843
1175
|
}
|
|
844
1176
|
// The share runs the hooks its deciding job read at base, around its own slice.
|
|
1177
|
+
if (wave.discover) {
|
|
1178
|
+
// #3821: a discovering wave's runs are the ones its deciding job planned.
|
|
1179
|
+
wave = { ...wave, runs: decision.members.map((m) => ({ target: m.member, ...(m.op !== spec.op ? { op: m.op } : {}) })) };
|
|
1180
|
+
ctx.wave = wave;
|
|
1181
|
+
}
|
|
845
1182
|
const slice = opWaveShare(wave.runs, options.share, shares);
|
|
846
|
-
const before = runHooks(ctx, "before-plan", decision.hooks?.["before-plan"], slice);
|
|
1183
|
+
const before = await runHooks(ctx, "before-plan", decision.hooks?.["before-plan"], slice);
|
|
847
1184
|
if (before.failed) return failed(ctx, `a hook before the plan failed, so nothing in share ${options.share} of wave ${k} was planned or applied`);
|
|
1185
|
+
if (before.waitingOn) return waitOnOther(ctx, decision, before.waitingOn);
|
|
848
1186
|
const { members } = planRuns(ctx, slice);
|
|
849
1187
|
const planned = new Map(members.map((m) => [m.member, m]));
|
|
850
|
-
const after = runHooks(ctx, "after-plan", decision.hooks?.["after-plan"], slice, planned);
|
|
1188
|
+
const after = await runHooks(ctx, "after-plan", decision.hooks?.["after-plan"], slice, planned);
|
|
851
1189
|
if (after.failed) return failed(ctx, `a hook after the plan failed, so nothing in share ${options.share} of wave ${k} was applied`);
|
|
1190
|
+
if (after.waitingOn) return waitOnOther(ctx, decision, after.waitingOn);
|
|
852
1191
|
const decided = new Map(decision.members.map((m) => [m.member, m.planDigest]));
|
|
853
1192
|
const unmoved: OpWaveRun[] = [];
|
|
854
1193
|
for (const run of slice) {
|
|
@@ -871,21 +1210,14 @@ export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveRes
|
|
|
871
1210
|
// The policy, the approval mode and the hooks, read at base before anything runs.
|
|
872
1211
|
const rules = readOpWavePolicy(options.specFile, wave.name, options.base ?? spec.base ?? "HEAD^1", cwd, options.show);
|
|
873
1212
|
const { policy, approval, source, base } = rules;
|
|
1213
|
+
const neverOverMcp = rules.neverOverMcp || spec.neverOverMcp === true;
|
|
1214
|
+
ctx.gateRule = { approval, base, neverOverMcp };
|
|
874
1215
|
noteHooksAtBase(ctx, rules);
|
|
875
1216
|
const holds: string[] = [];
|
|
876
1217
|
if (rules.hooksRefused) {
|
|
877
1218
|
holds.push(rules.hooksRefused);
|
|
878
1219
|
log(`wave ${k} (${wave.name}): ${rules.hooksRefused}, so none run and the wave waits for an approval`);
|
|
879
1220
|
}
|
|
880
|
-
const before = runHooks(ctx, "before-plan", rules.hooks["before-plan"], wave.runs);
|
|
881
|
-
if (before.failed) return failed(ctx, `a hook before the plan failed, so nothing in wave ${k} was planned or applied`);
|
|
882
|
-
const { members, pending } = planRuns(ctx, wave.runs);
|
|
883
|
-
const planned = new Map(members.map((m) => [m.member, m]));
|
|
884
|
-
const digest = opWaveSetDigest(spec, members);
|
|
885
|
-
const neverOverMcp = rules.neverOverMcp || spec.neverOverMcp === true;
|
|
886
|
-
const after = runHooks(ctx, "after-plan", rules.hooks["after-plan"], wave.runs, planned, digest);
|
|
887
|
-
if (after.failed) return failed(ctx, `a hook after the plan failed, so nothing in wave ${k} was applied`);
|
|
888
|
-
holds.push(...before.holds, ...after.holds);
|
|
889
1221
|
const gate = opWaveGateName(spec, k);
|
|
890
1222
|
const hooks = Object.fromEntries(Object.entries(rules.hooks).filter(([, list]) => list.length > 0));
|
|
891
1223
|
const decision: OpWaveDecision = {
|
|
@@ -897,41 +1229,248 @@ export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveRes
|
|
|
897
1229
|
approval,
|
|
898
1230
|
policySource: source,
|
|
899
1231
|
base,
|
|
900
|
-
digest,
|
|
901
|
-
members,
|
|
1232
|
+
digest: "",
|
|
1233
|
+
members: [],
|
|
902
1234
|
status: "not-required",
|
|
903
1235
|
...(Object.keys(hooks).length > 0 ? { hooks } : {}),
|
|
904
|
-
...(holds.length > 0 ? { heldBy: holds } : {}),
|
|
905
1236
|
...(rules.spend ? { spend: "base" as const } : spec.approvals?.spend === true ? { spend: "change" as const } : {}),
|
|
906
1237
|
...(rules.applyHooksRunBy === "apply" ? { applyHooksRunBy: "apply" as const } : {}),
|
|
907
1238
|
...(spec.digest?.over === "changing" ? { digestOver: "changing" as const } : {}),
|
|
908
|
-
...(pending ? { pending } : {}),
|
|
909
1239
|
...(neverOverMcp ? { neverOverMcp: true as const } : {}),
|
|
910
1240
|
};
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
if (
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
1241
|
+
const before = await runHooks(ctx, "before-plan", rules.hooks["before-plan"], wave.runs);
|
|
1242
|
+
if (before.failed) return failed(ctx, `a hook before the plan failed, so nothing in wave ${k} was planned or applied`);
|
|
1243
|
+
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);
|
|
1245
|
+
if (wave.discover) {
|
|
1246
|
+
wave = { ...wave, runs: members.map((m) => ({ target: m.member, ...(m.op !== spec.op ? { op: m.op } : {}) })) };
|
|
1247
|
+
ctx.wave = wave;
|
|
1248
|
+
decision.discovered = { ...(found !== undefined ? { waves: found } : {}) };
|
|
1249
|
+
log(`wave ${k} (${wave.name}): the plan found ${members.length} run${members.length === 1 ? "" : "s"}${found !== undefined ? ` of ${found} waves` : ""}${members.length ? `: ${members.map((m) => m.member).join(", ")}` : ""}`);
|
|
1250
|
+
}
|
|
1251
|
+
const planned = new Map(members.map((m) => [m.member, m]));
|
|
1252
|
+
const digest = opWaveSetDigest(spec, members, extras);
|
|
1253
|
+
decision.digest = digest;
|
|
1254
|
+
decision.members = members;
|
|
1255
|
+
if (extras) decision.extras = extras;
|
|
1256
|
+
if (pending) decision.pending = pending;
|
|
1257
|
+
const after = await runHooks(ctx, "after-plan", rules.hooks["after-plan"], wave.runs, planned, digest);
|
|
1258
|
+
if (after.failed) return failed(ctx, `a hook after the plan failed, so nothing in wave ${k} was applied`);
|
|
1259
|
+
holds.push(...before.holds, ...after.holds);
|
|
1260
|
+
if (holds.length > 0) decision.heldBy = holds;
|
|
1261
|
+
if (after.waitingOn) return decided(ctx, decision, recordPath, after.waitingOn);
|
|
1262
|
+
// #3821: a run the product's policy denied applies only under an override; with none the wave is refused, and no approval is waited for.
|
|
1263
|
+
const kept = prior !== undefined && (prior.status === "approved" || prior.status === "not-required") && prior.digest === digest;
|
|
1264
|
+
if (kept) {
|
|
1265
|
+
// The overrides this job applied under, and spent, still let the same plans through.
|
|
1266
|
+
const byRun = new Map(prior.members.map((m) => [m.member, m.override]));
|
|
1267
|
+
for (const m of members) if (m.denied && byRun.get(m.member)) m.override = byRun.get(m.member);
|
|
1268
|
+
if (prior.overridesSpentAt) decision.overridesSpentAt = prior.overridesSpentAt;
|
|
1269
|
+
}
|
|
1270
|
+
if (members.some((m) => m.denied && !m.override)) await settleDenials(ctx, decision, overriders);
|
|
1271
|
+
if (kept && prior.status === "approved" && decision.status !== "refused") {
|
|
1272
|
+
// #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
|
+
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];
|
|
1274
|
+
decision.status = "approved";
|
|
1275
|
+
log(`wave ${k} (${wave.name}): planned again to the same digest, so the approval by ${prior.approvedBy} still holds`);
|
|
1276
|
+
} else if (decision.status !== "refused" && (opWaveNeedsApproval(policy, members) || holds.length > 0)) await settleGate(options, ctx, decision);
|
|
1277
|
+
const code = await decided(ctx, decision, recordPath);
|
|
1278
|
+
if (code.exitCode !== 0) return code;
|
|
917
1279
|
log(
|
|
918
1280
|
`wave ${k} (${wave.name}): gate ${policy} (read at ${base.slice(0, 12)}${source === "absent-at-base" ? ", where the wave is absent" : ""}), ` +
|
|
919
1281
|
`digest ${digest}: ${decision.status}${decision.approvedBy ? ` by ${decision.approvedBy}` : ""}` +
|
|
920
1282
|
(decision.via === "pr-review" ? ` (review of #${decision.review?.pr})` : "") +
|
|
921
1283
|
(holds.length > 0 ? `; held by ${holds.join(", ")}` : ""),
|
|
922
1284
|
);
|
|
923
|
-
|
|
1285
|
+
// A wide wave's shares apply under the deciding job's approval and overrides, so they are spent here, once.
|
|
1286
|
+
if (options.decide) {
|
|
1287
|
+
if (!(await spendApproval(options, ctx, decision)) || !(await spendOverrides(ctx, decision))) return ctx.result;
|
|
1288
|
+
if (decision.spentAt || decision.overridesSpentAt) writeRecord(recordPath, decision, result);
|
|
1289
|
+
return result;
|
|
1290
|
+
}
|
|
1291
|
+
await applyStage(options, ctx, decision, wave.runs, planned, [], recordPath);
|
|
1292
|
+
return result;
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
/**
|
|
1296
|
+
* The gate has decided (#3821): write the decision record, run the
|
|
1297
|
+
* `after-gate` hooks with the decision in their environment, and say whether
|
|
1298
|
+
* the wave goes on. Returns the result with exit 3 when it waits, 7 when its
|
|
1299
|
+
* policy refused it, 1 when an `after-gate` hook failed on a wave that would
|
|
1300
|
+
* apply; exit 0 to go on. A hook that fails while the wave waits or is
|
|
1301
|
+
* refused is logged and leaves the exit code alone.
|
|
1302
|
+
*/
|
|
1303
|
+
async function decided(ctx: WaveContext, decision: OpWaveDecision, recordPath: string, heldOn?: OpWaveHeldOn): Promise<RunOpWaveResult> {
|
|
1304
|
+
const { k, wave, log, result } = ctx;
|
|
1305
|
+
if (heldOn) markHeldOn(decision, heldOn);
|
|
1306
|
+
mkdirSync(dirname(recordPath), { recursive: true });
|
|
1307
|
+
writeRecord(recordPath, decision, result);
|
|
1308
|
+
result.decision = decision;
|
|
1309
|
+
if (decision.approval === "pr-review" && decision.status === "waiting" && decision.review?.reason) {
|
|
924
1310
|
log(`The pull request's review does not approve wave ${k}: ${decision.review.reason}`);
|
|
925
1311
|
}
|
|
1312
|
+
const hook = await runGateHooks(ctx, decision, recordPath);
|
|
926
1313
|
if (decision.status === "waiting") {
|
|
927
|
-
log(`Nothing in wave ${k} or after it ran. Read the plans, then approve: ${decision.approve} (with --resume, the approval also starts this job again)`);
|
|
1314
|
+
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)`);
|
|
928
1315
|
return { ...result, exitCode: 3 };
|
|
929
1316
|
}
|
|
930
|
-
if (
|
|
931
|
-
|
|
1317
|
+
if (decision.status === "refused") {
|
|
1318
|
+
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
|
+
return { ...result, exitCode: 7 };
|
|
1320
|
+
}
|
|
1321
|
+
if (hook.failed) return failed(ctx, `wave ${k} (${wave.name}): ${hook.failed}, so nothing in it was applied`);
|
|
932
1322
|
return result;
|
|
933
1323
|
}
|
|
934
1324
|
|
|
1325
|
+
/** A hold on another gate that waits makes the wave wait on it (#3821). */
|
|
1326
|
+
function markHeldOn(decision: OpWaveDecision, heldOn: OpWaveHeldOn): void {
|
|
1327
|
+
decision.heldOn = heldOn;
|
|
1328
|
+
decision.status = "waiting";
|
|
1329
|
+
decision.approve = heldOn.approve;
|
|
1330
|
+
if (heldOn.waitingSince) decision.waitingSince = heldOn.waitingSince;
|
|
1331
|
+
if (heldOn.recorded !== undefined) decision.recorded = heldOn.recorded;
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
/** A share job held on another gate that waits (#3821). */
|
|
1335
|
+
async function waitOnOther(ctx: WaveContext, decision: OpWaveDecision, heldOn: OpWaveHeldOn): Promise<RunOpWaveResult> {
|
|
1336
|
+
markHeldOn(decision, heldOn);
|
|
1337
|
+
ctx.log(`share ${ctx.options.share} of wave ${ctx.k} (${ctx.wave.name}) waits on ${heldOn.op} / ${heldOn.gate}; nothing in it applied. Approve: ${heldOn.approve}`);
|
|
1338
|
+
return { ...ctx.result, decision, exitCode: 3 };
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
/**
|
|
1342
|
+
* Run the `after-gate` hooks (#3821) once the gate decided. Their
|
|
1343
|
+
* environment carries the decision: `CHANT_GATE_STATUS` (`approved`,
|
|
1344
|
+
* `not-required`, `waiting` or `refused`), `CHANT_GATE_OP`, `CHANT_GATE`,
|
|
1345
|
+
* `CHANT_GATE_DIGEST`, `CHANT_DECISION` (the decision record, with the
|
|
1346
|
+
* standing pending fact, the approval of another digest and the denials),
|
|
1347
|
+
* and when set `CHANT_GATE_APPROVE`, `CHANT_GATE_WAITING_SINCE` and
|
|
1348
|
+
* `CHANT_GATE_RECORDED` (`true` when this run recorded the pending fact).
|
|
1349
|
+
*/
|
|
1350
|
+
async function runGateHooks(ctx: WaveContext, decision: OpWaveDecision, recordPath: string): Promise<HooksOutcome> {
|
|
1351
|
+
const list = decision.hooks?.["after-gate"];
|
|
1352
|
+
if (!list?.length) return { holds: [] };
|
|
1353
|
+
const on = decision.heldOn;
|
|
1354
|
+
const env: Record<string, string> = {
|
|
1355
|
+
CHANT_GATE_STATUS: decision.status,
|
|
1356
|
+
CHANT_GATE_OP: on?.op ?? decision.op,
|
|
1357
|
+
CHANT_GATE: on?.gate ?? decision.gate,
|
|
1358
|
+
CHANT_GATE_DIGEST: on?.digest ?? decision.digest,
|
|
1359
|
+
CHANT_DECISION: relativeTo(ctx.cwd, recordPath),
|
|
1360
|
+
...(decision.approve ? { CHANT_GATE_APPROVE: decision.approve } : {}),
|
|
1361
|
+
...(decision.waitingSince ? { CHANT_GATE_WAITING_SINCE: decision.waitingSince } : {}),
|
|
1362
|
+
...(decision.recorded !== undefined ? { CHANT_GATE_RECORDED: String(decision.recorded) } : {}),
|
|
1363
|
+
};
|
|
1364
|
+
const planned = new Map(decision.members.map((m) => [m.member, m]));
|
|
1365
|
+
const out = await runHooks(ctx, "after-gate", list, ctx.wave.runs, planned.size > 0 ? planned : undefined, decision.digest || undefined, env);
|
|
1366
|
+
writeRecord(recordPath, decision, ctx.result);
|
|
1367
|
+
if (out.failed && decision.status !== "approved" && decision.status !== "not-required") ctx.log(`${out.failed}; the wave is ${decision.status} whatever the hook came to`);
|
|
1368
|
+
return out;
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
function relativeTo(cwd: string, path: string): string {
|
|
1372
|
+
return path.startsWith(cwd + "/") ? path.slice(cwd.length + 1) : path;
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
/**
|
|
1376
|
+
* Decide each run the product's policy denied (#3821) against the override
|
|
1377
|
+
* ledger, `_gates/policy-override.jsonl`, as `@intentius/chant/override-gate`
|
|
1378
|
+
* decides: an override counts when a listed overrider wrote it for this run,
|
|
1379
|
+
* its plan digest and its rules, gave a reason, and (under `sealed`) sealed
|
|
1380
|
+
* it. Overridden runs carry their override; with any run still denied the
|
|
1381
|
+
* decision is `refused`, and each such run gets a pending fact on the
|
|
1382
|
+
* override ledger unless one stands.
|
|
1383
|
+
*/
|
|
1384
|
+
async function settleDenials(ctx: WaveContext, decision: OpWaveDecision, listed: string[] | undefined): Promise<void> {
|
|
1385
|
+
const { spec, k, wave, cwd, log, options } = ctx;
|
|
1386
|
+
const og = await import("../override-gate");
|
|
1387
|
+
const port = options.gates ?? gitGateLedgerPort({ cwd });
|
|
1388
|
+
const path = spec.override?.overriders ?? og.OVERRIDERS_PATH;
|
|
1389
|
+
const overriders = listed ?? og.overridersAt(cwd, decision.base, path).principals;
|
|
1390
|
+
const sealed = decision.approval === "sealed";
|
|
1391
|
+
const signers = sealed ? (options.signers ?? (await import("../gate-decision")).gateSignersAt.bind(null, cwd))(decision.base) : undefined;
|
|
1392
|
+
const ledger = await port.read(og.OVERRIDE_OP);
|
|
1393
|
+
const applied = ((await port.readApplied?.(og.OVERRIDE_OP)) ?? []) as unknown as import("../override-gate").AppliedOverrideRecord[];
|
|
1394
|
+
const now = options.now ?? new Date().toISOString();
|
|
1395
|
+
const still: string[] = [];
|
|
1396
|
+
let moved = false;
|
|
1397
|
+
for (const m of decision.members) {
|
|
1398
|
+
if (!m.denied || m.override) continue;
|
|
1399
|
+
const verdict = og.decideOverride({
|
|
1400
|
+
ledger,
|
|
1401
|
+
applied,
|
|
1402
|
+
subject: m.member,
|
|
1403
|
+
planDigest: m.planDigest,
|
|
1404
|
+
rules: m.denied.rules,
|
|
1405
|
+
overriders,
|
|
1406
|
+
overridersPath: listed ? "the overriders the plans file names" : path,
|
|
1407
|
+
mode: sealed ? "sealed" : "ledger",
|
|
1408
|
+
...(signers ? { signers } : {}),
|
|
1409
|
+
now,
|
|
1410
|
+
});
|
|
1411
|
+
if (verdict.status === "overridden") {
|
|
1412
|
+
const o = verdict.override;
|
|
1413
|
+
m.override = { by: o.by, at: o.at, digest: o.digest, reason: o.reason };
|
|
1414
|
+
log(`${m.member}: the policy denial (${o.rules.join(", ")}) is overridden by ${o.by} at ${o.at}${o.sealed ? ", sealed" : ""}: ${o.reason}`);
|
|
1415
|
+
continue;
|
|
1416
|
+
}
|
|
1417
|
+
still.push(m.member);
|
|
1418
|
+
for (const why of verdict.refusals) log(`${m.member}: an override does not count: ${why}`);
|
|
1419
|
+
if (verdict.status === "moved") {
|
|
1420
|
+
moved = true;
|
|
1421
|
+
log(`${m.member}: ${verdict.by} overrode an earlier plan or other rules (${verdict.was ?? "no digest"}); this plan and its rules give ${verdict.digest}, so that override counts for nothing`);
|
|
1422
|
+
}
|
|
1423
|
+
if (!verdict.standing) {
|
|
1424
|
+
const { version: _v, kind: _k, ...fact } = og.overridePending({
|
|
1425
|
+
subject: m.member,
|
|
1426
|
+
planDigest: m.planDigest,
|
|
1427
|
+
rules: m.denied.rules,
|
|
1428
|
+
timestamp: now,
|
|
1429
|
+
expiresAt: new Date(new Date(now).getTime() + 48 * 3600 * 1000).toISOString(),
|
|
1430
|
+
description: m.denied.reason ?? `wave ${k} (${wave.name}) of ${spec.name}: ${m.member}: plan ${m.planDigest} denied by ${og.sortedRules(m.denied.rules).join(", ")}`,
|
|
1431
|
+
});
|
|
1432
|
+
try {
|
|
1433
|
+
await port.appendPending({ ...fact, members: [{ member: m.member, planDigest: m.planDigest }] } as typeof fact);
|
|
1434
|
+
} catch (err) {
|
|
1435
|
+
log(`${m.member}: the denial was not recorded for an override: ${err instanceof Error ? err.message : String(err)}`);
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
log(`${m.member}: ${overriders.length ? `${overriders.join(", ")} may` : "nobody listed may"} override it with: ${og.overrideCommand(m.member, m.denied.rules, sealed)}`);
|
|
1439
|
+
}
|
|
1440
|
+
if (still.length > 0) {
|
|
1441
|
+
decision.status = "refused";
|
|
1442
|
+
decision.refused = { reason: moved ? "override-moved" : "policy", runs: still.sort() };
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
/**
|
|
1447
|
+
* Record each override the wave is about to apply under (#3821), in
|
|
1448
|
+
* `_gates/policy-override/applied.jsonl`, which spends it: written before
|
|
1449
|
+
* anything applies, once. False when the lines could not be written: the
|
|
1450
|
+
* wave then fails and applies nothing.
|
|
1451
|
+
*/
|
|
1452
|
+
async function spendOverrides(ctx: WaveContext, decision: OpWaveDecision): Promise<boolean> {
|
|
1453
|
+
const used = decision.members.filter((m) => m.override);
|
|
1454
|
+
if (used.length === 0 || decision.overridesSpentAt) return true;
|
|
1455
|
+
const { k, wave, cwd, log, options } = ctx;
|
|
1456
|
+
const og = await import("../override-gate");
|
|
1457
|
+
const port = options.gates ?? gitGateLedgerPort({ cwd });
|
|
1458
|
+
const now = options.now ?? new Date().toISOString();
|
|
1459
|
+
try {
|
|
1460
|
+
if (!port.appendApplied) throw new Error("this gate ledger cannot record an applied override");
|
|
1461
|
+
for (const m of used) {
|
|
1462
|
+
const record = og.appliedOverride({ subject: m.member, digest: m.override!.digest, at: m.override!.at, by: m.override!.by }, { timestamp: now });
|
|
1463
|
+
await port.appendApplied(record as unknown as AppliedGateRecord);
|
|
1464
|
+
}
|
|
1465
|
+
} catch (err) {
|
|
1466
|
+
failed(ctx, `wave ${k} (${wave.name}) could not record the overrides it applies under, so nothing applied: ${err instanceof Error ? err.message : String(err)}`);
|
|
1467
|
+
return false;
|
|
1468
|
+
}
|
|
1469
|
+
decision.overridesSpentAt = now;
|
|
1470
|
+
log(`wave ${k} (${wave.name}) applies ${used.map((m) => m.member).join(", ")} under their overrides, which are now spent`);
|
|
1471
|
+
return true;
|
|
1472
|
+
}
|
|
1473
|
+
|
|
935
1474
|
function failed(ctx: WaveContext, line: string): RunOpWaveResult {
|
|
936
1475
|
ctx.log(line);
|
|
937
1476
|
ctx.result.exitCode = 1;
|
|
@@ -946,20 +1485,7 @@ function failed(ctx: WaveContext, line: string): RunOpWaveResult {
|
|
|
946
1485
|
async function settleGate(options: RunOpWaveOptions, ctx: WaveContext, decision: OpWaveDecision): Promise<void> {
|
|
947
1486
|
const { spec, k, wave, cwd } = ctx;
|
|
948
1487
|
const { gate, digest, approval, base, policy, members } = decision;
|
|
949
|
-
const ledger =
|
|
950
|
-
let port = ledger;
|
|
951
|
-
if (approval === "sealed") {
|
|
952
|
-
// #3684: only an approval sealed by a signer listed at base counts.
|
|
953
|
-
const review = await import("./gate-review");
|
|
954
|
-
const sealed = review.sealedApprovalRule(gate, (options.trust ?? ((b) => review.trustPolicyAt(cwd, b)))(base));
|
|
955
|
-
port = {
|
|
956
|
-
...ledger,
|
|
957
|
-
async approvalRule(g) {
|
|
958
|
-
const own = (await ledger.approvalRule?.(g)) ?? null;
|
|
959
|
-
return g === gate ? review.bothRules(own, sealed) : own;
|
|
960
|
-
},
|
|
961
|
-
};
|
|
962
|
-
}
|
|
1488
|
+
const { ledger, port } = await gatePort(options, cwd, gate, approval, base);
|
|
963
1489
|
const now = options.now ?? new Date().toISOString();
|
|
964
1490
|
// #3784: under the spend rule at base, an approval a wave already applied under counts for nothing.
|
|
965
1491
|
const spent: AppliedGateRecord[] | undefined = decision.spend === "base" ? ((await ledger.readApplied?.(spec.name)) ?? []).filter((a) => a.gate === gate) : undefined;
|
|
@@ -981,6 +1507,11 @@ async function settleGate(options: RunOpWaveOptions, ctx: WaveContext, decision:
|
|
|
981
1507
|
decision.approvedAt = check.resolution.timestamp;
|
|
982
1508
|
return;
|
|
983
1509
|
}
|
|
1510
|
+
// #3821: what the wait is, for the after-gate hooks and the record.
|
|
1511
|
+
decision.pendingFact = check.pending;
|
|
1512
|
+
decision.recorded = check.recorded;
|
|
1513
|
+
decision.waitingSince = check.pending.timestamp;
|
|
1514
|
+
if (check.mismatch) decision.mismatch = check.mismatch;
|
|
984
1515
|
const used = spent?.filter((a) => isApprovalSpent([a], { gate, planDigest: digest, timestamp: a.approvedAt })).pop();
|
|
985
1516
|
if (used) {
|
|
986
1517
|
ctx.log(`wave ${k} (${wave.name}): the approval of ${digest} by ${used.approvedBy} at ${used.approvedAt} is spent: a wave applied under it at ${used.timestamp}. Applying it again needs a new approval`);
|
|
@@ -989,6 +1520,7 @@ async function settleGate(options: RunOpWaveOptions, ctx: WaveContext, decision:
|
|
|
989
1520
|
const byReview = await approveByReview(options, { op: spec.name, gate, k, digest, cwd, now, port: ledger, ...(spent ? { spent } : {}) });
|
|
990
1521
|
decision.review = byReview.review;
|
|
991
1522
|
if (byReview.approved) {
|
|
1523
|
+
for (const key of ["pendingFact", "recorded", "waitingSince", "mismatch"] as const) delete decision[key];
|
|
992
1524
|
decision.status = "approved";
|
|
993
1525
|
decision.via = "pr-review";
|
|
994
1526
|
decision.approvedBy = byReview.review.approvers!.join(", ");
|
|
@@ -1026,6 +1558,8 @@ async function settleHolds(
|
|
|
1026
1558
|
await settleGate(options, ctx, decision);
|
|
1027
1559
|
if (recordPath) writeRecord(recordPath, decision, result);
|
|
1028
1560
|
if (decision.status === "waiting") {
|
|
1561
|
+
// #3821: the gate decided again, and the wave now waits: the after-gate hooks hear so.
|
|
1562
|
+
if (recordPath) await runGateHooks(ctx, decision, recordPath);
|
|
1029
1563
|
log(`wave ${k} (${wave.name}) is held by ${holds.join(", ")}, and nobody approved ${decision.digest}; nothing applied. Approve: ${decision.approve}`);
|
|
1030
1564
|
result.exitCode = 3;
|
|
1031
1565
|
return false;
|
|
@@ -1034,8 +1568,23 @@ async function settleHolds(
|
|
|
1034
1568
|
return true;
|
|
1035
1569
|
}
|
|
1036
1570
|
|
|
1037
|
-
/** How one apply call came out. `applied` with a `problem`: the runs applied, and the wave still fails. */
|
|
1038
|
-
type ApplyCall = { outcome: "applied"; problem?: string } | { outcome: "failed" | "waiting" };
|
|
1571
|
+
/** How one apply call came out. `applied` with a `problem`: the runs applied, and the wave still fails. `applied-nothing`: the apply said why (#3821). */
|
|
1572
|
+
type ApplyCall = { outcome: "applied"; problem?: string } | { outcome: "failed" | "waiting" } | { outcome: "applied-nothing"; said: OpWaveApplyOutcome };
|
|
1573
|
+
|
|
1574
|
+
/** Read what an apply wrote to `CHANT_APPLY_OUTCOME` (#3821), or undefined when it wrote nothing. A file that says something else is logged and ignored. */
|
|
1575
|
+
function readApplyOutcome(path: string, label: string, log: (line: string) => void): OpWaveApplyOutcome | undefined {
|
|
1576
|
+
if (!existsSync(path)) return undefined;
|
|
1577
|
+
try {
|
|
1578
|
+
const o = JSON.parse(readFileSync(path, "utf-8")) as Partial<OpWaveApplyOutcome>;
|
|
1579
|
+
if (!OP_WAVE_APPLY_OUTCOMES.includes(o.outcome as "held")) throw new Error(`outcome ${JSON.stringify(o.outcome)} is none of ${OP_WAVE_APPLY_OUTCOMES.join(", ")}`);
|
|
1580
|
+
if (o.reason !== undefined && typeof o.reason !== "string") throw new Error("its reason is not text");
|
|
1581
|
+
const holder = o.holder && typeof o.holder === "object" && typeof o.holder.run === "string" ? { run: o.holder.run, ...(typeof o.holder.url === "string" ? { url: o.holder.url } : {}) } : undefined;
|
|
1582
|
+
return { outcome: o.outcome as OpWaveApplyOutcome["outcome"], ...(o.reason ? { reason: o.reason } : {}), ...(holder ? { holder } : {}) };
|
|
1583
|
+
} catch (err) {
|
|
1584
|
+
log(`${label}: the apply wrote an outcome that cannot be read (${err instanceof Error ? err.message : String(err)}), so its exit code decides`);
|
|
1585
|
+
return undefined;
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1039
1588
|
|
|
1040
1589
|
/**
|
|
1041
1590
|
* Call one apply command (#3804) with `CHANT_HOOKS` and `CHANT_HOOKS_RESULT`.
|
|
@@ -1059,6 +1608,7 @@ async function callApply(
|
|
|
1059
1608
|
const dir = `.chant/op-waves/${spec.name}/wave-${k}`;
|
|
1060
1609
|
const filePath = `${dir}/${call.key}.json`;
|
|
1061
1610
|
const resultPath = `${dir}/${call.key}-result.json`;
|
|
1611
|
+
const outcomePath = `${dir}/${call.key}-outcome.json`;
|
|
1062
1612
|
const handed: Partial<Record<OpWaveHookMoment, OpWaveHook[]>> = {};
|
|
1063
1613
|
if (byApply) {
|
|
1064
1614
|
for (const m of ["before-apply", "after-apply"] as const) if (decision.hooks?.[m]?.length) handed[m] = decision.hooks[m];
|
|
@@ -1085,13 +1635,28 @@ async function callApply(
|
|
|
1085
1635
|
mkdirSync(join(cwd, dir), { recursive: true });
|
|
1086
1636
|
writeFileSync(join(cwd, filePath), JSON.stringify(file, null, 2) + "\n");
|
|
1087
1637
|
rmSync(join(cwd, resultPath), { force: true });
|
|
1638
|
+
rmSync(join(cwd, outcomePath), { force: true });
|
|
1088
1639
|
let code: number;
|
|
1089
1640
|
try {
|
|
1090
|
-
code = exec(argv, cwd, { CHANT_HOOKS: filePath, ...(byApply ? { CHANT_HOOKS_RESULT: resultPath } : {}) });
|
|
1641
|
+
code = exec(argv, cwd, { CHANT_HOOKS: filePath, CHANT_APPLY_OUTCOME: outcomePath, ...(byApply ? { CHANT_HOOKS_RESULT: resultPath } : {}) });
|
|
1091
1642
|
} catch (err) {
|
|
1092
1643
|
log(`${call.label}: the apply did not run: ${err instanceof Error ? err.message : String(err)}`);
|
|
1093
1644
|
code = 127;
|
|
1094
1645
|
}
|
|
1646
|
+
// #3821: an apply that applied nothing, and says why, is not a failure.
|
|
1647
|
+
const said = readApplyOutcome(join(cwd, outcomePath), call.label, log);
|
|
1648
|
+
if (said) {
|
|
1649
|
+
if (byApply) {
|
|
1650
|
+
try {
|
|
1651
|
+
const back = JSON.parse(readFileSync(join(cwd, resultPath), "utf-8")) as OpWaveHooksResult;
|
|
1652
|
+
if (Array.isArray(back?.outcomes)) result.hooks.push(...back.outcomes);
|
|
1653
|
+
} catch {
|
|
1654
|
+
// No hooks ran.
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
log(`${call.label}: the apply applied nothing: ${said.outcome}${said.reason ? ` (${said.reason})` : ""}${said.holder ? `, held by run ${said.holder.run}` : ""}`);
|
|
1658
|
+
return { outcome: "applied-nothing", said };
|
|
1659
|
+
}
|
|
1095
1660
|
if (!byApply) return { outcome: code === 0 ? "applied" : "failed" };
|
|
1096
1661
|
let back: OpWaveHooksResult | null = null;
|
|
1097
1662
|
try {
|
|
@@ -1147,23 +1712,36 @@ async function applyStage(
|
|
|
1147
1712
|
const byApply = decision.applyHooksRunBy === "apply";
|
|
1148
1713
|
const holds = [...earlierHolds];
|
|
1149
1714
|
if (!byApply) {
|
|
1150
|
-
const before = runHooks(ctx, "before-apply", decision.hooks?.["before-apply"], runs, planned, decision.digest);
|
|
1715
|
+
const before = await runHooks(ctx, "before-apply", decision.hooks?.["before-apply"], runs, planned, decision.digest);
|
|
1151
1716
|
if (before.failed) {
|
|
1152
1717
|
failed(ctx, `a hook before the apply failed, so nothing in wave ${k} was applied`);
|
|
1153
1718
|
return;
|
|
1154
1719
|
}
|
|
1720
|
+
if (before.waitingOn) {
|
|
1721
|
+
markHeldOn(decision, before.waitingOn);
|
|
1722
|
+
if (recordPath) {
|
|
1723
|
+
writeRecord(recordPath, decision, result);
|
|
1724
|
+
await runGateHooks(ctx, decision, recordPath);
|
|
1725
|
+
}
|
|
1726
|
+
result.decision = decision;
|
|
1727
|
+
result.exitCode = 3;
|
|
1728
|
+
return;
|
|
1729
|
+
}
|
|
1155
1730
|
holds.push(...before.holds);
|
|
1156
1731
|
}
|
|
1157
1732
|
if (!(await settleHolds(options, ctx, decision, holds, recordPath))) return;
|
|
1158
1733
|
if (!(await spendApproval(options, ctx, decision))) return;
|
|
1159
|
-
if (
|
|
1734
|
+
if (!(await spendOverrides(ctx, decision))) return;
|
|
1735
|
+
if (recordPath && (decision.spentAt || decision.overridesSpentAt)) writeRecord(recordPath, decision, result);
|
|
1160
1736
|
const problems: string[] = [];
|
|
1161
1737
|
let waiting = false;
|
|
1738
|
+
let nothing: OpWaveApplyOutcome | undefined;
|
|
1162
1739
|
if (spec.applyWave) {
|
|
1163
1740
|
const runsPath = writeRunsFile(ctx, runs, planned, decision.digest);
|
|
1164
1741
|
const argv = opWaveArgv(spec.applyWave, { op: spec.op, wave: wave.name, runs: runsPath, plans: opWavePlansPath(spec, k) });
|
|
1165
1742
|
const got = await callApply(options, ctx, decision, runs, planned, argv, { label: `wave ${k} (${wave.name})`, key: "hooks", runsPath }, recordPath);
|
|
1166
|
-
if (got.outcome === "applied")
|
|
1743
|
+
if (got.outcome === "applied-nothing") nothing = got.said;
|
|
1744
|
+
else if (got.outcome === "applied") {
|
|
1167
1745
|
result.applied.push(...runs.map((r) => r.target));
|
|
1168
1746
|
if (got.problem) problems.push(got.problem);
|
|
1169
1747
|
} else if (got.outcome === "waiting") waiting = true;
|
|
@@ -1178,6 +1756,10 @@ async function applyStage(
|
|
|
1178
1756
|
const runsPath = byApply ? writeRunsFile(ctx, [run], planned, decision.digest, `.chant/op-waves/${spec.name}/wave-${k}/${key}.runs.json`) : opWaveRunsPath(spec, k);
|
|
1179
1757
|
const argv = opWaveArgv(spec.apply ?? OP_WAVE_DEFAULT_APPLY, { op: run.op ?? spec.op, target: run.target, wave: wave.name, plan: opWavePlanPath(spec, k, run.target) });
|
|
1180
1758
|
const got = await callApply(options, ctx, decision, [run], planned, argv, { label: run.target, key: `${key}.hooks`, runsPath }, recordPath);
|
|
1759
|
+
if (got.outcome === "applied-nothing") {
|
|
1760
|
+
nothing = got.said;
|
|
1761
|
+
break;
|
|
1762
|
+
}
|
|
1181
1763
|
if (got.outcome === "waiting") {
|
|
1182
1764
|
waiting = true;
|
|
1183
1765
|
break;
|
|
@@ -1192,6 +1774,17 @@ async function applyStage(
|
|
|
1192
1774
|
}
|
|
1193
1775
|
}
|
|
1194
1776
|
if (recordPath && byApply) writeRecord(recordPath, decision, result);
|
|
1777
|
+
if (nothing) {
|
|
1778
|
+
// #3821: no run counts as failed; the wave stops here, or plans again.
|
|
1779
|
+
if (nothing.outcome === "replan") {
|
|
1780
|
+
result.replan = { ...(nothing.reason ? { reason: nothing.reason } : {}) };
|
|
1781
|
+
return;
|
|
1782
|
+
}
|
|
1783
|
+
result.outcome = nothing;
|
|
1784
|
+
result.exitCode = nothing.outcome === "stood-down" ? 6 : 5;
|
|
1785
|
+
if (recordPath) writeRecord(recordPath, decision, result);
|
|
1786
|
+
return;
|
|
1787
|
+
}
|
|
1195
1788
|
if (waiting) {
|
|
1196
1789
|
result.exitCode = 3;
|
|
1197
1790
|
return;
|
|
@@ -1206,7 +1799,7 @@ async function applyStage(
|
|
|
1206
1799
|
return;
|
|
1207
1800
|
}
|
|
1208
1801
|
if (!byApply) {
|
|
1209
|
-
const after = runHooks(ctx, "after-apply", decision.hooks?.["after-apply"], runs, planned, decision.digest);
|
|
1802
|
+
const after = await runHooks(ctx, "after-apply", decision.hooks?.["after-apply"], runs, planned, decision.digest);
|
|
1210
1803
|
if (after.failed) failed(ctx, `wave ${k} (${wave.name}) applied, and then a hook after the apply failed`);
|
|
1211
1804
|
}
|
|
1212
1805
|
if (recordPath && result.hooks.length > 0) writeRecord(recordPath, decision, result);
|
|
@@ -1343,11 +1936,16 @@ export async function recordOpWaveHeadPlans(options: RecordHeadPlansOptions): Pr
|
|
|
1343
1936
|
name: spec.name,
|
|
1344
1937
|
head,
|
|
1345
1938
|
timestamp: options.now ?? new Date().toISOString(),
|
|
1346
|
-
waves:
|
|
1347
|
-
const { members } = planRuns({ spec, k: i + 1, wave, cwd, exec }, wave.runs);
|
|
1348
|
-
return { wave: i + 1, name: wave.name, digest: opWaveSetDigest(spec, members) };
|
|
1349
|
-
}),
|
|
1939
|
+
waves: [],
|
|
1350
1940
|
};
|
|
1941
|
+
// A `rest` wave (#3821) goes on through the waves the plan command found.
|
|
1942
|
+
const last = spec.waves[spec.waves.length - 1]!;
|
|
1943
|
+
for (let k = 1; ; k++) {
|
|
1944
|
+
const wave = spec.waves[k - 1] ?? last;
|
|
1945
|
+
const { members, extras, waves: found } = planRuns({ spec, k, wave, cwd, exec }, wave.runs, wave.discover === true);
|
|
1946
|
+
plans.waves.push({ wave: k, name: wave.name, digest: opWaveSetDigest(spec, members, extras) });
|
|
1947
|
+
if (k >= spec.waves.length && !(last.rest && found !== undefined && k < found)) break;
|
|
1948
|
+
}
|
|
1351
1949
|
await (options.write ?? ((p) => writeHeadPlans(p, cwd)))(plans);
|
|
1352
1950
|
return plans;
|
|
1353
1951
|
}
|