@intentius/chant 0.124.16 → 0.124.18

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 (154) hide show
  1. package/dist/__golden__/wave-loop/fan-out-applied.json +511 -0
  2. package/dist/__golden__/wave-loop/fan-out-apply-fails.json +406 -0
  3. package/dist/__golden__/wave-loop/fan-out-gated.json +707 -0
  4. package/dist/__golden__/wave-loop/fan-out-no-members.json +898 -0
  5. package/dist/__golden__/wave-loop/fan-out-only.json +514 -0
  6. package/dist/__golden__/wave-loop/fan-out-plan-fails.json +359 -0
  7. package/dist/__golden__/wave-loop/fan-out-same-root.json +311 -0
  8. package/dist/__golden__/wave-loop/op-by-apply-after-fails.json +401 -0
  9. package/dist/__golden__/wave-loop/op-by-apply-hold.json +756 -0
  10. package/dist/__golden__/wave-loop/op-by-apply-ignored.json +340 -0
  11. package/dist/__golden__/wave-loop/op-by-apply-pass.json +400 -0
  12. package/dist/__golden__/wave-loop/op-by-apply-skip.json +404 -0
  13. package/dist/__golden__/wave-loop/op-cost-member-clash.json +62 -0
  14. package/dist/__golden__/wave-loop/op-cost-member.json +191 -0
  15. package/dist/__golden__/wave-loop/op-denied-source.json +407 -0
  16. package/dist/__golden__/wave-loop/op-denied.json +399 -0
  17. package/dist/__golden__/wave-loop/op-discover-rest-short.json +92 -0
  18. package/dist/__golden__/wave-loop/op-discover-rest-stops.json +340 -0
  19. package/dist/__golden__/wave-loop/op-discover-rest.json +659 -0
  20. package/dist/__golden__/wave-loop/op-discover-wide.json +290 -0
  21. package/dist/__golden__/wave-loop/op-held-on-again.json +176 -0
  22. package/dist/__golden__/wave-loop/op-held-on-before-apply.json +689 -0
  23. package/dist/__golden__/wave-loop/op-held-on.json +1455 -0
  24. package/dist/__golden__/wave-loop/op-hooks-apply-fails.json +673 -0
  25. package/dist/__golden__/wave-loop/op-hooks-fail-after-apply.json +737 -0
  26. package/dist/__golden__/wave-loop/op-hooks-fail-before-apply.json +450 -0
  27. package/dist/__golden__/wave-loop/op-hooks-fail-before-plan.json +97 -0
  28. package/dist/__golden__/wave-loop/op-hooks-held.json +1059 -0
  29. package/dist/__golden__/wave-loop/op-hooks-hold-before-apply.json +693 -0
  30. package/dist/__golden__/wave-loop/op-hooks-pass.json +735 -0
  31. package/dist/__golden__/wave-loop/op-hooks-refused-at-base.json +148 -0
  32. package/dist/__golden__/wave-loop/op-hooks-share.json +1225 -0
  33. package/dist/__golden__/wave-loop/op-outcome-held.json +202 -0
  34. package/dist/__golden__/wave-loop/op-outcome-replan-approved.json +286 -0
  35. package/dist/__golden__/wave-loop/op-outcome-replan-forever.json +823 -0
  36. package/dist/__golden__/wave-loop/op-outcome-replan.json +259 -0
  37. package/dist/__golden__/wave-loop/op-outcome-stood-down.json +196 -0
  38. package/dist/__golden__/wave-loop/op-outcome-unreadable.json +193 -0
  39. package/dist/__golden__/wave-loop/op-overridden.json +508 -0
  40. package/dist/__golden__/wave-loop/op-per-run.json +518 -0
  41. package/dist/__golden__/wave-loop/op-point.json +495 -0
  42. package/dist/__golden__/wave-loop/op-pr-review.json +397 -0
  43. package/dist/__golden__/wave-loop/op-sealed.json +125 -0
  44. package/dist/__golden__/wave-loop/op-spend-change-only.json +168 -0
  45. package/dist/__golden__/wave-loop/op-spend.json +328 -0
  46. package/dist/__golden__/wave-loop/op-wave-commands.json +1808 -0
  47. package/dist/__golden__/wave-loop/op-wide-refused.json +291 -0
  48. package/dist/__golden__/wave-loop/op-wide-waiting.json +231 -0
  49. package/dist/__golden__/wave-loop/op-wide.json +440 -0
  50. package/dist/cli/commands/check-lexicon-plugin.d.ts.map +1 -1
  51. package/dist/cli/commands/check-lexicon-plugin.js +2 -1
  52. package/dist/cli/commands/lint.d.ts.map +1 -1
  53. package/dist/cli/commands/lint.js +3 -2
  54. package/dist/components/discover.d.ts.map +1 -1
  55. package/dist/components/discover.js +2 -1
  56. package/dist/components/fan-out-run.d.ts +4 -3
  57. package/dist/components/fan-out-run.d.ts.map +1 -1
  58. package/dist/components/fan-out-run.js +138 -123
  59. package/dist/config-sandbox.d.ts.map +1 -1
  60. package/dist/config-sandbox.js +5 -1
  61. package/dist/discovery/import.d.ts.map +1 -1
  62. package/dist/discovery/import.js +2 -1
  63. package/dist/discovery/sandbox/driver.d.ts.map +1 -1
  64. package/dist/discovery/sandbox/driver.js +8 -3
  65. package/dist/lexicon-module.d.ts.map +1 -1
  66. package/dist/lexicon-module.js +4 -3
  67. package/dist/lint/policy-import.d.ts.map +1 -1
  68. package/dist/lint/policy-import.js +2 -1
  69. package/dist/lint/rule-loader.d.ts.map +1 -1
  70. package/dist/lint/rule-loader.js +2 -1
  71. package/dist/module-interop.d.ts +20 -0
  72. package/dist/module-interop.d.ts.map +1 -0
  73. package/dist/module-interop.js +39 -0
  74. package/dist/op/discover.d.ts.map +1 -1
  75. package/dist/op/discover.js +3 -2
  76. package/dist/op/op-waves-run.d.ts +7 -1
  77. package/dist/op/op-waves-run.d.ts.map +1 -1
  78. package/dist/op/op-waves-run.js +129 -72
  79. package/dist/wave-engine.d.ts +74 -5
  80. package/dist/wave-engine.d.ts.map +1 -1
  81. package/dist/wave-engine.js +47 -5
  82. package/dist/wave-runtime.mjs +218 -218
  83. package/dist/workspace/lineage-migrations.d.ts.map +1 -1
  84. package/dist/workspace/lineage-migrations.js +2 -1
  85. package/package.json +1 -1
  86. package/src/__golden__/wave-loop/fan-out-applied.json +511 -0
  87. package/src/__golden__/wave-loop/fan-out-apply-fails.json +406 -0
  88. package/src/__golden__/wave-loop/fan-out-gated.json +707 -0
  89. package/src/__golden__/wave-loop/fan-out-no-members.json +898 -0
  90. package/src/__golden__/wave-loop/fan-out-only.json +514 -0
  91. package/src/__golden__/wave-loop/fan-out-plan-fails.json +359 -0
  92. package/src/__golden__/wave-loop/fan-out-same-root.json +311 -0
  93. package/src/__golden__/wave-loop/op-by-apply-after-fails.json +401 -0
  94. package/src/__golden__/wave-loop/op-by-apply-hold.json +756 -0
  95. package/src/__golden__/wave-loop/op-by-apply-ignored.json +340 -0
  96. package/src/__golden__/wave-loop/op-by-apply-pass.json +400 -0
  97. package/src/__golden__/wave-loop/op-by-apply-skip.json +404 -0
  98. package/src/__golden__/wave-loop/op-cost-member-clash.json +62 -0
  99. package/src/__golden__/wave-loop/op-cost-member.json +191 -0
  100. package/src/__golden__/wave-loop/op-denied-source.json +407 -0
  101. package/src/__golden__/wave-loop/op-denied.json +399 -0
  102. package/src/__golden__/wave-loop/op-discover-rest-short.json +92 -0
  103. package/src/__golden__/wave-loop/op-discover-rest-stops.json +340 -0
  104. package/src/__golden__/wave-loop/op-discover-rest.json +659 -0
  105. package/src/__golden__/wave-loop/op-discover-wide.json +290 -0
  106. package/src/__golden__/wave-loop/op-held-on-again.json +176 -0
  107. package/src/__golden__/wave-loop/op-held-on-before-apply.json +689 -0
  108. package/src/__golden__/wave-loop/op-held-on.json +1455 -0
  109. package/src/__golden__/wave-loop/op-hooks-apply-fails.json +673 -0
  110. package/src/__golden__/wave-loop/op-hooks-fail-after-apply.json +737 -0
  111. package/src/__golden__/wave-loop/op-hooks-fail-before-apply.json +450 -0
  112. package/src/__golden__/wave-loop/op-hooks-fail-before-plan.json +97 -0
  113. package/src/__golden__/wave-loop/op-hooks-held.json +1059 -0
  114. package/src/__golden__/wave-loop/op-hooks-hold-before-apply.json +693 -0
  115. package/src/__golden__/wave-loop/op-hooks-pass.json +735 -0
  116. package/src/__golden__/wave-loop/op-hooks-refused-at-base.json +148 -0
  117. package/src/__golden__/wave-loop/op-hooks-share.json +1225 -0
  118. package/src/__golden__/wave-loop/op-outcome-held.json +202 -0
  119. package/src/__golden__/wave-loop/op-outcome-replan-approved.json +286 -0
  120. package/src/__golden__/wave-loop/op-outcome-replan-forever.json +823 -0
  121. package/src/__golden__/wave-loop/op-outcome-replan.json +259 -0
  122. package/src/__golden__/wave-loop/op-outcome-stood-down.json +196 -0
  123. package/src/__golden__/wave-loop/op-outcome-unreadable.json +193 -0
  124. package/src/__golden__/wave-loop/op-overridden.json +508 -0
  125. package/src/__golden__/wave-loop/op-per-run.json +518 -0
  126. package/src/__golden__/wave-loop/op-point.json +495 -0
  127. package/src/__golden__/wave-loop/op-pr-review.json +397 -0
  128. package/src/__golden__/wave-loop/op-sealed.json +125 -0
  129. package/src/__golden__/wave-loop/op-spend-change-only.json +168 -0
  130. package/src/__golden__/wave-loop/op-spend.json +328 -0
  131. package/src/__golden__/wave-loop/op-wave-commands.json +1808 -0
  132. package/src/__golden__/wave-loop/op-wide-refused.json +291 -0
  133. package/src/__golden__/wave-loop/op-wide-waiting.json +231 -0
  134. package/src/__golden__/wave-loop/op-wide.json +440 -0
  135. package/src/cli/commands/check-lexicon-plugin.ts +2 -1
  136. package/src/cli/commands/lint.ts +3 -2
  137. package/src/cli/compiled-cli.e2e.test.ts +42 -1
  138. package/src/components/discover.ts +2 -1
  139. package/src/components/fan-out-run.ts +156 -128
  140. package/src/config-sandbox.ts +5 -1
  141. package/src/discovery/import.ts +2 -1
  142. package/src/discovery/sandbox/driver.ts +8 -3
  143. package/src/lexicon-module.ts +4 -3
  144. package/src/lint/policy-import.ts +2 -1
  145. package/src/lint/rule-loader.ts +2 -1
  146. package/src/meta/peer-deps.test.ts +39 -0
  147. package/src/module-interop.test.ts +65 -0
  148. package/src/module-interop.ts +39 -0
  149. package/src/op/discover.ts +3 -2
  150. package/src/op/op-waves-run.ts +184 -68
  151. package/src/wave-engine.test.ts +38 -1
  152. package/src/wave-engine.ts +101 -5
  153. package/src/wave-loop-golden.test.ts +627 -0
  154. package/src/workspace/lineage-migrations.ts +2 -1
