@intentius/chant 0.115.0 → 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/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/lexicon.d.ts +22 -0
- package/dist/lexicon.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/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/lexicon.ts +26 -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 +224 -0
|
@@ -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/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
|
|
@@ -25,7 +25,9 @@ import {
|
|
|
25
25
|
type ScheduledOpSpec,
|
|
26
26
|
type OpPipelineFile,
|
|
27
27
|
type OpPipelineJob,
|
|
28
|
+
type OpWavesPipelineOptions,
|
|
28
29
|
} from "../lexicon";
|
|
30
|
+
import { assertOpWavesSpec, type OpWaveJob, type OpWavesSpec } from "./op-waves";
|
|
29
31
|
|
|
30
32
|
/**
|
|
31
33
|
* Load a lexicon's plugin (`@intentius/chant-lexicon-<name>`) to reach its
|
|
@@ -115,3 +117,50 @@ export async function generateOpsPipeline(
|
|
|
115
117
|
const { files, jobs } = plugin.generateOpPipeline(withOpSchedules(ops, discovered.ops), options);
|
|
116
118
|
return { success: true, files, jobs };
|
|
117
119
|
}
|
|
120
|
+
|
|
121
|
+
/** Result of generating an Op waves pipeline (#3679). */
|
|
122
|
+
export interface GenerateOpWavesPipelineResult {
|
|
123
|
+
success: boolean;
|
|
124
|
+
files?: OpPipelineFile[];
|
|
125
|
+
jobs?: OpWaveJob[];
|
|
126
|
+
error?: string;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Render ordered waves of Op runs for `lexicon` (#3679, `./op-waves.ts`):
|
|
131
|
+
* check the spec, check that every Op it names exists, then hand it to the
|
|
132
|
+
* lexicon's `generateOpWavesPipeline`.
|
|
133
|
+
*/
|
|
134
|
+
export async function generateOpWavesPipeline(
|
|
135
|
+
spec: OpWavesSpec,
|
|
136
|
+
lexicon: string,
|
|
137
|
+
options: OpWavesPipelineOptions,
|
|
138
|
+
cwd?: string,
|
|
139
|
+
): Promise<GenerateOpWavesPipelineResult> {
|
|
140
|
+
const plugin = await loadLexiconPlugin(lexicon);
|
|
141
|
+
if (!plugin?.generateOpWavesPipeline) {
|
|
142
|
+
return {
|
|
143
|
+
success: false,
|
|
144
|
+
error: `Lexicon "${lexicon}" does not render Op waves (no generateOpWavesPipeline). GitLab, GitHub, and Forgejo do.`,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
assertOpWavesSpec(spec);
|
|
149
|
+
} catch (err) {
|
|
150
|
+
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
|
151
|
+
}
|
|
152
|
+
const discovered = await discoverOps({ cwd });
|
|
153
|
+
if (discovered.errors.length > 0) return { success: false, error: discovered.errors.join("\n") };
|
|
154
|
+
const named = [...new Set([spec.op, ...spec.waves.flatMap((w) => w.runs.map((r) => r.op ?? spec.op))])];
|
|
155
|
+
const unknown = named.filter((name) => !discovered.ops.has(name));
|
|
156
|
+
if (unknown.length > 0) {
|
|
157
|
+
const known = [...discovered.ops.keys()].sort().join(", ");
|
|
158
|
+
return { success: false, error: `Unknown Op(s): ${unknown.join(", ")}.${known ? ` Known Ops: ${known}` : " No Ops discovered."}` };
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
const { files, jobs } = plugin.generateOpWavesPipeline(spec, options);
|
|
162
|
+
return { success: true, files, jobs };
|
|
163
|
+
} catch (err) {
|
|
164
|
+
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
|
165
|
+
}
|
|
166
|
+
}
|
package/src/op/index.ts
CHANGED
|
@@ -49,8 +49,19 @@ export type {
|
|
|
49
49
|
} from "./receipt-store";
|
|
50
50
|
export { discoverOps } from "./discover";
|
|
51
51
|
export type { DiscoveredOp, OpDiscoveryResult } from "./discover";
|
|
52
|
-
export { generateOpsPipeline, withOpSchedules } from "./generate-pipeline";
|
|
53
|
-
export type { GenerateOpsPipelineResult } from "./generate-pipeline";
|
|
52
|
+
export { generateOpsPipeline, generateOpWavesPipeline, withOpSchedules } from "./generate-pipeline";
|
|
53
|
+
export type { GenerateOpsPipelineResult, GenerateOpWavesPipelineResult } from "./generate-pipeline";
|
|
54
|
+
export {
|
|
55
|
+
OP_WAVE_DEFAULT_APPLY,
|
|
56
|
+
OP_WAVE_GATE_POLICIES,
|
|
57
|
+
assertOpWavesSpec,
|
|
58
|
+
opWaveJobs,
|
|
59
|
+
opWaveRecordPath,
|
|
60
|
+
opWaveShare,
|
|
61
|
+
} from "./op-waves";
|
|
62
|
+
export type { OpWave, OpWaveGatePolicy, OpWaveJob, OpWaveRun, OpWavesSpec } from "./op-waves";
|
|
63
|
+
export { opWaveNeedsApproval, parseOpWavesSpec, readOpWavePolicy, runOpWave } from "./op-waves-run";
|
|
64
|
+
export type { OpWaveDecision, OpWaveMember, OpWavePlanFile, RunOpWaveOptions, RunOpWaveResult } from "./op-waves-run";
|
|
54
65
|
export { loadActivities, loadProfiles, resolveActivity } from "./activity-registry";
|
|
55
66
|
export type { ActivityFn } from "./activity-registry";
|
|
56
67
|
export { loadActivityContracts, mergeActivityContracts } from "./activity-contract-registry";
|
|
@@ -0,0 +1,325 @@
|
|
|
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
|
+
|
|
19
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
20
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
21
|
+
import { dirname, join, posix } from "node:path";
|
|
22
|
+
import { waveGateName, waveSetDigest, type WaveMember } from "../gated-waves";
|
|
23
|
+
import { approveCommand, evaluateGate, gitGateLedgerPort, type GateLedgerPort } from "./gate";
|
|
24
|
+
import {
|
|
25
|
+
OP_WAVE_DEFAULT_APPLY,
|
|
26
|
+
OP_WAVE_GATE_POLICIES,
|
|
27
|
+
assertOpWavesSpec,
|
|
28
|
+
opWaveRecordPath,
|
|
29
|
+
opWaveShare,
|
|
30
|
+
type OpWaveGatePolicy,
|
|
31
|
+
type OpWaveRun,
|
|
32
|
+
type OpWavesSpec,
|
|
33
|
+
} from "./op-waves";
|
|
34
|
+
|
|
35
|
+
/** What a run's plan command writes to `{plan}`. */
|
|
36
|
+
export interface OpWavePlanFile {
|
|
37
|
+
planDigest: string;
|
|
38
|
+
/** The plan destroys, rewrites or otherwise needs a person under `on-destructive`. */
|
|
39
|
+
destructive?: boolean;
|
|
40
|
+
/** The plan changes nothing. */
|
|
41
|
+
empty?: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** One run's plan, as the decision records it. */
|
|
45
|
+
export interface OpWaveMember extends WaveMember {
|
|
46
|
+
op: string;
|
|
47
|
+
destructive?: boolean;
|
|
48
|
+
empty?: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Where a wave's policy came from. */
|
|
52
|
+
export type OpWavePolicySource = "base" | "absent-at-base";
|
|
53
|
+
|
|
54
|
+
/** A wave's decision: what was planned, the policy, and whether it may apply. */
|
|
55
|
+
export interface OpWaveDecision {
|
|
56
|
+
wave: number;
|
|
57
|
+
name: string;
|
|
58
|
+
/** The op the gate is recorded under: the spec's name. */
|
|
59
|
+
op: string;
|
|
60
|
+
gate: string;
|
|
61
|
+
policy: OpWaveGatePolicy;
|
|
62
|
+
policySource: OpWavePolicySource;
|
|
63
|
+
/** The base commit the policy was read at. */
|
|
64
|
+
base: string;
|
|
65
|
+
digest: string;
|
|
66
|
+
members: OpWaveMember[];
|
|
67
|
+
/** `approved`: an approval of this digest stands. `not-required`: the policy lets it through. `waiting`: neither. */
|
|
68
|
+
status: "approved" | "not-required" | "waiting";
|
|
69
|
+
approvedBy?: string;
|
|
70
|
+
/** The command that approves this digest, when waiting. */
|
|
71
|
+
approve?: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Runs one argv; returns its exit code. Injected so tests run no processes. */
|
|
75
|
+
export type OpWaveExec = (argv: string[], cwd: string) => number;
|
|
76
|
+
|
|
77
|
+
const spawnExec: OpWaveExec = (argv, cwd) => {
|
|
78
|
+
const result = spawnSync(argv[0]!, argv.slice(1), { cwd, stdio: "inherit" });
|
|
79
|
+
if (result.error) throw result.error;
|
|
80
|
+
return result.status ?? 1;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** Reads one file at one commit, or null when the commit has no such file. Injected for tests. */
|
|
84
|
+
export type OpWaveShowAtBase = (base: string, path: string, cwd: string) => { sha: string; text: string | null };
|
|
85
|
+
|
|
86
|
+
const gitShowAtBase: OpWaveShowAtBase = (base, path, cwd) => {
|
|
87
|
+
let sha: string;
|
|
88
|
+
try {
|
|
89
|
+
sha = execFileSync("git", ["rev-parse", "--verify", `${base}^{commit}`], { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
90
|
+
} catch {
|
|
91
|
+
throw new Error(
|
|
92
|
+
`the gate policy is read at ${base}, which this checkout does not have. ` +
|
|
93
|
+
`Check out the full history (fetch-depth: 0 on GitHub and Forgejo, GIT_DEPTH: "0" on GitLab), or set the spec's base.`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
const text = execFileSync("git", ["show", `${sha}:./${path}`], { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] });
|
|
98
|
+
return { sha, text };
|
|
99
|
+
} catch {
|
|
100
|
+
return { sha, text: null };
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/** Read a spec file, refusing anything that is not one. */
|
|
105
|
+
export function parseOpWavesSpec(text: string, where: string): OpWavesSpec {
|
|
106
|
+
let parsed: unknown;
|
|
107
|
+
try {
|
|
108
|
+
parsed = JSON.parse(text);
|
|
109
|
+
} catch (err) {
|
|
110
|
+
throw new Error(`${where}: ${err instanceof Error ? err.message : String(err)}`);
|
|
111
|
+
}
|
|
112
|
+
const waves = (parsed as { waves?: unknown } | null)?.waves;
|
|
113
|
+
const spec = (Array.isArray(waves) ? parsed : waves) as OpWavesSpec | undefined;
|
|
114
|
+
if (!spec || typeof spec !== "object" || !Array.isArray(spec.waves)) {
|
|
115
|
+
throw new Error(`${where} must hold an Op waves spec: { "name", "op", "plan", "waves": [...] }, or { "waves": <that>, "options": {...} }`);
|
|
116
|
+
}
|
|
117
|
+
return spec;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The policy of wave `name`, as the spec file at `base` declares it. The
|
|
122
|
+
* file or the wave missing there reads as `always`: a change that adds a
|
|
123
|
+
* wave, or the file, waits for a person the first time.
|
|
124
|
+
*/
|
|
125
|
+
export function readOpWavePolicy(
|
|
126
|
+
specFile: string,
|
|
127
|
+
name: string,
|
|
128
|
+
base: string,
|
|
129
|
+
cwd: string,
|
|
130
|
+
show: OpWaveShowAtBase = gitShowAtBase,
|
|
131
|
+
): { policy: OpWaveGatePolicy; source: OpWavePolicySource; base: string } {
|
|
132
|
+
const { sha, text } = show(base, specFile.replace(/^\.\//, "").split("\\").join(posix.sep), cwd);
|
|
133
|
+
if (text === null) return { policy: "always", source: "absent-at-base", base: sha };
|
|
134
|
+
let spec: OpWavesSpec;
|
|
135
|
+
try {
|
|
136
|
+
spec = parseOpWavesSpec(text, `${specFile} at ${base}`);
|
|
137
|
+
} catch {
|
|
138
|
+
return { policy: "always", source: "absent-at-base", base: sha };
|
|
139
|
+
}
|
|
140
|
+
const wave = spec.waves.find((w) => w.name === name);
|
|
141
|
+
if (!wave) return { policy: "always", source: "absent-at-base", base: sha };
|
|
142
|
+
const policy = wave.gate ?? "always";
|
|
143
|
+
return { policy: OP_WAVE_GATE_POLICIES.includes(policy) ? policy : "always", source: "base", base: sha };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Fill one argv template for one run. */
|
|
147
|
+
export function opWaveArgv(template: readonly string[], values: { op: string; target: string; wave: string; plan: string }): string[] {
|
|
148
|
+
return template.map((part) =>
|
|
149
|
+
part.replaceAll("{op}", values.op).replaceAll("{target}", values.target).replaceAll("{wave}", values.wave).replaceAll("{plan}", values.plan),
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Where a run's plan file goes. */
|
|
154
|
+
export function opWavePlanPath(spec: Pick<OpWavesSpec, "name">, wave: number, target: string): string {
|
|
155
|
+
return `.chant/op-waves/${spec.name}/wave-${wave}/${target.replace(/[^A-Za-z0-9._-]/g, "_")}.plan.json`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export interface RunOpWaveOptions {
|
|
159
|
+
spec: OpWavesSpec;
|
|
160
|
+
specFile: string;
|
|
161
|
+
/** 1-based. */
|
|
162
|
+
wave: number;
|
|
163
|
+
/** Plan and decide only (a wide wave's deciding job). */
|
|
164
|
+
decide?: boolean;
|
|
165
|
+
/** Apply this 1-based share of a decided wave. */
|
|
166
|
+
share?: number;
|
|
167
|
+
/** Overrides the spec's base. */
|
|
168
|
+
base?: string;
|
|
169
|
+
cwd?: string;
|
|
170
|
+
gates?: GateLedgerPort;
|
|
171
|
+
exec?: OpWaveExec;
|
|
172
|
+
show?: OpWaveShowAtBase;
|
|
173
|
+
now?: string;
|
|
174
|
+
log?: (line: string) => void;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export interface RunOpWaveResult {
|
|
178
|
+
/** 0 applied (or decided), 1 a run failed, 3 waiting for an approval, 4 a plan moved since the decision. */
|
|
179
|
+
exitCode: number;
|
|
180
|
+
decision?: OpWaveDecision;
|
|
181
|
+
applied: string[];
|
|
182
|
+
failed: string[];
|
|
183
|
+
/** Runs a share job refused because their plan moved since the decision. */
|
|
184
|
+
moved: string[];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function planRun(
|
|
188
|
+
opts: Required<Pick<RunOpWaveOptions, "spec" | "wave">> & { cwd: string; exec: OpWaveExec },
|
|
189
|
+
waveName: string,
|
|
190
|
+
run: OpWaveRun,
|
|
191
|
+
): OpWaveMember {
|
|
192
|
+
const op = run.op ?? opts.spec.op;
|
|
193
|
+
const plan = opWavePlanPath(opts.spec, opts.wave, run.target);
|
|
194
|
+
mkdirSync(dirname(join(opts.cwd, plan)), { recursive: true });
|
|
195
|
+
const code = opts.exec(opWaveArgv(opts.spec.plan, { op, target: run.target, wave: waveName, plan }), opts.cwd);
|
|
196
|
+
if (code !== 0) throw new Error(`planning ${run.target} exited ${code}`);
|
|
197
|
+
let file: OpWavePlanFile;
|
|
198
|
+
try {
|
|
199
|
+
file = JSON.parse(readFileSync(join(opts.cwd, plan), "utf-8")) as OpWavePlanFile;
|
|
200
|
+
} catch (err) {
|
|
201
|
+
throw new Error(`planning ${run.target} wrote no readable plan file at ${plan}: ${err instanceof Error ? err.message : String(err)}`);
|
|
202
|
+
}
|
|
203
|
+
if (typeof file.planDigest !== "string" || file.planDigest === "") {
|
|
204
|
+
throw new Error(`the plan file for ${run.target} (${plan}) has no planDigest`);
|
|
205
|
+
}
|
|
206
|
+
return {
|
|
207
|
+
member: run.target,
|
|
208
|
+
planDigest: file.planDigest,
|
|
209
|
+
op,
|
|
210
|
+
...(file.destructive === true ? { destructive: true } : {}),
|
|
211
|
+
...(file.empty === true ? { empty: true } : {}),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Whether `policy` lets a wave with these members through without an approval. */
|
|
216
|
+
export function opWaveNeedsApproval(policy: OpWaveGatePolicy, members: readonly OpWaveMember[]): boolean {
|
|
217
|
+
if (policy === "never") return false;
|
|
218
|
+
if (members.every((m) => m.empty === true)) return false;
|
|
219
|
+
if (policy === "on-destructive") return members.some((m) => m.destructive === true);
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Plan, decide and apply one wave, or one part of a wide wave. */
|
|
224
|
+
export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveResult> {
|
|
225
|
+
const { spec, wave: k } = options;
|
|
226
|
+
assertOpWavesSpec(spec);
|
|
227
|
+
const cwd = options.cwd ?? process.cwd();
|
|
228
|
+
const exec = options.exec ?? spawnExec;
|
|
229
|
+
const log = options.log ?? ((line: string) => console.error(line));
|
|
230
|
+
const wave = spec.waves[k - 1];
|
|
231
|
+
if (!wave) throw new Error(`Op waves "${spec.name}" has ${spec.waves.length} waves; there is no wave ${k}.`);
|
|
232
|
+
const shares = wave.shares ?? 1;
|
|
233
|
+
if (options.share !== undefined && (options.share < 1 || options.share > shares)) {
|
|
234
|
+
throw new Error(`wave ${k} (${wave.name}) has ${shares} share${shares === 1 ? "" : "s"}; there is no share ${options.share}.`);
|
|
235
|
+
}
|
|
236
|
+
const ctx = { spec, wave: k, cwd, exec };
|
|
237
|
+
const recordPath = join(cwd, opWaveRecordPath(spec, k));
|
|
238
|
+
const result: RunOpWaveResult = { exitCode: 0, applied: [], failed: [], moved: [] };
|
|
239
|
+
|
|
240
|
+
if (options.share !== undefined) {
|
|
241
|
+
let decision: OpWaveDecision;
|
|
242
|
+
try {
|
|
243
|
+
decision = JSON.parse(readFileSync(recordPath, "utf-8")) as OpWaveDecision;
|
|
244
|
+
} catch {
|
|
245
|
+
throw new Error(`share ${options.share} of wave ${k} (${wave.name}) found no decision at ${opWaveRecordPath(spec, k)}. Run the deciding job first.`);
|
|
246
|
+
}
|
|
247
|
+
result.decision = decision;
|
|
248
|
+
if (decision.status === "waiting") {
|
|
249
|
+
log(`wave ${k} (${wave.name}) is waiting on ${decision.gate}; nothing applied. ${decision.approve ?? ""}`.trim());
|
|
250
|
+
return { ...result, exitCode: 3 };
|
|
251
|
+
}
|
|
252
|
+
const decided = new Map(decision.members.map((m) => [m.member, m.planDigest]));
|
|
253
|
+
for (const run of opWaveShare(wave.runs, options.share, shares)) {
|
|
254
|
+
const member = planRun(ctx, wave.name, run);
|
|
255
|
+
if (decided.get(run.target) !== member.planDigest) {
|
|
256
|
+
log(
|
|
257
|
+
`${run.target}: its plan moved since wave ${k} was decided, so it was not applied. ` +
|
|
258
|
+
`decided: ${decided.get(run.target) ?? "(not in the decision)"}; planned now: ${member.planDigest}`,
|
|
259
|
+
);
|
|
260
|
+
result.moved.push(run.target);
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
applyRun(spec, wave.name, k, run, cwd, exec, result, log);
|
|
264
|
+
}
|
|
265
|
+
result.exitCode = result.failed.length > 0 ? 1 : result.moved.length > 0 ? 4 : 0;
|
|
266
|
+
return result;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const members = wave.runs.map((run) => planRun(ctx, wave.name, run));
|
|
270
|
+
const digest = waveSetDigest(members);
|
|
271
|
+
const { policy, source, base } = readOpWavePolicy(options.specFile, wave.name, options.base ?? spec.base ?? "HEAD^1", cwd, options.show);
|
|
272
|
+
const gate = waveGateName(spec.gate ?? spec.name, k);
|
|
273
|
+
const decision: OpWaveDecision = { wave: k, name: wave.name, op: spec.name, gate, policy, policySource: source, base, digest, members, status: "not-required" };
|
|
274
|
+
if (opWaveNeedsApproval(policy, members)) {
|
|
275
|
+
const check = await evaluateGate(options.gates ?? gitGateLedgerPort({ cwd }), {
|
|
276
|
+
op: spec.name,
|
|
277
|
+
gate,
|
|
278
|
+
planDigest: digest,
|
|
279
|
+
description: `wave ${k} (${wave.name}): ${members.length} run${members.length === 1 ? "" : "s"}, gate ${policy}`,
|
|
280
|
+
...(options.now ? { now: options.now } : {}),
|
|
281
|
+
});
|
|
282
|
+
if (check.satisfied) {
|
|
283
|
+
decision.status = "approved";
|
|
284
|
+
decision.approvedBy = check.resolution.resolvedBy;
|
|
285
|
+
} else {
|
|
286
|
+
decision.status = "waiting";
|
|
287
|
+
decision.approve = approveCommand(spec.name, gate, undefined, digest);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
mkdirSync(dirname(recordPath), { recursive: true });
|
|
291
|
+
writeFileSync(recordPath, JSON.stringify(decision, null, 2) + "\n");
|
|
292
|
+
result.decision = decision;
|
|
293
|
+
log(
|
|
294
|
+
`wave ${k} (${wave.name}): gate ${policy} (read at ${base.slice(0, 12)}${source === "absent-at-base" ? ", where the wave is absent" : ""}), ` +
|
|
295
|
+
`digest ${digest}: ${decision.status}${decision.approvedBy ? ` by ${decision.approvedBy}` : ""}`,
|
|
296
|
+
);
|
|
297
|
+
if (decision.status === "waiting") {
|
|
298
|
+
log(`Nothing in wave ${k} or after it ran. Read the plans, then approve: ${decision.approve}`);
|
|
299
|
+
return { ...result, exitCode: 3 };
|
|
300
|
+
}
|
|
301
|
+
if (options.decide) return result;
|
|
302
|
+
for (const run of wave.runs) applyRun(spec, wave.name, k, run, cwd, exec, result, log);
|
|
303
|
+
result.exitCode = result.failed.length > 0 ? 1 : 0;
|
|
304
|
+
return result;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function applyRun(
|
|
308
|
+
spec: OpWavesSpec,
|
|
309
|
+
waveName: string,
|
|
310
|
+
k: number,
|
|
311
|
+
run: OpWaveRun,
|
|
312
|
+
cwd: string,
|
|
313
|
+
exec: OpWaveExec,
|
|
314
|
+
result: RunOpWaveResult,
|
|
315
|
+
log: (line: string) => void,
|
|
316
|
+
): void {
|
|
317
|
+
const op = run.op ?? spec.op;
|
|
318
|
+
const plan = opWavePlanPath(spec, k, run.target);
|
|
319
|
+
const code = exec(opWaveArgv(spec.apply ?? OP_WAVE_DEFAULT_APPLY, { op, target: run.target, wave: waveName, plan }), cwd);
|
|
320
|
+
if (code === 0) result.applied.push(run.target);
|
|
321
|
+
else {
|
|
322
|
+
log(`${run.target}: apply exited ${code}`);
|
|
323
|
+
result.failed.push(run.target);
|
|
324
|
+
}
|
|
325
|
+
}
|