@intentius/chant 0.124.15 → 0.124.16
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/cli/flush.d.ts +16 -0
- package/dist/cli/flush.d.ts.map +1 -0
- package/dist/cli/flush.js +42 -0
- package/dist/cli/handlers/approve.d.ts +98 -0
- package/dist/cli/handlers/approve.d.ts.map +1 -0
- package/dist/cli/handlers/approve.js +495 -0
- package/dist/cli/handlers/operator.d.ts +1 -95
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/operator.js +4 -488
- package/dist/cli/handlers/override-approve.js +1 -1
- package/dist/cli/main.d.ts +4 -15
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +4 -1062
- package/dist/cli/parse-args.d.ts +6 -0
- package/dist/cli/parse-args.d.ts.map +1 -0
- package/dist/cli/parse-args.js +1028 -0
- package/dist/components/fan-out-record.d.ts +1 -1
- package/dist/components/fan-out-record.d.ts.map +1 -1
- package/dist/components/fan-out-record.js +3 -7
- package/dist/components/fan-out-run.d.ts +5 -3
- package/dist/components/fan-out-run.d.ts.map +1 -1
- package/dist/components/fan-out-run.js +7 -5
- package/dist/gated-waves.d.ts +25 -6
- package/dist/gated-waves.d.ts.map +1 -1
- package/dist/gated-waves.js +21 -6
- package/dist/op/activities/activity-contracts.d.ts +4 -4
- package/dist/op/op-waves-run.d.ts +1 -1
- package/dist/op/op-waves-run.d.ts.map +1 -1
- package/dist/op/op-waves-run.js +5 -6
- package/dist/op/op-waves.d.ts.map +1 -1
- package/dist/op/op-waves.js +4 -8
- package/dist/package-version.d.ts +2 -0
- package/dist/package-version.d.ts.map +1 -0
- package/dist/package-version.js +10 -0
- package/dist/wave-engine.d.ts +62 -0
- package/dist/wave-engine.d.ts.map +1 -0
- package/dist/wave-engine.js +59 -0
- package/dist/wave-layout.d.ts +29 -0
- package/dist/wave-layout.d.ts.map +1 -0
- package/dist/wave-layout.js +41 -0
- package/dist/wave-runtime.d.ts +29 -0
- package/dist/wave-runtime.d.ts.map +1 -0
- package/dist/wave-runtime.js +103 -0
- package/dist/wave-runtime.mjs +331 -0
- package/dist/workspace/ajv2020.d.ts +5 -0
- package/dist/workspace/ajv2020.d.ts.map +1 -0
- package/dist/workspace/ajv2020.js +14 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.js +4 -8
- package/dist/workspace/kinds.d.ts.map +1 -1
- package/dist/workspace/kinds.js +2 -5
- package/dist/workspace/points.d.ts.map +1 -1
- package/dist/workspace/points.js +2 -5
- package/dist/workspace/principal-classes.d.ts.map +1 -1
- package/dist/workspace/principal-classes.js +2 -5
- package/package.json +5 -3
- package/src/cli/flush.ts +44 -0
- package/src/cli/handlers/approve.ts +618 -0
- package/src/cli/handlers/operator.ts +10 -608
- package/src/cli/handlers/override-approve.test.ts +1 -1
- package/src/cli/handlers/override-approve.ts +1 -1
- package/src/cli/main.ts +5 -816
- package/src/cli/parse-args.ts +781 -0
- package/src/components/fan-out-record.ts +3 -7
- package/src/components/fan-out-run.ts +7 -5
- package/src/gate-decision-bundle.test.ts +1 -0
- package/src/gated-waves.ts +28 -6
- package/src/op/op-waves-run.ts +5 -6
- package/src/op/op-waves.ts +4 -8
- package/src/package-version.ts +11 -0
- package/src/wave-engine.test.ts +107 -0
- package/src/wave-engine.ts +98 -0
- package/src/wave-layout.ts +44 -0
- package/src/wave-runtime-bundle.test.ts +70 -0
- package/src/wave-runtime.e2e.test.ts +177 -0
- package/src/wave-runtime.ts +104 -0
- package/src/workspace/ajv2020.ts +17 -0
- package/src/workspace/declaration.ts +4 -10
- package/src/workspace/kinds.ts +2 -5
- package/src/workspace/points.ts +2 -5
- package/src/workspace/principal-classes.ts +2 -5
|
@@ -27,9 +27,8 @@
|
|
|
27
27
|
* per-wave CI job hands to the next.
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
|
-
import {
|
|
31
|
-
import {
|
|
32
|
-
import { readWaveRecords, type WaveRecord } from "../gated-waves";
|
|
30
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
31
|
+
import { readWaveRecords, writeWaveRecordFile, type WaveRecord } from "../wave-engine";
|
|
33
32
|
import type { ChangeSetDocument } from "../change-set";
|
|
34
33
|
import type { PrMember } from "../pr-loop";
|
|
35
34
|
|
|
@@ -126,8 +125,5 @@ export function readFanOutAttempt(path: string): FanOutAttempt | undefined {
|
|
|
126
125
|
* write leaves the previous record whole rather than half a JSON document.
|
|
127
126
|
*/
|
|
128
127
|
export function writeFanOutAttempt(path: string, attempt: FanOutAttempt): void {
|
|
129
|
-
|
|
130
|
-
const temporary = `${path}.${process.pid}.tmp`;
|
|
131
|
-
writeFileSync(temporary, JSON.stringify(attempt, null, 2) + "\n");
|
|
132
|
-
renameSync(temporary, path);
|
|
128
|
+
writeWaveRecordFile(path, attempt);
|
|
133
129
|
}
|
|
@@ -31,8 +31,10 @@
|
|
|
31
31
|
* ## Or one approval per wave
|
|
32
32
|
*
|
|
33
33
|
* `waveGate` (#3049) is the other mode. Each wave is planned once the waves
|
|
34
|
-
* before it applied, and its gate binds that wave's set digest
|
|
35
|
-
*
|
|
34
|
+
* before it applied, and its gate binds that wave's set digest. Gate names,
|
|
35
|
+
* the digest and the gate decision come from the wave engine
|
|
36
|
+
* (`../wave-engine.ts`, #3766), which `chant run wave` uses too. See
|
|
37
|
+
* {@link runGatedWaves}.
|
|
36
38
|
*/
|
|
37
39
|
|
|
38
40
|
import {
|
|
@@ -50,7 +52,7 @@ import type { CapabilityRegistry } from "./capability";
|
|
|
50
52
|
import type { PendingGateRecord } from "../lifecycle/gate-ledger";
|
|
51
53
|
import type { DeployContext } from "./capability";
|
|
52
54
|
import { planWaveComponent, type WaveComponentPlan } from "./wave-plan";
|
|
53
|
-
import { waveGateName, waveSetDigest, type WaveMember, type WaveRecord } from "../
|
|
55
|
+
import { decideWaveGate, waveGateName, waveSetDigest, type WaveMember, type WaveRecord } from "../wave-engine";
|
|
54
56
|
|
|
55
57
|
/**
|
|
56
58
|
* One gate per wave (#3049), each bound to its wave's set digest. The other
|
|
@@ -452,10 +454,10 @@ async function runGatedWaves(
|
|
|
452
454
|
continue;
|
|
453
455
|
}
|
|
454
456
|
|
|
455
|
-
const check = await
|
|
457
|
+
const check = await decideWaveGate(gates.port, {
|
|
456
458
|
op: waveGate.op,
|
|
457
459
|
gate: gateName,
|
|
458
|
-
|
|
460
|
+
digest,
|
|
459
461
|
description: waveGate.description ?? `wave ${waveNum} of ${plan.waves.length}: ${toRun.join(", ")}`,
|
|
460
462
|
...(waveGate.timeout ? { timeout: waveGate.timeout } : {}),
|
|
461
463
|
...(options.now ? { now: options.now } : {}),
|
|
@@ -46,6 +46,7 @@ describe("the gate-decision bundle", () => {
|
|
|
46
46
|
"packages/core/src/notify.ts",
|
|
47
47
|
"packages/core/src/op/gate-point.ts",
|
|
48
48
|
"packages/core/src/op/op-waves.ts",
|
|
49
|
+
"packages/core/src/wave-layout.ts",
|
|
49
50
|
"packages/core/src/workspace/class-data.ts",
|
|
50
51
|
"packages/core/src/workspace/identity-gate-rule.ts",
|
|
51
52
|
"packages/core/src/workspace/jsonc.ts",
|
package/src/gated-waves.ts
CHANGED
|
@@ -2,12 +2,17 @@
|
|
|
2
2
|
* Gated waves (#3049): one change rolled out across many roots, a wave at a
|
|
3
3
|
* time, each wave behind its own gate bound to that wave's set digest.
|
|
4
4
|
*
|
|
5
|
-
* `chant components fan-out --wave-gate`
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
5
|
+
* Two runners use it (#3766): `chant components fan-out --wave-gate` and the
|
|
6
|
+
* Op waves spec's `chant run wave` (`./op/op-waves-run.ts`). This module
|
|
7
|
+
* holds the parts that need no runner: how a set of roots is cut into waves,
|
|
8
|
+
* what a wave's digest is and which members it covers, what its gate is
|
|
9
|
+
* called, and what the attempt record says about each wave. A gate name
|
|
10
|
+
* pattern and the share split of a wide wave are in `./wave-layout.ts`;
|
|
11
|
+
* `./wave-engine.ts` puts them together with the gate decision call and the
|
|
12
|
+
* record file writer, which need the gate ledger and the filesystem.
|
|
13
|
+
*
|
|
14
|
+
* They are kept apart from the runners because terragucci calls them from
|
|
15
|
+
* its CI stages as one bundled file with no TypeScript toolchain (#3421). Everything here imports `./change-set` and nothing else,
|
|
11
16
|
* and `gated-waves-bundle.test.ts` holds that.
|
|
12
17
|
*
|
|
13
18
|
* ## The set digest
|
|
@@ -144,6 +149,23 @@ export function waveGateName(gate: string, wave: number): string {
|
|
|
144
149
|
return `${gate}-wave-${wave}`;
|
|
145
150
|
}
|
|
146
151
|
|
|
152
|
+
/** Which members a wave's set digest covers (#3821). */
|
|
153
|
+
export type WaveDigestOver = "all" | "changing";
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The members a wave's set digest is taken over (#3821, #3766). `all` (the
|
|
157
|
+
* default): every member. `changing`: the members whose plan is not
|
|
158
|
+
* `empty`, or every member when all of them are empty. `extras`, members
|
|
159
|
+
* that are no run (such as a wave's cost), are always in it.
|
|
160
|
+
*/
|
|
161
|
+
export function waveDigestMembers<M extends WaveMember & { empty?: boolean }>(
|
|
162
|
+
members: readonly M[],
|
|
163
|
+
options: { over?: WaveDigestOver; extras?: readonly WaveMember[] } = {},
|
|
164
|
+
): WaveMember[] {
|
|
165
|
+
const changing = options.over === "changing" ? members.filter((m) => m.empty !== true) : [];
|
|
166
|
+
return [...(changing.length > 0 ? changing : members), ...(options.extras ?? [])];
|
|
167
|
+
}
|
|
168
|
+
|
|
147
169
|
/** Where a wave got to. */
|
|
148
170
|
export type WaveStatus =
|
|
149
171
|
/** Planned, and its gate is not approved for this digest. Nothing in it ran. */
|
package/src/op/op-waves-run.ts
CHANGED
|
@@ -51,7 +51,7 @@
|
|
|
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 { waveSetDigest, type WaveMember } from "../
|
|
54
|
+
import { decideWaveGate, waveDigestMembers, waveSetDigest, writeWaveRecordFile, type WaveMember } from "../wave-engine";
|
|
55
55
|
import type { ReviewApproval } from "../gate-decision";
|
|
56
56
|
import { approveCommand, evaluateGate, gitGateLedgerPort, type GateCheck, type GateDigestMismatch, type GateLedgerPort } from "./gate";
|
|
57
57
|
import type { GateAnswerRef, PendingGateRecord } from "../lifecycle/gate-ledger";
|
|
@@ -817,8 +817,7 @@ function pendingFields(value: unknown, where: string): Record<string, unknown> |
|
|
|
817
817
|
* file's members that are no run (a cost), are always in it.
|
|
818
818
|
*/
|
|
819
819
|
export function opWaveSetDigest(spec: Pick<OpWavesSpec, "digest">, members: ReadonlyArray<WaveMember & { empty?: boolean }>, extras: readonly WaveMember[] = []): string {
|
|
820
|
-
|
|
821
|
-
return waveSetDigest([...(changing.length > 0 ? changing : members), ...extras]);
|
|
820
|
+
return waveSetDigest(waveDigestMembers(members, { ...(spec.digest?.over ? { over: spec.digest.over } : {}), extras }));
|
|
822
821
|
}
|
|
823
822
|
|
|
824
823
|
/** What one moment's hooks came to. */
|
|
@@ -1627,10 +1626,10 @@ async function settleGate(options: RunOpWaveOptions, ctx: WaveContext, decision:
|
|
|
1627
1626
|
// #3784: under the spend rule at base, an approval a wave already applied under counts for nothing.
|
|
1628
1627
|
const spent: AppliedGateRecord[] | undefined = decision.spend === "base" ? ((await ledger.readApplied?.(spec.name)) ?? []).filter((a) => a.gate === gate) : undefined;
|
|
1629
1628
|
const held = decision.heldBy?.length ? `, held by ${decision.heldBy.join(", ")}` : "";
|
|
1630
|
-
const check = await
|
|
1629
|
+
const check = await decideWaveGate(port, {
|
|
1631
1630
|
op: spec.name,
|
|
1632
1631
|
gate,
|
|
1633
|
-
|
|
1632
|
+
digest,
|
|
1634
1633
|
description: (typeof decision.pending?.description === "string" ? decision.pending.description : undefined) ?? `wave ${k} (${wave.name}): ${members.length} run${members.length === 1 ? "" : "s"}, gate ${policy}, approval ${approval}${held}`,
|
|
1635
1634
|
now,
|
|
1636
1635
|
...(spent ? { spent } : {}),
|
|
@@ -1728,7 +1727,7 @@ async function settlePointGate(options: RunOpWaveOptions, ctx: WaveContext, deci
|
|
|
1728
1727
|
/** Write the decision record, with every hook's outcome so far (#3804). */
|
|
1729
1728
|
function writeRecord(recordPath: string, decision: OpWaveDecision, result: RunOpWaveResult): void {
|
|
1730
1729
|
if (result.hooks.length > 0) decision.outcomes = [...result.hooks];
|
|
1731
|
-
|
|
1730
|
+
writeWaveRecordFile(recordPath, decision);
|
|
1732
1731
|
}
|
|
1733
1732
|
|
|
1734
1733
|
/**
|
package/src/op/op-waves.ts
CHANGED
|
@@ -69,6 +69,7 @@
|
|
|
69
69
|
import type { OpEnvironment, OpSetupStep } from "../lexicon";
|
|
70
70
|
import { gatePointProblems, type GatePoint } from "./gate-point";
|
|
71
71
|
import { notifyConfigProblem, type NotifyConfig } from "../notify";
|
|
72
|
+
import { WAVE_GATE_NAME_PATTERN, waveGateNameFrom, waveShare } from "../wave-layout";
|
|
72
73
|
|
|
73
74
|
/**
|
|
74
75
|
* The variables a wave job reads its notify secrets from, for the lexicons
|
|
@@ -361,11 +362,11 @@ export interface OpWavesDigest {
|
|
|
361
362
|
export const OP_WAVES_DIGEST_OVER = ["all", "changing"] as const;
|
|
362
363
|
|
|
363
364
|
/** The default gate name pattern. */
|
|
364
|
-
export const OP_WAVE_DEFAULT_GATE_NAME =
|
|
365
|
+
export const OP_WAVE_DEFAULT_GATE_NAME = WAVE_GATE_NAME_PATTERN;
|
|
365
366
|
|
|
366
367
|
/** The gate wave `k` waits on (#3821): the spec's `gateName` pattern, by default `<gate>-wave-<k>`. */
|
|
367
368
|
export function opWaveGateName(spec: Pick<OpWavesSpec, "name" | "gate" | "gateName">, k: number): string {
|
|
368
|
-
return (spec.gateName
|
|
369
|
+
return waveGateNameFrom(spec.gateName, spec.gate ?? spec.name, k);
|
|
369
370
|
}
|
|
370
371
|
|
|
371
372
|
/** Where a wave reads who may override a policy denial (#3821). */
|
|
@@ -638,12 +639,7 @@ export function opWaveApplyHooksRunBy(spec: Pick<OpWavesSpec, "hooks" | "waves">
|
|
|
638
639
|
* by target, cut into contiguous slices whose sizes differ by at most one.
|
|
639
640
|
*/
|
|
640
641
|
export function opWaveShare<T extends { target: string }>(runs: readonly T[], share: number, shares: number): T[] {
|
|
641
|
-
|
|
642
|
-
const base = Math.floor(sorted.length / shares);
|
|
643
|
-
const extra = sorted.length % shares;
|
|
644
|
-
const start = (share - 1) * base + Math.min(share - 1, extra);
|
|
645
|
-
const size = base + (share <= extra ? 1 : 0);
|
|
646
|
-
return sorted.slice(start, start + size);
|
|
642
|
+
return waveShare(runs, share, shares, (run) => run.target);
|
|
647
643
|
}
|
|
648
644
|
|
|
649
645
|
/**
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@intentius/chant`'s own version, read from its package.json as a file. The
|
|
3
|
+
* path is the same from `src/` and `dist/`. The slim wave-job runtime (#3896)
|
|
4
|
+
* has no package.json beside it, and its build writes the version in here.
|
|
5
|
+
*/
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
|
|
9
|
+
export function readChantPackageVersion(): string {
|
|
10
|
+
return (JSON.parse(readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf-8")) as { version: string }).version;
|
|
11
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One wave engine under both runners (#3766): the same runs, planned to the
|
|
3
|
+
* same digests, are the same set digest and the same gate name whether
|
|
4
|
+
* `chant run wave` (an Op waves spec) or `chant components fan-out
|
|
5
|
+
* --wave-gate` decides the wave. An approval recorded through one matches a
|
|
6
|
+
* wave the other plans.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { describe, expect, test } from "vitest";
|
|
13
|
+
import { memoryGateLedgerPort } from "./op/gate";
|
|
14
|
+
import { runOpWave, type OpWaveExec, type OpWaveShowAtBase } from "./op/op-waves-run";
|
|
15
|
+
import { opWaveGateName, opWaveShare, type OpWavesSpec } from "./op/op-waves";
|
|
16
|
+
import { planFanOut } from "./components/fan-out";
|
|
17
|
+
import { runFanOut } from "./components/fan-out-run";
|
|
18
|
+
import { CapabilityRegistry } from "./components/capability";
|
|
19
|
+
import type { DriverComponent } from "./components/driver";
|
|
20
|
+
import { waveGateName, waveGateNameFrom, waveSetDigest, waveShare, WAVE_GATE_NAME_PATTERN } from "./wave-engine";
|
|
21
|
+
|
|
22
|
+
const digest = (n: string) => `sha256:${n.repeat(64)}`;
|
|
23
|
+
/** The runs both runners plan: a member name and its plan digest. */
|
|
24
|
+
const RUNS: Record<string, string> = { "tenant-b": digest("b"), "tenant-a": digest("a"), "tenant-c": digest("c") };
|
|
25
|
+
const NOW = "2026-10-11T00:00:00Z";
|
|
26
|
+
|
|
27
|
+
async function throughOpWaves(): Promise<{ digest: string; gate: string; pending: string | undefined }> {
|
|
28
|
+
const spec: OpWavesSpec = {
|
|
29
|
+
name: "rollout",
|
|
30
|
+
op: "migrate",
|
|
31
|
+
plan: ["tool", "plan", "{target}", "--out", "{plan}"],
|
|
32
|
+
apply: ["tool", "apply", "{target}", "--plan", "{plan}"],
|
|
33
|
+
waves: [{ name: "tenants", runs: Object.keys(RUNS).map((target) => ({ target })) }],
|
|
34
|
+
};
|
|
35
|
+
const cwd = mkdtempSync(join(tmpdir(), "wave-engine-"));
|
|
36
|
+
try {
|
|
37
|
+
const exec: OpWaveExec = (argv, dir) => {
|
|
38
|
+
if (argv[1] === "plan") writeFileSync(join(dir, argv[4]!), JSON.stringify({ planDigest: RUNS[argv[2]!] }));
|
|
39
|
+
return 0;
|
|
40
|
+
};
|
|
41
|
+
const show: OpWaveShowAtBase = () => ({ sha: "b".repeat(40), text: JSON.stringify(spec) });
|
|
42
|
+
const gates = memoryGateLedgerPort();
|
|
43
|
+
const result = await runOpWave({ spec, specFile: "waves.json", wave: 1, cwd, exec, show, gates, now: NOW });
|
|
44
|
+
expect(result.exitCode).toBe(3);
|
|
45
|
+
return { digest: result.decision!.digest, gate: result.decision!.gate, pending: gates.appended[0]?.planDigest };
|
|
46
|
+
} finally {
|
|
47
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function throughFanOut(): Promise<{ digest: string; gate: string; pending: string | undefined }> {
|
|
52
|
+
const components: DriverComponent[] = Object.keys(RUNS).map((name) => ({ name, deploy: [{ phase: "Apply", steps: [] }] }));
|
|
53
|
+
const plan = planFanOut({ components, changed: Object.keys(RUNS) });
|
|
54
|
+
const gates = memoryGateLedgerPort();
|
|
55
|
+
const result = await runFanOut(plan, components, new CapabilityRegistry(), {
|
|
56
|
+
env: "prod",
|
|
57
|
+
gates,
|
|
58
|
+
now: NOW,
|
|
59
|
+
waveGate: {
|
|
60
|
+
op: "rollout",
|
|
61
|
+
gate: "rollout",
|
|
62
|
+
planComponent: async (component) => ({
|
|
63
|
+
component: component.name,
|
|
64
|
+
members: [{ member: component.name, planDigest: RUNS[component.name]! }],
|
|
65
|
+
plans: {},
|
|
66
|
+
parts: [],
|
|
67
|
+
}),
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
expect(result.status).toBe("gated");
|
|
71
|
+
const wave = result.waves![0]!;
|
|
72
|
+
return { digest: wave.digest, gate: wave.gate, pending: gates.appended[0]?.planDigest };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
describe("one wave engine (#3766)", () => {
|
|
76
|
+
test("the same runs bind the same set digest and gate through both runners", async () => {
|
|
77
|
+
const expected = waveSetDigest(Object.entries(RUNS).map(([member, planDigest]) => ({ member, planDigest })));
|
|
78
|
+
const op = await throughOpWaves();
|
|
79
|
+
const fan = await throughFanOut();
|
|
80
|
+
expect(op.digest).toBe(expected);
|
|
81
|
+
expect(fan.digest).toBe(expected);
|
|
82
|
+
expect(op.pending).toBe(expected);
|
|
83
|
+
expect(fan.pending).toBe(expected);
|
|
84
|
+
expect(op.gate).toBe("rollout-wave-1");
|
|
85
|
+
expect(fan.gate).toBe(op.gate);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("gate names: the default pattern is waveGateName's name, and a spec's pattern still wins", () => {
|
|
89
|
+
for (const k of [1, 2, 12]) {
|
|
90
|
+
expect(waveGateNameFrom(undefined, "deploy", k)).toBe(waveGateName("deploy", k));
|
|
91
|
+
expect(waveGateNameFrom(WAVE_GATE_NAME_PATTERN, "deploy", k)).toBe(`deploy-wave-${k}`);
|
|
92
|
+
expect(opWaveGateName({ name: "deploy" }, k)).toBe(waveGateName("deploy", k));
|
|
93
|
+
}
|
|
94
|
+
expect(opWaveGateName({ name: "x", gate: "tg", gateName: "wave-{k}" }, 3)).toBe("wave-3");
|
|
95
|
+
expect(opWaveGateName({ name: "x", gateName: "{gate}/{k}" }, 3)).toBe("x/3");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("the share split is the Op waves split by target", () => {
|
|
99
|
+
const runs = ["t5", "t1", "t4", "t2", "t3"].map((target) => ({ target }));
|
|
100
|
+
for (const shares of [1, 2, 3, 5]) {
|
|
101
|
+
for (let share = 1; share <= shares; share++) {
|
|
102
|
+
expect(waveShare(runs, share, shares, (r) => r.target)).toEqual(opWaveShare(runs, share, shares));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
expect(opWaveShare(runs, 1, 2).map((r) => r.target)).toEqual(["t1", "t2", "t3"]);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The wave engine (#3766): what `chant run wave` (the Op waves spec,
|
|
3
|
+
* `./op/op-waves-run.ts`) and `chant components fan-out --wave-gate`
|
|
4
|
+
* (`./components/fan-out-run.ts`) share once a wave is planned.
|
|
5
|
+
*
|
|
6
|
+
* - Gate naming: {@link waveGateName} and {@link waveGateNameFrom}.
|
|
7
|
+
* - The set digest: {@link waveSetDigest} over {@link waveDigestMembers}.
|
|
8
|
+
* - The share split of a wide wave: {@link waveShare}.
|
|
9
|
+
* - The gate decision call: {@link decideWaveGate}.
|
|
10
|
+
* - The record file a wave job hands the next one: {@link writeWaveRecordFile}.
|
|
11
|
+
*
|
|
12
|
+
* The parts that need neither the gate ledger nor the filesystem live in
|
|
13
|
+
* `./gated-waves.ts`, which terragucci bundles on its own (#3421), and
|
|
14
|
+
* `./wave-layout.ts`, which imports nothing so the Op waves spec module can
|
|
15
|
+
* use it without the change-set code; this module re-exports both so a
|
|
16
|
+
* runner imports the engine from one place.
|
|
17
|
+
*
|
|
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.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { dirname } from "node:path";
|
|
26
|
+
import { mkdirSync, renameSync, writeFileSync } from "node:fs";
|
|
27
|
+
import { evaluateGate, type GateCheck, type GateLedgerPort } from "./op/gate";
|
|
28
|
+
import type { AppliedGateRecord } from "./lifecycle/gate-applied";
|
|
29
|
+
|
|
30
|
+
export { WAVE_GATE_NAME_PATTERN, waveGateNameFrom, waveShare } from "./wave-layout";
|
|
31
|
+
export {
|
|
32
|
+
layerWaves,
|
|
33
|
+
waveGateName,
|
|
34
|
+
waveDigestMembers,
|
|
35
|
+
waveSetDigest,
|
|
36
|
+
readWaveRecords,
|
|
37
|
+
withWaveRecord,
|
|
38
|
+
describeChangedWave,
|
|
39
|
+
type WaveDigestOver,
|
|
40
|
+
type WaveMember,
|
|
41
|
+
type WaveNode,
|
|
42
|
+
type WaveRecord,
|
|
43
|
+
type WaveStatus,
|
|
44
|
+
} from "./gated-waves";
|
|
45
|
+
|
|
46
|
+
/** What a wave asks its gate. */
|
|
47
|
+
export interface WaveGateRequest {
|
|
48
|
+
/** The op the gate is recorded under: the fan-out's op, or the Op waves spec's name. */
|
|
49
|
+
op: string;
|
|
50
|
+
/** The wave's full gate name. */
|
|
51
|
+
gate: string;
|
|
52
|
+
/** The wave's set digest. */
|
|
53
|
+
digest: string;
|
|
54
|
+
/** The pending fact's description. */
|
|
55
|
+
description: string;
|
|
56
|
+
/** The pending fact's expiry, as a gate's `timeout`. */
|
|
57
|
+
timeout?: string;
|
|
58
|
+
now?: string;
|
|
59
|
+
/** Approvals a wave already applied under, which count for nothing (#3784). */
|
|
60
|
+
spent?: readonly AppliedGateRecord[];
|
|
61
|
+
/** Fields the pending fact carries (#3821). */
|
|
62
|
+
fields?: Record<string, unknown>;
|
|
63
|
+
/** The pending fact carries `neverOverMcp` (#3821). */
|
|
64
|
+
neverOverMcp?: boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Decide a wave's gate: an approval of `digest` under `gate` on `op`'s
|
|
69
|
+
* ledger, or the pending fact that stands for it. A field left out of the
|
|
70
|
+
* request is left out of the call, so each entry point's pending facts are
|
|
71
|
+
* the ones it always wrote.
|
|
72
|
+
*/
|
|
73
|
+
export function decideWaveGate(port: GateLedgerPort, request: WaveGateRequest): Promise<GateCheck> {
|
|
74
|
+
return evaluateGate(port, {
|
|
75
|
+
op: request.op,
|
|
76
|
+
gate: request.gate,
|
|
77
|
+
planDigest: request.digest,
|
|
78
|
+
description: request.description,
|
|
79
|
+
...(request.timeout ? { timeout: request.timeout } : {}),
|
|
80
|
+
...(request.now !== undefined ? { now: request.now } : {}),
|
|
81
|
+
...(request.spent ? { spent: request.spent } : {}),
|
|
82
|
+
...(request.fields ? { fields: request.fields } : {}),
|
|
83
|
+
...(request.neverOverMcp ? { neverOverMcp: true } : {}),
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Write a wave record file as indented JSON, through a temporary file and a
|
|
89
|
+
* rename, so a job killed during the write leaves the previous record whole.
|
|
90
|
+
* The fan-out attempt record and an Op wave's decision record both go
|
|
91
|
+
* through it.
|
|
92
|
+
*/
|
|
93
|
+
export function writeWaveRecordFile(path: string, value: unknown): void {
|
|
94
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
95
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
96
|
+
writeFileSync(temporary, JSON.stringify(value, null, 2) + "\n");
|
|
97
|
+
renameSync(temporary, path);
|
|
98
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wave gate names and the share split of a wide wave (#3766), the parts of
|
|
3
|
+
* the wave engine (`./wave-engine.ts`) that the Op waves spec module
|
|
4
|
+
* (`./op/op-waves.ts`) needs. That module is in the gate-decision bundle
|
|
5
|
+
* (`./gate-decision.ts` re-exports a constant from it), so this imports
|
|
6
|
+
* nothing and keeps the change-set digest code out of that bundle.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** The default wave gate name pattern: `{gate}` is the base gate name, `{k}` the wave's number. */
|
|
10
|
+
export const WAVE_GATE_NAME_PATTERN = "{gate}-wave-{k}";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The gate wave `k` waits on under a name pattern (#3821). With the default
|
|
14
|
+
* pattern this is `waveGateName(gate, k)` from `./gated-waves.ts`; an Op
|
|
15
|
+
* waves spec's `gateName` names another (such as `"wave-{k}"`) so approvals
|
|
16
|
+
* already on the ledger under a product's old names keep matching.
|
|
17
|
+
*
|
|
18
|
+
* `{gate}` is filled in before `{k}`, as `opWaveGateName` always did, so a
|
|
19
|
+
* base gate name holding the text `{k}` is filled in too. That is the one
|
|
20
|
+
* input on which this and `waveGateName` differ, and each entry point keeps
|
|
21
|
+
* the one it shipped with.
|
|
22
|
+
*/
|
|
23
|
+
export function waveGateNameFrom(pattern: string | undefined, gate: string, k: number): string {
|
|
24
|
+
return (pattern ?? WAVE_GATE_NAME_PATTERN).replaceAll("{gate}", gate).replaceAll("{k}", String(k));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Share `share` (1-based) of `shares` of a wide wave (#3679): the items
|
|
29
|
+
* sorted by `keyOf`, cut into contiguous slices whose sizes differ by at most
|
|
30
|
+
* one. The deciding job and every share job cut the same way, so each item
|
|
31
|
+
* is applied by exactly one share.
|
|
32
|
+
*/
|
|
33
|
+
export function waveShare<T>(items: readonly T[], share: number, shares: number, keyOf: (item: T) => string): T[] {
|
|
34
|
+
const sorted = [...items].sort((a, b) => {
|
|
35
|
+
const x = keyOf(a);
|
|
36
|
+
const y = keyOf(b);
|
|
37
|
+
return x < y ? -1 : x > y ? 1 : 0;
|
|
38
|
+
});
|
|
39
|
+
const base = Math.floor(sorted.length / shares);
|
|
40
|
+
const extra = sorted.length % shares;
|
|
41
|
+
const start = (share - 1) * base + Math.min(share - 1, extra);
|
|
42
|
+
const size = base + (share <= extra ? 1 : 0);
|
|
43
|
+
return sorted.slice(start, start + size);
|
|
44
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The wave-job runtime bundles to one self-contained file (#3896). A product
|
|
3
|
+
* copies `dist/wave-runtime.mjs` into its CI image with no npm install of
|
|
4
|
+
* chant, so the file may import nothing but `node:` builtins, and it carries
|
|
5
|
+
* no TypeScript compiler, esbuild or tsx: those three are the 33 MB a wave
|
|
6
|
+
* job's image has no use for. This builds it the way `npm run build` does and
|
|
7
|
+
* reads esbuild's own list of what went in.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { builtinModules } from "node:module";
|
|
11
|
+
import { describe, expect, test } from "vitest";
|
|
12
|
+
// @ts-expect-error: a plain .mjs script with no type declarations
|
|
13
|
+
import { buildWaveRuntime, LEFT_OUT } from "../scripts/build-wave-runtime.mjs";
|
|
14
|
+
|
|
15
|
+
interface Metafile {
|
|
16
|
+
inputs: Record<string, { bytes: number; imports: { path: string; kind: string; external?: boolean }[] }>;
|
|
17
|
+
outputs: Record<string, { imports: { path: string; kind: string; external?: boolean }[]; inputs: Record<string, { bytesInOutput: number }> }>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const built = (await buildWaveRuntime({ write: false })) as { bytes: number; metafile: Metafile; text: string };
|
|
21
|
+
const output = Object.values(built.metafile.outputs)[0]!;
|
|
22
|
+
const inputs = Object.keys(output.inputs);
|
|
23
|
+
|
|
24
|
+
describe("the wave-job runtime bundle (#3896)", () => {
|
|
25
|
+
test("leaves out the TypeScript compiler, esbuild and tsx", () => {
|
|
26
|
+
expect(LEFT_OUT).toEqual(["typescript", "esbuild", "tsx"]);
|
|
27
|
+
for (const name of LEFT_OUT as string[]) {
|
|
28
|
+
expect(inputs.filter((i) => i.includes(`node_modules/${name}/`)), name).toEqual([]);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("imports nothing but node: builtins, so it runs with no node_modules", () => {
|
|
33
|
+
const builtins = new Set(builtinModules);
|
|
34
|
+
const imported = output.imports.map((i) => i.path);
|
|
35
|
+
expect(imported.filter((p) => !builtins.has(p.replace(/^node:/, "").split("/")[0]!))).toEqual([]);
|
|
36
|
+
// zod, ajv, js-yaml and picomatch are inlined; nothing reads them from disk.
|
|
37
|
+
for (const pkg of ["zod", "ajv", "js-yaml", "picomatch"]) {
|
|
38
|
+
expect(inputs.some((i) => i.includes(`node_modules/${pkg}/`)), pkg).toBe(true);
|
|
39
|
+
}
|
|
40
|
+
expect(built.text).not.toMatch(/createRequire\(import\.meta\.url\)\("\.\.\/\.\.\/package\.json"\)/);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("a left-out package is reached only through import(), never on start", () => {
|
|
44
|
+
// The build refuses otherwise; this pins the four commands' own modules as the static graph.
|
|
45
|
+
const entry = Object.keys(built.metafile.inputs).find((k) => k.endsWith("src/wave-runtime.ts"))!;
|
|
46
|
+
const seen = new Set([entry]);
|
|
47
|
+
const queue = [entry];
|
|
48
|
+
while (queue.length) {
|
|
49
|
+
for (const im of built.metafile.inputs[queue.shift()!]?.imports ?? []) {
|
|
50
|
+
if (im.kind === "dynamic-import" || seen.has(im.path)) continue;
|
|
51
|
+
seen.add(im.path);
|
|
52
|
+
queue.push(im.path);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
for (const handler of ["cli/handlers/run-wave.ts", "cli/handlers/gate-resume.ts", "cli/handlers/approve.ts", "workspace/points-cli.ts", "cli/parse-args.ts"]) {
|
|
56
|
+
expect([...seen].some((k) => k.endsWith(`src/${handler}`)), handler).toBe(true);
|
|
57
|
+
}
|
|
58
|
+
expect([...seen].filter((k) => k.startsWith("chant-wave-runtime-left-out:"))).toEqual([]);
|
|
59
|
+
// Neither the full CLI's dispatch nor the operator loop is in it, and the build and plugin loader load on no start.
|
|
60
|
+
expect(inputs.filter((i) => /src\/cli\/main\.ts$|src\/cli\/handlers\/operator\.ts$/.test(i))).toEqual([]);
|
|
61
|
+
expect([...seen].filter((i) => /src\/cli\/plugins\.ts$|src\/build\.ts$/.test(i))).toEqual([]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("carries its version and stays small", () => {
|
|
65
|
+
expect(built.text).toContain('"workspace points"');
|
|
66
|
+
expect(built.text.startsWith("#!/usr/bin/env node\n")).toBe(true);
|
|
67
|
+
// About 1.45 MB minified today (440 KB gzipped), zod's inlined schemas about 300 KB of it.
|
|
68
|
+
expect(built.bytes).toBeLessThan(2 * 1024 * 1024);
|
|
69
|
+
});
|
|
70
|
+
});
|