@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.
- package/dist/__golden__/wave-loop/fan-out-applied.json +511 -0
- package/dist/__golden__/wave-loop/fan-out-apply-fails.json +406 -0
- package/dist/__golden__/wave-loop/fan-out-gated.json +707 -0
- package/dist/__golden__/wave-loop/fan-out-no-members.json +898 -0
- package/dist/__golden__/wave-loop/fan-out-only.json +514 -0
- package/dist/__golden__/wave-loop/fan-out-plan-fails.json +359 -0
- package/dist/__golden__/wave-loop/fan-out-same-root.json +311 -0
- package/dist/__golden__/wave-loop/op-by-apply-after-fails.json +401 -0
- package/dist/__golden__/wave-loop/op-by-apply-hold.json +756 -0
- package/dist/__golden__/wave-loop/op-by-apply-ignored.json +340 -0
- package/dist/__golden__/wave-loop/op-by-apply-pass.json +400 -0
- package/dist/__golden__/wave-loop/op-by-apply-skip.json +404 -0
- package/dist/__golden__/wave-loop/op-cost-member-clash.json +62 -0
- package/dist/__golden__/wave-loop/op-cost-member.json +191 -0
- package/dist/__golden__/wave-loop/op-denied-source.json +407 -0
- package/dist/__golden__/wave-loop/op-denied.json +399 -0
- package/dist/__golden__/wave-loop/op-discover-rest-short.json +92 -0
- package/dist/__golden__/wave-loop/op-discover-rest-stops.json +340 -0
- package/dist/__golden__/wave-loop/op-discover-rest.json +659 -0
- package/dist/__golden__/wave-loop/op-discover-wide.json +290 -0
- package/dist/__golden__/wave-loop/op-held-on-again.json +176 -0
- package/dist/__golden__/wave-loop/op-held-on-before-apply.json +689 -0
- package/dist/__golden__/wave-loop/op-held-on.json +1455 -0
- package/dist/__golden__/wave-loop/op-hooks-apply-fails.json +673 -0
- package/dist/__golden__/wave-loop/op-hooks-fail-after-apply.json +737 -0
- package/dist/__golden__/wave-loop/op-hooks-fail-before-apply.json +450 -0
- package/dist/__golden__/wave-loop/op-hooks-fail-before-plan.json +97 -0
- package/dist/__golden__/wave-loop/op-hooks-held.json +1059 -0
- package/dist/__golden__/wave-loop/op-hooks-hold-before-apply.json +693 -0
- package/dist/__golden__/wave-loop/op-hooks-pass.json +735 -0
- package/dist/__golden__/wave-loop/op-hooks-refused-at-base.json +148 -0
- package/dist/__golden__/wave-loop/op-hooks-share.json +1225 -0
- package/dist/__golden__/wave-loop/op-outcome-held.json +202 -0
- package/dist/__golden__/wave-loop/op-outcome-replan-approved.json +286 -0
- package/dist/__golden__/wave-loop/op-outcome-replan-forever.json +823 -0
- package/dist/__golden__/wave-loop/op-outcome-replan.json +259 -0
- package/dist/__golden__/wave-loop/op-outcome-stood-down.json +196 -0
- package/dist/__golden__/wave-loop/op-outcome-unreadable.json +193 -0
- package/dist/__golden__/wave-loop/op-overridden.json +508 -0
- package/dist/__golden__/wave-loop/op-per-run.json +518 -0
- package/dist/__golden__/wave-loop/op-point.json +495 -0
- package/dist/__golden__/wave-loop/op-pr-review.json +397 -0
- package/dist/__golden__/wave-loop/op-sealed.json +125 -0
- package/dist/__golden__/wave-loop/op-spend-change-only.json +168 -0
- package/dist/__golden__/wave-loop/op-spend.json +328 -0
- package/dist/__golden__/wave-loop/op-wave-commands.json +1808 -0
- package/dist/__golden__/wave-loop/op-wide-refused.json +291 -0
- package/dist/__golden__/wave-loop/op-wide-waiting.json +231 -0
- package/dist/__golden__/wave-loop/op-wide.json +440 -0
- package/dist/cli/commands/check-lexicon-plugin.d.ts.map +1 -1
- package/dist/cli/commands/check-lexicon-plugin.js +2 -1
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/commands/lint.js +3 -2
- package/dist/components/discover.d.ts.map +1 -1
- package/dist/components/discover.js +2 -1
- package/dist/components/fan-out-run.d.ts +4 -3
- package/dist/components/fan-out-run.d.ts.map +1 -1
- package/dist/components/fan-out-run.js +138 -123
- package/dist/config-sandbox.d.ts.map +1 -1
- package/dist/config-sandbox.js +5 -1
- package/dist/discovery/import.d.ts.map +1 -1
- package/dist/discovery/import.js +2 -1
- package/dist/discovery/sandbox/driver.d.ts.map +1 -1
- package/dist/discovery/sandbox/driver.js +8 -3
- package/dist/lexicon-module.d.ts.map +1 -1
- package/dist/lexicon-module.js +4 -3
- package/dist/lint/policy-import.d.ts.map +1 -1
- package/dist/lint/policy-import.js +2 -1
- package/dist/lint/rule-loader.d.ts.map +1 -1
- package/dist/lint/rule-loader.js +2 -1
- package/dist/module-interop.d.ts +20 -0
- package/dist/module-interop.d.ts.map +1 -0
- package/dist/module-interop.js +39 -0
- package/dist/op/discover.d.ts.map +1 -1
- package/dist/op/discover.js +3 -2
- package/dist/op/op-waves-run.d.ts +7 -1
- package/dist/op/op-waves-run.d.ts.map +1 -1
- package/dist/op/op-waves-run.js +129 -72
- package/dist/wave-engine.d.ts +74 -5
- package/dist/wave-engine.d.ts.map +1 -1
- package/dist/wave-engine.js +47 -5
- package/dist/wave-runtime.mjs +218 -218
- package/dist/workspace/lineage-migrations.d.ts.map +1 -1
- package/dist/workspace/lineage-migrations.js +2 -1
- package/package.json +1 -1
- package/src/__golden__/wave-loop/fan-out-applied.json +511 -0
- package/src/__golden__/wave-loop/fan-out-apply-fails.json +406 -0
- package/src/__golden__/wave-loop/fan-out-gated.json +707 -0
- package/src/__golden__/wave-loop/fan-out-no-members.json +898 -0
- package/src/__golden__/wave-loop/fan-out-only.json +514 -0
- package/src/__golden__/wave-loop/fan-out-plan-fails.json +359 -0
- package/src/__golden__/wave-loop/fan-out-same-root.json +311 -0
- package/src/__golden__/wave-loop/op-by-apply-after-fails.json +401 -0
- package/src/__golden__/wave-loop/op-by-apply-hold.json +756 -0
- package/src/__golden__/wave-loop/op-by-apply-ignored.json +340 -0
- package/src/__golden__/wave-loop/op-by-apply-pass.json +400 -0
- package/src/__golden__/wave-loop/op-by-apply-skip.json +404 -0
- package/src/__golden__/wave-loop/op-cost-member-clash.json +62 -0
- package/src/__golden__/wave-loop/op-cost-member.json +191 -0
- package/src/__golden__/wave-loop/op-denied-source.json +407 -0
- package/src/__golden__/wave-loop/op-denied.json +399 -0
- package/src/__golden__/wave-loop/op-discover-rest-short.json +92 -0
- package/src/__golden__/wave-loop/op-discover-rest-stops.json +340 -0
- package/src/__golden__/wave-loop/op-discover-rest.json +659 -0
- package/src/__golden__/wave-loop/op-discover-wide.json +290 -0
- package/src/__golden__/wave-loop/op-held-on-again.json +176 -0
- package/src/__golden__/wave-loop/op-held-on-before-apply.json +689 -0
- package/src/__golden__/wave-loop/op-held-on.json +1455 -0
- package/src/__golden__/wave-loop/op-hooks-apply-fails.json +673 -0
- package/src/__golden__/wave-loop/op-hooks-fail-after-apply.json +737 -0
- package/src/__golden__/wave-loop/op-hooks-fail-before-apply.json +450 -0
- package/src/__golden__/wave-loop/op-hooks-fail-before-plan.json +97 -0
- package/src/__golden__/wave-loop/op-hooks-held.json +1059 -0
- package/src/__golden__/wave-loop/op-hooks-hold-before-apply.json +693 -0
- package/src/__golden__/wave-loop/op-hooks-pass.json +735 -0
- package/src/__golden__/wave-loop/op-hooks-refused-at-base.json +148 -0
- package/src/__golden__/wave-loop/op-hooks-share.json +1225 -0
- package/src/__golden__/wave-loop/op-outcome-held.json +202 -0
- package/src/__golden__/wave-loop/op-outcome-replan-approved.json +286 -0
- package/src/__golden__/wave-loop/op-outcome-replan-forever.json +823 -0
- package/src/__golden__/wave-loop/op-outcome-replan.json +259 -0
- package/src/__golden__/wave-loop/op-outcome-stood-down.json +196 -0
- package/src/__golden__/wave-loop/op-outcome-unreadable.json +193 -0
- package/src/__golden__/wave-loop/op-overridden.json +508 -0
- package/src/__golden__/wave-loop/op-per-run.json +518 -0
- package/src/__golden__/wave-loop/op-point.json +495 -0
- package/src/__golden__/wave-loop/op-pr-review.json +397 -0
- package/src/__golden__/wave-loop/op-sealed.json +125 -0
- package/src/__golden__/wave-loop/op-spend-change-only.json +168 -0
- package/src/__golden__/wave-loop/op-spend.json +328 -0
- package/src/__golden__/wave-loop/op-wave-commands.json +1808 -0
- package/src/__golden__/wave-loop/op-wide-refused.json +291 -0
- package/src/__golden__/wave-loop/op-wide-waiting.json +231 -0
- package/src/__golden__/wave-loop/op-wide.json +440 -0
- package/src/cli/commands/check-lexicon-plugin.ts +2 -1
- package/src/cli/commands/lint.ts +3 -2
- package/src/cli/compiled-cli.e2e.test.ts +42 -1
- package/src/components/discover.ts +2 -1
- package/src/components/fan-out-run.ts +156 -128
- package/src/config-sandbox.ts +5 -1
- package/src/discovery/import.ts +2 -1
- package/src/discovery/sandbox/driver.ts +8 -3
- package/src/lexicon-module.ts +4 -3
- package/src/lint/policy-import.ts +2 -1
- package/src/lint/rule-loader.ts +2 -1
- package/src/meta/peer-deps.test.ts +39 -0
- package/src/module-interop.test.ts +65 -0
- package/src/module-interop.ts +39 -0
- package/src/op/discover.ts +3 -2
- package/src/op/op-waves-run.ts +184 -68
- package/src/wave-engine.test.ts +38 -1
- package/src/wave-engine.ts +101 -5
- package/src/wave-loop-golden.test.ts +627 -0
- package/src/workspace/lineage-migrations.ts +2 -1
package/src/op/op-waves-run.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
/**
|
|
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
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
//
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
}
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
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
|
-
/**
|
|
1264
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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 (
|
|
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 &&
|
|
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 (
|
|
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 ${
|
|
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
|
-
|
|
1418
|
-
|
|
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
|
/**
|
package/src/wave-engine.test.ts
CHANGED
|
@@ -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
|
});
|
package/src/wave-engine.ts
CHANGED
|
@@ -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
|
|
19
|
-
* commands, or each component's
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
+
}
|