@intentius/chant 0.124.4 → 0.124.6

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 (46) hide show
  1. package/dist/cli/handlers/gate-resume.d.ts +32 -1
  2. package/dist/cli/handlers/gate-resume.d.ts.map +1 -1
  3. package/dist/cli/main.d.ts.map +1 -1
  4. package/dist/lifecycle/gate-ledger.d.ts +7 -0
  5. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  6. package/dist/op/gate-point-run.d.ts +1 -1
  7. package/dist/op/gate-point-run.d.ts.map +1 -1
  8. package/dist/op/gate-point.d.ts +6 -0
  9. package/dist/op/gate-point.d.ts.map +1 -1
  10. package/dist/op/gate-resume.d.ts +2 -31
  11. package/dist/op/gate-resume.d.ts.map +1 -1
  12. package/dist/op/gate-run-locator.d.ts +40 -0
  13. package/dist/op/gate-run-locator.d.ts.map +1 -0
  14. package/dist/op/index.d.ts +2 -2
  15. package/dist/op/index.d.ts.map +1 -1
  16. package/dist/op/op-waves-notify.d.ts +3 -1
  17. package/dist/op/op-waves-notify.d.ts.map +1 -1
  18. package/dist/op/op-waves-run.d.ts +58 -3
  19. package/dist/op/op-waves-run.d.ts.map +1 -1
  20. package/dist/op/op-waves.d.ts +16 -0
  21. package/dist/op/op-waves.d.ts.map +1 -1
  22. package/dist/point-decision.d.ts +63 -1
  23. package/dist/point-decision.d.ts.map +1 -1
  24. package/dist/workspace/points-cli.d.ts.map +1 -1
  25. package/dist/workspace/records-cli.d.ts +15 -0
  26. package/dist/workspace/records-cli.d.ts.map +1 -1
  27. package/package.json +1 -1
  28. package/src/cli/handlers/gate-resume.ts +84 -11
  29. package/src/cli/main.ts +4 -2
  30. package/src/lifecycle/gate-ledger.ts +7 -0
  31. package/src/op/gate-point-ci.e2e.test.ts +39 -2
  32. package/src/op/gate-point-resume.e2e.test.ts +203 -0
  33. package/src/op/gate-point-run.ts +3 -0
  34. package/src/op/gate-point.ts +11 -0
  35. package/src/op/gate-resume.ts +5 -95
  36. package/src/op/gate-run-locator.ts +105 -0
  37. package/src/op/index.ts +3 -0
  38. package/src/op/op-waves-notify.ts +16 -1
  39. package/src/op/op-waves-run.ts +221 -23
  40. package/src/op/op-waves.test.ts +135 -0
  41. package/src/op/op-waves.ts +33 -1
  42. package/src/point-decision-bundle.test.ts +1 -0
  43. package/src/point-decision.ts +97 -1
  44. package/src/workspace/points-cli.ts +7 -2
  45. package/src/workspace/records-cli.ts +38 -7
  46. package/src/workspace/records.schema.json +4 -0
@@ -103,6 +103,14 @@ export interface OpWavePlansFile {
103
103
  * `override.overriders` file at base, else `.chant/overriders` at base.
104
104
  */
105
105
  overriders?: string[];
106
+ /**
107
+ * Digest members that are not runs (#3821), such as the wave's monthly cost
108
+ * when a cost threshold applies, so an approval at one cost does not apply
109
+ * at another. Each needs a name no run has.
110
+ */
111
+ members?: WaveMember[];
112
+ /** On a discovering wave (#3821): how many waves the product found, which a `rest` wave walks to. */
113
+ waves?: number;
106
114
  }
107
115
 
108
116
  /** Pending-fact keys chant writes itself, which a plans file's `pending` may not set (#3821). */
@@ -119,6 +127,8 @@ export interface OpWaveRunsFile {
119
127
  number: number;
120
128
  /** The wave's set digest, once it is decided. On `applyWave`, the digest the set was approved or let through at. */
121
129
  digest?: string;
130
+ /** Set when the wave discovers its runs (#3821): `runs` is empty, and the plan command plans the runs it finds for this wave. */
131
+ discover?: true;
122
132
  /** Each run, with its plan once planned. On `applyWave`, the runs to apply. */
123
133
  runs: Array<{ target: string; op: string; planDigest?: string; destructive?: boolean; empty?: boolean }>;
124
134
  }
@@ -208,8 +218,29 @@ export interface OpWaveDecision {
208
218
  heldOn?: OpWaveHeldOn;
209
219
  /** When the wave recorded the overrides it applies under (#3821), which spent them. */
210
220
  overridesSpentAt?: string;
221
+ /** Digest members that are no run, from the plans file (#3821), such as the wave's cost. */
222
+ extras?: WaveMember[];
223
+ /** On a discovering wave (#3821): how many waves the plan command found. */
224
+ discovered?: { waves?: number };
225
+ }
226
+
227
+ /**
228
+ * What an apply call writes to `CHANT_APPLY_OUTCOME` when it applied nothing
229
+ * for a reason that is not a failure (#3821). `stood-down`: a newer run (a
230
+ * newer push on the branch) applies instead, exit 6. `held`: another run
231
+ * holds what the apply changes and the apply does not wait for it, exit 5.
232
+ * `replan`: the run it waited for let go, so the plans may have moved and
233
+ * the wave plans again in the same job. An apply that applied writes nothing.
234
+ */
235
+ export interface OpWaveApplyOutcome {
236
+ outcome: "stood-down" | "held" | "replan";
237
+ reason?: string;
238
+ /** The run holding what the apply changes, for `held`. */
239
+ holder?: { run: string; url?: string };
211
240
  }
212
241
 
242
+ export const OP_WAVE_APPLY_OUTCOMES = ["stood-down", "held", "replan"] as const;
243
+
213
244
  /** What a hook asks for when it holds a wave on another gate (#3821): it writes this to `CHANT_HOLD` and exits non-zero. */