@@ -51,7 +51,16 @@
51
51
  import { execFileSync, spawnSync } from "node:child_process";
52
52
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
53
53
  import { dirname, isAbsolute, join, posix } from "node:path";
54
- import { decideWaveGate, waveDigestMembers, waveSetDigest, writeWaveRecordFile, type WaveMember } from "../wave-engine";
54
+ import {
55
+ decideWaveGate,
56
+ runWaveLoop,
57
+ waveDigestMembers,
58
+ waveSetDigest,
59
+ writeWaveRecordFile,
60
+ type WaveAttempt,
61
+ type WaveMember,
62
+ type WaveStep,
63
+ } from "../wave-engine";
55
64
  import type { ReviewApproval } from "../gate-decision";
56
65
  import { approveCommand, evaluateGate, gitGateLedgerPort, type GateCheck, type GateDigestMismatch, type GateLedgerPort } from "./gate";
57
66
  import type { GateAnswerRef, PendingGateRecord } from "../lifecycle/gate-ledger";
@@ -1224,7 +1233,13 @@ function noteHooksAtBase(ctx: WaveContext, rules: OpWaveBaseRules): void {
1224
1233
  }
1225
1234
  }
1226
1235
 
1227
- /** Plan, decide and apply one wave, or one part of a wide wave. */
1236
+ /**
1237
+ * Plan, decide and apply one wave, or one part of a wide wave: the wave
1238
+ * engine's loop (`runWaveLoop`, #3766) over this module's steps. An apply
1239
+ * that comes back `replan` plans the wave again (#3821), at most
1240
+ * {@link OP_WAVE_MAX_REPLANS} times; a `rest` wave goes on through the waves
1241
+ * its plan command found.
1242
+ */
1228
1243
  export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveResult> {
1229
1244
  const { spec } = options;
1230
1245
  assertOpWavesSpec(spec);
@@ -1232,36 +1247,88 @@ export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveRes
1232
1247
  const last = spec.waves[spec.waves.length - 1]!;
1233
1248
  const applied: string[] = [];
1234
1249
  const rest: NonNullable<RunOpWaveResult["rest"]> = [];
1235
- for (let k = options.wave; ; k++) {
1236
- let result: RunOpWaveResult | undefined;
1237
- let prior: OpWaveDecision | undefined;
1238
- // #3821: an apply that came back "replan" plans the wave again, a bounded number of times.
1239
- for (let pass = 0; ; pass++) {
1240
- result = await runOneWave(options, k, prior, pass);
1241
- if (!result.replan) break;
1242
- if (pass + 1 >= OP_WAVE_MAX_REPLANS) {
1243
- log(`wave ${k}: its apply asked to plan again ${OP_WAVE_MAX_REPLANS} times, so the wave stops here`);
1244
- result.exitCode = 1;
1245
- break;
1246
- }
1247
- log(`wave ${k}: the apply applied nothing and asks to plan again${result.replan.reason ? ` (${result.replan.reason})` : ""}; planning again`);
1248
- prior = result.decision;
1249
- }
1250
- applied.push(...result.applied);
1251
- // #3821: a `rest` wave goes on through the waves its plan command found.
1252
- const found = result.decision?.discovered?.waves;
1253
- const onward = k >= spec.waves.length && last.rest === true && options.share === undefined && !options.decide && result.exitCode === 0 && found !== undefined && k < found;
1254
- if (rest.length > 0 || onward) rest.push({ wave: k, exitCode: result.exitCode, ...(result.decision ? { digest: result.decision.digest } : {}) });
1255
- if (!onward) return { ...result, applied, ...(rest.length > 0 ? { rest } : {}) };
1256
- log(`wave ${k} applied; the plan found ${found} waves, so this job goes on to wave ${k + 1}`);
1257
- }
1250
+ const result = await runWaveLoop<OpWavePlanned, OpWaveDecided, RunOpWaveResult>({
1251
+ first: options.wave,
1252
+ plan: (at) => planOpWave(options, at),
1253
+ // A share job applies under the digest its deciding job recorded; it does not take the set digest again.
1254
+ digest: (_at, planned) => (planned.kind === "share" ? planned.decision.digest : opWaveSetDigest(spec, planned.members, planned.extras)),
1255
+ decide: (at, planned, digest) => (planned.kind === "share" ? shareUnmoved(planned) : decideOpWave(options, at, planned, digest)),
1256
+ apply: (_at, planned, decided) => applyOpWave(options, planned, decided),
1257
+ replan: (_at, outcome) => outcome.replan !== undefined,
1258
+ maxPasses: OP_WAVE_MAX_REPLANS,
1259
+ passesSpent({ k }, outcome) {
1260
+ log(`wave ${k}: its apply asked to plan again ${OP_WAVE_MAX_REPLANS} times, so the wave stops here`);
1261
+ outcome.exitCode = 1;
1262
+ return outcome;
1263
+ },
1264
+ replanning({ k }, outcome) {
1265
+ log(`wave ${k}: the apply applied nothing and asks to plan again${outcome.replan!.reason ? ` (${outcome.replan!.reason})` : ""}; planning again`);
1266
+ },
1267
+ onward(k, outcome) {
1268
+ applied.push(...outcome.applied);
1269
+ // #3821: a `rest` wave goes on through the waves its plan command found.
1270
+ const found = outcome.decision?.discovered?.waves;
1271
+ const onward = k >= spec.waves.length && last.rest === true && options.share === undefined && !options.decide && outcome.exitCode === 0 && found !== undefined && k < found;
1272
+ if (rest.length > 0 || onward) rest.push({ wave: k, exitCode: outcome.exitCode, ...(outcome.decision ? { digest: outcome.decision.digest } : {}) });
1273
+ if (onward) log(`wave ${k} applied; the plan found ${found} waves, so this job goes on to wave ${k + 1}`);
1274
+ return onward;
1275
+ },
1276
+ });
1277
+ return { ...result, applied, ...(rest.length > 0 ? { rest } : {}) };
1258
1278
  }
