@intentius/chant 0.124.2 → 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/cli/handlers/override-approve.d.ts +3 -1
- package/dist/cli/handlers/override-approve.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +2 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts +1 -33
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/lifecycle/gate-pending.d.ts +41 -0
- package/dist/lifecycle/gate-pending.d.ts.map +1 -0
- package/dist/op/activities/activity-contracts.d.ts +5 -4
- package/dist/op/activities/activity-contracts.d.ts.map +1 -1
- package/dist/op/activities/decide.d.ts +2 -0
- package/dist/op/activities/decide.d.ts.map +1 -1
- package/dist/op/gate-point-run.d.ts +5 -77
- package/dist/op/gate-point-run.d.ts.map +1 -1
- package/dist/op/gate-point.d.ts +18 -0
- package/dist/op/gate-point.d.ts.map +1 -1
- package/dist/op/gate.d.ts +8 -0
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/index.d.ts +4 -4
- 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 +123 -10
- package/dist/op/op-waves-run.d.ts.map +1 -1
- package/dist/op/op-waves.d.ts +71 -6
- package/dist/op/op-waves.d.ts.map +1 -1
- package/dist/op/steward-points.d.ts +7 -0
- package/dist/op/steward-points.d.ts.map +1 -1
- package/dist/override-gate.d.ts +8 -2
- package/dist/override-gate.d.ts.map +1 -1
- package/dist/point-decision.d.ts +134 -0
- package/dist/point-decision.d.ts.map +1 -0
- package/dist/pr-forge.d.ts +37 -2
- package/dist/pr-forge.d.ts.map +1 -1
- package/dist/workspace/decide.d.ts +3 -1
- package/dist/workspace/decide.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/cli/handlers/override-approve.test.ts +16 -0
- package/src/cli/handlers/override-approve.ts +6 -2
- package/src/cli/main.test.ts +3 -0
- package/src/cli/main.ts +9 -2
- package/src/cli/registry.ts +2 -0
- package/src/lifecycle/gate-ledger.ts +3 -53
- package/src/lifecycle/gate-pending.ts +62 -0
- package/src/notify-bundle.test.ts +20 -0
- package/src/op/activities/activity-contracts.ts +1 -0
- package/src/op/activities/decide.ts +3 -0
- package/src/op/gate-point-ci.e2e.test.ts +171 -0
- package/src/op/gate-point-mcp.e2e.test.ts +2 -0
- package/src/op/gate-point-run.ts +18 -149
- package/src/op/gate-point.e2e.test.ts +2 -0
- package/src/op/gate-point.test.ts +1 -1
- package/src/op/gate-point.ts +27 -3
- package/src/op/gate.ts +10 -0
- package/src/op/index.ts +12 -1
- package/src/op/op-waves-notify.ts +19 -2
- package/src/op/op-waves-run.ts +541 -76
- package/src/op/op-waves.test.ts +229 -0
- package/src/op/op-waves.ts +110 -10
- package/src/op/steward-points.ts +8 -0
- package/src/override-gate.test.ts +5 -0
- package/src/override-gate.ts +8 -2
- package/src/point-decision-bundle.test.ts +62 -0
- package/src/point-decision.e2e.test.ts +95 -0
- package/src/point-decision.ts +280 -0
- package/src/pr-forge.test.ts +62 -0
- package/src/pr-forge.ts +60 -9
- package/src/workspace/decide.ts +7 -2
- package/src/workspace/points-cli.ts +1 -1
package/src/op/op-waves-run.ts
CHANGED
|
@@ -42,12 +42,14 @@
|
|
|
42
42
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
43
43
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
44
44
|
import { dirname, isAbsolute, join, posix } from "node:path";
|
|
45
|
-
import {
|
|
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,
|
|
@@ -56,6 +58,7 @@ import {
|
|
|
56
58
|
assertOpWaveHooks,
|
|
57
59
|
assertOpWavesSpec,
|
|
58
60
|
opWaveApplyHooksRunBy,
|
|
61
|
+
opWaveGateName,
|
|
59
62
|
opWaveHooksAt,
|
|
60
63
|
opWaveRecordPath,
|
|
61
64
|
opWaveShare,
|
|
@@ -76,13 +79,38 @@ export interface OpWavePlanFile {
|
|
|
76
79
|
destructive?: boolean;
|
|
77
80
|
/** The plan changes nothing. */
|
|
78
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 };
|
|
79
88
|
}
|
|
80
89
|
|
|
81
90
|
/** What a wave-level plan command writes to `{plans}` (#3762): one plan per run, by target. The array alone is read too. */
|
|
82
91
|
export interface OpWavePlansFile {
|
|
83
92
|
runs: Array<OpWavePlanFile & { target: string }>;
|
|
93
|
+
/**
|
|
94
|
+
* Fields the product adds to the wave's pending fact (#3821), such as the
|
|
95
|
+
* commit it planned or the members its own readers expect. chant's own
|
|
96
|
+
* fields win: a key chant writes (`op`, `gate`, `planDigest`, `timestamp`,
|
|
97
|
+
* ...) is refused, except `description`.
|
|
98
|
+
*/
|
|
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[];
|
|
84
106
|
}
|
|
85
107
|
|
|
108
|
+
/** Pending-fact keys chant writes itself, which a plans file's `pending` may not set (#3821). */
|
|
109
|
+
export const OP_WAVE_PENDING_RESERVED = [
|
|
110
|
+
"version", "kind", "op", "gate", "timestamp", "expiresAt", "planDigest", "runId", "url", "resume",
|
|
111
|
+
"environment", "origin", "neverOverMcp", "approval", "answer",
|
|
112
|
+
] as const;
|
|
113
|
+
|
|
86
114
|
/** What chant writes to `{runs}` for a wave-level command or a hook (#3762). */
|
|
87
115
|
export interface OpWaveRunsFile {
|
|
88
116
|
/** The wave's name. */
|
|
@@ -100,6 +128,10 @@ export interface OpWaveMember extends WaveMember {
|
|
|
100
128
|
op: string;
|
|
101
129
|
destructive?: boolean;
|
|
102
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 };
|
|
103
135
|
}
|
|
104
136
|
|
|
105
137
|
/** Where a wave's policy came from. */
|
|
@@ -120,8 +152,12 @@ export interface OpWaveDecision {
|
|
|
120
152
|
base: string;
|
|
121
153
|
digest: string;
|
|
122
154
|
members: OpWaveMember[];
|
|
123
|
-
/**
|
|
124
|
-
|
|
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";
|
|
125
161
|
approvedBy?: string;
|
|
126
162
|
/** How the approval arrived: a resolution on the ledger, or the merged pull request's review (#3684). */
|
|
127
163
|
via?: "ledger" | "pr-review";
|
|
@@ -148,6 +184,58 @@ export interface OpWaveDecision {
|
|
|
148
184
|
applyHooksRunBy?: "apply";
|
|
149
185
|
/** Every hook's outcome so far in the job that wrote the record (#3804). */
|
|
150
186
|
outcomes?: OpWaveHookOutcome[];
|
|
187
|
+
/** `changing`: the digest covers only the runs whose plan is not empty, as the spec's `digest.over` says (#3821). Absent: every run. */
|
|
188
|
+
digestOver?: "changing";
|
|
189
|
+
/** The fields the plans file added to the pending fact (#3821). */
|
|
190
|
+
pending?: Record<string, unknown>;
|
|
191
|
+
/** The pending fact carries `neverOverMcp`, set by the spec here or at base (#3821). */
|
|
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;
|
|
151
239
|
}
|
|
152
240
|
|
|
153
241
|
/** One hook's run (#3762). */
|
|
@@ -216,6 +304,18 @@ export interface OpWaveHooksResult {
|
|
|
216
304
|
outcomes: OpWaveHookOutcome[];
|
|
217
305
|
}
|
|
218
306
|
|
|
307
|
+
/** The options of {@link runOpWaveApplyHooks} (#3825). */
|
|
308
|
+
export interface RunOpWaveApplyHooksOptions {
|
|
309
|
+
/** Where the hooks run. Default `process.cwd()`. */
|
|
310
|
+
cwd?: string;
|
|
311
|
+
/** Runs one hook. Default: spawn it with the job's environment. */
|
|
312
|
+
exec?: OpWaveExec;
|
|
313
|
+
/** Default: stderr. */
|
|
314
|
+
log?: (line: string) => void;
|
|
315
|
+
/** Run nothing and record the moment as skipped, with this reason (#3811). */
|
|
316
|
+
skip?: string;
|
|
317
|
+
}
|
|
318
|
+
|
|
219
319
|
/** One call of {@link runOpWaveApplyHooks}. */
|
|
220
320
|
export interface OpWaveApplyHooksRun {
|
|
221
321
|
moment: OpWaveHookMoment;
|
|
@@ -293,6 +393,8 @@ export interface OpWaveBaseRules {
|
|
|
293
393
|
spend: boolean;
|
|
294
394
|
/** Who runs the apply-stage hooks, as read at base (#3804). `chant` when the file, the wave or the hooks are missing there. */
|
|
295
395
|
applyHooksRunBy: OpWaveApplyHooksRunner;
|
|
396
|
+
/** The spec at base sets `neverOverMcp` (#3821). */
|
|
397
|
+
neverOverMcp: boolean;
|
|
296
398
|
}
|
|
297
399
|
|
|
298
400
|
const noHooks = (): Record<OpWaveHookMoment, OpWaveHook[]> =>
|
|
@@ -313,7 +415,7 @@ export function readOpWavePolicy(
|
|
|
313
415
|
show: OpWaveShowAtBase = gitShowAtBase,
|
|
314
416
|
): OpWaveBaseRules {
|
|
315
417
|
const { sha, text } = show(base, specFile.replace(/^\.\//, "").split("\\").join(posix.sep), cwd);
|
|
316
|
-
const absent = { policy: "always" as const, approval: "ledger" as const, source: "absent-at-base" as const, base: sha, hooks: noHooks(), spend: false, applyHooksRunBy: "chant" as const };
|
|
418
|
+
const absent = { policy: "always" as const, approval: "ledger" as const, source: "absent-at-base" as const, base: sha, hooks: noHooks(), spend: false, applyHooksRunBy: "chant" as const, neverOverMcp: false };
|
|
317
419
|
if (text === null) return absent;
|
|
318
420
|
let spec: OpWavesSpec;
|
|
319
421
|
try {
|
|
@@ -323,12 +425,13 @@ export function readOpWavePolicy(
|
|
|
323
425
|
}
|
|
324
426
|
// The spend rule is the spec's, so it holds for a wave the base does not have yet.
|
|
325
427
|
const spend = (spec as { approvals?: { spend?: unknown } }).approvals?.spend === true;
|
|
428
|
+
const neverOverMcp = (spec as { neverOverMcp?: unknown }).neverOverMcp === true;
|
|
326
429
|
const wave = spec.waves.find((w) => w.name === name);
|
|
327
|
-
if (!wave) return { ...absent, spend };
|
|
430
|
+
if (!wave) return { ...absent, spend, neverOverMcp };
|
|
328
431
|
const policy = wave.gate ?? "always";
|
|
329
432
|
// An approval mode this chant does not know reads as sealed: the strictest, so an unknown rule never loosens a gate.
|
|
330
433
|
const approval = wave.approval === undefined ? "ledger" : GATE_APPROVAL_SOURCES.includes(wave.approval) ? wave.approval : "sealed";
|
|
331
|
-
const rules: OpWaveBaseRules = { policy: OP_WAVE_GATE_POLICIES.includes(policy) ? policy : "always", approval, source: "base", base: sha, hooks: noHooks(), spend, applyHooksRunBy: "chant" };
|
|
434
|
+
const rules: OpWaveBaseRules = { policy: OP_WAVE_GATE_POLICIES.includes(policy) ? policy : "always", approval, source: "base", base: sha, hooks: noHooks(), spend, applyHooksRunBy: "chant", neverOverMcp };
|
|
332
435
|
try {
|
|
333
436
|
assertOpWaveHooks(`${specFile} at ${sha.slice(0, 12)}`, spec.hooks);
|
|
334
437
|
assertOpWaveHooks(`${specFile} at ${sha.slice(0, 12)}, wave "${name}"`, wave.hooks);
|
|
@@ -397,10 +500,12 @@ export interface RunOpWaveOptions {
|
|
|
397
500
|
headPlans?: (name: string, head: string) => Promise<HeadPlans | null>;
|
|
398
501
|
/** The signers policy at base, for a `sealed` gate. Default: read from the base commit. */
|
|
399
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;
|
|
400
505
|
}
|
|
401
506
|
|
|
402
507
|
export interface RunOpWaveResult {
|
|
403
|
-
/** 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). */
|
|
404
509
|
exitCode: number;
|
|
405
510
|
decision?: OpWaveDecision;
|
|
406
511
|
applied: string[];
|
|
@@ -420,6 +525,9 @@ interface WaveContext {
|
|
|
420
525
|
exec: OpWaveExec;
|
|
421
526
|
log: (line: string) => void;
|
|
422
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 };
|
|
423
531
|
}
|
|
424
532
|
|
|
425
533
|
function planRun(opts: { spec: OpWavesSpec; wave: number; cwd: string; exec: OpWaveExec }, waveName: string, run: OpWaveRun): OpWaveMember {
|
|
@@ -441,12 +549,20 @@ function planRun(opts: { spec: OpWavesSpec; wave: number; cwd: string; exec: OpW
|
|
|
441
549
|
}
|
|
442
550
|
|
|
443
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
|
+
}
|
|
444
559
|
return {
|
|
445
560
|
member: target,
|
|
446
561
|
planDigest: file.planDigest,
|
|
447
562
|
op,
|
|
448
563
|
...(file.destructive === true ? { destructive: true } : {}),
|
|
449
564
|
...(file.empty === true ? { empty: true } : {}),
|
|
565
|
+
...(file.denied ? { denied: { rules: file.denied.rules, ...(file.denied.reason ? { reason: file.denied.reason } : {}) } } : {}),
|
|
450
566
|
};
|
|
451
567
|
}
|
|
452
568
|
|
|
@@ -481,9 +597,9 @@ function writeRunsFile(
|
|
|
481
597
|
* for all of them (#3762). A wave plan is written out as each run's plan file
|
|
482
598
|
* too, so a per-run `apply` or hook reads `{plan}` either way.
|
|
483
599
|
*/
|
|
484
|
-
function planRuns(at: { spec: OpWavesSpec; k: number; wave: OpWave; cwd: string; exec: OpWaveExec }, runs: readonly OpWaveRun[]):
|
|
600
|
+
function planRuns(at: { spec: OpWavesSpec; k: number; wave: OpWave; cwd: string; exec: OpWaveExec }, runs: readonly OpWaveRun[]): PlannedRuns {
|
|
485
601
|
const { spec, k, wave, cwd, exec } = at;
|
|
486
|
-
if (!spec.planWave) return runs.map((run) => planRun({ spec, wave: k, cwd, exec }, wave.name, run));
|
|
602
|
+
if (!spec.planWave) return { members: runs.map((run) => planRun({ spec, wave: k, cwd, exec }, wave.name, run)) };
|
|
487
603
|
const runsPath = writeRunsFile(at, runs);
|
|
488
604
|
const plans = opWavePlansPath(spec, k);
|
|
489
605
|
rmSync(join(cwd, plans), { force: true });
|
|
@@ -510,12 +626,46 @@ function planRuns(at: { spec: OpWavesSpec; k: number; wave: OpWave; cwd: string;
|
|
|
510
626
|
}
|
|
511
627
|
const missing = runs.filter((r) => !byTarget.has(r.target)).map((r) => r.target);
|
|
512
628
|
if (missing.length > 0) throw new Error(`the plans file of wave ${k} (${plans}) has no plan for ${missing.join(", ")}`);
|
|
513
|
-
|
|
629
|
+
const pending = Array.isArray(parsed) ? undefined : pendingFields((parsed as { pending?: unknown }).pending, `the plans file of wave ${k} (${plans})`);
|
|
630
|
+
const members = runs.map((run) => {
|
|
514
631
|
const m = member(run.target, run.op ?? spec.op, byTarget.get(run.target)!);
|
|
515
632
|
const path = join(cwd, opWavePlanPath(spec, k, run.target));
|
|
516
|
-
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");
|
|
517
634
|
return m;
|
|
518
635
|
});
|
|
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[] } : {}) };
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/** What planning a wave's runs gave: each run's plan, and the product's pending-fact fields (#3821). */
|
|
644
|
+
interface PlannedRuns {
|
|
645
|
+
members: OpWaveMember[];
|
|
646
|
+
pending?: Record<string, unknown>;
|
|
647
|
+
/** The plans file's overriders (#3821). */
|
|
648
|
+
overriders?: string[];
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/** A plans file's `pending` (#3821): an object whose keys chant does not write itself, `description` aside. Undefined when absent or empty. */
|
|
652
|
+
function pendingFields(value: unknown, where: string): Record<string, unknown> | undefined {
|
|
653
|
+
if (value === undefined) return undefined;
|
|
654
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error(`${where}: \`pending\` must be an object of fields for the pending fact`);
|
|
655
|
+
const reserved = Object.keys(value).filter((key) => (OP_WAVE_PENDING_RESERVED as readonly string[]).includes(key));
|
|
656
|
+
if (reserved.length > 0) throw new Error(`${where}: \`pending\` sets ${reserved.join(", ")}, which chant writes itself`);
|
|
657
|
+
if ("description" in value && typeof (value as { description?: unknown }).description !== "string") throw new Error(`${where}: pending.description must be a string`);
|
|
658
|
+
return Object.keys(value).length > 0 ? (value as Record<string, unknown>) : undefined;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* A wave's set digest (#3821): {@link waveSetDigest} over its runs' plans,
|
|
663
|
+
* or under `digest.over: "changing"` over the runs whose plan is not empty,
|
|
664
|
+
* falling back to every run when all of them are empty.
|
|
665
|
+
*/
|
|
666
|
+
export function opWaveSetDigest(spec: Pick<OpWavesSpec, "digest">, members: ReadonlyArray<WaveMember & { empty?: boolean }>): string {
|
|
667
|
+
const changing = spec.digest?.over === "changing" ? members.filter((m) => m.empty !== true) : [];
|
|
668
|
+
return waveSetDigest(changing.length > 0 ? changing : members);
|
|
519
669
|
}
|
|
520
670
|
|
|
521
671
|
/** What one moment's hooks came to. */
|
|
@@ -524,6 +674,32 @@ interface HooksOutcome {
|
|
|
524
674
|
failed?: string;
|
|
525
675
|
/** The `onFailure: approve` hooks that failed. */
|
|
526
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
|
+
}
|
|
527
703
|
}
|
|
528
704
|
|
|
529
705
|
/** Where hooks run: the wave they belong to and the job's tools. */
|
|
@@ -549,11 +725,14 @@ function runHookList(
|
|
|
549
725
|
hooks: readonly OpWaveHook[] | undefined,
|
|
550
726
|
runs: readonly OpWaveHookRun[],
|
|
551
727
|
runsPath: string,
|
|
552
|
-
|
|
553
|
-
|
|
728
|
+
extraEnv: Record<string, string> = {},
|
|
729
|
+
): HooksOutcome & { outcomes: OpWaveHookOutcome[]; otherGates: OtherGateHold[] } {
|
|
730
|
+
const out: HooksOutcome & { outcomes: OpWaveHookOutcome[]; otherGates: OtherGateHold[] } = { holds: [], outcomes: [], otherGates: [] };
|
|
554
731
|
if (!hooks || hooks.length === 0) return out;
|
|
555
732
|
const { name: specName, op, waveName, k, cwd, exec, log } = at;
|
|
556
|
-
|
|
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 };
|
|
557
736
|
for (const hook of hooks) {
|
|
558
737
|
const name = hook.name ?? (hook.command.join(" ").length > 60 ? `${hook.command.join(" ").slice(0, 57)}...` : hook.command.join(" "));
|
|
559
738
|
const calls = hook.perRun
|
|
@@ -566,6 +745,7 @@ function runHookList(
|
|
|
566
745
|
for (const call of calls) {
|
|
567
746
|
const label = `${moment} hook ${name}${call.target ? ` (${call.target})` : ""}`;
|
|
568
747
|
let code: number;
|
|
748
|
+
rmSync(join(cwd, holdPath), { force: true });
|
|
569
749
|
try {
|
|
570
750
|
code = exec(call.argv, cwd, call.env);
|
|
571
751
|
} catch (err) {
|
|
@@ -579,6 +759,12 @@ function runHookList(
|
|
|
579
759
|
}
|
|
580
760
|
if (hook.onFailure === "approve") {
|
|
581
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
|
+
}
|
|
582
768
|
out.holds.push(label);
|
|
583
769
|
log(`${label} exited ${code}, so wave ${k} (${waveName}) waits for an approval of its set digest`);
|
|
584
770
|
continue;
|
|
@@ -597,20 +783,105 @@ const placeOf = (ctx: WaveContext): HookPlace => ({ name: ctx.spec.name, op: ctx
|
|
|
597
783
|
const hookRuns = (spec: OpWavesSpec, k: number, runs: readonly OpWaveRun[], members?: ReadonlyMap<string, OpWaveMember>): OpWaveHookRun[] =>
|
|
598
784
|
runs.map((run) => ({ target: run.target, op: run.op ?? spec.op, ...(members?.has(run.target) ? { plan: opWavePlanPath(spec, k, run.target) } : {}) }));
|
|
599
785
|
|
|
600
|
-
/**
|
|
601
|
-
|
|
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(
|
|
602
793
|
ctx: WaveContext,
|
|
603
794
|
moment: OpWaveHookMoment,
|
|
604
795
|
hooks: readonly OpWaveHook[] | undefined,
|
|
605
796
|
runs: readonly OpWaveRun[],
|
|
606
797
|
members?: ReadonlyMap<string, OpWaveMember>,
|
|
607
798
|
digest?: string,
|
|
608
|
-
|
|
799
|
+
extraEnv: Record<string, string> = {},
|
|
800
|
+
): Promise<HooksOutcome> {
|
|
609
801
|
if (!hooks || hooks.length === 0) return { holds: [] };
|
|
610
802
|
const runsPath = writeRunsFile(ctx, runs, members, digest);
|
|
611
|
-
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);
|
|
612
804
|
ctx.result.hooks.push(...out.outcomes);
|
|
613
|
-
|
|
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
|
+
};
|
|
614
885
|
}
|
|
615
886
|
|
|
616
887
|
/**
|
|
@@ -647,11 +918,7 @@ export function readOpWaveHooksFile(path: string | undefined = process.env.CHANT
|
|
|
647
918
|
* chant counts the moment as handled. A skip without a reason throws. `exec` runs one hook; inject it to run a hook
|
|
648
919
|
* in process or to keep its output.
|
|
649
920
|
*/
|
|
650
|
-
export function runOpWaveApplyHooks(
|
|
651
|
-
file: OpWaveHooksFile,
|
|
652
|
-
moment: OpWaveHookMoment,
|
|
653
|
-
options: { cwd?: string; exec?: OpWaveExec; log?: (line: string) => void; skip?: string } = {},
|
|
654
|
-
): OpWaveApplyHooksRun {
|
|
921
|
+
export function runOpWaveApplyHooks(file: OpWaveHooksFile, moment: OpWaveHookMoment, options: RunOpWaveApplyHooksOptions = {}): OpWaveApplyHooksRun {
|
|
655
922
|
const cwd = options.cwd ?? process.cwd();
|
|
656
923
|
const log = options.log ?? ((line: string) => console.error(line));
|
|
657
924
|
let run: OpWaveApplyHooksRun;
|
|
@@ -666,12 +933,14 @@ export function runOpWaveApplyHooks(
|
|
|
666
933
|
} else {
|
|
667
934
|
const place: HookPlace = { name: file.opWaves, op: file.op, waveName: file.wave, k: file.number, cwd, exec: options.exec ?? spawnExec, log };
|
|
668
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}`);
|
|
669
938
|
run = {
|
|
670
939
|
moment,
|
|
671
940
|
outcomes: out.outcomes,
|
|
672
941
|
...(out.failed ? { failed: out.failed } : {}),
|
|
673
|
-
held: out.holds,
|
|
674
|
-
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),
|
|
675
944
|
};
|
|
676
945
|
}
|
|
677
946
|
if (file.result) {
|
|
@@ -768,7 +1037,7 @@ export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveRes
|
|
|
768
1037
|
}
|
|
769
1038
|
const recordPath = join(cwd, opWaveRecordPath(spec, k));
|
|
770
1039
|
const result: RunOpWaveResult = { exitCode: 0, applied: [], failed: [], moved: [], hooks: [] };
|
|
771
|
-
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 } };
|
|
772
1041
|
|
|
773
1042
|
if (options.share !== undefined) {
|
|
774
1043
|
let decision: OpWaveDecision;
|
|
@@ -778,18 +1047,21 @@ export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveRes
|
|
|
778
1047
|
throw new Error(`share ${options.share} of wave ${k} (${wave.name}) found no decision at ${opWaveRecordPath(spec, k)}. Run the deciding job first.`);
|
|
779
1048
|
}
|
|
780
1049
|
result.decision = decision;
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
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 };
|
|
784
1054
|
}
|
|
785
1055
|
// The share runs the hooks its deciding job read at base, around its own slice.
|
|
786
1056
|
const slice = opWaveShare(wave.runs, options.share, shares);
|
|
787
|
-
const before = runHooks(ctx, "before-plan", decision.hooks?.["before-plan"], slice);
|
|
1057
|
+
const before = await runHooks(ctx, "before-plan", decision.hooks?.["before-plan"], slice);
|
|
788
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`);
|
|
789
|
-
|
|
1059
|
+
if (before.waitingOn) return waitOnOther(ctx, decision, before.waitingOn);
|
|
1060
|
+
const { members } = planRuns(ctx, slice);
|
|
790
1061
|
const planned = new Map(members.map((m) => [m.member, m]));
|
|
791
|
-
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);
|
|
792
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);
|
|
793
1065
|
const decided = new Map(decision.members.map((m) => [m.member, m.planDigest]));
|
|
794
1066
|
const unmoved: OpWaveRun[] = [];
|
|
795
1067
|
for (const run of slice) {
|
|
@@ -812,21 +1084,15 @@ export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveRes
|
|
|
812
1084
|
// The policy, the approval mode and the hooks, read at base before anything runs.
|
|
813
1085
|
const rules = readOpWavePolicy(options.specFile, wave.name, options.base ?? spec.base ?? "HEAD^1", cwd, options.show);
|
|
814
1086
|
const { policy, approval, source, base } = rules;
|
|
1087
|
+
const neverOverMcp = rules.neverOverMcp || spec.neverOverMcp === true;
|
|
1088
|
+
ctx.gateRule = { approval, base, neverOverMcp };
|
|
815
1089
|
noteHooksAtBase(ctx, rules);
|
|
816
1090
|
const holds: string[] = [];
|
|
817
1091
|
if (rules.hooksRefused) {
|
|
818
1092
|
holds.push(rules.hooksRefused);
|
|
819
1093
|
log(`wave ${k} (${wave.name}): ${rules.hooksRefused}, so none run and the wave waits for an approval`);
|
|
820
1094
|
}
|
|
821
|
-
const
|
|
822
|
-
if (before.failed) return failed(ctx, `a hook before the plan failed, so nothing in wave ${k} was planned or applied`);
|
|
823
|
-
const members = planRuns(ctx, wave.runs);
|
|
824
|
-
const planned = new Map(members.map((m) => [m.member, m]));
|
|
825
|
-
const digest = waveSetDigest(members);
|
|
826
|
-
const after = runHooks(ctx, "after-plan", rules.hooks["after-plan"], wave.runs, planned, digest);
|
|
827
|
-
if (after.failed) return failed(ctx, `a hook after the plan failed, so nothing in wave ${k} was applied`);
|
|
828
|
-
holds.push(...before.holds, ...after.holds);
|
|
829
|
-
const gate = waveGateName(spec.gate ?? spec.name, k);
|
|
1095
|
+
const gate = opWaveGateName(spec, k);
|
|
830
1096
|
const hooks = Object.fromEntries(Object.entries(rules.hooks).filter(([, list]) => list.length > 0));
|
|
831
1097
|
const decision: OpWaveDecision = {
|
|
832
1098
|
wave: k,
|
|
@@ -837,38 +1103,229 @@ export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveRes
|
|
|
837
1103
|
approval,
|
|
838
1104
|
policySource: source,
|
|
839
1105
|
base,
|
|
840
|
-
digest,
|
|
841
|
-
members,
|
|
1106
|
+
digest: "",
|
|
1107
|
+
members: [],
|
|
842
1108
|
status: "not-required",
|
|
843
1109
|
...(Object.keys(hooks).length > 0 ? { hooks } : {}),
|
|
844
|
-
...(holds.length > 0 ? { heldBy: holds } : {}),
|
|
845
1110
|
...(rules.spend ? { spend: "base" as const } : spec.approvals?.spend === true ? { spend: "change" as const } : {}),
|
|
846
1111
|
...(rules.applyHooksRunBy === "apply" ? { applyHooksRunBy: "apply" as const } : {}),
|
|
1112
|
+
...(spec.digest?.over === "changing" ? { digestOver: "changing" as const } : {}),
|
|
1113
|
+
...(neverOverMcp ? { neverOverMcp: true as const } : {}),
|
|
847
1114
|
};
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
if (
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
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;
|
|
854
1134
|
log(
|
|
855
1135
|
`wave ${k} (${wave.name}): gate ${policy} (read at ${base.slice(0, 12)}${source === "absent-at-base" ? ", where the wave is absent" : ""}), ` +
|
|
856
1136
|
`digest ${digest}: ${decision.status}${decision.approvedBy ? ` by ${decision.approvedBy}` : ""}` +
|
|
857
1137
|
(decision.via === "pr-review" ? ` (review of #${decision.review?.pr})` : "") +
|
|
858
1138
|
(holds.length > 0 ? `; held by ${holds.join(", ")}` : ""),
|
|
859
1139
|
);
|
|
860
|
-
|
|
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) {
|
|
861
1165
|
log(`The pull request's review does not approve wave ${k}: ${decision.review.reason}`);
|
|
862
1166
|
}
|
|
1167
|
+
const hook = await runGateHooks(ctx, decision, recordPath);
|
|
863
1168
|
if (decision.status === "waiting") {
|
|
864
|
-
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)`);
|
|
865
1170
|
return { ...result, exitCode: 3 };
|
|
866
1171
|
}
|
|
867
|
-
if (
|
|
868
|
-
|
|
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`);
|
|
869
1177
|
return result;
|
|
870
1178
|
}
|
|
871
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
|
+
|
|
872
1329
|
function failed(ctx: WaveContext, line: string): RunOpWaveResult {
|
|
873
1330
|
ctx.log(line);
|
|
874
1331
|
ctx.result.exitCode = 1;
|
|
@@ -883,20 +1340,7 @@ function failed(ctx: WaveContext, line: string): RunOpWaveResult {
|
|
|
883
1340
|
async function settleGate(options: RunOpWaveOptions, ctx: WaveContext, decision: OpWaveDecision): Promise<void> {
|
|
884
1341
|
const { spec, k, wave, cwd } = ctx;
|
|
885
1342
|
const { gate, digest, approval, base, policy, members } = decision;
|
|
886
|
-
const ledger =
|
|
887
|
-
let port = ledger;
|
|
888
|
-
if (approval === "sealed") {
|
|
889
|
-
// #3684: only an approval sealed by a signer listed at base counts.
|
|
890
|
-
const review = await import("./gate-review");
|
|
891
|
-
const sealed = review.sealedApprovalRule(gate, (options.trust ?? ((b) => review.trustPolicyAt(cwd, b)))(base));
|
|
892
|
-
port = {
|
|
893
|
-
...ledger,
|
|
894
|
-
async approvalRule(g) {
|
|
895
|
-
const own = (await ledger.approvalRule?.(g)) ?? null;
|
|
896
|
-
return g === gate ? review.bothRules(own, sealed) : own;
|
|
897
|
-
},
|
|
898
|
-
};
|
|
899
|
-
}
|
|
1343
|
+
const { ledger, port } = await gatePort(options, cwd, gate, approval, base);
|
|
900
1344
|
const now = options.now ?? new Date().toISOString();
|
|
901
1345
|
// #3784: under the spend rule at base, an approval a wave already applied under counts for nothing.
|
|
902
1346
|
const spent: AppliedGateRecord[] | undefined = decision.spend === "base" ? ((await ledger.readApplied?.(spec.name)) ?? []).filter((a) => a.gate === gate) : undefined;
|
|
@@ -905,9 +1349,11 @@ async function settleGate(options: RunOpWaveOptions, ctx: WaveContext, decision:
|
|
|
905
1349
|
op: spec.name,
|
|
906
1350
|
gate,
|
|
907
1351
|
planDigest: digest,
|
|
908
|
-
description: `wave ${k} (${wave.name}): ${members.length} run${members.length === 1 ? "" : "s"}, gate ${policy}, approval ${approval}${held}`,
|
|
1352
|
+
description: (typeof decision.pending?.description === "string" ? decision.pending.description : undefined) ?? `wave ${k} (${wave.name}): ${members.length} run${members.length === 1 ? "" : "s"}, gate ${policy}, approval ${approval}${held}`,
|
|
909
1353
|
now,
|
|
910
1354
|
...(spent ? { spent } : {}),
|
|
1355
|
+
...(decision.pending ? { fields: decision.pending } : {}),
|
|
1356
|
+
...(decision.neverOverMcp ? { neverOverMcp: true } : {}),
|
|
911
1357
|
});
|
|
912
1358
|
if (check.satisfied) {
|
|
913
1359
|
decision.status = "approved";
|
|
@@ -916,6 +1362,11 @@ async function settleGate(options: RunOpWaveOptions, ctx: WaveContext, decision:
|
|
|
916
1362
|
decision.approvedAt = check.resolution.timestamp;
|
|
917
1363
|
return;
|
|
918
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;
|
|
919
1370
|
const used = spent?.filter((a) => isApprovalSpent([a], { gate, planDigest: digest, timestamp: a.approvedAt })).pop();
|
|
920
1371
|
if (used) {
|
|
921
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`);
|
|
@@ -924,6 +1375,7 @@ async function settleGate(options: RunOpWaveOptions, ctx: WaveContext, decision:
|
|
|
924
1375
|
const byReview = await approveByReview(options, { op: spec.name, gate, k, digest, cwd, now, port: ledger, ...(spent ? { spent } : {}) });
|
|
925
1376
|
decision.review = byReview.review;
|
|
926
1377
|
if (byReview.approved) {
|
|
1378
|
+
for (const key of ["pendingFact", "recorded", "waitingSince", "mismatch"] as const) delete decision[key];
|
|
927
1379
|
decision.status = "approved";
|
|
928
1380
|
decision.via = "pr-review";
|
|
929
1381
|
decision.approvedBy = byReview.review.approvers!.join(", ");
|
|
@@ -961,6 +1413,8 @@ async function settleHolds(
|
|
|
961
1413
|
await settleGate(options, ctx, decision);
|
|
962
1414
|
if (recordPath) writeRecord(recordPath, decision, result);
|
|
963
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);
|
|
964
1418
|
log(`wave ${k} (${wave.name}) is held by ${holds.join(", ")}, and nobody approved ${decision.digest}; nothing applied. Approve: ${decision.approve}`);
|
|
965
1419
|
result.exitCode = 3;
|
|
966
1420
|
return false;
|
|
@@ -1082,16 +1536,27 @@ async function applyStage(
|
|
|
1082
1536
|
const byApply = decision.applyHooksRunBy === "apply";
|
|
1083
1537
|
const holds = [...earlierHolds];
|
|
1084
1538
|
if (!byApply) {
|
|
1085
|
-
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);
|
|
1086
1540
|
if (before.failed) {
|
|
1087
1541
|
failed(ctx, `a hook before the apply failed, so nothing in wave ${k} was applied`);
|
|
1088
1542
|
return;
|
|
1089
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
|
+
}
|
|
1090
1554
|
holds.push(...before.holds);
|
|
1091
1555
|
}
|
|
1092
1556
|
if (!(await settleHolds(options, ctx, decision, holds, recordPath))) return;
|
|
1093
1557
|
if (!(await spendApproval(options, ctx, decision))) return;
|
|
1094
|
-
if (
|
|
1558
|
+
if (!(await spendOverrides(ctx, decision))) return;
|
|
1559
|
+
if (recordPath && (decision.spentAt || decision.overridesSpentAt)) writeRecord(recordPath, decision, result);
|
|
1095
1560
|
const problems: string[] = [];
|
|
1096
1561
|
let waiting = false;
|
|
1097
1562
|
if (spec.applyWave) {
|
|
@@ -1141,7 +1606,7 @@ async function applyStage(
|
|
|
1141
1606
|
return;
|
|
1142
1607
|
}
|
|
1143
1608
|
if (!byApply) {
|
|
1144
|
-
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);
|
|
1145
1610
|
if (after.failed) failed(ctx, `wave ${k} (${wave.name}) applied, and then a hook after the apply failed`);
|
|
1146
1611
|
}
|
|
1147
1612
|
if (recordPath && result.hooks.length > 0) writeRecord(recordPath, decision, result);
|
|
@@ -1279,8 +1744,8 @@ export async function recordOpWaveHeadPlans(options: RecordHeadPlansOptions): Pr
|
|
|
1279
1744
|
head,
|
|
1280
1745
|
timestamp: options.now ?? new Date().toISOString(),
|
|
1281
1746
|
waves: spec.waves.map((wave, i) => {
|
|
1282
|
-
const members = planRuns({ spec, k: i + 1, wave, cwd, exec }, wave.runs);
|
|
1283
|
-
return { wave: i + 1, name: wave.name, digest:
|
|
1747
|
+
const { members } = planRuns({ spec, k: i + 1, wave, cwd, exec }, wave.runs);
|
|
1748
|
+
return { wave: i + 1, name: wave.name, digest: opWaveSetDigest(spec, members) };
|
|
1284
1749
|
}),
|
|
1285
1750
|
};
|
|
1286
1751
|
await (options.write ?? ((p) => writeHeadPlans(p, cwd)))(plans);
|