@intentius/chant 0.114.1 → 0.116.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 (45) hide show
  1. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  2. package/dist/cli/handlers/run-generate.d.ts.map +1 -1
  3. package/dist/cli/handlers/run-wave.d.ts +7 -0
  4. package/dist/cli/handlers/run-wave.d.ts.map +1 -0
  5. package/dist/cli/main.d.ts.map +1 -1
  6. package/dist/cli/registry.d.ts +5 -1
  7. package/dist/cli/registry.d.ts.map +1 -1
  8. package/dist/deep-observation.d.ts +8 -0
  9. package/dist/deep-observation.d.ts.map +1 -1
  10. package/dist/lexicon.d.ts +22 -0
  11. package/dist/lexicon.d.ts.map +1 -1
  12. package/dist/lifecycle/deep-diff.d.ts +6 -0
  13. package/dist/lifecycle/deep-diff.d.ts.map +1 -1
  14. package/dist/lifecycle/deep-observe.d.ts.map +1 -1
  15. package/dist/op/activity-registry.d.ts +13 -1
  16. package/dist/op/activity-registry.d.ts.map +1 -1
  17. package/dist/op/builders.d.ts +2 -1
  18. package/dist/op/builders.d.ts.map +1 -1
  19. package/dist/op/generate-pipeline.d.ts +15 -1
  20. package/dist/op/generate-pipeline.d.ts.map +1 -1
  21. package/dist/op/index.d.ts +6 -2
  22. package/dist/op/index.d.ts.map +1 -1
  23. package/dist/op/op-waves-run.d.ts +115 -0
  24. package/dist/op/op-waves-run.d.ts.map +1 -0
  25. package/dist/op/op-waves.d.ts +139 -0
  26. package/dist/op/op-waves.d.ts.map +1 -0
  27. package/package.json +1 -1
  28. package/src/cli/handlers/lifecycle.ts +2 -1
  29. package/src/cli/handlers/run-generate.ts +51 -1
  30. package/src/cli/handlers/run-wave.ts +43 -0
  31. package/src/cli/main.ts +14 -0
  32. package/src/cli/registry.ts +5 -1
  33. package/src/deep-observation.ts +8 -0
  34. package/src/lexicon.ts +26 -0
  35. package/src/lifecycle/deep-diff.test.ts +20 -1
  36. package/src/lifecycle/deep-diff.ts +7 -0
  37. package/src/lifecycle/deep-observe.ts +2 -0
  38. package/src/op/activity-registry.test.ts +21 -0
  39. package/src/op/activity-registry.ts +25 -5
  40. package/src/op/builders.ts +2 -1
  41. package/src/op/generate-pipeline.ts +49 -0
  42. package/src/op/index.ts +13 -2
  43. package/src/op/op-waves-run.ts +325 -0
  44. package/src/op/op-waves.test.ts +95 -0
  45. package/src/op/op-waves.ts +224 -0
@@ -24,12 +24,29 @@ export type { ActivityProfile } from "./activity-profiles";
24
24
  */
25
25
  export type ActivityFn = (args: Record<string, unknown>, signal?: AbortSignal) => Promise<unknown>;
26
26
 
27
- /** Add every exported function from an activity module to `into`, keyed by name. */
27
+ /**
28
+ * The export an activity module lists its fallback activities under: names it
29
+ * provides only when no other configured lexicon does (#3657). The sql
30
+ * lexicon's `receiptRead` / `receiptWrite` / `receiptStaleness` are the case:
31
+ * a project that uses only sql keeps its `effect()` receipts on its database
32
+ * server, and one that also configures aws or k8s keeps them in that
33
+ * lexicon's receipt row, whichever order `lexicons` lists them in.
34
+ */
35
+ export const ACTIVITY_FALLBACKS_EXPORT = "ACTIVITY_FALLBACKS";
36
+
37
+ /**
38
+ * Add every exported function from an activity module to `into`, keyed by
39
+ * name. A name the module lists in {@link ACTIVITY_FALLBACKS_EXPORT} is added
40
+ * only when nothing provides it yet; any other export replaces what is there,
41
+ * a fallback included.
42
+ */
28
43
  function collectActivities(mod: Record<string, unknown>, into: Map<string, ActivityFn>): void {
44
+ const listed = mod[ACTIVITY_FALLBACKS_EXPORT];
45
+ const fallbacks = new Set(Array.isArray(listed) ? listed.filter((n): n is string => typeof n === "string") : []);
29
46
  for (const [name, value] of Object.entries(mod)) {
30
- if (typeof value === "function") {
31
- into.set(name, value as ActivityFn);
32
- }
47
+ if (typeof value !== "function") continue;
48
+ if (fallbacks.has(name) && into.has(name)) continue;
49
+ into.set(name, value as ActivityFn);
33
50
  }
34
51
  }
35
52
 