214
245
  export interface OpWaveHoldRequest {
215
246
  /** The op the gate is recorded under. Default: the spec's name. */
@@ -505,8 +536,20 @@ export interface RunOpWaveOptions {
505
536
  }
506
537
 
507
538
  export interface RunOpWaveResult {
508
- /** 0 applied (or decided), 1 a run or a hook failed, 3 waiting for an approval, 4 a plan moved since the decision, 7 the product's policy denied a run and no override counts (#3821). */
539
+ /**
540
+ * 0 applied (or decided), 1 a run or a hook failed, 3 waiting for an
541
+ * approval, 4 a plan moved since the decision, 5 the apply applied nothing
542
+ * because another run holds what it changes, 6 the apply stood down for a
543
+ * newer run, 7 the product's policy denied a run and no override counts
544
+ * (5, 6 and 7: #3821).
545
+ */
509
546
  exitCode: number;
547
+ /** Set when the apply applied nothing and stood down or was held by another run (#3821): not a failure. */
548
+ outcome?: OpWaveApplyOutcome;
549
+ /** Set when the apply asked to plan again (#3821); {@link runOpWave} does, so a caller sees it only on a pass that gave up. */
550
+ replan?: { reason?: string };
551
+ /** The waves a `rest` wave's job ran (#3821), in order, with how each ended. */
552
+ rest?: Array<{ wave: number; exitCode: number; digest?: string }>;
510
553
  decision?: OpWaveDecision;
511
554
  applied: string[];
512
555
  failed: string[];
@@ -573,11 +616,13 @@ function writeRunsFile(
573
616
  members?: ReadonlyMap<string, OpWaveMember>,
574
617
  digest?: string,
575
618
  path: string = opWaveRunsPath(at.spec, at.k),
619
+ discover = false,
576
620
  ): string {
577
621
  const file: OpWaveRunsFile = {
578
622
  wave: at.wave.name,
579
623
  number: at.k,
580
624
  ...(digest ? { digest } : {}),
625
+ ...(discover ? { discover: true as const } : {}),
581
626
  runs: runs.map((run) => {
582
627
  const m = members?.get(run.target);
583
628
  return {
@@ -597,10 +642,10 @@ function writeRunsFile(
597
642
  * for all of them (#3762). A wave plan is written out as each run's plan file
598
643
  * too, so a per-run `apply` or hook reads `{plan}` either way.
599
644
  */
600
- function planRuns(at: { spec: OpWavesSpec; k: number; wave: OpWave; cwd: string; exec: OpWaveExec }, runs: readonly OpWaveRun[]): PlannedRuns {
645
+ function planRuns(at: { spec: OpWavesSpec; k: number; wave: OpWave; cwd: string; exec: OpWaveExec }, runs: readonly OpWaveRun[], discover = false): PlannedRuns {
601
646
  const { spec, k, wave, cwd, exec } = at;
602
647
  if (!spec.planWave) return { members: runs.map((run) => planRun({ spec, wave: k, cwd, exec }, wave.name, run)) };
603
- const runsPath = writeRunsFile(at, runs);
648
+ const runsPath = writeRunsFile(at, runs, undefined, undefined, undefined, discover);
604
649
  const plans = opWavePlansPath(spec, k);
605
650
  rmSync(join(cwd, plans), { force: true });
606
651
  const code = exec(opWaveArgv(spec.planWave, { op: spec.op, wave: wave.name, runs: runsPath, plans }), cwd);
@@ -615,8 +660,15 @@ function planRuns(at: { spec: OpWavesSpec; k: number; wave: OpWave; cwd: string;
615
660
  if (!Array.isArray(entries)) throw new Error(`the plans file of wave ${k} (${plans}) must hold { "runs": [{ "target", "planDigest" }] }`);
616
661
  const wanted = new Set(runs.map((r) => r.target));
617
662
  const byTarget = new Map<string, OpWavePlanFile>();
618
- for (const entry of entries as Array<Partial<OpWavePlanFile & { target: string }>>) {
663
+ // #3821: a discovering wave's runs are the ones the plans file names.
664
+ const found: OpWaveRun[] = [];
665
+ for (const entry of entries as Array<Partial<OpWavePlanFile & { target: string; op: string }>>) {
619
666
  const target = entry?.target;
667
+ if (discover && typeof target === "string" && target !== "" && !byTarget.has(target)) {
668
+ if (entry.op !== undefined && (typeof entry.op !== "string" || entry.op === "")) throw new Error(`the plans file of wave ${k} (${plans}) gives ${target} an op that is not a name`);
669
+ found.push({ target, ...(entry.op && entry.op !== spec.op ? { op: entry.op } : {}) });
670
+ wanted.add(target);
671
+ }
620
672
  if (typeof target !== "string" || !wanted.has(target)) {
621
673
  throw new Error(`the plans file of wave ${k} (${plans}) plans ${JSON.stringify(target)}, which is not a run it was asked to plan`);
622
674
  }
@@ -626,6 +678,10 @@ function planRuns(at: { spec: OpWavesSpec; k: number; wave: OpWave; cwd: string;
626
678
  }
627
679
  const missing = runs.filter((r) => !byTarget.has(r.target)).map((r) => r.target);
628
680
  if (missing.length > 0) throw new Error(`the plans file of wave ${k} (${plans}) has no plan for ${missing.join(", ")}`);
681
+ if (discover) runs = found;
682
+ const extras = Array.isArray(parsed) ? undefined : extraMembers((parsed as { members?: unknown }).members, new Set(runs.map((r) => r.target)), `the plans file of wave ${k} (${plans})`);
683
+ const count = Array.isArray(parsed) ? undefined : (parsed as { waves?: unknown }).waves;
684
+ if (count !== undefined && (!Number.isInteger(count) || (count as number) < 0)) throw new Error(`the plans file of wave ${k} (${plans}): \`waves\` must be the number of waves found, a whole number`);
629
685
  const pending = Array.isArray(parsed) ? undefined : pendingFields((parsed as { pending?: unknown }).pending, `the plans file of wave ${k} (${plans})`);
630
686
  const members = runs.map((run) => {
631
687
  const m = member(run.target, run.op ?? spec.op, byTarget.get(run.target)!);
@@ -637,7 +693,13 @@ function planRuns(at: { spec: OpWavesSpec; k: number; wave: OpWave; cwd: string;
637
693
  if (overriders !== undefined && (!Array.isArray(overriders) || !overriders.every((o) => typeof o === "string"))) {
638
694
  throw new Error(`the plans file of wave ${k} (${plans}): \`overriders\` must be a list of principals`);
639
695
  }
640
- return { members, ...(pending ? { pending } : {}), ...(overriders ? { overriders: overriders as string[] } : {}) };
696
+ return {
697
+ members,
698
+ ...(pending ? { pending } : {}),
699
+ ...(overriders ? { overriders: overriders as string[] } : {}),
700
+ ...(extras ? { extras } : {}),
701
+ ...(count !== undefined ? { waves: count as number } : {}),
702
+ };
641
703
  }
642
704
 
643
705
  /** What planning a wave's runs gave: each run's plan, and the product's pending-fact fields (#3821). */
@@ -646,6 +708,26 @@ interface PlannedRuns {
646
708
  pending?: Record<string, unknown>;
647
709
  /** The plans file's overriders (#3821). */
648
710
  overriders?: string[];
711
+ /** Digest members that are not runs, such as the wave's cost (#3821). */
712
+ extras?: WaveMember[];
713
+ /** How many waves the product found (#3821), for a `rest` wave. */
714
+ waves?: number;
715
+ }
716
+
717
+ /** A plans file's `members` (#3821): digest members that are no run, each named once and by no run's target. */
718
+ function extraMembers(value: unknown, targets: ReadonlySet<string>, where: string): WaveMember[] | undefined {
719
+ if (value === undefined) return undefined;
720
+ if (!Array.isArray(value)) throw new Error(`${where}: \`members\` must be a list of { "member", "planDigest" }`);
721
+ const seen = new Set<string>();
722
+ const out: WaveMember[] = [];
723
+ for (const m of value as Array<Partial<WaveMember>>) {
724
+ if (!m || typeof m.member !== "string" || m.member === "" || typeof m.planDigest !== "string" || m.planDigest === "") throw new Error(`${where}: each of \`members\` needs a member and a planDigest`);
725
+ if (targets.has(m.member)) throw new Error(`${where}: member ${m.member} is also a run's target; a digest member that is no run needs a name of its own`);
726
+ if (seen.has(m.member)) throw new Error(`${where}: names member ${m.member} twice`);
727
+ seen.add(m.member);
728
+ out.push({ member: m.member, planDigest: m.planDigest });
729
+ }
730
+ return out.length > 0 ? out : undefined;
649
731
  }
650
732
 
651
733
  /** A plans file's `pending` (#3821): an object whose keys chant does not write itself, `description` aside. Undefined when absent or empty. */
@@ -661,11 +743,12 @@ function pendingFields(value: unknown, where: string): Record<string, unknown> |
661
743
  /**
662
744
  * A wave's set digest (#3821): {@link waveSetDigest} over its runs' plans,
663
745
  * or under `digest.over: "changing"` over the runs whose plan is not empty,
664
- * falling back to every run when all of them are empty.
746
+ * falling back to every run when all of them are empty. `extras`, the plans
747
+ * file's members that are no run (a cost), are always in it.
665
748
  */
666
- export function opWaveSetDigest(spec: Pick<OpWavesSpec, "digest">, members: ReadonlyArray<WaveMember & { empty?: boolean }>): string {
749
+ export function opWaveSetDigest(spec: Pick<OpWavesSpec, "digest">, members: ReadonlyArray<WaveMember & { empty?: boolean }>, extras: readonly WaveMember[] = []): string {
667
750
  const changing = spec.digest?.over === "changing" ? members.filter((m) => m.empty !== true) : [];
668
- return waveSetDigest(changing.length > 0 ? changing : members);
751
+ return waveSetDigest([...(changing.length > 0 ? changing : members), ...extras]);
669
752
  }
670
753
 
671
754
  /** What one moment's hooks came to. */
@@ -1024,12 +1107,49 @@ function noteHooksAtBase(ctx: WaveContext, rules: OpWaveBaseRules): void {
1024
1107
 
1025
1108
  /** Plan, decide and apply one wave, or one part of a wide wave. */
1026
1109
  export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveResult> {
1027
- const { spec, wave: k } = options;
1110
+ const { spec } = options;
1028
1111
  assertOpWavesSpec(spec);
1112
+ const log = options.log ?? ((line: string) => console.error(line));
1113
+ const last = spec.waves[spec.waves.length - 1]!;
1114
+ const applied: string[] = [];
1115
+ const rest: NonNullable<RunOpWaveResult["rest"]> = [];
1116
+ for (let k = options.wave; ; k++) {
1117
+ let result: RunOpWaveResult | undefined;
1118
+ let prior: OpWaveDecision | undefined;
1119
+ // #3821: an apply that came back "replan" plans the wave again, a bounded number of times.
1120
+ for (let pass = 0; ; pass++) {
1121
+ result = await runOneWave(options, k, prior, pass);
1122
+ if (!result.replan) break;
1123
+ if (pass + 1 >= OP_WAVE_MAX_REPLANS) {
1124
+ log(`wave ${k}: its apply asked to plan again ${OP_WAVE_MAX_REPLANS} times, so the wave stops here`);
1125
+ result.exitCode = 1;
1126
+ break;
1127
+ }
1128
+ log(`wave ${k}: the apply applied nothing and asks to plan again${result.replan.reason ? ` (${result.replan.reason})` : ""}; planning again`);
1129
+ prior = result.decision;
1130
+ }
1131
+ applied.push(...result.applied);
1132
+ // #3821: a `rest` wave goes on through the waves its plan command found.
1133
+ const found = result.decision?.discovered?.waves;
1134
+ const onward = k >= spec.waves.length && last.rest === true && options.share === undefined && !options.decide && result.exitCode === 0 && found !== undefined && k < found;
1135
+ if (rest.length > 0 || onward) rest.push({ wave: k, exitCode: result.exitCode, ...(result.decision ? { digest: result.decision.digest } : {}) });
1136
+ if (!onward) return { ...result, applied, ...(rest.length > 0 ? { rest } : {}) };
1137
+ log(`wave ${k} applied; the plan found ${found} waves, so this job goes on to wave ${k + 1}`);
1138
+ }
1139
+ }
1140
+
1141
+ /** How many times one wave job plans again when its apply asks to (#3821). */
1142
+ export const OP_WAVE_MAX_REPLANS = 10;
1143
+
1144
+ /** Plan, decide and apply wave `k`, or one part of a wide wave. `prior`: the decision of the pass whose apply asked to plan again. */
1145
+ async function runOneWave(options: RunOpWaveOptions, k: number, prior: OpWaveDecision | undefined, pass: number): Promise<RunOpWaveResult> {
1146
+ const { spec } = options;
1029
1147
  const cwd = options.cwd ?? process.cwd();
1030
1148
  const exec = options.exec ?? spawnExec;
1031
1149
  const log = options.log ?? ((line: string) => console.error(line));
1032
- const wave = spec.waves[k - 1];
1150
+ const last = spec.waves[spec.waves.length - 1]!;
1151
+ // #3821: past the spec's waves, a `rest` wave stands for each wave its plan command found.
1152
+ let wave = spec.waves[k - 1] ?? (last.rest ? last : undefined);
1033
1153
  if (!wave) throw new Error(`Op waves "${spec.name}" has ${spec.waves.length} waves; there is no wave ${k}.`);
1034
1154
  const shares = wave.shares ?? 1;
1035
1155
  if (options.share !== undefined && (options.share < 1 || options.share > shares)) {
@@ -1038,6 +1158,7 @@ export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveRes
1038
1158
  const recordPath = join(cwd, opWaveRecordPath(spec, k));
1039
1159
  const result: RunOpWaveResult = { exitCode: 0, applied: [], failed: [], moved: [], hooks: [] };
1040
1160
  const ctx: WaveContext = { spec, k, wave, cwd, exec, log, result, options, gateRule: { approval: "ledger", base: "HEAD", neverOverMcp: spec.neverOverMcp === true } };
1161
+ if (pass > 0) log(`wave ${k} (${wave.name}): plan ${pass + 1}`);
1041
1162
 
1042
1163
  if (options.share !== undefined) {
1043
1164
  let decision: OpWaveDecision;
@@ -1053,6 +1174,11 @@ export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveRes
1053
1174
  return { ...result, exitCode: decision.status === "refused" ? 7 : 3 };
1054
1175
  }
1055
1176
  // The share runs the hooks its deciding job read at base, around its own slice.
1177
+ if (wave.discover) {
1178
+ // #3821: a discovering wave's runs are the ones its deciding job planned.
1179
+ wave = { ...wave, runs: decision.members.map((m) => ({ target: m.member, ...(m.op !== spec.op ? { op: m.op } : {}) })) };
1180
+ ctx.wave = wave;
1181
+ }
1056
1182
  const slice = opWaveShare(wave.runs, options.share, shares);
1057
1183
  const before = await runHooks(ctx, "before-plan", decision.hooks?.["before-plan"], slice);
1058
1184
  if (before.failed) return failed(ctx, `a hook before the plan failed, so nothing in share ${options.share} of wave ${k} was planned or applied`);
@@ -1115,11 +1241,18 @@ export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveRes
1115
1241
  const before = await runHooks(ctx, "before-plan", rules.hooks["before-plan"], wave.runs);
1116
1242
  if (before.failed) return failed(ctx, `a hook before the plan failed, so nothing in wave ${k} was planned or applied`);
1117
1243
  if (before.waitingOn) return decided(ctx, decision, recordPath, before.waitingOn);
1118
- const { members, pending, overriders } = planRuns(ctx, wave.runs);
1244
+ const { members, pending, overriders, extras, waves: found } = planRuns(ctx, wave.runs, wave.discover === true);
1245
+ if (wave.discover) {
1246
+ wave = { ...wave, runs: members.map((m) => ({ target: m.member, ...(m.op !== spec.op ? { op: m.op } : {}) })) };
1247
+ ctx.wave = wave;
1248
+ decision.discovered = { ...(found !== undefined ? { waves: found } : {}) };
1249
+ log(`wave ${k} (${wave.name}): the plan found ${members.length} run${members.length === 1 ? "" : "s"}${found !== undefined ? ` of ${found} waves` : ""}${members.length ? `: ${members.map((m) => m.member).join(", ")}` : ""}`);
1250
+ }
1119
1251
  const planned = new Map(members.map((m) => [m.member, m]));
1120
- const digest = opWaveSetDigest(spec, members);
1252
+ const digest = opWaveSetDigest(spec, members, extras);
1121
1253
  decision.digest = digest;
1122
1254
  decision.members = members;
1255
+ if (extras) decision.extras = extras;
1123
1256
  if (pending) decision.pending = pending;
1124
1257
  const after = await runHooks(ctx, "after-plan", rules.hooks["after-plan"], wave.runs, planned, digest);
1125
1258
  if (after.failed) return failed(ctx, `a hook after the plan failed, so nothing in wave ${k} was applied`);
@@ -1127,8 +1260,20 @@ export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveRes
1127
1260
  if (holds.length > 0) decision.heldBy = holds;
1128
1261
  if (after.waitingOn) return decided(ctx, decision, recordPath, after.waitingOn);
1129
1262
  // #3821: a run the product's policy denied applies only under an override; with none the wave is refused, and no approval is waited for.
1130
- if (members.some((m) => m.denied)) await settleDenials(ctx, decision, overriders);
1131
- if (decision.status !== "refused" && (opWaveNeedsApproval(policy, members) || holds.length > 0)) await settleGate(options, ctx, decision);
1263
+ const kept = prior !== undefined && (prior.status === "approved" || prior.status === "not-required") && prior.digest === digest;
1264
+ if (kept) {
1265
+ // The overrides this job applied under, and spent, still let the same plans through.
1266
+ const byRun = new Map(prior.members.map((m) => [m.member, m.override]));
1267
+ for (const m of members) if (m.denied && byRun.get(m.member)) m.override = byRun.get(m.member);
1268
+ if (prior.overridesSpentAt) decision.overridesSpentAt = prior.overridesSpentAt;
1269
+ }
1270
+ if (members.some((m) => m.denied && !m.override)) await settleDenials(ctx, decision, overriders);
1271
+ if (kept && prior.status === "approved" && decision.status !== "refused") {
1272
+ // #3821: planned again after the apply asked to, to the same digest: the approval this job found still holds, and is not spent twice.
1273
+ for (const key of ["via", "approvedBy", "approvedAt", "review", "spentAt"] as const) if (prior[key] !== undefined) (decision as unknown as Record<string, unknown>)[key] = prior[key];
1274
+ decision.status = "approved";
1275
+ log(`wave ${k} (${wave.name}): planned again to the same digest, so the approval by ${prior.approvedBy} still holds`);
1276
+ } else if (decision.status !== "refused" && (opWaveNeedsApproval(policy, members) || holds.length > 0)) await settleGate(options, ctx, decision);
1132
1277
  const code = await decided(ctx, decision, recordPath);
1133
1278
  if (code.exitCode !== 0) return code;
1134
1279
  log(
@@ -1250,7 +1395,7 @@ async function settleDenials(ctx: WaveContext, decision: OpWaveDecision, listed:
1250
1395
  const still: string[] = [];
1251
1396
  let moved = false;
1252
1397
  for (const m of decision.members) {
1253
- if (!m.denied) continue;
1398
+ if (!m.denied || m.override) continue;
1254
1399
  const verdict = og.decideOverride({
1255
1400
  ledger,
1256
1401
  applied,
@@ -1423,8 +1568,23 @@ async function settleHolds(
1423
1568
  return true;
1424
1569
  }
1425
1570
 
1426
- /** How one apply call came out. `applied` with a `problem`: the runs applied, and the wave still fails. */
1427
- type ApplyCall = { outcome: "applied"; problem?: string } | { outcome: "failed" | "waiting" };
1571
+ /** How one apply call came out. `applied` with a `problem`: the runs applied, and the wave still fails. `applied-nothing`: the apply said why (#3821). */
1572
+ type ApplyCall = { outcome: "applied"; problem?: string } | { outcome: "failed" | "waiting" } | { outcome: "applied-nothing"; said: OpWaveApplyOutcome };
1573
+
1574
+ /** Read what an apply wrote to `CHANT_APPLY_OUTCOME` (#3821), or undefined when it wrote nothing. A file that says something else is logged and ignored. */
1575
+ function readApplyOutcome(path: string, label: string, log: (line: string) => void): OpWaveApplyOutcome | undefined {
1576
+ if (!existsSync(path)) return undefined;
1577
+ try {
1578
+ const o = JSON.parse(readFileSync(path, "utf-8")) as Partial<OpWaveApplyOutcome>;
1579
+ if (!OP_WAVE_APPLY_OUTCOMES.includes(o.outcome as "held")) throw new Error(`outcome ${JSON.stringify(o.outcome)} is none of ${OP_WAVE_APPLY_OUTCOMES.join(", ")}`);
1580
+ if (o.reason !== undefined && typeof o.reason !== "string") throw new Error("its reason is not text");
1581
+ const holder = o.holder && typeof o.holder === "object" && typeof o.holder.run === "string" ? { run: o.holder.run, ...(typeof o.holder.url === "string" ? { url: o.holder.url } : {}) } : undefined;
1582
+ return { outcome: o.outcome as OpWaveApplyOutcome["outcome"], ...(o.reason ? { reason: o.reason } : {}), ...(holder ? { holder } : {}) };
1583
+ } catch (err) {
1584
+ log(`${label}: the apply wrote an outcome that cannot be read (${err instanceof Error ? err.message : String(err)}), so its exit code decides`);
1585
+ return undefined;
1586
+ }
1587
+ }
1428
1588
 
1429
1589
  /**
1430
1590
  * Call one apply command (#3804) with `CHANT_HOOKS` and `CHANT_HOOKS_RESULT`.
@@ -1448,6 +1608,7 @@ async function callApply(
1448
1608
  const dir = `.chant/op-waves/${spec.name}/wave-${k}`;
1449
1609
  const filePath = `${dir}/${call.key}.json`;
1450
1610
  const resultPath = `${dir}/${call.key}-result.json`;
1611
+ const outcomePath = `${dir}/${call.key}-outcome.json`;
1451
1612
  const handed: Partial<Record<OpWaveHookMoment, OpWaveHook[]>> = {};
1452
1613
  if (byApply) {
1453
1614
  for (const m of ["before-apply", "after-apply"] as const) if (decision.hooks?.[m]?.length) handed[m] = decision.hooks[m];
@@ -1474,13 +1635,28 @@ async function callApply(
1474
1635
  mkdirSync(join(cwd, dir), { recursive: true });
1475
1636
  writeFileSync(join(cwd, filePath), JSON.stringify(file, null, 2) + "\n");
1476
1637
  rmSync(join(cwd, resultPath), { force: true });
1638
+ rmSync(join(cwd, outcomePath), { force: true });
1477
1639
  let code: number;
1478
1640
  try {
1479
- code = exec(argv, cwd, { CHANT_HOOKS: filePath, ...(byApply ? { CHANT_HOOKS_RESULT: resultPath } : {}) });
1641
+ code = exec(argv, cwd, { CHANT_HOOKS: filePath, CHANT_APPLY_OUTCOME: outcomePath, ...(byApply ? { CHANT_HOOKS_RESULT: resultPath } : {}) });
1480
1642
  } catch (err) {
1481
1643
  log(`${call.label}: the apply did not run: ${err instanceof Error ? err.message : String(err)}`);
1482
1644
  code = 127;
1483
1645
  }
1646
+ // #3821: an apply that applied nothing, and says why, is not a failure.
1647
+ const said = readApplyOutcome(join(cwd, outcomePath), call.label, log);
1648
+ if (said) {
1649
+ if (byApply) {
1650
+ try {
1651
+ const back = JSON.parse(readFileSync(join(cwd, resultPath), "utf-8")) as OpWaveHooksResult;
1652
+ if (Array.isArray(back?.outcomes)) result.hooks.push(...back.outcomes);
1653
+ } catch {
1654
+ // No hooks ran.
1655
+ }
1656
+ }
1657
+ log(`${call.label}: the apply applied nothing: ${said.outcome}${said.reason ? ` (${said.reason})` : ""}${said.holder ? `, held by run ${said.holder.run}` : ""}`);
1658
+ return { outcome: "applied-nothing", said };
1659
+ }
1484
1660
  if (!byApply) return { outcome: code === 0 ? "applied" : "failed" };
1485
1661
  let back: OpWaveHooksResult | null = null;
1486
1662
  try {
@@ -1559,11 +1735,13 @@ async function applyStage(
1559
1735
  if (recordPath && (decision.spentAt || decision.overridesSpentAt)) writeRecord(recordPath, decision, result);
1560
1736
  const problems: string[] = [];
1561
1737
  let waiting = false;
1738
+ let nothing: OpWaveApplyOutcome | undefined;
1562
1739
  if (spec.applyWave) {
1563
1740
  const runsPath = writeRunsFile(ctx, runs, planned, decision.digest);
1564
1741
  const argv = opWaveArgv(spec.applyWave, { op: spec.op, wave: wave.name, runs: runsPath, plans: opWavePlansPath(spec, k) });
1565
1742
  const got = await callApply(options, ctx, decision, runs, planned, argv, { label: `wave ${k} (${wave.name})`, key: "hooks", runsPath }, recordPath);
1566
- if (got.outcome === "applied") {
1743
+ if (got.outcome === "applied-nothing") nothing = got.said;
1744
+ else if (got.outcome === "applied") {
1567
1745
  result.applied.push(...runs.map((r) => r.target));
1568
1746
  if (got.problem) problems.push(got.problem);
1569
1747
  } else if (got.outcome === "waiting") waiting = true;
@@ -1578,6 +1756,10 @@ async function applyStage(
1578
1756
  const runsPath = byApply ? writeRunsFile(ctx, [run], planned, decision.digest, `.chant/op-waves/${spec.name}/wave-${k}/${key}.runs.json`) : opWaveRunsPath(spec, k);
1579
1757
  const argv = opWaveArgv(spec.apply ?? OP_WAVE_DEFAULT_APPLY, { op: run.op ?? spec.op, target: run.target, wave: wave.name, plan: opWavePlanPath(spec, k, run.target) });
1580
1758
  const got = await callApply(options, ctx, decision, [run], planned, argv, { label: run.target, key: `${key}.hooks`, runsPath }, recordPath);
1759
+ if (got.outcome === "applied-nothing") {
1760
+ nothing = got.said;
1761
+ break;
1762
+ }
1581
1763
  if (got.outcome === "waiting") {
1582
1764
  waiting = true;
1583
1765
  break;
@@ -1592,6 +1774,17 @@ async function applyStage(
1592
1774
  }
1593
1775
  }
1594
1776
  if (recordPath && byApply) writeRecord(recordPath, decision, result);
1777
+ if (nothing) {
1778
+ // #3821: no run counts as failed; the wave stops here, or plans again.
1779
+ if (nothing.outcome === "replan") {
1780
+ result.replan = { ...(nothing.reason ? { reason: nothing.reason } : {}) };
1781
+ return;
1782
+ }
1783
+ result.outcome = nothing;
1784
+ result.exitCode = nothing.outcome === "stood-down" ? 6 : 5;
1785
+ if (recordPath) writeRecord(recordPath, decision, result);
1786
+ return;
1787
+ }
1595
1788
  if (waiting) {
1596
1789
  result.exitCode = 3;
1597
1790
  return;
@@ -1743,11 +1936,16 @@ export async function recordOpWaveHeadPlans(options: RecordHeadPlansOptions): Pr
1743
1936
  name: spec.name,
1744
1937
  head,
1745
1938
  timestamp: options.now ?? new Date().toISOString(),
1746
- waves: spec.waves.map((wave, i) => {
1747
- const { members } = planRuns({ spec, k: i + 1, wave, cwd, exec }, wave.runs);
1748
- return { wave: i + 1, name: wave.name, digest: opWaveSetDigest(spec, members) };
1749
- }),
1939
+ waves: [],
1750
1940
  };
1941
+ // A `rest` wave (#3821) goes on through the waves the plan command found.
1942
+ const last = spec.waves[spec.waves.length - 1]!;
1943
+ for (let k = 1; ; k++) {
1944
+ const wave = spec.waves[k - 1] ?? last;
1945
+ const { members, extras, waves: found } = planRuns({ spec, k, wave, cwd, exec }, wave.runs, wave.discover === true);
1946
+ plans.waves.push({ wave: k, name: wave.name, digest: opWaveSetDigest(spec, members, extras) });
1947
+ if (k >= spec.waves.length && !(last.rest && found !== undefined && k < found)) break;
1948
+ }
1751
1949
  await (options.write ?? ((p) => writeHeadPlans(p, cwd)))(plans);
1752
1950
  return plans;
1753
1951
  }
@@ -892,3 +892,138 @@ describe("gates and holds a product's wave needs (#3821)", () => {
892
892
  again.done();
893
893
  });
894
894
  });
895
+
896
+ describe("discovered waves, a cost member and applies that apply nothing (#3821)", () => {
897
+ const digest = (n: string) => `sha256:${n.repeat(64)}`;
898
+ const tg: OpWavesSpec = {
899
+ name: "tf-apply",
900
+ op: "tf-apply",
901
+ gateName: "wave-{k}",
902
+ digest: { over: "changing" },
903
+ planWave: ["tg", "plan", "{wave}", "{runs}", "{plans}"],
904
+ applyWave: ["tg", "apply", "{wave}", "{runs}", "{plans}"],
905
+ waves: [
906
+ { name: "layer", runs: [], discover: true, gate: "never" },
907
+ { name: "rest", runs: [], discover: true, rest: true, gate: "never" },
908
+ ],
909
+ };
910
+ /** `layers[k-1]` is what wave k's plan finds; `apply` says what each apply call writes to CHANT_APPLY_OUTCOME. */
911
+ function harness(layers: Array<Record<string, { planDigest: string; empty?: boolean }>>, opts: { extra?: (k: number) => Record<string, unknown>; apply?: (k: number, n: number) => unknown; atBase?: OpWavesSpec } = {}) {
912
+ const cwd = mkdtempSync(join(tmpdir(), "op-waves-3821c-"));
913
+ const calls: string[] = [];
914
+ const runsFiles: OpWaveRunsFile[] = [];
915
+ const applies = new Map<number, number>();
916
+ const exec: OpWaveExec = (argv, dir, env = {}) => {
917
+ calls.push(argv.join(" "));
918
+ const runs = JSON.parse(readFileSync(join(dir, argv[3]!), "utf-8")) as OpWaveRunsFile;
919
+ runsFiles.push(runs);
920
+ if (argv[1] === "plan") {
921
+ const found = layers[runs.number - 1] ?? {};
922
+ const entries = runs.discover ? Object.entries(found).map(([target, p]) => ({ target, ...p })) : runs.runs.map((r) => ({ target: r.target, ...found[r.target] }));
923
+ writeFileSync(join(dir, argv[4]!), JSON.stringify({ runs: entries, waves: layers.length, ...(opts.extra?.(runs.number) ?? {}) }));
924
+ }
925
+ if (argv[1] === "apply") {
926
+ const n = (applies.get(runs.number) ?? 0) + 1;
927
+ applies.set(runs.number, n);
928
+ const said = opts.apply?.(runs.number, n);
929
+ if (said) writeFileSync(join(dir, env.CHANT_APPLY_OUTCOME!), JSON.stringify(said));
930
+ }
931
+ return 0;
932
+ };
933
+ const show: OpWaveShowAtBase = () => ({ sha: "b".repeat(40), text: JSON.stringify(opts.atBase ?? tg) });
934
+ return { cwd, calls, runsFiles, exec, show, done: () => rmSync(cwd, { recursive: true, force: true }) };
935
+ }
936
+ const run = (h: ReturnType<typeof harness>, wave: number, gates = memoryGateLedgerPort(), spec: OpWavesSpec = tg) =>
937
+ runOpWave({ spec, specFile: "waves.json", wave, cwd: h.cwd, exec: h.exec, show: h.show, gates, now: "2026-10-11T00:00:00.000Z", log: () => {} });
938
+
939
+ test("the spec checks discover and rest", () => {
940
+ expect(() => assertOpWavesSpec(tg)).not.toThrow();
941
+ expect(() => assertOpWavesSpec({ ...tg, planWave: undefined, plan: ["p", "{plan}"] })).toThrow(/only `planWave` can report/);
942
+ expect(() => assertOpWavesSpec({ ...tg, waves: [{ name: "a", runs: [{ target: "x" }], discover: true }] })).toThrow(/lists none/);
943
+ expect(() => assertOpWavesSpec({ ...tg, waves: [{ name: "a", runs: [], rest: true, discover: true }, { name: "b", runs: [], discover: true }] })).toThrow(/only the last wave/);
944
+ expect(() => assertOpWavesSpec({ ...tg, waves: [{ name: "a", runs: [], rest: true }] })).toThrow(/set discover too/);
945
+ expect(() => assertOpWavesSpec({ ...tg, waves: [{ name: "a", runs: [], discover: true, rest: true, shares: 2 }] })).toThrow(/cannot be split/);
946
+ });
947
+
948
+ test("a wave plans the runs its plan command finds, and the rest wave walks on to the last wave it found", async () => {
949
+ const layers: Array<Record<string, { planDigest: string }>> = [{ "a/vpc": { planDigest: digest("1") } }, { "b/db": { planDigest: digest("2") } }, { "c/app": { planDigest: digest("3") } }, { "d/dns": { planDigest: digest("4") } }];
950
+ const h = harness(layers);
951
+ const first = await run(h, 1);
952
+ expect(first).toMatchObject({ exitCode: 0, applied: ["a/vpc"], decision: { gate: "wave-1", discovered: { waves: 4 } } });
953
+ expect(h.runsFiles[0]).toMatchObject({ discover: true, runs: [] });
954
+ const rest = await run(h, 2);
955
+ expect(rest).toMatchObject({ exitCode: 0, applied: ["b/db", "c/app", "d/dns"], decision: { wave: 4, gate: "wave-4", name: "rest" } });
956
+ expect(rest.rest!.map((r) => [r.wave, r.exitCode])).toEqual([[2, 0], [3, 0], [4, 0]]);
957
+ h.done();
958
+
959
+ // A repo with fewer waves than jobs: the later job finds nothing to apply.
960
+ const short = harness([{ "a/vpc": { planDigest: digest("1") } }]);
961
+ expect(await run(short, 2)).toMatchObject({ exitCode: 0, applied: [] });
962
+ short.done();
963
+ });
964
+
965
+ test("a wide discovering wave hands its found runs to its shares", async () => {
966
+ const spec: OpWavesSpec = { ...tg, waves: [{ name: "layer", runs: [], discover: true, gate: "never", shares: 2 }] };
967
+ const layers = [{ "a/1": { planDigest: digest("1") }, "a/2": { planDigest: digest("2") }, "a/3": { planDigest: digest("3") } }];
968
+ const h = harness(layers, { atBase: spec });
969
+ expect(await runOpWave({ spec, specFile: "waves.json", wave: 1, decide: true, cwd: h.cwd, exec: h.exec, show: h.show, gates: memoryGateLedgerPort(), log: () => {} })).toMatchObject({ exitCode: 0, applied: [] });
970
+ const share = await runOpWave({ spec, specFile: "waves.json", wave: 1, share: 2, cwd: h.cwd, exec: h.exec, show: h.show, gates: memoryGateLedgerPort(), log: () => {} });
971
+ expect(share).toMatchObject({ exitCode: 0, applied: ["a/3"] });
972
+ h.done();
973
+ });
974
+
975
+ test("a cost member joins the digest, so an approval at one cost does not apply at another", async () => {
976
+ const spec: OpWavesSpec = { ...tg, waves: [{ name: "layer", runs: [], discover: true, gate: "always" }] };
977
+ const layers = [{ "a/vpc": { planDigest: digest("1") }, "a/idle": { planDigest: digest("2"), empty: true } }];
978
+ const cost = (usd: string) => ({ members: [{ member: "cost", planDigest: `usd:${usd}` }] });
979
+ const h = harness(layers, { extra: () => cost("120.00"), atBase: spec });
980
+ const gates = memoryGateLedgerPort();
981
+ const result = await run(h, 1, gates, spec);
982
+ const priced = waveSetDigest([{ member: "a/vpc", planDigest: digest("1") }, { member: "cost", planDigest: "usd:120.00" }]);
983
+ expect(result).toMatchObject({ exitCode: 3, decision: { digest: priced, extras: [{ member: "cost", planDigest: "usd:120.00" }] } });
984
+ h.done();
985
+
986
+ const dearer = harness(layers, { extra: () => cost("900.00"), atBase: spec });
987
+ const approved = memoryGateLedgerPort({ resolutions: [{ version: 1, op: "tf-apply", gate: "wave-1", resolvedBy: "alice", timestamp: "2026-10-10T00:00:00Z", planDigest: priced }] });
988
+ expect(await run(dearer, 1, approved, spec)).toMatchObject({ exitCode: 3, decision: { mismatch: { approved: priced } } });
989
+ dearer.done();
990
+
991
+ const clash = harness([{ cost: { planDigest: digest("1") } }], { extra: () => cost("1"), atBase: spec });
992
+ await expect(run(clash, 1, memoryGateLedgerPort(), spec)).rejects.toThrow(/also a run's target/);
993
+ clash.done();
994
+ });
995
+
996
+ test("an apply that stood down or was held applies nothing and fails nothing; one that asks plans again", async () => {
997
+ const layers = [{ "a/vpc": { planDigest: digest("1") } }];
998
+ const down = harness(layers, { apply: () => ({ outcome: "stood-down", reason: "a newer push is on main" }) });
999
+ const stood = await run(down, 1);
1000
+ expect(stood).toMatchObject({ exitCode: 6, applied: [], failed: [], outcome: { outcome: "stood-down", reason: "a newer push is on main" } });
1001
+ const { opWaveNotifyFields } = await import("./op-waves-notify");
1002
+ expect(opWaveNotifyFields(tg, 1, { result: stood })).toBeUndefined();
1003
+ down.done();
1004
+
1005
+ const busy = harness(layers, { apply: () => ({ outcome: "held", holder: { run: "github:123", url: "https://example.test/run/123" } }) });
1006
+ const held = await run(busy, 1);
1007
+ expect(held).toMatchObject({ exitCode: 5, applied: [], failed: [], outcome: { outcome: "held", holder: { run: "github:123" } } });
1008
+ expect(opWaveNotifyFields(tg, 1, { result: held })).toMatchObject({ event: "refused", status: "held", targets: ["a/vpc"] });
1009
+ busy.done();
1010
+
1011
+ const again = harness(layers, { apply: (_k, n) => (n === 1 ? { outcome: "replan", reason: "run 123 let go" } : undefined) });
1012
+ const replanned = await run(again, 1);
1013
+ expect(replanned).toMatchObject({ exitCode: 0, applied: ["a/vpc"] });
1014
+ expect(again.calls.filter((c) => c.startsWith("tg plan"))).toHaveLength(2);
1015
+ again.done();
1016
+ });
1017
+
1018
+ test("planned again to the same digest, the approval the job found still holds and is not spent twice", async () => {
1019
+ const spec: OpWavesSpec = { ...tg, approvals: { spend: true }, waves: [{ name: "layer", runs: [], discover: true, gate: "always" }] };
1020
+ const layers = [{ "a/vpc": { planDigest: digest("1") } }];
1021
+ const set = waveSetDigest([{ member: "a/vpc", planDigest: digest("1") }]);
1022
+ const h = harness(layers, { apply: (_k, n) => (n === 1 ? { outcome: "replan" } : undefined), atBase: spec });
1023
+ const gates = memoryGateLedgerPort({ resolutions: [{ version: 1, op: "tf-apply", gate: "wave-1", resolvedBy: "alice", timestamp: "2026-10-10T00:00:00Z", planDigest: set }] });
1024
+ const result = await run(h, 1, gates, spec);
1025
+ expect(result).toMatchObject({ exitCode: 0, applied: ["a/vpc"], decision: { status: "approved", approvedBy: "alice" } });
1026
+ expect(gates.applied).toHaveLength(1);
1027
+ h.done();
1028
+ });
1029
+ });
@@ -154,7 +154,23 @@ export interface OpWaveRun {
154
154
  export interface OpWave {
155
155
  /** The wave's name, usually its environment. Lowercase letters, digits, `-` and `_`. */
156
156
  name: string;
157
+ /** The wave's runs. Empty or absent on a `discover` wave. */
157
158
  runs: OpWaveRun[];
159
+ /**
160
+ * The runs come from the product at plan time (#3821): `planWave` gets a
161
+ * runs file with no runs and `discover: true`, and plans the runs it finds
162
+ * for wave k (a Terragrunt repo's dependency layer from `terragrunt find`).
163
+ * Needs `planWave`.
164
+ */
165
+ discover?: boolean;
166
+ /**
167
+ * The last wave only, with `discover` (#3821): once it applies, the job
168
+ * goes on to wave k+1, k+2, ... while `planWave` reports more waves
169
+ * (`waves` in its plans file), each behind its own gate and read at base
170
+ * by this wave's name. For a pipeline whose wave count is not known up
171
+ * front.
172
+ */
173
+ rest?: boolean;
158
174
  /**
159
175
  * When the wave waits. Default `"always"`. The runner reads this from the
160
176
  * spec file at the base commit, never from the change being applied.
@@ -445,7 +461,23 @@ export function assertOpWavesSpec(spec: OpWavesSpec): void {
445
461
  throw new Error(`${where}: wave "${wave.name}" has approval "${wave.approval}"; use ${GATE_APPROVAL_SOURCES.join(", ")}.`);
446
462
  }
447
463
  assertOpWaveHooks(`${where}, wave "${wave.name}"`, wave.hooks);
448
- if (wave.runs.length === 0) throw new Error(`${where}: wave "${wave.name}" has no runs.`);
464
+ if (wave.discover !== undefined && typeof wave.discover !== "boolean") throw new Error(`${where}: wave "${wave.name}" has discover ${JSON.stringify(wave.discover)}; use true or false.`);
465
+ if (wave.rest !== undefined && typeof wave.rest !== "boolean") throw new Error(`${where}: wave "${wave.name}" has rest ${JSON.stringify(wave.rest)}; use true or false.`);
466
+ if (wave.discover) {
467
+ if (spec.planWave === undefined) throw new Error(`${where}: wave "${wave.name}" discovers its runs, which only \`planWave\` can report. Give planWave.`);
468
+ if ((wave.runs ?? []).length > 0) throw new Error(`${where}: wave "${wave.name}" discovers its runs, so it lists none.`);
469
+ }
470
+ if (wave.rest) {
471
+ if (!wave.discover) throw new Error(`${where}: wave "${wave.name}" has rest, which follows the waves planWave discovers; set discover too.`);
472
+ if (wave !== spec.waves[spec.waves.length - 1]) throw new Error(`${where}: wave "${wave.name}" has rest, which only the last wave can have.`);
473
+ if ((wave.shares ?? 1) > 1) throw new Error(`${where}: wave "${wave.name}" has rest and shares; the waves after it run in its one job, so it cannot be split.`);
474
+ }
475
+ if (wave.discover) {
476
+ const shares = wave.shares ?? 1;
477
+ if (!Number.isInteger(shares) || shares < 1) throw new Error(`${where}: wave "${wave.name}" has shares ${wave.shares}; use a whole number from 1.`);
478
+ continue;
479
+ }
480
+ if (!Array.isArray(wave.runs) || wave.runs.length === 0) throw new Error(`${where}: wave "${wave.name}" has no runs.`);
449
481
  const targets = new Set<string>();
450
482
  for (const run of wave.runs) {
451
483
  if (!run.target) throw new Error(`${where}: wave "${wave.name}" has a run with no target.`);
@@ -54,6 +54,7 @@ describe("the point-decision bundle", () => {
54
54
  "packages/core/src/lifecycle/plan-digest.ts",
55
55
  "packages/core/src/op/duration.ts",
56
56
  "packages/core/src/op/gate-point.ts",
57
+ "packages/core/src/op/gate-run-locator.ts",
57
58
  "packages/core/src/point-decision.ts",
58
59
  ]);
59
60
  const bytes = result.outputFiles.reduce((n, f) => n + f.contents.byteLength, 0);