@intentius/chant 0.114.1 → 0.116.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/run-generate.d.ts.map +1 -1
- package/dist/cli/handlers/run-wave.d.ts +7 -0
- package/dist/cli/handlers/run-wave.d.ts.map +1 -0
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +5 -1
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/deep-observation.d.ts +8 -0
- package/dist/deep-observation.d.ts.map +1 -1
- package/dist/lexicon.d.ts +22 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/deep-diff.d.ts +6 -0
- package/dist/lifecycle/deep-diff.d.ts.map +1 -1
- package/dist/lifecycle/deep-observe.d.ts.map +1 -1
- package/dist/op/activity-registry.d.ts +13 -1
- package/dist/op/activity-registry.d.ts.map +1 -1
- package/dist/op/builders.d.ts +2 -1
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/generate-pipeline.d.ts +15 -1
- package/dist/op/generate-pipeline.d.ts.map +1 -1
- package/dist/op/index.d.ts +6 -2
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/op-waves-run.d.ts +115 -0
- package/dist/op/op-waves-run.d.ts.map +1 -0
- package/dist/op/op-waves.d.ts +139 -0
- package/dist/op/op-waves.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/cli/handlers/lifecycle.ts +2 -1
- package/src/cli/handlers/run-generate.ts +51 -1
- package/src/cli/handlers/run-wave.ts +43 -0
- package/src/cli/main.ts +14 -0
- package/src/cli/registry.ts +5 -1
- package/src/deep-observation.ts +8 -0
- package/src/lexicon.ts +26 -0
- package/src/lifecycle/deep-diff.test.ts +20 -1
- package/src/lifecycle/deep-diff.ts +7 -0
- package/src/lifecycle/deep-observe.ts +2 -0
- package/src/op/activity-registry.test.ts +21 -0
- package/src/op/activity-registry.ts +25 -5
- package/src/op/builders.ts +2 -1
- package/src/op/generate-pipeline.ts +49 -0
- package/src/op/index.ts +13 -2
- package/src/op/op-waves-run.ts +325 -0
- package/src/op/op-waves.test.ts +95 -0
- package/src/op/op-waves.ts +224 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What an Op waves job runs (#3679): `chant run wave --spec <file> --wave <k>`.
|
|
3
|
+
* `./op-waves.ts` holds the spec and the jobs; this plans a wave's runs,
|
|
4
|
+
* decides its gate and applies it.
|
|
5
|
+
*
|
|
6
|
+
* - Plan: each run's `plan` command writes a plan file; its `planDigest` is
|
|
7
|
+
* the run's member digest, named by its target.
|
|
8
|
+
* - Decide: the wave's policy comes from the spec file at the base commit
|
|
9
|
+
* (`git show <base>:<file>`), so a change cannot loosen its own gate. A
|
|
10
|
+
* wave missing there, or a file missing there, is `always`. A wave that
|
|
11
|
+
* needs an approval waits on `<gate>-wave-<k>` bound to
|
|
12
|
+
* {@link waveSetDigest} over its members, and exits 3 when nobody approved
|
|
13
|
+
* that digest.
|
|
14
|
+
* - Apply: each run's `apply` command. A share job (`--share i`) re-plans its
|
|
15
|
+
* slice first and refuses, without applying it, a run whose plan digest is
|
|
16
|
+
* not the one the deciding job recorded (exit 4).
|
|
17
|
+
*/
|
|
18
|
+
import { type WaveMember } from "../gated-waves.js";
|
|
19
|
+
import { type GateLedgerPort } from "./gate.js";
|
|
20
|
+
import { type OpWaveGatePolicy, type OpWavesSpec } from "./op-waves.js";
|
|
21
|
+
/** What a run's plan command writes to `{plan}`. */
|
|
22
|
+
export interface OpWavePlanFile {
|
|
23
|
+
planDigest: string;
|
|
24
|
+
/** The plan destroys, rewrites or otherwise needs a person under `on-destructive`. */
|
|
25
|
+
destructive?: boolean;
|
|
26
|
+
/** The plan changes nothing. */
|
|
27
|
+
empty?: boolean;
|
|
28
|
+
}
|
|
29
|
+
/** One run's plan, as the decision records it. */
|
|
30
|
+
export interface OpWaveMember extends WaveMember {
|
|
31
|
+
op: string;
|
|
32
|
+
destructive?: boolean;
|
|
33
|
+
empty?: boolean;
|
|
34
|
+
}
|
|
35
|
+
/** Where a wave's policy came from. */
|
|
36
|
+
export type OpWavePolicySource = "base" | "absent-at-base";
|
|
37
|
+
/** A wave's decision: what was planned, the policy, and whether it may apply. */
|
|
38
|
+
export interface OpWaveDecision {
|
|
39
|
+
wave: number;
|
|
40
|
+
name: string;
|
|
41
|
+
/** The op the gate is recorded under: the spec's name. */
|
|
42
|
+
op: string;
|
|
43
|
+
gate: string;
|
|
44
|
+
policy: OpWaveGatePolicy;
|
|
45
|
+
policySource: OpWavePolicySource;
|
|
46
|
+
/** The base commit the policy was read at. */
|
|
47
|
+
base: string;
|
|
48
|
+
digest: string;
|
|
49
|
+
members: OpWaveMember[];
|
|
50
|
+
/** `approved`: an approval of this digest stands. `not-required`: the policy lets it through. `waiting`: neither. */
|
|
51
|
+
status: "approved" | "not-required" | "waiting";
|
|
52
|
+
approvedBy?: string;
|
|
53
|
+
/** The command that approves this digest, when waiting. */
|
|
54
|
+
approve?: string;
|
|
55
|
+
}
|
|
56
|
+
/** Runs one argv; returns its exit code. Injected so tests run no processes. */
|
|
57
|
+
export type OpWaveExec = (argv: string[], cwd: string) => number;
|
|
58
|
+
/** Reads one file at one commit, or null when the commit has no such file. Injected for tests. */
|
|
59
|
+
export type OpWaveShowAtBase = (base: string, path: string, cwd: string) => {
|
|
60
|
+
sha: string;
|
|
61
|
+
text: string | null;
|
|
62
|
+
};
|
|
63
|
+
/** Read a spec file, refusing anything that is not one. */
|
|
64
|
+
export declare function parseOpWavesSpec(text: string, where: string): OpWavesSpec;
|
|
65
|
+
/**
|
|
66
|
+
* The policy of wave `name`, as the spec file at `base` declares it. The
|
|
67
|
+
* file or the wave missing there reads as `always`: a change that adds a
|
|
68
|
+
* wave, or the file, waits for a person the first time.
|
|
69
|
+
*/
|
|
70
|
+
export declare function readOpWavePolicy(specFile: string, name: string, base: string, cwd: string, show?: OpWaveShowAtBase): {
|
|
71
|
+
policy: OpWaveGatePolicy;
|
|
72
|
+
source: OpWavePolicySource;
|
|
73
|
+
base: string;
|
|
74
|
+
};
|
|
75
|
+
/** Fill one argv template for one run. */
|
|
76
|
+
export declare function opWaveArgv(template: readonly string[], values: {
|
|
77
|
+
op: string;
|
|
78
|
+
target: string;
|
|
79
|
+
wave: string;
|
|
80
|
+
plan: string;
|
|
81
|
+
}): string[];
|
|
82
|
+
/** Where a run's plan file goes. */
|
|
83
|
+
export declare function opWavePlanPath(spec: Pick<OpWavesSpec, "name">, wave: number, target: string): string;
|
|
84
|
+
export interface RunOpWaveOptions {
|
|
85
|
+
spec: OpWavesSpec;
|
|
86
|
+
specFile: string;
|
|
87
|
+
/** 1-based. */
|
|
88
|
+
wave: number;
|
|
89
|
+
/** Plan and decide only (a wide wave's deciding job). */
|
|
90
|
+
decide?: boolean;
|
|
91
|
+
/** Apply this 1-based share of a decided wave. */
|
|
92
|
+
share?: number;
|
|
93
|
+
/** Overrides the spec's base. */
|
|
94
|
+
base?: string;
|
|
95
|
+
cwd?: string;
|
|
96
|
+
gates?: GateLedgerPort;
|
|
97
|
+
exec?: OpWaveExec;
|
|
98
|
+
show?: OpWaveShowAtBase;
|
|
99
|
+
now?: string;
|
|
100
|
+
log?: (line: string) => void;
|
|
101
|
+
}
|
|
102
|
+
export interface RunOpWaveResult {
|
|
103
|
+
/** 0 applied (or decided), 1 a run failed, 3 waiting for an approval, 4 a plan moved since the decision. */
|
|
104
|
+
exitCode: number;
|
|
105
|
+
decision?: OpWaveDecision;
|
|
106
|
+
applied: string[];
|
|
107
|
+
failed: string[];
|
|
108
|
+
/** Runs a share job refused because their plan moved since the decision. */
|
|
109
|
+
moved: string[];
|
|
110
|
+
}
|
|
111
|
+
/** Whether `policy` lets a wave with these members through without an approval. */
|
|
112
|
+
export declare function opWaveNeedsApproval(policy: OpWaveGatePolicy, members: readonly OpWaveMember[]): boolean;
|
|
113
|
+
/** Plan, decide and apply one wave, or one part of a wide wave. */
|
|
114
|
+
export declare function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveResult>;
|
|
115
|
+
//# sourceMappingURL=op-waves-run.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"op-waves-run.d.ts","sourceRoot":"","sources":["../../src/op/op-waves-run.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gated waves of Op runs (#3679): a pipeline that applies one Op per
|
|
3
|
+
* environment, or per target in a set, in order, each wave behind one gate.
|
|
4
|
+
*
|
|
5
|
+
* A product built on chant that applies a migration to dev, then staging, then
|
|
6
|
+
* prod, or the same change to every tenant database of an environment, used to
|
|
7
|
+
* render that ordering itself, once per forge, and compute each wave's set
|
|
8
|
+
* digest itself. This module holds the forge-neutral part: the spec, the jobs
|
|
9
|
+
* it becomes and the commands they run. Each forge lexicon (github, gitlab,
|
|
10
|
+
* forgejo) renders these jobs with its own transport, so the three cannot
|
|
11
|
+
* disagree about order or commands. `./op-waves-run.ts` is what the jobs run.
|
|
12
|
+
*
|
|
13
|
+
* ## Jobs
|
|
14
|
+
*
|
|
15
|
+
* A wave is one job, `wave-<k>-<name>`, running `chant run wave --spec <file>
|
|
16
|
+
* --wave <k>`: plan every run, decide the wave's gate, apply. A wide wave
|
|
17
|
+
* (`shares` > 1) is a deciding job that plans every run and decides the gate,
|
|
18
|
+
* plus `shares` jobs that each apply a slice of the runs and refuse a run
|
|
19
|
+
* whose plan moved since the decision. Each wave's jobs need the previous
|
|
20
|
+
* wave's last jobs, so a wave that waits (exit 3) or fails stops every later
|
|
21
|
+
* wave.
|
|
22
|
+
*
|
|
23
|
+
* ## The gate
|
|
24
|
+
*
|
|
25
|
+
* Wave k waits on `waveGateName(<gate>, k)`, recorded under the spec's name,
|
|
26
|
+
* bound to {@link waveSetDigest} over its runs' plan digests (`../gated-waves.ts`,
|
|
27
|
+
* the same digest a component fan-out's wave binds). Whether it waits at all
|
|
28
|
+
* is the wave's gate policy, read from the base commit's copy of the spec
|
|
29
|
+
* file, so a change cannot loosen the rule that gates its own merge.
|
|
30
|
+
*/
|
|
31
|
+
import type { OpEnvironment, OpSetupStep } from "../lexicon.js";
|
|
32
|
+
/** When a wave waits for an approval. */
|
|
33
|
+
export type OpWaveGatePolicy =
|
|
34
|
+
/** Whenever the wave changes something. */
|
|
35
|
+
"always"
|
|
36
|
+
/** When a run's plan says it is destructive. */
|
|
37
|
+
| "on-destructive"
|
|
38
|
+
/** Never: only review and branch protection stand in front of the wave. */
|
|
39
|
+
| "never";
|
|
40
|
+
export declare const OP_WAVE_GATE_POLICIES: readonly OpWaveGatePolicy[];
|
|
41
|
+
/** One Op run in a wave: an Op applied to one target (an environment, a database, a tenant). */
|
|
42
|
+
export interface OpWaveRun {
|
|
43
|
+
/** What the run applies to. Unique within its wave; it names the run in the set digest. */
|
|
44
|
+
target: string;
|
|
45
|
+
/** The Op to run. Default: the spec's `op`. */
|
|
46
|
+
op?: string;
|
|
47
|
+
}
|
|
48
|
+
/** One wave: runs that apply together, behind one gate. */
|
|
49
|
+
export interface OpWave {
|
|
50
|
+
/** The wave's name, usually its environment. Lowercase letters, digits, `-` and `_`. */
|
|
51
|
+
name: string;
|
|
52
|
+
runs: OpWaveRun[];
|
|
53
|
+
/**
|
|
54
|
+
* When the wave waits. Default `"always"`. The runner reads this from the
|
|
55
|
+
* spec file at the base commit, never from the change being applied.
|
|
56
|
+
*/
|
|
57
|
+
gate?: OpWaveGatePolicy;
|
|
58
|
+
/**
|
|
59
|
+
* Split the wave into a deciding job and this many share jobs, each
|
|
60
|
+
* applying a slice of the runs. Default 1: one job plans, decides and
|
|
61
|
+
* applies.
|
|
62
|
+
*/
|
|
63
|
+
shares?: number;
|
|
64
|
+
/** The forge deployment environment the wave's jobs run in (GitHub and GitLab; Forgejo has none). */
|
|
65
|
+
environment?: OpEnvironment;
|
|
66
|
+
/** Variables for this wave's jobs alone. */
|
|
67
|
+
variables?: Record<string, string>;
|
|
68
|
+
/** Steps this wave's jobs run after the checkout, as `ScheduledOpSpec.setup`. */
|
|
69
|
+
setup?: OpSetupStep[];
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* An ordered list of waves of Op runs, as a pipeline and as the file its jobs
|
|
73
|
+
* read. Commands are argv templates; each `{op}`, `{target}`, `{wave}` and
|
|
74
|
+
* `{plan}` is replaced per run.
|
|
75
|
+
*/
|
|
76
|
+
export interface OpWavesSpec {
|
|
77
|
+
/** The pipeline's name, and the op the wave gates are recorded under (`chant approve <name> <gate>-wave-<k>`). */
|
|
78
|
+
name: string;
|
|
79
|
+
/** The Op a run runs when it names none. */
|
|
80
|
+
op: string;
|
|
81
|
+
/** The base gate name. Default: `name`. */
|
|
82
|
+
gate?: string;
|
|
83
|
+
/** Branches whose pushes run the waves. Default `["main"]`. */
|
|
84
|
+
branches?: string[];
|
|
85
|
+
/**
|
|
86
|
+
* The commit the gate policy is read from. Default `HEAD^1`, the first
|
|
87
|
+
* parent: on a merge or squash onto the branch, the branch before the change.
|
|
88
|
+
*/
|
|
89
|
+
base?: string;
|
|
90
|
+
/**
|
|
91
|
+
* Plan one run and write its plan file to `{plan}`: JSON `{ "planDigest":
|
|
92
|
+
* string, "destructive"?: boolean, "empty"?: boolean }`. `destructive` is
|
|
93
|
+
* what `on-destructive` waits on; a wave whose runs are all `empty` never
|
|
94
|
+
* waits.
|
|
95
|
+
*/
|
|
96
|
+
plan: string[];
|
|
97
|
+
/** Apply one run whose plan is at `{plan}`. Default `chant run {op} --env {target}`. */
|
|
98
|
+
apply?: string[];
|
|
99
|
+
waves: OpWave[];
|
|
100
|
+
}
|
|
101
|
+
/** The default apply command. */
|
|
102
|
+
export declare const OP_WAVE_DEFAULT_APPLY: string[];
|
|
103
|
+
/** Where the runner keeps a wave's decision; a wide wave's deciding job hands it to its share jobs. */
|
|
104
|
+
export declare function opWaveRecordPath(spec: Pick<OpWavesSpec, "name">, wave: number): string;
|
|
105
|
+
/** One generated job. */
|
|
106
|
+
export interface OpWaveJob {
|
|
107
|
+
jobName: string;
|
|
108
|
+
/** 1-based wave number. */
|
|
109
|
+
wave: number;
|
|
110
|
+
/** The wave's name. */
|
|
111
|
+
waveName: string;
|
|
112
|
+
/** `wave`: plan, decide and apply. `decide`: plan and decide. `share`: apply one slice. */
|
|
113
|
+
kind: "wave" | "decide" | "share";
|
|
114
|
+
/** 1-based, on a share job. */
|
|
115
|
+
share?: number;
|
|
116
|
+
/** Jobs this one waits on. */
|
|
117
|
+
needs: string[];
|
|
118
|
+
/** The job's command, as argv. */
|
|
119
|
+
command: string[];
|
|
120
|
+
/** On a share job: the decision record to download. On a deciding job: the one to upload. */
|
|
121
|
+
record?: string;
|
|
122
|
+
}
|
|
123
|
+
/** Refuse a spec no job could run, naming what is wrong. */
|
|
124
|
+
export declare function assertOpWavesSpec(spec: OpWavesSpec): void;
|
|
125
|
+
/**
|
|
126
|
+
* The runs share `share` (1-based) of `shares` applies: the wave's runs sorted
|
|
127
|
+
* by target, cut into contiguous slices whose sizes differ by at most one.
|
|
128
|
+
*/
|
|
129
|
+
export declare function opWaveShare<T extends {
|
|
130
|
+
target: string;
|
|
131
|
+
}>(runs: readonly T[], share: number, shares: number): T[];
|
|
132
|
+
/**
|
|
133
|
+
* The jobs of an Op waves pipeline, in order. `specFile` is the spec's path
|
|
134
|
+
* from the job's working directory, which every command names.
|
|
135
|
+
*/
|
|
136
|
+
export declare function opWaveJobs(spec: OpWavesSpec, specFile: string): OpWaveJob[];
|
|
137
|
+
/** The wave a job belongs to. */
|
|
138
|
+
export declare function opWaveOf(spec: OpWavesSpec, job: Pick<OpWaveJob, "wave">): OpWave;
|
|
139
|
+
//# sourceMappingURL=op-waves.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"op-waves.d.ts","sourceRoot":"","sources":["../../src/op/op-waves.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAE7D,yCAAyC;AACzC,MAAM,MAAM,gBAAgB;AAC1B,2CAA2C;AACzC,QAAQ;AACV,gDAAgD;GAC9C,gBAAgB;AAClB,2EAA2E;GACzE,OAAO,CAAC;AAEZ,eAAO,MAAM,qBAAqB,EAAE,SAAS,gBAAgB,EAA0C,CAAC;AAExG,gGAAgG;AAChG,MAAM,WAAW,SAAS;IACxB,2FAA2F;IAC3F,MAAM,EAAE,MAAM,CAAC;IACf,+CAA+C;IAC/C,EAAE,CAAC,EAAE,MAAM,CAAC;CACb;AAED,2DAA2D;AAC3D,MAAM,WAAW,MAAM;IACrB,wFAAwF;IACxF,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,SAAS,EAAE,CAAC;IAClB;;;OAGG;IACH,IAAI,CAAC,EAAE,gBAAgB,CAAC;IACxB;;;;OAIG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,qGAAqG;IACrG,WAAW,CAAC,EAAE,aAAa,CAAC;IAC5B,4CAA4C;IAC5C,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACnC,iFAAiF;IACjF,KAAK,CAAC,EAAE,WAAW,EAAE,CAAC;CACvB;AAED;;;;GAIG;AACH,MAAM,WAAW,WAAW;IAC1B,kHAAkH;IAClH,IAAI,EAAE,MAAM,CAAC;IACb,4CAA4C;IAC5C,EAAE,EAAE,MAAM,CAAC;IACX,2CAA2C;IAC3C,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,+DAA+D;IAC/D,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;IACpB;;;OAGG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;;;OAKG;IACH,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,wFAAwF;IACxF,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;IACjB,KAAK,EAAE,MAAM,EAAE,CAAC;CACjB;AAED,iCAAiC;AACjC,eAAO,MAAM,qBAAqB,UAAgD,CAAC;AAEnF,uGAAuG;AACvG,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,IAAI,CAAC,WAAW,EAAE,MAAM,CAAC,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAEtF;AAED,yBAAyB;AACzB,MAAM,WAAW,SAAS;IACxB,OAAO,EAAE,MAAM,CAAC;IAChB,2BAA2B;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,uBAAuB;IACvB,QAAQ,EAAE,MAAM,CAAC;IACjB,2FAA2F;IAC3F,IAAI,EAAE,MAAM,GAAG,QAAQ,GAAG,OAAO,CAAC;IAClC,+BAA+B;IAC/B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,8BAA8B;IAC9B,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,kCAAkC;IAClC,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,6FAA6F;IAC7F,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAID,4DAA4D;AAC5D,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,WAAW,GAAG,IAAI,CAoCzD;AAED;;;GAGG;AACH,wBAAgB,WAAW,CAAC,CAAC,SAAS;IAAE,MAAM,EAAE,MAAM,CAAA;CAAE,EAAE,IAAI,EAAE,SAAS,CAAC,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,CAAC,EAAE,CAOhH;AAED;;;GAGG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,GAAG,SAAS,EAAE,CA0B3E;AAED,iCAAiC;AACjC,wBAAgB,QAAQ,CAAC,IAAI,EAAE,WAAW,EAAE,GAAG,EAAE,IAAI,CAAC,SAAS,EAAE,MAAM,CAAC,GAAG,MAAM,CAEhF"}
|
package/package.json
CHANGED
|
@@ -1039,7 +1039,8 @@ function renderDeepDiff(
|
|
|
1039
1039
|
// Both sides of the attribution, where each is known (#1443/#1189).
|
|
1040
1040
|
const from = change.origin ? ` [from: ${describePathOrigin(change.origin)}]` : "";
|
|
1041
1041
|
const owner = change.owner ? ` [owner: ${change.owner}]` : "";
|
|
1042
|
-
|
|
1042
|
+
const seen = change.seenOn ? ` [seen on: ${change.seenOn}]` : "";
|
|
1043
|
+
console.log(` ${change.path}: ${declared} → ${live}${baseline}${from}${owner}${seen}`);
|
|
1043
1044
|
// chant #2161 — what may be done about it, when the origin says
|
|
1044
1045
|
// something today's behaviour would get wrong. A field the author
|
|
1045
1046
|
// declared directly is left unremarked: editing it IS today's
|
|
@@ -26,7 +26,8 @@
|
|
|
26
26
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
27
27
|
import { dirname, join, resolve } from "node:path";
|
|
28
28
|
import { discoverOps } from "../../op/discover";
|
|
29
|
-
import { generateOpsPipeline } from "../../op/generate-pipeline";
|
|
29
|
+
import { generateOpsPipeline, generateOpWavesPipeline } from "../../op/generate-pipeline";
|
|
30
|
+
import { parseOpWavesSpec } from "../../op/op-waves-run";
|
|
30
31
|
import { GENERATED_MARKER } from "../../discovery/files";
|
|
31
32
|
import { findWorkspaceRoot } from "../../project-root";
|
|
32
33
|
import type { ComponentPipelineOptions, ScheduledOpSpec } from "../../lexicon";
|
|
@@ -71,10 +72,59 @@ export function readOpSpecFile(path: string): SpecFile {
|
|
|
71
72
|
return { ops: ops as ScheduledOpSpec[], ...(options ? { options } : {}) };
|
|
72
73
|
}
|
|
73
74
|
|
|
75
|
+
/** True when a `--spec` file holds an Op waves spec (#3679) rather than a list of scheduled Ops. */
|
|
76
|
+
function holdsOpWaves(path: string): boolean {
|
|
77
|
+
try {
|
|
78
|
+
const parsed = JSON.parse(readFileSync(path, "utf-8")) as { waves?: unknown } | null;
|
|
79
|
+
return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) && parsed.waves !== undefined;
|
|
80
|
+
} catch {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* `chant run --generate <provider> --spec <waves.json>` (#3679): one pipeline
|
|
87
|
+
* file of ordered, gated waves of Op runs. Its jobs read the same file.
|
|
88
|
+
*/
|
|
89
|
+
async function runOpWavesGenerate(ctx: CommandContext, provider: string, specPath: string): Promise<number> {
|
|
90
|
+
const { args } = ctx;
|
|
91
|
+
let spec;
|
|
92
|
+
let options: Record<string, unknown> | undefined;
|
|
93
|
+
try {
|
|
94
|
+
const text = readFileSync(resolve(specPath), "utf-8");
|
|
95
|
+
spec = parseOpWavesSpec(text, `--spec ${specPath}`);
|
|
96
|
+
const parsed = JSON.parse(text) as { options?: Record<string, unknown> };
|
|
97
|
+
options = Array.isArray((parsed as { waves?: unknown }).waves) ? undefined : parsed.options;
|
|
98
|
+
} catch (err) {
|
|
99
|
+
console.error(formatError({ message: err instanceof Error ? err.message : String(err) }));
|
|
100
|
+
return 1;
|
|
101
|
+
}
|
|
102
|
+
const result = await generateOpWavesPipeline(spec, provider, { ...options, specFile: specPath.replace(/^\.\//, "") });
|
|
103
|
+
if (!result.success) {
|
|
104
|
+
console.error(formatError({ message: result.error ?? "Failed to generate the Op waves pipeline" }));
|
|
105
|
+
return 1;
|
|
106
|
+
}
|
|
107
|
+
const header = opPipelineHeader(provider, specPath);
|
|
108
|
+
const outDir = args.output ?? DEFAULT_OP_PIPELINE_DIRS[provider] ?? ".";
|
|
109
|
+
const files = (result.files ?? []).map((f) => ({ name: f.name, path: join(outDir, f.name), content: header + f.yaml }));
|
|
110
|
+
if (args.format === "json") {
|
|
111
|
+
console.log(JSON.stringify({ files, jobs: result.jobs ?? [] }, null, 2));
|
|
112
|
+
return 0;
|
|
113
|
+
}
|
|
114
|
+
for (const file of files) {
|
|
115
|
+
const target = resolve(file.path);
|
|
116
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
117
|
+
writeFileSync(target, file.content);
|
|
118
|
+
console.error(formatSuccess(`wrote ${formatBold(file.path)}`));
|
|
119
|
+
}
|
|
120
|
+
return 0;
|
|
121
|
+
}
|
|
122
|
+
|
|
74
123
|
export async function runOpGenerate(ctx: CommandContext): Promise<number> {
|
|
75
124
|
const { args } = ctx;
|
|
76
125
|
const provider = args.generate as string;
|
|
77
126
|
const specPath = args.opsSpec;
|
|
127
|
+
if (specPath && holdsOpWaves(resolve(specPath))) return runOpWavesGenerate(ctx, provider, specPath);
|
|
78
128
|
|
|
79
129
|
let ops: ScheduledOpSpec[];
|
|
80
130
|
let options: ComponentPipelineOptions | undefined;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `chant run wave --spec <file> --wave <k>` (#3679): the command each job of
|
|
3
|
+
* a generated Op waves pipeline runs. See `../../op/op-waves-run.ts`.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import { relative, resolve } from "node:path";
|
|
8
|
+
import { parseOpWavesSpec, runOpWave } from "../../op/op-waves-run";
|
|
9
|
+
import { formatError } from "../format";
|
|
10
|
+
import type { CommandContext } from "../registry";
|
|
11
|
+
|
|
12
|
+
export async function runOpWaveCommand(ctx: CommandContext): Promise<number> {
|
|
13
|
+
const { args } = ctx;
|
|
14
|
+
const specFile = args.opsSpec;
|
|
15
|
+
if (!specFile || args.wave === undefined) {
|
|
16
|
+
console.error(formatError({
|
|
17
|
+
message: "`chant run wave` needs --spec <file> and --wave <k>",
|
|
18
|
+
hint: "Run it as a generated Op waves pipeline does: chant run wave --spec <file> --wave <k> [--decide | --share <i>]",
|
|
19
|
+
}));
|
|
20
|
+
return 1;
|
|
21
|
+
}
|
|
22
|
+
if (args.decide && args.share !== undefined) {
|
|
23
|
+
console.error(formatError({ message: "--decide and --share are two different jobs; pass one" }));
|
|
24
|
+
return 1;
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
const path = resolve(specFile);
|
|
28
|
+
const spec = parseOpWavesSpec(readFileSync(path, "utf-8"), `--spec ${specFile}`);
|
|
29
|
+
const result = await runOpWave({
|
|
30
|
+
spec,
|
|
31
|
+
specFile: relative(process.cwd(), path),
|
|
32
|
+
wave: args.wave,
|
|
33
|
+
...(args.decide ? { decide: true } : {}),
|
|
34
|
+
...(args.share !== undefined ? { share: args.share } : {}),
|
|
35
|
+
...(args.base ? { base: args.base } : {}),
|
|
36
|
+
});
|
|
37
|
+
if (args.json) console.log(JSON.stringify(result, null, 2));
|
|
38
|
+
return result.exitCode;
|
|
39
|
+
} catch (err) {
|
|
40
|
+
console.error(formatError({ message: err instanceof Error ? err.message : String(err) }));
|
|
41
|
+
return 1;
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/cli/main.ts
CHANGED
|
@@ -36,6 +36,7 @@ import { runGraph } from "./handlers/graph";
|
|
|
36
36
|
import { runExplain } from "./handlers/explain";
|
|
37
37
|
import { runSearch } from "./handlers/search";
|
|
38
38
|
import { runOp, runOpList, runOpStatus, runOpApprove, runOpSignalRenamed, runOpCancel, runOpLog } from "./handlers/run";
|
|
39
|
+
import { runOpWaveCommand } from "./handlers/run-wave";
|
|
39
40
|
import { runOperator, runOperatorStatus, runOperatorLog, runApprove } from "./handlers/operator";
|
|
40
41
|
import { runEmulator } from "./handlers/emulator";
|
|
41
42
|
import { splitJoinedFlags, dispatchCommandGroup, collectCommandGroups, formatCommandGroupsHelp, type CommandGroup } from "./command-group";
|
|
@@ -106,6 +107,7 @@ const BOOLEAN_FLAGS = new Set([
|
|
|
106
107
|
"--allow-code",
|
|
107
108
|
"--root-only",
|
|
108
109
|
"--generated",
|
|
110
|
+
"--decide",
|
|
109
111
|
]);
|
|
110
112
|
|
|
111
113
|
/**
|
|
@@ -355,6 +357,13 @@ export function parseArgs(args: string[]): ParsedArgs {
|
|
|
355
357
|
const wave = Number(raw);
|
|
356
358
|
if (!Number.isInteger(wave) || wave < 1) throw new Error(`--wave needs a wave number from 1: got "${raw ?? ""}"`);
|
|
357
359
|
result.wave = wave;
|
|
360
|
+
} else if (arg === "--decide") {
|
|
361
|
+
result.decide = true;
|
|
362
|
+
} else if (arg === "--share") {
|
|
363
|
+
const raw = args[++i];
|
|
364
|
+
const share = Number(raw);
|
|
365
|
+
if (!Number.isInteger(share) || share < 1) throw new Error(`--share needs a share number from 1: got "${raw ?? ""}"`);
|
|
366
|
+
result.share = share;
|
|
358
367
|
} else if (arg === "--canary") {
|
|
359
368
|
const raw = args[++i];
|
|
360
369
|
if (!raw || raw.startsWith("-")) throw new Error("--canary needs a component name: --canary <name>[,<name>...]");
|
|
@@ -909,6 +918,10 @@ Ops:
|
|
|
909
918
|
run approve <op> <gate> Record a gate's resolution and wake the runtime
|
|
910
919
|
run cancel <name> Cancel the active run (requires --force)
|
|
911
920
|
run log <name> Show run history for an Op
|
|
921
|
+
run wave --spec <file> --wave <k> Plan, gate and apply wave k of an Op
|
|
922
|
+
waves spec (#3679): the job a generated waves
|
|
923
|
+
pipeline runs. --decide plans and decides only;
|
|
924
|
+
--share <i> applies one share of a decided wave
|
|
912
925
|
run --generate <provider> Write one CI pipeline file per scheduled Op for
|
|
913
926
|
github, gitlab or forgejo (#2533), from every Op that
|
|
914
927
|
declares a schedule or from --spec <file.json>.
|
|
@@ -1815,6 +1828,7 @@ export const commandRegistry: CommandDef[] = [
|
|
|
1815
1828
|
{ name: "run signal", handler: runOpSignalRenamed },
|
|
1816
1829
|
{ name: "run cancel", handler: runOpCancel },
|
|
1817
1830
|
{ name: "run log", handler: runOpLog },
|
|
1831
|
+
{ name: "run wave", handler: runOpWaveCommand },
|
|
1818
1832
|
{ name: "run", handler: runOp },
|
|
1819
1833
|
|
|
1820
1834
|
{ name: "operator status", handler: runOperatorStatus },
|
package/src/cli/registry.ts
CHANGED
|
@@ -248,8 +248,12 @@ export interface ParsedArgs {
|
|
|
248
248
|
* <name>`, which generates one CI job per wave.
|
|
249
249
|
*/
|
|
250
250
|
waveGate?: string;
|
|
251
|
-
/** `chant components fan-out --wave <n>` (#3049) — run wave `n` of a `--wave-gate` fan-out and no other. */
|
|
251
|
+
/** `chant components fan-out --wave <n>` (#3049) — run wave `n` of a `--wave-gate` fan-out and no other. Also `chant run wave --wave <k>` (#3679). */
|
|
252
252
|
wave?: number;
|
|
253
|
+
/** `chant run wave --decide` (#3679) — plan a wide wave and decide its gate, applying nothing. */
|
|
254
|
+
decide?: boolean;
|
|
255
|
+
/** `chant run wave --share <i>` (#3679) — apply share `i` of a decided wide wave. */
|
|
256
|
+
share?: number;
|
|
253
257
|
/** `chant components fan-out --canary <component>` (#3049), repeatable — components that form wave 1. */
|
|
254
258
|
canary?: string[];
|
|
255
259
|
/**
|
package/src/deep-observation.ts
CHANGED
|
@@ -68,6 +68,14 @@ export interface DeepResourceObservation {
|
|
|
68
68
|
* very different answers to an operator.
|
|
69
69
|
*/
|
|
70
70
|
fieldOwners?: Record<string, string>;
|
|
71
|
+
/**
|
|
72
|
+
* The server the properties were read from, when a reader reads more than
|
|
73
|
+
* one copy of the entity and they disagree (#3664): a sql table on a
|
|
74
|
+
* ClickHouse cluster, read on every shard and replica, reports the copy
|
|
75
|
+
* that differs from the rest and names its server here. Absent when there
|
|
76
|
+
* is one copy, or every copy reads the same.
|
|
77
|
+
*/
|
|
78
|
+
observedOn?: string;
|
|
71
79
|
/**
|
|
72
80
|
* The paths this entity's declaration claims (#2160) — every property chant
|
|
73
81
|
* set, flattened into the diff's own grammar and sorted. Built by core in
|
package/src/lexicon.ts
CHANGED
|
@@ -10,6 +10,7 @@ import type { AgentConfigImporter } from "./agents/importer";
|
|
|
10
10
|
import type { ArtifactIntegrity } from "./lexicon-integrity";
|
|
11
11
|
import type { GatedWavePipelineOptions } from "./components/gated-wave-pipeline";
|
|
12
12
|
import type { PrLoopPipelineOptions } from "./components/pr-pipeline";
|
|
13
|
+
import type { OpWaveJob, OpWavesSpec } from "./op/op-waves";
|
|
13
14
|
import type { OkfFile } from "./okf";
|
|
14
15
|
import type { CompletionContext, CompletionItem, HoverContext, HoverInfo, CodeActionContext, CodeAction } from "./lsp/types";
|
|
15
16
|
import type { McpToolContribution, McpResourceContribution } from "./mcp/types";
|
|
@@ -900,6 +901,23 @@ export interface OpPipelineResult {
|
|
|
900
901
|
jobs: OpPipelineJob[];
|
|
901
902
|
}
|
|
902
903
|
|
|
904
|
+
/**
|
|
905
|
+
* Options for an Op waves pipeline (#3679): the generic knobs of
|
|
906
|
+
* {@link ComponentPipelineOptions} that apply to every wave job, and the path
|
|
907
|
+
* of the spec file the jobs read.
|
|
908
|
+
*/
|
|
909
|
+
export interface OpWavesPipelineOptions
|
|
910
|
+
extends Pick<ComponentPipelineOptions, "image" | "beforeScript" | "extraScript" | "variables" | "opsStage" | "opsFileName"> {
|
|
911
|
+
/** The spec's path from the repository root. Every job runs `chant run wave --spec <specFile>`. */
|
|
912
|
+
specFile: string;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/** An Op waves pipeline: one file, and its jobs in order. */
|
|
916
|
+
export interface OpWavesPipelineResult {
|
|
917
|
+
files: OpPipelineFile[];
|
|
918
|
+
jobs: OpWaveJob[];
|
|
919
|
+
}
|
|
920
|
+
|
|
903
921
|
/**
|
|
904
922
|
* Live status of a single deploy unit (a CloudFormation stack, a K8s release, …)
|
|
905
923
|
* addressed by its deployed name — the per-component presence signal
|
|
@@ -1325,6 +1343,14 @@ export interface LexiconPlugin {
|
|
|
1325
1343
|
options?: ComponentPipelineOptions,
|
|
1326
1344
|
): OpPipelineResult;
|
|
1327
1345
|
|
|
1346
|
+
/**
|
|
1347
|
+
* Render ordered waves of Op runs, each behind one gate (#3679,
|
|
1348
|
+
* `./op/op-waves.ts`): one job per wave, or a deciding job and share jobs
|
|
1349
|
+
* for a wide one, each wave needing the one before. Only CI-provider
|
|
1350
|
+
* lexicons implement this.
|
|
1351
|
+
*/
|
|
1352
|
+
generateOpWavesPipeline?(spec: OpWavesSpec, options: OpWavesPipelineOptions): OpWavesPipelineResult;
|
|
1353
|
+
|
|
1328
1354
|
/**
|
|
1329
1355
|
* Host Op runs — the seam `chant run <op> --on <this lexicon>` dispatches to
|
|
1330
1356
|
* (#2121, epic #2114). Core ships one provider of its own, `local`, and
|
|
@@ -8,7 +8,7 @@ import type { BaselineLexicon } from "./observation-baseline";
|
|
|
8
8
|
const held = (by: string, reason: string) => ({ heldElsewhere: HELD_ELSEWHERE_TAG, by, reason });
|
|
9
9
|
|
|
10
10
|
const live = (
|
|
11
|
-
resources: Record<string, { type: string; properties: Record<string, unknown>; fieldOwners?: Record<string, string
|
|
11
|
+
resources: Record<string, { type: string; properties: Record<string, unknown>; fieldOwners?: Record<string, string>; observedOn?: string }>,
|
|
12
12
|
unobserved: NormalizedDeepObservation["unobserved"] = {},
|
|
13
13
|
): NormalizedDeepObservation => ({ resources, unobserved });
|
|
14
14
|
|
|
@@ -242,6 +242,25 @@ describe("diffDeep — owning field manager (#1189)", () => {
|
|
|
242
242
|
});
|
|
243
243
|
});
|
|
244
244
|
|
|
245
|
+
// #3664 — a reader that read several copies of an entity (a table on every
|
|
246
|
+
// server of a ClickHouse cluster) names the server whose copy it reported.
|
|
247
|
+
describe("diffDeep — the server a live value was seen on (#3664)", () => {
|
|
248
|
+
const declared = { events: { type: "ClickHouse::Table", properties: { ttl: "ts + INTERVAL 30 DAY" } } };
|
|
249
|
+
|
|
250
|
+
test("rides on every drifted path of that entity", () => {
|
|
251
|
+
const result = diffDeep({
|
|
252
|
+
declared,
|
|
253
|
+
live: live({ events: { type: "ClickHouse::Table", properties: { ttl: "ts + toIntervalDay(7)" }, observedOn: "shard-2" } }),
|
|
254
|
+
});
|
|
255
|
+
expect(result.drifted[0].changes[0]).toMatchObject({ path: "ttl", kind: "changed", seenOn: "shard-2" });
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("is absent when the reader read one copy", () => {
|
|
259
|
+
const result = diffDeep({ declared, live: live({ events: { type: "ClickHouse::Table", properties: { ttl: "ts + toIntervalDay(7)" } } }) });
|
|
260
|
+
expect(result.drifted[0].changes[0]).not.toHaveProperty("seenOn");
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
245
264
|
// #1443 — the declared-side counterpart of `owner`. Both sides of one
|
|
246
265
|
// comparison get precise attribution, which is what makes the report actionable.
|
|
247
266
|
describe("diffDeep — declared-side origin (#1443)", () => {
|
|
@@ -107,6 +107,12 @@ export interface PropertyDrift {
|
|
|
107
107
|
* child, have no expression to attribute (see `EntityProvenance.paths`).
|
|
108
108
|
*/
|
|
109
109
|
origin?: PathOrigin;
|
|
110
|
+
/**
|
|
111
|
+
* The server the live value was read on, when the reader read the entity
|
|
112
|
+
* on several and reported the copy that differs (#3664,
|
|
113
|
+
* `DeepResourceObservation.observedOn`).
|
|
114
|
+
*/
|
|
115
|
+
seenOn?: string;
|
|
110
116
|
}
|
|
111
117
|
|
|
112
118
|
/** Property-level drift for one declared entity. */
|
|
@@ -382,6 +388,7 @@ export function diffDeep(input: DiffDeepInput): DeepDiffResult {
|
|
|
382
388
|
...(hasLive ? { live: liveValue } : {}),
|
|
383
389
|
...(owner ? { owner } : {}),
|
|
384
390
|
...(origin ? { origin } : {}),
|
|
391
|
+
...(hasLive && liveEntity.observedOn ? { seenOn: liveEntity.observedOn } : {}),
|
|
385
392
|
};
|
|
386
393
|
|
|
387
394
|
if (acceptedEntry) {
|
|
@@ -160,6 +160,8 @@ export function diffDeepObservation(
|
|
|
160
160
|
// up by the flattened path, so a keyed list element (`[#name]`) has
|
|
161
161
|
// no owner today — its raw index path is what the reader recorded.
|
|
162
162
|
...(liveEntity.fieldOwners ? { fieldOwners: liveEntity.fieldOwners } : {}),
|
|
163
|
+
// Which server's copy was read, when the reader read several (#3664).
|
|
164
|
+
...(liveEntity.observedOn ? { observedOn: liveEntity.observedOn } : {}),
|
|
163
165
|
// The claimed-field set (#2160), computed from the declaration and
|
|
164
166
|
// carried on the observation so a consumer holding only this envelope
|
|
165
167
|
// can classify a live value three ways rather than two. Same normalized
|
|
@@ -124,6 +124,27 @@ describe("loadActivities — multi-lexicon (#706)", () => {
|
|
|
124
124
|
expect(a.has("waitForStack")).toBe(true); // base still loaded alongside
|
|
125
125
|
});
|
|
126
126
|
|
|
127
|
+
// #3657 — the sql lexicon's receipt activities are fallbacks: a sql-only
|
|
128
|
+
// project's effect() receipts go to its database, and a project that also
|
|
129
|
+
// lists aws or k8s keeps them in that lexicon's row, in either order.
|
|
130
|
+
test("sql's fallback receipt activities give way to another lexicon's, in either order (#3657)", async () => {
|
|
131
|
+
const sql = await loadActivities(["sql"]);
|
|
132
|
+
const sqlRead = sql.get("receiptRead");
|
|
133
|
+
expect(typeof sqlRead).toBe("function");
|
|
134
|
+
expect(sql.has("sqlExec")).toBe(true);
|
|
135
|
+
|
|
136
|
+
const aws = (await loadActivities(["aws"])).get("receiptRead");
|
|
137
|
+
expect(aws).toBeDefined();
|
|
138
|
+
expect(aws).not.toBe(sqlRead);
|
|
139
|
+
for (const order of [["sql", "aws"], ["aws", "sql"]]) {
|
|
140
|
+
const a = await loadActivities(order);
|
|
141
|
+
expect(a.get("receiptRead"), order.join(",")).toBe(aws);
|
|
142
|
+
expect(a.get("sqlExec"), order.join(",")).toBe(sql.get("sqlExec"));
|
|
143
|
+
}
|
|
144
|
+
const k8s = await loadActivities(["sql", "k8s"]);
|
|
145
|
+
expect(k8s.get("receiptWrite")).toBe((await loadActivities(["k8s"])).get("receiptWrite"));
|
|
146
|
+
});
|
|
147
|
+
|
|
127
148
|
test("unknown lexicon is skipped without throwing", async () => {
|
|
128
149
|
const a = await loadActivities(["definitely-not-a-lexicon"]);
|
|
129
150
|
expect(a.has("waitForStack")).toBe(true); // core base still loads
|