@@ -41,7 +58,10 @@ function collectActivities(mod: Record<string, unknown>, into: Map<string, Activ
41
58
  * Cloud-specific appliers live in their own lexicon (aws → `flociUp`/`flociDown`,
42
59
  * gcp → `gcpApply`, azure → `azGroupEnsure`/`azGroupDelete`), so `lexicons` —
43
60
  * the project's configured lexicon list — is consulted to pull those in. A
44
- * lexicon that ships no `op/activities` module is skipped.
61
+ * lexicon that ships no `op/activities` module is skipped. A lexicon's
62
+ * fallback activities ({@link ACTIVITY_FALLBACKS_EXPORT}) give way to another
63
+ * lexicon's activity of the same name; otherwise a later lexicon's export
64
+ * replaces an earlier one's.
45
65
  *
46
66
  * Never throws: the base library is a static import, so there is no "no
47
67
  * activities registered" state to report.
@@ -177,7 +177,8 @@ export function gate(
177
177
  * A gate authored inside `steps` is reached only when the effect will fire
178
178
  * (the matched path never reaches it). The receipt-store activities
179
179
  * (`receiptRead`, `receiptWrite`) are provided by the receipt row's lexicon
180
- * (#1835, aws).
180
+ * (#1835 aws, #2074 k8s), or in a sql-only project by the sql lexicon's
181
+ * store on the database server (#3657).
181
182
  *
182
183
  * @example
183
184
  * ```ts
@@ -25,7 +25,9 @@ import {
25
25
  type ScheduledOpSpec,
26
26
  type OpPipelineFile,
27
27
  type OpPipelineJob,
28
+ type OpWavesPipelineOptions,
28
29
  } from "../lexicon";
30
+ import { assertOpWavesSpec, type OpWaveJob, type OpWavesSpec } from "./op-waves";
29
31
 
30
32
  /**
31
33
  * Load a lexicon's plugin (`@intentius/chant-lexicon-<name>`) to reach its
@@ -115,3 +117,50 @@ export async function generateOpsPipeline(
115
117
  const { files, jobs } = plugin.generateOpPipeline(withOpSchedules(ops, discovered.ops), options);
116
118
  return { success: true, files, jobs };
117
119
  }
120
+
121
+ /** Result of generating an Op waves pipeline (#3679). */
122
+ export interface GenerateOpWavesPipelineResult {
123
+ success: boolean;
124
+ files?: OpPipelineFile[];
125
+ jobs?: OpWaveJob[];
126
+ error?: string;
127
+ }
128
+
129
+ /**
130
+ * Render ordered waves of Op runs for `lexicon` (#3679, `./op-waves.ts`):
131
+ * check the spec, check that every Op it names exists, then hand it to the
132
+ * lexicon's `generateOpWavesPipeline`.
133
+ */
134
+ export async function generateOpWavesPipeline(
135
+ spec: OpWavesSpec,
136
+ lexicon: string,
137
+ options: OpWavesPipelineOptions,
138
+ cwd?: string,
139
+ ): Promise<GenerateOpWavesPipelineResult> {
140
+ const plugin = await loadLexiconPlugin(lexicon);
141
+ if (!plugin?.generateOpWavesPipeline) {
142
+ return {
143
+ success: false,
144
+ error: `Lexicon "${lexicon}" does not render Op waves (no generateOpWavesPipeline). GitLab, GitHub, and Forgejo do.`,
145
+ };
146
+ }
147
+ try {
148
+ assertOpWavesSpec(spec);
149
+ } catch (err) {
150
+ return { success: false, error: err instanceof Error ? err.message : String(err) };
151
+ }
152
+ const discovered = await discoverOps({ cwd });
153
+ if (discovered.errors.length > 0) return { success: false, error: discovered.errors.join("\n") };
154
+ const named = [...new Set([spec.op, ...spec.waves.flatMap((w) => w.runs.map((r) => r.op ?? spec.op))])];
155
+ const unknown = named.filter((name) => !discovered.ops.has(name));
156
+ if (unknown.length > 0) {
157
+ const known = [...discovered.ops.keys()].sort().join(", ");
158
+ return { success: false, error: `Unknown Op(s): ${unknown.join(", ")}.${known ? ` Known Ops: ${known}` : " No Ops discovered."}` };
159
+ }
160
+ try {
161
+ const { files, jobs } = plugin.generateOpWavesPipeline(spec, options);
162
+ return { success: true, files, jobs };
163
+ } catch (err) {
164
+ return { success: false, error: err instanceof Error ? err.message : String(err) };
165
+ }
166
+ }
package/src/op/index.ts CHANGED
@@ -49,8 +49,19 @@ export type {
49
49
  } from "./receipt-store";
50
50
  export { discoverOps } from "./discover";
51
51
  export type { DiscoveredOp, OpDiscoveryResult } from "./discover";
52
- export { generateOpsPipeline, withOpSchedules } from "./generate-pipeline";
53
- export type { GenerateOpsPipelineResult } from "./generate-pipeline";
52
+ export { generateOpsPipeline, generateOpWavesPipeline, withOpSchedules } from "./generate-pipeline";
53
+ export type { GenerateOpsPipelineResult, GenerateOpWavesPipelineResult } from "./generate-pipeline";
54
+ export {
55
+ OP_WAVE_DEFAULT_APPLY,
56
+ OP_WAVE_GATE_POLICIES,
57
+ assertOpWavesSpec,
58
+ opWaveJobs,
59
+ opWaveRecordPath,
60
+ opWaveShare,
61
+ } from "./op-waves";
62
+ export type { OpWave, OpWaveGatePolicy, OpWaveJob, OpWaveRun, OpWavesSpec } from "./op-waves";
63
+ export { opWaveNeedsApproval, parseOpWavesSpec, readOpWavePolicy, runOpWave } from "./op-waves-run";
64
+ export type { OpWaveDecision, OpWaveMember, OpWavePlanFile, RunOpWaveOptions, RunOpWaveResult } from "./op-waves-run";
54
65
  export { loadActivities, loadProfiles, resolveActivity } from "./activity-registry";
55
66
  export type { ActivityFn } from "./activity-registry";
56
67
  export { loadActivityContracts, mergeActivityContracts } from "./activity-contract-registry";
@@ -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}`);
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
+ });