@intentius/chant 0.115.0 → 0.117.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/cli/handlers/gate-resume.d.ts +44 -0
  2. package/dist/cli/handlers/gate-resume.d.ts.map +1 -0
  3. package/dist/cli/handlers/operator.d.ts +1 -0
  4. package/dist/cli/handlers/operator.d.ts.map +1 -1
  5. package/dist/cli/handlers/run-generate.d.ts.map +1 -1
  6. package/dist/cli/handlers/run-wave.d.ts +7 -0
  7. package/dist/cli/handlers/run-wave.d.ts.map +1 -0
  8. package/dist/cli/main.d.ts.map +1 -1
  9. package/dist/cli/registry.d.ts +11 -1
  10. package/dist/cli/registry.d.ts.map +1 -1
  11. package/dist/lexicon.d.ts +22 -0
  12. package/dist/lexicon.d.ts.map +1 -1
  13. package/dist/lifecycle/gate-ledger.d.ts +7 -0
  14. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  15. package/dist/op/gate-resume.d.ts +104 -0
  16. package/dist/op/gate-resume.d.ts.map +1 -0
  17. package/dist/op/gate.d.ts.map +1 -1
  18. package/dist/op/generate-pipeline.d.ts +15 -1
  19. package/dist/op/generate-pipeline.d.ts.map +1 -1
  20. package/dist/op/index.d.ts +6 -2
  21. package/dist/op/index.d.ts.map +1 -1
  22. package/dist/op/op-waves-run.d.ts +115 -0
  23. package/dist/op/op-waves-run.d.ts.map +1 -0
  24. package/dist/op/op-waves.d.ts +158 -0
  25. package/dist/op/op-waves.d.ts.map +1 -0
  26. package/package.json +1 -1
  27. package/src/cli/handlers/gate-resume.test.ts +51 -0
  28. package/src/cli/handlers/gate-resume.ts +166 -0
  29. package/src/cli/handlers/operator.ts +8 -2
  30. package/src/cli/handlers/run-generate.ts +51 -1
  31. package/src/cli/handlers/run-wave.ts +43 -0
  32. package/src/cli/main.ts +28 -2
  33. package/src/cli/registry.ts +11 -1
  34. package/src/lexicon.ts +26 -0
  35. package/src/lifecycle/gate-ledger.ts +7 -0
  36. package/src/op/gate-resume.e2e.test.ts +135 -0
  37. package/src/op/gate-resume.test.ts +177 -0
  38. package/src/op/gate-resume.ts +267 -0
  39. package/src/op/gate.ts +4 -0
  40. package/src/op/generate-pipeline.ts +49 -0
  41. package/src/op/index.ts +13 -2
  42. package/src/op/op-waves-run.ts +325 -0
  43. package/src/op/op-waves.test.ts +95 -0
  44. package/src/op/op-waves.ts +250 -0
