@intentius/chant 0.124.3 → 0.124.4
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 +2 -2
- package/dist/op/index.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 +86 -4
- package/dist/op/op-waves-run.d.ts.map +1 -1
- package/dist/op/op-waves.d.ts +33 -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 +3 -1
- package/src/op/op-waves-notify.ts +19 -2
- package/src/op/op-waves-run.ts +462 -62
- package/src/op/op-waves.test.ts +133 -0
- package/src/op/op-waves.ts +50 -6
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,12 @@ 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[];
|
|
92
106
|
}
|
|
93
107
|
|
|
94
108
|
/** Pending-fact keys chant writes itself, which a plans file's `pending` may not set (#3821). */
|
|
@@ -114,6 +128,10 @@ export interface OpWaveMember extends WaveMember {
|
|
|
114
128
|
op: string;
|
|
115
129
|
destructive?: boolean;
|
|
116
130
|
empty?: boolean;
|
|
131
|
+
/** The product's policy denied the plan (#3821). */
|
|
132
|
+
denied?: { rules: string[]; reason?: string };
|
|
133
|
+
/** The override that lets the denied plan through (#3821). */
|
|
134
|
+
override?: { by: string; at: string; digest: string; reason: string };
|
|
117
135
|
}
|
|
118
136
|
|
|
119
137
|
/** Where a wave's policy came from. */
|
|
@@ -134,8 +152,12 @@ export interface OpWaveDecision {
|
|
|
134
152
|
base: string;
|
|
135
153
|
digest: string;
|
|
136
154
|
members: OpWaveMember[];
|
|
137
|
-
/**
|
|
138
|
-
|
|
155
|
+
/**
|
|
156
|
+
* `approved`: an approval of this digest stands. `not-required`: the policy
|
|
157
|
+
* lets it through. `waiting`: neither. `refused`: the product's policy
|
|
158
|
+
* denied a run and no override counts (#3821, exit 7).
|
|
159
|
+
*/
|
|
160
|
+
status: "approved" | "not-required" | "waiting" | "refused";
|
|
139
161
|
approvedBy?: string;
|
|
140
162
|
/** How the approval arrived: a resolution on the ledger, or the merged pull request's review (#3684). */
|
|
141
163
|
via?: "ledger" | "pr-review";
|
|
@@ -168,6 +190,52 @@ export interface OpWaveDecision {
|
|
|
168
190
|
pending?: Record<string, unknown>;
|
|
169
191
|
/** The pending fact carries `neverOverMcp`, set by the spec here or at base (#3821). */
|
|
170
192
|
neverOverMcp?: true;
|
|
193
|
+
/** While waiting (#3821): the pending fact that stands for the digest, this run's own when `recorded`. */
|
|
194
|
+
pendingFact?: PendingGateRecord;
|
|
195
|
+
/** While waiting: whether this run recorded the pending fact, rather than finding one standing. */
|
|
196
|
+
recorded?: boolean;
|
|
197
|
+
/** While waiting: when the wait began, the standing pending fact's time. */
|
|
198
|
+
waitingSince?: string;
|
|
199
|
+
/** While waiting: the newest approval of this gate for another digest, which the plans moved away from. */
|
|
200
|
+
mismatch?: GateDigestMismatch;
|
|
201
|
+
/** Why a `refused` wave applies nothing (#3821): the runs the policy denied, and whether an override stands for an earlier plan or other rules. */
|
|
202
|
+
refused?: { reason: "policy" | "override-moved"; runs: string[] };
|
|
203
|
+
/**
|
|
204
|
+
* A hook held the wave on another gate (#3821), such as a state migration
|
|
205
|
+
* behind its own: that gate, its digest, and how it came out. While it
|
|
206
|
+
* waits, the wave waits (exit 3) and `approve` approves it.
|
|
207
|
+
*/
|
|
208
|
+
heldOn?: OpWaveHeldOn;
|
|
209
|
+
/** When the wave recorded the overrides it applies under (#3821), which spent them. */
|
|
210
|
+
overridesSpentAt?: string;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** What a hook asks for when it holds a wave on another gate (#3821): it writes this to `CHANT_HOLD` and exits non-zero. */
|
|
214
|
+
export interface OpWaveHoldRequest {
|
|
215
|
+
/** The op the gate is recorded under. Default: the spec's name. */
|
|
216
|
+
op?: string;
|
|
217
|
+
gate: string;
|
|
218
|
+
/** The digest an approval binds. */
|
|
219
|
+
digest: string;
|
|
220
|
+
description?: string;
|
|
221
|
+
/** Fields for the pending fact, as a plans file's `pending`. */
|
|
222
|
+
pending?: Record<string, unknown>;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** A hold on another gate, as the decision records it (#3821). */
|
|
226
|
+
export interface OpWaveHeldOn {
|
|
227
|
+
op: string;
|
|
228
|
+
gate: string;
|
|
229
|
+
digest: string;
|
|
230
|
+
/** The hook that asked. */
|
|
231
|
+
hook: string;
|
|
232
|
+
status: "approved" | "waiting";
|
|
233
|
+
approvedBy?: string;
|
|
234
|
+
approvedAt?: string;
|
|
235
|
+
approve?: string;
|
|
236
|
+
waitingSince?: string;
|
|
237
|
+
recorded?: boolean;
|
|
238
|
+
mismatch?: GateDigestMismatch;
|
|
171
239
|
}
|
|
172
240
|
|
|
173
241
|
/** One hook's run (#3762). */
|
|
@@ -432,10 +500,12 @@ export interface RunOpWaveOptions {
|
|
|
432
500
|
headPlans?: (name: string, head: string) => Promise<HeadPlans | null>;
|
|
433
501
|
/** The signers policy at base, for a `sealed` gate. Default: read from the base commit. */
|
|
434
502
|
trust?: (base: string) => TrustPolicy;
|
|
503
|
+
/** The signers at base, for a sealed override of a policy denial (#3821). Default: `gateSignersAt` from `@intentius/chant/gate-decision`. */
|
|
504
|
+
signers?: (base: string) => GateSigners;
|
|
435
505
|
}
|
|
436
506
|
|
|
437
507
|
export interface RunOpWaveResult {
|
|
438
|
-
/** 0 applied (or decided), 1 a run or a hook failed, 3 waiting for an approval, 4 a plan moved since the decision. */
|
|
508
|
+
/** 0 applied (or decided), 1 a run or a hook failed, 3 waiting for an approval, 4 a plan moved since the decision, 7 the product's policy denied a run and no override counts (#3821). */
|
|
439
509
|
exitCode: number;
|
|
440
510
|
decision?: OpWaveDecision;
|
|
441
511
|
applied: string[];
|
|
@@ -455,6 +525,9 @@ interface WaveContext {
|
|
|
455
525
|
exec: OpWaveExec;
|
|
456
526
|
log: (line: string) => void;
|
|
457
527
|
result: RunOpWaveResult;
|
|
528
|
+
options: RunOpWaveOptions;
|
|
529
|
+
/** 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. */
|
|
530
|
+
gateRule: { approval: GateApprovalSource; base: string; neverOverMcp: boolean };
|
|
458
531
|
}
|
|
459
532
|
|
|
460
533
|
function planRun(opts: { spec: OpWavesSpec; wave: number; cwd: string; exec: OpWaveExec }, waveName: string, run: OpWaveRun): OpWaveMember {
|
|
@@ -476,12 +549,20 @@ function planRun(opts: { spec: OpWavesSpec; wave: number; cwd: string; exec: OpW
|
|
|
476
549
|
}
|
|
477
550
|
|
|
478
551
|
function member(target: string, op: string, file: OpWavePlanFile): OpWaveMember {
|
|
552
|
+
const denied = file.denied as unknown;
|
|
553
|
+
if (denied !== undefined) {
|
|
554
|
+
const d = denied as { rules?: unknown; reason?: unknown } | null;
|
|
555
|
+
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")) {
|
|
556
|
+
throw new Error(`the plan of ${target} has denied ${JSON.stringify(denied)}; a denial is { "rules": ["<rule id>", ...], "reason"?: "<text>" }`);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
479
559
|
return {
|
|
480
560
|
member: target,
|
|
481
561
|
planDigest: file.planDigest,
|
|
482
562
|
op,
|
|
483
563
|
...(file.destructive === true ? { destructive: true } : {}),
|
|
484
564
|
...(file.empty === true ? { empty: true } : {}),
|
|
565
|
+
...(file.denied ? { denied: { rules: file.denied.rules, ...(file.denied.reason ? { reason: file.denied.reason } : {}) } } : {}),
|
|
485
566
|
};
|
|
486
567
|
}
|
|
487
568
|
|
|
@@ -549,16 +630,22 @@ function planRuns(at: { spec: OpWavesSpec; k: number; wave: OpWave; cwd: string;
|
|
|
549
630
|
const members = runs.map((run) => {
|
|
550
631
|
const m = member(run.target, run.op ?? spec.op, byTarget.get(run.target)!);
|
|
551
632
|
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");
|
|
633
|
+
writeFileSync(path, JSON.stringify({ planDigest: m.planDigest, ...(m.destructive ? { destructive: true } : {}), ...(m.empty ? { empty: true } : {}), ...(m.denied ? { denied: m.denied } : {}) }) + "\n");
|
|
553
634
|
return m;
|
|
554
635
|
});
|
|
555
|
-
|
|
636
|
+
const overriders = Array.isArray(parsed) ? undefined : (parsed as { overriders?: unknown }).overriders;
|
|
637
|
+
if (overriders !== undefined && (!Array.isArray(overriders) || !overriders.every((o) => typeof o === "string"))) {
|
|
638
|
+
throw new Error(`the plans file of wave ${k} (${plans}): \`overriders\` must be a list of principals`);
|
|
639
|
+
}
|
|
640
|
+
return { members, ...(pending ? { pending } : {}), ...(overriders ? { overriders: overriders as string[] } : {}) };
|
|
556
641
|
}
|
|
557
642
|
|
|
558
643
|
/** What planning a wave's runs gave: each run's plan, and the product's pending-fact fields (#3821). */
|
|
559
644
|
interface PlannedRuns {
|
|
560
645
|
members: OpWaveMember[];
|
|
561
646
|
pending?: Record<string, unknown>;
|
|
647
|
+
/** The plans file's overriders (#3821). */
|
|
648
|
+
overriders?: string[];
|
|
562
649
|
}
|
|
563
650
|
|
|
564
651
|
/** A plans file's `pending` (#3821): an object whose keys chant does not write itself, `description` aside. Undefined when absent or empty. */
|
|
@@ -587,6 +674,32 @@ interface HooksOutcome {
|
|
|
587
674
|
failed?: string;
|
|
588
675
|
/** The `onFailure: approve` hooks that failed. */
|
|
589
676
|
holds: string[];
|
|
677
|
+
/** A hook held the wave on another gate that nobody approved yet (#3821): the wave waits. */
|
|
678
|
+
waitingOn?: OpWaveHeldOn;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/** A hook call that held the wave on another gate (#3821), kept so it can run again once that gate is approved. */
|
|
682
|
+
interface OtherGateHold {
|
|
683
|
+
label: string;
|
|
684
|
+
name: string;
|
|
685
|
+
target?: string;
|
|
686
|
+
argv: string[];
|
|
687
|
+
env: Record<string, string>;
|
|
688
|
+
request: OpWaveHoldRequest;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/** Read the hold file a hook wrote (#3821), or undefined when it wrote none or one that names no gate and digest. */
|
|
692
|
+
function readHoldRequest(path: string, log: (line: string) => void, label: string): OpWaveHoldRequest | undefined {
|
|
693
|
+
if (!existsSync(path)) return undefined;
|
|
694
|
+
try {
|
|
695
|
+
const r = JSON.parse(readFileSync(path, "utf-8")) as Partial<OpWaveHoldRequest>;
|
|
696
|
+
if (typeof r.gate !== "string" || r.gate === "" || typeof r.digest !== "string" || r.digest === "") throw new Error("it names no gate and digest");
|
|
697
|
+
if (r.op !== undefined && (typeof r.op !== "string" || r.op === "")) throw new Error("its op is not a name");
|
|
698
|
+
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`) ?? {} } : {}) };
|
|
699
|
+
} catch (err) {
|
|
700
|
+
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`);
|
|
701
|
+
return undefined;
|
|
702
|
+
}
|
|
590
703
|
}
|
|
591
704
|
|
|
592
705
|
/** Where hooks run: the wave they belong to and the job's tools. */
|
|
@@ -612,11 +725,14 @@ function runHookList(
|
|
|
612
725
|
hooks: readonly OpWaveHook[] | undefined,
|
|
613
726
|
runs: readonly OpWaveHookRun[],
|
|
614
727
|
runsPath: string,
|
|
615
|
-
|
|
616
|
-
|
|
728
|
+
extraEnv: Record<string, string> = {},
|
|
729
|
+
): HooksOutcome & { outcomes: OpWaveHookOutcome[]; otherGates: OtherGateHold[] } {
|
|
730
|
+
const out: HooksOutcome & { outcomes: OpWaveHookOutcome[]; otherGates: OtherGateHold[] } = { holds: [], outcomes: [], otherGates: [] };
|
|
617
731
|
if (!hooks || hooks.length === 0) return out;
|
|
618
732
|
const { name: specName, op, waveName, k, cwd, exec, log } = at;
|
|
619
|
-
|
|
733
|
+
// #3821: a hook that holds the wave on another gate names it in this file.
|
|
734
|
+
const holdPath = `.chant/op-waves/${specName}/wave-${k}/hold.json`;
|
|
735
|
+
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
736
|
for (const hook of hooks) {
|
|
621
737
|
const name = hook.name ?? (hook.command.join(" ").length > 60 ? `${hook.command.join(" ").slice(0, 57)}...` : hook.command.join(" "));
|
|
622
738
|
const calls = hook.perRun
|
|
@@ -629,6 +745,7 @@ function runHookList(
|
|
|
629
745
|
for (const call of calls) {
|
|
630
746
|
const label = `${moment} hook ${name}${call.target ? ` (${call.target})` : ""}`;
|
|
631
747
|
let code: number;
|
|
748
|
+
rmSync(join(cwd, holdPath), { force: true });
|
|
632
749
|
try {
|
|
633
750
|
code = exec(call.argv, cwd, call.env);
|
|
634
751
|
} catch (err) {
|
|
@@ -642,6 +759,12 @@ function runHookList(
|
|
|
642
759
|
}
|
|
643
760
|
if (hook.onFailure === "approve") {
|
|
644
761
|
out.outcomes.push({ ...base, outcome: "held" });
|
|
762
|
+
const request = readHoldRequest(join(cwd, holdPath), log, label);
|
|
763
|
+
if (request) {
|
|
764
|
+
out.otherGates.push({ label, name, ...(call.target ? { target: call.target } : {}), argv: call.argv, env: call.env, request });
|
|
765
|
+
log(`${label} exited ${code} and holds wave ${k} (${waveName}) on gate ${request.op ?? specName} / ${request.gate}, digest ${request.digest}`);
|
|
766
|
+
continue;
|
|
767
|
+
}
|
|
645
768
|
out.holds.push(label);
|
|
646
769
|
log(`${label} exited ${code}, so wave ${k} (${waveName}) waits for an approval of its set digest`);
|
|
647
770
|
continue;
|
|
@@ -660,20 +783,105 @@ const placeOf = (ctx: WaveContext): HookPlace => ({ name: ctx.spec.name, op: ctx
|
|
|
660
783
|
const hookRuns = (spec: OpWavesSpec, k: number, runs: readonly OpWaveRun[], members?: ReadonlyMap<string, OpWaveMember>): OpWaveHookRun[] =>
|
|
661
784
|
runs.map((run) => ({ target: run.target, op: run.op ?? spec.op, ...(members?.has(run.target) ? { plan: opWavePlanPath(spec, k, run.target) } : {}) }));
|
|
662
785
|
|
|
663
|
-
/**
|
|
664
|
-
|
|
786
|
+
/**
|
|
787
|
+
* Run one moment's hooks in the wave job (#3762), recording each outcome on
|
|
788
|
+
* the result. A hook that held the wave on another gate (#3821) has that
|
|
789
|
+
* gate settled here: approved, the hook runs again with
|
|
790
|
+
* `CHANT_HOLD_APPROVED` and must pass; otherwise the wave waits on it.
|
|
791
|
+
*/
|
|
792
|
+
async function runHooks(
|
|
665
793
|
ctx: WaveContext,
|
|
666
794
|
moment: OpWaveHookMoment,
|
|
667
795
|
hooks: readonly OpWaveHook[] | undefined,
|
|
668
796
|
runs: readonly OpWaveRun[],
|
|
669
797
|
members?: ReadonlyMap<string, OpWaveMember>,
|
|
670
798
|
digest?: string,
|
|
671
|
-
|
|
799
|
+
extraEnv: Record<string, string> = {},
|
|
800
|
+
): Promise<HooksOutcome> {
|
|
672
801
|
if (!hooks || hooks.length === 0) return { holds: [] };
|
|
673
802
|
const runsPath = writeRunsFile(ctx, runs, members, digest);
|
|
674
|
-
const out = runHookList(placeOf(ctx), moment, hooks, hookRuns(ctx.spec, ctx.k, runs, members), runsPath);
|
|
803
|
+
const out = runHookList(placeOf(ctx), moment, hooks, hookRuns(ctx.spec, ctx.k, runs, members), runsPath, extraEnv);
|
|
675
804
|
ctx.result.hooks.push(...out.outcomes);
|
|
676
|
-
|
|
805
|
+
const back: HooksOutcome = { holds: out.holds, ...(out.failed ? { failed: out.failed } : {}) };
|
|
806
|
+
if (out.failed) return back;
|
|
807
|
+
for (const hold of out.otherGates) {
|
|
808
|
+
const held = await settleOtherGate(ctx, hold);
|
|
809
|
+
if (held.status === "waiting") {
|
|
810
|
+
back.waitingOn ??= held;
|
|
811
|
+
continue;
|
|
812
|
+
}
|
|
813
|
+
const approvedEnv = { ...hold.env, CHANT_HOLD_APPROVED: JSON.stringify({ op: held.op, gate: held.gate, digest: held.digest, approvedBy: held.approvedBy, approvedAt: held.approvedAt }) };
|
|
814
|
+
let code: number;
|
|
815
|
+
try {
|
|
816
|
+
code = ctx.exec(hold.argv, ctx.cwd, approvedEnv);
|
|
817
|
+
} catch (err) {
|
|
818
|
+
ctx.log(`${hold.label} did not run: ${err instanceof Error ? err.message : String(err)}`);
|
|
819
|
+
code = 127;
|
|
820
|
+
}
|
|
821
|
+
ctx.result.hooks.push({ moment, name: hold.name, ...(hold.target ? { target: hold.target } : {}), exit: code, outcome: code === 0 ? "passed" : "failed" });
|
|
822
|
+
if (code !== 0) {
|
|
823
|
+
back.failed = `${hold.label} exited ${code} after ${held.approvedBy} approved ${held.gate}`;
|
|
824
|
+
ctx.log(back.failed);
|
|
825
|
+
return back;
|
|
826
|
+
}
|
|
827
|
+
ctx.log(`${hold.label}: ${held.approvedBy} approved ${held.op} / ${held.gate} at ${held.digest}, and the hook ran again and passed`);
|
|
828
|
+
}
|
|
829
|
+
return back;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/** The gate ledger a wave decides by: the job's port, with the signers at base as the rule when `approval` is `sealed`. */
|
|
833
|
+
async function gatePort(options: RunOpWaveOptions, cwd: string, gate: string, approval: GateApprovalSource, base: string): Promise<{ ledger: GateLedgerPort; port: GateLedgerPort }> {
|
|
834
|
+
const ledger = options.gates ?? gitGateLedgerPort({ cwd });
|
|
835
|
+
if (approval !== "sealed") return { ledger, port: ledger };
|
|
836
|
+
// #3684: only an approval sealed by a signer listed at base counts.
|
|
837
|
+
const review = await import("./gate-review");
|
|
838
|
+
const sealed = review.sealedApprovalRule(gate, (options.trust ?? ((b) => review.trustPolicyAt(cwd, b)))(base));
|
|
839
|
+
return {
|
|
840
|
+
ledger,
|
|
841
|
+
port: {
|
|
842
|
+
...ledger,
|
|
843
|
+
async approvalRule(g) {
|
|
844
|
+
const own = (await ledger.approvalRule?.(g)) ?? null;
|
|
845
|
+
return g === gate ? review.bothRules(own, sealed) : own;
|
|
846
|
+
},
|
|
847
|
+
},
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/**
|
|
852
|
+
* Settle a hold on another gate (#3821): an approval of that gate and digest
|
|
853
|
+
* under the wave's approval mode (`pr-review` counts the ledger alone, since
|
|
854
|
+
* no review planned that digest), else a pending fact for it.
|
|
855
|
+
*/
|
|
856
|
+
async function settleOtherGate(ctx: WaveContext, hold: OtherGateHold): Promise<OpWaveHeldOn> {
|
|
857
|
+
const { spec, k, wave, log } = ctx;
|
|
858
|
+
const rules = ctx.gateRule;
|
|
859
|
+
const op = hold.request.op ?? spec.name;
|
|
860
|
+
const mode: GateApprovalSource = rules.approval === "sealed" ? "sealed" : "ledger";
|
|
861
|
+
const { port } = await gatePort(ctx.options, ctx.cwd, hold.request.gate, mode, rules.base);
|
|
862
|
+
const neverOverMcp = rules.neverOverMcp;
|
|
863
|
+
const now = ctx.options.now ?? new Date().toISOString();
|
|
864
|
+
const check = await evaluateGate(port, {
|
|
865
|
+
op,
|
|
866
|
+
gate: hold.request.gate,
|
|
867
|
+
planDigest: hold.request.digest,
|
|
868
|
+
description: hold.request.description ?? `wave ${k} (${wave.name}) of ${spec.name} is held on it by ${hold.label}`,
|
|
869
|
+
now,
|
|
870
|
+
...(hold.request.pending ? { fields: hold.request.pending } : {}),
|
|
871
|
+
...(neverOverMcp ? { neverOverMcp: true } : {}),
|
|
872
|
+
});
|
|
873
|
+
const base = { op, gate: hold.request.gate, digest: hold.request.digest, hook: hold.label };
|
|
874
|
+
if (check.satisfied) return { ...base, status: "approved", approvedBy: check.resolution.resolvedBy, approvedAt: check.resolution.timestamp };
|
|
875
|
+
const approve = approveCommand(op, hold.request.gate, undefined, hold.request.digest, mode === "sealed");
|
|
876
|
+
log(`wave ${k} (${wave.name}) waits on ${op} / ${hold.request.gate}, which ${hold.label} holds it on: nobody approved ${hold.request.digest}. Approve: ${approve}`);
|
|
877
|
+
return {
|
|
878
|
+
...base,
|
|
879
|
+
status: "waiting",
|
|
880
|
+
approve,
|
|
881
|
+
waitingSince: check.pending.timestamp,
|
|
882
|
+
recorded: check.recorded,
|
|
883
|
+
...(check.mismatch ? { mismatch: check.mismatch } : {}),
|
|
884
|
+
};
|
|
677
885
|
}
|
|
678
886
|
|
|
679
887
|
/**
|
|
@@ -725,12 +933,14 @@ export function runOpWaveApplyHooks(file: OpWaveHooksFile, moment: OpWaveHookMom
|
|
|
725
933
|
} else {
|
|
726
934
|
const place: HookPlace = { name: file.opWaves, op: file.op, waveName: file.wave, k: file.number, cwd, exec: options.exec ?? spawnExec, log };
|
|
727
935
|
const out = runHookList(place, moment, file.hooks[moment], file.runs, file.runsFile);
|
|
936
|
+
// A hold on another gate (#3821) is settled only by the wave job; under the apply's lock it stops the apply.
|
|
937
|
+
const others = out.otherGates.map((o) => `${o.label} on ${o.request.op ?? file.opWaves} / ${o.request.gate}`);
|
|
728
938
|
run = {
|
|
729
939
|
moment,
|
|
730
940
|
outcomes: out.outcomes,
|
|
731
941
|
...(out.failed ? { failed: out.failed } : {}),
|
|
732
|
-
held: out.holds,
|
|
733
|
-
proceed: !out.failed && (out.holds.length === 0 || file.approved),
|
|
942
|
+
held: [...out.holds, ...others],
|
|
943
|
+
proceed: !out.failed && others.length === 0 && (out.holds.length === 0 || file.approved),
|
|
734
944
|
};
|
|
735
945
|
}
|
|
736
946
|
if (file.result) {
|
|
@@ -827,7 +1037,7 @@ export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveRes
|
|
|
827
1037
|
}
|
|
828
1038
|
const recordPath = join(cwd, opWaveRecordPath(spec, k));
|
|
829
1039
|
const result: RunOpWaveResult = { exitCode: 0, applied: [], failed: [], moved: [], hooks: [] };
|
|
830
|
-
const ctx: WaveContext = { spec, k, wave, cwd, exec, log, result };
|
|
1040
|
+
const ctx: WaveContext = { spec, k, wave, cwd, exec, log, result, options, gateRule: { approval: "ledger", base: "HEAD", neverOverMcp: spec.neverOverMcp === true } };
|
|
831
1041
|
|
|
832
1042
|
if (options.share !== undefined) {
|
|
833
1043
|
let decision: OpWaveDecision;
|
|
@@ -837,18 +1047,21 @@ export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveRes
|
|
|
837
1047
|
throw new Error(`share ${options.share} of wave ${k} (${wave.name}) found no decision at ${opWaveRecordPath(spec, k)}. Run the deciding job first.`);
|
|
838
1048
|
}
|
|
839
1049
|
result.decision = decision;
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
1050
|
+
ctx.gateRule = { approval: decision.approval, base: decision.base, neverOverMcp: decision.neverOverMcp === true };
|
|
1051
|
+
if (decision.status === "waiting" || decision.status === "refused") {
|
|
1052
|
+
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());
|
|
1053
|
+
return { ...result, exitCode: decision.status === "refused" ? 7 : 3 };
|
|
843
1054
|
}
|
|
844
1055
|
// The share runs the hooks its deciding job read at base, around its own slice.
|
|
845
1056
|
const slice = opWaveShare(wave.runs, options.share, shares);
|
|
846
|
-
const before = runHooks(ctx, "before-plan", decision.hooks?.["before-plan"], slice);
|
|
1057
|
+
const before = await runHooks(ctx, "before-plan", decision.hooks?.["before-plan"], slice);
|
|
847
1058
|
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`);
|
|
1059
|
+
if (before.waitingOn) return waitOnOther(ctx, decision, before.waitingOn);
|
|
848
1060
|
const { members } = planRuns(ctx, slice);
|
|
849
1061
|
const planned = new Map(members.map((m) => [m.member, m]));
|
|
850
|
-
const after = runHooks(ctx, "after-plan", decision.hooks?.["after-plan"], slice, planned);
|
|
1062
|
+
const after = await runHooks(ctx, "after-plan", decision.hooks?.["after-plan"], slice, planned);
|
|
851
1063
|
if (after.failed) return failed(ctx, `a hook after the plan failed, so nothing in share ${options.share} of wave ${k} was applied`);
|
|
1064
|
+
if (after.waitingOn) return waitOnOther(ctx, decision, after.waitingOn);
|
|
852
1065
|
const decided = new Map(decision.members.map((m) => [m.member, m.planDigest]));
|
|
853
1066
|
const unmoved: OpWaveRun[] = [];
|
|
854
1067
|
for (const run of slice) {
|
|
@@ -871,21 +1084,14 @@ export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveRes
|
|
|
871
1084
|
// The policy, the approval mode and the hooks, read at base before anything runs.
|
|
872
1085
|
const rules = readOpWavePolicy(options.specFile, wave.name, options.base ?? spec.base ?? "HEAD^1", cwd, options.show);
|
|
873
1086
|
const { policy, approval, source, base } = rules;
|
|
1087
|
+
const neverOverMcp = rules.neverOverMcp || spec.neverOverMcp === true;
|
|
1088
|
+
ctx.gateRule = { approval, base, neverOverMcp };
|
|
874
1089
|
noteHooksAtBase(ctx, rules);
|
|
875
1090
|
const holds: string[] = [];
|
|
876
1091
|
if (rules.hooksRefused) {
|
|
877
1092
|
holds.push(rules.hooksRefused);
|
|
878
1093
|
log(`wave ${k} (${wave.name}): ${rules.hooksRefused}, so none run and the wave waits for an approval`);
|
|
879
1094
|
}
|
|
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
1095
|
const gate = opWaveGateName(spec, k);
|
|
890
1096
|
const hooks = Object.fromEntries(Object.entries(rules.hooks).filter(([, list]) => list.length > 0));
|
|
891
1097
|
const decision: OpWaveDecision = {
|
|
@@ -897,41 +1103,229 @@ export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveRes
|
|
|
897
1103
|
approval,
|
|
898
1104
|
policySource: source,
|
|
899
1105
|
base,
|
|
900
|
-
digest,
|
|
901
|
-
members,
|
|
1106
|
+
digest: "",
|
|
1107
|
+
members: [],
|
|
902
1108
|
status: "not-required",
|
|
903
1109
|
...(Object.keys(hooks).length > 0 ? { hooks } : {}),
|
|
904
|
-
...(holds.length > 0 ? { heldBy: holds } : {}),
|
|
905
1110
|
...(rules.spend ? { spend: "base" as const } : spec.approvals?.spend === true ? { spend: "change" as const } : {}),
|
|
906
1111
|
...(rules.applyHooksRunBy === "apply" ? { applyHooksRunBy: "apply" as const } : {}),
|
|
907
1112
|
...(spec.digest?.over === "changing" ? { digestOver: "changing" as const } : {}),
|
|
908
|
-
...(pending ? { pending } : {}),
|
|
909
1113
|
...(neverOverMcp ? { neverOverMcp: true as const } : {}),
|
|
910
1114
|
};
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
if (
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
1115
|
+
const before = await runHooks(ctx, "before-plan", rules.hooks["before-plan"], wave.runs);
|
|
1116
|
+
if (before.failed) return failed(ctx, `a hook before the plan failed, so nothing in wave ${k} was planned or applied`);
|
|
1117
|
+
if (before.waitingOn) return decided(ctx, decision, recordPath, before.waitingOn);
|
|
1118
|
+
const { members, pending, overriders } = planRuns(ctx, wave.runs);
|
|
1119
|
+
const planned = new Map(members.map((m) => [m.member, m]));
|
|
1120
|
+
const digest = opWaveSetDigest(spec, members);
|
|
1121
|
+
decision.digest = digest;
|
|
1122
|
+
decision.members = members;
|
|
1123
|
+
if (pending) decision.pending = pending;
|
|
1124
|
+
const after = await runHooks(ctx, "after-plan", rules.hooks["after-plan"], wave.runs, planned, digest);
|
|
1125
|
+
if (after.failed) return failed(ctx, `a hook after the plan failed, so nothing in wave ${k} was applied`);
|
|
1126
|
+
holds.push(...before.holds, ...after.holds);
|
|
1127
|
+
if (holds.length > 0) decision.heldBy = holds;
|
|
1128
|
+
if (after.waitingOn) return decided(ctx, decision, recordPath, after.waitingOn);
|
|
1129
|
+
// #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.
|
|
1130
|
+
if (members.some((m) => m.denied)) await settleDenials(ctx, decision, overriders);
|
|
1131
|
+
if (decision.status !== "refused" && (opWaveNeedsApproval(policy, members) || holds.length > 0)) await settleGate(options, ctx, decision);
|
|
1132
|
+
const code = await decided(ctx, decision, recordPath);
|
|
1133
|
+
if (code.exitCode !== 0) return code;
|
|
917
1134
|
log(
|
|
918
1135
|
`wave ${k} (${wave.name}): gate ${policy} (read at ${base.slice(0, 12)}${source === "absent-at-base" ? ", where the wave is absent" : ""}), ` +
|
|
919
1136
|
`digest ${digest}: ${decision.status}${decision.approvedBy ? ` by ${decision.approvedBy}` : ""}` +
|
|
920
1137
|
(decision.via === "pr-review" ? ` (review of #${decision.review?.pr})` : "") +
|
|
921
1138
|
(holds.length > 0 ? `; held by ${holds.join(", ")}` : ""),
|
|
922
1139
|
);
|
|
923
|
-
|
|
1140
|
+
// A wide wave's shares apply under the deciding job's approval and overrides, so they are spent here, once.
|
|
1141
|
+
if (options.decide) {
|
|
1142
|
+
if (!(await spendApproval(options, ctx, decision)) || !(await spendOverrides(ctx, decision))) return ctx.result;
|
|
1143
|
+
if (decision.spentAt || decision.overridesSpentAt) writeRecord(recordPath, decision, result);
|
|
1144
|
+
return result;
|
|
1145
|
+
}
|
|
1146
|
+
await applyStage(options, ctx, decision, wave.runs, planned, [], recordPath);
|
|
1147
|
+
return result;
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
/**
|
|
1151
|
+
* The gate has decided (#3821): write the decision record, run the
|
|
1152
|
+
* `after-gate` hooks with the decision in their environment, and say whether
|
|
1153
|
+
* the wave goes on. Returns the result with exit 3 when it waits, 7 when its
|
|
1154
|
+
* policy refused it, 1 when an `after-gate` hook failed on a wave that would
|
|
1155
|
+
* apply; exit 0 to go on. A hook that fails while the wave waits or is
|
|
1156
|
+
* refused is logged and leaves the exit code alone.
|
|
1157
|
+
*/
|
|
1158
|
+
async function decided(ctx: WaveContext, decision: OpWaveDecision, recordPath: string, heldOn?: OpWaveHeldOn): Promise<RunOpWaveResult> {
|
|
1159
|
+
const { k, wave, log, result } = ctx;
|
|
1160
|
+
if (heldOn) markHeldOn(decision, heldOn);
|
|
1161
|
+
mkdirSync(dirname(recordPath), { recursive: true });
|
|
1162
|
+
writeRecord(recordPath, decision, result);
|
|
1163
|
+
result.decision = decision;
|
|
1164
|
+
if (decision.approval === "pr-review" && decision.status === "waiting" && decision.review?.reason) {
|
|
924
1165
|
log(`The pull request's review does not approve wave ${k}: ${decision.review.reason}`);
|
|
925
1166
|
}
|
|
1167
|
+
const hook = await runGateHooks(ctx, decision, recordPath);
|
|
926
1168
|
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)`);
|
|
1169
|
+
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
1170
|
return { ...result, exitCode: 3 };
|
|
929
1171
|
}
|
|
930
|
-
if (
|
|
931
|
-
|
|
1172
|
+
if (decision.status === "refused") {
|
|
1173
|
+
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"}.`);
|
|
1174
|
+
return { ...result, exitCode: 7 };
|
|
1175
|
+
}
|
|
1176
|
+
if (hook.failed) return failed(ctx, `wave ${k} (${wave.name}): ${hook.failed}, so nothing in it was applied`);
|
|
932
1177
|
return result;
|
|
933
1178
|
}
|
|
934
1179
|
|
|
1180
|
+
/** A hold on another gate that waits makes the wave wait on it (#3821). */
|
|
1181
|
+
function markHeldOn(decision: OpWaveDecision, heldOn: OpWaveHeldOn): void {
|
|
1182
|
+
decision.heldOn = heldOn;
|
|
1183
|
+
decision.status = "waiting";
|
|
1184
|
+
decision.approve = heldOn.approve;
|
|
1185
|
+
if (heldOn.waitingSince) decision.waitingSince = heldOn.waitingSince;
|
|
1186
|
+
if (heldOn.recorded !== undefined) decision.recorded = heldOn.recorded;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
/** A share job held on another gate that waits (#3821). */
|
|
1190
|
+
async function waitOnOther(ctx: WaveContext, decision: OpWaveDecision, heldOn: OpWaveHeldOn): Promise<RunOpWaveResult> {
|
|
1191
|
+
markHeldOn(decision, heldOn);
|
|
1192
|
+
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}`);
|
|
1193
|
+
return { ...ctx.result, decision, exitCode: 3 };
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
/**
|
|
1197
|
+
* Run the `after-gate` hooks (#3821) once the gate decided. Their
|
|
1198
|
+
* environment carries the decision: `CHANT_GATE_STATUS` (`approved`,
|
|
1199
|
+
* `not-required`, `waiting` or `refused`), `CHANT_GATE_OP`, `CHANT_GATE`,
|
|
1200
|
+
* `CHANT_GATE_DIGEST`, `CHANT_DECISION` (the decision record, with the
|
|
1201
|
+
* standing pending fact, the approval of another digest and the denials),
|
|
1202
|
+
* and when set `CHANT_GATE_APPROVE`, `CHANT_GATE_WAITING_SINCE` and
|
|
1203
|
+
* `CHANT_GATE_RECORDED` (`true` when this run recorded the pending fact).
|
|
1204
|
+
*/
|
|
1205
|
+
async function runGateHooks(ctx: WaveContext, decision: OpWaveDecision, recordPath: string): Promise<HooksOutcome> {
|
|
1206
|
+
const list = decision.hooks?.["after-gate"];
|
|
1207
|
+
if (!list?.length) return { holds: [] };
|
|
1208
|
+
const on = decision.heldOn;
|
|
1209
|
+
const env: Record<string, string> = {
|
|
1210
|
+
CHANT_GATE_STATUS: decision.status,
|
|
1211
|
+
CHANT_GATE_OP: on?.op ?? decision.op,
|
|
1212
|
+
CHANT_GATE: on?.gate ?? decision.gate,
|
|
1213
|
+
CHANT_GATE_DIGEST: on?.digest ?? decision.digest,
|
|
1214
|
+
CHANT_DECISION: relativeTo(ctx.cwd, recordPath),
|
|
1215
|
+
...(decision.approve ? { CHANT_GATE_APPROVE: decision.approve } : {}),
|
|
1216
|
+
...(decision.waitingSince ? { CHANT_GATE_WAITING_SINCE: decision.waitingSince } : {}),
|
|
1217
|
+
...(decision.recorded !== undefined ? { CHANT_GATE_RECORDED: String(decision.recorded) } : {}),
|
|
1218
|
+
};
|
|
1219
|
+
const planned = new Map(decision.members.map((m) => [m.member, m]));
|
|
1220
|
+
const out = await runHooks(ctx, "after-gate", list, ctx.wave.runs, planned.size > 0 ? planned : undefined, decision.digest || undefined, env);
|
|
1221
|
+
writeRecord(recordPath, decision, ctx.result);
|
|
1222
|
+
if (out.failed && decision.status !== "approved" && decision.status !== "not-required") ctx.log(`${out.failed}; the wave is ${decision.status} whatever the hook came to`);
|
|
1223
|
+
return out;
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
function relativeTo(cwd: string, path: string): string {
|
|
1227
|
+
return path.startsWith(cwd + "/") ? path.slice(cwd.length + 1) : path;
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
/**
|
|
1231
|
+
* Decide each run the product's policy denied (#3821) against the override
|
|
1232
|
+
* ledger, `_gates/policy-override.jsonl`, as `@intentius/chant/override-gate`
|
|
1233
|
+
* decides: an override counts when a listed overrider wrote it for this run,
|
|
1234
|
+
* its plan digest and its rules, gave a reason, and (under `sealed`) sealed
|
|
1235
|
+
* it. Overridden runs carry their override; with any run still denied the
|
|
1236
|
+
* decision is `refused`, and each such run gets a pending fact on the
|
|
1237
|
+
* override ledger unless one stands.
|
|
1238
|
+
*/
|
|
1239
|
+
async function settleDenials(ctx: WaveContext, decision: OpWaveDecision, listed: string[] | undefined): Promise<void> {
|
|
1240
|
+
const { spec, k, wave, cwd, log, options } = ctx;
|
|
1241
|
+
const og = await import("../override-gate");
|
|
1242
|
+
const port = options.gates ?? gitGateLedgerPort({ cwd });
|
|
1243
|
+
const path = spec.override?.overriders ?? og.OVERRIDERS_PATH;
|
|
1244
|
+
const overriders = listed ?? og.overridersAt(cwd, decision.base, path).principals;
|
|
1245
|
+
const sealed = decision.approval === "sealed";
|
|
1246
|
+
const signers = sealed ? (options.signers ?? (await import("../gate-decision")).gateSignersAt.bind(null, cwd))(decision.base) : undefined;
|
|
1247
|
+
const ledger = await port.read(og.OVERRIDE_OP);
|
|
1248
|
+
const applied = ((await port.readApplied?.(og.OVERRIDE_OP)) ?? []) as unknown as import("../override-gate").AppliedOverrideRecord[];
|
|
1249
|
+
const now = options.now ?? new Date().toISOString();
|
|
1250
|
+
const still: string[] = [];
|
|
1251
|
+
let moved = false;
|
|
1252
|
+
for (const m of decision.members) {
|
|
1253
|
+
if (!m.denied) continue;
|
|
1254
|
+
const verdict = og.decideOverride({
|
|
1255
|
+
ledger,
|
|
1256
|
+
applied,
|
|
1257
|
+
subject: m.member,
|
|
1258
|
+
planDigest: m.planDigest,
|
|
1259
|
+
rules: m.denied.rules,
|
|
1260
|
+
overriders,
|
|
1261
|
+
overridersPath: listed ? "the overriders the plans file names" : path,
|
|
1262
|
+
mode: sealed ? "sealed" : "ledger",
|
|
1263
|
+
...(signers ? { signers } : {}),
|
|
1264
|
+
now,
|
|
1265
|
+
});
|
|
1266
|
+
if (verdict.status === "overridden") {
|
|
1267
|
+
const o = verdict.override;
|
|
1268
|
+
m.override = { by: o.by, at: o.at, digest: o.digest, reason: o.reason };
|
|
1269
|
+
log(`${m.member}: the policy denial (${o.rules.join(", ")}) is overridden by ${o.by} at ${o.at}${o.sealed ? ", sealed" : ""}: ${o.reason}`);
|
|
1270
|
+
continue;
|
|
1271
|
+
}
|
|
1272
|
+
still.push(m.member);
|
|
1273
|
+
for (const why of verdict.refusals) log(`${m.member}: an override does not count: ${why}`);
|
|
1274
|
+
if (verdict.status === "moved") {
|
|
1275
|
+
moved = true;
|
|
1276
|
+
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`);
|
|
1277
|
+
}
|
|
1278
|
+
if (!verdict.standing) {
|
|
1279
|
+
const { version: _v, kind: _k, ...fact } = og.overridePending({
|
|
1280
|
+
subject: m.member,
|
|
1281
|
+
planDigest: m.planDigest,
|
|
1282
|
+
rules: m.denied.rules,
|
|
1283
|
+
timestamp: now,
|
|
1284
|
+
expiresAt: new Date(new Date(now).getTime() + 48 * 3600 * 1000).toISOString(),
|
|
1285
|
+
description: m.denied.reason ?? `wave ${k} (${wave.name}) of ${spec.name}: ${m.member}: plan ${m.planDigest} denied by ${og.sortedRules(m.denied.rules).join(", ")}`,
|
|
1286
|
+
});
|
|
1287
|
+
try {
|
|
1288
|
+
await port.appendPending({ ...fact, members: [{ member: m.member, planDigest: m.planDigest }] } as typeof fact);
|
|
1289
|
+
} catch (err) {
|
|
1290
|
+
log(`${m.member}: the denial was not recorded for an override: ${err instanceof Error ? err.message : String(err)}`);
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
log(`${m.member}: ${overriders.length ? `${overriders.join(", ")} may` : "nobody listed may"} override it with: ${og.overrideCommand(m.member, m.denied.rules, sealed)}`);
|
|
1294
|
+
}
|
|
1295
|
+
if (still.length > 0) {
|
|
1296
|
+
decision.status = "refused";
|
|
1297
|
+
decision.refused = { reason: moved ? "override-moved" : "policy", runs: still.sort() };
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
/**
|
|
1302
|
+
* Record each override the wave is about to apply under (#3821), in
|
|
1303
|
+
* `_gates/policy-override/applied.jsonl`, which spends it: written before
|
|
1304
|
+
* anything applies, once. False when the lines could not be written: the
|
|
1305
|
+
* wave then fails and applies nothing.
|
|
1306
|
+
*/
|
|
1307
|
+
async function spendOverrides(ctx: WaveContext, decision: OpWaveDecision): Promise<boolean> {
|
|
1308
|
+
const used = decision.members.filter((m) => m.override);
|
|
1309
|
+
if (used.length === 0 || decision.overridesSpentAt) return true;
|
|
1310
|
+
const { k, wave, cwd, log, options } = ctx;
|
|
1311
|
+
const og = await import("../override-gate");
|
|
1312
|
+
const port = options.gates ?? gitGateLedgerPort({ cwd });
|
|
1313
|
+
const now = options.now ?? new Date().toISOString();
|
|
1314
|
+
try {
|
|
1315
|
+
if (!port.appendApplied) throw new Error("this gate ledger cannot record an applied override");
|
|
1316
|
+
for (const m of used) {
|
|
1317
|
+
const record = og.appliedOverride({ subject: m.member, digest: m.override!.digest, at: m.override!.at, by: m.override!.by }, { timestamp: now });
|
|
1318
|
+
await port.appendApplied(record as unknown as AppliedGateRecord);
|
|
1319
|
+
}
|
|
1320
|
+
} catch (err) {
|
|
1321
|
+
failed(ctx, `wave ${k} (${wave.name}) could not record the overrides it applies under, so nothing applied: ${err instanceof Error ? err.message : String(err)}`);
|
|
1322
|
+
return false;
|
|
1323
|
+
}
|
|
1324
|
+
decision.overridesSpentAt = now;
|
|
1325
|
+
log(`wave ${k} (${wave.name}) applies ${used.map((m) => m.member).join(", ")} under their overrides, which are now spent`);
|
|
1326
|
+
return true;
|
|
1327
|
+
}
|
|
1328
|
+
|
|
935
1329
|
function failed(ctx: WaveContext, line: string): RunOpWaveResult {
|
|
936
1330
|
ctx.log(line);
|
|
937
1331
|
ctx.result.exitCode = 1;
|
|
@@ -946,20 +1340,7 @@ function failed(ctx: WaveContext, line: string): RunOpWaveResult {
|
|
|
946
1340
|
async function settleGate(options: RunOpWaveOptions, ctx: WaveContext, decision: OpWaveDecision): Promise<void> {
|
|
947
1341
|
const { spec, k, wave, cwd } = ctx;
|
|
948
1342
|
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
|
-
}
|
|
1343
|
+
const { ledger, port } = await gatePort(options, cwd, gate, approval, base);
|
|
963
1344
|
const now = options.now ?? new Date().toISOString();
|
|
964
1345
|
// #3784: under the spend rule at base, an approval a wave already applied under counts for nothing.
|
|
965
1346
|
const spent: AppliedGateRecord[] | undefined = decision.spend === "base" ? ((await ledger.readApplied?.(spec.name)) ?? []).filter((a) => a.gate === gate) : undefined;
|
|
@@ -981,6 +1362,11 @@ async function settleGate(options: RunOpWaveOptions, ctx: WaveContext, decision:
|
|
|
981
1362
|
decision.approvedAt = check.resolution.timestamp;
|
|
982
1363
|
return;
|
|
983
1364
|
}
|
|
1365
|
+
// #3821: what the wait is, for the after-gate hooks and the record.
|
|
1366
|
+
decision.pendingFact = check.pending;
|
|
1367
|
+
decision.recorded = check.recorded;
|
|
1368
|
+
decision.waitingSince = check.pending.timestamp;
|
|
1369
|
+
if (check.mismatch) decision.mismatch = check.mismatch;
|
|
984
1370
|
const used = spent?.filter((a) => isApprovalSpent([a], { gate, planDigest: digest, timestamp: a.approvedAt })).pop();
|
|
985
1371
|
if (used) {
|
|
986
1372
|
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 +1375,7 @@ async function settleGate(options: RunOpWaveOptions, ctx: WaveContext, decision:
|
|
|
989
1375
|
const byReview = await approveByReview(options, { op: spec.name, gate, k, digest, cwd, now, port: ledger, ...(spent ? { spent } : {}) });
|
|
990
1376
|
decision.review = byReview.review;
|
|
991
1377
|
if (byReview.approved) {
|
|
1378
|
+
for (const key of ["pendingFact", "recorded", "waitingSince", "mismatch"] as const) delete decision[key];
|
|
992
1379
|
decision.status = "approved";
|
|
993
1380
|
decision.via = "pr-review";
|
|
994
1381
|
decision.approvedBy = byReview.review.approvers!.join(", ");
|
|
@@ -1026,6 +1413,8 @@ async function settleHolds(
|
|
|
1026
1413
|
await settleGate(options, ctx, decision);
|
|
1027
1414
|
if (recordPath) writeRecord(recordPath, decision, result);
|
|
1028
1415
|
if (decision.status === "waiting") {
|
|
1416
|
+
// #3821: the gate decided again, and the wave now waits: the after-gate hooks hear so.
|
|
1417
|
+
if (recordPath) await runGateHooks(ctx, decision, recordPath);
|
|
1029
1418
|
log(`wave ${k} (${wave.name}) is held by ${holds.join(", ")}, and nobody approved ${decision.digest}; nothing applied. Approve: ${decision.approve}`);
|
|
1030
1419
|
result.exitCode = 3;
|
|
1031
1420
|
return false;
|
|
@@ -1147,16 +1536,27 @@ async function applyStage(
|
|
|
1147
1536
|
const byApply = decision.applyHooksRunBy === "apply";
|
|
1148
1537
|
const holds = [...earlierHolds];
|
|
1149
1538
|
if (!byApply) {
|
|
1150
|
-
const before = runHooks(ctx, "before-apply", decision.hooks?.["before-apply"], runs, planned, decision.digest);
|
|
1539
|
+
const before = await runHooks(ctx, "before-apply", decision.hooks?.["before-apply"], runs, planned, decision.digest);
|
|
1151
1540
|
if (before.failed) {
|
|
1152
1541
|
failed(ctx, `a hook before the apply failed, so nothing in wave ${k} was applied`);
|
|
1153
1542
|
return;
|
|
1154
1543
|
}
|
|
1544
|
+
if (before.waitingOn) {
|
|
1545
|
+
markHeldOn(decision, before.waitingOn);
|
|
1546
|
+
if (recordPath) {
|
|
1547
|
+
writeRecord(recordPath, decision, result);
|
|
1548
|
+
await runGateHooks(ctx, decision, recordPath);
|
|
1549
|
+
}
|
|
1550
|
+
result.decision = decision;
|
|
1551
|
+
result.exitCode = 3;
|
|
1552
|
+
return;
|
|
1553
|
+
}
|
|
1155
1554
|
holds.push(...before.holds);
|
|
1156
1555
|
}
|
|
1157
1556
|
if (!(await settleHolds(options, ctx, decision, holds, recordPath))) return;
|
|
1158
1557
|
if (!(await spendApproval(options, ctx, decision))) return;
|
|
1159
|
-
if (
|
|
1558
|
+
if (!(await spendOverrides(ctx, decision))) return;
|
|
1559
|
+
if (recordPath && (decision.spentAt || decision.overridesSpentAt)) writeRecord(recordPath, decision, result);
|
|
1160
1560
|
const problems: string[] = [];
|
|
1161
1561
|
let waiting = false;
|
|
1162
1562
|
if (spec.applyWave) {
|
|
@@ -1206,7 +1606,7 @@ async function applyStage(
|
|
|
1206
1606
|
return;
|
|
1207
1607
|
}
|
|
1208
1608
|
if (!byApply) {
|
|
1209
|
-
const after = runHooks(ctx, "after-apply", decision.hooks?.["after-apply"], runs, planned, decision.digest);
|
|
1609
|
+
const after = await runHooks(ctx, "after-apply", decision.hooks?.["after-apply"], runs, planned, decision.digest);
|
|
1210
1610
|
if (after.failed) failed(ctx, `wave ${k} (${wave.name}) applied, and then a hook after the apply failed`);
|
|
1211
1611
|
}
|
|
1212
1612
|
if (recordPath && result.hooks.length > 0) writeRecord(recordPath, decision, result);
|