@intentius/chant 0.122.0 → 0.124.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/check-lexicon.d.ts.map +1 -1
- 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/config-import.d.ts +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/policy-import.d.ts +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-verify.js +76 -0
- package/dist/notify.d.ts +202 -0
- package/dist/notify.d.ts.map +1 -0
- package/dist/notify.js +307 -0
- package/dist/op/__fixtures__/decide-stub-backend.d.ts +1 -1
- 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 +218 -16
- package/dist/op/op-waves-run.d.ts.map +1 -1
- package/dist/op/op-waves.d.ts +113 -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 +225 -19
- package/dist/pr-forge.d.ts.map +1 -1
- package/dist/workspace/conformance/__fixtures__/reference-broker.d.ts +1 -1
- package/dist/workspace/conformance/broker.d.ts +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 +15 -2
- package/src/cli/commands/build.ts +1 -1
- package/src/cli/commands/check-lexicon.ts +3 -1
- package/src/cli/commands/lint-post-synth.e2e.test.ts +117 -0
- package/src/cli/commands/lint.test.ts +4 -109
- package/src/cli/commands/lint.ts +5 -2
- package/src/cli/discovery-skip.e2e.test.ts +18 -3
- 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/config-import.ts +1 -1
- package/src/discovery/sandbox/run.test.ts +1 -1
- 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/policy-import.ts +1 -1
- 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-plain-node.e2e.test.ts +68 -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/__fixtures__/decide-stub-backend.ts +1 -1
- 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 +36 -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 +908 -123
- package/src/op/op-waves.test.ts +543 -3
- package/src/op/op-waves.ts +215 -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 +378 -0
- package/src/pr-forge.ts +546 -47
- package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +1 -1
- package/src/workspace/conformance/__fixtures__/reference-broker.ts +1 -1
- package/src/workspace/conformance/broker.ts +1 -1
- package/src/workspace/records-write-contract.test.ts +3 -2
- package/src/workspace/records-write.e2e.test.ts +1 -1
- 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/audit/{catalog.test.ts → catalog.e2e.test.ts} +0 -0
- /package/src/audit/{discover.test.ts → discover.e2e.test.ts} +0 -0
- /package/src/audit/{remote-discovery.test.ts → remote-discovery.e2e.test.ts} +0 -0
- /package/src/audit/{rules-doc.test.ts → rules-doc.e2e.test.ts} +0 -0
- /package/src/{bench.test.ts → bench.e2e.test.ts} +0 -0
- /package/src/cli/commands/{check-lexicon-doc-drift.test.ts → check-lexicon-doc-drift.e2e.test.ts} +0 -0
- /package/src/cli/commands/{import.test.ts → import.e2e.test.ts} +0 -0
- /package/src/cli/{component-security-boundary.test.ts → component-security-boundary.e2e.test.ts} +0 -0
- /package/src/cli/mcp/{workspace-tools.test.ts → workspace-tools.e2e.test.ts} +0 -0
- /package/src/cli/{security-boundary.test.ts → security-boundary.e2e.test.ts} +0 -0
- /package/src/cli/{serve-mcp-workspace.test.ts → serve-mcp-workspace.e2e.test.ts} +0 -0
- /package/src/cli/{static-config-read.test.ts → static-config-read.e2e.test.ts} +0 -0
- /package/src/codegen/{json-schema-to-ts.test.ts → json-schema-to-ts.e2e.test.ts} +0 -0
- /package/src/{lexicon-seams.test.ts → lexicon-seams.e2e.test.ts} +0 -0
- /package/src/meta/{declared-imports.test.ts → declared-imports.e2e.test.ts} +0 -0
- /package/src/op/{command-gate.test.ts → command-gate.e2e.test.ts} +0 -0
- /package/src/op/{steward-beside.test.ts → steward-beside.e2e.test.ts} +0 -0
- /package/src/op/{steward-points.test.ts → steward-points.e2e.test.ts} +0 -0
- /package/src/workspace/{checks.test.ts → checks.e2e.test.ts} +0 -0
- /package/src/workspace/{intent-reviews.test.ts → intent-reviews.e2e.test.ts} +0 -0
- /package/src/workspace/{profiles.test.ts → profiles.e2e.test.ts} +0 -0
- /package/src/workspace/{read-contract.test.ts → read-contract.e2e.test.ts} +0 -0
- /package/src/workspace/{records-contract.test.ts → records-contract.e2e.test.ts} +0 -0
- /package/src/workspace/{records-since.test.ts → records-since.e2e.test.ts} +0 -0
- /package/src/workspace/{records-write.test.ts → records-write-commands.e2e.test.ts} +0 -0
- /package/src/workspace/{work-size.test.ts → work-size.e2e.test.ts} +0 -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,50 @@
|
|
|
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).
|
|
32
|
+
* - Hooks file (#3804): every apply call gets `CHANT_HOOKS`, a JSON file
|
|
33
|
+
* ({@link OpWaveHooksFile}) with the outcomes of the hooks that already ran
|
|
34
|
+
* and whether an approval stands. Under `hooks.apply.runBy: "apply"` (read
|
|
35
|
+
* at base) chant does not run the apply-stage hooks itself: the file hands
|
|
36
|
+
* them to the apply, which runs them under its own lock with
|
|
37
|
+
* {@link runOpWaveApplyHooks}, and their outcomes come back through
|
|
38
|
+
* `CHANT_HOOKS_RESULT`. A non-wave apply reads the same hooks at base with
|
|
39
|
+
* {@link opWaveHooksAtBase}.
|
|
22
40
|
*/
|
|
23
41
|
|
|
24
42
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
25
|
-
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
26
|
-
import { dirname, join, posix } from "node:path";
|
|
43
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
44
|
+
import { dirname, isAbsolute, join, posix } from "node:path";
|
|
27
45
|
import { waveGateName, waveSetDigest, type WaveMember } from "../gated-waves";
|
|
46
|
+
import type { ReviewApproval } from "../gate-decision";
|
|
28
47
|
import { approveCommand, evaluateGate, gitGateLedgerPort, type GateLedgerPort } from "./gate";
|
|
48
|
+
import { isApprovalSpent, type AppliedGateRecord } from "../lifecycle/gate-applied";
|
|
29
49
|
import type { HeadPlans, MergedReview, ReviewSource } from "./gate-review";
|
|
30
50
|
import type { TrustPolicy } from "../workspace/trust/policy";
|
|
31
51
|
import {
|
|
32
52
|
OP_WAVE_DEFAULT_APPLY,
|
|
33
53
|
OP_WAVE_GATE_POLICIES,
|
|
54
|
+
OP_WAVE_HOOK_MOMENTS,
|
|
34
55
|
GATE_APPROVAL_SOURCES,
|
|
56
|
+
assertOpWaveHooks,
|
|
35
57
|
assertOpWavesSpec,
|
|
58
|
+
opWaveApplyHooksRunBy,
|
|
59
|
+
opWaveHooksAt,
|
|
36
60
|
opWaveRecordPath,
|
|
37
61
|
opWaveShare,
|
|
38
62
|
type GateApprovalSource,
|
|
63
|
+
type OpWave,
|
|
64
|
+
type OpWaveApplyHooksRunner,
|
|
39
65
|
type OpWaveGatePolicy,
|
|
66
|
+
type OpWaveHook,
|
|
67
|
+
type OpWaveHookMoment,
|
|
40
68
|
type OpWaveRun,
|
|
41
69
|
type OpWavesSpec,
|
|
42
70
|
} from "./op-waves";
|
|
@@ -50,6 +78,23 @@ export interface OpWavePlanFile {
|
|
|
50
78
|
empty?: boolean;
|
|
51
79
|
}
|
|
52
80
|
|
|
81
|
+
/** What a wave-level plan command writes to `{plans}` (#3762): one plan per run, by target. The array alone is read too. */
|
|
82
|
+
export interface OpWavePlansFile {
|
|
83
|
+
runs: Array<OpWavePlanFile & { target: string }>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** What chant writes to `{runs}` for a wave-level command or a hook (#3762). */
|
|
87
|
+
export interface OpWaveRunsFile {
|
|
88
|
+
/** The wave's name. */
|
|
89
|
+
wave: string;
|
|
90
|
+
/** 1-based. */
|
|
91
|
+
number: number;
|
|
92
|
+
/** The wave's set digest, once it is decided. On `applyWave`, the digest the set was approved or let through at. */
|
|
93
|
+
digest?: string;
|
|
94
|
+
/** Each run, with its plan once planned. On `applyWave`, the runs to apply. */
|
|
95
|
+
runs: Array<{ target: string; op: string; planDigest?: string; destructive?: boolean; empty?: boolean }>;
|
|
96
|
+
}
|
|
97
|
+
|
|
53
98
|
/** One run's plan, as the decision records it. */
|
|
54
99
|
export interface OpWaveMember extends WaveMember {
|
|
55
100
|
op: string;
|
|
@@ -84,13 +129,113 @@ export interface OpWaveDecision {
|
|
|
84
129
|
review?: { pr?: number; head?: string; approvers?: string[]; url?: string; reason?: string };
|
|
85
130
|
/** The command that approves this digest, when waiting. */
|
|
86
131
|
approve?: string;
|
|
132
|
+
/** The hooks read at base (#3762), which a wide wave's share jobs run too. Absent when there are none. */
|
|
133
|
+
hooks?: Partial<Record<OpWaveHookMoment, OpWaveHook[]>>;
|
|
134
|
+
/** The `onFailure: approve` hooks that failed: the wave needs an approval whatever its policy (#3762). */
|
|
135
|
+
heldBy?: string[];
|
|
136
|
+
/**
|
|
137
|
+
* Spent approvals (#3784). `base`: the spec at base sets `approvals.spend`,
|
|
138
|
+
* so a spent approval counts for nothing and the approval this wave applies
|
|
139
|
+
* under is recorded. `change`: only the change sets it, so the approval is
|
|
140
|
+
* recorded and spent from the next decision on. Absent: neither.
|
|
141
|
+
*/
|
|
142
|
+
spend?: "base" | "change";
|
|
143
|
+
/** The approval's own timestamp: what the applied record names. */
|
|
144
|
+
approvedAt?: string;
|
|
145
|
+
/** When the wave recorded that it applies under the approval, which spent it. */
|
|
146
|
+
spentAt?: string;
|
|
147
|
+
/** `apply`: the apply command runs the apply-stage hooks under its own lock (#3804), as read at base. Absent: chant runs them. */
|
|
148
|
+
applyHooksRunBy?: "apply";
|
|
149
|
+
/** Every hook's outcome so far in the job that wrote the record (#3804). */
|
|
150
|
+
outcomes?: OpWaveHookOutcome[];
|
|
87
151
|
}
|
|
88
152
|
|
|
89
|
-
/**
|
|
90
|
-
export
|
|
153
|
+
/** One hook's run (#3762). */
|
|
154
|
+
export interface OpWaveHookOutcome {
|
|
155
|
+
moment: OpWaveHookMoment;
|
|
156
|
+
name: string;
|
|
157
|
+
/** The run, for a `perRun` hook. */
|
|
158
|
+
target?: string;
|
|
159
|
+
exit: number;
|
|
160
|
+
outcome: "passed" | "failed" | "held";
|
|
161
|
+
}
|
|
91
162
|
|
|
92
|
-
|
|
93
|
-
|
|
163
|
+
/** One run as a hook sees it (#3804). */
|
|
164
|
+
export interface OpWaveHookRun {
|
|
165
|
+
target: string;
|
|
166
|
+
op: string;
|
|
167
|
+
/** The run's plan file, once planned. */
|
|
168
|
+
plan?: string;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* What chant hands an apply command in `CHANT_HOOKS` (#3804), and what
|
|
173
|
+
* {@link opWaveHooksAtBase} builds for an apply outside the waves. Paths are
|
|
174
|
+
* relative to the job's working directory.
|
|
175
|
+
*/
|
|
176
|
+
export interface OpWaveHooksFile {
|
|
177
|
+
version: 1;
|
|
178
|
+
/** The Op waves spec's name. */
|
|
179
|
+
opWaves: string;
|
|
180
|
+
/** The spec's `op`: a hook's `{op}` when it runs once for the wave. */
|
|
181
|
+
op: string;
|
|
182
|
+
wave: string;
|
|
183
|
+
/** 1-based; 0 when the working tree's spec has no such wave. */
|
|
184
|
+
number: number;
|
|
185
|
+
/** The commit the hooks were read at. */
|
|
186
|
+
base?: string;
|
|
187
|
+
/** The wave's set digest, once decided. */
|
|
188
|
+
digest?: string;
|
|
189
|
+
/** An approval of the digest stands, so a held hook does not stop the apply. */
|
|
190
|
+
approved: boolean;
|
|
191
|
+
/** The labels of the hooks that held the wave, approved or not. */
|
|
192
|
+
heldBy?: string[];
|
|
193
|
+
/** Who runs the apply-stage hooks. With `chant`, `hooks` is empty: chant runs them around the apply. */
|
|
194
|
+
runBy: OpWaveApplyHooksRunner;
|
|
195
|
+
/** The runs this apply call applies. */
|
|
196
|
+
runs: OpWaveHookRun[];
|
|
197
|
+
/** The runs file, a hook's `{runs}` and `CHANT_RUNS`. */
|
|
198
|
+
runsFile: string;
|
|
199
|
+
/** The hooks the apply runs, by moment, as read at base. */
|
|
200
|
+
hooks: Partial<Record<OpWaveHookMoment, OpWaveHook[]>>;
|
|
201
|
+
/** The hooks that already ran for this wave in this job, and how each came out. */
|
|
202
|
+
outcomes: OpWaveHookOutcome[];
|
|
203
|
+
/** Where {@link runOpWaveApplyHooks} writes its outcomes (`CHANT_HOOKS_RESULT`), which chant reads back. */
|
|
204
|
+
result?: string;
|
|
205
|
+
/** Set when the hooks at base could not be read: none run, and the runner refuses to proceed. */
|
|
206
|
+
refused?: string;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** What {@link runOpWaveApplyHooks} writes to `CHANT_HOOKS_RESULT` (#3804). */
|
|
210
|
+
export interface OpWaveHooksResult {
|
|
211
|
+
version: 1;
|
|
212
|
+
/** The moments the apply ran, empty ones included. */
|
|
213
|
+
moments: OpWaveHookMoment[];
|
|
214
|
+
outcomes: OpWaveHookOutcome[];
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** One call of {@link runOpWaveApplyHooks}. */
|
|
218
|
+
export interface OpWaveApplyHooksRun {
|
|
219
|
+
moment: OpWaveHookMoment;
|
|
220
|
+
outcomes: OpWaveHookOutcome[];
|
|
221
|
+
/** Set when a hook with `onFailure: fail` failed; the hooks after it did not run. */
|
|
222
|
+
failed?: string;
|
|
223
|
+
/** The `onFailure: approve` hooks that failed, or why the hooks could not be read at base. */
|
|
224
|
+
held: string[];
|
|
225
|
+
/**
|
|
226
|
+
* Whether the apply may go on: no hook failed, and nothing held it or an
|
|
227
|
+
* approval of the digest stands. When false after a hold, the apply applies
|
|
228
|
+
* nothing and exits non-zero; chant then waits for, or finds, an approval
|
|
229
|
+
* and calls it again.
|
|
230
|
+
*/
|
|
231
|
+
proceed: boolean;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Runs one argv; returns its exit code. `env` is added to the job's environment. Injected so tests run no processes. */
|
|
235
|
+
export type OpWaveExec = (argv: string[], cwd: string, env?: Record<string, string>) => number;
|
|
236
|
+
|
|
237
|
+
const spawnExec: OpWaveExec = (argv, cwd, env) => {
|
|
238
|
+
const result = spawnSync(argv[0]!, argv.slice(1), { cwd, stdio: "inherit", ...(env ? { env: { ...process.env, ...env } } : {}) });
|
|
94
239
|
if (result.error) throw result.error;
|
|
95
240
|
return result.status ?? 1;
|
|
96
241
|
};
|
|
@@ -132,10 +277,31 @@ export function parseOpWavesSpec(text: string, where: string): OpWavesSpec {
|
|
|
132
277
|
return spec;
|
|
133
278
|
}
|
|
134
279
|
|
|
280
|
+
/** What {@link readOpWavePolicy} read at base. */
|
|
281
|
+
export interface OpWaveBaseRules {
|
|
282
|
+
policy: OpWaveGatePolicy;
|
|
283
|
+
approval: GateApprovalSource;
|
|
284
|
+
source: OpWavePolicySource;
|
|
285
|
+
base: string;
|
|
286
|
+
/** 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. */
|
|
287
|
+
hooks: Record<OpWaveHookMoment, OpWaveHook[]>;
|
|
288
|
+
/** Set when the hooks at base could not be read: none run, and the wave waits for a person. */
|
|
289
|
+
hooksRefused?: string;
|
|
290
|
+
/** The spec at base sets `approvals.spend` (#3784). */
|
|
291
|
+
spend: boolean;
|
|
292
|
+
/** Who runs the apply-stage hooks, as read at base (#3804). `chant` when the file, the wave or the hooks are missing there. */
|
|
293
|
+
applyHooksRunBy: OpWaveApplyHooksRunner;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const noHooks = (): Record<OpWaveHookMoment, OpWaveHook[]> =>
|
|
297
|
+
Object.fromEntries(OP_WAVE_HOOK_MOMENTS.map((m) => [m, []])) as unknown as Record<OpWaveHookMoment, OpWaveHook[]>;
|
|
298
|
+
|
|
135
299
|
/**
|
|
136
300
|
* The policy of wave `name`, as the spec file at `base` declares it. The
|
|
137
301
|
* 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.
|
|
302
|
+
* wave, or the file, waits for a person the first time. Its stage hooks are
|
|
303
|
+
* read there too (#3762), so a change cannot add, edit or remove a hook its
|
|
304
|
+
* own apply runs; the file or the wave missing there has none.
|
|
139
305
|
*/
|
|
140
306
|
export function readOpWavePolicy(
|
|
141
307
|
specFile: string,
|
|
@@ -143,9 +309,9 @@ export function readOpWavePolicy(
|
|
|
143
309
|
base: string,
|
|
144
310
|
cwd: string,
|
|
145
311
|
show: OpWaveShowAtBase = gitShowAtBase,
|
|
146
|
-
):
|
|
312
|
+
): OpWaveBaseRules {
|
|
147
313
|
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 };
|
|
314
|
+
const absent = { policy: "always" as const, approval: "ledger" as const, source: "absent-at-base" as const, base: sha, hooks: noHooks(), spend: false, applyHooksRunBy: "chant" as const };
|
|
149
315
|
if (text === null) return absent;
|
|
150
316
|
let spec: OpWavesSpec;
|
|
151
317
|
try {
|
|
@@ -153,19 +319,40 @@ export function readOpWavePolicy(
|
|
|
153
319
|
} catch {
|
|
154
320
|
return absent;
|
|
155
321
|
}
|
|
322
|
+
// The spend rule is the spec's, so it holds for a wave the base does not have yet.
|
|
323
|
+
const spend = (spec as { approvals?: { spend?: unknown } }).approvals?.spend === true;
|
|
156
324
|
const wave = spec.waves.find((w) => w.name === name);
|
|
157
|
-
if (!wave) return absent;
|
|
325
|
+
if (!wave) return { ...absent, spend };
|
|
158
326
|
const policy = wave.gate ?? "always";
|
|
159
327
|
// An approval mode this chant does not know reads as sealed: the strictest, so an unknown rule never loosens a gate.
|
|
160
328
|
const approval = wave.approval === undefined ? "ledger" : GATE_APPROVAL_SOURCES.includes(wave.approval) ? wave.approval : "sealed";
|
|
161
|
-
|
|
329
|
+
const rules: OpWaveBaseRules = { policy: OP_WAVE_GATE_POLICIES.includes(policy) ? policy : "always", approval, source: "base", base: sha, hooks: noHooks(), spend, applyHooksRunBy: "chant" };
|
|
330
|
+
try {
|
|
331
|
+
assertOpWaveHooks(`${specFile} at ${sha.slice(0, 12)}`, spec.hooks);
|
|
332
|
+
assertOpWaveHooks(`${specFile} at ${sha.slice(0, 12)}, wave "${name}"`, wave.hooks);
|
|
333
|
+
rules.hooks = opWaveHooksAt(spec, name);
|
|
334
|
+
rules.applyHooksRunBy = opWaveApplyHooksRunBy(spec, name);
|
|
335
|
+
} catch (err) {
|
|
336
|
+
// A hook at base no runner can run: none run, and the wave waits for a person rather than going out without them.
|
|
337
|
+
rules.hooksRefused = `the hooks at base cannot be run (${err instanceof Error ? err.message : String(err)})`;
|
|
338
|
+
}
|
|
339
|
+
return rules;
|
|
162
340
|
}
|
|
163
341
|
|
|
164
|
-
/** Fill one argv template
|
|
165
|
-
export function opWaveArgv(
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
342
|
+
/** Fill one argv template. A placeholder whose value is not given is left as it is. */
|
|
343
|
+
export function opWaveArgv(
|
|
344
|
+
template: readonly string[],
|
|
345
|
+
values: { op: string; wave: string; target?: string; plan?: string; runs?: string; plans?: string },
|
|
346
|
+
): string[] {
|
|
347
|
+
const pairs: Array<[string, string | undefined]> = [
|
|
348
|
+
["{op}", values.op],
|
|
349
|
+
["{target}", values.target],
|
|
350
|
+
["{wave}", values.wave],
|
|
351
|
+
["{plans}", values.plans],
|
|
352
|
+
["{plan}", values.plan],
|
|
353
|
+
["{runs}", values.runs],
|
|
354
|
+
];
|
|
355
|
+
return template.map((part) => pairs.reduce((out, [key, value]) => (value === undefined ? out : out.replaceAll(key, value)), part));
|
|
169
356
|
}
|
|
170
357
|
|
|
171
358
|
/** Where a run's plan file goes. */
|
|
@@ -173,6 +360,16 @@ export function opWavePlanPath(spec: Pick<OpWavesSpec, "name">, wave: number, ta
|
|
|
173
360
|
return `.chant/op-waves/${spec.name}/wave-${wave}/${target.replace(/[^A-Za-z0-9._-]/g, "_")}.plan.json`;
|
|
174
361
|
}
|
|
175
362
|
|
|
363
|
+
/** Where a wave's runs file goes: the `{runs}` of a wave-level command or a hook (#3762). */
|
|
364
|
+
export function opWaveRunsPath(spec: Pick<OpWavesSpec, "name">, wave: number): string {
|
|
365
|
+
return `.chant/op-waves/${spec.name}/wave-${wave}/runs.json`;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Where a wave-level plan command writes its plans: its `{plans}` (#3762). */
|
|
369
|
+
export function opWavePlansPath(spec: Pick<OpWavesSpec, "name">, wave: number): string {
|
|
370
|
+
return `.chant/op-waves/${spec.name}/wave-${wave}/plans.json`;
|
|
371
|
+
}
|
|
372
|
+
|
|
176
373
|
export interface RunOpWaveOptions {
|
|
177
374
|
spec: OpWavesSpec;
|
|
178
375
|
specFile: string;
|
|
@@ -201,24 +398,33 @@ export interface RunOpWaveOptions {
|
|
|
201
398
|
}
|
|
202
399
|
|
|
203
400
|
export interface RunOpWaveResult {
|
|
204
|
-
/** 0 applied (or decided), 1 a run failed, 3 waiting for an approval, 4 a plan moved since the decision. */
|
|
401
|
+
/** 0 applied (or decided), 1 a run or a hook failed, 3 waiting for an approval, 4 a plan moved since the decision. */
|
|
205
402
|
exitCode: number;
|
|
206
403
|
decision?: OpWaveDecision;
|
|
207
404
|
applied: string[];
|
|
208
405
|
failed: string[];
|
|
209
406
|
/** Runs a share job refused because their plan moved since the decision. */
|
|
210
407
|
moved: string[];
|
|
408
|
+
/** The hooks that ran (#3762). */
|
|
409
|
+
hooks: OpWaveHookOutcome[];
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** What one wave job works with. */
|
|
413
|
+
interface WaveContext {
|
|
414
|
+
spec: OpWavesSpec;
|
|
415
|
+
k: number;
|
|
416
|
+
wave: OpWave;
|
|
417
|
+
cwd: string;
|
|
418
|
+
exec: OpWaveExec;
|
|
419
|
+
log: (line: string) => void;
|
|
420
|
+
result: RunOpWaveResult;
|
|
211
421
|
}
|
|
212
422
|
|
|
213
|
-
function planRun(
|
|
214
|
-
opts: Required<Pick<RunOpWaveOptions, "spec" | "wave">> & { cwd: string; exec: OpWaveExec },
|
|
215
|
-
waveName: string,
|
|
216
|
-
run: OpWaveRun,
|
|
217
|
-
): OpWaveMember {
|
|
423
|
+
function planRun(opts: { spec: OpWavesSpec; wave: number; cwd: string; exec: OpWaveExec }, waveName: string, run: OpWaveRun): OpWaveMember {
|
|
218
424
|
const op = run.op ?? opts.spec.op;
|
|
219
425
|
const plan = opWavePlanPath(opts.spec, opts.wave, run.target);
|
|
220
426
|
mkdirSync(dirname(join(opts.cwd, plan)), { recursive: true });
|
|
221
|
-
const code = opts.exec(opWaveArgv(opts.spec.plan
|
|
427
|
+
const code = opts.exec(opWaveArgv(opts.spec.plan!, { op, target: run.target, wave: waveName, plan }), opts.cwd);
|
|
222
428
|
if (code !== 0) throw new Error(`planning ${run.target} exited ${code}`);
|
|
223
429
|
let file: OpWavePlanFile;
|
|
224
430
|
try {
|
|
@@ -229,8 +435,12 @@ function planRun(
|
|
|
229
435
|
if (typeof file.planDigest !== "string" || file.planDigest === "") {
|
|
230
436
|
throw new Error(`the plan file for ${run.target} (${plan}) has no planDigest`);
|
|
231
437
|
}
|
|
438
|
+
return member(run.target, op, file);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function member(target: string, op: string, file: OpWavePlanFile): OpWaveMember {
|
|
232
442
|
return {
|
|
233
|
-
member:
|
|
443
|
+
member: target,
|
|
234
444
|
planDigest: file.planDigest,
|
|
235
445
|
op,
|
|
236
446
|
...(file.destructive === true ? { destructive: true } : {}),
|
|
@@ -238,6 +448,285 @@ function planRun(
|
|
|
238
448
|
};
|
|
239
449
|
}
|
|
240
450
|
|
|
451
|
+
/** Write the `{runs}` file: the runs, with their plans once planned. */
|
|
452
|
+
function writeRunsFile(
|
|
453
|
+
at: { spec: OpWavesSpec; k: number; wave: OpWave; cwd: string },
|
|
454
|
+
runs: readonly OpWaveRun[],
|
|
455
|
+
members?: ReadonlyMap<string, OpWaveMember>,
|
|
456
|
+
digest?: string,
|
|
457
|
+
path: string = opWaveRunsPath(at.spec, at.k),
|
|
458
|
+
): string {
|
|
459
|
+
const file: OpWaveRunsFile = {
|
|
460
|
+
wave: at.wave.name,
|
|
461
|
+
number: at.k,
|
|
462
|
+
...(digest ? { digest } : {}),
|
|
463
|
+
runs: runs.map((run) => {
|
|
464
|
+
const m = members?.get(run.target);
|
|
465
|
+
return {
|
|
466
|
+
target: run.target,
|
|
467
|
+
op: run.op ?? at.spec.op,
|
|
468
|
+
...(m ? { planDigest: m.planDigest, ...(m.destructive ? { destructive: true } : {}), ...(m.empty ? { empty: true } : {}) } : {}),
|
|
469
|
+
};
|
|
470
|
+
}),
|
|
471
|
+
};
|
|
472
|
+
mkdirSync(dirname(join(at.cwd, path)), { recursive: true });
|
|
473
|
+
writeFileSync(join(at.cwd, path), JSON.stringify(file, null, 2) + "\n");
|
|
474
|
+
return path;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Plan `runs` of wave `k`: one `plan` call per run, or one `planWave` call
|
|
479
|
+
* for all of them (#3762). A wave plan is written out as each run's plan file
|
|
480
|
+
* too, so a per-run `apply` or hook reads `{plan}` either way.
|
|
481
|
+
*/
|
|
482
|
+
function planRuns(at: { spec: OpWavesSpec; k: number; wave: OpWave; cwd: string; exec: OpWaveExec }, runs: readonly OpWaveRun[]): OpWaveMember[] {
|
|
483
|
+
const { spec, k, wave, cwd, exec } = at;
|
|
484
|
+
if (!spec.planWave) return runs.map((run) => planRun({ spec, wave: k, cwd, exec }, wave.name, run));
|
|
485
|
+
const runsPath = writeRunsFile(at, runs);
|
|
486
|
+
const plans = opWavePlansPath(spec, k);
|
|
487
|
+
rmSync(join(cwd, plans), { force: true });
|
|
488
|
+
const code = exec(opWaveArgv(spec.planWave, { op: spec.op, wave: wave.name, runs: runsPath, plans }), cwd);
|
|
489
|
+
if (code !== 0) throw new Error(`planning wave ${k} (${wave.name}) exited ${code}`);
|
|
490
|
+
let parsed: unknown;
|
|
491
|
+
try {
|
|
492
|
+
parsed = JSON.parse(readFileSync(join(cwd, plans), "utf-8"));
|
|
493
|
+
} catch (err) {
|
|
494
|
+
throw new Error(`planning wave ${k} (${wave.name}) wrote no readable plans file at ${plans}: ${err instanceof Error ? err.message : String(err)}`);
|
|
495
|
+
}
|
|
496
|
+
const entries = Array.isArray(parsed) ? parsed : (parsed as { runs?: unknown } | null)?.runs;
|
|
497
|
+
if (!Array.isArray(entries)) throw new Error(`the plans file of wave ${k} (${plans}) must hold { "runs": [{ "target", "planDigest" }] }`);
|
|
498
|
+
const wanted = new Set(runs.map((r) => r.target));
|
|
499
|
+
const byTarget = new Map<string, OpWavePlanFile>();
|
|
500
|
+
for (const entry of entries as Array<Partial<OpWavePlanFile & { target: string }>>) {
|
|
501
|
+
const target = entry?.target;
|
|
502
|
+
if (typeof target !== "string" || !wanted.has(target)) {
|
|
503
|
+
throw new Error(`the plans file of wave ${k} (${plans}) plans ${JSON.stringify(target)}, which is not a run it was asked to plan`);
|
|
504
|
+
}
|
|
505
|
+
if (byTarget.has(target)) throw new Error(`the plans file of wave ${k} (${plans}) plans ${target} twice`);
|
|
506
|
+
if (typeof entry.planDigest !== "string" || entry.planDigest === "") throw new Error(`the plans file of wave ${k} (${plans}) has no planDigest for ${target}`);
|
|
507
|
+
byTarget.set(target, entry as OpWavePlanFile);
|
|
508
|
+
}
|
|
509
|
+
const missing = runs.filter((r) => !byTarget.has(r.target)).map((r) => r.target);
|
|
510
|
+
if (missing.length > 0) throw new Error(`the plans file of wave ${k} (${plans}) has no plan for ${missing.join(", ")}`);
|
|
511
|
+
return runs.map((run) => {
|
|
512
|
+
const m = member(run.target, run.op ?? spec.op, byTarget.get(run.target)!);
|
|
513
|
+
const path = join(cwd, opWavePlanPath(spec, k, run.target));
|
|
514
|
+
writeFileSync(path, JSON.stringify({ planDigest: m.planDigest, ...(m.destructive ? { destructive: true } : {}), ...(m.empty ? { empty: true } : {}) }) + "\n");
|
|
515
|
+
return m;
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** What one moment's hooks came to. */
|
|
520
|
+
interface HooksOutcome {
|
|
521
|
+
/** Set when a hook with `onFailure: fail` failed; the hooks after it did not run. */
|
|
522
|
+
failed?: string;
|
|
523
|
+
/** The `onFailure: approve` hooks that failed. */
|
|
524
|
+
holds: string[];
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** Where hooks run: the wave they belong to and the job's tools. */
|
|
528
|
+
interface HookPlace {
|
|
529
|
+
/** The spec's name and `op`. */
|
|
530
|
+
name: string;
|
|
531
|
+
op: string;
|
|
532
|
+
waveName: string;
|
|
533
|
+
k: number;
|
|
534
|
+
cwd: string;
|
|
535
|
+
exec: OpWaveExec;
|
|
536
|
+
log: (line: string) => void;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Run one moment's hooks, in order: each once for the wave, or once per run
|
|
541
|
+
* for a `perRun` hook. A hook that fails stops the rest, unless its
|
|
542
|
+
* `onFailure` is `approve`: then it holds the wave and the hooks after it run.
|
|
543
|
+
*/
|
|
544
|
+
function runHookList(
|
|
545
|
+
at: HookPlace,
|
|
546
|
+
moment: OpWaveHookMoment,
|
|
547
|
+
hooks: readonly OpWaveHook[] | undefined,
|
|
548
|
+
runs: readonly OpWaveHookRun[],
|
|
549
|
+
runsPath: string,
|
|
550
|
+
): HooksOutcome & { outcomes: OpWaveHookOutcome[] } {
|
|
551
|
+
const out: HooksOutcome & { outcomes: OpWaveHookOutcome[] } = { holds: [], outcomes: [] };
|
|
552
|
+
if (!hooks || hooks.length === 0) return out;
|
|
553
|
+
const { name: specName, op, waveName, k, cwd, exec, log } = at;
|
|
554
|
+
const env = { CHANT_OP_WAVES: specName, CHANT_WAVE: waveName, CHANT_WAVE_NUMBER: String(k), CHANT_HOOK: moment, CHANT_RUNS: runsPath };
|
|
555
|
+
for (const hook of hooks) {
|
|
556
|
+
const name = hook.name ?? (hook.command.join(" ").length > 60 ? `${hook.command.join(" ").slice(0, 57)}...` : hook.command.join(" "));
|
|
557
|
+
const calls = hook.perRun
|
|
558
|
+
? runs.map((run) => ({
|
|
559
|
+
target: run.target as string | undefined,
|
|
560
|
+
argv: opWaveArgv(hook.command, { op: run.op, wave: waveName, target: run.target, runs: runsPath, ...(run.plan ? { plan: run.plan } : {}) }),
|
|
561
|
+
env: { ...env, CHANT_TARGET: run.target, ...(run.plan ? { CHANT_PLAN: run.plan } : {}) } as Record<string, string>,
|
|
562
|
+
}))
|
|
563
|
+
: [{ target: undefined, argv: opWaveArgv(hook.command, { op, wave: waveName, runs: runsPath }), env: env as Record<string, string> }];
|
|
564
|
+
for (const call of calls) {
|
|
565
|
+
const label = `${moment} hook ${name}${call.target ? ` (${call.target})` : ""}`;
|
|
566
|
+
let code: number;
|
|
567
|
+
try {
|
|
568
|
+
code = exec(call.argv, cwd, call.env);
|
|
569
|
+
} catch (err) {
|
|
570
|
+
log(`${label} did not run: ${err instanceof Error ? err.message : String(err)}`);
|
|
571
|
+
code = 127;
|
|
572
|
+
}
|
|
573
|
+
const base = { moment, name, ...(call.target ? { target: call.target } : {}), exit: code };
|
|
574
|
+
if (code === 0) {
|
|
575
|
+
out.outcomes.push({ ...base, outcome: "passed" });
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
578
|
+
if (hook.onFailure === "approve") {
|
|
579
|
+
out.outcomes.push({ ...base, outcome: "held" });
|
|
580
|
+
out.holds.push(label);
|
|
581
|
+
log(`${label} exited ${code}, so wave ${k} (${waveName}) waits for an approval of its set digest`);
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
584
|
+
out.outcomes.push({ ...base, outcome: "failed" });
|
|
585
|
+
out.failed = `${label} exited ${code}`;
|
|
586
|
+
log(out.failed);
|
|
587
|
+
return out;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
return out;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const placeOf = (ctx: WaveContext): HookPlace => ({ name: ctx.spec.name, op: ctx.spec.op, waveName: ctx.wave.name, k: ctx.k, cwd: ctx.cwd, exec: ctx.exec, log: ctx.log });
|
|
594
|
+
|
|
595
|
+
const hookRuns = (spec: OpWavesSpec, k: number, runs: readonly OpWaveRun[], members?: ReadonlyMap<string, OpWaveMember>): OpWaveHookRun[] =>
|
|
596
|
+
runs.map((run) => ({ target: run.target, op: run.op ?? spec.op, ...(members?.has(run.target) ? { plan: opWavePlanPath(spec, k, run.target) } : {}) }));
|
|
597
|
+
|
|
598
|
+
/** Run one moment's hooks in the wave job (#3762), recording each outcome on the result. */
|
|
599
|
+
function runHooks(
|
|
600
|
+
ctx: WaveContext,
|
|
601
|
+
moment: OpWaveHookMoment,
|
|
602
|
+
hooks: readonly OpWaveHook[] | undefined,
|
|
603
|
+
runs: readonly OpWaveRun[],
|
|
604
|
+
members?: ReadonlyMap<string, OpWaveMember>,
|
|
605
|
+
digest?: string,
|
|
606
|
+
): HooksOutcome {
|
|
607
|
+
if (!hooks || hooks.length === 0) return { holds: [] };
|
|
608
|
+
const runsPath = writeRunsFile(ctx, runs, members, digest);
|
|
609
|
+
const out = runHookList(placeOf(ctx), moment, hooks, hookRuns(ctx.spec, ctx.k, runs, members), runsPath);
|
|
610
|
+
ctx.result.hooks.push(...out.outcomes);
|
|
611
|
+
return { holds: out.holds, ...(out.failed ? { failed: out.failed } : {}) };
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Read the hooks file chant hands an apply command (#3804): the one at
|
|
616
|
+
* `path`, by default `CHANT_HOOKS`. Null when there is none, as for an apply
|
|
617
|
+
* that a wave job did not start.
|
|
618
|
+
*/
|
|
619
|
+
export function readOpWaveHooksFile(path: string | undefined = process.env.CHANT_HOOKS, cwd: string = process.cwd()): OpWaveHooksFile | null {
|
|
620
|
+
if (!path) return null;
|
|
621
|
+
const at = isAbsolute(path) ? path : join(cwd, path);
|
|
622
|
+
let parsed: OpWaveHooksFile;
|
|
623
|
+
try {
|
|
624
|
+
parsed = JSON.parse(readFileSync(at, "utf-8")) as OpWaveHooksFile;
|
|
625
|
+
} catch (err) {
|
|
626
|
+
throw new Error(`the hooks file ${path} cannot be read: ${err instanceof Error ? err.message : String(err)}`);
|
|
627
|
+
}
|
|
628
|
+
if (parsed?.version !== 1 || !Array.isArray(parsed.runs) || typeof parsed.hooks !== "object") throw new Error(`${path} is not an Op waves hooks file (version 1)`);
|
|
629
|
+
return parsed;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Run one moment's hooks from a hooks file (#3804): what an apply command
|
|
634
|
+
* calls under its own lock, `before-apply` right before it changes anything
|
|
635
|
+
* and `after-apply` once it has, when the spec's `hooks.apply.runBy` at base
|
|
636
|
+
* is `apply`. Also what a product's apply outside the waves calls, with a
|
|
637
|
+
* file from {@link opWaveHooksAtBase}. A moment the file has no hooks for
|
|
638
|
+
* runs nothing and proceeds, so an apply can call it whoever runs the hooks.
|
|
639
|
+
*
|
|
640
|
+
* Each call adds its outcomes to the file's `result` (`CHANT_HOOKS_RESULT`),
|
|
641
|
+
* which chant reads back after the apply exits: the outcomes join the wave's
|
|
642
|
+
* result and decision record, and a hold makes the wave wait for, or find,
|
|
643
|
+
* an approval of its digest. `exec` runs one hook; inject it to run a hook
|
|
644
|
+
* in process or to keep its output.
|
|
645
|
+
*/
|
|
646
|
+
export function runOpWaveApplyHooks(
|
|
647
|
+
file: OpWaveHooksFile,
|
|
648
|
+
moment: OpWaveHookMoment,
|
|
649
|
+
options: { cwd?: string; exec?: OpWaveExec; log?: (line: string) => void } = {},
|
|
650
|
+
): OpWaveApplyHooksRun {
|
|
651
|
+
const cwd = options.cwd ?? process.cwd();
|
|
652
|
+
const log = options.log ?? ((line: string) => console.error(line));
|
|
653
|
+
let run: OpWaveApplyHooksRun;
|
|
654
|
+
if (file.refused) {
|
|
655
|
+
log(`${file.refused}, so none of the ${moment} hooks run and the apply does not go on`);
|
|
656
|
+
run = { moment, outcomes: [], held: [file.refused], proceed: false };
|
|
657
|
+
} else {
|
|
658
|
+
const place: HookPlace = { name: file.opWaves, op: file.op, waveName: file.wave, k: file.number, cwd, exec: options.exec ?? spawnExec, log };
|
|
659
|
+
const out = runHookList(place, moment, file.hooks[moment], file.runs, file.runsFile);
|
|
660
|
+
run = {
|
|
661
|
+
moment,
|
|
662
|
+
outcomes: out.outcomes,
|
|
663
|
+
...(out.failed ? { failed: out.failed } : {}),
|
|
664
|
+
held: out.holds,
|
|
665
|
+
proceed: !out.failed && (out.holds.length === 0 || file.approved),
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
if (file.result) {
|
|
669
|
+
const at = isAbsolute(file.result) ? file.result : join(cwd, file.result);
|
|
670
|
+
let prior: OpWaveHooksResult = { version: 1, moments: [], outcomes: [] };
|
|
671
|
+
if (existsSync(at)) {
|
|
672
|
+
try {
|
|
673
|
+
prior = JSON.parse(readFileSync(at, "utf-8")) as OpWaveHooksResult;
|
|
674
|
+
} catch {
|
|
675
|
+
// A result file that cannot be read is started again.
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
const next: OpWaveHooksResult = { version: 1, moments: [...prior.moments, moment], outcomes: [...prior.outcomes, ...run.outcomes] };
|
|
679
|
+
mkdirSync(dirname(at), { recursive: true });
|
|
680
|
+
writeFileSync(at, JSON.stringify(next, null, 2) + "\n");
|
|
681
|
+
}
|
|
682
|
+
return run;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* The hooks of wave `wave` read at base, for an apply outside the waves
|
|
687
|
+
* (#3804): a product's own `apply <env>` that runs the same Op as the wave
|
|
688
|
+
* does, and the same hooks. All four moments are in the file; the apply runs
|
|
689
|
+
* the ones it has a stage for with {@link runOpWaveApplyHooks}. Nothing is
|
|
690
|
+
* approved: a held hook stops the apply. The wave or the file missing at
|
|
691
|
+
* base has no hooks, as in a wave job.
|
|
692
|
+
*/
|
|
693
|
+
export function opWaveHooksAtBase(options: {
|
|
694
|
+
specFile: string;
|
|
695
|
+
wave: string;
|
|
696
|
+
/** The runs this apply applies. Default: the wave's runs in the working tree's spec. */
|
|
697
|
+
runs?: ReadonlyArray<{ target: string; op?: string }>;
|
|
698
|
+
/** Default: the spec's `base`, else `HEAD^1`. */
|
|
699
|
+
base?: string;
|
|
700
|
+
cwd?: string;
|
|
701
|
+
show?: OpWaveShowAtBase;
|
|
702
|
+
}): OpWaveHooksFile {
|
|
703
|
+
const cwd = options.cwd ?? process.cwd();
|
|
704
|
+
const spec = parseOpWavesSpec(readFileSync(isAbsolute(options.specFile) ? options.specFile : join(cwd, options.specFile), "utf-8"), options.specFile);
|
|
705
|
+
const k = spec.waves.findIndex((w) => w.name === options.wave) + 1;
|
|
706
|
+
const rules = readOpWavePolicy(options.specFile, options.wave, options.base ?? spec.base ?? "HEAD^1", cwd, options.show);
|
|
707
|
+
const runs = (options.runs ?? spec.waves[k - 1]?.runs ?? []).map((r) => ({ target: r.target, op: r.op ?? spec.op }));
|
|
708
|
+
const runsFile = `.chant/op-waves/${spec.name}/${options.wave.replace(/[^A-Za-z0-9._-]/g, "_")}.runs.json`;
|
|
709
|
+
const runsBody: OpWaveRunsFile = { wave: options.wave, number: k, runs };
|
|
710
|
+
mkdirSync(dirname(join(cwd, runsFile)), { recursive: true });
|
|
711
|
+
writeFileSync(join(cwd, runsFile), JSON.stringify(runsBody, null, 2) + "\n");
|
|
712
|
+
const hooks = Object.fromEntries(Object.entries(rules.hooks).filter(([, list]) => list.length > 0));
|
|
713
|
+
return {
|
|
714
|
+
version: 1,
|
|
715
|
+
opWaves: spec.name,
|
|
716
|
+
op: spec.op,
|
|
717
|
+
wave: options.wave,
|
|
718
|
+
number: k,
|
|
719
|
+
base: rules.base,
|
|
720
|
+
approved: false,
|
|
721
|
+
runBy: "apply",
|
|
722
|
+
runs,
|
|
723
|
+
runsFile,
|
|
724
|
+
hooks,
|
|
725
|
+
outcomes: [],
|
|
726
|
+
...(rules.hooksRefused ? { refused: rules.hooksRefused } : {}),
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
|
|
241
730
|
/** Whether `policy` lets a wave with these members through without an approval. */
|
|
242
731
|
export function opWaveNeedsApproval(policy: OpWaveGatePolicy, members: readonly OpWaveMember[]): boolean {
|
|
243
732
|
if (policy === "never") return false;
|
|
@@ -246,6 +735,15 @@ export function opWaveNeedsApproval(policy: OpWaveGatePolicy, members: readonly
|
|
|
246
735
|
return true;
|
|
247
736
|
}
|
|
248
737
|
|
|
738
|
+
/** Log when the change's hooks are not the ones at base, which are the ones that run. */
|
|
739
|
+
function noteHooksAtBase(ctx: WaveContext, rules: OpWaveBaseRules): void {
|
|
740
|
+
const { hooks: atBase, base } = rules;
|
|
741
|
+
const own = opWaveHooksAt(ctx.spec, ctx.wave.name);
|
|
742
|
+
if (JSON.stringify(own) !== JSON.stringify(atBase) || opWaveApplyHooksRunBy(ctx.spec, ctx.wave.name) !== rules.applyHooksRunBy) {
|
|
743
|
+
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`);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
249
747
|
/** Plan, decide and apply one wave, or one part of a wide wave. */
|
|
250
748
|
export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveResult> {
|
|
251
749
|
const { spec, wave: k } = options;
|
|
@@ -259,9 +757,9 @@ export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveRes
|
|
|
259
757
|
if (options.share !== undefined && (options.share < 1 || options.share > shares)) {
|
|
260
758
|
throw new Error(`wave ${k} (${wave.name}) has ${shares} share${shares === 1 ? "" : "s"}; there is no share ${options.share}.`);
|
|
261
759
|
}
|
|
262
|
-
const ctx = { spec, wave: k, cwd, exec };
|
|
263
760
|
const recordPath = join(cwd, opWaveRecordPath(spec, k));
|
|
264
|
-
const result: RunOpWaveResult = { exitCode: 0, applied: [], failed: [], moved: [] };
|
|
761
|
+
const result: RunOpWaveResult = { exitCode: 0, applied: [], failed: [], moved: [], hooks: [] };
|
|
762
|
+
const ctx: WaveContext = { spec, k, wave, cwd, exec, log, result };
|
|
265
763
|
|
|
266
764
|
if (options.share !== undefined) {
|
|
267
765
|
let decision: OpWaveDecision;
|
|
@@ -275,78 +773,80 @@ export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveRes
|
|
|
275
773
|
log(`wave ${k} (${wave.name}) is waiting on ${decision.gate}; nothing applied. ${decision.approve ?? ""}`.trim());
|
|
276
774
|
return { ...result, exitCode: 3 };
|
|
277
775
|
}
|
|
776
|
+
// The share runs the hooks its deciding job read at base, around its own slice.
|
|
777
|
+
const slice = opWaveShare(wave.runs, options.share, shares);
|
|
778
|
+
const before = runHooks(ctx, "before-plan", decision.hooks?.["before-plan"], slice);
|
|
779
|
+
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`);
|
|
780
|
+
const members = planRuns(ctx, slice);
|
|
781
|
+
const planned = new Map(members.map((m) => [m.member, m]));
|
|
782
|
+
const after = runHooks(ctx, "after-plan", decision.hooks?.["after-plan"], slice, planned);
|
|
783
|
+
if (after.failed) return failed(ctx, `a hook after the plan failed, so nothing in share ${options.share} of wave ${k} was applied`);
|
|
278
784
|
const decided = new Map(decision.members.map((m) => [m.member, m.planDigest]));
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
785
|
+
const unmoved: OpWaveRun[] = [];
|
|
786
|
+
for (const run of slice) {
|
|
787
|
+
const now = planned.get(run.target)!;
|
|
788
|
+
if (decided.get(run.target) !== now.planDigest) {
|
|
282
789
|
log(
|
|
283
790
|
`${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: ${
|
|
791
|
+
`decided: ${decided.get(run.target) ?? "(not in the decision)"}; planned now: ${now.planDigest}`,
|
|
285
792
|
);
|
|
286
793
|
result.moved.push(run.target);
|
|
287
794
|
continue;
|
|
288
795
|
}
|
|
289
|
-
|
|
796
|
+
unmoved.push(run);
|
|
290
797
|
}
|
|
291
|
-
|
|
798
|
+
await applyStage(options, ctx, decision, unmoved, planned, [...before.holds, ...after.holds]);
|
|
799
|
+
if (result.exitCode === 0 && result.moved.length > 0) result.exitCode = 4;
|
|
292
800
|
return result;
|
|
293
801
|
}
|
|
294
802
|
|
|
295
|
-
|
|
803
|
+
// The policy, the approval mode and the hooks, read at base before anything runs.
|
|
804
|
+
const rules = readOpWavePolicy(options.specFile, wave.name, options.base ?? spec.base ?? "HEAD^1", cwd, options.show);
|
|
805
|
+
const { policy, approval, source, base } = rules;
|
|
806
|
+
noteHooksAtBase(ctx, rules);
|
|
807
|
+
const holds: string[] = [];
|
|
808
|
+
if (rules.hooksRefused) {
|
|
809
|
+
holds.push(rules.hooksRefused);
|
|
810
|
+
log(`wave ${k} (${wave.name}): ${rules.hooksRefused}, so none run and the wave waits for an approval`);
|
|
811
|
+
}
|
|
812
|
+
const before = runHooks(ctx, "before-plan", rules.hooks["before-plan"], wave.runs);
|
|
813
|
+
if (before.failed) return failed(ctx, `a hook before the plan failed, so nothing in wave ${k} was planned or applied`);
|
|
814
|
+
const members = planRuns(ctx, wave.runs);
|
|
815
|
+
const planned = new Map(members.map((m) => [m.member, m]));
|
|
296
816
|
const digest = waveSetDigest(members);
|
|
297
|
-
const
|
|
817
|
+
const after = runHooks(ctx, "after-plan", rules.hooks["after-plan"], wave.runs, planned, digest);
|
|
818
|
+
if (after.failed) return failed(ctx, `a hook after the plan failed, so nothing in wave ${k} was applied`);
|
|
819
|
+
holds.push(...before.holds, ...after.holds);
|
|
298
820
|
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
|
-
|
|
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
|
-
}
|
|
821
|
+
const hooks = Object.fromEntries(Object.entries(rules.hooks).filter(([, list]) => list.length > 0));
|
|
822
|
+
const decision: OpWaveDecision = {
|
|
823
|
+
wave: k,
|
|
824
|
+
name: wave.name,
|
|
825
|
+
op: spec.name,
|
|
826
|
+
gate,
|
|
827
|
+
policy,
|
|
828
|
+
approval,
|
|
829
|
+
policySource: source,
|
|
830
|
+
base,
|
|
831
|
+
digest,
|
|
832
|
+
members,
|
|
833
|
+
status: "not-required",
|
|
834
|
+
...(Object.keys(hooks).length > 0 ? { hooks } : {}),
|
|
835
|
+
...(holds.length > 0 ? { heldBy: holds } : {}),
|
|
836
|
+
...(rules.spend ? { spend: "base" as const } : spec.approvals?.spend === true ? { spend: "change" as const } : {}),
|
|
837
|
+
...(rules.applyHooksRunBy === "apply" ? { applyHooksRunBy: "apply" as const } : {}),
|
|
838
|
+
};
|
|
839
|
+
if (opWaveNeedsApproval(policy, members) || holds.length > 0) await settleGate(options, ctx, decision);
|
|
840
|
+
// A wide wave's shares apply under the deciding job's approval, so it is spent here, once.
|
|
841
|
+
if (options.decide && !(await spendApproval(options, ctx, decision))) return ctx.result;
|
|
343
842
|
mkdirSync(dirname(recordPath), { recursive: true });
|
|
344
|
-
|
|
843
|
+
writeRecord(recordPath, decision, result);
|
|
345
844
|
result.decision = decision;
|
|
346
845
|
log(
|
|
347
846
|
`wave ${k} (${wave.name}): gate ${policy} (read at ${base.slice(0, 12)}${source === "absent-at-base" ? ", where the wave is absent" : ""}), ` +
|
|
348
847
|
`digest ${digest}: ${decision.status}${decision.approvedBy ? ` by ${decision.approvedBy}` : ""}` +
|
|
349
|
-
(decision.via === "pr-review" ? ` (review of #${decision.review?.pr})` : "")
|
|
848
|
+
(decision.via === "pr-review" ? ` (review of #${decision.review?.pr})` : "") +
|
|
849
|
+
(holds.length > 0 ? `; held by ${holds.join(", ")}` : ""),
|
|
350
850
|
);
|
|
351
851
|
if (approval === "pr-review" && decision.status === "waiting" && decision.review?.reason) {
|
|
352
852
|
log(`The pull request's review does not approve wave ${k}: ${decision.review.reason}`);
|
|
@@ -356,37 +856,329 @@ export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveRes
|
|
|
356
856
|
return { ...result, exitCode: 3 };
|
|
357
857
|
}
|
|
358
858
|
if (options.decide) return result;
|
|
359
|
-
|
|
360
|
-
result.exitCode = result.failed.length > 0 ? 1 : 0;
|
|
859
|
+
await applyStage(options, ctx, decision, wave.runs, planned, [], recordPath);
|
|
361
860
|
return result;
|
|
362
861
|
}
|
|
363
862
|
|
|
364
|
-
function
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
const
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
863
|
+
function failed(ctx: WaveContext, line: string): RunOpWaveResult {
|
|
864
|
+
ctx.log(line);
|
|
865
|
+
ctx.result.exitCode = 1;
|
|
866
|
+
return ctx.result;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
/**
|
|
870
|
+
* Decide whether an approval of the decision's digest stands, whatever its
|
|
871
|
+
* policy: sets its status to `approved` or `waiting`. Called when the policy
|
|
872
|
+
* asks for one, or when a hook holds the wave (#3762).
|
|
873
|
+
*/
|
|
874
|
+
async function settleGate(options: RunOpWaveOptions, ctx: WaveContext, decision: OpWaveDecision): Promise<void> {
|
|
875
|
+
const { spec, k, wave, cwd } = ctx;
|
|
876
|
+
const { gate, digest, approval, base, policy, members } = decision;
|
|
877
|
+
const ledger = options.gates ?? gitGateLedgerPort({ cwd });
|
|
878
|
+
let port = ledger;
|
|
879
|
+
if (approval === "sealed") {
|
|
880
|
+
// #3684: only an approval sealed by a signer listed at base counts.
|
|
881
|
+
const review = await import("./gate-review");
|
|
882
|
+
const sealed = review.sealedApprovalRule(gate, (options.trust ?? ((b) => review.trustPolicyAt(cwd, b)))(base));
|
|
883
|
+
port = {
|
|
884
|
+
...ledger,
|
|
885
|
+
async approvalRule(g) {
|
|
886
|
+
const own = (await ledger.approvalRule?.(g)) ?? null;
|
|
887
|
+
return g === gate ? review.bothRules(own, sealed) : own;
|
|
888
|
+
},
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
const now = options.now ?? new Date().toISOString();
|
|
892
|
+
// #3784: under the spend rule at base, an approval a wave already applied under counts for nothing.
|
|
893
|
+
const spent: AppliedGateRecord[] | undefined = decision.spend === "base" ? ((await ledger.readApplied?.(spec.name)) ?? []).filter((a) => a.gate === gate) : undefined;
|
|
894
|
+
const held = decision.heldBy?.length ? `, held by ${decision.heldBy.join(", ")}` : "";
|
|
895
|
+
const check = await evaluateGate(port, {
|
|
896
|
+
op: spec.name,
|
|
897
|
+
gate,
|
|
898
|
+
planDigest: digest,
|
|
899
|
+
description: `wave ${k} (${wave.name}): ${members.length} run${members.length === 1 ? "" : "s"}, gate ${policy}, approval ${approval}${held}`,
|
|
900
|
+
now,
|
|
901
|
+
...(spent ? { spent } : {}),
|
|
902
|
+
});
|
|
903
|
+
if (check.satisfied) {
|
|
904
|
+
decision.status = "approved";
|
|
905
|
+
decision.via = "ledger";
|
|
906
|
+
decision.approvedBy = check.resolution.resolvedBy;
|
|
907
|
+
decision.approvedAt = check.resolution.timestamp;
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
const used = spent?.filter((a) => isApprovalSpent([a], { gate, planDigest: digest, timestamp: a.approvedAt })).pop();
|
|
911
|
+
if (used) {
|
|
912
|
+
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`);
|
|
913
|
+
}
|
|
914
|
+
if (approval === "pr-review") {
|
|
915
|
+
const byReview = await approveByReview(options, { op: spec.name, gate, k, digest, cwd, now, port: ledger, ...(spent ? { spent } : {}) });
|
|
916
|
+
decision.review = byReview.review;
|
|
917
|
+
if (byReview.approved) {
|
|
918
|
+
decision.status = "approved";
|
|
919
|
+
decision.via = "pr-review";
|
|
920
|
+
decision.approvedBy = byReview.review.approvers!.join(", ");
|
|
921
|
+
decision.approvedAt = now;
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
decision.status = "waiting";
|
|
925
|
+
decision.approve = approveCommand(spec.name, gate, undefined, digest);
|
|
926
|
+
return;
|
|
381
927
|
}
|
|
928
|
+
decision.status = "waiting";
|
|
929
|
+
decision.approve = approveCommand(spec.name, gate, undefined, digest, approval === "sealed");
|
|
382
930
|
}
|
|
383
931
|
|
|
384
|
-
/**
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
932
|
+
/** Write the decision record, with every hook's outcome so far (#3804). */
|
|
933
|
+
function writeRecord(recordPath: string, decision: OpWaveDecision, result: RunOpWaveResult): void {
|
|
934
|
+
if (result.hooks.length > 0) decision.outcomes = [...result.hooks];
|
|
935
|
+
writeFileSync(recordPath, JSON.stringify(decision, null, 2) + "\n");
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
/**
|
|
939
|
+
* A hold waits only when nobody approved the decided digest: settle the gate
|
|
940
|
+
* once for `holds`. False when the wave now waits (exit 3).
|
|
941
|
+
*/
|
|
942
|
+
async function settleHolds(
|
|
943
|
+
options: RunOpWaveOptions,
|
|
944
|
+
ctx: WaveContext,
|
|
945
|
+
decision: OpWaveDecision,
|
|
946
|
+
holds: readonly string[],
|
|
947
|
+
recordPath?: string,
|
|
948
|
+
): Promise<boolean> {
|
|
949
|
+
const { k, wave, log, result } = ctx;
|
|
950
|
+
if (holds.length === 0 || decision.status === "approved") return true;
|
|
951
|
+
decision.heldBy = [...(decision.heldBy ?? []), ...holds];
|
|
952
|
+
await settleGate(options, ctx, decision);
|
|
953
|
+
if (recordPath) writeRecord(recordPath, decision, result);
|
|
954
|
+
if (decision.status === "waiting") {
|
|
955
|
+
log(`wave ${k} (${wave.name}) is held by ${holds.join(", ")}, and nobody approved ${decision.digest}; nothing applied. Approve: ${decision.approve}`);
|
|
956
|
+
result.exitCode = 3;
|
|
957
|
+
return false;
|
|
958
|
+
}
|
|
959
|
+
log(`wave ${k} (${wave.name}) is held by ${holds.join(", ")}, and ${decision.approvedBy} approved its digest`);
|
|
960
|
+
return true;
|
|
388
961
|
}
|
|
389
962
|
|
|
963
|
+
/** How one apply call came out. `applied` with a `problem`: the runs applied, and the wave still fails. */
|
|
964
|
+
type ApplyCall = { outcome: "applied"; problem?: string } | { outcome: "failed" | "waiting" };
|
|
965
|
+
|
|
966
|
+
/**
|
|
967
|
+
* Call one apply command (#3804) with `CHANT_HOOKS` and `CHANT_HOOKS_RESULT`.
|
|
968
|
+
* When the apply runs the apply-stage hooks, read their outcomes back: a hold
|
|
969
|
+
* with no approval standing settles the gate and, once approved, calls the
|
|
970
|
+
* apply again; an `after-apply` failure means the runs applied and the wave
|
|
971
|
+
* fails; an apply that exited 0 without running its hooks fails the wave.
|
|
972
|
+
*/
|
|
973
|
+
async function callApply(
|
|
974
|
+
options: RunOpWaveOptions,
|
|
975
|
+
ctx: WaveContext,
|
|
976
|
+
decision: OpWaveDecision,
|
|
977
|
+
runs: readonly OpWaveRun[],
|
|
978
|
+
planned: ReadonlyMap<string, OpWaveMember>,
|
|
979
|
+
argv: string[],
|
|
980
|
+
call: { label: string; key: string; runsPath: string },
|
|
981
|
+
recordPath?: string,
|
|
982
|
+
): Promise<ApplyCall> {
|
|
983
|
+
const { spec, k, wave, cwd, exec, log, result } = ctx;
|
|
984
|
+
const byApply = decision.applyHooksRunBy === "apply";
|
|
985
|
+
const dir = `.chant/op-waves/${spec.name}/wave-${k}`;
|
|
986
|
+
const filePath = `${dir}/${call.key}.json`;
|
|
987
|
+
const resultPath = `${dir}/${call.key}-result.json`;
|
|
988
|
+
const handed: Partial<Record<OpWaveHookMoment, OpWaveHook[]>> = {};
|
|
989
|
+
if (byApply) {
|
|
990
|
+
for (const m of ["before-apply", "after-apply"] as const) if (decision.hooks?.[m]?.length) handed[m] = decision.hooks[m];
|
|
991
|
+
}
|
|
992
|
+
for (let pass = 0; pass < 2; pass++) {
|
|
993
|
+
const approved = decision.status === "approved";
|
|
994
|
+
const file: OpWaveHooksFile = {
|
|
995
|
+
version: 1,
|
|
996
|
+
opWaves: spec.name,
|
|
997
|
+
op: spec.op,
|
|
998
|
+
wave: wave.name,
|
|
999
|
+
number: k,
|
|
1000
|
+
base: decision.base,
|
|
1001
|
+
digest: decision.digest,
|
|
1002
|
+
approved,
|
|
1003
|
+
...(decision.heldBy?.length ? { heldBy: decision.heldBy } : {}),
|
|
1004
|
+
runBy: byApply ? "apply" : "chant",
|
|
1005
|
+
runs: hookRuns(spec, k, runs, planned),
|
|
1006
|
+
runsFile: call.runsPath,
|
|
1007
|
+
hooks: handed,
|
|
1008
|
+
outcomes: [...result.hooks],
|
|
1009
|
+
...(byApply ? { result: resultPath } : {}),
|
|
1010
|
+
};
|
|
1011
|
+
mkdirSync(join(cwd, dir), { recursive: true });
|
|
1012
|
+
writeFileSync(join(cwd, filePath), JSON.stringify(file, null, 2) + "\n");
|
|
1013
|
+
rmSync(join(cwd, resultPath), { force: true });
|
|
1014
|
+
let code: number;
|
|
1015
|
+
try {
|
|
1016
|
+
code = exec(argv, cwd, { CHANT_HOOKS: filePath, ...(byApply ? { CHANT_HOOKS_RESULT: resultPath } : {}) });
|
|
1017
|
+
} catch (err) {
|
|
1018
|
+
log(`${call.label}: the apply did not run: ${err instanceof Error ? err.message : String(err)}`);
|
|
1019
|
+
code = 127;
|
|
1020
|
+
}
|
|
1021
|
+
if (!byApply) return { outcome: code === 0 ? "applied" : "failed" };
|
|
1022
|
+
let back: OpWaveHooksResult | null = null;
|
|
1023
|
+
try {
|
|
1024
|
+
back = JSON.parse(readFileSync(join(cwd, resultPath), "utf-8")) as OpWaveHooksResult;
|
|
1025
|
+
} catch {
|
|
1026
|
+
back = null;
|
|
1027
|
+
}
|
|
1028
|
+
const outcomes = Array.isArray(back?.outcomes) ? back!.outcomes : [];
|
|
1029
|
+
const moments = Array.isArray(back?.moments) ? back!.moments : [];
|
|
1030
|
+
result.hooks.push(...outcomes);
|
|
1031
|
+
const holds = outcomes.filter((o) => o.moment === "before-apply" && o.outcome === "held").map((o) => `${o.moment} hook ${o.name}${o.target ? ` (${o.target})` : ""}`);
|
|
1032
|
+
if (holds.length > 0 && !approved) {
|
|
1033
|
+
if (code === 0) {
|
|
1034
|
+
log(`${call.label}: the apply exited 0 although ${holds.join(", ")} held it and no approval stood; an apply applies nothing on a hold`);
|
|
1035
|
+
return { outcome: "failed" };
|
|
1036
|
+
}
|
|
1037
|
+
if (pass > 0 || !(await settleHolds(options, ctx, decision, holds, recordPath))) return { outcome: "waiting" };
|
|
1038
|
+
if (!(await spendApproval(options, ctx, decision))) return { outcome: "failed" };
|
|
1039
|
+
if (recordPath && decision.spentAt) writeRecord(recordPath, decision, result);
|
|
1040
|
+
continue;
|
|
1041
|
+
}
|
|
1042
|
+
const afterFailed = outcomes.find((o) => o.moment === "after-apply" && o.outcome === "failed");
|
|
1043
|
+
if (afterFailed) return { outcome: "applied", problem: `${call.label} applied, and then the after-apply hook ${afterFailed.name} failed` };
|
|
1044
|
+
if (code !== 0) return { outcome: "failed" };
|
|
1045
|
+
const skipped = (["before-apply", "after-apply"] as const).filter((m) => handed[m] && !moments.includes(m));
|
|
1046
|
+
if (skipped.length > 0) {
|
|
1047
|
+
return { outcome: "applied", problem: `${call.label}: the apply exited 0 without running its ${skipped.join(" and ")} hooks (runOpWaveApplyHooks with CHANT_HOOKS), which the spec at base hands it` };
|
|
1048
|
+
}
|
|
1049
|
+
return { outcome: "applied" };
|
|
1050
|
+
}
|
|
1051
|
+
return { outcome: "failed" };
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
/**
|
|
1055
|
+
* Apply `runs` of a decided wave: the before-apply hooks, the apply (one call
|
|
1056
|
+
* per run, or one `applyWave` call for the set), then the after-apply hooks.
|
|
1057
|
+
* A hook that holds the wave here, or held a share job's plan, makes it wait
|
|
1058
|
+
* for an approval of the decided digest unless one already stands. Under
|
|
1059
|
+
* `hooks.apply.runBy: "apply"` the apply call runs the apply-stage hooks
|
|
1060
|
+
* itself, under its lock (#3804).
|
|
1061
|
+
*/
|
|
1062
|
+
async function applyStage(
|
|
1063
|
+
options: RunOpWaveOptions,
|
|
1064
|
+
ctx: WaveContext,
|
|
1065
|
+
decision: OpWaveDecision,
|
|
1066
|
+
runs: readonly OpWaveRun[],
|
|
1067
|
+
planned: ReadonlyMap<string, OpWaveMember>,
|
|
1068
|
+
earlierHolds: readonly string[],
|
|
1069
|
+
recordPath?: string,
|
|
1070
|
+
): Promise<void> {
|
|
1071
|
+
const { spec, k, wave, log, result } = ctx;
|
|
1072
|
+
if (runs.length === 0) return;
|
|
1073
|
+
const byApply = decision.applyHooksRunBy === "apply";
|
|
1074
|
+
const holds = [...earlierHolds];
|
|
1075
|
+
if (!byApply) {
|
|
1076
|
+
const before = runHooks(ctx, "before-apply", decision.hooks?.["before-apply"], runs, planned, decision.digest);
|
|
1077
|
+
if (before.failed) {
|
|
1078
|
+
failed(ctx, `a hook before the apply failed, so nothing in wave ${k} was applied`);
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
1081
|
+
holds.push(...before.holds);
|
|
1082
|
+
}
|
|
1083
|
+
if (!(await settleHolds(options, ctx, decision, holds, recordPath))) return;
|
|
1084
|
+
if (!(await spendApproval(options, ctx, decision))) return;
|
|
1085
|
+
if (recordPath && decision.spentAt) writeRecord(recordPath, decision, result);
|
|
1086
|
+
const problems: string[] = [];
|
|
1087
|
+
let waiting = false;
|
|
1088
|
+
if (spec.applyWave) {
|
|
1089
|
+
const runsPath = writeRunsFile(ctx, runs, planned, decision.digest);
|
|
1090
|
+
const argv = opWaveArgv(spec.applyWave, { op: spec.op, wave: wave.name, runs: runsPath, plans: opWavePlansPath(spec, k) });
|
|
1091
|
+
const got = await callApply(options, ctx, decision, runs, planned, argv, { label: `wave ${k} (${wave.name})`, key: "hooks", runsPath }, recordPath);
|
|
1092
|
+
if (got.outcome === "applied") {
|
|
1093
|
+
result.applied.push(...runs.map((r) => r.target));
|
|
1094
|
+
if (got.problem) problems.push(got.problem);
|
|
1095
|
+
} else if (got.outcome === "waiting") waiting = true;
|
|
1096
|
+
else {
|
|
1097
|
+
log(`wave ${k} (${wave.name}): apply failed, so none of ${runs.map((r) => r.target).join(", ")} counts as applied`);
|
|
1098
|
+
result.failed.push(...runs.map((r) => r.target));
|
|
1099
|
+
}
|
|
1100
|
+
} else {
|
|
1101
|
+
for (const run of runs) {
|
|
1102
|
+
const key = run.target.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
1103
|
+
// Under runBy "apply" each run's apply runs its own apply-stage hooks, over a runs file of that run alone.
|
|
1104
|
+
const runsPath = byApply ? writeRunsFile(ctx, [run], planned, decision.digest, `.chant/op-waves/${spec.name}/wave-${k}/${key}.runs.json`) : opWaveRunsPath(spec, k);
|
|
1105
|
+
const argv = opWaveArgv(spec.apply ?? OP_WAVE_DEFAULT_APPLY, { op: run.op ?? spec.op, target: run.target, wave: wave.name, plan: opWavePlanPath(spec, k, run.target) });
|
|
1106
|
+
const got = await callApply(options, ctx, decision, [run], planned, argv, { label: run.target, key: `${key}.hooks`, runsPath }, recordPath);
|
|
1107
|
+
if (got.outcome === "waiting") {
|
|
1108
|
+
waiting = true;
|
|
1109
|
+
break;
|
|
1110
|
+
}
|
|
1111
|
+
if (got.outcome === "applied") {
|
|
1112
|
+
result.applied.push(run.target);
|
|
1113
|
+
if (got.problem) problems.push(got.problem);
|
|
1114
|
+
} else {
|
|
1115
|
+
log(`${run.target}: apply failed`);
|
|
1116
|
+
result.failed.push(run.target);
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
if (recordPath && byApply) writeRecord(recordPath, decision, result);
|
|
1121
|
+
if (waiting) {
|
|
1122
|
+
result.exitCode = 3;
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
1125
|
+
if (result.failed.length > 0) {
|
|
1126
|
+
result.exitCode = 1;
|
|
1127
|
+
return;
|
|
1128
|
+
}
|
|
1129
|
+
if (problems.length > 0) {
|
|
1130
|
+
for (const p of problems) log(p);
|
|
1131
|
+
result.exitCode = 1;
|
|
1132
|
+
return;
|
|
1133
|
+
}
|
|
1134
|
+
if (!byApply) {
|
|
1135
|
+
const after = runHooks(ctx, "after-apply", decision.hooks?.["after-apply"], runs, planned, decision.digest);
|
|
1136
|
+
if (after.failed) failed(ctx, `wave ${k} (${wave.name}) applied, and then a hook after the apply failed`);
|
|
1137
|
+
}
|
|
1138
|
+
if (recordPath && result.hooks.length > 0) writeRecord(recordPath, decision, result);
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
/**
|
|
1142
|
+
* Record that the wave applies under its approval (#3784), which spends it:
|
|
1143
|
+
* one line in `_gates/<name>/applied.jsonl`, before anything applies, as
|
|
1144
|
+
* terragucci's wave gate writes it. Only an approved wave under the spend
|
|
1145
|
+
* rule records, and only once. False when the line could not be written: the
|
|
1146
|
+
* wave then fails and applies nothing, since an apply the record misses
|
|
1147
|
+
* would leave its approval standing.
|
|
1148
|
+
*/
|
|
1149
|
+
async function spendApproval(options: RunOpWaveOptions, ctx: WaveContext, decision: OpWaveDecision): Promise<boolean> {
|
|
1150
|
+
if (!decision.spend || decision.status !== "approved" || decision.spentAt || !decision.approvedAt) return true;
|
|
1151
|
+
const { spec, k, wave, cwd, log } = ctx;
|
|
1152
|
+
const port = options.gates ?? gitGateLedgerPort({ cwd });
|
|
1153
|
+
const now = options.now ?? new Date().toISOString();
|
|
1154
|
+
const record: AppliedGateRecord = {
|
|
1155
|
+
version: 1,
|
|
1156
|
+
kind: "applied",
|
|
1157
|
+
op: spec.name,
|
|
1158
|
+
gate: decision.gate,
|
|
1159
|
+
planDigest: decision.digest,
|
|
1160
|
+
approvedAt: decision.approvedAt,
|
|
1161
|
+
approvedBy: decision.approvedBy ?? "",
|
|
1162
|
+
timestamp: now,
|
|
1163
|
+
wave: { number: k, name: wave.name },
|
|
1164
|
+
...(decision.via === "pr-review" && decision.review ? { review: { ...(decision.review.pr !== undefined ? { pr: decision.review.pr } : {}), ...(decision.review.head ? { head: decision.review.head } : {}) } } : {}),
|
|
1165
|
+
};
|
|
1166
|
+
try {
|
|
1167
|
+
if (!port.appendApplied) throw new Error("this gate ledger cannot record an applied approval");
|
|
1168
|
+
const pushed = await port.appendApplied(record);
|
|
1169
|
+
if (!pushed.pushed && pushed.pushWarning) log(`wave ${k} (${wave.name}): ${pushed.pushWarning}`);
|
|
1170
|
+
} catch (err) {
|
|
1171
|
+
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)}`);
|
|
1172
|
+
return false;
|
|
1173
|
+
}
|
|
1174
|
+
decision.spentAt = now;
|
|
1175
|
+
log(`wave ${k} (${wave.name}) applies under the approval by ${record.approvedBy} at ${record.approvedAt}, which is now spent`);
|
|
1176
|
+
return true;
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
/** What {@link approveByReview} found. */
|
|
1180
|
+
type ReviewOutcome = ReviewApproval;
|
|
1181
|
+
|
|
390
1182
|
/**
|
|
391
1183
|
* Under `pr-review` (#3684): the merged pull request's review approves the
|
|
392
1184
|
* wave when a writer other than the author approved its head, and the head
|
|
@@ -395,7 +1187,7 @@ interface ReviewOutcome {
|
|
|
395
1187
|
*/
|
|
396
1188
|
async function approveByReview(
|
|
397
1189
|
options: RunOpWaveOptions,
|
|
398
|
-
at: { op: string; gate: string; k: number; digest: string; cwd: string; now: string; port: GateLedgerPort },
|
|
1190
|
+
at: { op: string; gate: string; k: number; digest: string; cwd: string; now: string; port: GateLedgerPort; spent?: readonly AppliedGateRecord[] },
|
|
399
1191
|
): Promise<ReviewOutcome> {
|
|
400
1192
|
const review = await import("./gate-review");
|
|
401
1193
|
const head = options.head ?? execFileSync("git", ["rev-parse", "HEAD"], { cwd: at.cwd, encoding: "utf-8" }).trim();
|
|
@@ -406,23 +1198,16 @@ async function approveByReview(
|
|
|
406
1198
|
return { approved: false, review: { reason: `the forge's review could not be read: ${err instanceof Error ? err.message : String(err)}` } };
|
|
407
1199
|
}
|
|
408
1200
|
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) {
|
|
1201
|
+
// The head's plans are read only when a writer approved it.
|
|
1202
|
+
const plans = merged.approvers.length === 0 ? null : await (options.headPlans ?? ((name, h) => readHeadPlans(name, h, at.cwd)))(options.spec.name, merged.head);
|
|
1203
|
+
const { reviewApproval } = await import("../gate-decision");
|
|
1204
|
+
const outcome = reviewApproval(merged, plans?.waves.find((w) => w.wave === at.k)?.digest, at.digest);
|
|
1205
|
+
if (!outcome.approved) return outcome;
|
|
1206
|
+
// #3784: a review a wave already applied this digest under is spent, so it is not recorded again.
|
|
1207
|
+
if (at.spent && isApprovalSpent(at.spent, { gate: at.gate, planDigest: at.digest, review: { head: merged.head } })) {
|
|
423
1208
|
return {
|
|
424
1209
|
approved: false,
|
|
425
|
-
review: { ...
|
|
1210
|
+
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
1211
|
};
|
|
427
1212
|
}
|
|
428
1213
|
const note = `approved by the review of #${merged.pr} at ${merged.head.slice(0, 12)}: ${merged.approvers.join(", ")}`;
|
|
@@ -437,7 +1222,7 @@ async function approveByReview(
|
|
|
437
1222
|
...(merged.url ? { url: merged.url } : {}),
|
|
438
1223
|
review: { pr: merged.pr, head: merged.head, approvers: merged.approvers },
|
|
439
1224
|
});
|
|
440
|
-
return
|
|
1225
|
+
return outcome;
|
|
441
1226
|
}
|
|
442
1227
|
|
|
443
1228
|
async function readHeadPlans(name: string, head: string, cwd: string): Promise<HeadPlans | null> {
|
|
@@ -485,7 +1270,7 @@ export async function recordOpWaveHeadPlans(options: RecordHeadPlansOptions): Pr
|
|
|
485
1270
|
head,
|
|
486
1271
|
timestamp: options.now ?? new Date().toISOString(),
|
|
487
1272
|
waves: spec.waves.map((wave, i) => {
|
|
488
|
-
const members =
|
|
1273
|
+
const members = planRuns({ spec, k: i + 1, wave, cwd, exec }, wave.runs);
|
|
489
1274
|
return { wave: i + 1, name: wave.name, digest: waveSetDigest(members) };
|
|
490
1275
|
}),
|
|
491
1276
|
};
|