diff --git a/src/cli/cron-cli.test.ts b/src/cli/cron-cli.test.ts index 876e9aaec70..f53bff61230 100644 --- a/src/cli/cron-cli.test.ts +++ b/src/cli/cron-cli.test.ts @@ -1,5 +1,6 @@ import { Command } from "commander"; import { describe, expect, it, vi } from "vitest"; +import type { CronJob } from "../cron/types.js"; import { registerCronCli } from "./cron-cli.js"; const CRON_CLI_TEST_TIMEOUT_MS = 15_000; @@ -93,6 +94,22 @@ function buildProgram() { return program; } +function createCronJob(id: string, name: string): CronJob { + const now = Date.now(); + return { + id, + name, + enabled: true, + createdAtMs: now, + updatedAtMs: now, + schedule: { kind: "at", at: new Date(now + 3_600_000).toISOString() }, + sessionTarget: "isolated", + wakeMode: "next-heartbeat", + payload: { kind: "agentTurn", message: "hello" }, + state: {}, + }; +} + function resetGatewayMock() { callGatewayFromCli.mockClear(); callGatewayFromCli.mockImplementation(defaultGatewayMock); @@ -387,6 +404,54 @@ describe("cron cli", () => { expect(patch.enabled).toBe(expectedEnabled); }); + it("paginates cron show lookups", async () => { + resetGatewayMock(); + callGatewayFromCli.mockImplementation( + async (method: string, _opts: unknown, params?: unknown) => { + if (method === "cron.status") { + return { enabled: true }; + } + if (method === "cron.list") { + const offset = (params as { offset?: number }).offset ?? 0; + if (offset === 0) { + return { + jobs: [createCronJob("first-page", "First Page")], + hasMore: true, + nextOffset: 200, + }; + } + return { + jobs: [createCronJob("target-job", "Target Job")], + hasMore: false, + nextOffset: null, + deliveryPreviews: { + "target-job": { + label: "announce -> telegram:-100", + detail: "resolved from last, main session", + }, + }, + }; + } + return { ok: true, params }; + }, + ); + + const program = buildProgram(); + await program.parseAsync(["cron", "show", "Target Job"], { from: "user" }); + + const listParams = callGatewayFromCli.mock.calls + .filter((call) => call[0] === "cron.list") + .map((call) => call[2]); + expect(listParams).toEqual([ + { includeDisabled: true, limit: 200, offset: 0 }, + { includeDisabled: true, limit: 200, offset: 200 }, + ]); + expect(defaultRuntime.log).toHaveBeenCalledWith("id: target-job"); + expect(defaultRuntime.log).toHaveBeenCalledWith( + "delivery: announce -> telegram:-100 (resolved from last, main session)", + ); + }); + it("sends agent id on cron add", async () => { await runCronCommand([ "cron", diff --git a/src/cli/cron-cli/register.cron-simple.ts b/src/cli/cron-cli/register.cron-simple.ts index 600ca476bd6..0a724641163 100644 --- a/src/cli/cron-cli/register.cron-simple.ts +++ b/src/cli/cron-cli/register.cron-simple.ts @@ -1,7 +1,8 @@ import type { Command } from "commander"; -import type { CronJob } from "../../cron/types.js"; +import type { CronDeliveryPreview, CronJob } from "../../cron/types.js"; import { defaultRuntime } from "../../runtime.js"; import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; +import type { GatewayRpcOpts } from "../gateway-rpc.js"; import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js"; import { coerceCronDeliveryPreviews, @@ -11,7 +12,9 @@ import { warnIfCronSchedulerDisabled, } from "./shared.js"; -function findCronJobForShow(jobs: CronJob[], idOrName: string): CronJob | undefined { +const CRON_SHOW_PAGE_SIZE = 200; + +function findCronJobInPage(jobs: CronJob[], idOrName: string): CronJob | undefined { const needle = normalizeLowercaseStringOrEmpty(idOrName); return jobs.find( (job) => @@ -20,6 +23,34 @@ function findCronJobForShow(jobs: CronJob[], idOrName: string): CronJob | undefi ); } +async function loadCronJobForShow( + opts: GatewayRpcOpts, + idOrName: string, +): Promise<{ job?: CronJob; deliveryPreview?: CronDeliveryPreview }> { + let offset = 0; + for (;;) { + const res = await callGatewayFromCli("cron.list", opts, { + includeDisabled: true, + limit: CRON_SHOW_PAGE_SIZE, + offset, + }); + const page = res as { + jobs?: CronJob[]; + hasMore?: boolean; + nextOffset?: number | null; + }; + const jobs = page.jobs ?? []; + const job = findCronJobInPage(jobs, idOrName); + if (job) { + return { job, deliveryPreview: coerceCronDeliveryPreviews(res).get(job.id) }; + } + if (!page.hasMore || typeof page.nextOffset !== "number") { + return {}; + } + offset = page.nextOffset; + } +} + function registerCronToggleCommand(params: { cron: Command; name: "enable" | "disable"; @@ -86,9 +117,7 @@ export function registerCronSimpleCommands(cron: Command) { .option("--json", "Output JSON", false) .action(async (id, opts) => { try { - const res = await callGatewayFromCli("cron.list", opts, { includeDisabled: true }); - const jobs = (res as { jobs?: CronJob[] } | null)?.jobs ?? []; - const job = findCronJobForShow(jobs, String(id)); + const { job, deliveryPreview } = await loadCronJobForShow(opts, String(id)); if (!job) { throw new Error(`cron job not found: ${String(id)}`); } @@ -96,8 +125,7 @@ export function registerCronSimpleCommands(cron: Command) { printCronJson(job); return; } - const deliveryPreviews = coerceCronDeliveryPreviews(res); - printCronShow(job, defaultRuntime, { deliveryPreview: deliveryPreviews.get(job.id) }); + printCronShow(job, defaultRuntime, { deliveryPreview }); } catch (err) { handleCronCliError(err); }