From ea7fde26db919c10b2407b3df52bc4b126bf9ca3 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Sun, 12 Jul 2026 21:57:19 -0700 Subject: [PATCH] feat(miner): add --dry-run to governor pause/resume Completes the --dry-run coverage started in #5527/#5532: governor pause/resume (#4851) are the last local-mutating commands in the CLI. Reports what would happen and returns before opening governor-state, matching every other mutating command's dry-run pattern. governor status is read-only and needs no dry-run. Closes #4847 --- packages/gittensory-miner/lib/cli.js | 4 +- .../lib/governor-pause-cli.d.ts | 8 ++- .../lib/governor-pause-cli.js | 53 +++++++++++++-- test/unit/miner-governor-pause-cli.test.ts | 68 ++++++++++++++++++- 4 files changed, 123 insertions(+), 10 deletions(-) diff --git a/packages/gittensory-miner/lib/cli.js b/packages/gittensory-miner/lib/cli.js index eea03a184..2a1d038d0 100644 --- a/packages/gittensory-miner/lib/cli.js +++ b/packages/gittensory-miner/lib/cli.js @@ -43,8 +43,8 @@ export function printHelp(input) { " gittensory-miner plan list [--status pending|running|completed|failed] [--json]", " gittensory-miner plan show [--json]", " gittensory-miner governor list [--repo ] [--type allowed|denied|throttled|kill_switch] [--json]", - " gittensory-miner governor pause [--reason ] [--json] Stop the loop before its next cycle", - " gittensory-miner governor resume [--json] Let a paused loop continue", + " gittensory-miner governor pause [--reason ] [--dry-run] [--json] Stop the loop before its next cycle", + " gittensory-miner governor resume [--dry-run] [--json] Let a paused loop continue", " gittensory-miner governor status [--json] Show whether the governor is paused", " gittensory-miner calibration [--json] Report predicted-vs-realized gate accuracy", " gittensory-miner feasibility [--not-found] [--json]", diff --git a/packages/gittensory-miner/lib/governor-pause-cli.d.ts b/packages/gittensory-miner/lib/governor-pause-cli.d.ts index e0396d01b..a83bbccfc 100644 --- a/packages/gittensory-miner/lib/governor-pause-cli.d.ts +++ b/packages/gittensory-miner/lib/governor-pause-cli.d.ts @@ -1,6 +1,10 @@ import type { GovernorState } from "./governor-state.js"; -export type ParsedGovernorPauseArgs = { json: boolean; reason: string | null } | { error: string }; +export type ParsedGovernorPauseArgs = + | { json: boolean; dryRun: boolean; reason: string | null } + | { error: string }; + +export type ParsedGovernorResumeArgs = { json: boolean; dryRun: boolean } | { error: string }; export type ParsedGovernorNoArgsSubcommand = { json: boolean } | { error: string }; @@ -10,6 +14,8 @@ export type GovernorPauseCliOptions = { export function parseGovernorPauseArgs(args: string[]): ParsedGovernorPauseArgs; +export function parseGovernorResumeArgs(args: string[]): ParsedGovernorResumeArgs; + export function runGovernorPause(args: string[], options?: GovernorPauseCliOptions): Promise; export function runGovernorResume(args: string[], options?: GovernorPauseCliOptions): Promise; diff --git a/packages/gittensory-miner/lib/governor-pause-cli.js b/packages/gittensory-miner/lib/governor-pause-cli.js index 777d3e344..02bc248b5 100644 --- a/packages/gittensory-miner/lib/governor-pause-cli.js +++ b/packages/gittensory-miner/lib/governor-pause-cli.js @@ -8,12 +8,12 @@ import { openGovernorState } from "./governor-state.js"; -const GOVERNOR_PAUSE_USAGE = "Usage: gittensory-miner governor pause [--reason ] [--json]"; -const GOVERNOR_RESUME_USAGE = "Usage: gittensory-miner governor resume [--json]"; +const GOVERNOR_PAUSE_USAGE = "Usage: gittensory-miner governor pause [--reason ] [--dry-run] [--json]"; +const GOVERNOR_RESUME_USAGE = "Usage: gittensory-miner governor resume [--dry-run] [--json]"; const GOVERNOR_STATUS_USAGE = "Usage: gittensory-miner governor status [--json]"; export function parseGovernorPauseArgs(args) { - const options = { json: false, reason: null }; + const options = { json: false, dryRun: false, reason: null }; for (let index = 0; index < args.length; index += 1) { const token = args[index]; @@ -21,6 +21,11 @@ export function parseGovernorPauseArgs(args) { options.json = true; continue; } + // #4847: reports what pausing would do and returns before writing to governor-state. + if (token === "--dry-run") { + options.dryRun = true; + continue; + } if (token === "--reason") { const value = args[index + 1]; if (!value || value.startsWith("-")) return { error: GOVERNOR_PAUSE_USAGE }; @@ -34,6 +39,25 @@ export function parseGovernorPauseArgs(args) { return options; } +export function parseGovernorResumeArgs(args) { + const options = { json: false, dryRun: false }; + + for (const token of args) { + if (token === "--json") { + options.json = true; + continue; + } + // #4847: reports what resuming would do and returns before writing to governor-state. + if (token === "--dry-run") { + options.dryRun = true; + continue; + } + return { error: GOVERNOR_RESUME_USAGE }; + } + + return options; +} + function parseNoArgsSubcommand(args, usage) { if (args.length === 0) return { json: false }; if (args.length === 1 && args[0] === "--json") return { json: true }; @@ -63,6 +87,17 @@ export async function runGovernorPause(args, options = {}) { return 2; } + if (parsed.dryRun) { + const dryRunResult = { outcome: "dry_run", paused: true, reason: parsed.reason }; + if (parsed.json) { + console.log(JSON.stringify(dryRunResult)); + } else { + const reason = parsed.reason ? ` (${parsed.reason})` : ""; + console.log(`DRY RUN: would pause the governor${reason}. No governor-state write was made.`); + } + return 0; + } + try { return await withGovernorState(options, (governorState) => { const pauseState = governorState.savePauseState({ paused: true, reason: parsed.reason }); @@ -80,12 +115,22 @@ export async function runGovernorPause(args, options = {}) { } export async function runGovernorResume(args, options = {}) { - const parsed = parseNoArgsSubcommand(args, GOVERNOR_RESUME_USAGE); + const parsed = parseGovernorResumeArgs(args); if ("error" in parsed) { console.error(parsed.error); return 2; } + if (parsed.dryRun) { + const dryRunResult = { outcome: "dry_run", paused: false }; + if (parsed.json) { + console.log(JSON.stringify(dryRunResult)); + } else { + console.log("DRY RUN: would resume the governor. No governor-state write was made."); + } + return 0; + } + try { return await withGovernorState(options, (governorState) => { const pauseState = governorState.savePauseState({ paused: false }); diff --git a/test/unit/miner-governor-pause-cli.test.ts b/test/unit/miner-governor-pause-cli.test.ts index 5e68e1df5..6f8b3e47e 100644 --- a/test/unit/miner-governor-pause-cli.test.ts +++ b/test/unit/miner-governor-pause-cli.test.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { closeDefaultGovernorState, openGovernorState } from "../../packages/gittensory-miner/lib/governor-state.js"; import { parseGovernorPauseArgs, + parseGovernorResumeArgs, runGovernorPause, runGovernorResume, runGovernorStatus, @@ -30,12 +31,13 @@ afterEach(() => { describe("parseGovernorPauseArgs (#4851)", () => { it("defaults to no reason and non-JSON output", () => { - expect(parseGovernorPauseArgs([])).toEqual({ json: false, reason: null }); + expect(parseGovernorPauseArgs([])).toEqual({ json: false, dryRun: false, reason: null }); }); - it("parses --reason and --json together", () => { - expect(parseGovernorPauseArgs(["--reason", "investigating a bad PR", "--json"])).toEqual({ + it("parses --reason, --dry-run, and --json together", () => { + expect(parseGovernorPauseArgs(["--reason", "investigating a bad PR", "--dry-run", "--json"])).toEqual({ json: true, + dryRun: true, reason: "investigating a bad PR", }); }); @@ -54,6 +56,22 @@ describe("parseGovernorPauseArgs (#4851)", () => { }); }); +describe("parseGovernorResumeArgs (#4847)", () => { + it("defaults to non-dry-run, non-JSON output", () => { + expect(parseGovernorResumeArgs([])).toEqual({ json: false, dryRun: false }); + }); + + it("parses --dry-run and --json together", () => { + expect(parseGovernorResumeArgs(["--dry-run", "--json"])).toEqual({ json: true, dryRun: true }); + }); + + it("rejects an unrecognized token", () => { + expect(parseGovernorResumeArgs(["extra"])).toEqual({ + error: expect.stringContaining("Usage: gittensory-miner governor resume"), + }); + }); +}); + describe("gittensory-miner governor pause/resume/status CLI (#4851)", () => { it("pauses with a reason, then resumes, using an injected governor state", async () => { const governorState = tempGovernorState(); @@ -107,6 +125,50 @@ describe("gittensory-miner governor pause/resume/status CLI (#4851)", () => { expect(String(error.mock.calls[0]?.[0])).toContain("Usage: gittensory-miner governor status"); }); + it("#4847: --dry-run reports what pause/resume would do and returns 0 without opening the governor state", async () => { + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + const openGovernorStateSpy = vi.fn(); + + expect( + await runGovernorPause(["--reason", "operator requested", "--dry-run", "--json"], { + openGovernorState: openGovernorStateSpy, + }), + ).toBe(0); + expect(openGovernorStateSpy).not.toHaveBeenCalled(); + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({ + outcome: "dry_run", + paused: true, + reason: "operator requested", + }); + + log.mockClear(); + expect(await runGovernorPause(["--dry-run"], { openGovernorState: openGovernorStateSpy })).toBe(0); + expect(openGovernorStateSpy).not.toHaveBeenCalled(); + expect(String(log.mock.calls[0]?.[0])).toBe("DRY RUN: would pause the governor. No governor-state write was made."); + + log.mockClear(); + expect( + await runGovernorPause(["--reason", "operator requested", "--dry-run"], { + openGovernorState: openGovernorStateSpy, + }), + ).toBe(0); + expect(String(log.mock.calls[0]?.[0])).toBe( + "DRY RUN: would pause the governor (operator requested). No governor-state write was made.", + ); + + log.mockClear(); + expect( + await runGovernorResume(["--dry-run", "--json"], { openGovernorState: openGovernorStateSpy }), + ).toBe(0); + expect(openGovernorStateSpy).not.toHaveBeenCalled(); + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({ outcome: "dry_run", paused: false }); + + log.mockClear(); + expect(await runGovernorResume(["--dry-run"], { openGovernorState: openGovernorStateSpy })).toBe(0); + expect(openGovernorStateSpy).not.toHaveBeenCalled(); + expect(String(log.mock.calls[0]?.[0])).toBe("DRY RUN: would resume the governor. No governor-state write was made."); + }); + it("rejects an unknown pause option before opening any store", async () => { const openGovernorStateFn = vi.fn(); const error = vi.spyOn(console, "error").mockImplementation(() => undefined);