@@ -0,0 +1,325 @@
1
+ /**
2
+ * What an Op waves job runs (#3679): `chant run wave --spec <file> --wave <k>`.
3
+ * `./op-waves.ts` holds the spec and the jobs; this plans a wave's runs,
4
+ * decides its gate and applies it.
5
+ *
6
+ * - Plan: each run's `plan` command writes a plan file; its `planDigest` is
7
+ * the run's member digest, named by its target.
8
+ * - Decide: the wave's policy comes from the spec file at the base commit
9
+ * (`git show <base>:<file>`), so a change cannot loosen its own gate. A
10
+ * wave missing there, or a file missing there, is `always`. A wave that
11
+ * needs an approval waits on `<gate>-wave-<k>` bound to
12
+ * {@link waveSetDigest} over its members, and exits 3 when nobody approved
13
+ * that digest.
14
+ * - Apply: each run's `apply` command. A share job (`--share i`) re-plans its
15
+ * slice first and refuses, without applying it, a run whose plan digest is
16
+ * not the one the deciding job recorded (exit 4).
17
+ */
18
+
19
+ import { execFileSync, spawnSync } from "node:child_process";
20
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
21
+ import { dirname, join, posix } from "node:path";
22
+ import { waveGateName, waveSetDigest, type WaveMember } from "../gated-waves";
23
+ import { approveCommand, evaluateGate, gitGateLedgerPort, type GateLedgerPort } from "./gate";
24
+ import {
25
+ OP_WAVE_DEFAULT_APPLY,
26
+ OP_WAVE_GATE_POLICIES,
27
+ assertOpWavesSpec,
28
+ opWaveRecordPath,
29
+ opWaveShare,
30
+ type OpWaveGatePolicy,
31
+ type OpWaveRun,
32
+ type OpWavesSpec,
33
+ } from "./op-waves";
34
+
35
+ /** What a run's plan command writes to `{plan}`. */
36
+ export interface OpWavePlanFile {
37
+ planDigest: string;
38
+ /** The plan destroys, rewrites or otherwise needs a person under `on-destructive`. */
39
+ destructive?: boolean;
40
+ /** The plan changes nothing. */
41
+ empty?: boolean;
42
+ }
43
+
44
+ /** One run's plan, as the decision records it. */
45
+ export interface OpWaveMember extends WaveMember {
46
+ op: string;
47
+ destructive?: boolean;
48
+ empty?: boolean;
49
+ }
50
+
51
+ /** Where a wave's policy came from. */
52
+ export type OpWavePolicySource = "base" | "absent-at-base";
53
+
54
+ /** A wave's decision: what was planned, the policy, and whether it may apply. */
55
+ export interface OpWaveDecision {
56
+ wave: number;
57
+ name: string;
58
+ /** The op the gate is recorded under: the spec's name. */
59
+ op: string;
60
+ gate: string;
61
+ policy: OpWaveGatePolicy;
62
+ policySource: OpWavePolicySource;
63
+ /** The base commit the policy was read at. */
64
+ base: string;
65
+ digest: string;
66
+ members: OpWaveMember[];
67
+ /** `approved`: an approval of this digest stands. `not-required`: the policy lets it through. `waiting`: neither. */
68
+ status: "approved" | "not-required" | "waiting";
69
+ approvedBy?: string;
70
+ /** The command that approves this digest, when waiting. */
71
+ approve?: string;
72
+ }
73
+
74
+ /** Runs one argv; returns its exit code. Injected so tests run no processes. */
75
+ export type OpWaveExec = (argv: string[], cwd: string) => number;
76
+
77
+ const spawnExec: OpWaveExec = (argv, cwd) => {
78
+ const result = spawnSync(argv[0]!, argv.slice(1), { cwd, stdio: "inherit" });
79
+ if (result.error) throw result.error;
80
+ return result.status ?? 1;
81
+ };
82
+
83
+ /** Reads one file at one commit, or null when the commit has no such file. Injected for tests. */
84
+ export type OpWaveShowAtBase = (base: string, path: string, cwd: string) => { sha: string; text: string | null };
85
+
86
+ const gitShowAtBase: OpWaveShowAtBase = (base, path, cwd) => {
87
+ let sha: string;
88
+ try {
89
+ sha = execFileSync("git", ["rev-parse", "--verify", `${base}^{commit}`], { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }).trim();
90
+ } catch {
91
+ throw new Error(
92
+ `the gate policy is read at ${base}, which this checkout does not have. ` +
93
+ `Check out the full history (fetch-depth: 0 on GitHub and Forgejo, GIT_DEPTH: "0" on GitLab), or set the spec's base.`,
94
+ );
95
+ }
96
+ try {
97
+ const text = execFileSync("git", ["show", `${sha}:./${path}`], { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] });
98
+ return { sha, text };
99
+ } catch {
100
+ return { sha, text: null };
101
+ }
102
+ };
103
+
104
+ /** Read a spec file, refusing anything that is not one. */
105
+ export function parseOpWavesSpec(text: string, where: string): OpWavesSpec {
106
+ let parsed: unknown;
107
+ try {
108
+ parsed = JSON.parse(text);
109
+ } catch (err) {
110
+ throw new Error(`${where}: ${err instanceof Error ? err.message : String(err)}`);
111
+ }
112
+ const waves = (parsed as { waves?: unknown } | null)?.waves;
113
+ const spec = (Array.isArray(waves) ? parsed : waves) as OpWavesSpec | undefined;
114
+ if (!spec || typeof spec !== "object" || !Array.isArray(spec.waves)) {
115
+ throw new Error(`${where} must hold an Op waves spec: { "name", "op", "plan", "waves": [...] }, or { "waves": <that>, "options": {...} }`);
116
+ }
117
+ return spec;
118
+ }
119
+
120
+ /**
121
+ * The policy of wave `name`, as the spec file at `base` declares it. The
122
+ * file or the wave missing there reads as `always`: a change that adds a
123
+ * wave, or the file, waits for a person the first time.
124
+ */
125
+ export function readOpWavePolicy(
126
+ specFile: string,
127
+ name: string,
128
+ base: string,
129
+ cwd: string,
130
+ show: OpWaveShowAtBase = gitShowAtBase,
131
+ ): { policy: OpWaveGatePolicy; source: OpWavePolicySource; base: string } {
132
+ const { sha, text } = show(base, specFile.replace(/^\.\//, "").split("\\").join(posix.sep), cwd);
133
+ if (text === null) return { policy: "always", source: "absent-at-base", base: sha };
134
+ let spec: OpWavesSpec;
135
+ try {
136
+ spec = parseOpWavesSpec(text, `${specFile} at ${base}`);
137
+ } catch {
138
+ return { policy: "always", source: "absent-at-base", base: sha };
139
+ }
140
+ const wave = spec.waves.find((w) => w.name === name);
141
+ if (!wave) return { policy: "always", source: "absent-at-base", base: sha };
142
+ const policy = wave.gate ?? "always";
143
+ return { policy: OP_WAVE_GATE_POLICIES.includes(policy) ? policy : "always", source: "base", base: sha };
144
+ }
145
+
146
+ /** Fill one argv template for one run. */
147
+ export function opWaveArgv(template: readonly string[], values: { op: string; target: string; wave: string; plan: string }): string[] {
148
+ return template.map((part) =>
149
+ part.replaceAll("{op}", values.op).replaceAll("{target}", values.target).replaceAll("{wave}", values.wave).replaceAll("{plan}", values.plan),
150
+ );
151
+ }
152
+
153
+ /** Where a run's plan file goes. */
154
+ export function opWavePlanPath(spec: Pick<OpWavesSpec, "name">, wave: number, target: string): string {
155
+ return `.chant/op-waves/${spec.name}/wave-${wave}/${target.replace(/[^A-Za-z0-9._-]/g, "_")}.plan.json`;
156
+ }
157
+
158
+ export interface RunOpWaveOptions {
159
+ spec: OpWavesSpec;
160
+ specFile: string;
161
+ /** 1-based. */
162
+ wave: number;
163
+ /** Plan and decide only (a wide wave's deciding job). */
164
+ decide?: boolean;
165
+ /** Apply this 1-based share of a decided wave. */
166
+ share?: number;
167
+ /** Overrides the spec's base. */
168
+ base?: string;
169
+ cwd?: string;
170
+ gates?: GateLedgerPort;
171
+ exec?: OpWaveExec;
172
+ show?: OpWaveShowAtBase;
173
+ now?: string;
174
+ log?: (line: string) => void;
175
+ }
176
+
177
+ export interface RunOpWaveResult {
178
+ /** 0 applied (or decided), 1 a run failed, 3 waiting for an approval, 4 a plan moved since the decision. */
179
+ exitCode: number;
180
+ decision?: OpWaveDecision;
181
+ applied: string[];
182
+ failed: string[];
183
+ /** Runs a share job refused because their plan moved since the decision. */
184
+ moved: string[];
185
+ }
186
+
187
+ function planRun(
188
+ opts: Required<Pick<RunOpWaveOptions, "spec" | "wave">> & { cwd: string; exec: OpWaveExec },
189
+ waveName: string,
190
+ run: OpWaveRun,
191
+ ): OpWaveMember {
192
+ const op = run.op ?? opts.spec.op;
193
+ const plan = opWavePlanPath(opts.spec, opts.wave, run.target);
194
+ mkdirSync(dirname(join(opts.cwd, plan)), { recursive: true });
195
+ const code = opts.exec(opWaveArgv(opts.spec.plan, { op, target: run.target, wave: waveName, plan }), opts.cwd);
196
+ if (code !== 0) throw new Error(`planning ${run.target} exited ${code}`);
197
+ let file: OpWavePlanFile;
198
+ try {
199
+ file = JSON.parse(readFileSync(join(opts.cwd, plan), "utf-8")) as OpWavePlanFile;
200
+ } catch (err) {
201
+ throw new Error(`planning ${run.target} wrote no readable plan file at ${plan}: ${err instanceof Error ? err.message : String(err)}`);
202
+ }
203
+ if (typeof file.planDigest !== "string" || file.planDigest === "") {
204
+ throw new Error(`the plan file for ${run.target} (${plan}) has no planDigest`);
205
+ }
206
+ return {
207
+ member: run.target,
208
+ planDigest: file.planDigest,
209
+ op,
210
+ ...(file.destructive === true ? { destructive: true } : {}),
211
+ ...(file.empty === true ? { empty: true } : {}),
212
+ };
213
+ }
214
+
215
+ /** Whether `policy` lets a wave with these members through without an approval. */
216
+ export function opWaveNeedsApproval(policy: OpWaveGatePolicy, members: readonly OpWaveMember[]): boolean {
217
+ if (policy === "never") return false;
218
+ if (members.every((m) => m.empty === true)) return false;
219
+ if (policy === "on-destructive") return members.some((m) => m.destructive === true);
220
+ return true;
221
+ }
222
+
223
+ /** Plan, decide and apply one wave, or one part of a wide wave. */
224
+ export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveResult> {
225
+ const { spec, wave: k } = options;
226
+ assertOpWavesSpec(spec);
227
+ const cwd = options.cwd ?? process.cwd();
228
+ const exec = options.exec ?? spawnExec;
229
+ const log = options.log ?? ((line: string) => console.error(line));
230
+ const wave = spec.waves[k - 1];
231
+ if (!wave) throw new Error(`Op waves "${spec.name}" has ${spec.waves.length} waves; there is no wave ${k}.`);
232
+ const shares = wave.shares ?? 1;
233
+ if (options.share !== undefined && (options.share < 1 || options.share > shares)) {
234
+ throw new Error(`wave ${k} (${wave.name}) has ${shares} share${shares === 1 ? "" : "s"}; there is no share ${options.share}.`);
235
+ }
236
+ const ctx = { spec, wave: k, cwd, exec };
237
+ const recordPath = join(cwd, opWaveRecordPath(spec, k));
238
+ const result: RunOpWaveResult = { exitCode: 0, applied: [], failed: [], moved: [] };
239
+
240
+ if (options.share !== undefined) {
241
+ let decision: OpWaveDecision;
242
+ try {
243
+ decision = JSON.parse(readFileSync(recordPath, "utf-8")) as OpWaveDecision;
244
+ } catch {
245
+ throw new Error(`share ${options.share} of wave ${k} (${wave.name}) found no decision at ${opWaveRecordPath(spec, k)}. Run the deciding job first.`);
246
+ }
247
+ result.decision = decision;
248
+ if (decision.status === "waiting") {
249
+ log(`wave ${k} (${wave.name}) is waiting on ${decision.gate}; nothing applied. ${decision.approve ?? ""}`.trim());
250
+ return { ...result, exitCode: 3 };
251
+ }
252
+ const decided = new Map(decision.members.map((m) => [m.member, m.planDigest]));
253
+ for (const run of opWaveShare(wave.runs, options.share, shares)) {
254
+ const member = planRun(ctx, wave.name, run);
255
+ if (decided.get(run.target) !== member.planDigest) {
256
+ log(
257
+ `${run.target}: its plan moved since wave ${k} was decided, so it was not applied. ` +
258
+ `decided: ${decided.get(run.target) ?? "(not in the decision)"}; planned now: ${member.planDigest}`,
259
+ );
260
+ result.moved.push(run.target);
261
+ continue;
262
+ }
263
+ applyRun(spec, wave.name, k, run, cwd, exec, result, log);
264
+ }
265
+ result.exitCode = result.failed.length > 0 ? 1 : result.moved.length > 0 ? 4 : 0;
266
+ return result;
267
+ }
268
+
269
+ const members = wave.runs.map((run) => planRun(ctx, wave.name, run));
270
+ const digest = waveSetDigest(members);
271
+ const { policy, source, base } = readOpWavePolicy(options.specFile, wave.name, options.base ?? spec.base ?? "HEAD^1", cwd, options.show);
272
+ const gate = waveGateName(spec.gate ?? spec.name, k);
273
+ const decision: OpWaveDecision = { wave: k, name: wave.name, op: spec.name, gate, policy, policySource: source, base, digest, members, status: "not-required" };
274
+ if (opWaveNeedsApproval(policy, members)) {
275
+ const check = await evaluateGate(options.gates ?? gitGateLedgerPort({ cwd }), {
276
+ op: spec.name,
277
+ gate,
278
+ planDigest: digest,
279
+ description: `wave ${k} (${wave.name}): ${members.length} run${members.length === 1 ? "" : "s"}, gate ${policy}`,
280
+ ...(options.now ? { now: options.now } : {}),
281
+ });
282
+ if (check.satisfied) {
283
+ decision.status = "approved";
284
+ decision.approvedBy = check.resolution.resolvedBy;
285
+ } else {
286
+ decision.status = "waiting";
287
+ decision.approve = approveCommand(spec.name, gate, undefined, digest);
288
+ }
289
+ }
290
+ mkdirSync(dirname(recordPath), { recursive: true });
291
+ writeFileSync(recordPath, JSON.stringify(decision, null, 2) + "\n");
292
+ result.decision = decision;
293
+ log(
294
+ `wave ${k} (${wave.name}): gate ${policy} (read at ${base.slice(0, 12)}${source === "absent-at-base" ? ", where the wave is absent" : ""}), ` +
295
+ `digest ${digest}: ${decision.status}${decision.approvedBy ? ` by ${decision.approvedBy}` : ""}`,
296
+ );
297
+ if (decision.status === "waiting") {
298
+ log(`Nothing in wave ${k} or after it ran. Read the plans, then approve: ${decision.approve} (with --resume, the approval also starts this job again)`);
299
+ return { ...result, exitCode: 3 };
300
+ }
301
+ if (options.decide) return result;
302
+ for (const run of wave.runs) applyRun(spec, wave.name, k, run, cwd, exec, result, log);
303
+ result.exitCode = result.failed.length > 0 ? 1 : 0;
304
+ return result;
305
+ }
306
+
307
+ function applyRun(
308
+ spec: OpWavesSpec,
309
+ waveName: string,
310
+ k: number,
311
+ run: OpWaveRun,
312
+ cwd: string,
313
+ exec: OpWaveExec,
314
+ result: RunOpWaveResult,
315
+ log: (line: string) => void,
316
+ ): void {
317
+ const op = run.op ?? spec.op;
318
+ const plan = opWavePlanPath(spec, k, run.target);
319
+ const code = exec(opWaveArgv(spec.apply ?? OP_WAVE_DEFAULT_APPLY, { op, target: run.target, wave: waveName, plan }), cwd);
320
+ if (code === 0) result.applied.push(run.target);
321
+ else {
322
+ log(`${run.target}: apply exited ${code}`);
323
+ result.failed.push(run.target);
324
+ }
325
+ }
@@ -0,0 +1,95 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
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";
7
+ import { memoryGateLedgerPort } from "./gate";
8
+ import { waveSetDigest } from "../gated-waves";
9
+
10
+ const spec: OpWavesSpec = {
11
+ name: "migrations",
12
+ op: "migrate",
13
+ plan: ["tool", "plan", "{target}", "--out", "{plan}"],
14
+ apply: ["tool", "apply", "{target}", "--plan", "{plan}"],
15
+ waves: [
16
+ { name: "dev", runs: [{ target: "dev" }], gate: "never" },
17
+ { name: "staging", runs: [{ target: "staging" }], gate: "on-destructive" },
18
+ { name: "prod", runs: [{ target: "t3" }, { target: "t1" }, { target: "t2" }], shares: 2 },
19
+ ],
20
+ };
21
+
22
+ describe("opWaveJobs", () => {
23
+ test("one job per wave, a deciding job and share jobs for a wide one, each wave needing the last", () => {
24
+ const jobs = opWaveJobs(spec, "waves.json");
25
+ expect(jobs.map((j) => [j.jobName, j.needs])).toEqual([
26
+ ["wave-1-dev", []],
27
+ ["wave-2-staging", ["wave-1-dev"]],
28
+ ["wave-3-prod-decide", ["wave-2-staging"]],
29
+ ["wave-3-prod-share-1", ["wave-3-prod-decide"]],
30
+ ["wave-3-prod-share-2", ["wave-3-prod-decide"]],
31
+ ]);
32
+ expect(jobs[2]!.command.join(" ")).toBe("chant run wave --spec waves.json --wave 3 --decide");
33
+ expect(jobs[4]!.command.join(" ")).toBe("chant run wave --spec waves.json --wave 3 --share 2");
34
+ });
35
+
36
+ test("shares slice the runs by target", () => {
37
+ const runs = spec.waves[2]!.runs;
38
+ expect(opWaveShare(runs, 1, 2).map((r) => r.target)).toEqual(["t1", "t2"]);
39
+ expect(opWaveShare(runs, 2, 2).map((r) => r.target)).toEqual(["t3"]);
40
+ });
41
+ });
42
+
43
+ describe("runOpWave", () => {
44
+ function harness(plans: Record<string, { planDigest: string; destructive?: boolean }>, policyAtBase: string | null) {
45
+ const cwd = mkdtempSync(join(tmpdir(), "op-waves-"));
46
+ const calls: string[] = [];
47
+ const exec: OpWaveExec = (argv, dir) => {
48
+ calls.push(argv.join(" "));
49
+ if (argv[1] === "plan") writeFileSync(join(dir, argv[4]!), JSON.stringify(plans[argv[2]!]));
50
+ return 0;
51
+ };
52
+ const show: OpWaveShowAtBase = () => ({ sha: "b".repeat(40), text: policyAtBase });
53
+ return { cwd, calls, exec, show, done: () => rmSync(cwd, { recursive: true, force: true }) };
54
+ }
55
+ const digest = (n: string) => `sha256:${n.repeat(64)}`;
56
+
57
+ test("the policy comes from the base commit: a change that sets staging to never still waits", async () => {
58
+ const h = harness({ staging: { planDigest: digest("a"), destructive: true } }, JSON.stringify(spec));
59
+ const loosened = { ...spec, waves: spec.waves.map((w) => ({ ...w, gate: "never" as const })) };
60
+ const gates = memoryGateLedgerPort();
61
+ const result = await runOpWave({ spec: loosened, specFile: "waves.json", wave: 2, cwd: h.cwd, exec: h.exec, show: h.show, gates });
62
+ expect(result.exitCode).toBe(3);
63
+ expect(result.decision).toMatchObject({ policy: "on-destructive", policySource: "base", status: "waiting", gate: "migrations-wave-2" });
64
+ expect(gates.appended[0]!.planDigest).toBe(waveSetDigest([{ member: "staging", planDigest: digest("a") }]));
65
+ expect(h.calls.some((c) => c.startsWith("tool apply"))).toBe(false);
66
+ h.done();
67
+ });
68
+
69
+ test("an approval of the set digest lets the wave apply; a non-destructive plan needs none", async () => {
70
+ const h = harness({ staging: { planDigest: digest("a"), destructive: true } }, JSON.stringify(spec));
71
+ const set = waveSetDigest([{ member: "staging", planDigest: digest("a") }]);
72
+ const gates = memoryGateLedgerPort({
73
+ resolutions: [{ version: 1, op: "migrations", gate: "migrations-wave-2", resolvedBy: "alice", timestamp: "2026-10-10T00:00:00Z", planDigest: set }],
74
+ });
75
+ const approved = await runOpWave({ spec, specFile: "waves.json", wave: 2, cwd: h.cwd, exec: h.exec, show: h.show, gates });
76
+ expect(approved).toMatchObject({ exitCode: 0, applied: ["staging"], decision: { status: "approved", approvedBy: "alice" } });
77
+ h.done();
78
+
79
+ const quiet = harness({ staging: { planDigest: digest("c") } }, JSON.stringify(spec));
80
+ const free = await runOpWave({ spec, specFile: "waves.json", wave: 2, cwd: quiet.cwd, exec: quiet.exec, show: quiet.show, gates: memoryGateLedgerPort() });
81
+ expect(free).toMatchObject({ exitCode: 0, applied: ["staging"], decision: { status: "not-required" } });
82
+ quiet.done();
83
+ });
84
+
85
+ test("a share refuses a run whose plan moved since the decision", async () => {
86
+ const plans: Record<string, { planDigest: string }> = { t1: { planDigest: digest("1") }, t2: { planDigest: digest("2") }, t3: { planDigest: digest("3") } };
87
+ const h = harness(plans, JSON.stringify({ ...spec, waves: spec.waves.map((w) => ({ ...w, gate: "never" })) }));
88
+ const decided = await runOpWave({ spec, specFile: "waves.json", wave: 3, decide: true, cwd: h.cwd, exec: h.exec, show: h.show, gates: memoryGateLedgerPort() });
89
+ expect(decided).toMatchObject({ exitCode: 0, applied: [], decision: { status: "not-required" } });
90
+ plans.t2 = { planDigest: digest("9") };
91
+ const share = await runOpWave({ spec, specFile: "waves.json", wave: 3, share: 1, cwd: h.cwd, exec: h.exec, show: h.show });
92
+ expect(share).toMatchObject({ exitCode: 4, applied: ["t1"], moved: ["t2"] });
93
+ h.done();
94
+ });
95
+ });
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Gated waves of Op runs (#3679): a pipeline that applies one Op per
3
+ * environment, or per target in a set, in order, each wave behind one gate.
4
+ *
5
+ * A product built on chant that applies a migration to dev, then staging, then
6
+ * prod, or the same change to every tenant database of an environment, used to
7
+ * render that ordering itself, once per forge, and compute each wave's set
8
+ * digest itself. This module holds the forge-neutral part: the spec, the jobs
9
+ * it becomes and the commands they run. Each forge lexicon (github, gitlab,
10
+ * forgejo) renders these jobs with its own transport, so the three cannot
11
+ * disagree about order or commands. `./op-waves-run.ts` is what the jobs run.
12
+ *
13
+ * ## Jobs
14
+ *
15
+ * A wave is one job, `wave-<k>-<name>`, running `chant run wave --spec <file>
16
+ * --wave <k>`: plan every run, decide the wave's gate, apply. A wide wave
17
+ * (`shares` > 1) is a deciding job that plans every run and decides the gate,
18
+ * plus `shares` jobs that each apply a slice of the runs and refuse a run
19
+ * whose plan moved since the decision. Each wave's jobs need the previous
20
+ * wave's last jobs, so a wave that waits (exit 3) or fails stops every later
21
+ * wave.
22
+ *
23
+ * ## The gate
24
+ *
25
+ * Wave k waits on `waveGateName(<gate>, k)`, recorded under the spec's name,
26
+ * bound to {@link waveSetDigest} over its runs' plan digests (`../gated-waves.ts`,
27
+ * the same digest a component fan-out's wave binds). Whether it waits at all
28
+ * is the wave's gate policy, read from the base commit's copy of the spec
29
+ * file, so a change cannot loosen the rule that gates its own merge.
30
+ */
31
+
32
+ import type { OpEnvironment, OpSetupStep } from "../lexicon";
33
+
34
+ /** When a wave waits for an approval. */
35
+ export type OpWaveGatePolicy =
36
+ /** Whenever the wave changes something. */
37
+ | "always"
38
+ /** When a run's plan says it is destructive. */
39
+ | "on-destructive"
40
+ /** Never: only review and branch protection stand in front of the wave. */
41
+ | "never";
42
+
43
+ export const OP_WAVE_GATE_POLICIES: readonly OpWaveGatePolicy[] = ["always", "on-destructive", "never"];
44
+
45
+ /** One Op run in a wave: an Op applied to one target (an environment, a database, a tenant). */
46
+ export interface OpWaveRun {
47
+ /** What the run applies to. Unique within its wave; it names the run in the set digest. */
48
+ target: string;
49
+ /** The Op to run. Default: the spec's `op`. */
50
+ op?: string;
51
+ }
52
+
53
+ /** One wave: runs that apply together, behind one gate. */
54
+ export interface OpWave {
55
+ /** The wave's name, usually its environment. Lowercase letters, digits, `-` and `_`. */
56
+ name: string;
57
+ runs: OpWaveRun[];
58
+ /**
59
+ * When the wave waits. Default `"always"`. The runner reads this from the
60
+ * spec file at the base commit, never from the change being applied.
61
+ */
62
+ gate?: OpWaveGatePolicy;
63
+ /**
64
+ * Split the wave into a deciding job and this many share jobs, each
65
+ * applying a slice of the runs. Default 1: one job plans, decides and
66
+ * applies.
67
+ */
68
+ shares?: number;
69
+ /** The forge deployment environment the wave's jobs run in (GitHub and GitLab; Forgejo has none). */
70
+ environment?: OpEnvironment;
71
+ /** Variables for this wave's jobs alone. */
72
+ variables?: Record<string, string>;
73
+ /** Steps this wave's jobs run after the checkout, as `ScheduledOpSpec.setup`. */
74
+ setup?: OpSetupStep[];
75
+ }
76
+
77
+ /**
78
+ * An ordered list of waves of Op runs, as a pipeline and as the file its jobs
79
+ * read. Commands are argv templates; each `{op}`, `{target}`, `{wave}` and
80
+ * `{plan}` is replaced per run.
81
+ */
82
+ export interface OpWavesSpec {
83
+ /** The pipeline's name, and the op the wave gates are recorded under (`chant approve <name> <gate>-wave-<k>`). */
84
+ name: string;
85
+ /** The Op a run runs when it names none. */
86
+ op: string;
87
+ /** The base gate name. Default: `name`. */
88
+ gate?: string;
89
+ /** Branches whose pushes run the waves. Default `["main"]`. */
90
+ branches?: string[];
91
+ /**
92
+ * The commit the gate policy is read from. Default `HEAD^1`, the first
93
+ * parent: on a merge or squash onto the branch, the branch before the change.
94
+ */
95
+ base?: string;
96
+ /**
97
+ * Plan one run and write its plan file to `{plan}`: JSON `{ "planDigest":
98
+ * string, "destructive"?: boolean, "empty"?: boolean }`. `destructive` is
99
+ * what `on-destructive` waits on; a wave whose runs are all `empty` never
100
+ * waits.
101
+ */
102
+ plan: string[];
103
+ /** Apply one run whose plan is at `{plan}`. Default `chant run {op} --env {target}`. */
104
+ apply?: string[];
105
+ waves: OpWave[];
106
+ /**
107
+ * A scheduled job that starts again a wave job whose approval has arrived
108
+ * (#3683): it runs `chant run resume --op <name>`, which re-runs the
109
+ * GitHub run, retries the GitLab job or dispatches the Forgejo workflow.
110
+ * It approves nothing. Absent, no such job is rendered and an approval is
111
+ * followed by `chant approve --resume` or a re-run by hand.
112
+ */
113
+ resume?: OpWavesResume;
114
+ }
115
+
116
+ /** The scheduled resume job of an Op waves pipeline (#3683). */
117
+ export interface OpWavesResume {
118
+ /**
119
+ * Five-field cron, such as `"*\/10 * * * *"`. GitHub and Forgejo put it
120
+ * in the workflow; GitLab runs the job in a pipeline schedule you create,
121
+ * which sets the cron.
122
+ */
123
+ schedule: string;
124
+ }
125
+
126
+ /** The command a resume job runs. */
127
+ export function opWavesResumeCommand(spec: Pick<OpWavesSpec, "name">): string[] {
128
+ return ["chant", "run", "resume", "--op", spec.name];
129
+ }
130
+
131
+ /** The default apply command. */
132
+ export const OP_WAVE_DEFAULT_APPLY = ["chant", "run", "{op}", "--env", "{target}"];
133
+
134
+ /** Where the runner keeps a wave's decision; a wide wave's deciding job hands it to its share jobs. */
135
+ export function opWaveRecordPath(spec: Pick<OpWavesSpec, "name">, wave: number): string {
136
+ return `.chant/op-waves/${spec.name}/wave-${wave}.json`;
137
+ }
138
+
139
+ /** One generated job. */
140
+ export interface OpWaveJob {
141
+ jobName: string;
142
+ /** 1-based wave number. */
143
+ wave: number;
144
+ /** The wave's name. */
145
+ waveName: string;
146
+ /** `wave`: plan, decide and apply. `decide`: plan and decide. `share`: apply one slice. */
147
+ kind: "wave" | "decide" | "share";
148
+ /** 1-based, on a share job. */
149
+ share?: number;
150
+ /** Jobs this one waits on. */
151
+ needs: string[];
152
+ /** The job's command, as argv. */
153
+ command: string[];
154
+ /** On a share job: the decision record to download. On a deciding job: the one to upload. */
155
+ record?: string;
156
+ }
157
+
158
+ const NAME = /^[a-z0-9][a-z0-9_-]*$/;
159
+
160
+ /** Refuse a spec no job could run, naming what is wrong. */
161
+ export function assertOpWavesSpec(spec: OpWavesSpec): void {
162
+ const where = `Op waves "${spec.name}"`;
163
+ if (!NAME.test(spec.name)) throw new Error(`${where}: the name must be lowercase letters, digits, "-" and "_"`);
164
+ if (!spec.op) throw new Error(`${where} names no \`op\`. Name the Op a run runs when it names none.`);
165
+ if (!Array.isArray(spec.plan) || spec.plan.length === 0) {
166
+ throw new Error(`${where} has no \`plan\` command. Each wave plans its runs to bind its gate to their plan digests.`);
167
+ }
168
+ if (!spec.plan.some((part) => part.includes("{plan}"))) {
169
+ throw new Error(`${where}: the \`plan\` command never names {plan}, the file it writes the plan digest to.`);
170
+ }
171
+ if (spec.waves.length === 0) throw new Error(`${where} has no waves.`);
172
+ if (spec.resume !== undefined && (typeof spec.resume.schedule !== "string" || spec.resume.schedule.trim().split(/\s+/).length !== 5)) {
173
+ throw new Error(`${where}: resume.schedule must be a five-field cron expression, such as "*/10 * * * *".`);
174
+ }
175
+ const names = new Set<string>();
176
+ for (const wave of spec.waves) {
177
+ if (!NAME.test(wave.name)) {
178
+ throw new Error(`${where}: wave "${wave.name}" must be named with lowercase letters, digits, "-" and "_"`);
179
+ }
180
+ if (names.has(wave.name)) throw new Error(`${where} names wave "${wave.name}" twice.`);
181
+ names.add(wave.name);
182
+ if (wave.gate !== undefined && !OP_WAVE_GATE_POLICIES.includes(wave.gate)) {
183
+ throw new Error(`${where}: wave "${wave.name}" has gate "${wave.gate}"; use ${OP_WAVE_GATE_POLICIES.join(", ")}.`);
184
+ }
185
+ if (wave.runs.length === 0) throw new Error(`${where}: wave "${wave.name}" has no runs.`);
186
+ const targets = new Set<string>();
187
+ for (const run of wave.runs) {
188
+ if (!run.target) throw new Error(`${where}: wave "${wave.name}" has a run with no target.`);
189
+ if (targets.has(run.target)) throw new Error(`${where}: wave "${wave.name}" names target "${run.target}" twice.`);
190
+ targets.add(run.target);
191
+ }
192
+ const shares = wave.shares ?? 1;
193
+ if (!Number.isInteger(shares) || shares < 1) {
194
+ throw new Error(`${where}: wave "${wave.name}" has shares ${wave.shares}; use a whole number from 1.`);
195
+ }
196
+ if (shares > wave.runs.length) {
197
+ throw new Error(`${where}: wave "${wave.name}" has ${shares} shares for ${wave.runs.length} runs, so a share would apply nothing.`);
198
+ }
199
+ }
200
+ }
201
+
202
+ /**
203
+ * The runs share `share` (1-based) of `shares` applies: the wave's runs sorted
204
+ * by target, cut into contiguous slices whose sizes differ by at most one.
205
+ */
206
+ export function opWaveShare<T extends { target: string }>(runs: readonly T[], share: number, shares: number): T[] {
207
+ const sorted = [...runs].sort((a, b) => (a.target < b.target ? -1 : a.target > b.target ? 1 : 0));
208
+ const base = Math.floor(sorted.length / shares);
209
+ const extra = sorted.length % shares;
210
+ const start = (share - 1) * base + Math.min(share - 1, extra);
211
+ const size = base + (share <= extra ? 1 : 0);
212
+ return sorted.slice(start, start + size);
213
+ }
214
+
215
+ /**
216
+ * The jobs of an Op waves pipeline, in order. `specFile` is the spec's path
217
+ * from the job's working directory, which every command names.
218
+ */
219
+ export function opWaveJobs(spec: OpWavesSpec, specFile: string): OpWaveJob[] {
220
+ assertOpWavesSpec(spec);
221
+ const jobs: OpWaveJob[] = [];
222
+ let previous: string[] = [];
223
+ spec.waves.forEach((wave, index) => {
224
+ const k = index + 1;
225
+ const command = ["chant", "run", "wave", "--spec", specFile, "--wave", String(k), ...(spec.base ? ["--base", spec.base] : [])];
226
+ const prefix = `wave-${k}-${wave.name}`;
227
+ const shares = wave.shares ?? 1;
228
+ if (shares === 1) {
229
+ jobs.push({ jobName: prefix, wave: k, waveName: wave.name, kind: "wave", needs: previous, command });
230
+ previous = [prefix];
231
+ return;
232
+ }
233
+ const record = opWaveRecordPath(spec, k);
234
+ const decide = `${prefix}-decide`;
235
+ jobs.push({ jobName: decide, wave: k, waveName: wave.name, kind: "decide", needs: previous, command: [...command, "--decide"], record });
236
+ const shareJobs: string[] = [];
237
+ for (let share = 1; share <= shares; share++) {
238
+ const jobName = `${prefix}-share-${share}`;
239
+ shareJobs.push(jobName);
240
+ jobs.push({ jobName, wave: k, waveName: wave.name, kind: "share", share, needs: [decide], command: [...command, "--share", String(share)], record });
241
+ }
242
+ previous = shareJobs;
243
+ });
244
+ return jobs;
245
+ }
246
+
247
+ /** The wave a job belongs to. */
248
+ export function opWaveOf(spec: OpWavesSpec, job: Pick<OpWaveJob, "wave">): OpWave {
249
+ return spec.waves[job.wave - 1]!;
250
+ }