@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.
- package/dist/cli/commands/build.d.ts +12 -0
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/lint.d.ts +16 -0
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/handlers/gate-resume.d.ts +1 -1
- package/dist/cli/handlers/gate-resume.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts +1 -1
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/override-approve.d.ts +17 -0
- package/dist/cli/handlers/override-approve.d.ts.map +1 -0
- package/dist/cli/handlers/run-wave.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +4 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/gate-decision.d.ts +157 -0
- package/dist/gate-decision.d.ts.map +1 -0
- package/dist/lifecycle/gate-applied.d.ts +75 -0
- package/dist/lifecycle/gate-applied.d.ts.map +1 -0
- package/dist/lifecycle/gate-audit.d.ts +145 -0
- package/dist/lifecycle/gate-audit.d.ts.map +1 -0
- package/dist/lifecycle/gate-ledger-select.d.ts +54 -0
- package/dist/lifecycle/gate-ledger-select.d.ts.map +1 -0
- package/dist/lifecycle/gate-ledger.d.ts +1 -45
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/lifecycle/override-ledger.d.ts +34 -0
- package/dist/lifecycle/override-ledger.d.ts.map +1 -0
- package/dist/lifecycle/rollback.d.ts.map +1 -1
- package/dist/lifecycle/work-item-id.d.ts +22 -0
- package/dist/lifecycle/work-item-id.d.ts.map +1 -0
- package/dist/lifecycle/work-lease.d.ts +2 -2
- package/dist/lifecycle/work-lease.d.ts.map +1 -1
- package/dist/lint/post-synth.d.ts +20 -1
- package/dist/lint/post-synth.d.ts.map +1 -1
- package/dist/notify-verify.d.ts +62 -0
- package/dist/notify-verify.d.ts.map +1 -0
- package/dist/notify.d.ts +202 -0
- package/dist/notify.d.ts.map +1 -0
- package/dist/op/activities/activity-contracts.d.ts +4 -4
- package/dist/op/activities/gh-host.d.ts +37 -0
- package/dist/op/activities/gh-host.d.ts.map +1 -0
- package/dist/op/activities/lexicon-upgrade.d.ts +11 -0
- package/dist/op/activities/lexicon-upgrade.d.ts.map +1 -1
- package/dist/op/activities/propose-upgrade.d.ts.map +1 -1
- package/dist/op/activities/reconcile.d.ts +74 -76
- package/dist/op/activities/reconcile.d.ts.map +1 -1
- package/dist/op/gate-approval.d.ts +17 -1
- package/dist/op/gate-approval.d.ts.map +1 -1
- package/dist/op/gate-name.d.ts +8 -1
- package/dist/op/gate-name.d.ts.map +1 -1
- package/dist/op/gate-resume.d.ts +4 -2
- package/dist/op/gate-resume.d.ts.map +1 -1
- package/dist/op/gate-review.d.ts +2 -1
- package/dist/op/gate-review.d.ts.map +1 -1
- package/dist/op/gate.d.ts +43 -6
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/index.d.ts +8 -6
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/op-waves-notify.d.ts +31 -0
- package/dist/op/op-waves-notify.d.ts.map +1 -0
- package/dist/op/op-waves-run.d.ts +91 -16
- package/dist/op/op-waves-run.d.ts.map +1 -1
- package/dist/op/op-waves.d.ts +99 -2
- package/dist/op/op-waves.d.ts.map +1 -1
- package/dist/op/operator.d.ts +15 -0
- package/dist/op/operator.d.ts.map +1 -1
- package/dist/op/work-lease-decl.d.ts.map +1 -1
- package/dist/op/work-lease-run.d.ts.map +1 -1
- package/dist/override-gate.d.ts +224 -0
- package/dist/override-gate.d.ts.map +1 -0
- package/dist/pr-forge.d.ts +198 -19
- package/dist/pr-forge.d.ts.map +1 -1
- package/dist/workspace/status-gates.d.ts +1 -0
- package/dist/workspace/status-gates.d.ts.map +1 -1
- package/dist/workspace/trust/gate-seal.d.ts +96 -0
- package/dist/workspace/trust/gate-seal.d.ts.map +1 -0
- package/dist/workspace/trust/policy.d.ts +2 -41
- package/dist/workspace/trust/policy.d.ts.map +1 -1
- package/dist/workspace/trust/seal.d.ts +2 -27
- package/dist/workspace/trust/seal.d.ts.map +1 -1
- package/dist/workspace/trust/trust-files.d.ts +57 -0
- package/dist/workspace/trust/trust-files.d.ts.map +1 -0
- package/dist/workspace/work-cli.d.ts.map +1 -1
- package/package.json +2 -1
- package/src/cli/commands/build.ts +37 -18
- package/src/cli/commands/carve-emit-state.test.ts +8 -2
- package/src/cli/commands/lint.test.ts +109 -1
- package/src/cli/commands/lint.ts +165 -1
- package/src/cli/handlers/__snapshots__/build-member.test.ts.snap +2 -2
- package/src/cli/handlers/gate-resume.ts +3 -2
- package/src/cli/handlers/operator.test.ts +12 -1
- package/src/cli/handlers/operator.ts +25 -13
- package/src/cli/handlers/override-approve.test.ts +87 -0
- package/src/cli/handlers/override-approve.ts +103 -0
- package/src/cli/handlers/run-wave.ts +10 -2
- package/src/cli/main.test.ts +8 -0
- package/src/cli/main.ts +15 -0
- package/src/cli/registry.ts +4 -0
- package/src/gate-decision-bundle.test.ts +56 -0
- package/src/gate-decision.test.ts +237 -0
- package/src/gate-decision.ts +321 -0
- package/src/lifecycle/gate-applied.ts +115 -0
- package/src/lifecycle/gate-audit-bundle.test.ts +47 -0
- package/src/lifecycle/gate-audit.test.ts +134 -0
- package/src/lifecycle/gate-audit.ts +438 -0
- package/src/lifecycle/gate-ledger-select.ts +113 -0
- package/src/lifecycle/gate-ledger.test.ts +22 -0
- package/src/lifecycle/gate-ledger.ts +9 -104
- package/src/lifecycle/override-ledger.test.ts +63 -0
- package/src/lifecycle/override-ledger.ts +69 -0
- package/src/lifecycle/rollback.ts +5 -1
- package/src/lifecycle/work-item-id.test.ts +71 -0
- package/src/lifecycle/work-item-id.ts +35 -0
- package/src/lifecycle/work-lease.ts +4 -5
- package/src/lint/post-synth.test.ts +29 -0
- package/src/lint/post-synth.ts +21 -2
- package/src/notify-bundle.test.ts +50 -0
- package/src/notify-verify.test.ts +59 -0
- package/src/notify-verify.ts +93 -0
- package/src/notify.schema.json +72 -0
- package/src/notify.test.ts +279 -0
- package/src/notify.ts +398 -0
- package/src/op/activities/gh-host.test.ts +130 -0
- package/src/op/activities/gh-host.ts +79 -0
- package/src/op/activities/lexicon-upgrade.ts +22 -1
- package/src/op/activities/propose-upgrade.ts +5 -1
- package/src/op/activities/reconcile.test.ts +284 -280
- package/src/op/activities/reconcile.ts +128 -131
- package/src/op/gate-approval.test.ts +175 -7
- package/src/op/gate-approval.ts +27 -1
- package/src/op/gate-name.ts +10 -2
- package/src/op/gate-resume.ts +6 -3
- package/src/op/gate-review.ts +4 -7
- package/src/op/gate.ts +117 -16
- package/src/op/index.ts +20 -5
- package/src/op/op-waves-notify.test.ts +127 -0
- package/src/op/op-waves-notify.ts +101 -0
- package/src/op/op-waves-run.ts +527 -104
- package/src/op/op-waves.test.ts +383 -3
- package/src/op/op-waves.ts +187 -6
- package/src/op/operator.test.ts +29 -0
- package/src/op/operator.ts +27 -2
- package/src/op/run-context.test.ts +2 -2
- package/src/op/work-lease-decl.ts +4 -4
- package/src/op/work-lease-run.test.ts +5 -0
- package/src/op/work-lease-run.ts +4 -3
- package/src/override-gate-bundle.test.ts +47 -0
- package/src/override-gate.test.ts +239 -0
- package/src/override-gate.ts +395 -0
- package/src/pr-forge-bundle.test.ts +40 -0
- package/src/pr-forge.test.ts +303 -0
- package/src/pr-forge.ts +502 -47
- package/src/workspace/records-write-contract.test.ts +3 -2
- package/src/workspace/status-gates.ts +7 -4
- package/src/workspace/trust/gate-seal.test.ts +156 -0
- package/src/workspace/trust/gate-seal.ts +254 -0
- package/src/workspace/trust/policy.ts +10 -165
- package/src/workspace/trust/seal.ts +19 -34
- package/src/workspace/trust/trust-files.test.ts +50 -0
- package/src/workspace/trust/trust-files.ts +245 -0
- package/src/workspace/work-cli.ts +5 -3
- package/src/workspace/work-history.test.ts +7 -0
package/src/op/op-waves.test.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { describe, expect, test } from "vitest";
|
|
2
|
-
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
|
-
import { opWaveJobs, opWaveShare, type OpWavesSpec } from "./op-waves";
|
|
6
|
-
import { runOpWave, type OpWaveExec, type OpWaveShowAtBase } from "./op-waves-run";
|
|
5
|
+
import { assertOpWavesSpec, opWaveJobs, opWaveShare, type OpWavesSpec } from "./op-waves";
|
|
6
|
+
import { recordOpWaveHeadPlans, runOpWave, type OpWaveExec, type OpWaveRunsFile, type OpWaveShowAtBase } from "./op-waves-run";
|
|
7
7
|
import { memoryGateLedgerPort } from "./gate";
|
|
8
8
|
import { waveSetDigest } from "../gated-waves";
|
|
9
9
|
|
|
@@ -93,3 +93,383 @@ describe("runOpWave", () => {
|
|
|
93
93
|
h.done();
|
|
94
94
|
});
|
|
95
95
|
});
|
|
96
|
+
|
|
97
|
+
describe("wave-level commands and hooks in the spec (#3762)", () => {
|
|
98
|
+
const base = { name: "w", op: "migrate", waves: [{ name: "dev", runs: [{ target: "a" }] }] };
|
|
99
|
+
test("a spec without them is unchanged, and the new fields are checked", () => {
|
|
100
|
+
expect(() => assertOpWavesSpec({ ...base, plan: ["p", "{plan}"] })).not.toThrow();
|
|
101
|
+
expect(() => assertOpWavesSpec({ ...base, planWave: ["p", "{runs}", "{plans}"], applyWave: ["a", "{runs}"] })).not.toThrow();
|
|
102
|
+
expect(() => assertOpWavesSpec({ ...base, plan: ["p", "{plan}"], planWave: ["p", "{plans}"] })).toThrow(/both `plan` and `planWave`/);
|
|
103
|
+
expect(() => assertOpWavesSpec({ ...base, planWave: ["p", "{runs}"] })).toThrow(/never names \{plans\}/);
|
|
104
|
+
expect(() => assertOpWavesSpec({ ...base, planWave: ["p", "{plans}"], apply: ["a"], applyWave: ["a", "{runs}"] })).toThrow(/both `apply` and `applyWave`/);
|
|
105
|
+
expect(() => assertOpWavesSpec(base as OpWavesSpec)).toThrow(/give `plan` or `planWave`/);
|
|
106
|
+
const hooked = (hooks: unknown): OpWavesSpec => ({ ...base, plan: ["p", "{plan}"], hooks: hooks as OpWavesSpec["hooks"] });
|
|
107
|
+
expect(() => assertOpWavesSpec(hooked({ before: { plan: [{ command: ["check", "{runs}"], onFailure: "approve" }] }, after: { apply: [{ command: ["smoke", "{target}"], perRun: true }] } }))).not.toThrow();
|
|
108
|
+
expect(() => assertOpWavesSpec(hooked({ after: { apply: [{ command: ["smoke"], onFailure: "approve" }] } }))).toThrow(/nothing left to hold/);
|
|
109
|
+
expect(() => assertOpWavesSpec(hooked({ before: { init: [{ command: ["x"] }] } }))).toThrow(/hooks.before.init is not a stage/);
|
|
110
|
+
expect(() => assertOpWavesSpec(hooked({ during: {} }))).toThrow(/hooks.during is not a moment/);
|
|
111
|
+
expect(() => assertOpWavesSpec(hooked({ after: { plan: [{ command: ["x", "{plan}"] }] } }))).toThrow(/runs once for the wave/);
|
|
112
|
+
expect(() => assertOpWavesSpec(hooked({ before: { plan: [{ command: ["x", "{plan}"], perRun: true }] } }))).toThrow(/no \{plan\} yet/);
|
|
113
|
+
expect(() => assertOpWavesSpec(hooked({ before: { plan: [{ command: ["x"], onFailure: "warn" }] } }))).toThrow(/onFailure "warn"/);
|
|
114
|
+
expect(() => assertOpWavesSpec({ ...base, plan: ["p", "{plan}"], waves: [{ name: "dev", runs: [{ target: "a" }], hooks: { before: { apply: [{ command: [] }] } } }] })).toThrow(/wave "dev".*argv/);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("runOpWave with wave-level commands (#3762)", () => {
|
|
119
|
+
const digest = (n: string) => `sha256:${n.repeat(64)}`;
|
|
120
|
+
const waveSpec: OpWavesSpec = {
|
|
121
|
+
name: "roots",
|
|
122
|
+
op: "tf",
|
|
123
|
+
planWave: ["tg", "plan", "{wave}", "{runs}", "{plans}"],
|
|
124
|
+
applyWave: ["tg", "apply", "{wave}", "{runs}", "{plans}"],
|
|
125
|
+
waves: [
|
|
126
|
+
{ name: "net", runs: [{ target: "vpc" }, { target: "dns" }], gate: "on-destructive" },
|
|
127
|
+
{ name: "apps", runs: [{ target: "a1" }, { target: "a2" }, { target: "a3" }], gate: "never", shares: 2 },
|
|
128
|
+
],
|
|
129
|
+
};
|
|
130
|
+
function harness(plans: Record<string, { planDigest: string; destructive?: boolean; empty?: boolean }>, atBase: OpWavesSpec | null = waveSpec) {
|
|
131
|
+
const cwd = mkdtempSync(join(tmpdir(), "op-waves-wave-"));
|
|
132
|
+
const calls: string[] = [];
|
|
133
|
+
const applied: OpWaveRunsFile[] = [];
|
|
134
|
+
const exec: OpWaveExec = (argv, dir) => {
|
|
135
|
+
calls.push(argv.join(" "));
|
|
136
|
+
const runs = JSON.parse(readFileSync(join(dir, argv[3]!), "utf-8")) as OpWaveRunsFile;
|
|
137
|
+
if (argv[1] === "plan") writeFileSync(join(dir, argv[4]!), JSON.stringify({ runs: runs.runs.map((r) => ({ target: r.target, ...plans[r.target] })) }));
|
|
138
|
+
if (argv[1] === "apply") applied.push(runs);
|
|
139
|
+
return 0;
|
|
140
|
+
};
|
|
141
|
+
const show: OpWaveShowAtBase = () => ({ sha: "b".repeat(40), text: atBase === null ? null : JSON.stringify(atBase) });
|
|
142
|
+
return { cwd, calls, applied, exec, show, done: () => rmSync(cwd, { recursive: true, force: true }) };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
test("one call plans the wave; the digest and the gate are those of per-run plans; the apply gets the approved set", async () => {
|
|
146
|
+
const plans = { vpc: { planDigest: digest("1"), destructive: true }, dns: { planDigest: digest("2") } };
|
|
147
|
+
const h = harness(plans);
|
|
148
|
+
const set = waveSetDigest([{ member: "vpc", planDigest: digest("1") }, { member: "dns", planDigest: digest("2") }]);
|
|
149
|
+
const waiting = await runOpWave({ spec: waveSpec, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates: memoryGateLedgerPort() });
|
|
150
|
+
expect(waiting).toMatchObject({ exitCode: 3, decision: { digest: set, status: "waiting", policy: "on-destructive" } });
|
|
151
|
+
expect(h.calls).toEqual([`tg plan net .chant/op-waves/roots/wave-1/runs.json .chant/op-waves/roots/wave-1/plans.json`]);
|
|
152
|
+
// Each run's plan file is written too, so a per-run apply or hook reads {plan} either way.
|
|
153
|
+
expect(JSON.parse(readFileSync(join(h.cwd, ".chant/op-waves/roots/wave-1/vpc.plan.json"), "utf-8"))).toEqual({ planDigest: digest("1"), destructive: true });
|
|
154
|
+
|
|
155
|
+
const gates = memoryGateLedgerPort({ resolutions: [{ version: 1, op: "roots", gate: "roots-wave-1", resolvedBy: "alice", timestamp: "2026-10-10T00:00:00Z", planDigest: set }] });
|
|
156
|
+
const approved = await runOpWave({ spec: waveSpec, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates });
|
|
157
|
+
expect(approved).toMatchObject({ exitCode: 0, applied: ["vpc", "dns"], decision: { status: "approved", approvedBy: "alice" } });
|
|
158
|
+
expect(h.applied).toEqual([
|
|
159
|
+
{
|
|
160
|
+
wave: "net",
|
|
161
|
+
number: 1,
|
|
162
|
+
digest: set,
|
|
163
|
+
runs: [
|
|
164
|
+
{ target: "vpc", op: "tf", planDigest: digest("1"), destructive: true },
|
|
165
|
+
{ target: "dns", op: "tf", planDigest: digest("2") },
|
|
166
|
+
],
|
|
167
|
+
},
|
|
168
|
+
]);
|
|
169
|
+
h.done();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("a plans file that misses a run, or plans one it was not asked to, is refused", async () => {
|
|
173
|
+
const h = harness({ vpc: { planDigest: digest("1") } });
|
|
174
|
+
await expect(runOpWave({ spec: waveSpec, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates: memoryGateLedgerPort() })).rejects.toThrow(/no planDigest for dns|no plan for dns/);
|
|
175
|
+
h.done();
|
|
176
|
+
const extra = harness({});
|
|
177
|
+
const exec: OpWaveExec = (argv, dir) => (writeFileSync(join(dir, argv[4]!), JSON.stringify([{ target: "vpc", planDigest: digest("1") }, { target: "dns", planDigest: digest("2") }, { target: "zzz", planDigest: digest("3") }])), 0);
|
|
178
|
+
await expect(runOpWave({ spec: waveSpec, specFile: "waves.json", wave: 1, cwd: extra.cwd, exec, show: extra.show, gates: memoryGateLedgerPort() })).rejects.toThrow(/"zzz", which is not a run/);
|
|
179
|
+
extra.done();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("shares keep working: each share plans its slice in one call and applies only the runs whose plan did not move", async () => {
|
|
183
|
+
const plans: Record<string, { planDigest: string }> = { a1: { planDigest: digest("1") }, a2: { planDigest: digest("2") }, a3: { planDigest: digest("3") } };
|
|
184
|
+
const h = harness(plans);
|
|
185
|
+
const decided = await runOpWave({ spec: waveSpec, specFile: "waves.json", wave: 2, decide: true, cwd: h.cwd, exec: h.exec, show: h.show, gates: memoryGateLedgerPort() });
|
|
186
|
+
expect(decided).toMatchObject({ exitCode: 0, applied: [], decision: { status: "not-required" } });
|
|
187
|
+
plans.a2 = { planDigest: digest("9") };
|
|
188
|
+
const share = await runOpWave({ spec: waveSpec, specFile: "waves.json", wave: 2, share: 1, cwd: h.cwd, exec: h.exec, show: h.show });
|
|
189
|
+
expect(share).toMatchObject({ exitCode: 4, applied: ["a1"], moved: ["a2"] });
|
|
190
|
+
expect(h.applied.at(-1)!.runs.map((r) => r.target)).toEqual(["a1"]);
|
|
191
|
+
h.done();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("--record-plans plans each wave with one call", async () => {
|
|
195
|
+
const h = harness({ vpc: { planDigest: digest("1") }, dns: { planDigest: digest("2") }, a1: { planDigest: digest("1") }, a2: { planDigest: digest("2") }, a3: { planDigest: digest("3") } });
|
|
196
|
+
const plans = await recordOpWaveHeadPlans({ spec: waveSpec, cwd: h.cwd, exec: h.exec, head: "c".repeat(40), now: "t", write: async () => {} });
|
|
197
|
+
expect(plans.waves[0]!.digest).toBe(waveSetDigest([{ member: "vpc", planDigest: digest("1") }, { member: "dns", planDigest: digest("2") }]));
|
|
198
|
+
expect(h.calls.filter((c) => c.startsWith("tg plan"))).toHaveLength(2);
|
|
199
|
+
h.done();
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe("runOpWave hooks (#3762)", () => {
|
|
204
|
+
const digest = (n: string) => `sha256:${n.repeat(64)}`;
|
|
205
|
+
const hooked: OpWavesSpec = {
|
|
206
|
+
name: "migrations",
|
|
207
|
+
op: "migrate",
|
|
208
|
+
plan: ["tool", "plan", "{target}", "{plan}"],
|
|
209
|
+
apply: ["tool", "apply", "{target}", "{plan}"],
|
|
210
|
+
hooks: { before: { plan: [{ name: "lint", command: ["hook", "lint", "{runs}"] }] } },
|
|
211
|
+
waves: [
|
|
212
|
+
{
|
|
213
|
+
name: "prod",
|
|
214
|
+
runs: [{ target: "t1" }, { target: "t2" }],
|
|
215
|
+
gate: "never",
|
|
216
|
+
hooks: {
|
|
217
|
+
after: { plan: [{ name: "check", command: ["hook", "check", "{target}", "{plan}"], perRun: true, onFailure: "approve" }], apply: [{ name: "smoke", command: ["hook", "smoke", "{wave}"] }] },
|
|
218
|
+
before: { apply: [{ name: "backup", command: ["hook", "backup", "{runs}"] }] },
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
],
|
|
222
|
+
};
|
|
223
|
+
function harness(atBase: OpWavesSpec | null, fails: string[] = []) {
|
|
224
|
+
const cwd = mkdtempSync(join(tmpdir(), "op-waves-hooks-"));
|
|
225
|
+
const calls: Array<{ argv: string; env?: Record<string, string> }> = [];
|
|
226
|
+
const exec: OpWaveExec = (argv, dir, env) => {
|
|
227
|
+
calls.push({ argv: argv.join(" "), ...(env ? { env } : {}) });
|
|
228
|
+
if (argv[1] === "plan") writeFileSync(join(dir, argv[3]!), JSON.stringify({ planDigest: digest(argv[2] === "t1" ? "1" : "2") }));
|
|
229
|
+
return fails.some((f) => argv.join(" ").startsWith(f)) ? 1 : 0;
|
|
230
|
+
};
|
|
231
|
+
const show: OpWaveShowAtBase = () => ({ sha: "b".repeat(40), text: atBase === null ? null : JSON.stringify(atBase) });
|
|
232
|
+
return { cwd, calls, exec, show, argv: () => calls.map((c) => c.argv), done: () => rmSync(cwd, { recursive: true, force: true }) };
|
|
233
|
+
}
|
|
234
|
+
const set = waveSetDigest([{ member: "t1", planDigest: digest("1") }, { member: "t2", planDigest: digest("2") }]);
|
|
235
|
+
|
|
236
|
+
test("hooks run around the plan and the apply, per run with {target}, {plan} and the CHANT_ variables", async () => {
|
|
237
|
+
const h = harness(hooked);
|
|
238
|
+
const result = await runOpWave({ spec: hooked, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates: memoryGateLedgerPort() });
|
|
239
|
+
expect(result).toMatchObject({ exitCode: 0, applied: ["t1", "t2"], decision: { status: "not-required" } });
|
|
240
|
+
expect(h.argv()).toEqual([
|
|
241
|
+
"hook lint .chant/op-waves/migrations/wave-1/runs.json",
|
|
242
|
+
"tool plan t1 .chant/op-waves/migrations/wave-1/t1.plan.json",
|
|
243
|
+
"tool plan t2 .chant/op-waves/migrations/wave-1/t2.plan.json",
|
|
244
|
+
"hook check t1 .chant/op-waves/migrations/wave-1/t1.plan.json",
|
|
245
|
+
"hook check t2 .chant/op-waves/migrations/wave-1/t2.plan.json",
|
|
246
|
+
"hook backup .chant/op-waves/migrations/wave-1/runs.json",
|
|
247
|
+
"tool apply t1 .chant/op-waves/migrations/wave-1/t1.plan.json",
|
|
248
|
+
"tool apply t2 .chant/op-waves/migrations/wave-1/t2.plan.json",
|
|
249
|
+
"hook smoke prod",
|
|
250
|
+
]);
|
|
251
|
+
expect(h.calls[3]!.env).toMatchObject({ CHANT_OP_WAVES: "migrations", CHANT_WAVE: "prod", CHANT_WAVE_NUMBER: "1", CHANT_HOOK: "after-plan", CHANT_TARGET: "t1", CHANT_PLAN: ".chant/op-waves/migrations/wave-1/t1.plan.json" });
|
|
252
|
+
expect(result.hooks.map((o) => `${o.moment} ${o.name}${o.target ? ` ${o.target}` : ""} ${o.outcome}`)).toEqual([
|
|
253
|
+
"before-plan lint passed",
|
|
254
|
+
"after-plan check t1 passed",
|
|
255
|
+
"after-plan check t2 passed",
|
|
256
|
+
"before-apply backup passed",
|
|
257
|
+
"after-apply smoke passed",
|
|
258
|
+
]);
|
|
259
|
+
h.done();
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("hooks are read at base: a change cannot add a hook its own apply runs, nor remove one", async () => {
|
|
263
|
+
const added = harness({ ...hooked, hooks: undefined, waves: hooked.waves.map((w) => ({ ...w, hooks: undefined })) });
|
|
264
|
+
const logs: string[] = [];
|
|
265
|
+
await runOpWave({ spec: hooked, specFile: "waves.json", wave: 1, cwd: added.cwd, exec: added.exec, show: added.show, gates: memoryGateLedgerPort(), log: (l) => logs.push(l) });
|
|
266
|
+
expect(added.argv().filter((c) => c.startsWith("hook"))).toEqual([]);
|
|
267
|
+
expect(logs.some((l) => l.includes("this change's differ; the ones at base run"))).toBe(true);
|
|
268
|
+
added.done();
|
|
269
|
+
|
|
270
|
+
const removed = harness(hooked);
|
|
271
|
+
const bare = { ...hooked, hooks: undefined, waves: hooked.waves.map((w) => ({ ...w, hooks: undefined })) };
|
|
272
|
+
await runOpWave({ spec: bare, specFile: "waves.json", wave: 1, cwd: removed.cwd, exec: removed.exec, show: removed.show, gates: memoryGateLedgerPort() });
|
|
273
|
+
expect(removed.argv().filter((c) => c.startsWith("hook"))).toHaveLength(5);
|
|
274
|
+
removed.done();
|
|
275
|
+
|
|
276
|
+
const absent = harness(null);
|
|
277
|
+
await runOpWave({ spec: hooked, specFile: "waves.json", wave: 1, cwd: absent.cwd, exec: absent.exec, show: absent.show, gates: memoryGateLedgerPort() });
|
|
278
|
+
expect(absent.argv().filter((c) => c.startsWith("hook"))).toEqual([]);
|
|
279
|
+
absent.done();
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("an approve hook that fails holds a `never` wave until its set digest is approved", async () => {
|
|
283
|
+
const h = harness(hooked, ["hook check t2"]);
|
|
284
|
+
const gates = memoryGateLedgerPort();
|
|
285
|
+
const held = await runOpWave({ spec: hooked, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates });
|
|
286
|
+
expect(held).toMatchObject({ exitCode: 3, applied: [], decision: { policy: "never", status: "waiting", digest: set, heldBy: ["after-plan hook check (t2)"] } });
|
|
287
|
+
expect(gates.appended[0]!.planDigest).toBe(set);
|
|
288
|
+
expect(h.argv().some((c) => c.startsWith("tool apply") || c.startsWith("hook backup"))).toBe(false);
|
|
289
|
+
h.done();
|
|
290
|
+
|
|
291
|
+
const again = harness(hooked, ["hook check t2"]);
|
|
292
|
+
const approved = memoryGateLedgerPort({ resolutions: [{ version: 1, op: "migrations", gate: "migrations-wave-1", resolvedBy: "alice", timestamp: "2026-10-10T00:00:00Z", planDigest: set }] });
|
|
293
|
+
const applied = await runOpWave({ spec: hooked, specFile: "waves.json", wave: 1, cwd: again.cwd, exec: again.exec, show: again.show, gates: approved });
|
|
294
|
+
expect(applied).toMatchObject({ exitCode: 0, applied: ["t1", "t2"], decision: { status: "approved", approvedBy: "alice" } });
|
|
295
|
+
again.done();
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("a failing hook fails the wave: before the plan nothing is planned, after the apply the wave exits 1", async () => {
|
|
299
|
+
const early = harness(hooked, ["hook lint"]);
|
|
300
|
+
const r1 = await runOpWave({ spec: hooked, specFile: "waves.json", wave: 1, cwd: early.cwd, exec: early.exec, show: early.show, gates: memoryGateLedgerPort() });
|
|
301
|
+
expect(r1).toMatchObject({ exitCode: 1, applied: [] });
|
|
302
|
+
expect(early.argv()).toEqual(["hook lint .chant/op-waves/migrations/wave-1/runs.json"]);
|
|
303
|
+
early.done();
|
|
304
|
+
|
|
305
|
+
const late = harness(hooked, ["hook smoke"]);
|
|
306
|
+
const r2 = await runOpWave({ spec: hooked, specFile: "waves.json", wave: 1, cwd: late.cwd, exec: late.exec, show: late.show, gates: memoryGateLedgerPort() });
|
|
307
|
+
expect(r2).toMatchObject({ exitCode: 1, applied: ["t1", "t2"] });
|
|
308
|
+
late.done();
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test("an approve hook before the apply holds a wave the policy let through", async () => {
|
|
312
|
+
const spec: OpWavesSpec = { ...hooked, hooks: undefined, waves: [{ ...hooked.waves[0]!, hooks: { before: { apply: [{ name: "drain", command: ["hook", "drain"], onFailure: "approve" }] } } }] };
|
|
313
|
+
const h = harness(spec, ["hook drain"]);
|
|
314
|
+
const gates = memoryGateLedgerPort();
|
|
315
|
+
const result = await runOpWave({ spec, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates });
|
|
316
|
+
expect(result).toMatchObject({ exitCode: 3, applied: [], decision: { status: "waiting", heldBy: ["before-apply hook drain"] } });
|
|
317
|
+
expect(gates.appended[0]!.planDigest).toBe(set);
|
|
318
|
+
h.done();
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test("hooks at base that no runner can run hold the wave and none run", async () => {
|
|
322
|
+
const broken = { ...hooked, hooks: { before: { plan: [{ command: "lint" }] } } } as unknown as OpWavesSpec;
|
|
323
|
+
const h = harness(broken);
|
|
324
|
+
const result = await runOpWave({ spec: hooked, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates: memoryGateLedgerPort() });
|
|
325
|
+
expect(result.exitCode).toBe(3);
|
|
326
|
+
expect(result.decision!.heldBy![0]).toMatch(/the hooks at base cannot be run/);
|
|
327
|
+
expect(h.argv().filter((c) => c.startsWith("hook"))).toEqual([]);
|
|
328
|
+
h.done();
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("a wide wave's shares run the hooks their deciding job read at base", async () => {
|
|
332
|
+
const wide: OpWavesSpec = { ...hooked, waves: [{ ...hooked.waves[0]!, shares: 2 }] };
|
|
333
|
+
const h = harness(wide);
|
|
334
|
+
await runOpWave({ spec: { ...wide, hooks: undefined }, specFile: "waves.json", wave: 1, decide: true, cwd: h.cwd, exec: h.exec, show: h.show, gates: memoryGateLedgerPort() });
|
|
335
|
+
const before = h.calls.length;
|
|
336
|
+
const share = await runOpWave({ spec: { ...wide, hooks: undefined }, specFile: "waves.json", wave: 1, share: 2, cwd: h.cwd, exec: h.exec, show: h.show });
|
|
337
|
+
expect(share).toMatchObject({ exitCode: 0, applied: ["t2"] });
|
|
338
|
+
expect(h.argv().slice(before)).toEqual([
|
|
339
|
+
"hook lint .chant/op-waves/migrations/wave-1/runs.json",
|
|
340
|
+
"tool plan t2 .chant/op-waves/migrations/wave-1/t2.plan.json",
|
|
341
|
+
"hook check t2 .chant/op-waves/migrations/wave-1/t2.plan.json",
|
|
342
|
+
"hook backup .chant/op-waves/migrations/wave-1/runs.json",
|
|
343
|
+
"tool apply t2 .chant/op-waves/migrations/wave-1/t2.plan.json",
|
|
344
|
+
"hook smoke prod",
|
|
345
|
+
]);
|
|
346
|
+
h.done();
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test("a hold in a share job waits unless the wave was approved", async () => {
|
|
350
|
+
const wide: OpWavesSpec = { ...hooked, waves: [{ ...hooked.waves[0]!, shares: 2 }] };
|
|
351
|
+
const h = harness(wide);
|
|
352
|
+
await runOpWave({ spec: wide, specFile: "waves.json", wave: 1, decide: true, cwd: h.cwd, exec: h.exec, show: h.show, gates: memoryGateLedgerPort() });
|
|
353
|
+
const failing: OpWaveExec = (argv, dir, env) => (h.exec(argv, dir, env), argv.join(" ").startsWith("hook check") ? 1 : 0);
|
|
354
|
+
const gates = memoryGateLedgerPort();
|
|
355
|
+
const share = await runOpWave({ spec: wide, specFile: "waves.json", wave: 1, share: 1, cwd: h.cwd, exec: failing, show: h.show, gates });
|
|
356
|
+
expect(share).toMatchObject({ exitCode: 3, applied: [] });
|
|
357
|
+
expect(gates.appended[0]!.planDigest).toBe(set);
|
|
358
|
+
h.done();
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
describe("spent approvals (#3784)", () => {
|
|
363
|
+
const spending: OpWavesSpec = { ...spec, approvals: { spend: true } };
|
|
364
|
+
const digest = (n: string) => `sha256:${n.repeat(64)}`;
|
|
365
|
+
const set = waveSetDigest([{ member: "staging", planDigest: digest("a") }]);
|
|
366
|
+
const approval = { version: 1 as const, op: "migrations", gate: "migrations-wave-2", resolvedBy: "alice", timestamp: "2026-10-10T00:00:00Z", planDigest: set };
|
|
367
|
+
function harness(atBase: OpWavesSpec | null) {
|
|
368
|
+
const cwd = mkdtempSync(join(tmpdir(), "op-waves-spend-"));
|
|
369
|
+
const calls: string[] = [];
|
|
370
|
+
const exec: OpWaveExec = (argv, dir) => {
|
|
371
|
+
calls.push(argv.join(" "));
|
|
372
|
+
if (argv[1] === "plan") writeFileSync(join(dir, argv[4]!), JSON.stringify({ planDigest: digest(argv[2] === "staging" ? "a" : "b"), destructive: true }));
|
|
373
|
+
return 0;
|
|
374
|
+
};
|
|
375
|
+
const show: OpWaveShowAtBase = () => ({ sha: "b".repeat(40), text: atBase === null ? null : JSON.stringify(atBase) });
|
|
376
|
+
return { cwd, calls, exec, show, applies: () => calls.filter((c) => c.startsWith("tool apply")).length, done: () => rmSync(cwd, { recursive: true, force: true }) };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
test("the spec checks approvals", () => {
|
|
380
|
+
expect(() => assertOpWavesSpec({ ...spec, approvals: { spend: "yes" } } as unknown as OpWavesSpec)).toThrow(/approvals.spend must be true or false/);
|
|
381
|
+
expect(() => assertOpWavesSpec({ ...spec, approvals: { burn: true } } as unknown as OpWavesSpec)).toThrow(/approvals.burn is not a setting/);
|
|
382
|
+
expect(() => assertOpWavesSpec(spending)).not.toThrow();
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
test("with spend at base, an approval passes the same wave once: the second run waits for a new approval", async () => {
|
|
386
|
+
const h = harness(spending);
|
|
387
|
+
const gates = memoryGateLedgerPort({ resolutions: [approval] });
|
|
388
|
+
const now = "2026-10-11T00:00:00Z";
|
|
389
|
+
const first = await runOpWave({ spec: spending, specFile: "waves.json", wave: 2, cwd: h.cwd, exec: h.exec, show: h.show, gates, now });
|
|
390
|
+
expect(first).toMatchObject({ exitCode: 0, applied: ["staging"], decision: { status: "approved", approvedBy: "alice", approvedAt: approval.timestamp, spend: "base", spentAt: now } });
|
|
391
|
+
expect(gates.applied).toEqual([
|
|
392
|
+
{ version: 1, kind: "applied", op: "migrations", gate: "migrations-wave-2", planDigest: set, approvedAt: approval.timestamp, approvedBy: "alice", timestamp: now, wave: { number: 2, name: "staging" } },
|
|
393
|
+
]);
|
|
394
|
+
|
|
395
|
+
const logs: string[] = [];
|
|
396
|
+
const second = await runOpWave({ spec: spending, specFile: "waves.json", wave: 2, cwd: h.cwd, exec: h.exec, show: h.show, gates, now: "2026-10-11T01:00:00Z", log: (l) => logs.push(l) });
|
|
397
|
+
expect(second).toMatchObject({ exitCode: 3, applied: [], decision: { status: "waiting" } });
|
|
398
|
+
expect(h.applies()).toBe(1);
|
|
399
|
+
expect(logs.some((l) => /the approval of .* by alice at 2026-10-10T00:00:00Z is spent/.test(l))).toBe(true);
|
|
400
|
+
expect(gates.appended.at(-1)!.planDigest).toBe(set);
|
|
401
|
+
|
|
402
|
+
// A new approval of the same digest passes it once more, and is spent in turn.
|
|
403
|
+
await gates.appendResolution!({ op: "migrations", gate: "migrations-wave-2", resolvedBy: "bob", timestamp: "2026-10-11T02:00:00Z", planDigest: set });
|
|
404
|
+
const third = await runOpWave({ spec: spending, specFile: "waves.json", wave: 2, cwd: h.cwd, exec: h.exec, show: h.show, gates, now: "2026-10-11T03:00:00Z" });
|
|
405
|
+
expect(third).toMatchObject({ exitCode: 0, decision: { approvedBy: "bob" } });
|
|
406
|
+
expect(gates.applied).toHaveLength(2);
|
|
407
|
+
h.done();
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
test("without spend at base, the approval passes the digest every time, and only a change that adds spend records it", async () => {
|
|
411
|
+
const off = harness(spec);
|
|
412
|
+
const gates = memoryGateLedgerPort({ resolutions: [approval] });
|
|
413
|
+
for (const now of ["2026-10-11T00:00:00Z", "2026-10-11T01:00:00Z"]) {
|
|
414
|
+
expect(await runOpWave({ spec, specFile: "waves.json", wave: 2, cwd: off.cwd, exec: off.exec, show: off.show, gates, now })).toMatchObject({ exitCode: 0 });
|
|
415
|
+
}
|
|
416
|
+
expect(gates.applied).toEqual([]);
|
|
417
|
+
off.done();
|
|
418
|
+
|
|
419
|
+
// The change turns spend on: this apply is recorded, and the rule holds from the next decision at base.
|
|
420
|
+
const adding = harness(spec);
|
|
421
|
+
const added = await runOpWave({ spec: spending, specFile: "waves.json", wave: 2, cwd: adding.cwd, exec: adding.exec, show: adding.show, gates, now: "2026-10-11T02:00:00Z" });
|
|
422
|
+
expect(added).toMatchObject({ exitCode: 0, decision: { spend: "change" } });
|
|
423
|
+
expect(gates.applied).toHaveLength(1);
|
|
424
|
+
adding.done();
|
|
425
|
+
const next = harness(spending);
|
|
426
|
+
expect(await runOpWave({ spec: spending, specFile: "waves.json", wave: 2, cwd: next.cwd, exec: next.exec, show: next.show, gates, now: "2026-10-11T03:00:00Z" })).toMatchObject({ exitCode: 3 });
|
|
427
|
+
next.done();
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
test("a change cannot turn spend off for its own apply", async () => {
|
|
431
|
+
const h = harness(spending);
|
|
432
|
+
const gates = memoryGateLedgerPort({ resolutions: [approval] });
|
|
433
|
+
await runOpWave({ spec: spending, specFile: "waves.json", wave: 2, cwd: h.cwd, exec: h.exec, show: h.show, gates, now: "2026-10-11T00:00:00Z" });
|
|
434
|
+
const again = await runOpWave({ spec, specFile: "waves.json", wave: 2, cwd: h.cwd, exec: h.exec, show: h.show, gates, now: "2026-10-11T01:00:00Z" });
|
|
435
|
+
expect(again.exitCode).toBe(3);
|
|
436
|
+
h.done();
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
test("a spent approval of another digest does not refuse; the wave waits for its own", async () => {
|
|
440
|
+
const h = harness(spending);
|
|
441
|
+
const other = { ...approval, planDigest: "sha256:" + "f".repeat(64) };
|
|
442
|
+
const gates = memoryGateLedgerPort({
|
|
443
|
+
resolutions: [other],
|
|
444
|
+
applied: [{ version: 1, kind: "applied", op: "migrations", gate: "migrations-wave-2", planDigest: other.planDigest, approvedAt: other.timestamp, approvedBy: "alice", timestamp: "2026-10-10T01:00:00Z" }],
|
|
445
|
+
});
|
|
446
|
+
const result = await runOpWave({ spec: spending, specFile: "waves.json", wave: 2, cwd: h.cwd, exec: h.exec, show: h.show, gates, now: "2026-10-11T00:00:00Z" });
|
|
447
|
+
expect(result).toMatchObject({ exitCode: 3, decision: { status: "waiting" } });
|
|
448
|
+
h.done();
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
test("a wide wave's deciding job spends the approval once, before its shares apply", async () => {
|
|
452
|
+
const wide: OpWavesSpec = { ...spending, waves: spending.waves.map((w) => (w.name === "prod" ? { ...w, gate: "always" as const } : w)) };
|
|
453
|
+
const h = harness(wide);
|
|
454
|
+
const prodSet = waveSetDigest(["t1", "t2", "t3"].map((t) => ({ member: t, planDigest: digest("b") })));
|
|
455
|
+
const gates = memoryGateLedgerPort({ resolutions: [{ ...approval, gate: "migrations-wave-3", planDigest: prodSet }] });
|
|
456
|
+
const decided = await runOpWave({ spec: wide, specFile: "waves.json", wave: 3, decide: true, cwd: h.cwd, exec: h.exec, show: h.show, gates, now: "2026-10-11T00:00:00Z" });
|
|
457
|
+
expect(decided).toMatchObject({ exitCode: 0, decision: { status: "approved", spentAt: "2026-10-11T00:00:00Z" } });
|
|
458
|
+
expect(gates.applied).toHaveLength(1);
|
|
459
|
+
for (const share of [1, 2]) {
|
|
460
|
+
expect(await runOpWave({ spec: wide, specFile: "waves.json", wave: 3, share, cwd: h.cwd, exec: h.exec, show: h.show, gates })).toMatchObject({ exitCode: 0 });
|
|
461
|
+
}
|
|
462
|
+
expect(gates.applied).toHaveLength(1);
|
|
463
|
+
expect(h.applies()).toBe(3);
|
|
464
|
+
h.done();
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
test("a wave that cannot record the approval applies nothing", async () => {
|
|
468
|
+
const h = harness(spending);
|
|
469
|
+
const gates = { ...memoryGateLedgerPort({ resolutions: [approval] }), appendApplied: undefined };
|
|
470
|
+
const result = await runOpWave({ spec: spending, specFile: "waves.json", wave: 2, cwd: h.cwd, exec: h.exec, show: h.show, gates });
|
|
471
|
+
expect(result).toMatchObject({ exitCode: 1, applied: [] });
|
|
472
|
+
expect(h.applies()).toBe(0);
|
|
473
|
+
h.done();
|
|
474
|
+
});
|
|
475
|
+
});
|