1259
1279
 
1260
1280
  /** How many times one wave job plans again when its apply asks to (#3821). */
1261
1281
  export const OP_WAVE_MAX_REPLANS = 10;
1262
1282
 
1263
- /** 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. */
1264
- async function runOneWave(options: RunOpWaveOptions, k: number, prior: OpWaveDecision | undefined, pass: number): Promise<RunOpWaveResult> {
1283
+ /**
1284
+ * What planning one pass of a wave gave. `decide`: the deciding job (or a
1285
+ * narrow wave's only job), with the rules read at base and the decision it
1286
+ * fills in. `share`: a share job (`--share i`), with its deciding job's
1287
+ * decision and the slice it planned again.
1288
+ */
1289
+ type OpWavePlanned =
1290
+ | {
1291
+ kind: "decide";
1292
+ ctx: WaveContext;
1293
+ recordPath: string;
1294
+ rules: OpWaveBaseRules;
1295
+ decision: OpWaveDecision;
1296
+ /** The holds so far: the hooks at base that cannot be run (#3762). */
1297
+ holds: string[];
1298
+ /** The holds of the before-plan hooks. */
1299
+ before: string[];
1300
+ members: OpWaveMember[];
1301
+ planned: Map<string, OpWaveMember>;
1302
+ pending?: Record<string, unknown>;
1303
+ overriders?: string[];
1304
+ overridersSource?: string;
1305
+ extras?: WaveMember[];
1306
+ }
1307
+ | {
1308
+ kind: "share";
1309
+ ctx: WaveContext;
1310
+ decision: OpWaveDecision;
1311
+ slice: OpWaveRun[];
1312
+ planned: Map<string, OpWaveMember>;
1313
+ /** The holds of the before-plan and after-plan hooks. */
1314
+ holds: string[];
1315
+ };
1316
+
1317
+ /** What a decided wave applies: these runs, with the holds of the hooks so far, writing the record at `recordPath` when the job owns it. */
1318
+ interface OpWaveDecided {
1319
+ runs: readonly OpWaveRun[];
1320
+ earlierHolds: readonly string[];
1321
+ recordPath?: string;
1322
+ }
1323
+
1324
+ /**
1325
+ * Plan wave `k`: read its rules at base and run the before-plan hooks
1326
+ * around planning its runs, or for a share job, read the deciding job's
1327
+ * decision and plan the share's slice between its hooks. Ends the wave when
1328
+ * a hook fails or holds it on another gate, or a share's decision waits or
1329
+ * was refused.
1330
+ */
1331
+ async function planOpWave(options: RunOpWaveOptions, { k, pass }: WaveAttempt<RunOpWaveResult>): Promise<WaveStep<OpWavePlanned, RunOpWaveResult>> {
1265
1332
  const { spec } = options;
1266
1333
  const cwd = options.cwd ?? process.cwd();
1267
1334
  const exec = options.exec ?? spawnExec;
@@ -1290,7 +1357,7 @@ async function runOneWave(options: RunOpWaveOptions, k: number, prior: OpWaveDec
1290
1357
  ctx.gateRule = { approval: decision.approval, base: decision.base, neverOverMcp: decision.neverOverMcp === true };
1291
1358
  if (decision.status === "waiting" || decision.status === "refused") {
1292
1359
  log(`wave ${k} (${wave.name}) is ${decision.status === "refused" ? "refused by its policy" : `waiting on ${decision.heldOn?.gate ?? decision.gate}`}; nothing applied. ${decision.approve ?? ""}`.trim());
1293
- return { ...result, exitCode: decision.status === "refused" ? refusedExit(decision) : 3 };
1360
+ return { end: { ...result, exitCode: decision.status === "refused" ? refusedExit(decision) : 3 } };
1294
1361
  }
1295
1362
  // The share runs the hooks its deciding job read at base, around its own slice.
1296
1363
  if (wave.discover) {
@@ -1300,30 +1367,14 @@ async function runOneWave(options: RunOpWaveOptions, k: number, prior: OpWaveDec
1300
1367
  }
1301
1368
  const slice = opWaveShare(wave.runs, options.share, shares);
1302
1369
  const before = await runHooks(ctx, "before-plan", decision.hooks?.["before-plan"], slice);
1303
- 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`);
1304
- if (before.waitingOn) return waitOnOther(ctx, decision, before.waitingOn);
1370
+ if (before.failed) return { end: failed(ctx, `a hook before the plan failed, so nothing in share ${options.share} of wave ${k} was planned or applied`) };
1371
+ if (before.waitingOn) return { end: await waitOnOther(ctx, decision, before.waitingOn) };
1305
1372
  const { members } = planRuns(ctx, slice);
1306
1373
  const planned = new Map(members.map((m) => [m.member, m]));
1307
1374
  const after = await runHooks(ctx, "after-plan", decision.hooks?.["after-plan"], slice, planned);
1308
- if (after.failed) return failed(ctx, `a hook after the plan failed, so nothing in share ${options.share} of wave ${k} was applied`);
1309
- if (after.waitingOn) return waitOnOther(ctx, decision, after.waitingOn);
1310
- const decided = new Map(decision.members.map((m) => [m.member, m.planDigest]));
1311
- const unmoved: OpWaveRun[] = [];
1312
- for (const run of slice) {
1313
- const now = planned.get(run.target)!;
1314
- if (decided.get(run.target) !== now.planDigest) {
1315
- log(
1316
- `${run.target}: its plan moved since wave ${k} was decided, so it was not applied. ` +
1317
- `decided: ${decided.get(run.target) ?? "(not in the decision)"}; planned now: ${now.planDigest}`,
1318
- );
1319
- result.moved.push(run.target);
1320
- continue;
1321
- }
1322
- unmoved.push(run);
1323
- }
1324
- await applyStage(options, ctx, decision, unmoved, planned, [...before.holds, ...after.holds]);
1325
- if (result.exitCode === 0 && result.moved.length > 0) result.exitCode = 4;
1326
- return result;
1375
+ if (after.failed) return { end: failed(ctx, `a hook after the plan failed, so nothing in share ${options.share} of wave ${k} was applied`) };
1376
+ if (after.waitingOn) return { end: await waitOnOther(ctx, decision, after.waitingOn) };
1377
+ return { go: { kind: "share", ctx, decision, slice, planned, holds: [...before.holds, ...after.holds] } };
1327
1378
  }
1328
1379
 
1329
1380
  // The policy, the approval mode and the hooks, read at base before anything runs.
@@ -1333,7 +1384,7 @@ async function runOneWave(options: RunOpWaveOptions, k: number, prior: OpWaveDec
1333
1384
  ctx.gateRule = { approval, base, neverOverMcp };
1334
1385
  noteHooksAtBase(ctx, rules);
1335
1386
  // #3836: a point at base that cannot be asked fails the wave; the ledger never stands in for it.
1336
- if (rules.pointRefused) return failed(ctx, `wave ${k} (${wave.name}): ${rules.pointRefused}, so nothing in it was planned or applied`);
1387
+ if (rules.pointRefused) return { end: failed(ctx, `wave ${k} (${wave.name}): ${rules.pointRefused}, so nothing in it was planned or applied`) };
1337
1388
  const own = opWavePointAt(spec, wave.name);
1338
1389
  if (JSON.stringify(own) !== JSON.stringify(rules.point)) {
1339
1390
  log(`wave ${k} (${wave.name}): its gate's decision point is read at ${base.slice(0, 12)} (${rules.point?.name ?? "none"}), and this change's differs (${own?.name ?? "none"}); the one at base holds`);
@@ -1365,8 +1416,8 @@ async function runOneWave(options: RunOpWaveOptions, k: number, prior: OpWaveDec
1365
1416
  ...(rules.point ? { point: { ...rules.point } } : {}),
1366
1417
  };
1367
1418
  const before = await runHooks(ctx, "before-plan", rules.hooks["before-plan"], wave.runs);
1368
- if (before.failed) return failed(ctx, `a hook before the plan failed, so nothing in wave ${k} was planned or applied`);
1369
- if (before.waitingOn) return decided(ctx, decision, recordPath, before.waitingOn);
1419
+ if (before.failed) return { end: failed(ctx, `a hook before the plan failed, so nothing in wave ${k} was planned or applied`) };
1420
+ if (before.waitingOn) return { end: await decided(ctx, decision, recordPath, before.waitingOn) };
1370
1421
  const { members, pending, overriders, overridersSource, extras, waves: found } = planRuns(ctx, wave.runs, wave.discover === true);
1371
1422
  if (wave.discover) {
1372
1423
  wave = { ...wave, runs: members.map((m) => ({ target: m.member, ...(m.op !== spec.op ? { op: m.op } : {}) })) };
@@ -1375,33 +1426,91 @@ async function runOneWave(options: RunOpWaveOptions, k: number, prior: OpWaveDec
1375
1426
  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(", ")}` : ""}`);
1376
1427
  }
1377
1428
  const planned = new Map(members.map((m) => [m.member, m]));
1378
- const digest = opWaveSetDigest(spec, members, extras);
1429
+ return {
1430
+ go: {
1431
+ kind: "decide",
1432
+ ctx,
1433
+ recordPath,
1434
+ rules,
1435
+ decision,
1436
+ holds,
1437
+ before: before.holds,
1438
+ members,
1439
+ planned,
1440
+ ...(pending ? { pending } : {}),
1441
+ ...(overriders ? { overriders } : {}),
1442
+ ...(overridersSource ? { overridersSource } : {}),
1443
+ ...(extras ? { extras } : {}),
1444
+ },
1445
+ };
1446
+ }
1447
+
1448
+ /**
1449
+ * A share job's decision is its deciding job's: it applies the runs of its
1450
+ * slice whose plan has not moved since, and names each one that has.
1451
+ */
1452
+ async function shareUnmoved(planned: Extract<OpWavePlanned, { kind: "share" }>): Promise<WaveStep<OpWaveDecided, RunOpWaveResult>> {
1453
+ const { ctx, decision, slice, holds } = planned;
1454
+ const decidedDigests = new Map(decision.members.map((m) => [m.member, m.planDigest]));
1455
+ const unmoved: OpWaveRun[] = [];
1456
+ for (const run of slice) {
1457
+ const now = planned.planned.get(run.target)!;
1458
+ if (decidedDigests.get(run.target) !== now.planDigest) {
1459
+ ctx.log(
1460
+ `${run.target}: its plan moved since wave ${ctx.k} was decided, so it was not applied. ` +
1461
+ `decided: ${decidedDigests.get(run.target) ?? "(not in the decision)"}; planned now: ${now.planDigest}`,
1462
+ );
1463
+ ctx.result.moved.push(run.target);
1464
+ continue;
1465
+ }
1466
+ unmoved.push(run);
1467
+ }
1468
+ return { go: { runs: unmoved, earlierHolds: holds } };
1469
+ }
1470
+
1471
+ /**
1472
+ * Decide wave `k` against its set digest: run the after-plan hooks, settle
1473
+ * the product's denials and the gate under the rules read at base, write the
1474
+ * decision record and run the after-gate hooks. Ends the wave when it waits,
1475
+ * is refused or failed, and for a deciding job (`--decide`), which spends the
1476
+ * approval and overrides its shares apply under and applies nothing.
1477
+ */
1478
+ async function decideOpWave(
1479
+ options: RunOpWaveOptions,
1480
+ { prior }: WaveAttempt<RunOpWaveResult>,
1481
+ planned: Extract<OpWavePlanned, { kind: "decide" }>,
1482
+ digest: string,
1483
+ ): Promise<WaveStep<OpWaveDecided, RunOpWaveResult>> {
1484
+ const { ctx, recordPath, decision, holds, members, pending, overriders, overridersSource, extras } = planned;
1485
+ const { k, wave, log, result } = ctx;
1486
+ const { policy, source, base } = planned.rules;
1379
1487
  decision.digest = digest;
1380
1488
  decision.members = members;
1381
1489
  if (extras) decision.extras = extras;
1382
1490
  if (pending) decision.pending = pending;
1383
- const after = await runHooks(ctx, "after-plan", rules.hooks["after-plan"], wave.runs, planned, digest);
1384
- if (after.failed) return failed(ctx, `a hook after the plan failed, so nothing in wave ${k} was applied`);
1385
- holds.push(...before.holds, ...after.holds);
1491
+ const after = await runHooks(ctx, "after-plan", planned.rules.hooks["after-plan"], wave.runs, planned.planned, digest);
1492
+ if (after.failed) return { end: failed(ctx, `a hook after the plan failed, so nothing in wave ${k} was applied`) };
1493
+ holds.push(...planned.before, ...after.holds);
1386
1494
  if (holds.length > 0) decision.heldBy = holds;
1387
- if (after.waitingOn) return decided(ctx, decision, recordPath, after.waitingOn);
1495
+ if (after.waitingOn) return { end: await decided(ctx, decision, recordPath, after.waitingOn) };
1388
1496
  // #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.
1389
- const kept = prior !== undefined && (prior.status === "approved" || prior.status === "not-required") && prior.digest === digest;
1497
+ const was = prior?.decision;
1498
+ const kept = was !== undefined && (was.status === "approved" || was.status === "not-required") && was.digest === digest;
1390
1499
  if (kept) {
1391
1500
  // The overrides this job applied under, and spent, still let the same plans through.
1392
- const byRun = new Map(prior.members.map((m) => [m.member, m.override]));
1501
+ const byRun = new Map(was.members.map((m) => [m.member, m.override]));
1393
1502
  for (const m of members) if (m.denied && byRun.get(m.member)) m.override = byRun.get(m.member);
1394
- if (prior.overridesSpentAt) decision.overridesSpentAt = prior.overridesSpentAt;
1503
+ if (was.overridesSpentAt) decision.overridesSpentAt = was.overridesSpentAt;
1395
1504
  }
1396
1505
  if (members.some((m) => m.denied && !m.override)) await settleDenials(ctx, decision, overriders, overridersSource);
1397
- if (kept && prior.status === "approved" && decision.status !== "refused") {
1506
+ if (kept && was.status === "approved" && decision.status !== "refused") {
1398
1507
  // #3821: planned again after the apply asked to, to the same digest: the approval this job found still holds, and is not spent twice.
1399
- 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];
1508
+ for (const key of ["via", "approvedBy", "approvedAt", "review", "spentAt"] as const) if (was[key] !== undefined) (decision as unknown as Record<string, unknown>)[key] = was[key];
1400
1509
  decision.status = "approved";
1401
- log(`wave ${k} (${wave.name}): planned again to the same digest, so the approval by ${prior.approvedBy} still holds`);
1510
+ log(`wave ${k} (${wave.name}): planned again to the same digest, so the approval by ${was.approvedBy} still holds`);
1402
1511
  } else if (decision.status !== "refused" && (opWaveNeedsApproval(policy, members) || holds.length > 0)) await settleGate(options, ctx, decision);
1403
1512
  const code = await decided(ctx, decision, recordPath);
1404
- if (code.exitCode !== 0) return code;
1513
+ if (code.exitCode !== 0) return { end: code };
1405
1514
  log(
1406
1515
  `wave ${k} (${wave.name}): gate ${policy} (read at ${base.slice(0, 12)}${source === "absent-at-base" ? ", where the wave is absent" : ""}), ` +
1407
1516
  `digest ${digest}: ${decision.status}${decision.approvedBy ? ` by ${decision.approvedBy}` : ""}` +
@@ -1410,12 +1519,19 @@ async function runOneWave(options: RunOpWaveOptions, k: number, prior: OpWaveDec
1410
1519
  );
1411
1520
  // A wide wave's shares apply under the deciding job's approval and overrides, so they are spent here, once.
1412
1521
  if (options.decide) {
1413
- if (!(await spendApproval(options, ctx, decision)) || !(await spendOverrides(ctx, decision))) return ctx.result;
1522
+ if (!(await spendApproval(options, ctx, decision)) || !(await spendOverrides(ctx, decision))) return { end: ctx.result };
1414
1523
  if (decision.spentAt || decision.overridesSpentAt) writeRecord(recordPath, decision, result);
1415
- return result;
1524
+ return { end: result };
1416
1525
  }
1417
- await applyStage(options, ctx, decision, wave.runs, planned, [], recordPath);
1418
- return result;
1526
+ return { go: { runs: wave.runs, earlierHolds: [], recordPath } };
1527
+ }
1528
+
1529
+ /** Apply a decided wave, or a share's unmoved runs (exit 4 when any moved). */
1530
+ async function applyOpWave(options: RunOpWaveOptions, planned: OpWavePlanned, decidedWave: OpWaveDecided): Promise<RunOpWaveResult> {
1531
+ const { ctx, decision } = planned;
1532
+ await applyStage(options, ctx, decision, decidedWave.runs, planned.planned, decidedWave.earlierHolds, decidedWave.recordPath);
1533
+ if (planned.kind === "share" && ctx.result.exitCode === 0 && ctx.result.moved.length > 0) ctx.result.exitCode = 4;
1534
+ return ctx.result;
1419
1535
  }
1420
1536
 
1421
1537
  /**
@@ -17,7 +17,7 @@ import { planFanOut } from "./components/fan-out";
17
17
  import { runFanOut } from "./components/fan-out-run";
18
18
  import { CapabilityRegistry } from "./components/capability";
19
19
  import type { DriverComponent } from "./components/driver";
20
- import { waveGateName, waveGateNameFrom, waveSetDigest, waveShare, WAVE_GATE_NAME_PATTERN } from "./wave-engine";
20
+ import { runWaveLoop, waveGateName, waveGateNameFrom, waveSetDigest, waveShare, WAVE_GATE_NAME_PATTERN, type WaveLoopSteps } from "./wave-engine";
21
21
 
22
22
  const digest = (n: string) => `sha256:${n.repeat(64)}`;
23
23
  /** The runs both runners plan: a member name and its plan digest. */
@@ -104,4 +104,41 @@ describe("one wave engine (#3766)", () => {
104
104
  }
105
105
  expect(opWaveShare(runs, 1, 2).map((r) => r.target)).toEqual(["t1", "t2", "t3"]);
106
106
  });
107
+
108
+ test("the loop: plan, digest, decide, apply per wave; a replan is bounded; onward walks on; an ended step skips the rest", async () => {
109
+ const seen: string[] = [];
110
+ type R = { k: number; replan?: boolean; stop?: boolean };
111
+ const steps = (over: Partial<WaveLoopSteps<string, string, R>> = {}): WaveLoopSteps<string, string, R> => ({
112
+ first: 2,
113
+ async plan({ k, pass, prior }) {
114
+ seen.push(`plan ${k}.${pass}${prior ? " after a replan" : ""}`);
115
+ return k === 3 ? { end: { k } } : { go: `p${k}` };
116
+ },
117
+ digest: ({ k }, planned) => (seen.push(`digest ${k} of ${planned}`), `d${k}`),
118
+ async decide({ k }, _planned, digest) {
119
+ seen.push(`decide ${k} at ${digest}`);
120
+ return { go: `g${k}` };
121
+ },
122
+ async apply({ k, pass }, planned, decided) {
123
+ seen.push(`apply ${k} ${planned} ${decided}`);
124
+ return { k, replan: k === 2 && pass === 0 };
125
+ },
126
+ replan: (_at, r) => r.replan === true,
127
+ maxPasses: 2,
128
+ onward: (k) => k < 4,
129
+ ...over,
130
+ });
131
+ expect(await runWaveLoop(steps())).toEqual({ k: 4, replan: false });
132
+ expect(seen).toEqual([
133
+ "plan 2.0", "digest 2 of p2", "decide 2 at d2", "apply 2 p2 g2",
134
+ "plan 2.1 after a replan", "digest 2 of p2", "decide 2 at d2", "apply 2 p2 g2",
135
+ "plan 3.0",
136
+ "plan 4.0", "digest 4 of p4", "decide 4 at d4", "apply 4 p4 g4",
137
+ ]);
138
+
139
+ seen.length = 0;
140
+ const spent = await runWaveLoop(steps({ maxPasses: 1, onward: () => false, passesSpent: (_at, r) => ({ ...r, stop: true }) }));
141
+ expect(spent).toEqual({ k: 2, replan: true, stop: true });
142
+ expect(seen.filter((l) => l.startsWith("plan"))).toEqual(["plan 2.0"]);
143
+ });
107
144
  });
@@ -8,6 +8,9 @@
8
8
  * - The share split of a wide wave: {@link waveShare}.
9
9
  * - The gate decision call: {@link decideWaveGate}.
10
10
  * - The record file a wave job hands the next one: {@link writeWaveRecordFile}.
11
+ * - The loop itself: {@link runWaveLoop} plans a wave, takes its set digest,
12
+ * decides its gate and applies it, then goes on to the next wave or plans
13
+ * the same one again, as the entry point's steps say.
11
14
  *
12
15
  * The parts that need neither the gate ledger nor the filesystem live in
13
16
  * `./gated-waves.ts`, which terragucci bundles on its own (#3421), and
@@ -15,11 +18,12 @@
15
18
  * use it without the change-set code; this module re-exports both so a
16
19
  * runner imports the engine from one place.
17
20
  *
18
- * What stays with each entry point: how a wave is planned (Op runs' plan
19
- * commands, or each component's capabilities), the gate policy, approval
20
- * mode, decision point, hooks and spend rule (Op waves only), and the shape
21
- * of the record each writes. Those shapes are read by jobs and approvals
22
- * already in the wild, so the engine writes what it is handed.
21
+ * What stays with each entry point, as the steps it hands {@link runWaveLoop}:
22
+ * how a wave is planned (Op runs' plan commands, or each component's
23
+ * capabilities), which members its digest covers, the gate policy, approval
24
+ * mode, decision point, hooks and spend rule (Op waves only), how it applies,
25
+ * and the shape of the record each writes. Those shapes are read by jobs and
26
+ * approvals already in the wild, so the engine writes what it is handed.
23
27
  */
24
28
 
25
29
  import { dirname } from "node:path";
@@ -96,3 +100,95 @@ export function writeWaveRecordFile(path: string, value: unknown): void {
96
100
  writeFileSync(temporary, JSON.stringify(value, null, 2) + "\n");
97
101
  renameSync(temporary, path);
98
102
  }
103
+
104
+ /**
105
+ * What a step hands {@link runWaveLoop}: `go` carries a value into the
106
+ * wave's next step; `end` ends the wave with its outcome, and nothing after
107
+ * that step runs for it.
108
+ */
109
+ export type WaveStep<T, R> = { go: T } | { end: R };
110
+
111
+ /** Where the loop is: wave `k` (1-based), its `pass`, and the outcome of the pass before when the wave is planned again. */
112
+ export interface WaveAttempt<R> {
113
+ k: number;
114
+ /** 0 on a wave's first plan. */
115
+ pass: number;
116
+ /** The outcome of the pass that asked to plan again. */
117
+ prior?: R;
118
+ }
119
+
120
+ /**
121
+ * The steps an entry point hands {@link runWaveLoop}, typed by what planning
122
+ * gives (`P`), what deciding gives (`D`) and a wave's outcome (`R`).
123
+ *
124
+ * The two entry points differ in each step, on purpose:
125
+ *
126
+ * - `chant run wave` (Op waves) plans with argv commands and hooks, takes
127
+ * the digest under `digest.over` with the plans file's extras, decides
128
+ * with the approval mode, decision point, spend rule, denials and pending
129
+ * fields read at base, and runs one wave per job (a `rest` wave walks on,
130
+ * an apply may ask to plan again; a share job applies under its deciding
131
+ * job's decision).
132
+ * - `chant components fan-out --wave-gate` plans each component's
133
+ * capabilities, takes the digest over every member (a wave where nothing
134
+ * planned has the empty digest, and one where nothing is left to run skips
135
+ * its gate), asks the plain ledger, and walks every wave in one job.
136
+ */
137
+ export interface WaveLoopSteps<P, D, R> {
138
+ /** The first wave's number. */
139
+ first: number;
140
+ /** Plan the wave, or end it before it has a set (nothing to run, a hook failed or waits). */
141
+ plan(at: WaveAttempt<R>): Promise<WaveStep<P, R>>;
142
+ /** The wave's set digest over what was planned. */
143
+ digest(at: WaveAttempt<R>, planned: P): string;
144
+ /** Decide the wave's gate against its digest: what the apply goes ahead under, or the outcome when the wave stops here. */
145
+ decide(at: WaveAttempt<R>, planned: P, digest: string): Promise<WaveStep<D, R>>;
146
+ /** Apply the decided wave. */
147
+ apply(at: WaveAttempt<R>, planned: P, decided: D): Promise<R>;
148
+ /** Whether the outcome asks for the same wave to be planned again in this job. Absent: never. */
149
+ replan?(at: WaveAttempt<R>, outcome: R): boolean;
150
+ /** How many passes one wave gets before a replan is refused. Default 1. */
151
+ maxPasses?: number;
152
+ /** The wave asked to plan again after its last pass: the outcome it ends with. */
153
+ passesSpent?(at: WaveAttempt<R>, outcome: R): R;
154
+ /** Called before another pass at the same wave. */
155
+ replanning?(at: WaveAttempt<R>, outcome: R): void;
156
+ /** The wave ended with `outcome`: whether the job goes on to wave `k + 1`. */
157
+ onward(k: number, outcome: R): boolean;
158
+ }
159
+
160
+ /**
161
+ * The wave loop both entry points run (#3766): for each wave, plan it, take
162
+ * its set digest, decide its gate and apply it; plan it again while its
163
+ * outcome asks to, up to `maxPasses`; then go on to the next wave while
164
+ * `onward` says so. Returns the outcome of the last wave it ran. A step that
165
+ * throws ends the loop with that error.
166
+ */
167
+ export async function runWaveLoop<P, D, R>(steps: WaveLoopSteps<P, D, R>): Promise<R> {
168
+ for (let k = steps.first; ; k++) {
169
+ let outcome: R;
170
+ let prior: R | undefined;
171
+ for (let pass = 0; ; pass++) {
172
+ const at: WaveAttempt<R> = { k, pass, ...(prior !== undefined ? { prior } : {}) };
173
+ outcome = await runWave(steps, at);
174
+ if (!steps.replan?.(at, outcome)) break;
175
+ if (pass + 1 >= (steps.maxPasses ?? 1)) {
176
+ outcome = steps.passesSpent ? steps.passesSpent(at, outcome) : outcome;
177
+ break;
178
+ }
179
+ steps.replanning?.(at, outcome);
180
+ prior = outcome;
181
+ }
182
+ if (!steps.onward(k, outcome)) return outcome;
183
+ }
184
+ }
185
+
186
+ /** One pass at one wave: plan, digest, decide, apply. */
187
+ async function runWave<P, D, R>(steps: WaveLoopSteps<P, D, R>, at: WaveAttempt<R>): Promise<R> {
188
+ const planned = await steps.plan(at);
189
+ if ("end" in planned) return planned.end;
190
+ const digest = steps.digest(at, planned.go);
191
+ const decided = await steps.decide(at, planned.go, digest);
192
+ if ("end" in decided) return decided.end;
193
+ return steps.apply(at, planned.go, decided.go);
194
+ }