@intentius/chant 0.122.0 → 0.123.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/handlers/gate-resume.d.ts +1 -1
- package/dist/cli/handlers/gate-resume.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts +1 -1
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/override-approve.d.ts +17 -0
- package/dist/cli/handlers/override-approve.d.ts.map +1 -0
- package/dist/cli/handlers/run-wave.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +4 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/gate-decision.d.ts +157 -0
- package/dist/gate-decision.d.ts.map +1 -0
- package/dist/lifecycle/gate-applied.d.ts +75 -0
- package/dist/lifecycle/gate-applied.d.ts.map +1 -0
- package/dist/lifecycle/gate-audit.d.ts +145 -0
- package/dist/lifecycle/gate-audit.d.ts.map +1 -0
- package/dist/lifecycle/gate-ledger-select.d.ts +54 -0
- package/dist/lifecycle/gate-ledger-select.d.ts.map +1 -0
- package/dist/lifecycle/gate-ledger.d.ts +1 -45
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/lifecycle/override-ledger.d.ts +34 -0
- package/dist/lifecycle/override-ledger.d.ts.map +1 -0
- package/dist/lifecycle/rollback.d.ts.map +1 -1
- package/dist/lifecycle/work-item-id.d.ts +22 -0
- package/dist/lifecycle/work-item-id.d.ts.map +1 -0
- package/dist/lifecycle/work-lease.d.ts +2 -2
- package/dist/lifecycle/work-lease.d.ts.map +1 -1
- package/dist/lint/post-synth.d.ts +20 -1
- package/dist/lint/post-synth.d.ts.map +1 -1
- package/dist/notify-verify.d.ts +62 -0
- package/dist/notify-verify.d.ts.map +1 -0
- package/dist/notify.d.ts +202 -0
- package/dist/notify.d.ts.map +1 -0
- package/dist/op/activities/activity-contracts.d.ts +4 -4
- package/dist/op/activities/gh-host.d.ts +37 -0
- package/dist/op/activities/gh-host.d.ts.map +1 -0
- package/dist/op/activities/lexicon-upgrade.d.ts +11 -0
- package/dist/op/activities/lexicon-upgrade.d.ts.map +1 -1
- package/dist/op/activities/propose-upgrade.d.ts.map +1 -1
- package/dist/op/activities/reconcile.d.ts +74 -76
- package/dist/op/activities/reconcile.d.ts.map +1 -1
- package/dist/op/gate-approval.d.ts +17 -1
- package/dist/op/gate-approval.d.ts.map +1 -1
- package/dist/op/gate-name.d.ts +8 -1
- package/dist/op/gate-name.d.ts.map +1 -1
- package/dist/op/gate-resume.d.ts +4 -2
- package/dist/op/gate-resume.d.ts.map +1 -1
- package/dist/op/gate-review.d.ts +2 -1
- package/dist/op/gate-review.d.ts.map +1 -1
- package/dist/op/gate.d.ts +43 -6
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/index.d.ts +8 -6
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/op-waves-notify.d.ts +31 -0
- package/dist/op/op-waves-notify.d.ts.map +1 -0
- package/dist/op/op-waves-run.d.ts +91 -16
- package/dist/op/op-waves-run.d.ts.map +1 -1
- package/dist/op/op-waves.d.ts +99 -2
- package/dist/op/op-waves.d.ts.map +1 -1
- package/dist/op/operator.d.ts +15 -0
- package/dist/op/operator.d.ts.map +1 -1
- package/dist/op/work-lease-decl.d.ts.map +1 -1
- package/dist/op/work-lease-run.d.ts.map +1 -1
- package/dist/override-gate.d.ts +224 -0
- package/dist/override-gate.d.ts.map +1 -0
- package/dist/pr-forge.d.ts +198 -19
- package/dist/pr-forge.d.ts.map +1 -1
- package/dist/workspace/status-gates.d.ts +1 -0
- package/dist/workspace/status-gates.d.ts.map +1 -1
- package/dist/workspace/trust/gate-seal.d.ts +96 -0
- package/dist/workspace/trust/gate-seal.d.ts.map +1 -0
- package/dist/workspace/trust/policy.d.ts +2 -41
- package/dist/workspace/trust/policy.d.ts.map +1 -1
- package/dist/workspace/trust/seal.d.ts +2 -27
- package/dist/workspace/trust/seal.d.ts.map +1 -1
- package/dist/workspace/trust/trust-files.d.ts +57 -0
- package/dist/workspace/trust/trust-files.d.ts.map +1 -0
- package/dist/workspace/work-cli.d.ts.map +1 -1
- package/package.json +2 -1
- package/src/cli/commands/build.ts +1 -1
- package/src/cli/commands/lint.ts +5 -2
- package/src/cli/handlers/__snapshots__/build-member.test.ts.snap +2 -2
- package/src/cli/handlers/gate-resume.ts +3 -2
- package/src/cli/handlers/operator.test.ts +12 -1
- package/src/cli/handlers/operator.ts +25 -13
- package/src/cli/handlers/override-approve.test.ts +87 -0
- package/src/cli/handlers/override-approve.ts +103 -0
- package/src/cli/handlers/run-wave.ts +10 -2
- package/src/cli/main.test.ts +8 -0
- package/src/cli/main.ts +15 -0
- package/src/cli/registry.ts +4 -0
- package/src/gate-decision-bundle.test.ts +56 -0
- package/src/gate-decision.test.ts +237 -0
- package/src/gate-decision.ts +321 -0
- package/src/lifecycle/gate-applied.ts +115 -0
- package/src/lifecycle/gate-audit-bundle.test.ts +47 -0
- package/src/lifecycle/gate-audit.test.ts +134 -0
- package/src/lifecycle/gate-audit.ts +438 -0
- package/src/lifecycle/gate-ledger-select.ts +113 -0
- package/src/lifecycle/gate-ledger.test.ts +22 -0
- package/src/lifecycle/gate-ledger.ts +9 -104
- package/src/lifecycle/override-ledger.test.ts +63 -0
- package/src/lifecycle/override-ledger.ts +69 -0
- package/src/lifecycle/rollback.ts +5 -1
- package/src/lifecycle/work-item-id.test.ts +71 -0
- package/src/lifecycle/work-item-id.ts +35 -0
- package/src/lifecycle/work-lease.ts +4 -5
- package/src/lint/post-synth.test.ts +29 -0
- package/src/lint/post-synth.ts +21 -2
- package/src/notify-bundle.test.ts +50 -0
- package/src/notify-verify.test.ts +59 -0
- package/src/notify-verify.ts +93 -0
- package/src/notify.schema.json +72 -0
- package/src/notify.test.ts +279 -0
- package/src/notify.ts +398 -0
- package/src/op/activities/gh-host.test.ts +130 -0
- package/src/op/activities/gh-host.ts +79 -0
- package/src/op/activities/lexicon-upgrade.ts +22 -1
- package/src/op/activities/propose-upgrade.ts +5 -1
- package/src/op/activities/reconcile.test.ts +284 -280
- package/src/op/activities/reconcile.ts +128 -131
- package/src/op/gate-approval.test.ts +175 -7
- package/src/op/gate-approval.ts +27 -1
- package/src/op/gate-name.ts +10 -2
- package/src/op/gate-resume.ts +6 -3
- package/src/op/gate-review.ts +4 -7
- package/src/op/gate.ts +117 -16
- package/src/op/index.ts +20 -5
- package/src/op/op-waves-notify.test.ts +127 -0
- package/src/op/op-waves-notify.ts +101 -0
- package/src/op/op-waves-run.ts +527 -104
- package/src/op/op-waves.test.ts +383 -3
- package/src/op/op-waves.ts +187 -6
- package/src/op/operator.test.ts +29 -0
- package/src/op/operator.ts +27 -2
- package/src/op/run-context.test.ts +2 -2
- package/src/op/work-lease-decl.ts +4 -4
- package/src/op/work-lease-run.test.ts +5 -0
- package/src/op/work-lease-run.ts +4 -3
- package/src/override-gate-bundle.test.ts +47 -0
- package/src/override-gate.test.ts +239 -0
- package/src/override-gate.ts +395 -0
- package/src/pr-forge-bundle.test.ts +40 -0
- package/src/pr-forge.test.ts +303 -0
- package/src/pr-forge.ts +502 -47
- package/src/workspace/records-write-contract.test.ts +3 -2
- package/src/workspace/status-gates.ts +7 -4
- package/src/workspace/trust/gate-seal.test.ts +156 -0
- package/src/workspace/trust/gate-seal.ts +254 -0
- package/src/workspace/trust/policy.ts +10 -165
- package/src/workspace/trust/seal.ts +19 -34
- package/src/workspace/trust/trust-files.test.ts +50 -0
- package/src/workspace/trust/trust-files.ts +245 -0
- package/src/workspace/work-cli.ts +5 -3
- package/src/workspace/work-history.test.ts +7 -0
package/src/op/op-waves-run.ts
CHANGED
|
@@ -4,7 +4,12 @@
|
|
|
4
4
|
* decides its gate and applies it.
|
|
5
5
|
*
|
|
6
6
|
* - Plan: each run's `plan` command writes a plan file; its `planDigest` is
|
|
7
|
-
* the run's member digest, named by its target.
|
|
7
|
+
* the run's member digest, named by its target. With `planWave` (#3762)
|
|
8
|
+
* one call plans every run and lists each run's plan; the digest and the
|
|
9
|
+
* decision are the same as from per-run plans.
|
|
10
|
+
* - Hooks (#3762): read at base with the policy, run before and after the
|
|
11
|
+
* plan and the apply. One with `onFailure: approve` that fails holds the
|
|
12
|
+
* wave: it waits for an approval of its set digest whatever its policy.
|
|
8
13
|
* - Decide: the wave's policy comes from the spec file at the base commit
|
|
9
14
|
* (`git show <base>:<file>`), so a change cannot loosen its own gate. A
|
|
10
15
|
* wave missing there, or a file missing there, is `always`. A wave that
|
|
@@ -16,27 +21,40 @@
|
|
|
16
21
|
* Under `pr-review` the pull request's pipeline runs `--record-plans`
|
|
17
22
|
* ({@link recordOpWaveHeadPlans}), which plans every wave at the head and
|
|
18
23
|
* records the digests a review of that head approves.
|
|
19
|
-
* -
|
|
24
|
+
* - Spend (#3784): with `approvals.spend` at base, an approval a wave applied
|
|
25
|
+
* under counts for nothing, and the wave records each approval it applies
|
|
26
|
+
* under in `_gates/<name>/applied.jsonl` (`../lifecycle/gate-applied.ts`)
|
|
27
|
+
* just before it applies (a wide wave's deciding job, before its shares).
|
|
28
|
+
* - Apply: each run's `apply` command, or one `applyWave` call for the
|
|
29
|
+
* approved set (#3762). A share job (`--share i`) re-plans its
|
|
20
30
|
* slice first and refuses, without applying it, a run whose plan digest is
|
|
21
31
|
* not the one the deciding job recorded (exit 4).
|
|
22
32
|
*/
|
|
23
33
|
|
|
24
34
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
25
|
-
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
35
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
26
36
|
import { dirname, join, posix } from "node:path";
|
|
27
37
|
import { waveGateName, waveSetDigest, type WaveMember } from "../gated-waves";
|
|
38
|
+
import type { ReviewApproval } from "../gate-decision";
|
|
28
39
|
import { approveCommand, evaluateGate, gitGateLedgerPort, type GateLedgerPort } from "./gate";
|
|
40
|
+
import { isApprovalSpent, type AppliedGateRecord } from "../lifecycle/gate-applied";
|
|
29
41
|
import type { HeadPlans, MergedReview, ReviewSource } from "./gate-review";
|
|
30
42
|
import type { TrustPolicy } from "../workspace/trust/policy";
|
|
31
43
|
import {
|
|
32
44
|
OP_WAVE_DEFAULT_APPLY,
|
|
33
45
|
OP_WAVE_GATE_POLICIES,
|
|
46
|
+
OP_WAVE_HOOK_MOMENTS,
|
|
34
47
|
GATE_APPROVAL_SOURCES,
|
|
48
|
+
assertOpWaveHooks,
|
|
35
49
|
assertOpWavesSpec,
|
|
50
|
+
opWaveHooksAt,
|
|
36
51
|
opWaveRecordPath,
|
|
37
52
|
opWaveShare,
|
|
38
53
|
type GateApprovalSource,
|
|
54
|
+
type OpWave,
|
|
39
55
|
type OpWaveGatePolicy,
|
|
56
|
+
type OpWaveHook,
|
|
57
|
+
type OpWaveHookMoment,
|
|
40
58
|
type OpWaveRun,
|
|
41
59
|
type OpWavesSpec,
|
|
42
60
|
} from "./op-waves";
|
|
@@ -50,6 +68,23 @@ export interface OpWavePlanFile {
|
|
|
50
68
|
empty?: boolean;
|
|
51
69
|
}
|
|
52
70
|
|
|
71
|
+
/** What a wave-level plan command writes to `{plans}` (#3762): one plan per run, by target. The array alone is read too. */
|
|
72
|
+
export interface OpWavePlansFile {
|
|
73
|
+
runs: Array<OpWavePlanFile & { target: string }>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** What chant writes to `{runs}` for a wave-level command or a hook (#3762). */
|
|
77
|
+
export interface OpWaveRunsFile {
|
|
78
|
+
/** The wave's name. */
|
|
79
|
+
wave: string;
|
|
80
|
+
/** 1-based. */
|
|
81
|
+
number: number;
|
|
82
|
+
/** The wave's set digest, once it is decided. On `applyWave`, the digest the set was approved or let through at. */
|
|
83
|
+
digest?: string;
|
|
84
|
+
/** Each run, with its plan once planned. On `applyWave`, the runs to apply. */
|
|
85
|
+
runs: Array<{ target: string; op: string; planDigest?: string; destructive?: boolean; empty?: boolean }>;
|
|
86
|
+
}
|
|
87
|
+
|
|
53
88
|
/** One run's plan, as the decision records it. */
|
|
54
89
|
export interface OpWaveMember extends WaveMember {
|
|
55
90
|
op: string;
|
|
@@ -84,13 +119,38 @@ export interface OpWaveDecision {
|
|
|
84
119
|
review?: { pr?: number; head?: string; approvers?: string[]; url?: string; reason?: string };
|
|
85
120
|
/** The command that approves this digest, when waiting. */
|
|
86
121
|
approve?: string;
|
|
122
|
+
/** The hooks read at base (#3762), which a wide wave's share jobs run too. Absent when there are none. */
|
|
123
|
+
hooks?: Partial<Record<OpWaveHookMoment, OpWaveHook[]>>;
|
|
124
|
+
/** The `onFailure: approve` hooks that failed: the wave needs an approval whatever its policy (#3762). */
|
|
125
|
+
heldBy?: string[];
|
|
126
|
+
/**
|
|
127
|
+
* Spent approvals (#3784). `base`: the spec at base sets `approvals.spend`,
|
|
128
|
+
* so a spent approval counts for nothing and the approval this wave applies
|
|
129
|
+
* under is recorded. `change`: only the change sets it, so the approval is
|
|
130
|
+
* recorded and spent from the next decision on. Absent: neither.
|
|
131
|
+
*/
|
|
132
|
+
spend?: "base" | "change";
|
|
133
|
+
/** The approval's own timestamp: what the applied record names. */
|
|
134
|
+
approvedAt?: string;
|
|
135
|
+
/** When the wave recorded that it applies under the approval, which spent it. */
|
|
136
|
+
spentAt?: string;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** One hook's run (#3762). */
|
|
140
|
+
export interface OpWaveHookOutcome {
|
|
141
|
+
moment: OpWaveHookMoment;
|
|
142
|
+
name: string;
|
|
143
|
+
/** The run, for a `perRun` hook. */
|
|
144
|
+
target?: string;
|
|
145
|
+
exit: number;
|
|
146
|
+
outcome: "passed" | "failed" | "held";
|
|
87
147
|
}
|
|
88
148
|
|
|
89
|
-
/** Runs one argv; returns its exit code. Injected so tests run no processes. */
|
|
90
|
-
export type OpWaveExec = (argv: string[], cwd: string) => number;
|
|
149
|
+
/** Runs one argv; returns its exit code. `env` is added to the job's environment. Injected so tests run no processes. */
|
|
150
|
+
export type OpWaveExec = (argv: string[], cwd: string, env?: Record<string, string>) => number;
|
|
91
151
|
|
|
92
|
-
const spawnExec: OpWaveExec = (argv, cwd) => {
|
|
93
|
-
const result = spawnSync(argv[0]!, argv.slice(1), { cwd, stdio: "inherit" });
|
|
152
|
+
const spawnExec: OpWaveExec = (argv, cwd, env) => {
|
|
153
|
+
const result = spawnSync(argv[0]!, argv.slice(1), { cwd, stdio: "inherit", ...(env ? { env: { ...process.env, ...env } } : {}) });
|
|
94
154
|
if (result.error) throw result.error;
|
|
95
155
|
return result.status ?? 1;
|
|
96
156
|
};
|
|
@@ -132,10 +192,29 @@ export function parseOpWavesSpec(text: string, where: string): OpWavesSpec {
|
|
|
132
192
|
return spec;
|
|
133
193
|
}
|
|
134
194
|
|
|
195
|
+
/** What {@link readOpWavePolicy} read at base. */
|
|
196
|
+
export interface OpWaveBaseRules {
|
|
197
|
+
policy: OpWaveGatePolicy;
|
|
198
|
+
approval: GateApprovalSource;
|
|
199
|
+
source: OpWavePolicySource;
|
|
200
|
+
base: string;
|
|
201
|
+
/** The wave's hooks at base, by moment (#3762): the spec's, then the wave's. None when the file or the wave is absent there. */
|
|
202
|
+
hooks: Record<OpWaveHookMoment, OpWaveHook[]>;
|
|
203
|
+
/** Set when the hooks at base could not be read: none run, and the wave waits for a person. */
|
|
204
|
+
hooksRefused?: string;
|
|
205
|
+
/** The spec at base sets `approvals.spend` (#3784). */
|
|
206
|
+
spend: boolean;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const noHooks = (): Record<OpWaveHookMoment, OpWaveHook[]> =>
|
|
210
|
+
Object.fromEntries(OP_WAVE_HOOK_MOMENTS.map((m) => [m, []])) as unknown as Record<OpWaveHookMoment, OpWaveHook[]>;
|
|
211
|
+
|
|
135
212
|
/**
|
|
136
213
|
* The policy of wave `name`, as the spec file at `base` declares it. The
|
|
137
214
|
* file or the wave missing there reads as `always`: a change that adds a
|
|
138
|
-
* wave, or the file, waits for a person the first time.
|
|
215
|
+
* wave, or the file, waits for a person the first time. Its stage hooks are
|
|
216
|
+
* read there too (#3762), so a change cannot add, edit or remove a hook its
|
|
217
|
+
* own apply runs; the file or the wave missing there has none.
|
|
139
218
|
*/
|
|
140
219
|
export function readOpWavePolicy(
|
|
141
220
|
specFile: string,
|
|
@@ -143,9 +222,9 @@ export function readOpWavePolicy(
|
|
|
143
222
|
base: string,
|
|
144
223
|
cwd: string,
|
|
145
224
|
show: OpWaveShowAtBase = gitShowAtBase,
|
|
146
|
-
):
|
|
225
|
+
): OpWaveBaseRules {
|
|
147
226
|
const { sha, text } = show(base, specFile.replace(/^\.\//, "").split("\\").join(posix.sep), cwd);
|
|
148
|
-
const absent = { policy: "always" as const, approval: "ledger" as const, source: "absent-at-base" as const, base: sha };
|
|
227
|
+
const absent = { policy: "always" as const, approval: "ledger" as const, source: "absent-at-base" as const, base: sha, hooks: noHooks(), spend: false };
|
|
149
228
|
if (text === null) return absent;
|
|
150
229
|
let spec: OpWavesSpec;
|
|
151
230
|
try {
|
|
@@ -153,19 +232,39 @@ export function readOpWavePolicy(
|
|
|
153
232
|
} catch {
|
|
154
233
|
return absent;
|
|
155
234
|
}
|
|
235
|
+
// The spend rule is the spec's, so it holds for a wave the base does not have yet.
|
|
236
|
+
const spend = (spec as { approvals?: { spend?: unknown } }).approvals?.spend === true;
|
|
156
237
|
const wave = spec.waves.find((w) => w.name === name);
|
|
157
|
-
if (!wave) return absent;
|
|
238
|
+
if (!wave) return { ...absent, spend };
|
|
158
239
|
const policy = wave.gate ?? "always";
|
|
159
240
|
// An approval mode this chant does not know reads as sealed: the strictest, so an unknown rule never loosens a gate.
|
|
160
241
|
const approval = wave.approval === undefined ? "ledger" : GATE_APPROVAL_SOURCES.includes(wave.approval) ? wave.approval : "sealed";
|
|
161
|
-
|
|
242
|
+
const rules: OpWaveBaseRules = { policy: OP_WAVE_GATE_POLICIES.includes(policy) ? policy : "always", approval, source: "base", base: sha, hooks: noHooks(), spend };
|
|
243
|
+
try {
|
|
244
|
+
assertOpWaveHooks(`${specFile} at ${sha.slice(0, 12)}`, spec.hooks);
|
|
245
|
+
assertOpWaveHooks(`${specFile} at ${sha.slice(0, 12)}, wave "${name}"`, wave.hooks);
|
|
246
|
+
rules.hooks = opWaveHooksAt(spec, name);
|
|
247
|
+
} catch (err) {
|
|
248
|
+
// A hook at base no runner can run: none run, and the wave waits for a person rather than going out without them.
|
|
249
|
+
rules.hooksRefused = `the hooks at base cannot be run (${err instanceof Error ? err.message : String(err)})`;
|
|
250
|
+
}
|
|
251
|
+
return rules;
|
|
162
252
|
}
|
|
163
253
|
|
|
164
|
-
/** Fill one argv template
|
|
165
|
-
export function opWaveArgv(
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
254
|
+
/** Fill one argv template. A placeholder whose value is not given is left as it is. */
|
|
255
|
+
export function opWaveArgv(
|
|
256
|
+
template: readonly string[],
|
|
257
|
+
values: { op: string; wave: string; target?: string; plan?: string; runs?: string; plans?: string },
|
|
258
|
+
): string[] {
|
|
259
|
+
const pairs: Array<[string, string | undefined]> = [
|
|
260
|
+
["{op}", values.op],
|
|
261
|
+
["{target}", values.target],
|
|
262
|
+
["{wave}", values.wave],
|
|
263
|
+
["{plans}", values.plans],
|
|
264
|
+
["{plan}", values.plan],
|
|
265
|
+
["{runs}", values.runs],
|
|
266
|
+
];
|
|
267
|
+
return template.map((part) => pairs.reduce((out, [key, value]) => (value === undefined ? out : out.replaceAll(key, value)), part));
|
|
169
268
|
}
|
|
170
269
|
|
|
171
270
|
/** Where a run's plan file goes. */
|
|
@@ -173,6 +272,16 @@ export function opWavePlanPath(spec: Pick<OpWavesSpec, "name">, wave: number, ta
|
|
|
173
272
|
return `.chant/op-waves/${spec.name}/wave-${wave}/${target.replace(/[^A-Za-z0-9._-]/g, "_")}.plan.json`;
|
|
174
273
|
}
|
|
175
274
|
|
|
275
|
+
/** Where a wave's runs file goes: the `{runs}` of a wave-level command or a hook (#3762). */
|
|
276
|
+
export function opWaveRunsPath(spec: Pick<OpWavesSpec, "name">, wave: number): string {
|
|
277
|
+
return `.chant/op-waves/${spec.name}/wave-${wave}/runs.json`;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Where a wave-level plan command writes its plans: its `{plans}` (#3762). */
|
|
281
|
+
export function opWavePlansPath(spec: Pick<OpWavesSpec, "name">, wave: number): string {
|
|
282
|
+
return `.chant/op-waves/${spec.name}/wave-${wave}/plans.json`;
|
|
283
|
+
}
|
|
284
|
+
|
|
176
285
|
export interface RunOpWaveOptions {
|
|
177
286
|
spec: OpWavesSpec;
|
|
178
287
|
specFile: string;
|
|
@@ -201,24 +310,33 @@ export interface RunOpWaveOptions {
|
|
|
201
310
|
}
|
|
202
311
|
|
|
203
312
|
export interface RunOpWaveResult {
|
|
204
|
-
/** 0 applied (or decided), 1 a run failed, 3 waiting for an approval, 4 a plan moved since the decision. */
|
|
313
|
+
/** 0 applied (or decided), 1 a run or a hook failed, 3 waiting for an approval, 4 a plan moved since the decision. */
|
|
205
314
|
exitCode: number;
|
|
206
315
|
decision?: OpWaveDecision;
|
|
207
316
|
applied: string[];
|
|
208
317
|
failed: string[];
|
|
209
318
|
/** Runs a share job refused because their plan moved since the decision. */
|
|
210
319
|
moved: string[];
|
|
320
|
+
/** The hooks that ran (#3762). */
|
|
321
|
+
hooks: OpWaveHookOutcome[];
|
|
211
322
|
}
|
|
212
323
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
324
|
+
/** What one wave job works with. */
|
|
325
|
+
interface WaveContext {
|
|
326
|
+
spec: OpWavesSpec;
|
|
327
|
+
k: number;
|
|
328
|
+
wave: OpWave;
|
|
329
|
+
cwd: string;
|
|
330
|
+
exec: OpWaveExec;
|
|
331
|
+
log: (line: string) => void;
|
|
332
|
+
result: RunOpWaveResult;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function planRun(opts: { spec: OpWavesSpec; wave: number; cwd: string; exec: OpWaveExec }, waveName: string, run: OpWaveRun): OpWaveMember {
|
|
218
336
|
const op = run.op ?? opts.spec.op;
|
|
219
337
|
const plan = opWavePlanPath(opts.spec, opts.wave, run.target);
|
|
220
338
|
mkdirSync(dirname(join(opts.cwd, plan)), { recursive: true });
|
|
221
|
-
const code = opts.exec(opWaveArgv(opts.spec.plan
|
|
339
|
+
const code = opts.exec(opWaveArgv(opts.spec.plan!, { op, target: run.target, wave: waveName, plan }), opts.cwd);
|
|
222
340
|
if (code !== 0) throw new Error(`planning ${run.target} exited ${code}`);
|
|
223
341
|
let file: OpWavePlanFile;
|
|
224
342
|
try {
|
|
@@ -229,8 +347,12 @@ function planRun(
|
|
|
229
347
|
if (typeof file.planDigest !== "string" || file.planDigest === "") {
|
|
230
348
|
throw new Error(`the plan file for ${run.target} (${plan}) has no planDigest`);
|
|
231
349
|
}
|
|
350
|
+
return member(run.target, op, file);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function member(target: string, op: string, file: OpWavePlanFile): OpWaveMember {
|
|
232
354
|
return {
|
|
233
|
-
member:
|
|
355
|
+
member: target,
|
|
234
356
|
planDigest: file.planDigest,
|
|
235
357
|
op,
|
|
236
358
|
...(file.destructive === true ? { destructive: true } : {}),
|
|
@@ -238,6 +360,141 @@ function planRun(
|
|
|
238
360
|
};
|
|
239
361
|
}
|
|
240
362
|
|
|
363
|
+
/** Write the `{runs}` file: the runs, with their plans once planned. */
|
|
364
|
+
function writeRunsFile(
|
|
365
|
+
at: { spec: OpWavesSpec; k: number; wave: OpWave; cwd: string },
|
|
366
|
+
runs: readonly OpWaveRun[],
|
|
367
|
+
members?: ReadonlyMap<string, OpWaveMember>,
|
|
368
|
+
digest?: string,
|
|
369
|
+
): string {
|
|
370
|
+
const path = opWaveRunsPath(at.spec, at.k);
|
|
371
|
+
const file: OpWaveRunsFile = {
|
|
372
|
+
wave: at.wave.name,
|
|
373
|
+
number: at.k,
|
|
374
|
+
...(digest ? { digest } : {}),
|
|
375
|
+
runs: runs.map((run) => {
|
|
376
|
+
const m = members?.get(run.target);
|
|
377
|
+
return {
|
|
378
|
+
target: run.target,
|
|
379
|
+
op: run.op ?? at.spec.op,
|
|
380
|
+
...(m ? { planDigest: m.planDigest, ...(m.destructive ? { destructive: true } : {}), ...(m.empty ? { empty: true } : {}) } : {}),
|
|
381
|
+
};
|
|
382
|
+
}),
|
|
383
|
+
};
|
|
384
|
+
mkdirSync(dirname(join(at.cwd, path)), { recursive: true });
|
|
385
|
+
writeFileSync(join(at.cwd, path), JSON.stringify(file, null, 2) + "\n");
|
|
386
|
+
return path;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Plan `runs` of wave `k`: one `plan` call per run, or one `planWave` call
|
|
391
|
+
* for all of them (#3762). A wave plan is written out as each run's plan file
|
|
392
|
+
* too, so a per-run `apply` or hook reads `{plan}` either way.
|
|
393
|
+
*/
|
|
394
|
+
function planRuns(at: { spec: OpWavesSpec; k: number; wave: OpWave; cwd: string; exec: OpWaveExec }, runs: readonly OpWaveRun[]): OpWaveMember[] {
|
|
395
|
+
const { spec, k, wave, cwd, exec } = at;
|
|
396
|
+
if (!spec.planWave) return runs.map((run) => planRun({ spec, wave: k, cwd, exec }, wave.name, run));
|
|
397
|
+
const runsPath = writeRunsFile(at, runs);
|
|
398
|
+
const plans = opWavePlansPath(spec, k);
|
|
399
|
+
rmSync(join(cwd, plans), { force: true });
|
|
400
|
+
const code = exec(opWaveArgv(spec.planWave, { op: spec.op, wave: wave.name, runs: runsPath, plans }), cwd);
|
|
401
|
+
if (code !== 0) throw new Error(`planning wave ${k} (${wave.name}) exited ${code}`);
|
|
402
|
+
let parsed: unknown;
|
|
403
|
+
try {
|
|
404
|
+
parsed = JSON.parse(readFileSync(join(cwd, plans), "utf-8"));
|
|
405
|
+
} catch (err) {
|
|
406
|
+
throw new Error(`planning wave ${k} (${wave.name}) wrote no readable plans file at ${plans}: ${err instanceof Error ? err.message : String(err)}`);
|
|
407
|
+
}
|
|
408
|
+
const entries = Array.isArray(parsed) ? parsed : (parsed as { runs?: unknown } | null)?.runs;
|
|
409
|
+
if (!Array.isArray(entries)) throw new Error(`the plans file of wave ${k} (${plans}) must hold { "runs": [{ "target", "planDigest" }] }`);
|
|
410
|
+
const wanted = new Set(runs.map((r) => r.target));
|
|
411
|
+
const byTarget = new Map<string, OpWavePlanFile>();
|
|
412
|
+
for (const entry of entries as Array<Partial<OpWavePlanFile & { target: string }>>) {
|
|
413
|
+
const target = entry?.target;
|
|
414
|
+
if (typeof target !== "string" || !wanted.has(target)) {
|
|
415
|
+
throw new Error(`the plans file of wave ${k} (${plans}) plans ${JSON.stringify(target)}, which is not a run it was asked to plan`);
|
|
416
|
+
}
|
|
417
|
+
if (byTarget.has(target)) throw new Error(`the plans file of wave ${k} (${plans}) plans ${target} twice`);
|
|
418
|
+
if (typeof entry.planDigest !== "string" || entry.planDigest === "") throw new Error(`the plans file of wave ${k} (${plans}) has no planDigest for ${target}`);
|
|
419
|
+
byTarget.set(target, entry as OpWavePlanFile);
|
|
420
|
+
}
|
|
421
|
+
const missing = runs.filter((r) => !byTarget.has(r.target)).map((r) => r.target);
|
|
422
|
+
if (missing.length > 0) throw new Error(`the plans file of wave ${k} (${plans}) has no plan for ${missing.join(", ")}`);
|
|
423
|
+
return runs.map((run) => {
|
|
424
|
+
const m = member(run.target, run.op ?? spec.op, byTarget.get(run.target)!);
|
|
425
|
+
const path = join(cwd, opWavePlanPath(spec, k, run.target));
|
|
426
|
+
writeFileSync(path, JSON.stringify({ planDigest: m.planDigest, ...(m.destructive ? { destructive: true } : {}), ...(m.empty ? { empty: true } : {}) }) + "\n");
|
|
427
|
+
return m;
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** What one moment's hooks came to. */
|
|
432
|
+
interface HooksOutcome {
|
|
433
|
+
/** Set when a hook with `onFailure: fail` failed; the hooks after it did not run. */
|
|
434
|
+
failed?: string;
|
|
435
|
+
/** The `onFailure: approve` hooks that failed. */
|
|
436
|
+
holds: string[];
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Run one moment's hooks (#3762), in order: each once for the wave, or once
|
|
441
|
+
* per run for a `perRun` hook. A hook that fails stops the rest, unless its
|
|
442
|
+
* `onFailure` is `approve`: then it holds the wave and the hooks after it run.
|
|
443
|
+
*/
|
|
444
|
+
function runHooks(
|
|
445
|
+
ctx: WaveContext,
|
|
446
|
+
moment: OpWaveHookMoment,
|
|
447
|
+
hooks: readonly OpWaveHook[] | undefined,
|
|
448
|
+
runs: readonly OpWaveRun[],
|
|
449
|
+
members?: ReadonlyMap<string, OpWaveMember>,
|
|
450
|
+
digest?: string,
|
|
451
|
+
): HooksOutcome {
|
|
452
|
+
const out: HooksOutcome = { holds: [] };
|
|
453
|
+
if (!hooks || hooks.length === 0) return out;
|
|
454
|
+
const { spec, k, wave, cwd, exec, log } = ctx;
|
|
455
|
+
const runsPath = writeRunsFile(ctx, runs, members, digest);
|
|
456
|
+
const env = { CHANT_OP_WAVES: spec.name, CHANT_WAVE: wave.name, CHANT_WAVE_NUMBER: String(k), CHANT_HOOK: moment, CHANT_RUNS: runsPath };
|
|
457
|
+
for (const hook of hooks) {
|
|
458
|
+
const name = hook.name ?? (hook.command.join(" ").length > 60 ? `${hook.command.join(" ").slice(0, 57)}...` : hook.command.join(" "));
|
|
459
|
+
const calls = hook.perRun
|
|
460
|
+
? runs.map((run) => {
|
|
461
|
+
const plan = members?.has(run.target) ? opWavePlanPath(spec, k, run.target) : undefined;
|
|
462
|
+
return {
|
|
463
|
+
target: run.target as string | undefined,
|
|
464
|
+
argv: opWaveArgv(hook.command, { op: run.op ?? spec.op, wave: wave.name, target: run.target, runs: runsPath, ...(plan ? { plan } : {}) }),
|
|
465
|
+
env: { ...env, CHANT_TARGET: run.target, ...(plan ? { CHANT_PLAN: plan } : {}) } as Record<string, string>,
|
|
466
|
+
};
|
|
467
|
+
})
|
|
468
|
+
: [{ target: undefined, argv: opWaveArgv(hook.command, { op: spec.op, wave: wave.name, runs: runsPath }), env: env as Record<string, string> }];
|
|
469
|
+
for (const call of calls) {
|
|
470
|
+
const label = `${moment} hook ${name}${call.target ? ` (${call.target})` : ""}`;
|
|
471
|
+
let code: number;
|
|
472
|
+
try {
|
|
473
|
+
code = exec(call.argv, cwd, call.env);
|
|
474
|
+
} catch (err) {
|
|
475
|
+
log(`${label} did not run: ${err instanceof Error ? err.message : String(err)}`);
|
|
476
|
+
code = 127;
|
|
477
|
+
}
|
|
478
|
+
const base = { moment, name, ...(call.target ? { target: call.target } : {}), exit: code };
|
|
479
|
+
if (code === 0) {
|
|
480
|
+
ctx.result.hooks.push({ ...base, outcome: "passed" });
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
if (hook.onFailure === "approve") {
|
|
484
|
+
ctx.result.hooks.push({ ...base, outcome: "held" });
|
|
485
|
+
out.holds.push(label);
|
|
486
|
+
log(`${label} exited ${code}, so wave ${k} (${wave.name}) waits for an approval of its set digest`);
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
ctx.result.hooks.push({ ...base, outcome: "failed" });
|
|
490
|
+
out.failed = `${label} exited ${code}`;
|
|
491
|
+
log(out.failed);
|
|
492
|
+
return out;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
return out;
|
|
496
|
+
}
|
|
497
|
+
|
|
241
498
|
/** Whether `policy` lets a wave with these members through without an approval. */
|
|
242
499
|
export function opWaveNeedsApproval(policy: OpWaveGatePolicy, members: readonly OpWaveMember[]): boolean {
|
|
243
500
|
if (policy === "never") return false;
|
|
@@ -246,6 +503,14 @@ export function opWaveNeedsApproval(policy: OpWaveGatePolicy, members: readonly
|
|
|
246
503
|
return true;
|
|
247
504
|
}
|
|
248
505
|
|
|
506
|
+
/** Log when the change's hooks are not the ones at base, which are the ones that run. */
|
|
507
|
+
function noteHooksAtBase(ctx: WaveContext, atBase: Record<OpWaveHookMoment, OpWaveHook[]>, base: string): void {
|
|
508
|
+
const own = opWaveHooksAt(ctx.spec, ctx.wave.name);
|
|
509
|
+
if (JSON.stringify(own) !== JSON.stringify(atBase)) {
|
|
510
|
+
ctx.log(`wave ${ctx.k} (${ctx.wave.name}): its hooks are read at ${base.slice(0, 12)}, and this change's differ; the ones at base run`);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
249
514
|
/** Plan, decide and apply one wave, or one part of a wide wave. */
|
|
250
515
|
export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveResult> {
|
|
251
516
|
const { spec, wave: k } = options;
|
|
@@ -259,9 +524,9 @@ export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveRes
|
|
|
259
524
|
if (options.share !== undefined && (options.share < 1 || options.share > shares)) {
|
|
260
525
|
throw new Error(`wave ${k} (${wave.name}) has ${shares} share${shares === 1 ? "" : "s"}; there is no share ${options.share}.`);
|
|
261
526
|
}
|
|
262
|
-
const ctx = { spec, wave: k, cwd, exec };
|
|
263
527
|
const recordPath = join(cwd, opWaveRecordPath(spec, k));
|
|
264
|
-
const result: RunOpWaveResult = { exitCode: 0, applied: [], failed: [], moved: [] };
|
|
528
|
+
const result: RunOpWaveResult = { exitCode: 0, applied: [], failed: [], moved: [], hooks: [] };
|
|
529
|
+
const ctx: WaveContext = { spec, k, wave, cwd, exec, log, result };
|
|
265
530
|
|
|
266
531
|
if (options.share !== undefined) {
|
|
267
532
|
let decision: OpWaveDecision;
|
|
@@ -275,78 +540,79 @@ export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveRes
|
|
|
275
540
|
log(`wave ${k} (${wave.name}) is waiting on ${decision.gate}; nothing applied. ${decision.approve ?? ""}`.trim());
|
|
276
541
|
return { ...result, exitCode: 3 };
|
|
277
542
|
}
|
|
543
|
+
// The share runs the hooks its deciding job read at base, around its own slice.
|
|
544
|
+
const slice = opWaveShare(wave.runs, options.share, shares);
|
|
545
|
+
const before = runHooks(ctx, "before-plan", decision.hooks?.["before-plan"], slice);
|
|
546
|
+
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`);
|
|
547
|
+
const members = planRuns(ctx, slice);
|
|
548
|
+
const planned = new Map(members.map((m) => [m.member, m]));
|
|
549
|
+
const after = runHooks(ctx, "after-plan", decision.hooks?.["after-plan"], slice, planned);
|
|
550
|
+
if (after.failed) return failed(ctx, `a hook after the plan failed, so nothing in share ${options.share} of wave ${k} was applied`);
|
|
278
551
|
const decided = new Map(decision.members.map((m) => [m.member, m.planDigest]));
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
552
|
+
const unmoved: OpWaveRun[] = [];
|
|
553
|
+
for (const run of slice) {
|
|
554
|
+
const now = planned.get(run.target)!;
|
|
555
|
+
if (decided.get(run.target) !== now.planDigest) {
|
|
282
556
|
log(
|
|
283
557
|
`${run.target}: its plan moved since wave ${k} was decided, so it was not applied. ` +
|
|
284
|
-
`decided: ${decided.get(run.target) ?? "(not in the decision)"}; planned now: ${
|
|
558
|
+
`decided: ${decided.get(run.target) ?? "(not in the decision)"}; planned now: ${now.planDigest}`,
|
|
285
559
|
);
|
|
286
560
|
result.moved.push(run.target);
|
|
287
561
|
continue;
|
|
288
562
|
}
|
|
289
|
-
|
|
563
|
+
unmoved.push(run);
|
|
290
564
|
}
|
|
291
|
-
|
|
565
|
+
await applyStage(options, ctx, decision, unmoved, planned, [...before.holds, ...after.holds]);
|
|
566
|
+
if (result.exitCode === 0 && result.moved.length > 0) result.exitCode = 4;
|
|
292
567
|
return result;
|
|
293
568
|
}
|
|
294
569
|
|
|
295
|
-
|
|
570
|
+
// The policy, the approval mode and the hooks, read at base before anything runs.
|
|
571
|
+
const rules = readOpWavePolicy(options.specFile, wave.name, options.base ?? spec.base ?? "HEAD^1", cwd, options.show);
|
|
572
|
+
const { policy, approval, source, base } = rules;
|
|
573
|
+
noteHooksAtBase(ctx, rules.hooks, base);
|
|
574
|
+
const holds: string[] = [];
|
|
575
|
+
if (rules.hooksRefused) {
|
|
576
|
+
holds.push(rules.hooksRefused);
|
|
577
|
+
log(`wave ${k} (${wave.name}): ${rules.hooksRefused}, so none run and the wave waits for an approval`);
|
|
578
|
+
}
|
|
579
|
+
const before = runHooks(ctx, "before-plan", rules.hooks["before-plan"], wave.runs);
|
|
580
|
+
if (before.failed) return failed(ctx, `a hook before the plan failed, so nothing in wave ${k} was planned or applied`);
|
|
581
|
+
const members = planRuns(ctx, wave.runs);
|
|
582
|
+
const planned = new Map(members.map((m) => [m.member, m]));
|
|
296
583
|
const digest = waveSetDigest(members);
|
|
297
|
-
const
|
|
584
|
+
const after = runHooks(ctx, "after-plan", rules.hooks["after-plan"], wave.runs, planned, digest);
|
|
585
|
+
if (after.failed) return failed(ctx, `a hook after the plan failed, so nothing in wave ${k} was applied`);
|
|
586
|
+
holds.push(...before.holds, ...after.holds);
|
|
298
587
|
const gate = waveGateName(spec.gate ?? spec.name, k);
|
|
299
|
-
const
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
planDigest: digest,
|
|
320
|
-
description: `wave ${k} (${wave.name}): ${members.length} run${members.length === 1 ? "" : "s"}, gate ${policy}, approval ${approval}`,
|
|
321
|
-
now,
|
|
322
|
-
});
|
|
323
|
-
if (check.satisfied) {
|
|
324
|
-
decision.status = "approved";
|
|
325
|
-
decision.via = "ledger";
|
|
326
|
-
decision.approvedBy = check.resolution.resolvedBy;
|
|
327
|
-
} else if (approval === "pr-review") {
|
|
328
|
-
const byReview = await approveByReview(options, { op: spec.name, gate, k, digest, cwd, now, port: ledger });
|
|
329
|
-
decision.review = byReview.review;
|
|
330
|
-
if (byReview.approved) {
|
|
331
|
-
decision.status = "approved";
|
|
332
|
-
decision.via = "pr-review";
|
|
333
|
-
decision.approvedBy = byReview.review.approvers!.join(", ");
|
|
334
|
-
} else {
|
|
335
|
-
decision.status = "waiting";
|
|
336
|
-
decision.approve = approveCommand(spec.name, gate, undefined, digest);
|
|
337
|
-
}
|
|
338
|
-
} else {
|
|
339
|
-
decision.status = "waiting";
|
|
340
|
-
decision.approve = approveCommand(spec.name, gate, undefined, digest, approval === "sealed");
|
|
341
|
-
}
|
|
342
|
-
}
|
|
588
|
+
const hooks = Object.fromEntries(Object.entries(rules.hooks).filter(([, list]) => list.length > 0));
|
|
589
|
+
const decision: OpWaveDecision = {
|
|
590
|
+
wave: k,
|
|
591
|
+
name: wave.name,
|
|
592
|
+
op: spec.name,
|
|
593
|
+
gate,
|
|
594
|
+
policy,
|
|
595
|
+
approval,
|
|
596
|
+
policySource: source,
|
|
597
|
+
base,
|
|
598
|
+
digest,
|
|
599
|
+
members,
|
|
600
|
+
status: "not-required",
|
|
601
|
+
...(Object.keys(hooks).length > 0 ? { hooks } : {}),
|
|
602
|
+
...(holds.length > 0 ? { heldBy: holds } : {}),
|
|
603
|
+
...(rules.spend ? { spend: "base" as const } : spec.approvals?.spend === true ? { spend: "change" as const } : {}),
|
|
604
|
+
};
|
|
605
|
+
if (opWaveNeedsApproval(policy, members) || holds.length > 0) await settleGate(options, ctx, decision);
|
|
606
|
+
// A wide wave's shares apply under the deciding job's approval, so it is spent here, once.
|
|
607
|
+
if (options.decide && !(await spendApproval(options, ctx, decision))) return ctx.result;
|
|
343
608
|
mkdirSync(dirname(recordPath), { recursive: true });
|
|
344
609
|
writeFileSync(recordPath, JSON.stringify(decision, null, 2) + "\n");
|
|
345
610
|
result.decision = decision;
|
|
346
611
|
log(
|
|
347
612
|
`wave ${k} (${wave.name}): gate ${policy} (read at ${base.slice(0, 12)}${source === "absent-at-base" ? ", where the wave is absent" : ""}), ` +
|
|
348
613
|
`digest ${digest}: ${decision.status}${decision.approvedBy ? ` by ${decision.approvedBy}` : ""}` +
|
|
349
|
-
(decision.via === "pr-review" ? ` (review of #${decision.review?.pr})` : "")
|
|
614
|
+
(decision.via === "pr-review" ? ` (review of #${decision.review?.pr})` : "") +
|
|
615
|
+
(holds.length > 0 ? `; held by ${holds.join(", ")}` : ""),
|
|
350
616
|
);
|
|
351
617
|
if (approval === "pr-review" && decision.status === "waiting" && decision.review?.reason) {
|
|
352
618
|
log(`The pull request's review does not approve wave ${k}: ${decision.review.reason}`);
|
|
@@ -356,11 +622,140 @@ export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveRes
|
|
|
356
622
|
return { ...result, exitCode: 3 };
|
|
357
623
|
}
|
|
358
624
|
if (options.decide) return result;
|
|
359
|
-
|
|
360
|
-
result.exitCode = result.failed.length > 0 ? 1 : 0;
|
|
625
|
+
await applyStage(options, ctx, decision, wave.runs, planned, [], recordPath);
|
|
361
626
|
return result;
|
|
362
627
|
}
|
|
363
628
|
|
|
629
|
+
function failed(ctx: WaveContext, line: string): RunOpWaveResult {
|
|
630
|
+
ctx.log(line);
|
|
631
|
+
ctx.result.exitCode = 1;
|
|
632
|
+
return ctx.result;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Decide whether an approval of the decision's digest stands, whatever its
|
|
637
|
+
* policy: sets its status to `approved` or `waiting`. Called when the policy
|
|
638
|
+
* asks for one, or when a hook holds the wave (#3762).
|
|
639
|
+
*/
|
|
640
|
+
async function settleGate(options: RunOpWaveOptions, ctx: WaveContext, decision: OpWaveDecision): Promise<void> {
|
|
641
|
+
const { spec, k, wave, cwd } = ctx;
|
|
642
|
+
const { gate, digest, approval, base, policy, members } = decision;
|
|
643
|
+
const ledger = options.gates ?? gitGateLedgerPort({ cwd });
|
|
644
|
+
let port = ledger;
|
|
645
|
+
if (approval === "sealed") {
|
|
646
|
+
// #3684: only an approval sealed by a signer listed at base counts.
|
|
647
|
+
const review = await import("./gate-review");
|
|
648
|
+
const sealed = review.sealedApprovalRule(gate, (options.trust ?? ((b) => review.trustPolicyAt(cwd, b)))(base));
|
|
649
|
+
port = {
|
|
650
|
+
...ledger,
|
|
651
|
+
async approvalRule(g) {
|
|
652
|
+
const own = (await ledger.approvalRule?.(g)) ?? null;
|
|
653
|
+
return g === gate ? review.bothRules(own, sealed) : own;
|
|
654
|
+
},
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
const now = options.now ?? new Date().toISOString();
|
|
658
|
+
// #3784: under the spend rule at base, an approval a wave already applied under counts for nothing.
|
|
659
|
+
const spent: AppliedGateRecord[] | undefined = decision.spend === "base" ? ((await ledger.readApplied?.(spec.name)) ?? []).filter((a) => a.gate === gate) : undefined;
|
|
660
|
+
const held = decision.heldBy?.length ? `, held by ${decision.heldBy.join(", ")}` : "";
|
|
661
|
+
const check = await evaluateGate(port, {
|
|
662
|
+
op: spec.name,
|
|
663
|
+
gate,
|
|
664
|
+
planDigest: digest,
|
|
665
|
+
description: `wave ${k} (${wave.name}): ${members.length} run${members.length === 1 ? "" : "s"}, gate ${policy}, approval ${approval}${held}`,
|
|
666
|
+
now,
|
|
667
|
+
...(spent ? { spent } : {}),
|
|
668
|
+
});
|
|
669
|
+
if (check.satisfied) {
|
|
670
|
+
decision.status = "approved";
|
|
671
|
+
decision.via = "ledger";
|
|
672
|
+
decision.approvedBy = check.resolution.resolvedBy;
|
|
673
|
+
decision.approvedAt = check.resolution.timestamp;
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
const used = spent?.filter((a) => isApprovalSpent([a], { gate, planDigest: digest, timestamp: a.approvedAt })).pop();
|
|
677
|
+
if (used) {
|
|
678
|
+
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`);
|
|
679
|
+
}
|
|
680
|
+
if (approval === "pr-review") {
|
|
681
|
+
const byReview = await approveByReview(options, { op: spec.name, gate, k, digest, cwd, now, port: ledger, ...(spent ? { spent } : {}) });
|
|
682
|
+
decision.review = byReview.review;
|
|
683
|
+
if (byReview.approved) {
|
|
684
|
+
decision.status = "approved";
|
|
685
|
+
decision.via = "pr-review";
|
|
686
|
+
decision.approvedBy = byReview.review.approvers!.join(", ");
|
|
687
|
+
decision.approvedAt = now;
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
decision.status = "waiting";
|
|
691
|
+
decision.approve = approveCommand(spec.name, gate, undefined, digest);
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
decision.status = "waiting";
|
|
695
|
+
decision.approve = approveCommand(spec.name, gate, undefined, digest, approval === "sealed");
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* Apply `runs` of a decided wave: the before-apply hooks, the apply (one call
|
|
700
|
+
* per run, or one `applyWave` call for the set), then the after-apply hooks.
|
|
701
|
+
* A hook that holds the wave here, or held a share job's plan, makes it wait
|
|
702
|
+
* for an approval of the decided digest unless one already stands.
|
|
703
|
+
*/
|
|
704
|
+
async function applyStage(
|
|
705
|
+
options: RunOpWaveOptions,
|
|
706
|
+
ctx: WaveContext,
|
|
707
|
+
decision: OpWaveDecision,
|
|
708
|
+
runs: readonly OpWaveRun[],
|
|
709
|
+
planned: ReadonlyMap<string, OpWaveMember>,
|
|
710
|
+
earlierHolds: readonly string[],
|
|
711
|
+
recordPath?: string,
|
|
712
|
+
): Promise<void> {
|
|
713
|
+
const { spec, k, wave, cwd, exec, log, result } = ctx;
|
|
714
|
+
if (runs.length === 0) return;
|
|
715
|
+
const before = runHooks(ctx, "before-apply", decision.hooks?.["before-apply"], runs, planned, decision.digest);
|
|
716
|
+
if (before.failed) {
|
|
717
|
+
failed(ctx, `a hook before the apply failed, so nothing in wave ${k} was applied`);
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
const holds = [...earlierHolds, ...before.holds];
|
|
721
|
+
if (holds.length > 0 && decision.status !== "approved") {
|
|
722
|
+
decision.heldBy = [...(decision.heldBy ?? []), ...holds];
|
|
723
|
+
await settleGate(options, ctx, decision);
|
|
724
|
+
if (recordPath) writeFileSync(recordPath, JSON.stringify(decision, null, 2) + "\n");
|
|
725
|
+
if (decision.status === "waiting") {
|
|
726
|
+
log(`wave ${k} (${wave.name}) is held by ${holds.join(", ")}, and nobody approved ${decision.digest}; nothing applied. Approve: ${decision.approve}`);
|
|
727
|
+
result.exitCode = 3;
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
log(`wave ${k} (${wave.name}) is held by ${holds.join(", ")}, and ${decision.approvedBy} approved its digest`);
|
|
731
|
+
}
|
|
732
|
+
if (!(await spendApproval(options, ctx, decision))) return;
|
|
733
|
+
if (recordPath && decision.spentAt) writeFileSync(recordPath, JSON.stringify(decision, null, 2) + "\n");
|
|
734
|
+
if (spec.applyWave) {
|
|
735
|
+
const runsPath = writeRunsFile(ctx, runs, planned, decision.digest);
|
|
736
|
+
let code: number;
|
|
737
|
+
try {
|
|
738
|
+
code = exec(opWaveArgv(spec.applyWave, { op: spec.op, wave: wave.name, runs: runsPath, plans: opWavePlansPath(spec, k) }), cwd);
|
|
739
|
+
} catch (err) {
|
|
740
|
+
log(`applying wave ${k} (${wave.name}) did not run: ${err instanceof Error ? err.message : String(err)}`);
|
|
741
|
+
code = 127;
|
|
742
|
+
}
|
|
743
|
+
if (code === 0) result.applied.push(...runs.map((r) => r.target));
|
|
744
|
+
else {
|
|
745
|
+
log(`wave ${k} (${wave.name}): apply exited ${code}, so none of ${runs.map((r) => r.target).join(", ")} counts as applied`);
|
|
746
|
+
result.failed.push(...runs.map((r) => r.target));
|
|
747
|
+
}
|
|
748
|
+
} else {
|
|
749
|
+
for (const run of runs) applyRun(spec, wave.name, k, run, cwd, exec, result, log);
|
|
750
|
+
}
|
|
751
|
+
if (result.failed.length > 0) {
|
|
752
|
+
result.exitCode = 1;
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
const after = runHooks(ctx, "after-apply", decision.hooks?.["after-apply"], runs, planned, decision.digest);
|
|
756
|
+
if (after.failed) failed(ctx, `wave ${k} (${wave.name}) applied, and then a hook after the apply failed`);
|
|
757
|
+
}
|
|
758
|
+
|
|
364
759
|
function applyRun(
|
|
365
760
|
spec: OpWavesSpec,
|
|
366
761
|
waveName: string,
|
|
@@ -381,12 +776,47 @@ function applyRun(
|
|
|
381
776
|
}
|
|
382
777
|
}
|
|
383
778
|
|
|
384
|
-
/**
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
779
|
+
/**
|
|
780
|
+
* Record that the wave applies under its approval (#3784), which spends it:
|
|
781
|
+
* one line in `_gates/<name>/applied.jsonl`, before anything applies, as
|
|
782
|
+
* terragucci's wave gate writes it. Only an approved wave under the spend
|
|
783
|
+
* rule records, and only once. False when the line could not be written: the
|
|
784
|
+
* wave then fails and applies nothing, since an apply the record misses
|
|
785
|
+
* would leave its approval standing.
|
|
786
|
+
*/
|
|
787
|
+
async function spendApproval(options: RunOpWaveOptions, ctx: WaveContext, decision: OpWaveDecision): Promise<boolean> {
|
|
788
|
+
if (!decision.spend || decision.status !== "approved" || decision.spentAt || !decision.approvedAt) return true;
|
|
789
|
+
const { spec, k, wave, cwd, log } = ctx;
|
|
790
|
+
const port = options.gates ?? gitGateLedgerPort({ cwd });
|
|
791
|
+
const now = options.now ?? new Date().toISOString();
|
|
792
|
+
const record: AppliedGateRecord = {
|
|
793
|
+
version: 1,
|
|
794
|
+
kind: "applied",
|
|
795
|
+
op: spec.name,
|
|
796
|
+
gate: decision.gate,
|
|
797
|
+
planDigest: decision.digest,
|
|
798
|
+
approvedAt: decision.approvedAt,
|
|
799
|
+
approvedBy: decision.approvedBy ?? "",
|
|
800
|
+
timestamp: now,
|
|
801
|
+
wave: { number: k, name: wave.name },
|
|
802
|
+
...(decision.via === "pr-review" && decision.review ? { review: { ...(decision.review.pr !== undefined ? { pr: decision.review.pr } : {}), ...(decision.review.head ? { head: decision.review.head } : {}) } } : {}),
|
|
803
|
+
};
|
|
804
|
+
try {
|
|
805
|
+
if (!port.appendApplied) throw new Error("this gate ledger cannot record an applied approval");
|
|
806
|
+
const pushed = await port.appendApplied(record);
|
|
807
|
+
if (!pushed.pushed && pushed.pushWarning) log(`wave ${k} (${wave.name}): ${pushed.pushWarning}`);
|
|
808
|
+
} catch (err) {
|
|
809
|
+
failed(ctx, `wave ${k} (${wave.name}) could not record that it applies under the approval by ${record.approvedBy}, so nothing applied: ${err instanceof Error ? err.message : String(err)}`);
|
|
810
|
+
return false;
|
|
811
|
+
}
|
|
812
|
+
decision.spentAt = now;
|
|
813
|
+
log(`wave ${k} (${wave.name}) applies under the approval by ${record.approvedBy} at ${record.approvedAt}, which is now spent`);
|
|
814
|
+
return true;
|
|
388
815
|
}
|
|
389
816
|
|
|
817
|
+
/** What {@link approveByReview} found. */
|
|
818
|
+
type ReviewOutcome = ReviewApproval;
|
|
819
|
+
|
|
390
820
|
/**
|
|
391
821
|
* Under `pr-review` (#3684): the merged pull request's review approves the
|
|
392
822
|
* wave when a writer other than the author approved its head, and the head
|
|
@@ -395,7 +825,7 @@ interface ReviewOutcome {
|
|
|
395
825
|
*/
|
|
396
826
|
async function approveByReview(
|
|
397
827
|
options: RunOpWaveOptions,
|
|
398
|
-
at: { op: string; gate: string; k: number; digest: string; cwd: string; now: string; port: GateLedgerPort },
|
|
828
|
+
at: { op: string; gate: string; k: number; digest: string; cwd: string; now: string; port: GateLedgerPort; spent?: readonly AppliedGateRecord[] },
|
|
399
829
|
): Promise<ReviewOutcome> {
|
|
400
830
|
const review = await import("./gate-review");
|
|
401
831
|
const head = options.head ?? execFileSync("git", ["rev-parse", "HEAD"], { cwd: at.cwd, encoding: "utf-8" }).trim();
|
|
@@ -406,23 +836,16 @@ async function approveByReview(
|
|
|
406
836
|
return { approved: false, review: { reason: `the forge's review could not be read: ${err instanceof Error ? err.message : String(err)}` } };
|
|
407
837
|
}
|
|
408
838
|
if (!merged) return { approved: false, review: { reason: `no merged pull request produced ${head.slice(0, 12)}` } };
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
if (!planned) {
|
|
417
|
-
return {
|
|
418
|
-
approved: false,
|
|
419
|
-
review: { ...seen, approvers: merged.approvers, reason: `no plans are recorded for the head ${merged.head.slice(0, 12)} of #${merged.pr} (its pipeline runs \`chant run wave --record-plans\`)` },
|
|
420
|
-
};
|
|
421
|
-
}
|
|
422
|
-
if (planned.digest !== at.digest) {
|
|
839
|
+
// The head's plans are read only when a writer approved it.
|
|
840
|
+
const plans = merged.approvers.length === 0 ? null : await (options.headPlans ?? ((name, h) => readHeadPlans(name, h, at.cwd)))(options.spec.name, merged.head);
|
|
841
|
+
const { reviewApproval } = await import("../gate-decision");
|
|
842
|
+
const outcome = reviewApproval(merged, plans?.waves.find((w) => w.wave === at.k)?.digest, at.digest);
|
|
843
|
+
if (!outcome.approved) return outcome;
|
|
844
|
+
// #3784: a review a wave already applied this digest under is spent, so it is not recorded again.
|
|
845
|
+
if (at.spent && isApprovalSpent(at.spent, { gate: at.gate, planDigest: at.digest, review: { head: merged.head } })) {
|
|
423
846
|
return {
|
|
424
847
|
approved: false,
|
|
425
|
-
review: { ...
|
|
848
|
+
review: { ...outcome.review, reason: `a wave already applied ${at.digest} under the review of #${merged.pr} at ${merged.head.slice(0, 12)}, so the review is spent` },
|
|
426
849
|
};
|
|
427
850
|
}
|
|
428
851
|
const note = `approved by the review of #${merged.pr} at ${merged.head.slice(0, 12)}: ${merged.approvers.join(", ")}`;
|
|
@@ -437,7 +860,7 @@ async function approveByReview(
|
|
|
437
860
|
...(merged.url ? { url: merged.url } : {}),
|
|
438
861
|
review: { pr: merged.pr, head: merged.head, approvers: merged.approvers },
|
|
439
862
|
});
|
|
440
|
-
return
|
|
863
|
+
return outcome;
|
|
441
864
|
}
|
|
442
865
|
|
|
443
866
|
async function readHeadPlans(name: string, head: string, cwd: string): Promise<HeadPlans | null> {
|
|
@@ -485,7 +908,7 @@ export async function recordOpWaveHeadPlans(options: RecordHeadPlansOptions): Pr
|
|
|
485
908
|
head,
|
|
486
909
|
timestamp: options.now ?? new Date().toISOString(),
|
|
487
910
|
waves: spec.waves.map((wave, i) => {
|
|
488
|
-
const members =
|
|
911
|
+
const members = planRuns({ spec, k: i + 1, wave, cwd, exec }, wave.runs);
|
|
489
912
|
return { wave: i + 1, name: wave.name, digest: waveSetDigest(members) };
|
|
490
913
|
}),
|
|
491
914
|
};
|