Skip to content

Commit 0b25a73

Browse files
committed
fix(cron): resolve delivery preview server-side
1 parent 4f0a978 commit 0b25a73

10 files changed

Lines changed: 231 additions & 111 deletions

File tree

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,11 @@ import { parsePositiveIntOrUndefined } from "../program/helpers.js";
1212
import { resolveCronCreateSchedule } from "./schedule-options.js";
1313
import {
1414
getCronChannelOptions,
15+
coerceCronDeliveryPreviews,
1516
handleCronCliError,
1617
parseCronToolsAllow,
1718
printCronJson,
1819
printCronList,
19-
resolveCronDeliveryPreviews,
2020
warnIfCronSchedulerDisabled,
2121
} from "./shared.js";
2222

@@ -54,7 +54,7 @@ export function registerCronListCommand(cron: Command) {
5454
return;
5555
}
5656
const jobs = (res as { jobs?: CronJob[] } | null)?.jobs ?? [];
57-
const deliveryPreviews = await resolveCronDeliveryPreviews(jobs);
57+
const deliveryPreviews = coerceCronDeliveryPreviews(res);
5858
printCronList(jobs, defaultRuntime, { deliveryPreviews });
5959
} catch (err) {
6060
handleCronCliError(err);

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { defaultRuntime } from "../../runtime.js";
44
import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js";
55
import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js";
66
import {
7+
coerceCronDeliveryPreviews,
78
handleCronCliError,
89
printCronJson,
910
printCronShow,
@@ -95,7 +96,8 @@ export function registerCronSimpleCommands(cron: Command) {
9596
printCronJson(job);
9697
return;
9798
}
98-
await printCronShow(job, defaultRuntime);
99+
const deliveryPreviews = coerceCronDeliveryPreviews(res);
100+
printCronShow(job, defaultRuntime, { deliveryPreview: deliveryPreviews.get(job.id) });
99101
} catch (err) {
100102
handleCronCliError(err);
101103
}

src/cli/cron-cli/shared.test.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import { beforeEach, describe, expect, it, vi } from "vitest";
22
import type { CronJob } from "../../cron/types.js";
33
import type { RuntimeEnv } from "../../runtime.js";
4-
import { getCronChannelOptions, parseCronToolsAllow, printCronList } from "./shared.js";
4+
import {
5+
coerceCronDeliveryPreviews,
6+
getCronChannelOptions,
7+
parseCronToolsAllow,
8+
printCronList,
9+
} from "./shared.js";
510

611
const hoisted = vi.hoisted(() => ({
712
listChannelPluginsMock: vi.fn(),
@@ -234,3 +239,25 @@ describe("parseCronToolsAllow", () => {
234239
expect(parseCronToolsAllow(" , ")).toBeUndefined();
235240
});
236241
});
242+
243+
describe("coerceCronDeliveryPreviews", () => {
244+
it("keeps gateway-provided preview entries", () => {
245+
expect(
246+
coerceCronDeliveryPreviews({
247+
deliveryPreviews: {
248+
job1: { label: "announce -> telegram:123", detail: "explicit" },
249+
},
250+
}).get("job1"),
251+
).toEqual({ label: "announce -> telegram:123", detail: "explicit" });
252+
});
253+
254+
it("drops malformed preview entries", () => {
255+
expect(
256+
coerceCronDeliveryPreviews({
257+
deliveryPreviews: {
258+
job1: { label: "announce -> telegram:123" },
259+
},
260+
}).size,
261+
).toBe(0);
262+
});
263+
});

src/cli/cron-cli/shared.ts

Lines changed: 24 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { listChannelPlugins } from "../../channels/plugins/index.js";
22
import { parseAbsoluteTimeMs } from "../../cron/parse.js";
33
import { resolveCronStaggerMs } from "../../cron/stagger.js";
4-
import type { CronJob, CronSchedule } from "../../cron/types.js";
4+
import type { CronDeliveryPreview, CronJob, CronSchedule } from "../../cron/types.js";
55
import { danger } from "../../globals.js";
66
import { formatDurationHuman } from "../../infra/format-time/format-duration.ts";
77
import {
@@ -225,99 +225,26 @@ const formatStatus = (job: CronJob) => {
225225
return job.state.lastStatus ?? "idle";
226226
};
227227

228-
export type CronDeliveryPreview = {
229-
label: string;
230-
detail: string;
231-
};
232-
233-
function formatTarget(channel?: string, to?: string | null): string {
234-
if (!channel) {
235-
return "last";
236-
}
237-
if (to) {
238-
return `${channel}:${to}`;
239-
}
240-
return channel;
241-
}
242-
243-
function formatDeliveryDetail(params: {
244-
requestedChannel?: string;
245-
resolved: boolean;
246-
sessionKey?: string;
247-
error?: string;
248-
}): string {
249-
if (params.requestedChannel === "last" || !params.requestedChannel) {
250-
if (!params.resolved) {
251-
return params.error
252-
? `last -> no route, will fail-closed: ${params.error}`
253-
: "last -> no route, will fail-closed";
254-
}
255-
return params.sessionKey
256-
? `resolved from last, session ${params.sessionKey}`
257-
: "resolved from last, main session";
258-
}
259-
return params.resolved ? "explicit" : (params.error ?? "unresolved");
260-
}
261-
262-
export async function resolveCronDeliveryPreview(job: CronJob): Promise<CronDeliveryPreview> {
263-
const { resolveCronDeliveryPlan } = await import("../../cron/delivery-plan.js");
264-
const plan = resolveCronDeliveryPlan(job);
265-
if (!plan.requested && plan.mode === "none" && !job.delivery) {
266-
return { label: "not requested", detail: "not requested" };
267-
}
268-
if (plan.mode === "webhook") {
269-
const target = plan.to ? `webhook:${plan.to}` : "webhook";
270-
return { label: target, detail: plan.to ? "webhook" : "webhook target missing" };
228+
export function coerceCronDeliveryPreviews(value: unknown): Map<string, CronDeliveryPreview> {
229+
const previews =
230+
value && typeof value === "object"
231+
? (value as { deliveryPreviews?: unknown }).deliveryPreviews
232+
: undefined;
233+
if (!previews || typeof previews !== "object") {
234+
return new Map();
271235
}
272-
273-
const requestedChannel = plan.channel ?? "last";
274-
const [{ loadConfig }, { resolveDefaultAgentId }, { resolveDeliveryTarget }] = await Promise.all([
275-
import("../../config/config.js"),
276-
import("../../agents/agent-scope-config.js"),
277-
import("../../cron/isolated-agent/delivery-target.js"),
278-
]);
279-
const cfg = loadConfig();
280-
const agentId = job.agentId?.trim() || resolveDefaultAgentId(cfg);
281-
const resolved = await resolveDeliveryTarget(
282-
cfg,
283-
agentId,
284-
{
285-
channel: requestedChannel,
286-
to: plan.to,
287-
threadId: plan.threadId,
288-
accountId: plan.accountId,
289-
sessionKey: job.sessionKey,
290-
},
291-
{ dryRun: true },
292-
);
293-
if (!resolved.ok) {
294-
return {
295-
label: `${plan.mode} -> ${formatTarget(requestedChannel, plan.to ?? null)}`,
296-
detail: formatDeliveryDetail({
297-
requestedChannel,
298-
resolved: false,
299-
sessionKey: job.sessionKey,
300-
error: resolved.error.message,
301-
}),
302-
};
303-
}
304-
return {
305-
label: `${plan.mode} -> ${formatTarget(resolved.channel, resolved.to)}`,
306-
detail: formatDeliveryDetail({
307-
requestedChannel,
308-
resolved: true,
309-
sessionKey: job.sessionKey,
236+
return new Map(
237+
Object.entries(previews as Record<string, unknown>).flatMap(([jobId, preview]) => {
238+
if (!preview || typeof preview !== "object") {
239+
return [];
240+
}
241+
const record = preview as { label?: unknown; detail?: unknown };
242+
if (typeof record.label !== "string" || typeof record.detail !== "string") {
243+
return [];
244+
}
245+
return [[jobId, { label: record.label, detail: record.detail }]];
310246
}),
311-
};
312-
}
313-
314-
export async function resolveCronDeliveryPreviews(
315-
jobs: CronJob[],
316-
): Promise<Map<string, CronDeliveryPreview>> {
317-
const entries = await Promise.all(
318-
jobs.map(async (job) => [job.id, await resolveCronDeliveryPreview(job)] as const),
319247
);
320-
return new Map(entries);
321248
}
322249

323250
export function printCronList(
@@ -421,8 +348,12 @@ export function printCronList(
421348
}
422349
}
423350

424-
export async function printCronShow(job: CronJob, runtime: RuntimeEnv = defaultRuntime) {
425-
const preview = await resolveCronDeliveryPreview(job);
351+
export function printCronShow(
352+
job: CronJob,
353+
runtime: RuntimeEnv = defaultRuntime,
354+
opts?: { deliveryPreview?: CronDeliveryPreview },
355+
) {
356+
const preview = opts?.deliveryPreview ?? { label: "-", detail: "unavailable" };
426357
runtime.log(`id: ${job.id}`);
427358
runtime.log(`name: ${job.name}`);
428359
runtime.log(`enabled: ${job.enabled ? "yes" : "no"}`);

src/cron/delivery-preview.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { resolveDefaultAgentId } from "../agents/agent-scope-config.js";
2+
import type { OpenClawConfig } from "../config/types.openclaw.js";
3+
import { resolveCronDeliveryPlan } from "./delivery-plan.js";
4+
import { resolveDeliveryTarget } from "./isolated-agent/delivery-target.js";
5+
import type { CronDeliveryPreview, CronJob } from "./types.js";
6+
7+
function formatTarget(channel?: string, to?: string | null): string {
8+
if (!channel) {
9+
return "last";
10+
}
11+
if (to) {
12+
return `${channel}:${to}`;
13+
}
14+
return channel;
15+
}
16+
17+
function formatDeliveryDetail(params: {
18+
requestedChannel?: string;
19+
resolved: boolean;
20+
sessionKey?: string;
21+
error?: string;
22+
}): string {
23+
if (params.requestedChannel === "last" || !params.requestedChannel) {
24+
if (!params.resolved) {
25+
return params.error
26+
? `last -> no route, will fail-closed: ${params.error}`
27+
: "last -> no route, will fail-closed";
28+
}
29+
return params.sessionKey
30+
? `resolved from last, session ${params.sessionKey}`
31+
: "resolved from last, main session";
32+
}
33+
return params.resolved ? "explicit" : (params.error ?? "unresolved");
34+
}
35+
36+
export async function resolveCronDeliveryPreview(params: {
37+
cfg: OpenClawConfig;
38+
defaultAgentId?: string;
39+
job: CronJob;
40+
}): Promise<CronDeliveryPreview> {
41+
const plan = resolveCronDeliveryPlan(params.job);
42+
if (!plan.requested && plan.mode === "none" && !params.job.delivery) {
43+
return { label: "not requested", detail: "not requested" };
44+
}
45+
if (plan.mode === "webhook") {
46+
const target = plan.to ? `webhook:${plan.to}` : "webhook";
47+
return { label: target, detail: plan.to ? "webhook" : "webhook target missing" };
48+
}
49+
50+
const requestedChannel = plan.channel ?? "last";
51+
const agentId =
52+
params.job.agentId?.trim() || params.defaultAgentId || resolveDefaultAgentId(params.cfg);
53+
const resolved = await resolveDeliveryTarget(
54+
params.cfg,
55+
agentId,
56+
{
57+
channel: requestedChannel,
58+
to: plan.to,
59+
threadId: plan.threadId,
60+
accountId: plan.accountId,
61+
sessionKey: params.job.sessionKey,
62+
},
63+
{ dryRun: true },
64+
);
65+
if (!resolved.ok) {
66+
return {
67+
label: `${plan.mode} -> ${formatTarget(requestedChannel, plan.to ?? null)}`,
68+
detail: formatDeliveryDetail({
69+
requestedChannel,
70+
resolved: false,
71+
sessionKey: params.job.sessionKey,
72+
error: resolved.error.message,
73+
}),
74+
};
75+
}
76+
return {
77+
label: `${plan.mode} -> ${formatTarget(resolved.channel, resolved.to)}`,
78+
detail: formatDeliveryDetail({
79+
requestedChannel,
80+
resolved: true,
81+
sessionKey: params.job.sessionKey,
82+
}),
83+
};
84+
}
85+
86+
export async function resolveCronDeliveryPreviews(params: {
87+
cfg: OpenClawConfig;
88+
defaultAgentId?: string;
89+
jobs: CronJob[];
90+
}): Promise<Record<string, CronDeliveryPreview>> {
91+
const entries = await Promise.all(
92+
params.jobs.map(
93+
async (job) =>
94+
[
95+
job.id,
96+
await resolveCronDeliveryPreview({
97+
cfg: params.cfg,
98+
defaultAgentId: params.defaultAgentId,
99+
job,
100+
}),
101+
] as const,
102+
),
103+
);
104+
return Object.fromEntries(entries);
105+
}

src/cron/run-log.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
DEFAULT_CRON_RUN_LOG_MAX_BYTES,
99
getPendingCronRunLogWriteCountForTests,
1010
readCronRunLogEntries,
11+
readCronRunLogEntriesPage,
1112
resolveCronRunLogPruneOptions,
1213
resolveCronRunLogPath,
1314
} from "./run-log.js";
@@ -237,6 +238,48 @@ describe("cron run log", () => {
237238
});
238239
});
239240

241+
it("does not include raw delivery targets in run-log search", async () => {
242+
await withRunLogDir("openclaw-cron-log-target-query-", async (dir) => {
243+
const logPath = path.join(dir, "runs", "job-1.jsonl");
244+
await fs.mkdir(path.dirname(logPath), { recursive: true });
245+
await fs.writeFile(
246+
logPath,
247+
JSON.stringify({
248+
ts: 2,
249+
jobId: "job-1",
250+
action: "finished",
251+
status: "ok",
252+
summary: "done",
253+
delivery: {
254+
intended: { channel: "last", to: null, source: "last" },
255+
resolved: { ok: true, channel: "telegram", to: "-100", source: "last" },
256+
messageToolSentTo: [{ channel: "telegram", to: "-100" }],
257+
},
258+
}) + "\n",
259+
"utf-8",
260+
);
261+
262+
expect(
263+
(
264+
await readCronRunLogEntriesPage(logPath, {
265+
limit: 10,
266+
jobId: "job-1",
267+
query: "telegram",
268+
})
269+
).entries,
270+
).toHaveLength(1);
271+
expect(
272+
(
273+
await readCronRunLogEntriesPage(logPath, {
274+
limit: 10,
275+
jobId: "job-1",
276+
query: "-100",
277+
})
278+
).entries,
279+
).toEqual([]);
280+
});
281+
});
282+
240283
it("reads telemetry fields", async () => {
241284
await withRunLogDir("openclaw-cron-log-telemetry-", async (dir) => {
242285
const logPath = path.join(dir, "runs", "job-1.jsonl");

0 commit comments

Comments
 (0)