From 4400f4ca91b2a58096353197337427d824856963 Mon Sep 17 00:00:00 2001 From: Kate Stahnke Date: Tue, 21 Jul 2026 15:04:15 -0400 Subject: [PATCH] fix(cron): hint after disable about list filtering disabled jobs (#78139) * fix(cron): hint after disable about list filtering disabled jobs by default * fix(cron): use !params.enabled in disable-hint guard for oxlint compliance * docs(cron): clarify disabled jobs in list output * fix(cron): keep disable hint interactive * test(cron): use exported store snapshot helper * test(cron): create disable-list regression job via service * docs(cron): defer list default contract wording * test(cron): tighten disable list coverage Co-authored-by: Kate Stahnke <35552+kate@users.noreply.github.com> * docs(cron): document enabled-only list default Co-authored-by: Kate Stahnke <35552+kate@users.noreply.github.com> --------- Co-authored-by: Kate <35552+kate@users.noreply.github.com> Co-authored-by: Peter Steinberger --- docs/automation/cron-jobs.md | 5 +- docs/cli/cron.md | 2 +- src/cli/cron-cli/register.cron-simple.test.ts | 62 +++++++++++++++++++ src/cli/cron-cli/register.cron-simple.ts | 5 ++ .../ops.update.disable-and-list.test.ts | 50 +++++++++++++++ 5 files changed, 122 insertions(+), 2 deletions(-) create mode 100644 src/cron/service/ops.update.disable-and-list.test.ts diff --git a/docs/automation/cron-jobs.md b/docs/automation/cron-jobs.md index ac9d712ab502..a01edfed3d54 100644 --- a/docs/automation/cron-jobs.md +++ b/docs/automation/cron-jobs.md @@ -371,9 +371,12 @@ For template files, keep the language instruction in the rendered prompt and ver ## Managing jobs ```bash -# List all jobs +# List enabled jobs openclaw cron list +# Include disabled jobs +openclaw cron list --all + # Get one stored job as JSON openclaw cron get diff --git a/docs/cli/cron.md b/docs/cli/cron.md index 73dffafefa66..9bede2e8554c 100644 --- a/docs/cli/cron.md +++ b/docs/cli/cron.md @@ -298,7 +298,7 @@ openclaw cron runs --id --limit 50 openclaw cron runs --id --run-id ``` -`openclaw cron list` shows all matching jobs by default. Pass `--agent ` to show only jobs whose effective normalized agent id matches; jobs without a stored agent id count as the configured default agent. +`openclaw cron list` shows enabled jobs by default. Pass `--all` to include disabled jobs, or `--agent ` to show only jobs whose effective normalized agent id matches; jobs without a stored agent id count as the configured default agent. `openclaw cron get ` returns the stored job JSON directly. Use `cron show ` when you want the human-readable view with delivery-route preview. diff --git a/src/cli/cron-cli/register.cron-simple.test.ts b/src/cli/cron-cli/register.cron-simple.test.ts index cadfaec01427..d64033bc93ae 100644 --- a/src/cli/cron-cli/register.cron-simple.test.ts +++ b/src/cli/cron-cli/register.cron-simple.test.ts @@ -16,6 +16,7 @@ vi.mock("../gateway-rpc.js", async () => { }); const { registerCronSimpleCommands } = await import("./register.cron-simple.js"); +const originalStderrIsTTY = Object.getOwnPropertyDescriptor(process.stderr, "isTTY"); async function runCronShow(id: string): Promise { const cron = new Command(); @@ -23,6 +24,28 @@ async function runCronShow(id: string): Promise { await cron.parseAsync(["show", id, "--json"], { from: "user" }); } +async function runCronToggle(command: "enable" | "disable"): Promise { + const program = new Command(); + program.exitOverride(); + registerCronSimpleCommands(program); + await program.parseAsync([command, "job-1"], { from: "user" }); +} + +function setStderrIsTTY(value: boolean): void { + Object.defineProperty(process.stderr, "isTTY", { + value, + configurable: true, + }); +} + +function restoreStderrIsTTY(): void { + if (originalStderrIsTTY) { + Object.defineProperty(process.stderr, "isTTY", originalStderrIsTTY); + } else { + Reflect.deleteProperty(process.stderr, "isTTY"); + } +} + describe("cron show pagination guard (regression for #83856)", () => { beforeEach(() => { callGatewayFromCli.mockReset(); @@ -85,3 +108,42 @@ describe("cron show pagination guard (regression for #83856)", () => { ); }); }); + +describe("cron disable hint", () => { + beforeEach(() => { + callGatewayFromCli.mockReset(); + callGatewayFromCli.mockImplementation(async (method: string) => { + if (method === "cron.status") { + return { enabled: true }; + } + return { ok: true }; + }); + vi.spyOn(defaultRuntime, "writeJson").mockImplementation(() => {}); + }); + + afterEach(() => { + restoreStderrIsTTY(); + vi.restoreAllMocks(); + }); + + it.each([ + { command: "disable" as const, tty: false, expectedHint: false }, + { command: "disable" as const, tty: true, expectedHint: true }, + { command: "enable" as const, tty: true, expectedHint: false }, + ])("$command with stderr TTY=$tty emits hint=$expectedHint", async (params) => { + setStderrIsTTY(params.tty); + const stderrWrite = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + + await runCronToggle(params.command); + + expect(callGatewayFromCli).toHaveBeenCalledWith("cron.update", expect.anything(), { + id: "job-1", + patch: { enabled: params.command === "enable" }, + }); + if (params.expectedHint) { + expect(stderrWrite).toHaveBeenCalledWith(expect.stringContaining("openclaw cron list --all")); + } else { + expect(stderrWrite).not.toHaveBeenCalled(); + } + }); +}); diff --git a/src/cli/cron-cli/register.cron-simple.ts b/src/cli/cron-cli/register.cron-simple.ts index 9cbbdc1b08e4..2fd9c2e55a96 100644 --- a/src/cli/cron-cli/register.cron-simple.ts +++ b/src/cli/cron-cli/register.cron-simple.ts @@ -143,6 +143,11 @@ function registerCronToggleCommand(params: { patch: { enabled: params.enabled }, }); printCronJson(res); + if (!params.enabled && process.stderr.isTTY) { + process.stderr.write( + `Note: 'openclaw cron list' hides disabled jobs by default. Use 'openclaw cron list --all' to see this job, or 'openclaw cron enable ' to re-enable it.\n`, + ); + } await warnIfCronSchedulerDisabled(opts); } catch (err) { handleCronCliError(err); diff --git a/src/cron/service/ops.update.disable-and-list.test.ts b/src/cron/service/ops.update.disable-and-list.test.ts new file mode 100644 index 000000000000..b491171c19f5 --- /dev/null +++ b/src/cron/service/ops.update.disable-and-list.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from "vitest"; +import { + noopLogger, + setupCronRegressionFixtures, +} from "../../../test/helpers/cron/service-regression-fixtures.js"; +import { add, list, update } from "./ops.js"; +import { createCronServiceState } from "./state.js"; + +const fixtures = setupCronRegressionFixtures({ prefix: "cron-disable-list-" }); + +describe("cron service ops: disable + list round-trip", () => { + it("keeps a disabled job available to --all and restores it after enable", async () => { + const { storePath } = fixtures.makeStorePath(); + const state = createCronServiceState({ + cronEnabled: true, + storePath, + log: noopLogger, + enqueueSystemEvent: vi.fn(), + requestHeartbeat: vi.fn(), + runIsolatedAgentJob: vi.fn(), + }); + + const job = await add(state, { + name: "disable-and-list", + enabled: true, + schedule: { kind: "every", everyMs: 60_000 }, + payload: { kind: "agentTurn", message: "ping" }, + sessionTarget: "isolated", + wakeMode: "next-heartbeat", + delivery: { mode: "announce" }, + }); + + try { + await update(state, job.id, { enabled: false }); + expect((await list(state)).map(({ id }) => id)).not.toContain(job.id); + expect(await list(state, { includeDisabled: true })).toContainEqual( + expect.objectContaining({ id: job.id, enabled: false }), + ); + + await update(state, job.id, { enabled: true }); + expect(await list(state)).toContainEqual( + expect.objectContaining({ id: job.id, enabled: true }), + ); + } finally { + if (state.timer) { + clearTimeout(state.timer); + } + } + }); +});