@intentius/chant 0.121.1 → 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.
Files changed (161) hide show
  1. package/dist/cli/commands/build.d.ts +12 -0
  2. package/dist/cli/commands/build.d.ts.map +1 -1
  3. package/dist/cli/commands/lint.d.ts +16 -0
  4. package/dist/cli/commands/lint.d.ts.map +1 -1
  5. package/dist/cli/handlers/gate-resume.d.ts +1 -1
  6. package/dist/cli/handlers/gate-resume.d.ts.map +1 -1
  7. package/dist/cli/handlers/operator.d.ts +1 -1
  8. package/dist/cli/handlers/operator.d.ts.map +1 -1
  9. package/dist/cli/handlers/override-approve.d.ts +17 -0
  10. package/dist/cli/handlers/override-approve.d.ts.map +1 -0
  11. package/dist/cli/handlers/run-wave.d.ts.map +1 -1
  12. package/dist/cli/main.d.ts.map +1 -1
  13. package/dist/cli/registry.d.ts +4 -0
  14. package/dist/cli/registry.d.ts.map +1 -1
  15. package/dist/gate-decision.d.ts +157 -0
  16. package/dist/gate-decision.d.ts.map +1 -0
  17. package/dist/lifecycle/gate-applied.d.ts +75 -0
  18. package/dist/lifecycle/gate-applied.d.ts.map +1 -0
  19. package/dist/lifecycle/gate-audit.d.ts +145 -0
  20. package/dist/lifecycle/gate-audit.d.ts.map +1 -0
  21. package/dist/lifecycle/gate-ledger-select.d.ts +54 -0
  22. package/dist/lifecycle/gate-ledger-select.d.ts.map +1 -0
  23. package/dist/lifecycle/gate-ledger.d.ts +1 -45
  24. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  25. package/dist/lifecycle/override-ledger.d.ts +34 -0
  26. package/dist/lifecycle/override-ledger.d.ts.map +1 -0
  27. package/dist/lifecycle/rollback.d.ts.map +1 -1
  28. package/dist/lifecycle/work-item-id.d.ts +22 -0
  29. package/dist/lifecycle/work-item-id.d.ts.map +1 -0
  30. package/dist/lifecycle/work-lease.d.ts +2 -2
  31. package/dist/lifecycle/work-lease.d.ts.map +1 -1
  32. package/dist/lint/post-synth.d.ts +20 -1
  33. package/dist/lint/post-synth.d.ts.map +1 -1
  34. package/dist/notify-verify.d.ts +62 -0
  35. package/dist/notify-verify.d.ts.map +1 -0
  36. package/dist/notify.d.ts +202 -0
  37. package/dist/notify.d.ts.map +1 -0
  38. package/dist/op/activities/activity-contracts.d.ts +4 -4
  39. package/dist/op/activities/gh-host.d.ts +37 -0
  40. package/dist/op/activities/gh-host.d.ts.map +1 -0
  41. package/dist/op/activities/lexicon-upgrade.d.ts +11 -0
  42. package/dist/op/activities/lexicon-upgrade.d.ts.map +1 -1
  43. package/dist/op/activities/propose-upgrade.d.ts.map +1 -1
  44. package/dist/op/activities/reconcile.d.ts +74 -76
  45. package/dist/op/activities/reconcile.d.ts.map +1 -1
  46. package/dist/op/gate-approval.d.ts +17 -1
  47. package/dist/op/gate-approval.d.ts.map +1 -1
  48. package/dist/op/gate-name.d.ts +8 -1
  49. package/dist/op/gate-name.d.ts.map +1 -1
  50. package/dist/op/gate-resume.d.ts +4 -2
  51. package/dist/op/gate-resume.d.ts.map +1 -1
  52. package/dist/op/gate-review.d.ts +2 -1
  53. package/dist/op/gate-review.d.ts.map +1 -1
  54. package/dist/op/gate.d.ts +43 -6
  55. package/dist/op/gate.d.ts.map +1 -1
  56. package/dist/op/index.d.ts +8 -6
  57. package/dist/op/index.d.ts.map +1 -1
  58. package/dist/op/op-waves-notify.d.ts +31 -0
  59. package/dist/op/op-waves-notify.d.ts.map +1 -0
  60. package/dist/op/op-waves-run.d.ts +91 -16
  61. package/dist/op/op-waves-run.d.ts.map +1 -1
  62. package/dist/op/op-waves.d.ts +99 -2
  63. package/dist/op/op-waves.d.ts.map +1 -1
  64. package/dist/op/operator.d.ts +15 -0
  65. package/dist/op/operator.d.ts.map +1 -1
  66. package/dist/op/work-lease-decl.d.ts.map +1 -1
  67. package/dist/op/work-lease-run.d.ts.map +1 -1
  68. package/dist/override-gate.d.ts +224 -0
  69. package/dist/override-gate.d.ts.map +1 -0
  70. package/dist/pr-forge.d.ts +198 -19
  71. package/dist/pr-forge.d.ts.map +1 -1
  72. package/dist/workspace/status-gates.d.ts +1 -0
  73. package/dist/workspace/status-gates.d.ts.map +1 -1
  74. package/dist/workspace/trust/gate-seal.d.ts +96 -0
  75. package/dist/workspace/trust/gate-seal.d.ts.map +1 -0
  76. package/dist/workspace/trust/policy.d.ts +2 -41
  77. package/dist/workspace/trust/policy.d.ts.map +1 -1
  78. package/dist/workspace/trust/seal.d.ts +2 -27
  79. package/dist/workspace/trust/seal.d.ts.map +1 -1
  80. package/dist/workspace/trust/trust-files.d.ts +57 -0
  81. package/dist/workspace/trust/trust-files.d.ts.map +1 -0
  82. package/dist/workspace/work-cli.d.ts.map +1 -1
  83. package/package.json +2 -1
  84. package/src/cli/commands/build.ts +37 -18
  85. package/src/cli/commands/carve-emit-state.test.ts +8 -2
  86. package/src/cli/commands/lint.test.ts +109 -1
  87. package/src/cli/commands/lint.ts +165 -1
  88. package/src/cli/handlers/__snapshots__/build-member.test.ts.snap +2 -2
  89. package/src/cli/handlers/gate-resume.ts +3 -2
  90. package/src/cli/handlers/operator.test.ts +12 -1
  91. package/src/cli/handlers/operator.ts +25 -13
  92. package/src/cli/handlers/override-approve.test.ts +87 -0
  93. package/src/cli/handlers/override-approve.ts +103 -0
  94. package/src/cli/handlers/run-wave.ts +10 -2
  95. package/src/cli/main.test.ts +8 -0
  96. package/src/cli/main.ts +15 -0
  97. package/src/cli/registry.ts +4 -0
  98. package/src/gate-decision-bundle.test.ts +56 -0
  99. package/src/gate-decision.test.ts +237 -0
  100. package/src/gate-decision.ts +321 -0
  101. package/src/lifecycle/gate-applied.ts +115 -0
  102. package/src/lifecycle/gate-audit-bundle.test.ts +47 -0
  103. package/src/lifecycle/gate-audit.test.ts +134 -0
  104. package/src/lifecycle/gate-audit.ts +438 -0
  105. package/src/lifecycle/gate-ledger-select.ts +113 -0
  106. package/src/lifecycle/gate-ledger.test.ts +22 -0
  107. package/src/lifecycle/gate-ledger.ts +9 -104
  108. package/src/lifecycle/override-ledger.test.ts +63 -0
  109. package/src/lifecycle/override-ledger.ts +69 -0
  110. package/src/lifecycle/rollback.ts +5 -1
  111. package/src/lifecycle/work-item-id.test.ts +71 -0
  112. package/src/lifecycle/work-item-id.ts +35 -0
  113. package/src/lifecycle/work-lease.ts +4 -5
  114. package/src/lint/post-synth.test.ts +29 -0
  115. package/src/lint/post-synth.ts +21 -2
  116. package/src/notify-bundle.test.ts +50 -0
  117. package/src/notify-verify.test.ts +59 -0
  118. package/src/notify-verify.ts +93 -0
  119. package/src/notify.schema.json +72 -0
  120. package/src/notify.test.ts +279 -0
  121. package/src/notify.ts +398 -0
  122. package/src/op/activities/gh-host.test.ts +130 -0
  123. package/src/op/activities/gh-host.ts +79 -0
  124. package/src/op/activities/lexicon-upgrade.ts +22 -1
  125. package/src/op/activities/propose-upgrade.ts +5 -1
  126. package/src/op/activities/reconcile.test.ts +284 -280
  127. package/src/op/activities/reconcile.ts +128 -131
  128. package/src/op/gate-approval.test.ts +175 -7
  129. package/src/op/gate-approval.ts +27 -1
  130. package/src/op/gate-name.ts +10 -2
  131. package/src/op/gate-resume.ts +6 -3
  132. package/src/op/gate-review.ts +4 -7
  133. package/src/op/gate.ts +117 -16
  134. package/src/op/index.ts +20 -5
  135. package/src/op/op-waves-notify.test.ts +127 -0
  136. package/src/op/op-waves-notify.ts +101 -0
  137. package/src/op/op-waves-run.ts +527 -104
  138. package/src/op/op-waves.test.ts +383 -3
  139. package/src/op/op-waves.ts +187 -6
  140. package/src/op/operator.test.ts +29 -0
  141. package/src/op/operator.ts +27 -2
  142. package/src/op/run-context.test.ts +2 -2
  143. package/src/op/work-lease-decl.ts +4 -4
  144. package/src/op/work-lease-run.test.ts +5 -0
  145. package/src/op/work-lease-run.ts +4 -3
  146. package/src/override-gate-bundle.test.ts +47 -0
  147. package/src/override-gate.test.ts +239 -0
  148. package/src/override-gate.ts +395 -0
  149. package/src/pr-forge-bundle.test.ts +40 -0
  150. package/src/pr-forge.test.ts +303 -0
  151. package/src/pr-forge.ts +502 -47
  152. package/src/workspace/records-write-contract.test.ts +3 -2
  153. package/src/workspace/status-gates.ts +7 -4
  154. package/src/workspace/trust/gate-seal.test.ts +156 -0
  155. package/src/workspace/trust/gate-seal.ts +254 -0
  156. package/src/workspace/trust/policy.ts +10 -165
  157. package/src/workspace/trust/seal.ts +19 -34
  158. package/src/workspace/trust/trust-files.test.ts +50 -0
  159. package/src/workspace/trust/trust-files.ts +245 -0
  160. package/src/workspace/work-cli.ts +5 -3
  161. package/src/workspace/work-history.test.ts +7 -0
