mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:20:43 +00:00
feat(cron): preview resolved delivery targets
This commit is contained in:
@@ -25,6 +25,7 @@ openclaw cron add \
|
||||
|
||||
# Check your jobs
|
||||
openclaw cron list
|
||||
openclaw cron show <job-id>
|
||||
|
||||
# See run history
|
||||
openclaw cron runs --id <job-id>
|
||||
@@ -316,6 +317,9 @@ gog gmail watch start \
|
||||
# List all jobs
|
||||
openclaw cron list
|
||||
|
||||
# Show one job, including resolved delivery route
|
||||
openclaw cron show <jobId>
|
||||
|
||||
# Edit a job
|
||||
openclaw cron edit <jobId> --message "Updated prompt" --model "opus"
|
||||
|
||||
|
||||
@@ -16,6 +16,10 @@ Related:
|
||||
|
||||
Tip: run `openclaw cron --help` for the full command surface.
|
||||
|
||||
Note: `openclaw cron list` and `openclaw cron show <job-id>` preview the
|
||||
resolved delivery route. For `channel: "last"`, the preview shows whether the
|
||||
route resolved from the main/current session or will fail closed.
|
||||
|
||||
Note: isolated `cron add` jobs default to `--announce` delivery. Use `--no-deliver` to keep
|
||||
output internal. `--deliver` remains as a deprecated alias for `--announce`.
|
||||
|
||||
@@ -124,22 +128,27 @@ openclaw cron add \
|
||||
|
||||
Delivery ownership note:
|
||||
|
||||
- Cron-owned isolated jobs always route final user-visible delivery through the
|
||||
cron runner (`announce`, `webhook`, or internal-only `none`).
|
||||
- If the task mentions messaging some external recipient, the agent should
|
||||
describe the intended destination in its result instead of trying to send it
|
||||
directly.
|
||||
- Isolated cron chat delivery is shared. The agent can send directly with the
|
||||
`message` tool when a chat route is available.
|
||||
- `announce` fallback-delivers the final reply only when the agent did not send
|
||||
directly to the resolved target. `webhook` posts the finished payload to a URL.
|
||||
`none` disables runner fallback delivery.
|
||||
|
||||
## Common admin commands
|
||||
|
||||
Manual run:
|
||||
|
||||
```bash
|
||||
openclaw cron list
|
||||
openclaw cron show <job-id>
|
||||
openclaw cron run <job-id>
|
||||
openclaw cron run <job-id> --due
|
||||
openclaw cron runs --id <job-id> --limit 50
|
||||
```
|
||||
|
||||
`cron runs` entries include delivery diagnostics with the intended cron target,
|
||||
the resolved target, message-tool sends, fallback use, and delivered state.
|
||||
|
||||
Agent/session retargeting:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
parseCronToolsAllow,
|
||||
printCronJson,
|
||||
printCronList,
|
||||
resolveCronDeliveryPreviews,
|
||||
warnIfCronSchedulerDisabled,
|
||||
} from "./shared.js";
|
||||
|
||||
@@ -53,7 +54,8 @@ export function registerCronListCommand(cron: Command) {
|
||||
return;
|
||||
}
|
||||
const jobs = (res as { jobs?: CronJob[] } | null)?.jobs ?? [];
|
||||
printCronList(jobs, defaultRuntime);
|
||||
const deliveryPreviews = await resolveCronDeliveryPreviews(jobs);
|
||||
printCronList(jobs, defaultRuntime, { deliveryPreviews });
|
||||
} catch (err) {
|
||||
handleCronCliError(err);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,23 @@
|
||||
import type { Command } from "commander";
|
||||
import type { CronJob } from "../../cron/types.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js";
|
||||
import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js";
|
||||
import { handleCronCliError, printCronJson, warnIfCronSchedulerDisabled } from "./shared.js";
|
||||
import {
|
||||
handleCronCliError,
|
||||
printCronJson,
|
||||
printCronShow,
|
||||
warnIfCronSchedulerDisabled,
|
||||
} from "./shared.js";
|
||||
|
||||
function findCronJobForShow(jobs: CronJob[], idOrName: string): CronJob | undefined {
|
||||
const needle = normalizeLowercaseStringOrEmpty(idOrName);
|
||||
return jobs.find(
|
||||
(job) =>
|
||||
normalizeLowercaseStringOrEmpty(job.id) === needle ||
|
||||
normalizeLowercaseStringOrEmpty(job.name) === needle,
|
||||
);
|
||||
}
|
||||
|
||||
function registerCronToggleCommand(params: {
|
||||
cron: Command;
|
||||
@@ -61,6 +77,31 @@ export function registerCronSimpleCommands(cron: Command) {
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
addGatewayClientOptions(
|
||||
cron
|
||||
.command("show")
|
||||
.description("Show a cron job")
|
||||
.argument("<id>", "Job id or exact name")
|
||||
.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));
|
||||
if (!job) {
|
||||
throw new Error(`cron job not found: ${String(id)}`);
|
||||
}
|
||||
if (opts.json) {
|
||||
printCronJson(job);
|
||||
return;
|
||||
}
|
||||
await printCronShow(job, defaultRuntime);
|
||||
} catch (err) {
|
||||
handleCronCliError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
addGatewayClientOptions(
|
||||
cron
|
||||
.command("runs")
|
||||
|
||||
@@ -122,6 +122,31 @@ describe("printCronList", () => {
|
||||
expect(dataLine).toContain("sonnet");
|
||||
});
|
||||
|
||||
it("shows delivery preview when provided", () => {
|
||||
const { logs, runtime } = createRuntimeLogCapture();
|
||||
const job = createBaseJob({
|
||||
id: "delivery-job",
|
||||
name: "Delivery",
|
||||
sessionTarget: "isolated",
|
||||
payload: { kind: "agentTurn", message: "hello" },
|
||||
});
|
||||
|
||||
printCronList([job], runtime, {
|
||||
deliveryPreviews: new Map([
|
||||
[
|
||||
"delivery-job",
|
||||
{
|
||||
label: "announce -> telegram:-100",
|
||||
detail: "resolved from last, main session",
|
||||
},
|
||||
],
|
||||
]),
|
||||
});
|
||||
|
||||
expect(logs[0]).toContain("Delivery");
|
||||
expect(logs[1]).toContain("announce -> telegram:-100");
|
||||
});
|
||||
|
||||
it("shows dash in Model column for systemEvent jobs", () => {
|
||||
const { logs, runtime } = createRuntimeLogCapture();
|
||||
const job = createBaseJob({
|
||||
|
||||
@@ -152,6 +152,7 @@ const CRON_NEXT_PAD = 10;
|
||||
const CRON_LAST_PAD = 10;
|
||||
const CRON_STATUS_PAD = 9;
|
||||
const CRON_TARGET_PAD = 9;
|
||||
const CRON_DELIVERY_PAD = 42;
|
||||
const CRON_AGENT_PAD = 10;
|
||||
const CRON_MODEL_PAD = 20;
|
||||
|
||||
@@ -224,7 +225,101 @@ const formatStatus = (job: CronJob) => {
|
||||
return job.state.lastStatus ?? "idle";
|
||||
};
|
||||
|
||||
export function printCronList(jobs: CronJob[], runtime: RuntimeEnv = defaultRuntime) {
|
||||
export type CronDeliveryPreview = {
|
||||
label: string;
|
||||
detail: string;
|
||||
};
|
||||
|
||||
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(job: CronJob): Promise<CronDeliveryPreview> {
|
||||
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,
|
||||
});
|
||||
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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveCronDeliveryPreviews(
|
||||
jobs: CronJob[],
|
||||
): Promise<Map<string, CronDeliveryPreview>> {
|
||||
const entries = await Promise.all(
|
||||
jobs.map(async (job) => [job.id, await resolveCronDeliveryPreview(job)] as const),
|
||||
);
|
||||
return new Map(entries);
|
||||
}
|
||||
|
||||
export function printCronList(
|
||||
jobs: CronJob[],
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
opts?: { deliveryPreviews?: Map<string, CronDeliveryPreview> },
|
||||
) {
|
||||
if (jobs.length === 0) {
|
||||
runtime.log("No cron jobs.");
|
||||
return;
|
||||
@@ -239,6 +334,7 @@ export function printCronList(jobs: CronJob[], runtime: RuntimeEnv = defaultRunt
|
||||
pad("Last", CRON_LAST_PAD),
|
||||
pad("Status", CRON_STATUS_PAD),
|
||||
pad("Target", CRON_TARGET_PAD),
|
||||
pad("Delivery", CRON_DELIVERY_PAD),
|
||||
pad("Agent ID", CRON_AGENT_PAD),
|
||||
pad("Model", CRON_MODEL_PAD),
|
||||
].join(" ");
|
||||
@@ -261,6 +357,11 @@ export function printCronList(jobs: CronJob[], runtime: RuntimeEnv = defaultRunt
|
||||
const statusRaw = formatStatus(job);
|
||||
const statusLabel = pad(statusRaw, CRON_STATUS_PAD);
|
||||
const targetLabel = pad(job.sessionTarget ?? "-", CRON_TARGET_PAD);
|
||||
const deliveryPreview = opts?.deliveryPreviews?.get(job.id);
|
||||
const deliveryLabel = pad(
|
||||
truncate(deliveryPreview?.label ?? "-", CRON_DELIVERY_PAD),
|
||||
CRON_DELIVERY_PAD,
|
||||
);
|
||||
const agentLabel = pad(truncate(job.agentId ?? "-", CRON_AGENT_PAD), CRON_AGENT_PAD);
|
||||
const modelLabel = pad(
|
||||
truncate(
|
||||
@@ -302,6 +403,9 @@ export function printCronList(jobs: CronJob[], runtime: RuntimeEnv = defaultRunt
|
||||
colorize(rich, theme.muted, lastLabel),
|
||||
coloredStatus,
|
||||
coloredTarget,
|
||||
deliveryPreview
|
||||
? colorize(rich, theme.info, deliveryLabel)
|
||||
: colorize(rich, theme.muted, deliveryLabel),
|
||||
coloredAgent,
|
||||
job.payload.kind === "agentTurn" && job.payload.model
|
||||
? colorize(rich, theme.info, modelLabel)
|
||||
@@ -311,3 +415,18 @@ export function printCronList(jobs: CronJob[], runtime: RuntimeEnv = defaultRunt
|
||||
runtime.log(line.trimEnd());
|
||||
}
|
||||
}
|
||||
|
||||
export async function printCronShow(job: CronJob, runtime: RuntimeEnv = defaultRuntime) {
|
||||
const preview = await resolveCronDeliveryPreview(job);
|
||||
runtime.log(`id: ${job.id}`);
|
||||
runtime.log(`name: ${job.name}`);
|
||||
runtime.log(`enabled: ${job.enabled ? "yes" : "no"}`);
|
||||
runtime.log(`schedule: ${formatSchedule(job.schedule)}`);
|
||||
runtime.log(`session: ${job.sessionTarget ?? "-"}`);
|
||||
runtime.log(`agent: ${job.agentId ?? "-"}`);
|
||||
runtime.log(`model: ${job.payload.kind === "agentTurn" ? (job.payload.model ?? "-") : "-"}`);
|
||||
runtime.log(`delivery: ${preview.label} (${preview.detail})`);
|
||||
runtime.log(`next: ${formatRelative(job.state.nextRunAtMs, Date.now())}`);
|
||||
runtime.log(`last: ${formatRelative(job.state.lastRunAtMs, Date.now())}`);
|
||||
runtime.log(`status: ${formatStatus(job)}`);
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ export function resolveCronDeliveryPlan(job: CronJob): CronDeliveryPlan {
|
||||
|
||||
const isIsolatedAgentTurn =
|
||||
job.payload.kind === "agentTurn" &&
|
||||
typeof job.sessionTarget === "string" &&
|
||||
(job.sessionTarget === "isolated" ||
|
||||
job.sessionTarget === "current" ||
|
||||
job.sessionTarget.startsWith("session:"));
|
||||
|
||||
Reference in New Issue
Block a user