fix(cron): stop advertising inactive JSON defaults (#124903)

* fix(cron): make --json help match behavior

* fix(cron): preserve scratch write JSON output

* test(cli): classify cron JSON result commands
This commit is contained in:
Peter Steinberger
2026-08-16 17:32:49 -07:00
committed by GitHub
parent 6405a59c45
commit 6205cf3bb3
14 changed files with 190 additions and 54 deletions
+3 -1
View File
@@ -313,7 +313,9 @@ openclaw automations runs --id <job-id> --run-id <run-id>
`openclaw automations list` shows enabled jobs by default. Pass `--all` to include disabled jobs, or `--agent <id>` to show only jobs whose effective normalized agent id matches; jobs without a stored agent id count as the configured default agent.
`openclaw automations get <job-id>` returns the stored job JSON directly. `get` and `runs` accept `--json` as the explicit machine-output spelling. Use `automations show <job-id>` when you want the human-readable view with delivery-route preview.
`--json` always requests JSON output. Commands whose product is already a machine-readable result emit JSON results by default: `add`/`create`, `status`, `enable`, `disable`, `rm`/`remove`/`delete`, `run`, `edit`, `get`, and `runs`. They accept `--json` as the explicit machine-output spelling. `openclaw automations get <job-id>` returns the stored job JSON directly; use `automations show <job-id>` when you want the human-readable view with delivery-route preview.
`list` and `show` use human-readable output by default and switch to JSON with `--json`. `scratch` reads raw scratch content by default and prints the scratch plus revision metadata with `--json`; scratch writes return the revision result as JSON by default and accept `--json` as the explicit machine-output spelling.
`automations list --json` and `automations show <job-id> --json` include a top-level `status` field on each job, computed from `enabled`, `state.runningAtMs`, and `state.lastRunStatus`. Values: `disabled`, `running`, `ok`, `error`, `skipped`, or `idle`. JSON status stays canonical and undecorated so external tooling can read job state without re-deriving it; human output may decorate repeated `error` statuses with a failure count.
+46 -19
View File
@@ -1,27 +1,54 @@
import { getMachineOutputCommandPath } from "../machine-output-argv.js";
import type { Command } from "commander";
import {
getMachineOutputCommandPath,
MACHINE_OUTPUT_JSON_OPTION_DESCRIPTION,
} from "../machine-output-argv.js";
const MACHINE_OUTPUT_COMMANDS = new Set([
"add",
"create",
"delete",
"disable",
"edit",
"enable",
"get",
"remove",
"rm",
"run",
"runs",
"status",
]);
const CRON_SCRATCH_JSON_OPTION_DESCRIPTION =
"Output scratch plus revision metadata as JSON; writes return JSON by default";
type CronOutputCommandDefinition = {
aliases: readonly string[];
alwaysJson: boolean;
};
const CRON_OUTPUT_COMMANDS = {
status: { aliases: [], alwaysJson: true },
add: { aliases: ["create"], alwaysJson: true },
rm: { aliases: ["remove", "delete"], alwaysJson: true },
enable: { aliases: [], alwaysJson: true },
disable: { aliases: [], alwaysJson: true },
get: { aliases: [], alwaysJson: true },
runs: { aliases: [], alwaysJson: true },
run: { aliases: [], alwaysJson: true },
edit: { aliases: [], alwaysJson: true },
scratch: { aliases: [], alwaysJson: false },
} as const satisfies Record<string, CronOutputCommandDefinition>;
type CronOutputCommandName = keyof typeof CRON_OUTPUT_COMMANDS;
const MACHINE_OUTPUT_COMMANDS = new Set<string>();
for (const [name, definition] of Object.entries(CRON_OUTPUT_COMMANDS)) {
MACHINE_OUTPUT_COMMANDS.add(name);
for (const alias of definition.aliases) {
MACHINE_OUTPUT_COMMANDS.add(alias);
}
}
export function createCronOutputCommand(parent: Command, name: CronOutputCommandName): Command {
const definition = CRON_OUTPUT_COMMANDS[name];
const command = parent.command(name);
for (const alias of definition.aliases) {
command.alias(alias);
}
return definition.alwaysJson
? command.option("--json", MACHINE_OUTPUT_JSON_OPTION_DESCRIPTION)
: command.option("--json", CRON_SCRATCH_JSON_OPTION_DESCRIPTION);
}
export function isCronMachineOutput(argv: readonly string[]): boolean {
const [, command] = getMachineOutputCommandPath(argv, 2);
if (!command) {
return false;
}
if (MACHINE_OUTPUT_COMMANDS.has(command)) {
return true;
}
return command === "scratch";
return MACHINE_OUTPUT_COMMANDS.has(command);
}
+3 -7
View File
@@ -13,6 +13,7 @@ import { defaultRuntime } from "../../runtime.js";
import type { GatewayRpcOpts } from "../gateway-rpc.js";
import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js";
import { listCronJobsFromGateway } from "./list-jobs.js";
import { createCronOutputCommand } from "./output-mode.js";
import { resolveCronCreateScheduleFromArgs } from "./schedule-options.js";
import {
getCronChannelOptions,
@@ -32,10 +33,8 @@ import { readCronPayloadScript, readCronTriggerScript } from "./trigger-options.
export function registerCronStatusCommand(cron: Command) {
addGatewayClientOptions(
cron
.command("status")
createCronOutputCommand(cron, "status")
.description("Show automations scheduler status")
.option("--json", "Output JSON", false)
.action(async (opts) => {
try {
const res = await callGatewayFromCli("cron.status", opts, {});
@@ -84,9 +83,7 @@ export function registerCronListCommand(cron: Command) {
export function registerCronAddCommand(cron: Command) {
addGatewayClientOptions(
cron
.command("add")
.alias("create")
createCronOutputCommand(cron, "add")
.description("Add an automation")
.argument("[scheduleOrName]", "Schedule string, or job name when using --at/--every/--cron")
.argument("[message]", "Agent message when using a positional schedule")
@@ -163,7 +160,6 @@ export function registerCronAddCommand(cron: Command) {
.option("--thread-id <id>", "Telegram forum topic thread id")
.option("--account <id>", "Channel account id for delivery (multi-account setups)")
.option("--best-effort-deliver", "Do not fail the job if delivery fails", false)
.option("--json", "Output JSON", false)
.action(
async (
nameArg: string | undefined,
@@ -58,6 +58,17 @@ describe("cron edit command", () => {
expect(help).toMatch(/also\s+implies --announce when used alone/);
});
it("accepts --json as the explicit machine-output spelling", async () => {
await createCronProgram().parseAsync(["edit", "job-1", "--enable", "--json"], {
from: "user",
});
expect(callGatewayFromCli).toHaveBeenCalledWith("cron.update", expect.anything(), {
id: "job-1",
patch: { enabled: true },
});
});
it("updates the human-readable display name without changing the job name", async () => {
await createCronProgram().parseAsync(["edit", "job-1", "--display-name", "Daily summary"], {
from: "user",
+2 -2
View File
@@ -18,6 +18,7 @@ import {
} from "../gateway-rpc.js";
import { parseDurationMs } from "../parse-duration.js";
import { isUnknownCronGetMethodError, listCronJobsFromGateway } from "./list-jobs.js";
import { createCronOutputCommand } from "./output-mode.js";
import { resolveCronEditPayloadDeliveryPatch } from "./register.cron-edit-options.js";
import {
applyExistingCronSchedulePatch,
@@ -55,8 +56,7 @@ async function readCronJobForEdit(opts: GatewayRpcOpts, id: string): Promise<Cro
export function registerCronEditCommand(cron: Command) {
addGatewayClientOptions(
cron
.command("edit")
createCronOutputCommand(cron, "edit")
.description("Edit an automation (patch fields)")
.argument("<id>", "Job id")
.option("--name <name>", "Set name")
+34 -1
View File
@@ -1,6 +1,6 @@
// Cron scratch register tests cover cron scratch command option validation.
import { Command } from "commander";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { defaultRuntime } from "../../runtime.js";
const callGatewayFromCli = vi.fn();
@@ -38,6 +38,38 @@ describe("cron scratch command", () => {
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it.each([
["without --json", ["--set", "new note"]],
["with --json", ["--unset", "--json"]],
])("prints the write result as JSON %s", async (_label, args) => {
const stdoutWrite = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
const writeJson = vi.spyOn(defaultRuntime, "writeJson").mockImplementation(() => {});
await createCronProgram().parseAsync(["scratch", "job-1", ...args], { from: "user" });
expect(writeJson).toHaveBeenCalledWith({
ok: true,
scratch: null,
currentRevision: 3,
maxBytes: 1024,
});
expect(stdoutWrite).not.toHaveBeenCalled();
});
it("documents the read/write JSON split", () => {
const scratch = createCronProgram().commands.find((command) => command.name() === "scratch");
const jsonOption = scratch?.options.find((option) => option.long === "--json");
expect(jsonOption?.description).toBe(
"Output scratch plus revision metadata as JSON; writes return JSON by default",
);
expect(jsonOption?.defaultValue).toBeUndefined();
});
it.each(["0x2", "1e2", "2.5", "-1", "2a"])(
"rejects non-decimal --expected-revision %j",
async (revision) => {
@@ -72,6 +104,7 @@ describe("cron scratch command", () => {
])(
"passes decimal --expected-revision %j through to the CAS write",
async (revision, expectedRevision) => {
vi.spyOn(process.stdout, "write").mockImplementation(() => true);
await createCronProgram().parseAsync(
["scratch", "job-1", "--set", "x", "--expected-revision", revision],
{ from: "user" },
+2 -3
View File
@@ -2,6 +2,7 @@ import { parseStrictNonNegativeInteger } from "@openclaw/normalization-core/numb
// Cron scratch CLI: private per-job prompt context reads and compare-and-swap writes.
import type { Command } from "commander";
import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js";
import { createCronOutputCommand } from "./output-mode.js";
import { handleCronCliError, printCronJson } from "./shared.js";
import { readCronScratchContent } from "./trigger-options.js";
@@ -28,15 +29,13 @@ function parseExpectedRevision(value: string | undefined): number | undefined {
export function registerCronScratchCommand(cron: Command) {
addGatewayClientOptions(
cron
.command("scratch")
createCronOutputCommand(cron, "scratch")
.description("Read or replace an automation's private scratch")
.argument("<id>", "Job id")
.option("--set <text>", "Replace scratch with exact text")
.option("--file <path>", "Replace scratch from a file, or - for stdin")
.option("--unset", "Remove the scratch row", false)
.option("--expected-revision <n>", "Require the current scratch revision")
.option("--json", "Output JSON", false)
.action(async (id, opts) => {
try {
const mutations = [
@@ -15,6 +15,8 @@ vi.mock("../gateway-rpc.js", async () => {
};
});
const { isCronMachineOutput } = await import("./output-mode.js");
const { registerCronCli } = await import("./register.js");
const { registerCronSimpleCommands } = await import("./register.cron-simple.js");
const originalStderrIsTTY = Object.getOwnPropertyDescriptor(process.stderr, "isTTY");
@@ -65,6 +67,60 @@ function restoreStderrIsTTY(): void {
}
}
function createRegisteredCronCommand(): Command {
const program = new Command().name("openclaw");
registerCronCli(program);
const cron = program.commands.find((command) => command.name() === "cron");
if (!cron) {
throw new Error("cron command was not registered");
}
return cron;
}
describe("cron machine-output help", () => {
it.each([
{ name: "status", aliases: [] },
{ name: "add", aliases: ["create"] },
{ name: "rm", aliases: ["remove", "delete"] },
{ name: "enable", aliases: [] },
{ name: "disable", aliases: [] },
{ name: "get", aliases: [] },
{ name: "runs", aliases: [] },
{ name: "run", aliases: [] },
{ name: "edit", aliases: [] },
])("documents $name as always-JSON machine output", ({ name, aliases }) => {
const command = createRegisteredCronCommand().commands.find((candidate) =>
[candidate.name(), ...candidate.aliases()].includes(name),
);
const jsonOption = command?.options.find((option) => option.long === "--json");
expect(command?.aliases()).toEqual(aliases);
expect(jsonOption?.description).toBe(
"Explicit machine-output spelling (command results are JSON by default)",
);
expect(jsonOption?.defaultValue).toBeUndefined();
for (const commandName of [name, ...aliases]) {
expect(isCronMachineOutput(["node", "openclaw", "cron", commandName])).toBe(true);
}
});
it("keeps registered command output declarations aligned with early stdout routing", () => {
const cron = createRegisteredCronCommand();
for (const command of cron.commands) {
const jsonOption = command.options.find((option) => option.long === "--json");
const alwaysJson =
jsonOption?.description ===
"Explicit machine-output spelling (command results are JSON by default)";
const reservesMachineOutput = command.name() === "scratch" || alwaysJson;
for (const commandName of [command.name(), ...command.aliases()]) {
expect(isCronMachineOutput(["node", "openclaw", "cron", commandName]), commandName).toBe(
reservesMachineOutput,
);
}
}
});
});
describe("cron show pagination guard (regression for #83856)", () => {
beforeEach(() => {
callGatewayFromCli.mockReset();
+6 -15
View File
@@ -12,6 +12,7 @@ import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js";
import { parseDurationMs } from "../parse-duration.js";
import { parseTimeoutMs } from "../parse-timeout.js";
import { findCronJobByIdOrName } from "./list-jobs.js";
import { createCronOutputCommand } from "./output-mode.js";
import {
enrichCronJsonWithStatus,
handleCronCliError,
@@ -101,8 +102,7 @@ function registerCronToggleCommand(params: {
enabled: boolean;
}) {
addGatewayClientOptions(
params.cron
.command(params.name)
createCronOutputCommand(params.cron, params.name)
.description(params.description)
.argument("<id>", "Job id")
.action(async (id, opts) => {
@@ -127,13 +127,9 @@ function registerCronToggleCommand(params: {
export function registerCronSimpleCommands(cron: Command) {
addGatewayClientOptions(
cron
.command("rm")
.alias("remove")
.alias("delete")
createCronOutputCommand(cron, "rm")
.description("Remove an automation")
.argument("<id>", "Job id")
.option("--json", "Output JSON", false)
.action(async (id, opts) => {
try {
const res = await callGatewayFromCli("cron.remove", opts, { id });
@@ -158,11 +154,9 @@ export function registerCronSimpleCommands(cron: Command) {
});
addGatewayClientOptions(
cron
.command("get")
createCronOutputCommand(cron, "get")
.description("Get an automation as JSON")
.argument("<id>", "Job id")
.option("--json", "Output JSON", false)
.action(async (id, opts) => {
try {
const res = await callGatewayFromCli("cron.get", opts, { id: String(id) });
@@ -199,11 +193,9 @@ export function registerCronSimpleCommands(cron: Command) {
);
addGatewayClientOptions(
cron
.command("runs")
createCronOutputCommand(cron, "runs")
.description("Show automation run history")
.requiredOption("--id <id>", "Job id")
.option("--json", "Output JSON", false)
.option("--run-id <runId>", "Filter by cron run id")
.option("--limit <n>", "Max entries (default 50)", "50")
.action(async (opts) => {
@@ -229,8 +221,7 @@ export function registerCronSimpleCommands(cron: Command) {
);
addGatewayClientOptions(
cron
.command("run")
createCronOutputCommand(cron, "run")
.description("Run an automation now (debug)")
.argument("<id>", "Job id")
.option("--due", "Run only when due (default behavior in older versions)", false)
@@ -42,6 +42,21 @@ describe("gateway restart-handoff commands", () => {
expect(gateway.helpInformation()).not.toContain("restart-handoff");
});
it("documents restart-handoff output as unconditionally JSON", () => {
const program = new Command();
const gateway = program.command("gateway");
addGatewayRestartHandoffCommands(gateway);
const restartHandoff = gateway.commands.find((command) => command.name() === "restart-handoff");
for (const command of restartHandoff?.commands ?? []) {
const jsonOption = command.options.find((option) => option.long === "--json");
expect(jsonOption?.description, command.name()).toBe(
"Explicit machine-output spelling (command results are JSON by default)",
);
expect(jsonOption?.defaultValue, command.name()).toBeUndefined();
}
});
it("reports protocol version 1 capabilities", async () => {
await runRegisteredCli({
register: registerGatewayRestartHandoffCli,
@@ -7,6 +7,7 @@ import {
GATEWAY_RESTART_HANDOFF_PROTOCOL_VERSION,
} from "../../infra/restart-handoff-contract.js";
import { defaultRuntime } from "../../runtime.js";
import { MACHINE_OUTPUT_JSON_OPTION_DESCRIPTION } from "../machine-output-argv.js";
function writeRestartHandoffError(reason: "invalid-expected-pid" | "store-unavailable") {
defaultRuntime.writeJson({
@@ -28,7 +29,7 @@ export function addGatewayRestartHandoffCommands(gateway: Command): void {
restartHandoff
.command("capabilities")
.description("Report the gateway restart-handoff machine contract")
.option("--json", "Output JSON", false)
.option("--json", MACHINE_OUTPUT_JSON_OPTION_DESCRIPTION)
.action(() => {
defaultRuntime.writeJson({
ok: true,
@@ -43,7 +44,7 @@ export function addGatewayRestartHandoffCommands(gateway: Command): void {
.allowUnknownOption()
.allowExcessArguments()
.option("--expected-pid [pid]", "PID of the exited gateway process", collectExpectedPid, [])
.option("--json", "Output JSON", false)
.option("--json", MACHINE_OUTPUT_JSON_OPTION_DESCRIPTION)
.action(async (opts, command: Command) => {
const expectedPidValues = Array.isArray(opts.expectedPid) ? opts.expectedPid : [];
const expectedPid =
+3
View File
@@ -10,6 +10,9 @@ export type MachineOutputResolverParams = {
export type MachineOutputResolver = (params: MachineOutputResolverParams) => boolean;
export const MACHINE_OUTPUT_JSON_OPTION_DESCRIPTION =
"Explicit machine-output spelling (command results are JSON by default)";
/** Normalize Node's absent `isTTY` property to the public resolver's boolean contract. */
export function isMachineOutputStdoutTTY(
stdout: { readonly isTTY?: boolean } = process.stdout,
+6
View File
@@ -86,6 +86,12 @@ describe("built-in machine-output resolvers", () => {
it("reserves raw cron scratch output", () => {
expect(isCronMachineOutput(["node", "openclaw", "cron", "scratch", "job"])).toBe(true);
expect(
isCronMachineOutput(["node", "openclaw", "cron", "scratch", "job", "--set", "note"]),
).toBe(true);
expect(isCronMachineOutput(["node", "openclaw", "cron", "scratch", "job", "--unset"])).toBe(
true,
);
});
it.each(["get", "file", "schema"])("reserves config %s machine output", (subcommand) => {
@@ -187,10 +187,6 @@ const JSON_NOT_APPLICABLE = {
"fleet restart",
"fleet upgrade",
"fleet rm",
"cron enable",
"cron disable",
"cron run",
"cron edit",
"dns setup",
"proxy purge",
"pairing approve",