@@ -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
- * - Apply: each run's `apply` command. A share job (`--share i`) re-plans its
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
- ): { policy: OpWaveGatePolicy; approval: GateApprovalSource; source: OpWavePolicySource; base: string } {
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
- return { policy: OP_WAVE_GATE_POLICIES.includes(policy) ? policy : "always", approval, source: "base", base: sha };
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 for one run. */
165
- export function opWaveArgv(template: readonly string[], values: { op: string; target: string; wave: string; plan: string }): string[] {
166
- return template.map((part) =>
167
- part.replaceAll("{op}", values.op).replaceAll("{target}", values.target).replaceAll("{wave}", values.wave).replaceAll("{plan}", values.plan),
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
- function planRun(
214
- opts: Required<Pick<RunOpWaveOptions, "spec" | "wave">> & { cwd: string; exec: OpWaveExec },
215
- waveName: string,
216
- run: OpWaveRun,
217
- ): OpWaveMember {
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, { op, target: run.target, wave: waveName, plan }), opts.cwd);
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: run.target,
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
- for (const run of opWaveShare(wave.runs, options.share, shares)) {
280
- const member = planRun(ctx, wave.name, run);
281
- if (decided.get(run.target) !== member.planDigest) {
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: ${member.planDigest}`,
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
- applyRun(spec, wave.name, k, run, cwd, exec, result, log);
563
+ unmoved.push(run);
290
564
  }
291
- result.exitCode = result.failed.length > 0 ? 1 : result.moved.length > 0 ? 4 : 0;
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
- const members = wave.runs.map((run) => planRun(ctx, wave.name, run));
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 { policy, approval, source, base } = readOpWavePolicy(options.specFile, wave.name, options.base ?? spec.base ?? "HEAD^1", cwd, options.show);
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 decision: OpWaveDecision = { wave: k, name: wave.name, op: spec.name, gate, policy, approval, policySource: source, base, digest, members, status: "not-required" };
300
- if (opWaveNeedsApproval(policy, members)) {
301
- const ledger = options.gates ?? gitGateLedgerPort({ cwd });
302
- let port = ledger;
303
- if (approval === "sealed") {
304
- // #3684: only an approval sealed by a signer listed at base counts.
305
- const review = await import("./gate-review");
306
- const sealed = review.sealedApprovalRule(gate, (options.trust ?? ((b) => review.trustPolicyAt(cwd, b)))(base));
307
- port = {
308
- ...ledger,
309
- async approvalRule(g) {
310
- const own = (await ledger.approvalRule?.(g)) ?? null;
311
- return g === gate ? review.bothRules(own, sealed) : own;
312
- },
313
- };
314
- }
315
- const now = options.now ?? new Date().toISOString();
316
- const check = await evaluateGate(port, {
317
- op: spec.name,
318
- gate,
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
- for (const run of wave.runs) applyRun(spec, wave.name, k, run, cwd, exec, result, log);
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
- /** What {@link approveByReview} found. */
385
- interface ReviewOutcome {
386
- approved: boolean;
387
- review: NonNullable<OpWaveDecision["review"]>;
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
- const seen = { pr: merged.pr, head: merged.head, ...(merged.url ? { url: merged.url } : {}) };
410
- if (merged.approvers.length === 0) {
411
- const why = merged.refused.length > 0 ? merged.refused.join("; ") : "nobody approved it";
412
- return { approved: false, review: { ...seen, reason: `no writer other than its author approved the head of #${merged.pr}: ${why}` } };
413
- }
414
- const plans = await (options.headPlans ?? ((name, h) => readHeadPlans(name, h, at.cwd)))(options.spec.name, merged.head);
415
- const planned = plans?.waves.find((w) => w.wave === at.k);
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: { ...seen, approvers: merged.approvers, reason: `the plan moved after the review: the head planned ${planned.digest}, and the merge plans ${at.digest}` },
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 { approved: true, review: { ...seen, approvers: merged.approvers } };
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 = wave.runs.map((run) => planRun({ spec, wave: i + 1, cwd, exec }, wave.name, run));
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
  };