diff --git a/src/cli/cron-cli/register.cron-add.ts b/src/cli/cron-cli/register.cron-add.ts index 22e4493fb04..5e7c0ce96bb 100644 --- a/src/cli/cron-cli/register.cron-add.ts +++ b/src/cli/cron-cli/register.cron-add.ts @@ -12,11 +12,11 @@ import { parsePositiveIntOrUndefined } from "../program/helpers.js"; import { resolveCronCreateSchedule } from "./schedule-options.js"; import { getCronChannelOptions, + coerceCronDeliveryPreviews, handleCronCliError, parseCronToolsAllow, printCronJson, printCronList, - resolveCronDeliveryPreviews, warnIfCronSchedulerDisabled, } from "./shared.js"; @@ -54,7 +54,7 @@ export function registerCronListCommand(cron: Command) { return; } const jobs = (res as { jobs?: CronJob[] } | null)?.jobs ?? []; - const deliveryPreviews = await resolveCronDeliveryPreviews(jobs); + const deliveryPreviews = coerceCronDeliveryPreviews(res); printCronList(jobs, defaultRuntime, { deliveryPreviews }); } catch (err) { handleCronCliError(err); diff --git a/src/cli/cron-cli/register.cron-simple.ts b/src/cli/cron-cli/register.cron-simple.ts index 7e951aec8fc..600ca476bd6 100644 --- a/src/cli/cron-cli/register.cron-simple.ts +++ b/src/cli/cron-cli/register.cron-simple.ts @@ -4,6 +4,7 @@ import { defaultRuntime } from "../../runtime.js"; import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js"; import { + coerceCronDeliveryPreviews, handleCronCliError, printCronJson, printCronShow, @@ -95,7 +96,8 @@ export function registerCronSimpleCommands(cron: Command) { printCronJson(job); return; } - await printCronShow(job, defaultRuntime); + const deliveryPreviews = coerceCronDeliveryPreviews(res); + printCronShow(job, defaultRuntime, { deliveryPreview: deliveryPreviews.get(job.id) }); } catch (err) { handleCronCliError(err); } diff --git a/src/cli/cron-cli/shared.test.ts b/src/cli/cron-cli/shared.test.ts index a756e16b956..039935e1412 100644 --- a/src/cli/cron-cli/shared.test.ts +++ b/src/cli/cron-cli/shared.test.ts @@ -1,7 +1,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { CronJob } from "../../cron/types.js"; import type { RuntimeEnv } from "../../runtime.js"; -import { getCronChannelOptions, parseCronToolsAllow, printCronList } from "./shared.js"; +import { + coerceCronDeliveryPreviews, + getCronChannelOptions, + parseCronToolsAllow, + printCronList, +} from "./shared.js"; const hoisted = vi.hoisted(() => ({ listChannelPluginsMock: vi.fn(), @@ -234,3 +239,25 @@ describe("parseCronToolsAllow", () => { expect(parseCronToolsAllow(" , ")).toBeUndefined(); }); }); + +describe("coerceCronDeliveryPreviews", () => { + it("keeps gateway-provided preview entries", () => { + expect( + coerceCronDeliveryPreviews({ + deliveryPreviews: { + job1: { label: "announce -> telegram:123", detail: "explicit" }, + }, + }).get("job1"), + ).toEqual({ label: "announce -> telegram:123", detail: "explicit" }); + }); + + it("drops malformed preview entries", () => { + expect( + coerceCronDeliveryPreviews({ + deliveryPreviews: { + job1: { label: "announce -> telegram:123" }, + }, + }).size, + ).toBe(0); + }); +}); diff --git a/src/cli/cron-cli/shared.ts b/src/cli/cron-cli/shared.ts index 2ed0ce65981..ec2664a380a 100644 --- a/src/cli/cron-cli/shared.ts +++ b/src/cli/cron-cli/shared.ts @@ -1,7 +1,7 @@ import { listChannelPlugins } from "../../channels/plugins/index.js"; import { parseAbsoluteTimeMs } from "../../cron/parse.js"; import { resolveCronStaggerMs } from "../../cron/stagger.js"; -import type { CronJob, CronSchedule } from "../../cron/types.js"; +import type { CronDeliveryPreview, CronJob, CronSchedule } from "../../cron/types.js"; import { danger } from "../../globals.js"; import { formatDurationHuman } from "../../infra/format-time/format-duration.ts"; import { @@ -225,99 +225,26 @@ const formatStatus = (job: CronJob) => { return job.state.lastStatus ?? "idle"; }; -export type CronDeliveryPreview = { - label: string; - detail: string; -}; - -function formatTarget(channel?: string, to?: string | null): string { - if (!channel) { - return "last"; +export function coerceCronDeliveryPreviews(value: unknown): Map { + const previews = + value && typeof value === "object" + ? (value as { deliveryPreviews?: unknown }).deliveryPreviews + : undefined; + if (!previews || typeof previews !== "object") { + return new Map(); } - if (to) { - return `${channel}:${to}`; - } - return channel; -} - -function formatDeliveryDetail(params: { - requestedChannel?: string; - resolved: boolean; - sessionKey?: string; - error?: string; -}): string { - if (params.requestedChannel === "last" || !params.requestedChannel) { - if (!params.resolved) { - return params.error - ? `last -> no route, will fail-closed: ${params.error}` - : "last -> no route, will fail-closed"; - } - return params.sessionKey - ? `resolved from last, session ${params.sessionKey}` - : "resolved from last, main session"; - } - return params.resolved ? "explicit" : (params.error ?? "unresolved"); -} - -export async function resolveCronDeliveryPreview(job: CronJob): Promise { - const { resolveCronDeliveryPlan } = await import("../../cron/delivery-plan.js"); - const plan = resolveCronDeliveryPlan(job); - if (!plan.requested && plan.mode === "none" && !job.delivery) { - return { label: "not requested", detail: "not requested" }; - } - if (plan.mode === "webhook") { - const target = plan.to ? `webhook:${plan.to}` : "webhook"; - return { label: target, detail: plan.to ? "webhook" : "webhook target missing" }; - } - - const requestedChannel = plan.channel ?? "last"; - const [{ loadConfig }, { resolveDefaultAgentId }, { resolveDeliveryTarget }] = await Promise.all([ - import("../../config/config.js"), - import("../../agents/agent-scope-config.js"), - import("../../cron/isolated-agent/delivery-target.js"), - ]); - const cfg = loadConfig(); - const agentId = job.agentId?.trim() || resolveDefaultAgentId(cfg); - const resolved = await resolveDeliveryTarget( - cfg, - agentId, - { - channel: requestedChannel, - to: plan.to, - threadId: plan.threadId, - accountId: plan.accountId, - sessionKey: job.sessionKey, - }, - { dryRun: true }, - ); - if (!resolved.ok) { - return { - label: `${plan.mode} -> ${formatTarget(requestedChannel, plan.to ?? null)}`, - detail: formatDeliveryDetail({ - requestedChannel, - resolved: false, - sessionKey: job.sessionKey, - error: resolved.error.message, - }), - }; - } - return { - label: `${plan.mode} -> ${formatTarget(resolved.channel, resolved.to)}`, - detail: formatDeliveryDetail({ - requestedChannel, - resolved: true, - sessionKey: job.sessionKey, + return new Map( + Object.entries(previews as Record).flatMap(([jobId, preview]) => { + if (!preview || typeof preview !== "object") { + return []; + } + const record = preview as { label?: unknown; detail?: unknown }; + if (typeof record.label !== "string" || typeof record.detail !== "string") { + return []; + } + return [[jobId, { label: record.label, detail: record.detail }]]; }), - }; -} - -export async function resolveCronDeliveryPreviews( - jobs: CronJob[], -): Promise> { - const entries = await Promise.all( - jobs.map(async (job) => [job.id, await resolveCronDeliveryPreview(job)] as const), ); - return new Map(entries); } export function printCronList( @@ -421,8 +348,12 @@ export function printCronList( } } -export async function printCronShow(job: CronJob, runtime: RuntimeEnv = defaultRuntime) { - const preview = await resolveCronDeliveryPreview(job); +export function printCronShow( + job: CronJob, + runtime: RuntimeEnv = defaultRuntime, + opts?: { deliveryPreview?: CronDeliveryPreview }, +) { + const preview = opts?.deliveryPreview ?? { label: "-", detail: "unavailable" }; runtime.log(`id: ${job.id}`); runtime.log(`name: ${job.name}`); runtime.log(`enabled: ${job.enabled ? "yes" : "no"}`); diff --git a/src/cron/delivery-preview.ts b/src/cron/delivery-preview.ts new file mode 100644 index 00000000000..80cc9d37936 --- /dev/null +++ b/src/cron/delivery-preview.ts @@ -0,0 +1,105 @@ +import { resolveDefaultAgentId } from "../agents/agent-scope-config.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { resolveCronDeliveryPlan } from "./delivery-plan.js"; +import { resolveDeliveryTarget } from "./isolated-agent/delivery-target.js"; +import type { CronDeliveryPreview, CronJob } from "./types.js"; + +function formatTarget(channel?: string, to?: string | null): string { + if (!channel) { + return "last"; + } + if (to) { + return `${channel}:${to}`; + } + return channel; +} + +function formatDeliveryDetail(params: { + requestedChannel?: string; + resolved: boolean; + sessionKey?: string; + error?: string; +}): string { + if (params.requestedChannel === "last" || !params.requestedChannel) { + if (!params.resolved) { + return params.error + ? `last -> no route, will fail-closed: ${params.error}` + : "last -> no route, will fail-closed"; + } + return params.sessionKey + ? `resolved from last, session ${params.sessionKey}` + : "resolved from last, main session"; + } + return params.resolved ? "explicit" : (params.error ?? "unresolved"); +} + +export async function resolveCronDeliveryPreview(params: { + cfg: OpenClawConfig; + defaultAgentId?: string; + job: CronJob; +}): Promise { + const plan = resolveCronDeliveryPlan(params.job); + if (!plan.requested && plan.mode === "none" && !params.job.delivery) { + return { label: "not requested", detail: "not requested" }; + } + if (plan.mode === "webhook") { + const target = plan.to ? `webhook:${plan.to}` : "webhook"; + return { label: target, detail: plan.to ? "webhook" : "webhook target missing" }; + } + + const requestedChannel = plan.channel ?? "last"; + const agentId = + params.job.agentId?.trim() || params.defaultAgentId || resolveDefaultAgentId(params.cfg); + const resolved = await resolveDeliveryTarget( + params.cfg, + agentId, + { + channel: requestedChannel, + to: plan.to, + threadId: plan.threadId, + accountId: plan.accountId, + sessionKey: params.job.sessionKey, + }, + { dryRun: true }, + ); + if (!resolved.ok) { + return { + label: `${plan.mode} -> ${formatTarget(requestedChannel, plan.to ?? null)}`, + detail: formatDeliveryDetail({ + requestedChannel, + resolved: false, + sessionKey: params.job.sessionKey, + error: resolved.error.message, + }), + }; + } + return { + label: `${plan.mode} -> ${formatTarget(resolved.channel, resolved.to)}`, + detail: formatDeliveryDetail({ + requestedChannel, + resolved: true, + sessionKey: params.job.sessionKey, + }), + }; +} + +export async function resolveCronDeliveryPreviews(params: { + cfg: OpenClawConfig; + defaultAgentId?: string; + jobs: CronJob[]; +}): Promise> { + const entries = await Promise.all( + params.jobs.map( + async (job) => + [ + job.id, + await resolveCronDeliveryPreview({ + cfg: params.cfg, + defaultAgentId: params.defaultAgentId, + job, + }), + ] as const, + ), + ); + return Object.fromEntries(entries); +} diff --git a/src/cron/run-log.test.ts b/src/cron/run-log.test.ts index bf3626771a3..6fb6505e9d8 100644 --- a/src/cron/run-log.test.ts +++ b/src/cron/run-log.test.ts @@ -8,6 +8,7 @@ import { DEFAULT_CRON_RUN_LOG_MAX_BYTES, getPendingCronRunLogWriteCountForTests, readCronRunLogEntries, + readCronRunLogEntriesPage, resolveCronRunLogPruneOptions, resolveCronRunLogPath, } from "./run-log.js"; @@ -237,6 +238,48 @@ describe("cron run log", () => { }); }); + it("does not include raw delivery targets in run-log search", async () => { + await withRunLogDir("openclaw-cron-log-target-query-", async (dir) => { + const logPath = path.join(dir, "runs", "job-1.jsonl"); + await fs.mkdir(path.dirname(logPath), { recursive: true }); + await fs.writeFile( + logPath, + JSON.stringify({ + ts: 2, + jobId: "job-1", + action: "finished", + status: "ok", + summary: "done", + delivery: { + intended: { channel: "last", to: null, source: "last" }, + resolved: { ok: true, channel: "telegram", to: "-100", source: "last" }, + messageToolSentTo: [{ channel: "telegram", to: "-100" }], + }, + }) + "\n", + "utf-8", + ); + + expect( + ( + await readCronRunLogEntriesPage(logPath, { + limit: 10, + jobId: "job-1", + query: "telegram", + }) + ).entries, + ).toHaveLength(1); + expect( + ( + await readCronRunLogEntriesPage(logPath, { + limit: 10, + jobId: "job-1", + query: "-100", + }) + ).entries, + ).toEqual([]); + }); + }); + it("reads telemetry fields", async () => { await withRunLogDir("openclaw-cron-log-telemetry-", async (dir) => { const logPath = path.join(dir, "runs", "job-1.jsonl"); diff --git a/src/cron/run-log.ts b/src/cron/run-log.ts index 98052846fc3..ea45719659f 100644 --- a/src/cron/run-log.ts +++ b/src/cron/run-log.ts @@ -390,13 +390,8 @@ export async function readCronRunLogEntriesPage( entry.error ?? "", entry.jobId, entry.delivery?.intended?.channel ?? "", - entry.delivery?.intended?.to ?? "", entry.delivery?.resolved?.channel ?? "", - entry.delivery?.resolved?.to ?? "", - ...(entry.delivery?.messageToolSentTo ?? []).flatMap((target) => [ - target.channel, - target.to ?? "", - ]), + ...(entry.delivery?.messageToolSentTo ?? []).map((target) => target.channel), ].join(" "), }); const sorted = @@ -460,13 +455,8 @@ export async function readCronRunLogEntriesPageAll( entry.jobId, jobName, entry.delivery?.intended?.channel ?? "", - entry.delivery?.intended?.to ?? "", entry.delivery?.resolved?.channel ?? "", - entry.delivery?.resolved?.to ?? "", - ...(entry.delivery?.messageToolSentTo ?? []).flatMap((target) => [ - target.channel, - target.to ?? "", - ]), + ...(entry.delivery?.messageToolSentTo ?? []).map((target) => target.channel), ].join(" "); }, }); diff --git a/src/cron/types.ts b/src/cron/types.ts index 8f85f55f53e..b379c941c18 100644 --- a/src/cron/types.ts +++ b/src/cron/types.ts @@ -69,6 +69,11 @@ export type CronDeliveryTrace = { delivered?: boolean; }; +export type CronDeliveryPreview = { + label: string; + detail: string; +}; + export type CronUsageSummary = { input_tokens?: number; output_tokens?: number; diff --git a/src/gateway/server-methods/cron.ts b/src/gateway/server-methods/cron.ts index 0e165a81143..2ad388f7ce8 100644 --- a/src/gateway/server-methods/cron.ts +++ b/src/gateway/server-methods/cron.ts @@ -1,6 +1,7 @@ import { listPotentialConfiguredChannelIds } from "../../channels/config-presence.js"; import { loadConfig } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { resolveCronDeliveryPreviews } from "../../cron/delivery-preview.js"; import { normalizeCronJobCreate, normalizeCronJobPatch } from "../../cron/normalize.js"; import { readCronRunLogEntriesPage, @@ -161,7 +162,12 @@ export const cronHandlers: GatewayRequestHandlers = { sortBy: p.sortBy, sortDir: p.sortDir, }); - respond(true, page, undefined); + const deliveryPreviews = await resolveCronDeliveryPreviews({ + cfg: loadConfig(), + defaultAgentId: context.cron.getDefaultAgentId(), + jobs: page.jobs, + }); + respond(true, { ...page, deliveryPreviews }, undefined); }, "cron.status": async ({ params, respond, context }) => { if (!validateCronStatusParams(params)) { diff --git a/src/gateway/server.cron.test.ts b/src/gateway/server.cron.test.ts index 76af3f1f7d2..dd07aed7506 100644 --- a/src/gateway/server.cron.test.ts +++ b/src/gateway/server.cron.test.ts @@ -275,7 +275,8 @@ describe("gateway server cron", () => { delivery: { mode: "webhook", to: "https://example.invalid/cron-finished" }, }); expect(addRes.ok).toBe(true); - expect(typeof (addRes.payload as { id?: unknown } | null)?.id).toBe("string"); + const dailyJobId = (addRes.payload as { id?: unknown } | null)?.id; + expect(typeof dailyJobId).toBe("string"); const listRes = await rpcReq(ws, "cron.list", { includeDisabled: true, @@ -288,6 +289,16 @@ describe("gateway server cron", () => { expect( ((jobs as Array<{ delivery?: { mode?: unknown } }>)[0]?.delivery?.mode as string) ?? "", ).toBe("webhook"); + expect( + ( + listRes.payload as { + deliveryPreviews?: Record; + } | null + )?.deliveryPreviews?.[String(dailyJobId)], + ).toEqual({ + label: "webhook:https://example.invalid/cron-finished", + detail: "webhook", + }); const routeAtMs = Date.now() - 1; const routeRes = await rpcReq(ws, "cron.add", {