Skip to content

Commit 7828b4b

Browse files
fix(cron-cli): bound loadCronJobForShow pagination (#83856)
1 parent 0c67dc7 commit 7828b4b

3 files changed

Lines changed: 82 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai
1313

1414
- fix(mattermost): fail closed on missing channel type [AI]. (#84091) Thanks @pgondhi987.
1515
- Recheck rebuilt system.run argv [AI]. (#84090) Thanks @pgondhi987.
16+
- CLI/cron: bound `openclaw cron show` job lookup pagination so non-advancing or unbounded `cron.list` responses fail instead of hanging the command. Fixes #83856. (#83989)
1617
- Agents/messages: stop message-tool-only turns after a successful source-channel `message` send while keeping transcript mirrors under the session write lock. (#84289)
1718
- Agents: filter silent heartbeat response-tool transcript artifacts out of embedded context snapshots so later user turns are not polluted by heartbeat no-op messages. (#83477) Thanks @fuller-stack-dev.
1819
- Agents/OpenAI: log repeated strict tool-schema downgrade diagnostics once per provider/model/tool signature, reducing duplicate debug noise while preserving `strict=false` fallback behavior. Fixes #82930. (#82933) Thanks @galiniliev.
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
import type { CronJob } from "../../cron/types.js";
3+
import type { GatewayRpcOpts } from "../gateway-rpc.js";
4+
5+
const callGatewayFromCli = vi.fn();
6+
7+
vi.mock("../gateway-rpc.js", async () => {
8+
const actual = await vi.importActual<typeof import("../gateway-rpc.js")>("../gateway-rpc.js");
9+
return {
10+
...actual,
11+
callGatewayFromCli: (...args: Parameters<typeof actual.callGatewayFromCli>) =>
12+
callGatewayFromCli(...args),
13+
};
14+
});
15+
16+
const { loadCronJobForShow } = await import("./register.cron-simple.js");
17+
18+
const opts: GatewayRpcOpts = {} as GatewayRpcOpts;
19+
20+
describe("loadCronJobForShow pagination guard (regression for #83856)", () => {
21+
beforeEach(() => {
22+
callGatewayFromCli.mockReset();
23+
});
24+
afterEach(() => {
25+
vi.restoreAllMocks();
26+
});
27+
28+
it("throws when nextOffset fails to advance", async () => {
29+
callGatewayFromCli.mockResolvedValue({
30+
jobs: [],
31+
hasMore: true,
32+
nextOffset: 0,
33+
});
34+
await expect(loadCronJobForShow(opts, "missing")).rejects.toThrow(/pagination did not advance/);
35+
expect(callGatewayFromCli).toHaveBeenCalledTimes(1);
36+
});
37+
38+
it("throws when pagination exceeds the max page count", async () => {
39+
let nextOffset = 0;
40+
callGatewayFromCli.mockImplementation(async () => {
41+
nextOffset += 1;
42+
return { jobs: [], hasMore: true, nextOffset };
43+
});
44+
await expect(loadCronJobForShow(opts, "missing")).rejects.toThrow(
45+
/pagination exceeded maximum pages/,
46+
);
47+
expect(callGatewayFromCli.mock.calls.length).toBeGreaterThan(1);
48+
expect(callGatewayFromCli.mock.calls.length).toBeLessThanOrEqual(50);
49+
});
50+
51+
it("returns the job when found on a later page", async () => {
52+
const job: CronJob = { id: "abc", name: "wanted" } as unknown as CronJob;
53+
callGatewayFromCli
54+
.mockResolvedValueOnce({ jobs: [], hasMore: true, nextOffset: 200 })
55+
.mockResolvedValueOnce({ jobs: [job], hasMore: false, nextOffset: null });
56+
const result = await loadCronJobForShow(opts, "wanted");
57+
expect(result.job?.id).toBe("abc");
58+
expect(callGatewayFromCli).toHaveBeenCalledTimes(2);
59+
});
60+
61+
it("returns empty result when pagination terminates without a match", async () => {
62+
callGatewayFromCli.mockResolvedValueOnce({
63+
jobs: [],
64+
hasMore: false,
65+
nextOffset: null,
66+
});
67+
const result = await loadCronJobForShow(opts, "missing");
68+
expect(result.job).toBeUndefined();
69+
});
70+
});

src/cli/cron-cli/register.cron-simple.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
} from "./shared.js";
1616

1717
const CRON_SHOW_PAGE_SIZE = 200;
18+
const CRON_SHOW_LOOKUP_MAX_PAGES = 50;
1819
const CRON_RUN_WAIT_TIMEOUT_DEFAULT = "10m";
1920
const CRON_RUN_WAIT_POLL_INTERVAL_DEFAULT = "2s";
2021

@@ -87,32 +88,36 @@ function findCronJobInPage(jobs: CronJob[], idOrName: string): CronJob | undefin
8788
);
8889
}
8990

90-
async function loadCronJobForShow(
91+
export async function loadCronJobForShow(
9192
opts: GatewayRpcOpts,
9293
idOrName: string,
9394
): Promise<{ job?: CronJob; deliveryPreview?: CronDeliveryPreview }> {
9495
let offset = 0;
95-
for (;;) {
96+
for (let page = 0; page < CRON_SHOW_LOOKUP_MAX_PAGES; page += 1) {
9697
const res = await callGatewayFromCli("cron.list", opts, {
9798
includeDisabled: true,
9899
limit: CRON_SHOW_PAGE_SIZE,
99100
offset,
100101
});
101-
const page = res as {
102+
const listed = res as {
102103
jobs?: CronJob[];
103104
hasMore?: boolean;
104105
nextOffset?: number | null;
105106
};
106-
const jobs = page.jobs ?? [];
107+
const jobs = listed.jobs ?? [];
107108
const job = findCronJobInPage(jobs, idOrName);
108109
if (job) {
109110
return { job, deliveryPreview: coerceCronDeliveryPreviews(res).get(job.id) };
110111
}
111-
if (!page.hasMore || typeof page.nextOffset !== "number") {
112+
if (!listed.hasMore || typeof listed.nextOffset !== "number") {
112113
return {};
113114
}
114-
offset = page.nextOffset;
115+
if (listed.nextOffset <= offset) {
116+
throw new Error("cron.list pagination did not advance while looking up cron job");
117+
}
118+
offset = listed.nextOffset;
115119
}
120+
throw new Error("cron.list pagination exceeded maximum pages while looking up cron job");
116121
}
117122

118123
function registerCronToggleCommand(params: {

0 commit comments

Comments
 (0)