@intentius/chant 0.115.0 → 0.117.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/gate-resume.d.ts +44 -0
- package/dist/cli/handlers/gate-resume.d.ts.map +1 -0
- package/dist/cli/handlers/operator.d.ts +1 -0
- package/dist/cli/handlers/operator.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 +11 -1
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/lexicon.d.ts +22 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts +7 -0
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/op/gate-resume.d.ts +104 -0
- package/dist/op/gate-resume.d.ts.map +1 -0
- package/dist/op/gate.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 +158 -0
- package/dist/op/op-waves.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/cli/handlers/gate-resume.test.ts +51 -0
- package/src/cli/handlers/gate-resume.ts +166 -0
- package/src/cli/handlers/operator.ts +8 -2
- package/src/cli/handlers/run-generate.ts +51 -1
- package/src/cli/handlers/run-wave.ts +43 -0
- package/src/cli/main.ts +28 -2
- package/src/cli/registry.ts +11 -1
- package/src/lexicon.ts +26 -0
- package/src/lifecycle/gate-ledger.ts +7 -0
- package/src/op/gate-resume.e2e.test.ts +135 -0
- package/src/op/gate-resume.test.ts +177 -0
- package/src/op/gate-resume.ts +267 -0
- package/src/op/gate.ts +4 -0
- 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 +250 -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":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAKH,OAAO,EAA+B,KAAK,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAC9E,OAAO,EAAmD,KAAK,cAAc,EAAE,MAAM,QAAQ,CAAC;AAC9F,OAAO,EAML,KAAK,gBAAgB,EAErB,KAAK,WAAW,EACjB,MAAM,YAAY,CAAC;AAEpB,oDAAoD;AACpD,MAAM,WAAW,cAAc;IAC7B,UAAU,EAAE,MAAM,CAAC;IACnB,sFAAsF;IACtF,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,gCAAgC;IAChC,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED,kDAAkD;AAClD,MAAM,WAAW,YAAa,SAAQ,UAAU;IAC9C,EAAE,EAAE,MAAM,CAAC;IACX,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED,uCAAuC;AACvC,MAAM,MAAM,kBAAkB,GAAG,MAAM,GAAG,gBAAgB,CAAC;AAE3D,iFAAiF;AACjF,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,0DAA0D;IAC1D,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,gBAAgB,CAAC;IACzB,YAAY,EAAE,kBAAkB,CAAC;IACjC,8CAA8C;IAC9C,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,YAAY,EAAE,CAAC;IACxB,qHAAqH;IACrH,MAAM,EAAE,UAAU,GAAG,cAAc,GAAG,SAAS,CAAC;IAChD,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,2DAA2D;IAC3D,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,gFAAgF;AAChF,MAAM,MAAM,UAAU,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,GAAG,EAAE,MAAM,KAAK,MAAM,CAAC;AAQjE,kGAAkG;AAClG,MAAM,MAAM,gBAAgB,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,KAAK;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,CAAC;AAoBjH,2DAA2D;AAC3D,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,WAAW,CAazE;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAC9B,QAAQ,EAAE,MAAM,EAChB,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,EACZ,GAAG,EAAE,MAAM,EACX,IAAI,GAAE,gBAAgC,GACrC;IAAE,MAAM,EAAE,gBAAgB,CAAC;IAAC,MAAM,EAAE,kBAAkB,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAaxE;AAED,0CAA0C;AAC1C,wBAAgB,UAAU,CAAC,QAAQ,EAAE,SAAS,MAAM,EAAE,EAAE,MAAM,EAAE;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAAG,MAAM,EAAE,CAIpI;AAED,oCAAoC;AACpC,wBAAgB,cAAc,CAAC,IAAI,EAAE,IAAI,CAAC,WAAW,EAAE,MAAM,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAEpG;AAED,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,WAAW,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,eAAe;IACf,IAAI,EAAE,MAAM,CAAC;IACb,yDAAyD;IACzD,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,kDAAkD;IAClD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,iCAAiC;IACjC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,cAAc,CAAC;IACvB,IAAI,CAAC,EAAE,UAAU,CAAC;IAClB,IAAI,CAAC,EAAE,gBAAgB,CAAC;IACxB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,GAAG,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CAC9B;AAED,MAAM,WAAW,eAAe;IAC9B,4GAA4G;IAC5G,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,cAAc,CAAC;IAC1B,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,4EAA4E;IAC5E,KAAK,EAAE,MAAM,EAAE,CAAC;CACjB;AA8BD,mFAAmF;AACnF,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,gBAAgB,EAAE,OAAO,EAAE,SAAS,YAAY,EAAE,GAAG,OAAO,CAKvG;AAED,mEAAmE;AACnE,wBAAsB,SAAS,CAAC,OAAO,EAAE,gBAAgB,GAAG,OAAO,CAAC,eAAe,CAAC,CAiFnF"}
|
|
@@ -0,0 +1,158 @@
|
|
|
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
|
+
* A scheduled job that starts again a wave job whose approval has arrived
|
|
102
|
+
* (#3683): it runs `chant run resume --op <name>`, which re-runs the
|
|
103
|
+
* GitHub run, retries the GitLab job or dispatches the Forgejo workflow.
|
|
104
|
+
* It approves nothing. Absent, no such job is rendered and an approval is
|
|
105
|
+
* followed by `chant approve --resume` or a re-run by hand.
|
|
106
|
+
*/
|
|
107
|
+
resume?: OpWavesResume;
|
|
108
|
+
}
|
|
109
|
+
/** The scheduled resume job of an Op waves pipeline (#3683). */
|
|
110
|
+
export interface OpWavesResume {
|
|
111
|
+
/**
|
|
112
|
+
* Five-field cron, such as `"*\/10 * * * *"`. GitHub and Forgejo put it
|
|
113
|
+
* in the workflow; GitLab runs the job in a pipeline schedule you create,
|
|
114
|
+
* which sets the cron.
|
|
115
|
+
*/
|
|
116
|
+
schedule: string;
|
|
117
|
+
}
|
|
118
|
+
/** The command a resume job runs. */
|
|
119
|
+
export declare function opWavesResumeCommand(spec: Pick<OpWavesSpec, "name">): string[];
|
|
120
|
+
/** The default apply command. */
|
|
121
|
+
export declare const OP_WAVE_DEFAULT_APPLY: string[];
|
|
122
|
+
/** Where the runner keeps a wave's decision; a wide wave's deciding job hands it to its share jobs. */
|
|
123
|
+
export declare function opWaveRecordPath(spec: Pick<OpWavesSpec, "name">, wave: number): string;
|
|
124
|
+
/** One generated job. */
|
|
125
|
+
export interface OpWaveJob {
|
|
126
|
+
jobName: string;
|
|
127
|
+
/** 1-based wave number. */
|
|
128
|
+
wave: number;
|
|
129
|
+
/** The wave's name. */
|
|
130
|
+
waveName: string;
|
|
131
|
+
/** `wave`: plan, decide and apply. `decide`: plan and decide. `share`: apply one slice. */
|
|
132
|
+
kind: "wave" | "decide" | "share";
|
|
133
|
+
/** 1-based, on a share job. */
|
|
134
|
+
share?: number;
|
|
135
|
+
/** Jobs this one waits on. */
|
|
136
|
+
needs: string[];
|
|
137
|
+
/** The job's command, as argv. */
|
|
138
|
+
command: string[];
|
|
139
|
+
/** On a share job: the decision record to download. On a deciding job: the one to upload. */
|
|
140
|
+
record?: string;
|
|
141
|
+
}
|
|
142
|
+
/** Refuse a spec no job could run, naming what is wrong. */
|
|
143
|
+
export declare function assertOpWavesSpec(spec: OpWavesSpec): void;
|
|
144
|
+
/**
|
|
145
|
+
* The runs share `share` (1-based) of `shares` applies: the wave's runs sorted
|
|
146
|
+
* by target, cut into contiguous slices whose sizes differ by at most one.
|
|
147
|
+
*/
|
|
148
|
+
export declare function opWaveShare<T extends {
|
|
149
|
+
target: string;
|
|
150
|
+
}>(runs: readonly T[], share: number, shares: number): T[];
|
|
151
|
+
/**
|
|
152
|
+
* The jobs of an Op waves pipeline, in order. `specFile` is the spec's path
|
|
153
|
+
* from the job's working directory, which every command names.
|
|
154
|
+
*/
|
|
155
|
+
export declare function opWaveJobs(spec: OpWavesSpec, specFile: string): OpWaveJob[];
|
|
156
|
+
/** The wave a job belongs to. */
|
|
157
|
+
export declare function opWaveOf(spec: OpWavesSpec, job: Pick<OpWaveJob, "wave">): OpWave;
|
|
158
|
+
//# 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":"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"}
|
package/package.json
CHANGED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import type { GateResolutionRecord, PendingGateRecord } from "../../lifecycle/gate-ledger";
|
|
3
|
+
import type { ForgeFetch } from "../../pr-forge";
|
|
4
|
+
import { parseArgs } from "../main";
|
|
5
|
+
import { resumeStanding } from "./gate-resume";
|
|
6
|
+
|
|
7
|
+
const DIGEST = `sha256:${"a".repeat(64)}`;
|
|
8
|
+
const standing: PendingGateRecord = {
|
|
9
|
+
version: 1,
|
|
10
|
+
kind: "pending",
|
|
11
|
+
op: "migrations",
|
|
12
|
+
gate: "migrations-wave-2",
|
|
13
|
+
timestamp: "2026-10-01T00:00:00Z",
|
|
14
|
+
expiresAt: "2026-10-03T00:00:00Z",
|
|
15
|
+
planDigest: DIGEST,
|
|
16
|
+
resume: { forge: "github", api: "https://api.github.com", repo: "acme/db", run: "42", attempt: 1 },
|
|
17
|
+
};
|
|
18
|
+
const approval: GateResolutionRecord = { version: 1, op: "migrations", gate: "migrations-wave-2", resolvedBy: "alice", timestamp: "2026-10-01T01:00:00Z", planDigest: DIGEST };
|
|
19
|
+
|
|
20
|
+
describe("resumeStanding (#3683)", () => {
|
|
21
|
+
test("no approval, no call", async () => {
|
|
22
|
+
const fetch: ForgeFetch = async () => {
|
|
23
|
+
throw new Error("no call expected");
|
|
24
|
+
};
|
|
25
|
+
expect(await resumeStanding(standing, [], { env: { GITHUB_TOKEN: "t" }, fetch })).toMatchObject({ status: "not-approved" });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("approved: re-runs the run the pending fact names", async () => {
|
|
29
|
+
const calls: string[] = [];
|
|
30
|
+
const fetch: ForgeFetch = async (url, init) => {
|
|
31
|
+
calls.push(`${init.method} ${new URL(url).pathname}`);
|
|
32
|
+
const body = init.method === "GET" ? { status: "completed", conclusion: "failure", run_attempt: 1 } : null;
|
|
33
|
+
return { ok: true, status: 200, text: async () => (body ? JSON.stringify(body) : "") };
|
|
34
|
+
};
|
|
35
|
+
const report = await resumeStanding(standing, [approval], { env: { GITHUB_TOKEN: "t" }, fetch });
|
|
36
|
+
expect(report).toMatchObject({ status: "resumed", approvedBy: "alice" });
|
|
37
|
+
expect(calls).toEqual(["GET /repos/acme/db/actions/runs/42", "POST /repos/acme/db/actions/runs/42/rerun-failed-jobs"]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("a fact recorded outside CI, or no token, resumes nothing and says why", async () => {
|
|
41
|
+
const { resume: _none, ...local } = standing;
|
|
42
|
+
expect(await resumeStanding(local, [approval], { env: { GITHUB_TOKEN: "t" } })).toMatchObject({ status: "no-job" });
|
|
43
|
+
expect(await resumeStanding(standing, [approval], { env: {} })).toMatchObject({ status: "no-token" });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("`--resume` takes no value on approve, and keeps its file on the applies", () => {
|
|
47
|
+
expect(parseArgs(["approve", "migrations", "migrations-wave-2", "--resume"]).resumeRun).toBe(true);
|
|
48
|
+
expect(parseArgs(["approve", "migrations", "g", "--resume", "--sign"])).toMatchObject({ resumeRun: true, sign: true });
|
|
49
|
+
expect(parseArgs(["components", "pr-apply", "--resume", "attempt.json"]).resume).toBe("attempt.json");
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `chant approve <op> <gate> --resume` and `chant run resume` (#3683): start
|
|
3
|
+
* the CI job that waits at a gate again once its approval has arrived. See
|
|
4
|
+
* `../../op/gate-resume.ts` for what each forge is asked and the token it
|
|
5
|
+
* needs. Neither approves anything.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
GATES_DIR,
|
|
10
|
+
isPendingGateExpired,
|
|
11
|
+
readGateLedger,
|
|
12
|
+
type GateResolutionRecord,
|
|
13
|
+
type PendingGateRecord,
|
|
14
|
+
} from "../../lifecycle/gate-ledger";
|
|
15
|
+
import { listFilesInDir, requireLifecycleLedger } from "../../lifecycle/git";
|
|
16
|
+
import {
|
|
17
|
+
isGateRunLocator,
|
|
18
|
+
resolutionAnswering,
|
|
19
|
+
resumeGateRun,
|
|
20
|
+
resumeTokenFromEnv,
|
|
21
|
+
type GateResumeOutcome,
|
|
22
|
+
} from "../../op/gate-resume";
|
|
23
|
+
import type { ForgeFetch } from "../../pr-forge";
|
|
24
|
+
import { formatError, formatInfo, formatSuccess, formatWarning } from "../format";
|
|
25
|
+
import type { CommandContext } from "../registry";
|
|
26
|
+
|
|
27
|
+
/** One waiting gate and what resuming it did. */
|
|
28
|
+
export interface GateResumeReport {
|
|
29
|
+
op: string;
|
|
30
|
+
gate: string;
|
|
31
|
+
environment?: string;
|
|
32
|
+
/** `resumed` and `skipped` come from the forge; the rest are decided before any call. */
|
|
33
|
+
status: GateResumeOutcome["status"] | "not-approved" | "no-job" | "no-token" | "failed";
|
|
34
|
+
message: string;
|
|
35
|
+
approvedBy?: string;
|
|
36
|
+
url?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The newest pending fact per gate and environment: the facts a run still waits on. */
|
|
40
|
+
function standingFacts(pending: readonly PendingGateRecord[], gate?: string): PendingGateRecord[] {
|
|
41
|
+
const newest = new Map<string, PendingGateRecord>();
|
|
42
|
+
for (const p of pending) {
|
|
43
|
+
if (gate !== undefined && p.gate !== gate) continue;
|
|
44
|
+
const key = `${p.gate}\u0000${p.environment ?? ""}`;
|
|
45
|
+
const prior = newest.get(key);
|
|
46
|
+
if (!prior || new Date(p.timestamp).getTime() >= new Date(prior.timestamp).getTime()) newest.set(key, p);
|
|
47
|
+
}
|
|
48
|
+
return [...newest.values()];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface ResumeGateOptions {
|
|
52
|
+
env?: Record<string, string | undefined>;
|
|
53
|
+
fetch?: ForgeFetch;
|
|
54
|
+
now?: string;
|
|
55
|
+
dryRun?: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Resume the job behind one standing pending fact, when an approval answers it. */
|
|
59
|
+
export async function resumeStanding(
|
|
60
|
+
standing: PendingGateRecord,
|
|
61
|
+
resolutions: readonly GateResolutionRecord[],
|
|
62
|
+
opts: ResumeGateOptions = {},
|
|
63
|
+
): Promise<GateResumeReport> {
|
|
64
|
+
const base = {
|
|
65
|
+
op: standing.op,
|
|
66
|
+
gate: standing.gate,
|
|
67
|
+
...(standing.environment !== undefined ? { environment: standing.environment } : {}),
|
|
68
|
+
...(standing.resume?.url ? { url: standing.resume.url } : {}),
|
|
69
|
+
};
|
|
70
|
+
const answer = resolutionAnswering(resolutions, standing);
|
|
71
|
+
if (!answer) return { ...base, status: "not-approved", message: "no approval answers it yet" };
|
|
72
|
+
const approvedBy = { approvedBy: answer.resolvedBy };
|
|
73
|
+
if (!isGateRunLocator(standing.resume)) {
|
|
74
|
+
return { ...base, ...approvedBy, status: "no-job", message: "its pending fact names no CI job (it was recorded outside CI); run it again by hand" };
|
|
75
|
+
}
|
|
76
|
+
const loc = standing.resume;
|
|
77
|
+
if (opts.dryRun) return { ...base, ...approvedBy, status: "skipped", message: `would resume ${loc.forge} run ${loc.run} (--dry-run)` };
|
|
78
|
+
const token = resumeTokenFromEnv(loc.forge, opts.env ?? process.env);
|
|
79
|
+
if (!token) {
|
|
80
|
+
return {
|
|
81
|
+
...base,
|
|
82
|
+
...approvedBy,
|
|
83
|
+
status: "no-token",
|
|
84
|
+
message: `no token to call ${loc.forge} with: set CHANT_FORGE_TOKEN (or ${loc.forge === "gitlab" ? "GITLAB_TOKEN" : "GITHUB_TOKEN"})`,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
const outcome = await resumeGateRun(loc, { token, since: answer.timestamp, ...(opts.fetch ? { fetch: opts.fetch } : {}) });
|
|
89
|
+
return { ...base, ...approvedBy, status: outcome.status, message: outcome.status === "resumed" ? outcome.how : outcome.reason };
|
|
90
|
+
} catch (err) {
|
|
91
|
+
return { ...base, ...approvedBy, status: "failed", message: err instanceof Error ? err.message : String(err) };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Resume every job waiting on `op`'s gates (or one gate) whose approval arrived. */
|
|
96
|
+
export async function resumeOpGates(op: string, gate: string | undefined, opts: ResumeGateOptions = {}): Promise<GateResumeReport[]> {
|
|
97
|
+
const ledger = await readGateLedger(op);
|
|
98
|
+
const now = opts.now ?? new Date().toISOString();
|
|
99
|
+
const reports: GateResumeReport[] = [];
|
|
100
|
+
for (const standing of standingFacts(ledger.pending, gate)) {
|
|
101
|
+
if (isPendingGateExpired(standing, now)) continue;
|
|
102
|
+
reports.push(await resumeStanding(standing, ledger.resolutions, opts));
|
|
103
|
+
}
|
|
104
|
+
return reports;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function printReport(r: GateResumeReport): void {
|
|
108
|
+
const where = `${r.op} / ${r.gate}${r.environment !== undefined ? ` (${r.environment})` : ""}`;
|
|
109
|
+
const by = r.approvedBy ? `, approved by ${r.approvedBy}` : "";
|
|
110
|
+
const link = r.url ? ` ${r.url}` : "";
|
|
111
|
+
if (r.status === "resumed") console.error(formatSuccess(`${where}${by}: ${r.message}${link}`));
|
|
112
|
+
else if (r.status === "failed" || r.status === "no-token") {
|
|
113
|
+
console.error(formatError({
|
|
114
|
+
message: `${where}${by}: not resumed: ${r.message}`,
|
|
115
|
+
hint: "GitHub needs actions: write; GitLab a token with the api scope and the Developer role; Forgejo a token with write:repository.",
|
|
116
|
+
}));
|
|
117
|
+
} else console.error(formatInfo(`${where}${by}: not resumed: ${r.message}${link}`));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* What `chant approve --resume` runs once the approval is recorded and
|
|
122
|
+
* pushed. Returns the exit code: 0 when the job was resumed or had nothing to
|
|
123
|
+
* resume, 1 when a forge call failed or no token was found.
|
|
124
|
+
*/
|
|
125
|
+
export async function resumeAfterApproval(op: string, gate: string, environment: string | undefined, pushed: boolean): Promise<number> {
|
|
126
|
+
if (!pushed) {
|
|
127
|
+
console.error(formatWarning({
|
|
128
|
+
message: "Not resuming: the approval did not reach the remote, so the resumed job would not see it.",
|
|
129
|
+
hint: "Push chant/lifecycle, then run `chant run resume --op " + op + "`.",
|
|
130
|
+
}));
|
|
131
|
+
return 1;
|
|
132
|
+
}
|
|
133
|
+
const reports = (await resumeOpGates(op, gate)).filter((r) => environment === undefined || r.environment === environment);
|
|
134
|
+
if (reports.length === 0) {
|
|
135
|
+
console.error(formatInfo(`No pending fact for ${op} / ${gate} is waiting, so there is nothing to resume.`));
|
|
136
|
+
return 0;
|
|
137
|
+
}
|
|
138
|
+
for (const r of reports) printReport(r);
|
|
139
|
+
return reports.some((r) => r.status === "failed" || r.status === "no-token") ? 1 : 0;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* `chant run resume [--op <op>] [--dry-run] [--json]`: the scheduled job.
|
|
144
|
+
* Reads every gate ledger on `chant/lifecycle` (or `--op`'s), and starts
|
|
145
|
+
* again each CI job that waits at a gate an approval now answers.
|
|
146
|
+
*/
|
|
147
|
+
export async function runResumeCommand(ctx: CommandContext): Promise<number> {
|
|
148
|
+
try {
|
|
149
|
+
await requireLifecycleLedger();
|
|
150
|
+
} catch (err) {
|
|
151
|
+
console.error(formatError({ message: `Cannot read the gate ledgers: ${err instanceof Error ? err.message : String(err)}` }));
|
|
152
|
+
return 1;
|
|
153
|
+
}
|
|
154
|
+
const ops = ctx.args.op
|
|
155
|
+
? [ctx.args.op]
|
|
156
|
+
: (await listFilesInDir(GATES_DIR)).filter((f) => f.endsWith(".jsonl")).map((f) => f.slice(0, -".jsonl".length));
|
|
157
|
+
const reports: GateResumeReport[] = [];
|
|
158
|
+
for (const op of ops) reports.push(...(await resumeOpGates(op, undefined, { dryRun: ctx.args.dryRun === true })));
|
|
159
|
+
if (ctx.args.json) console.log(JSON.stringify({ reports }, null, 2));
|
|
160
|
+
else {
|
|
161
|
+
for (const r of reports.filter((r) => r.status !== "not-approved")) printReport(r);
|
|
162
|
+
const waiting = reports.filter((r) => r.status === "not-approved").length;
|
|
163
|
+
console.error(formatInfo(`${reports.filter((r) => r.status === "resumed").length} resumed, ${waiting} still waiting for an approval.`));
|
|
164
|
+
}
|
|
165
|
+
return reports.some((r) => r.status === "failed" || r.status === "no-token") ? 1 : 0;
|
|
166
|
+
}
|
|
@@ -911,6 +911,12 @@ export async function runApprove(ctx: CommandContext): Promise<number> {
|
|
|
911
911
|
});
|
|
912
912
|
if (!outcome.ok) return 1;
|
|
913
913
|
|
|
914
|
+
// #3683: start the CI job that waits at this gate again.
|
|
915
|
+
if (ctx.args.resumeRun || ctx.args.resume !== undefined) {
|
|
916
|
+
const { resumeAfterApproval } = await import("./gate-resume");
|
|
917
|
+
return resumeAfterApproval(opName, gate, outcome.record.environment, outcome.pushed);
|
|
918
|
+
}
|
|
919
|
+
|
|
914
920
|
console.error(formatInfo(
|
|
915
921
|
`This records the resolution as a fact; it does not itself re-run anything. ` +
|
|
916
922
|
(opName === FAN_OUT_GATE_OP
|
|
@@ -988,7 +994,7 @@ export interface GateApprovalOptions {
|
|
|
988
994
|
}
|
|
989
995
|
|
|
990
996
|
export type GateApprovalOutcome =
|
|
991
|
-
| { ok: true; record: GateResolutionRecord }
|
|
997
|
+
| { ok: true; record: GateResolutionRecord; pushed: boolean }
|
|
992
998
|
| { ok: false };
|
|
993
999
|
|
|
994
1000
|
/**
|
|
@@ -1313,7 +1319,7 @@ export async function recordGateApproval(
|
|
|
1313
1319
|
console.error(formatInfo(line));
|
|
1314
1320
|
}
|
|
1315
1321
|
}
|
|
1316
|
-
return { ok: true, record };
|
|
1322
|
+
return { ok: true, record, pushed };
|
|
1317
1323
|
}
|
|
1318
1324
|
|
|
1319
1325
|
/**
|
|
@@ -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
|
+
}
|