@intentius/chant 0.124.23 → 0.124.25
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/ci-jobs.d.ts +41 -1
- package/dist/ci-jobs.d.ts.map +1 -1
- package/dist/ci-jobs.js +48 -1
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/build.js +14 -0
- package/dist/cli/handlers/approve.d.ts.map +1 -1
- package/dist/cli/handlers/approve.js +9 -0
- package/dist/cli/handlers/emulator.d.ts +13 -2
- package/dist/cli/handlers/emulator.d.ts.map +1 -1
- package/dist/cli/handlers/emulator.js +63 -3
- package/dist/cli/handlers/promote.d.ts.map +1 -1
- package/dist/cli/handlers/promote.js +7 -1
- package/dist/cli/handlers/run-wave.d.ts.map +1 -1
- package/dist/cli/handlers/run-wave.js +5 -4
- package/dist/cli/handlers/run.d.ts +4 -3
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/handlers/run.js +73 -9
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +38 -11
- package/dist/cli/mcp/workspace-tools.d.ts +3 -3
- package/dist/cli/mcp/workspace-tools.d.ts.map +1 -1
- package/dist/cli/mcp/workspace-tools.js +27 -4
- package/dist/cli/parse-args.d.ts.map +1 -1
- package/dist/cli/parse-args.js +31 -0
- package/dist/cli/registry.d.ts +12 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/cli-support.d.ts +45 -6
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/components/cli-support.js +97 -22
- package/dist/components/driver.d.ts.map +1 -1
- package/dist/components/driver.js +25 -10
- package/dist/effect-receipt.d.ts +13 -0
- package/dist/effect-receipt.d.ts.map +1 -1
- package/dist/effect-receipt.js +19 -0
- package/dist/graph-detail.d.ts.map +1 -1
- package/dist/graph-detail.js +24 -2
- package/dist/lexicon-module.d.ts +7 -1
- package/dist/lexicon-module.d.ts.map +1 -1
- package/dist/lexicon-module.js +7 -2
- package/dist/lifecycle/git.d.ts +11 -0
- package/dist/lifecycle/git.d.ts.map +1 -1
- package/dist/lifecycle/git.js +62 -6
- package/dist/lifecycle/no-record.d.ts +59 -0
- package/dist/lifecycle/no-record.d.ts.map +1 -0
- package/dist/lifecycle/no-record.js +88 -0
- package/dist/lifecycle/receipt-plan.d.ts.map +1 -1
- package/dist/lifecycle/receipt-plan.js +4 -2
- package/dist/op/activities/shell.d.ts.map +1 -1
- package/dist/op/activities/shell.js +30 -9
- package/dist/op/emulator-lifecycle.d.ts +23 -0
- package/dist/op/emulator-lifecycle.d.ts.map +1 -1
- package/dist/op/emulator-lifecycle.js +39 -2
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/gate.js +3 -0
- package/dist/op/index.d.ts +1 -1
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/index.js +1 -1
- package/dist/op/local-executor.d.ts +10 -0
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/local-executor.js +71 -7
- package/dist/op/op-waves-notify.d.ts +2 -0
- package/dist/op/op-waves-notify.d.ts.map +1 -1
- package/dist/op/op-waves-notify.js +9 -3
- package/dist/op/op-waves-run.d.ts +8 -2
- package/dist/op/op-waves-run.d.ts.map +1 -1
- package/dist/op/op-waves-run.js +36 -11
- package/dist/op/op-waves.d.ts +85 -1
- package/dist/op/op-waves.d.ts.map +1 -1
- package/dist/op/op-waves.js +153 -1
- package/dist/op/receipt-store.d.ts +5 -0
- package/dist/op/receipt-store.d.ts.map +1 -1
- package/dist/op/receipt-store.js +4 -3
- package/dist/op/runtimes/local.d.ts.map +1 -1
- package/dist/op/runtimes/local.js +6 -0
- package/dist/op/step-console.d.ts +65 -0
- package/dist/op/step-console.d.ts.map +1 -0
- package/dist/op/step-console.js +140 -0
- package/dist/op/step-output-ref.d.ts +26 -18
- package/dist/op/step-output-ref.d.ts.map +1 -1
- package/dist/op/step-output-ref.js +86 -63
- package/dist/point-decision.d.ts.map +1 -1
- package/dist/point-decision.js +3 -0
- package/dist/wave-runtime.mjs +239 -239
- package/dist/workspace/agent-cli.d.ts.map +1 -1
- package/dist/workspace/agent-cli.js +3 -0
- package/dist/workspace/compose-graph.d.ts +22 -3
- package/dist/workspace/compose-graph.d.ts.map +1 -1
- package/dist/workspace/compose-graph.js +18 -5
- package/dist/workspace/conformance/__writer_fixture__/answers/answer.schema.json +16 -1
- package/dist/workspace/conformance/writer.js +1 -1
- package/dist/workspace/decide.d.ts +51 -9
- package/dist/workspace/decide.d.ts.map +1 -1
- package/dist/workspace/decide.js +125 -14
- package/dist/workspace/graph.schema.json +68 -1
- package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
- package/dist/workspace/lineage-upgrade.js +20 -11
- package/dist/workspace/nested-graph.d.ts +1 -1
- package/dist/workspace/nested-graph.d.ts.map +1 -1
- package/dist/workspace/nested-graph.js +5 -1
- package/dist/workspace/point-answer.schema.json +16 -1
- package/dist/workspace/points-cli.d.ts +5 -3
- package/dist/workspace/points-cli.d.ts.map +1 -1
- package/dist/workspace/points-cli.js +16 -7
- package/dist/workspace/points-write.schema.json +39 -4
- package/dist/workspace/points.schema.json +42 -3
- package/dist/workspace/reason-codes.d.ts +2 -1
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/reason-codes.js +2 -1
- package/dist/workspace/records-cli.d.ts +12 -2
- package/dist/workspace/records-cli.d.ts.map +1 -1
- package/dist/workspace/records-cli.js +27 -7
- package/dist/workspace/records-write.d.ts.map +1 -1
- package/dist/workspace/records-write.js +2 -3
- package/dist/workspace/records.d.ts +10 -4
- package/dist/workspace/records.d.ts.map +1 -1
- package/dist/workspace/records.js +16 -3
- package/dist/workspace/records.schema.json +2 -1
- package/dist/workspace/runs.d.ts.map +1 -1
- package/dist/workspace/runs.js +4 -2
- package/dist/workspace/trust/verify-cli.d.ts.map +1 -1
- package/dist/workspace/trust/verify-cli.js +2 -1
- package/dist/workspace/trust/verify.d.ts +22 -0
- package/dist/workspace/trust/verify.d.ts.map +1 -1
- package/dist/workspace/trust/verify.js +94 -14
- package/dist/workspace/write-scope.d.ts +3 -1
- package/dist/workspace/write-scope.d.ts.map +1 -1
- package/dist/workspace/write-scope.js +11 -4
- package/package.json +1 -1
- package/src/ci-jobs.ts +58 -1
- package/src/cli/commands/build.ts +18 -0
- package/src/cli/compiled-cli.e2e.test.ts +45 -1
- package/src/cli/handlers/approve.ts +9 -0
- package/src/cli/handlers/emulator.test.ts +61 -1
- package/src/cli/handlers/emulator.ts +65 -4
- package/src/cli/handlers/promote.test.ts +7 -0
- package/src/cli/handlers/promote.ts +7 -1
- package/src/cli/handlers/run-no-record.e2e.test.ts +87 -0
- package/src/cli/handlers/run-wave.ts +5 -4
- package/src/cli/handlers/run.test.ts +126 -0
- package/src/cli/handlers/run.ts +69 -9
- package/src/cli/main.test.ts +34 -0
- package/src/cli/main.ts +38 -11
- package/src/cli/mcp/workspace-tools.e2e.test.ts +1 -1
- package/src/cli/mcp/workspace-tools.ts +27 -4
- package/src/cli/parse-args.ts +21 -0
- package/src/cli/registry.ts +12 -0
- package/src/components/cli-support.test.ts +75 -0
- package/src/components/cli-support.ts +130 -25
- package/src/components/driver.test.ts +28 -0
- package/src/components/driver.ts +23 -10
- package/src/components/generate-disabled.test.ts +56 -0
- package/src/effect-receipt.ts +22 -0
- package/src/graph-ci-edges.test.ts +50 -0
- package/src/graph-detail.ts +21 -2
- package/src/lexicon-module.ts +9 -2
- package/src/lifecycle/git.ts +61 -6
- package/src/lifecycle/no-record.test.ts +175 -0
- package/src/lifecycle/no-record.ts +108 -0
- package/src/lifecycle/receipt-plan.test.ts +9 -0
- package/src/lifecycle/receipt-plan.ts +4 -1
- package/src/op/activities/shell.ts +28 -6
- package/src/op/effect-step.test.ts +95 -1
- package/src/op/emulator-lifecycle.test.ts +24 -1
- package/src/op/emulator-lifecycle.ts +58 -2
- package/src/op/gate.ts +3 -0
- package/src/op/index.ts +1 -1
- package/src/op/local-executor.test.ts +65 -0
- package/src/op/local-executor.ts +88 -8
- package/src/op/op-waves-jobs.test.ts +213 -0
- package/src/op/op-waves-notify.ts +11 -5
- package/src/op/op-waves-run.ts +42 -11
- package/src/op/op-waves.ts +202 -2
- package/src/op/receipt-store.ts +13 -3
- package/src/op/runtimes/local.ts +6 -0
- package/src/op/step-console.test.ts +150 -0
- package/src/op/step-console.ts +155 -0
- package/src/op/step-output-ref.test.ts +68 -24
- package/src/op/step-output-ref.ts +85 -65
- package/src/point-decision-bundle.test.ts +1 -0
- package/src/point-decision.ts +3 -0
- package/src/workspace/agent-cli.ts +2 -0
- package/src/workspace/compose-graph.test.ts +55 -0
- package/src/workspace/compose-graph.ts +28 -6
- package/src/workspace/conformance/__writer_fixture__/answers/answer.schema.json +16 -1
- package/src/workspace/conformance/writer.ts +1 -1
- package/src/workspace/decide.test.ts +87 -3
- package/src/workspace/decide.ts +142 -17
- package/src/workspace/graph-contract.test.ts +26 -1
- package/src/workspace/graph.schema.json +68 -1
- package/src/workspace/lineage-upgrade.test.ts +21 -0
- package/src/workspace/lineage-upgrade.ts +19 -11
- package/src/workspace/nested-graph.test.ts +14 -0
- package/src/workspace/nested-graph.ts +7 -2
- package/src/workspace/point-answer.schema.json +16 -1
- package/src/workspace/points-cli.ts +16 -8
- package/src/workspace/points-write.schema.json +39 -4
- package/src/workspace/points.schema.json +42 -3
- package/src/workspace/reason-codes.ts +2 -1
- package/src/workspace/record-assets.test.ts +14 -0
- package/src/workspace/records-cli.ts +28 -7
- package/src/workspace/records-write-commands.e2e.test.ts +32 -0
- package/src/workspace/records-write.ts +2 -3
- package/src/workspace/records.schema.json +2 -1
- package/src/workspace/records.ts +17 -4
- package/src/workspace/runs.ts +4 -2
- package/src/workspace/trust/trust.test.ts +105 -0
- package/src/workspace/trust/verify-cli.ts +2 -1
- package/src/workspace/trust/verify.ts +106 -13
- package/src/workspace/write-scope.test.ts +18 -0
- package/src/workspace/write-scope.ts +16 -5
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A runner per wave and job groups per wave (#3914): the spec checks, the
|
|
3
|
+
* jobs a wave with `jobs` becomes, and `chant run wave --group` applying one
|
|
4
|
+
* group's runs under the deciding job's one decision.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, test } from "vitest";
|
|
7
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { assertOpWavesSpec, opWaveGroupRuns, opWaveJobs, opWaveRunMatches, type OpWave, type OpWavesSpec } from "./op-waves";
|
|
11
|
+
import { runOpWave, type OpWaveExec, type OpWaveRunsFile, type OpWaveShowAtBase } from "./op-waves-run";
|
|
12
|
+
import { memoryGateLedgerPort } from "./gate";
|
|
13
|
+
import { opWaveNotifyFields } from "./op-waves-notify";
|
|
14
|
+
import { parseArgs } from "../cli/parse-args";
|
|
15
|
+
import { waveSetDigest } from "../gated-waves";
|
|
16
|
+
|
|
17
|
+
const digest = (n: string) => `sha256:${n.repeat(64)}`;
|
|
18
|
+
|
|
19
|
+
const grouped: OpWave = {
|
|
20
|
+
name: "apply",
|
|
21
|
+
runs: [{ target: "live/dev/a" }, { target: "live/prod/a" }, { target: "live/prod/b" }, { target: "live/prod/c" }],
|
|
22
|
+
jobs: [
|
|
23
|
+
{ name: "dev", runs: ["live/dev/*"], runner: ["self-hosted", "dev"], environment: { name: "dev" }, idTokens: { TG_TOKEN: "dev.example" } },
|
|
24
|
+
{ name: "prod", runs: ["live/prod/**"], shares: 2, runner: "prod", idTokens: { TG_TOKEN: "prod.example" } },
|
|
25
|
+
],
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const spec: OpWavesSpec = {
|
|
29
|
+
name: "tf-apply",
|
|
30
|
+
op: "tf-apply",
|
|
31
|
+
plan: ["tool", "plan", "{target}", "--out", "{plan}"],
|
|
32
|
+
apply: ["tool", "apply", "{target}", "--plan", "{plan}"],
|
|
33
|
+
runner: "plan",
|
|
34
|
+
waves: [{ name: "first", runs: [{ target: "live/dev/z" }], gate: "never" }, { ...grouped, gate: "always" }, { name: "last", runs: [{ target: "live/x" }], gate: "never" }],
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
describe("the spec (#3914)", () => {
|
|
38
|
+
test("runner and jobs are checked, and name the wave and the field", () => {
|
|
39
|
+
expect(() => assertOpWavesSpec(spec)).not.toThrow();
|
|
40
|
+
const wave = (w: Partial<OpWave>): OpWavesSpec => ({ ...spec, waves: [{ ...grouped, ...w }] });
|
|
41
|
+
const group = (g: Record<string, unknown>): OpWavesSpec => wave({ jobs: [{ ...grouped.jobs![0]!, ...g }, grouped.jobs![1]!] as OpWave["jobs"] });
|
|
42
|
+
expect(() => assertOpWavesSpec(wave({ jobs: [{ name: "all", runs: ["live/**"] }, { name: "prod", runs: ["live/prod/*"] }] }))).toThrow(/wave "apply" jobs: run "live\/prod\/a" is in job groups "all" and "prod"/);
|
|
43
|
+
expect(() => assertOpWavesSpec(wave({ jobs: [{ name: "prod", runs: ["live/prod/*"] }] }))).toThrow(/wave "apply" jobs: run "live\/dev\/a" is in no job group/);
|
|
44
|
+
expect(() => assertOpWavesSpec(wave({ jobs: [] }))).toThrow(/wave "apply" jobs must be a non-empty list/);
|
|
45
|
+
expect(() => assertOpWavesSpec(wave({ shares: 2 }))).toThrow(/wave "apply" has jobs and shares/);
|
|
46
|
+
expect(() => assertOpWavesSpec(group({ name: "decide" }))).toThrow(/jobs\[0\] \(decide\) is named like the wave's own jobs/);
|
|
47
|
+
expect(() => assertOpWavesSpec(group({ name: "prod" }))).toThrow(/names job group "prod" twice/);
|
|
48
|
+
expect(() => assertOpWavesSpec(group({ runs: [] }))).toThrow(/jobs\[0\] \(dev\) runs must be a non-empty list/);
|
|
49
|
+
expect(() => assertOpWavesSpec(group({ shares: 0 }))).toThrow(/jobs\[0\] \(dev\) has shares 0/);
|
|
50
|
+
expect(() => assertOpWavesSpec(group({ env: {} }))).toThrow(/jobs\[0\] \(dev\)\.env is not a setting/);
|
|
51
|
+
expect(() => assertOpWavesSpec(group({ idTokens: { "1X": "aud" } }))).toThrow(/jobs\[0\] \(dev\) idTokens names "1X"/);
|
|
52
|
+
expect(() => assertOpWavesSpec(group({ idTokens: { X: "" } }))).toThrow(/idTokens\.X must be an audience/);
|
|
53
|
+
expect(() => assertOpWavesSpec(group({ runner: [] }))).toThrow(/jobs\[0\] \(dev\) runner must be a runner label/);
|
|
54
|
+
expect(() => assertOpWavesSpec(group({ runner: { group: "apply", label: "x" } }))).toThrow(/runner\.label is not a setting/);
|
|
55
|
+
expect(() => assertOpWavesSpec(group({ runner: { group: "apply", labels: ["linux"] } }))).not.toThrow();
|
|
56
|
+
expect(() => assertOpWavesSpec({ ...spec, runner: "" })).toThrow(/Op waves "tf-apply": runner must be a runner label/);
|
|
57
|
+
expect(() => assertOpWavesSpec(wave({ runner: 3 as unknown as string }))).toThrow(/wave "apply" runner must be/);
|
|
58
|
+
expect(() => assertOpWavesSpec(wave({ idTokens: {} }))).toThrow(/wave "apply" idTokens must be/);
|
|
59
|
+
const discovering: OpWavesSpec = { ...spec, plan: undefined, planWave: ["tg", "{runs}", "{plans}"], waves: [{ ...grouped, runs: [], discover: true, rest: true }] };
|
|
60
|
+
expect(() => assertOpWavesSpec(discovering)).toThrow(/wave "apply" has jobs and rest/);
|
|
61
|
+
expect(() => assertOpWavesSpec({ ...discovering, waves: [{ ...grouped, runs: [], discover: true }] })).not.toThrow();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("a group's runs are targets or patterns: * within a segment, ** across", () => {
|
|
65
|
+
expect(opWaveRunMatches("live/prod/a", "live/prod/a")).toBe(true);
|
|
66
|
+
expect(opWaveRunMatches("live/*/a", "live/prod/a")).toBe(true);
|
|
67
|
+
expect(opWaveRunMatches("live/*", "live/prod/a")).toBe(false);
|
|
68
|
+
expect(opWaveRunMatches("live/**", "live/prod/a")).toBe(true);
|
|
69
|
+
expect(opWaveRunMatches("live.prod", "live-prod")).toBe(false);
|
|
70
|
+
expect(opWaveGroupRuns(grouped.jobs![1]!, grouped.runs).map((r) => r.target)).toEqual(["live/prod/a", "live/prod/b", "live/prod/c"]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("a wave with jobs is a deciding job and a job per group, or per share of a group; the next wave needs them all", () => {
|
|
74
|
+
const jobs = opWaveJobs(spec, "waves.json");
|
|
75
|
+
expect(jobs.map((j) => [j.jobName, j.kind, j.group, j.share, j.needs])).toEqual([
|
|
76
|
+
["wave-1-first", "wave", undefined, undefined, []],
|
|
77
|
+
["wave-2-apply-decide", "decide", undefined, undefined, ["wave-1-first"]],
|
|
78
|
+
["wave-2-apply-dev", "share", "dev", undefined, ["wave-2-apply-decide"]],
|
|
79
|
+
["wave-2-apply-prod-share-1", "share", "prod", 1, ["wave-2-apply-decide"]],
|
|
80
|
+
["wave-2-apply-prod-share-2", "share", "prod", 2, ["wave-2-apply-decide"]],
|
|
81
|
+
["wave-3-last", "wave", undefined, undefined, ["wave-2-apply-dev", "wave-2-apply-prod-share-1", "wave-2-apply-prod-share-2"]],
|
|
82
|
+
]);
|
|
83
|
+
expect(jobs[2]!.command.join(" ")).toBe("chant run wave --spec waves.json --wave 2 --group dev");
|
|
84
|
+
expect(jobs[4]!.command.join(" ")).toBe("chant run wave --spec waves.json --wave 2 --group prod --share 2");
|
|
85
|
+
expect(jobs[2]!.record).toBe(".chant/op-waves/tf-apply/wave-2.json");
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("chant run wave --group (#3914)", () => {
|
|
90
|
+
function harness(plans: Record<string, { planDigest: string; destructive?: boolean }>, atBase: OpWavesSpec = spec) {
|
|
91
|
+
const cwd = mkdtempSync(join(tmpdir(), "op-waves-3914-"));
|
|
92
|
+
const calls: string[] = [];
|
|
93
|
+
const exec: OpWaveExec = (argv, dir) => {
|
|
94
|
+
calls.push(argv.join(" "));
|
|
95
|
+
if (argv[1] === "plan") writeFileSync(join(dir, argv[4]!), JSON.stringify(plans[argv[2]!]));
|
|
96
|
+
return 0;
|
|
97
|
+
};
|
|
98
|
+
const show: OpWaveShowAtBase = () => ({ sha: "b".repeat(40), text: JSON.stringify(atBase) });
|
|
99
|
+
return { cwd, calls, exec, show, done: () => rmSync(cwd, { recursive: true, force: true }) };
|
|
100
|
+
}
|
|
101
|
+
const plans = () => ({
|
|
102
|
+
"live/dev/a": { planDigest: digest("1") },
|
|
103
|
+
"live/prod/a": { planDigest: digest("2") },
|
|
104
|
+
"live/prod/b": { planDigest: digest("3") },
|
|
105
|
+
"live/prod/c": { planDigest: digest("4") },
|
|
106
|
+
});
|
|
107
|
+
const setDigest = (p: Record<string, { planDigest: string }>) => waveSetDigest(Object.entries(p).map(([member, { planDigest }]) => ({ member, planDigest })));
|
|
108
|
+
|
|
109
|
+
test("one approval of the wave's digest lets every group's job apply its own runs, and nothing else", async () => {
|
|
110
|
+
const p = plans();
|
|
111
|
+
const h = harness(p);
|
|
112
|
+
const gates = memoryGateLedgerPort();
|
|
113
|
+
const opts = { spec, specFile: "waves.json", wave: 2, cwd: h.cwd, exec: h.exec, show: h.show, gates, log: () => {} };
|
|
114
|
+
expect(await runOpWave({ ...opts, decide: true })).toMatchObject({ exitCode: 3, applied: [], decision: { status: "waiting", gate: "tf-apply-wave-2" } });
|
|
115
|
+
// Before the approval, every group's job waits and applies nothing.
|
|
116
|
+
expect(await runOpWave({ ...opts, group: "dev" })).toMatchObject({ exitCode: 3, applied: [] });
|
|
117
|
+
expect(await runOpWave({ ...opts, group: "prod", share: 1 })).toMatchObject({ exitCode: 3, applied: [] });
|
|
118
|
+
expect(h.calls.some((c) => c.startsWith("tool apply"))).toBe(false);
|
|
119
|
+
|
|
120
|
+
const approved = memoryGateLedgerPort({
|
|
121
|
+
resolutions: [{ version: 1, op: "tf-apply", gate: "tf-apply-wave-2", resolvedBy: "alice", timestamp: "2026-10-10T00:00:00Z", planDigest: setDigest(p) }],
|
|
122
|
+
});
|
|
123
|
+
expect(await runOpWave({ ...opts, gates: approved, decide: true })).toMatchObject({ exitCode: 0, applied: [], decision: { status: "approved", approvedBy: "alice" } });
|
|
124
|
+
expect(await runOpWave({ ...opts, group: "dev" })).toMatchObject({ exitCode: 0, applied: ["live/dev/a"] });
|
|
125
|
+
expect(await runOpWave({ ...opts, group: "prod", share: 1 })).toMatchObject({ exitCode: 0, applied: ["live/prod/a", "live/prod/b"] });
|
|
126
|
+
expect(await runOpWave({ ...opts, group: "prod", share: 2 })).toMatchObject({ exitCode: 0, applied: ["live/prod/c"] });
|
|
127
|
+
h.done();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("a group's job refuses a run whose plan moved since the decision (exit 4), as a share does", async () => {
|
|
131
|
+
const p = plans();
|
|
132
|
+
const never: OpWavesSpec = { ...spec, waves: spec.waves.map((w) => ({ ...w, gate: "never" as const })) };
|
|
133
|
+
const h = harness(p, never);
|
|
134
|
+
const opts = { spec: never, specFile: "waves.json", wave: 2, cwd: h.cwd, exec: h.exec, show: h.show, gates: memoryGateLedgerPort(), log: () => {} };
|
|
135
|
+
expect(await runOpWave({ ...opts, decide: true })).toMatchObject({ exitCode: 0, decision: { status: "not-required" } });
|
|
136
|
+
p["live/prod/b"] = { planDigest: digest("9") };
|
|
137
|
+
expect(await runOpWave({ ...opts, group: "prod", share: 1 })).toMatchObject({ exitCode: 4, applied: ["live/prod/a"], moved: ["live/prod/b"] });
|
|
138
|
+
h.done();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("a group's job needs its deciding job, a group the wave has, and --group with --share on a grouped wave", async () => {
|
|
142
|
+
const h = harness(plans());
|
|
143
|
+
const opts = { spec, specFile: "waves.json", wave: 2, cwd: h.cwd, exec: h.exec, show: h.show, gates: memoryGateLedgerPort(), log: () => {} };
|
|
144
|
+
await expect(runOpWave({ ...opts, group: "dev" })).rejects.toThrow(/job group "dev" of wave 2 \(apply\) found no decision/);
|
|
145
|
+
await expect(runOpWave({ ...opts, group: "eu" })).rejects.toThrow(/has the job groups dev, prod; there is no group "eu"/);
|
|
146
|
+
await expect(runOpWave({ ...opts, share: 1 })).rejects.toThrow(/is split into job groups; name the group a share belongs to with --group/);
|
|
147
|
+
await expect(runOpWave({ ...opts, group: "dev", share: 2 })).rejects.toThrow(/job group "dev" of wave 2 \(apply\) has 1 share; there is no share 2/);
|
|
148
|
+
h.done();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe("a discovering wave", () => {
|
|
152
|
+
const tg: OpWavesSpec = {
|
|
153
|
+
name: "tf-apply",
|
|
154
|
+
op: "tf-apply",
|
|
155
|
+
planWave: ["tg", "plan", "{runs}", "{plans}"],
|
|
156
|
+
applyWave: ["tg", "apply", "{runs}"],
|
|
157
|
+
waves: [{ name: "layer", runs: [], discover: true, gate: "never", jobs: [{ name: "dev", runs: ["dev/**"] }, { name: "prod", runs: ["prod/**"] }] }],
|
|
158
|
+
};
|
|
159
|
+
function discover(found: Record<string, { planDigest: string }>) {
|
|
160
|
+
const cwd = mkdtempSync(join(tmpdir(), "op-waves-3914d-"));
|
|
161
|
+
const applied: string[][] = [];
|
|
162
|
+
const exec: OpWaveExec = (argv, dir) => {
|
|
163
|
+
const runs = JSON.parse(readFileSync(join(dir, argv[2]!), "utf-8")) as OpWaveRunsFile;
|
|
164
|
+
if (argv[1] === "plan") {
|
|
165
|
+
const entries = runs.discover ? Object.entries(found).map(([target, p]) => ({ target, ...p })) : runs.runs.map((r) => ({ target: r.target, ...found[r.target] }));
|
|
166
|
+
writeFileSync(join(dir, argv[3]!), JSON.stringify({ runs: entries }));
|
|
167
|
+
}
|
|
168
|
+
if (argv[1] === "apply") applied.push(runs.runs.map((r) => r.target));
|
|
169
|
+
return 0;
|
|
170
|
+
};
|
|
171
|
+
const show: OpWaveShowAtBase = () => ({ sha: "b".repeat(40), text: JSON.stringify(tg) });
|
|
172
|
+
const opts = { spec: tg, specFile: "waves.json", wave: 1, cwd, exec, show, gates: memoryGateLedgerPort(), log: () => {} };
|
|
173
|
+
return { opts, applied, done: () => rmSync(cwd, { recursive: true, force: true }) };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
test("each group's job applies the runs found that it holds; a group that holds none applies nothing", async () => {
|
|
177
|
+
const d = discover({ "dev/a": { planDigest: digest("1") }, "dev/b": { planDigest: digest("2") } });
|
|
178
|
+
expect(await runOpWave({ ...d.opts, decide: true })).toMatchObject({ exitCode: 0, applied: [] });
|
|
179
|
+
expect(await runOpWave({ ...d.opts, group: "dev" })).toMatchObject({ exitCode: 0, applied: ["dev/a", "dev/b"] });
|
|
180
|
+
expect(await runOpWave({ ...d.opts, group: "prod" })).toMatchObject({ exitCode: 0, applied: [] });
|
|
181
|
+
expect(d.applied).toEqual([["dev/a", "dev/b"]]);
|
|
182
|
+
d.done();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("a run found in no group fails the deciding job, which names it, before the gate", async () => {
|
|
186
|
+
const d = discover({ "dev/a": { planDigest: digest("1") }, "stage/a": { planDigest: digest("2") } });
|
|
187
|
+
const lines: string[] = [];
|
|
188
|
+
const result = await runOpWave({ ...d.opts, decide: true, log: (l) => lines.push(l) });
|
|
189
|
+
expect(result.exitCode).toBe(1);
|
|
190
|
+
expect(lines.join("\n")).toMatch(/wave "layer" jobs: run "stage\/a" is in no job group/);
|
|
191
|
+
d.done();
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe("a job group's notify (#3914)", () => {
|
|
197
|
+
const notifying: OpWavesSpec = { ...spec, notify: { webhook: "CHANT_WEBHOOK", key: "CHANT_WEBHOOK_KEY" } };
|
|
198
|
+
const decision = { wave: 2, name: "apply", op: "tf-apply", gate: "tf-apply-wave-2", digest: digest("a"), members: [] } as never;
|
|
199
|
+
test("a group's wait was announced by its deciding job; a moved run names the group", () => {
|
|
200
|
+
const waiting = { exitCode: 3, applied: [], failed: [], moved: [], hooks: [], decision };
|
|
201
|
+
expect(opWaveNotifyFields(notifying, 2, { result: waiting }, { group: "prod", share: 1 })).toBeUndefined();
|
|
202
|
+
expect(opWaveNotifyFields(notifying, 2, { result: waiting })).toMatchObject({ event: "waiting" });
|
|
203
|
+
const moved = { exitCode: 4, applied: [], failed: [], moved: ["live/prod/b"], hooks: [], decision };
|
|
204
|
+
expect(opWaveNotifyFields(notifying, 2, { result: moved }, { group: "prod", share: 1 })!.summary).toBe("wave 2 (apply) of tf-apply, share 1 of job group prod, refused 1 run whose plan moved since the wave was decided");
|
|
205
|
+
expect(opWaveNotifyFields(notifying, 2, { result: moved }, { group: "dev" })!.summary).toBe("wave 2 (apply) of tf-apply, job group dev, refused 1 run whose plan moved since the wave was decided");
|
|
206
|
+
expect(opWaveNotifyFields(notifying, 2, { result: moved }, { share: 2 })!.summary).toBe("wave 2 (apply) of tf-apply, share 2, refused 1 run whose plan moved since the wave was decided");
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("chant run wave parses --group <name> (#3914)", () => {
|
|
211
|
+
expect(parseArgs(["run", "wave", "--spec", "w.json", "--wave", "2", "--group", "prod", "--share", "1"])).toMatchObject({ wave: 2, group: "prod", share: 1 });
|
|
212
|
+
expect(() => parseArgs(["run", "wave", "--group"])).toThrow(/--group needs a job group's name/);
|
|
213
|
+
});
|
|
@@ -22,7 +22,7 @@ import type { RunOpWaveResult } from "./op-waves-run";
|
|
|
22
22
|
export type OpWaveOutcome = { result: RunOpWaveResult; error?: undefined } | { result?: undefined; error: string };
|
|
23
23
|
|
|
24
24
|
/** The event a wave job sends, or undefined when it sends none. */
|
|
25
|
-
export function opWaveNotifyFields(spec: OpWavesSpec, k: number, outcome: OpWaveOutcome, job: { share?: number } = {}): NotifyFields | undefined {
|
|
25
|
+
export function opWaveNotifyFields(spec: OpWavesSpec, k: number, outcome: OpWaveOutcome, job: { share?: number; group?: string } = {}): NotifyFields | undefined {
|
|
26
26
|
const wave = spec.waves[k - 1];
|
|
27
27
|
const name = wave?.name;
|
|
28
28
|
const label = `wave ${k}${name ? ` (${name})` : ""} of ${spec.name}`;
|
|
@@ -39,9 +39,9 @@ export function opWaveNotifyFields(spec: OpWavesSpec, k: number, outcome: OpWave
|
|
|
39
39
|
};
|
|
40
40
|
if (result.exitCode === 3) {
|
|
41
41
|
if (!decision) return undefined;
|
|
42
|
-
// A share job's wait was announced by its deciding job, unless a hook in this job held it (#3762).
|
|
42
|
+
// A share job's wait (or a job group's, #3914) was announced by its deciding job, unless a hook in this job held it (#3762).
|
|
43
43
|
const heldHere = result.hooks.filter((h) => h.outcome === "held");
|
|
44
|
-
if (job.share !== undefined && heldHere.length === 0) return undefined;
|
|
44
|
+
if ((job.share !== undefined || job.group !== undefined) && heldHere.length === 0) return undefined;
|
|
45
45
|
const review = decision.review?.pr !== undefined && decision.review.url ? { review: { pr: decision.review.pr, url: decision.review.url } } : {};
|
|
46
46
|
return {
|
|
47
47
|
...base,
|
|
@@ -102,7 +102,7 @@ export function opWaveNotifyFields(spec: OpWavesSpec, k: number, outcome: OpWave
|
|
|
102
102
|
...base,
|
|
103
103
|
event: "refused",
|
|
104
104
|
status: "moved",
|
|
105
|
-
summary: `${label}${job
|
|
105
|
+
summary: `${label}${jobPart(job)} refused ${result.moved.length} run${result.moved.length === 1 ? "" : "s"} whose plan moved since the wave was decided`,
|
|
106
106
|
targets: result.moved,
|
|
107
107
|
reason: "their plans moved since the wave was decided, so they were not applied",
|
|
108
108
|
};
|
|
@@ -124,12 +124,18 @@ export function opWaveNotifyFields(spec: OpWavesSpec, k: number, outcome: OpWave
|
|
|
124
124
|
return undefined;
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
/** `, share 2,`, `, job group prod,` or `, share 2 of job group prod,`; empty for a wave's own job. */
|
|
128
|
+
function jobPart(job: { share?: number; group?: string }): string {
|
|
129
|
+
if (job.group !== undefined) return `, ${job.share !== undefined ? `share ${job.share} of ` : ""}job group ${job.group},`;
|
|
130
|
+
return job.share !== undefined ? `, share ${job.share},` : "";
|
|
131
|
+
}
|
|
132
|
+
|
|
127
133
|
/** Send what the job's outcome calls for to what `spec.notify` names. Never throws. */
|
|
128
134
|
export async function notifyOpWave(
|
|
129
135
|
spec: OpWavesSpec,
|
|
130
136
|
k: number,
|
|
131
137
|
outcome: OpWaveOutcome,
|
|
132
|
-
job: { share?: number } = {},
|
|
138
|
+
job: { share?: number; group?: string } = {},
|
|
133
139
|
options: SendNotifyOptions = {},
|
|
134
140
|
): Promise<NotifyDelivery[]> {
|
|
135
141
|
// A spec whose notify the run refused (it may name a URL in place of a variable) sends nothing.
|
package/src/op/op-waves-run.ts
CHANGED
|
@@ -28,7 +28,8 @@
|
|
|
28
28
|
* - Apply: each run's `apply` command, or one `applyWave` call for the
|
|
29
29
|
* approved set (#3762). A share job (`--share i`) re-plans its
|
|
30
30
|
* slice first and refuses, without applying it, a run whose plan digest is
|
|
31
|
-
* not the one the deciding job recorded (exit 4).
|
|
31
|
+
* not the one the deciding job recorded (exit 4). A job group's job
|
|
32
|
+
* (`--group <name>`, #3914) is a share job over the group's runs.
|
|
32
33
|
* - Hooks file (#3804): every apply call gets `CHANT_HOOKS`, a JSON file
|
|
33
34
|
* ({@link OpWaveHooksFile}) with the outcomes of the hooks that already ran
|
|
34
35
|
* and whether an approval stands. Under `hooks.apply.runBy: "apply"` (read
|
|
@@ -83,6 +84,8 @@ import {
|
|
|
83
84
|
opWavePointAt,
|
|
84
85
|
opWavePointProblems,
|
|
85
86
|
opWaveRecordPath,
|
|
87
|
+
opWaveGroupRuns,
|
|
88
|
+
opWaveGroupsProblem,
|
|
86
89
|
opWaveShare,
|
|
87
90
|
type GateApprovalSource,
|
|
88
91
|
type OpWave,
|
|
@@ -582,8 +585,13 @@ export interface RunOpWaveOptions {
|
|
|
582
585
|
wave: number;
|
|
583
586
|
/** Plan and decide only (a wide wave's deciding job). */
|
|
584
587
|
decide?: boolean;
|
|
585
|
-
/** Apply this 1-based share of a decided wave. */
|
|
588
|
+
/** Apply this 1-based share of a decided wave, or with `group`, of that job group. */
|
|
586
589
|
share?: number;
|
|
590
|
+
/**
|
|
591
|
+
* Apply this job group's runs of a decided wave (#3914), as a share job
|
|
592
|
+
* does: plan them again and apply those whose plan has not moved.
|
|
593
|
+
*/
|
|
594
|
+
group?: string;
|
|
587
595
|
/** Overrides the spec's base. */
|
|
588
596
|
base?: string;
|
|
589
597
|
cwd?: string;
|
|
@@ -1268,7 +1276,7 @@ export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveRes
|
|
|
1268
1276
|
applied.push(...outcome.applied);
|
|
1269
1277
|
// #3821: a `rest` wave goes on through the waves its plan command found.
|
|
1270
1278
|
const found = outcome.decision?.discovered?.waves;
|
|
1271
|
-
const onward = k >= spec.waves.length && last.rest === true && options.share === undefined && !options.decide && outcome.exitCode === 0 && found !== undefined && k < found;
|
|
1279
|
+
const onward = k >= spec.waves.length && last.rest === true && options.share === undefined && options.group === undefined && !options.decide && outcome.exitCode === 0 && found !== undefined && k < found;
|
|
1272
1280
|
if (rest.length > 0 || onward) rest.push({ wave: k, exitCode: outcome.exitCode, ...(outcome.decision ? { digest: outcome.decision.digest } : {}) });
|
|
1273
1281
|
if (onward) log(`wave ${k} applied; the plan found ${found} waves, so this job goes on to wave ${k + 1}`);
|
|
1274
1282
|
return onward;
|
|
@@ -1277,6 +1285,12 @@ export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveRes
|
|
|
1277
1285
|
return { ...result, applied, ...(rest.length > 0 ? { rest } : {}) };
|
|
1278
1286
|
}
|
|
1279
1287
|
|
|
1288
|
+
/** What a share or job group's job calls itself in the log: `share 2`, `job group "prod"`, `share 2 of job group "prod"`. */
|
|
1289
|
+
function shareLabel(options: Pick<RunOpWaveOptions, "share" | "group">): string {
|
|
1290
|
+
if (options.group === undefined) return `share ${options.share}`;
|
|
1291
|
+
return `${options.share !== undefined ? `share ${options.share} of ` : ""}job group "${options.group}"`;
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1280
1294
|
/** How many times one wave job plans again when its apply asks to (#3821). */
|
|
1281
1295
|
export const OP_WAVE_MAX_REPLANS = 10;
|
|
1282
1296
|
|
|
@@ -1337,21 +1351,31 @@ async function planOpWave(options: RunOpWaveOptions, { k, pass }: WaveAttempt<Ru
|
|
|
1337
1351
|
// #3821: past the spec's waves, a `rest` wave stands for each wave its plan command found.
|
|
1338
1352
|
let wave = spec.waves[k - 1] ?? (last.rest ? last : undefined);
|
|
1339
1353
|
if (!wave) throw new Error(`Op waves "${spec.name}" has ${spec.waves.length} waves; there is no wave ${k}.`);
|
|
1340
|
-
|
|
1354
|
+
// #3914: a job group's job is a share job over the group's runs, split by the group's own shares.
|
|
1355
|
+
const group = options.group === undefined ? undefined : wave.jobs?.find((g) => g.name === options.group);
|
|
1356
|
+
if (options.group !== undefined && !group) {
|
|
1357
|
+
const names = (wave.jobs ?? []).map((g) => g.name);
|
|
1358
|
+
throw new Error(`wave ${k} (${wave.name}) has ${names.length > 0 ? `the job groups ${names.join(", ")}` : "no job groups"}; there is no group "${options.group}".`);
|
|
1359
|
+
}
|
|
1360
|
+
if (options.share !== undefined && options.group === undefined && wave.jobs !== undefined) {
|
|
1361
|
+
throw new Error(`wave ${k} (${wave.name}) is split into job groups; name the group a share belongs to with --group.`);
|
|
1362
|
+
}
|
|
1363
|
+
const shares = group ? (group.shares ?? 1) : (wave.shares ?? 1);
|
|
1341
1364
|
if (options.share !== undefined && (options.share < 1 || options.share > shares)) {
|
|
1342
|
-
throw new Error(`wave ${k} (${wave.name}) has ${shares} share${shares === 1 ? "" : "s"}; there is no share ${options.share}.`);
|
|
1365
|
+
throw new Error(`${group ? `job group "${group.name}" of ` : ""}wave ${k} (${wave.name}) has ${shares} share${shares === 1 ? "" : "s"}; there is no share ${options.share}.`);
|
|
1343
1366
|
}
|
|
1367
|
+
const part = shareLabel(options);
|
|
1344
1368
|
const recordPath = join(cwd, opWaveRecordPath(spec, k));
|
|
1345
1369
|
const result: RunOpWaveResult = { exitCode: 0, applied: [], failed: [], moved: [], hooks: [] };
|
|
1346
1370
|
const ctx: WaveContext = { spec, k, wave, cwd, exec, log, result, options, gateRule: { approval: "ledger", base: "HEAD", neverOverMcp: spec.neverOverMcp === true } };
|
|
1347
1371
|
if (pass > 0) log(`wave ${k} (${wave.name}): plan ${pass + 1}`);
|
|
1348
1372
|
|
|
1349
|
-
if (options.share !== undefined) {
|
|
1373
|
+
if (options.share !== undefined || group !== undefined) {
|
|
1350
1374
|
let decision: OpWaveDecision;
|
|
1351
1375
|
try {
|
|
1352
1376
|
decision = JSON.parse(readFileSync(recordPath, "utf-8")) as OpWaveDecision;
|
|
1353
1377
|
} catch {
|
|
1354
|
-
throw new Error(
|
|
1378
|
+
throw new Error(`${part} of wave ${k} (${wave.name}) found no decision at ${opWaveRecordPath(spec, k)}. Run the deciding job first.`);
|
|
1355
1379
|
}
|
|
1356
1380
|
result.decision = decision;
|
|
1357
1381
|
ctx.gateRule = { approval: decision.approval, base: decision.base, neverOverMcp: decision.neverOverMcp === true };
|
|
@@ -1365,14 +1389,18 @@ async function planOpWave(options: RunOpWaveOptions, { k, pass }: WaveAttempt<Ru
|
|
|
1365
1389
|
wave = { ...wave, runs: decision.members.map((m) => ({ target: m.member, ...(m.op !== spec.op ? { op: m.op } : {}) })) };
|
|
1366
1390
|
ctx.wave = wave;
|
|
1367
1391
|
}
|
|
1368
|
-
const slice = opWaveShare(wave.runs, options.share, shares);
|
|
1392
|
+
const slice = opWaveShare(group ? opWaveGroupRuns(group, wave.runs) : wave.runs, options.share ?? 1, shares);
|
|
1393
|
+
if (group && slice.length === 0) {
|
|
1394
|
+
log(`${part} of wave ${k} (${wave.name}) holds none of the wave's runs; nothing to apply`);
|
|
1395
|
+
return { end: result };
|
|
1396
|
+
}
|
|
1369
1397
|
const before = await runHooks(ctx, "before-plan", decision.hooks?.["before-plan"], slice);
|
|
1370
|
-
if (before.failed) return { end: failed(ctx, `a hook before the plan failed, so nothing in
|
|
1398
|
+
if (before.failed) return { end: failed(ctx, `a hook before the plan failed, so nothing in ${part} of wave ${k} was planned or applied`) };
|
|
1371
1399
|
if (before.waitingOn) return { end: await waitOnOther(ctx, decision, before.waitingOn) };
|
|
1372
1400
|
const { members } = planRuns(ctx, slice);
|
|
1373
1401
|
const planned = new Map(members.map((m) => [m.member, m]));
|
|
1374
1402
|
const after = await runHooks(ctx, "after-plan", decision.hooks?.["after-plan"], slice, planned);
|
|
1375
|
-
if (after.failed) return { end: failed(ctx, `a hook after the plan failed, so nothing in
|
|
1403
|
+
if (after.failed) return { end: failed(ctx, `a hook after the plan failed, so nothing in ${part} of wave ${k} was applied`) };
|
|
1376
1404
|
if (after.waitingOn) return { end: await waitOnOther(ctx, decision, after.waitingOn) };
|
|
1377
1405
|
return { go: { kind: "share", ctx, decision, slice, planned, holds: [...before.holds, ...after.holds] } };
|
|
1378
1406
|
}
|
|
@@ -1424,6 +1452,9 @@ async function planOpWave(options: RunOpWaveOptions, { k, pass }: WaveAttempt<Ru
|
|
|
1424
1452
|
ctx.wave = wave;
|
|
1425
1453
|
decision.discovered = { ...(found !== undefined ? { waves: found } : {}) };
|
|
1426
1454
|
log(`wave ${k} (${wave.name}): the plan found ${members.length} run${members.length === 1 ? "" : "s"}${found !== undefined ? ` of ${found} waves` : ""}${members.length ? `: ${members.map((m) => m.member).join(", ")}` : ""}`);
|
|
1455
|
+
// #3914: each run found is in exactly one job group, or no group's job could apply it.
|
|
1456
|
+
const problem = wave.jobs ? opWaveGroupsProblem(wave, members.map((m) => m.member)) : undefined;
|
|
1457
|
+
if (problem) return { end: failed(ctx, `wave ${k} (${wave.name}): ${problem}, so nothing in it was applied`) };
|
|
1427
1458
|
}
|
|
1428
1459
|
const planned = new Map(members.map((m) => [m.member, m]));
|
|
1429
1460
|
return {
|
|
@@ -1580,7 +1611,7 @@ function markHeldOn(decision: OpWaveDecision, heldOn: OpWaveHeldOn): void {
|
|
|
1580
1611
|
/** A share job held on another gate that waits (#3821). */
|
|
1581
1612
|
async function waitOnOther(ctx: WaveContext, decision: OpWaveDecision, heldOn: OpWaveHeldOn): Promise<RunOpWaveResult> {
|
|
1582
1613
|
markHeldOn(decision, heldOn);
|
|
1583
|
-
ctx.log(
|
|
1614
|
+
ctx.log(`${shareLabel(ctx.options)} of wave ${ctx.k} (${ctx.wave.name}) waits on ${heldOn.op} / ${heldOn.gate}; nothing in it applied. Approve: ${heldOn.approve}`);
|
|
1584
1615
|
return { ...ctx.result, decision, exitCode: 3 };
|
|
1585
1616
|
}
|
|
1586
1617
|
|