feat: improve cron create delivery ergonomics

Summary:
- Add Hermes-style schedule-first cron create parsing while preserving flagged create options.
- Support webhook create/edit delivery and clear stale webhook/chat delivery fields across mode changes.
- Update cron docs and schedule identity normalization tests.

Verification:
- pnpm test src/cron/schedule-identity.test.ts src/cli/cron-cli.test.ts src/cron/service.jobs.test.ts -- --reporter=verbose
- pnpm test src/cli/cron-cli.test.ts src/cron/service.jobs.test.ts -- --reporter=verbose
- pnpm check:test-types
- pnpm check:import-cycles
- pnpm check:docs
- pnpm check:changed via Crabbox run_8c44bcb158da, exit 0
- autoreview branch diff clean
This commit is contained in:
Peter Steinberger
2026-05-29 04:34:50 +01:00
committed by GitHub
parent 4b18234fc1
commit 2209faef40
12 changed files with 576 additions and 219 deletions
+14 -6
View File
@@ -15,9 +15,8 @@ Cron is the Gateway's built-in scheduler. It persists jobs, wakes the agent at t
<Steps>
<Step title="Add a one-shot reminder">
```bash
openclaw cron add \
openclaw cron create "2026-02-01T16:00:00Z" \
--name "Reminder" \
--at "2026-02-01T16:00:00Z" \
--session main \
--system-event "Reminder: check the cron docs draft" \
--wake now \
@@ -215,12 +214,11 @@ Failure notifications follow a separate destination path:
</Tab>
<Tab title="Recurring isolated job">
```bash
openclaw cron add \
openclaw cron create "0 7 * * *" \
"Summarize overnight updates." \
--name "Morning brief" \
--cron "0 7 * * *" \
--tz "America/Los_Angeles" \
--session isolated \
--message "Summarize overnight updates." \
--announce \
--channel slack \
--to "channel:C1234567890"
@@ -239,6 +237,14 @@ Failure notifications follow a separate destination path:
--announce
```
</Tab>
<Tab title="Webhook output">
```bash
openclaw cron create "0 18 * * 1-5" \
"Summarize today's deploys as JSON." \
--name "Deploy digest" \
--webhook "https://example.invalid/openclaw/cron"
```
</Tab>
</Tabs>
## Webhooks
@@ -411,12 +417,14 @@ openclaw cron runs --id <jobId> --run-id <runId>
openclaw cron remove <jobId>
# Agent selection (multi-agent setups)
openclaw cron add --name "Ops sweep" --cron "0 6 * * *" --session isolated --message "Check ops queue" --agent ops
openclaw cron create "0 6 * * *" "Check ops queue" --name "Ops sweep" --session isolated --agent ops
openclaw cron edit <jobId> --clear-agent
```
`openclaw cron run <jobId>` returns after enqueueing the manual run. Use `--wait` for shutdown hooks, maintenance scripts, or other automation that must block until the queued run finishes. Wait mode polls the exact returned `runId`; it exits `0` for status `ok` and non-zero for `error`, `skipped`, or a wait timeout.
`openclaw cron create` is an alias for `openclaw cron add`, and new jobs can use a positional schedule (`"0 9 * * 1"`, `"every 1h"`, `"20m"`, or an ISO timestamp) followed by a positional agent prompt. Use `--webhook <url>` on `cron add|create` or `cron edit` to POST the finished run payload to an HTTP endpoint. Webhook delivery cannot be combined with chat delivery flags such as `--announce`, `--channel`, `--to`, `--thread-id`, or `--account`.
<Note>
Model override note:
+25 -3
View File
@@ -14,6 +14,26 @@ Manage cron jobs for the Gateway scheduler.
Run `openclaw cron --help` for the full command surface. See [Cron jobs](/automation/cron-jobs) for the conceptual guide.
</Tip>
## Create jobs quickly
`openclaw cron create` is an alias for `openclaw cron add`. For new jobs, put the schedule first and the prompt second:
```bash
openclaw cron create "0 7 * * *" \
"Summarize overnight updates." \
--name "Morning brief" \
--agent ops
```
Use `--webhook <url>` when the job should POST the finished payload instead of delivering to a chat target:
```bash
openclaw cron create "0 18 * * 1-5" \
"Summarize today's deploys as JSON." \
--name "Deploy digest" \
--webhook "https://example.invalid/openclaw/cron"
```
## Sessions
`--session` accepts `main`, `isolated`, `current`, or `session:<id>`.
@@ -50,6 +70,8 @@ Isolated cron chat delivery is shared between the agent and the runner:
- `webhook` posts the finished payload to a URL.
- `none` disables runner fallback delivery.
Use `cron add|create --webhook <url>` or `cron edit <job-id> --webhook <url>` to set webhook delivery. Do not combine `--webhook` with chat delivery flags such as `--announce`, `--no-deliver`, `--channel`, `--to`, `--thread-id`, or `--account`.
`--announce` is runner fallback delivery for the final reply. `--no-deliver` disables that fallback but does not remove the agent's `message` tool when a chat route is available.
Reminders created from an active chat preserve the live chat delivery target for fallback announce delivery. Internal session keys may be lowercase; do not use them as a source of truth for case-sensitive provider IDs such as Matrix room IDs.
@@ -219,11 +241,10 @@ openclaw cron edit <job-id> --announce --channel telegram --to "-1001234567890"
Create an isolated job with lightweight bootstrap context:
```bash
openclaw cron add \
openclaw cron create "0 7 * * *" \
"Summarize overnight updates." \
--name "Lightweight morning brief" \
--cron "0 7 * * *" \
--session isolated \
--message "Summarize overnight updates." \
--light-context \
--no-deliver
```
@@ -270,6 +291,7 @@ Delivery tweaks:
```bash
openclaw cron edit <job-id> --announce --channel slack --to "channel:C1234567890"
openclaw cron edit <job-id> --webhook "https://example.invalid/openclaw/cron"
openclaw cron edit <job-id> --best-effort-deliver
openclaw cron edit <job-id> --no-best-effort-deliver
openclaw cron edit <job-id> --no-deliver
+49 -37
View File
@@ -75,7 +75,9 @@ function stableJson(value) {
}
export function sanitizeDisplayValue(value) {
return String(value).replace(/[\p{Cc}]/gu, "?").slice(0, 240);
return String(value)
.replace(/[\p{Cc}]/gu, "?")
.slice(0, 240);
}
export function markdownCode(value) {
@@ -242,10 +244,7 @@ export function renderAuthorizedDependencyComment(override) {
if (override.reason) {
lines.push(`- Reason: ${markdownCode(override.reason)}`);
}
lines.push(
"",
"A later push changes the PR head SHA and requires a fresh security approval.",
);
lines.push("", "A later push changes the PR head SHA and requires a fresh security approval.");
return lines.join("\n");
}
@@ -278,19 +277,20 @@ export function renderBlockedDependencyComment({
`- ${markdownCode(change.path)} changed ${change.fields.map(markdownCode).join(", ")}.`,
);
}
const removalSteps = lockfileChanges.length > 0
? [
"",
"To remove accidental lockfile residue, restore the lockfile changes from the target branch:",
"",
"```bash",
"git fetch origin",
`git checkout ${baseRef} -- ${lockfileChanges.map(shellQuote).join(" ")}`,
'git commit -m "Remove dependency lockfile change"',
"git push",
"```",
]
: [];
const removalSteps =
lockfileChanges.length > 0
? [
"",
"To remove accidental lockfile residue, restore the lockfile changes from the target branch:",
"",
"```bash",
"git fetch origin",
`git checkout ${baseRef} -- ${lockfileChanges.map(shellQuote).join(" ")}`,
'git commit -m "Remove dependency lockfile change"',
"git push",
"```",
]
: [];
return [
dependencyGraphGuardMarker,
"",
@@ -328,7 +328,9 @@ function githubApi(token) {
return null;
}
if (!response.ok) {
const error = new Error(`${response.status} ${response.statusText}: ${await response.text()}`);
const error = new Error(
`${response.status} ${response.statusText}: ${await response.text()}`,
);
error.status = response.status;
throw error;
}
@@ -477,39 +479,49 @@ async function main() {
if (!labelNames.has(label)) {
return;
}
await api.request(`${issuePath}/labels/${encodeURIComponent(label)}`, {
method: "DELETE",
}).catch(ignoreUnavailableWritePermission(`label "${label}" removal`));
await api
.request(`${issuePath}/labels/${encodeURIComponent(label)}`, {
method: "DELETE",
})
.catch(ignoreUnavailableWritePermission(`label "${label}" removal`));
};
const addLabelIfMissing = async (label) => {
if (labelNames.has(label)) {
return;
}
await api.request(`${issuePath}/labels`, {
method: "POST",
body: JSON.stringify({ labels: [label] }),
}).catch(ignoreUnavailableWritePermission(`label "${label}" update`));
await api
.request(`${issuePath}/labels`, {
method: "POST",
body: JSON.stringify({ labels: [label] }),
})
.catch(ignoreUnavailableWritePermission(`label "${label}" update`));
};
const deleteCommentIfPresent = async (comment) => {
if (!comment) {
return;
}
await api.request(`/repos/${owner}/${repo}/issues/comments/${comment.id}`, {
method: "DELETE",
}).catch(ignoreUnavailableWritePermission("comment deletion"));
await api
.request(`/repos/${owner}/${repo}/issues/comments/${comment.id}`, {
method: "DELETE",
})
.catch(ignoreUnavailableWritePermission("comment deletion"));
};
const upsertComment = async (comment, body) => {
if (comment) {
await api.request(`/repos/${owner}/${repo}/issues/comments/${comment.id}`, {
method: "PATCH",
body: JSON.stringify({ body }),
}).catch(ignoreUnavailableWritePermission("comment update"));
await api
.request(`/repos/${owner}/${repo}/issues/comments/${comment.id}`, {
method: "PATCH",
body: JSON.stringify({ body }),
})
.catch(ignoreUnavailableWritePermission("comment update"));
return;
}
await api.request(`${issuePath}/comments`, {
method: "POST",
body: JSON.stringify({ body }),
}).catch(ignoreUnavailableWritePermission("comment creation"));
await api
.request(`${issuePath}/comments`, {
method: "POST",
body: JSON.stringify({ body }),
})
.catch(ignoreUnavailableWritePermission("comment creation"));
};
if (dependencyGraphFiles.length === 0) {
+148 -2
View File
@@ -79,8 +79,11 @@ type CronUpdatePatch = {
};
type CronAddParams = {
schedule?: { kind?: string; at?: string; staggerMs?: number };
name?: string;
schedule?: { kind?: string; at?: string; expr?: string; everyMs?: number; staggerMs?: number };
payload?: {
kind?: string;
message?: string;
model?: string;
thinking?: string;
lightContext?: boolean;
@@ -398,6 +401,111 @@ describe("cron cli", () => {
expect(params?.delivery?.mode).toBe("announce");
});
it("accepts positional cron create name with webhook delivery", async () => {
const params = await runCronAddAndGetParams([
"Webhook reminder",
"--at",
"20m",
"--system-event",
"Summarize the latest status",
"--webhook",
" https://example.invalid/openclaw ",
]);
expect(params?.name).toBe("Webhook reminder");
expect(params?.sessionTarget).toBe("main");
expect(params?.delivery).toEqual({
mode: "webhook",
to: "https://example.invalid/openclaw",
channel: undefined,
threadId: undefined,
accountId: undefined,
bestEffort: undefined,
});
});
it("accepts Hermes-style positional cron schedule and message on cron create", async () => {
const params = await runCronAddAndGetParams([
"0 2 * * *",
"Pull the top bug from the issue tracker, attempt a fix, and open a draft PR.",
"--name",
"Nightly bug fix",
"--agent",
"ops",
]);
expect(params?.name).toBe("Nightly bug fix");
expect(params?.schedule).toMatchObject({ kind: "cron", expr: "0 2 * * *" });
expect(params?.sessionTarget).toBe("isolated");
expect(params?.payload?.kind).toBe("agentTurn");
expect(params?.payload?.message).toBe(
"Pull the top bug from the issue tracker, attempt a fix, and open a draft PR.",
);
});
it("accepts Hermes-style every interval schedule on cron create", async () => {
const params = await runCronAddAndGetParams([
"every 1h",
"Summarize what changed.",
"--name",
"Pricing monitor",
"--no-deliver",
]);
expect(params?.schedule).toEqual({ kind: "every", everyMs: 3_600_000 });
expect(params?.payload?.message).toBe("Summarize what changed.");
expect(params?.delivery?.mode).toBe("none");
});
it("rejects conflicting positional and option messages on cron create", async () => {
await expectCronCommandExit([
"cron",
"create",
"0 2 * * *",
"Positional prompt",
"--name",
"Mixed prompt",
"--message",
"Option prompt",
]);
expectRuntimeErrorContaining("Pass the cron job message either positionally or with --message");
});
it("rejects ambiguous cron add names", async () => {
await expectCronCommandExit([
"cron",
"add",
"Positional",
"--name",
"Option",
"--cron",
"* * * * *",
"--system-event",
"tick",
]);
expectRuntimeErrorContaining("Pass the cron job name either positionally or with --name");
});
it("rejects webhook delivery mixed with chat delivery on cron add", async () => {
await expectCronCommandExit([
"cron",
"add",
"Mixed delivery",
"--cron",
"* * * * *",
"--message",
"hello",
"--webhook",
"https://example.invalid/openclaw",
"--to",
"channel:C123",
]);
expectRuntimeErrorContaining("--webhook cannot be combined with chat delivery options");
});
it("infers sessionTarget from payload when --session is omitted", async () => {
await runCronCommand([
"cron",
@@ -938,10 +1046,48 @@ describe("cron cli", () => {
patch?: { payload?: { kind?: string }; delivery?: { mode?: string } };
}>("cron.update");
expect(patch?.patch?.payload?.kind).toBe("agentTurn");
expect(patch?.patch?.payload).toBeUndefined();
expect(patch?.patch?.delivery?.mode).toBe("none");
});
it("sets webhook delivery without forcing an agentTurn payload on cron edit", async () => {
await runCronCommand([
"cron",
"edit",
"job-1",
"--webhook",
" https://example.invalid/cron ",
"--best-effort-deliver",
]);
const patch = getGatewayCallParams<{
patch?: {
payload?: { kind?: string };
delivery?: { mode?: string; to?: string; bestEffort?: boolean };
};
}>("cron.update");
expect(patch?.patch?.payload).toBeUndefined();
expect(patch?.patch?.delivery).toEqual({
mode: "webhook",
to: "https://example.invalid/cron",
bestEffort: true,
});
});
it("rejects webhook delivery mixed with announce delivery on cron edit", async () => {
await expectCronCommandExit([
"cron",
"edit",
"job-1",
"--webhook",
"https://example.invalid/cron",
"--announce",
]);
expectRuntimeErrorContaining("Choose at most one of --announce, --no-deliver, or --webhook");
});
it("updates delivery account without requiring --message on cron edit", async () => {
const patch = await runCronEditAndGetPatch(["--account", " coordinator "]);
expect(patch?.patch?.payload?.kind).toBe("agentTurn");
+200 -152
View File
@@ -10,7 +10,7 @@ import { theme } from "../../terminal/theme.js";
import type { GatewayRpcOpts } from "../gateway-rpc.js";
import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js";
import { parsePositiveIntOrUndefined } from "../program/helpers.js";
import { resolveCronCreateSchedule } from "./schedule-options.js";
import { resolveCronCreateScheduleFromArgs } from "./schedule-options.js";
import {
getCronChannelOptions,
coerceCronDeliveryPreviews,
@@ -78,7 +78,9 @@ export function registerCronAddCommand(cron: Command) {
.command("add")
.alias("create")
.description("Add a cron job")
.requiredOption("--name <name>", "Job name")
.argument("[scheduleOrName]", "Schedule string, or job name when using --at/--every/--cron")
.argument("[message]", "Agent message when using a positional schedule")
.option("--name <name>", "Job name")
.option("--description <text>", "Optional description")
.option("--disabled", "Create job disabled", false)
.option("--delete-after-run", "Delete one-shot job after it succeeds", false)
@@ -113,6 +115,7 @@ export function registerCronAddCommand(cron: Command) {
.option("--announce", "Fallback-deliver final text to a chat", false)
.option("--deliver", "Deprecated (use --announce). Fallback-delivers final text to a chat.")
.option("--no-deliver", "Disable runner fallback delivery")
.option("--webhook <url>", "POST the finished payload to a webhook URL")
.option("--channel <channel>", `Delivery channel (${getCronChannelOptions()})`, "last")
.option(
"--to <dest>",
@@ -122,161 +125,206 @@ export function registerCronAddCommand(cron: Command) {
.option("--account <id>", "Channel account id for delivery (multi-account setups)")
.option("--best-effort-deliver", "Do not fail the job if delivery fails", false)
.option("--json", "Output JSON", false)
.action(async (opts: GatewayRpcOpts & Record<string, unknown>, cmd?: Command) => {
try {
const schedule = resolveCronCreateSchedule({
at: opts.at,
cron: opts.cron,
every: opts.every,
exact: opts.exact,
stagger: opts.stagger,
tz: opts.tz,
});
.action(
async (
nameArg: string | undefined,
messageArg: string | undefined,
opts: GatewayRpcOpts & Record<string, unknown>,
cmd?: Command,
) => {
try {
const hasScheduleFlag =
typeof opts.at === "string" ||
typeof opts.cron === "string" ||
typeof opts.every === "string";
const positionalSchedule = hasScheduleFlag ? undefined : nameArg;
const schedule = resolveCronCreateScheduleFromArgs({
at: opts.at,
cron: opts.cron,
every: opts.every,
exact: opts.exact,
positionalSchedule,
stagger: opts.stagger,
tz: opts.tz,
});
const wakeMode = normalizeOptionalString(opts.wake) ?? "now";
if (wakeMode !== "now" && wakeMode !== "next-heartbeat") {
throw new Error("--wake must be now or next-heartbeat");
}
const rawAgentId = normalizeOptionalString(opts.agent);
const agentId = rawAgentId ? sanitizeAgentId(rawAgentId) : undefined;
const hasAnnounce = Boolean(opts.announce) || opts.deliver === true;
const hasNoDeliver = opts.deliver === false;
const deliveryFlagCount = [hasAnnounce, hasNoDeliver].filter(Boolean).length;
if (deliveryFlagCount > 1) {
throw new Error("Choose at most one of --announce or --no-deliver");
}
const payload = (() => {
const systemEvent = normalizeOptionalString(opts.systemEvent) ?? "";
const message = normalizeOptionalString(opts.message) ?? "";
const chosen = [Boolean(systemEvent), Boolean(message)].filter(Boolean).length;
if (chosen !== 1) {
throw new Error("Choose exactly one payload: --system-event or --message");
const wakeMode = normalizeOptionalString(opts.wake) ?? "now";
if (wakeMode !== "now" && wakeMode !== "next-heartbeat") {
throw new Error("--wake must be now or next-heartbeat");
}
if (systemEvent) {
return { kind: "systemEvent" as const, text: systemEvent };
const rawAgentId = normalizeOptionalString(opts.agent);
const agentId = rawAgentId ? sanitizeAgentId(rawAgentId) : undefined;
const optionSource =
typeof cmd?.getOptionValueSource === "function"
? (name: string) => cmd.getOptionValueSource(name)
: () => undefined;
const hasAnnounce = Boolean(opts.announce) || opts.deliver === true;
const hasNoDeliver = opts.deliver === false;
const webhookUrl = normalizeOptionalString(opts.webhook);
const hasWebhook = typeof opts.webhook === "string";
const deliveryFlagCount = [hasAnnounce, hasNoDeliver, hasWebhook].filter(
Boolean,
).length;
if (deliveryFlagCount > 1) {
throw new Error("Choose at most one of --announce, --no-deliver, or --webhook");
}
const timeoutSeconds = parsePositiveIntOrUndefined(opts.timeoutSeconds);
return {
kind: "agentTurn" as const,
message,
model: normalizeOptionalString(opts.model),
thinking: normalizeOptionalString(opts.thinking),
timeoutSeconds:
timeoutSeconds && Number.isFinite(timeoutSeconds) ? timeoutSeconds : undefined,
lightContext: opts.lightContext === true ? true : undefined,
toolsAllow: parseCronToolsAllow(opts.tools),
const payload = (() => {
const systemEvent = normalizeOptionalString(opts.systemEvent) ?? "";
const optionMessage = normalizeOptionalString(opts.message);
const positionalMessage = normalizeOptionalString(messageArg);
if (optionMessage && positionalMessage && optionMessage !== positionalMessage) {
throw new Error(
"Pass the cron job message either positionally or with --message, not both.",
);
}
const message = optionMessage ?? positionalMessage ?? "";
const chosen = [Boolean(systemEvent), Boolean(message)].filter(Boolean).length;
if (chosen !== 1) {
throw new Error("Choose exactly one payload: --system-event or --message");
}
if (systemEvent) {
return { kind: "systemEvent" as const, text: systemEvent };
}
const timeoutSeconds = parsePositiveIntOrUndefined(opts.timeoutSeconds);
return {
kind: "agentTurn" as const,
message,
model: normalizeOptionalString(opts.model),
thinking: normalizeOptionalString(opts.thinking),
timeoutSeconds:
timeoutSeconds && Number.isFinite(timeoutSeconds) ? timeoutSeconds : undefined,
lightContext: opts.lightContext === true ? true : undefined,
toolsAllow: parseCronToolsAllow(opts.tools),
};
})();
const sessionSource = optionSource("session");
const sessionTargetRaw = normalizeOptionalString(opts.session) ?? "";
const inferredSessionTarget = payload.kind === "agentTurn" ? "isolated" : "main";
const sessionTarget =
sessionSource === "cli"
? normalizeCronSessionTargetOption(sessionTargetRaw) || ""
: inferredSessionTarget;
const isCustomSessionTarget =
normalizeLowercaseStringOrEmpty(sessionTarget).startsWith("session:") &&
Boolean(normalizeOptionalString(sessionTarget.slice(8)));
const isIsolatedLikeSessionTarget =
sessionTarget === "isolated" || sessionTarget === "current" || isCustomSessionTarget;
if (sessionTarget !== "main" && !isIsolatedLikeSessionTarget) {
throw new Error("--session must be main, isolated, current, or session:<id>");
}
if (opts.deleteAfterRun && opts.keepAfterRun) {
throw new Error("Choose --delete-after-run or --keep-after-run, not both");
}
if (sessionTarget === "main" && payload.kind !== "systemEvent") {
throw new Error("Main jobs require --system-event (systemEvent).");
}
if (isIsolatedLikeSessionTarget && payload.kind !== "agentTurn") {
throw new Error(
"Isolated/current/custom-session jobs require --message (agentTurn).",
);
}
if (
(opts.announce || typeof opts.deliver === "boolean") &&
(!isIsolatedLikeSessionTarget || payload.kind !== "agentTurn")
) {
throw new Error(
"--announce/--no-deliver require a non-main agentTurn session target.",
);
}
const accountId = normalizeOptionalString(opts.account);
const threadId = parseCronThreadIdOption(opts.threadId);
const hasThreadId = typeof threadId === "number";
const hasChatDeliveryTarget =
optionSource("channel") === "cli" ||
typeof opts.to === "string" ||
Boolean(accountId) ||
hasThreadId;
if (
(accountId || hasThreadId) &&
(!isIsolatedLikeSessionTarget || payload.kind !== "agentTurn")
) {
throw new Error(
"--account and --thread-id require a non-main agentTurn job with delivery.",
);
}
if (hasWebhook && hasChatDeliveryTarget) {
throw new Error("--webhook cannot be combined with chat delivery options.");
}
const deliveryMode = hasWebhook
? "webhook"
: isIsolatedLikeSessionTarget && payload.kind === "agentTurn"
? hasAnnounce
? "announce"
: hasNoDeliver
? "none"
: "announce"
: undefined;
const optionName = normalizeOptionalString(opts.name);
const positionalName = hasScheduleFlag ? normalizeOptionalString(nameArg) : undefined;
if (optionName && positionalName && optionName !== positionalName) {
throw new Error(
"Pass the cron job name either positionally or with --name, not both.",
);
}
const name = optionName ?? positionalName ?? "";
if (!name) {
throw new Error("Cron job name is required. Pass a name or --name <name>.");
}
const description = normalizeOptionalString(opts.description);
const sessionKey = normalizeOptionalString(opts.sessionKey);
if (payload.kind === "agentTurn" && !agentId) {
defaultRuntime.error(
theme.warn(
"No --agent specified; the job will run with the configured default agent. " +
"Specify --agent to choose a specific agent.",
),
);
}
const params = {
name,
description,
enabled: !opts.disabled,
deleteAfterRun: opts.deleteAfterRun ? true : opts.keepAfterRun ? false : undefined,
agentId,
sessionKey,
schedule,
sessionTarget,
wakeMode,
payload,
delivery: deliveryMode
? {
mode: deliveryMode,
channel: hasWebhook ? undefined : normalizeOptionalString(opts.channel),
to: hasWebhook ? webhookUrl : normalizeOptionalString(opts.to),
threadId: hasWebhook ? undefined : threadId,
accountId: hasWebhook ? undefined : accountId,
bestEffort: opts.bestEffortDeliver ? true : undefined,
}
: undefined,
};
})();
const optionSource =
typeof cmd?.getOptionValueSource === "function"
? (name: string) => cmd.getOptionValueSource(name)
: () => undefined;
const sessionSource = optionSource("session");
const sessionTargetRaw = normalizeOptionalString(opts.session) ?? "";
const inferredSessionTarget = payload.kind === "agentTurn" ? "isolated" : "main";
const sessionTarget =
sessionSource === "cli"
? normalizeCronSessionTargetOption(sessionTargetRaw) || ""
: inferredSessionTarget;
const isCustomSessionTarget =
normalizeLowercaseStringOrEmpty(sessionTarget).startsWith("session:") &&
Boolean(normalizeOptionalString(sessionTarget.slice(8)));
const isIsolatedLikeSessionTarget =
sessionTarget === "isolated" || sessionTarget === "current" || isCustomSessionTarget;
if (sessionTarget !== "main" && !isIsolatedLikeSessionTarget) {
throw new Error("--session must be main, isolated, current, or session:<id>");
const res = await callGatewayFromCli("cron.add", opts, params);
printCronJson(res);
await warnIfCronSchedulerDisabled(opts);
} catch (err) {
handleCronCliError(err);
}
if (opts.deleteAfterRun && opts.keepAfterRun) {
throw new Error("Choose --delete-after-run or --keep-after-run, not both");
}
if (sessionTarget === "main" && payload.kind !== "systemEvent") {
throw new Error("Main jobs require --system-event (systemEvent).");
}
if (isIsolatedLikeSessionTarget && payload.kind !== "agentTurn") {
throw new Error("Isolated/current/custom-session jobs require --message (agentTurn).");
}
if (
(opts.announce || typeof opts.deliver === "boolean") &&
(!isIsolatedLikeSessionTarget || payload.kind !== "agentTurn")
) {
throw new Error("--announce/--no-deliver require a non-main agentTurn session target.");
}
const accountId = normalizeOptionalString(opts.account);
const threadId = parseCronThreadIdOption(opts.threadId);
const hasThreadId = typeof threadId === "number";
if (
(accountId || hasThreadId) &&
(!isIsolatedLikeSessionTarget || payload.kind !== "agentTurn")
) {
throw new Error(
"--account and --thread-id require a non-main agentTurn job with delivery.",
);
}
const deliveryMode =
isIsolatedLikeSessionTarget && payload.kind === "agentTurn"
? hasAnnounce
? "announce"
: hasNoDeliver
? "none"
: "announce"
: undefined;
const name = normalizeOptionalString(opts.name) ?? "";
if (!name) {
throw new Error("Cron job name is required. Pass --name <name>.");
}
const description = normalizeOptionalString(opts.description);
const sessionKey = normalizeOptionalString(opts.sessionKey);
if (payload.kind === "agentTurn" && !agentId) {
defaultRuntime.error(
theme.warn(
"No --agent specified; the job will run with the configured default agent. " +
"Specify --agent to choose a specific agent.",
),
);
}
const params = {
name,
description,
enabled: !opts.disabled,
deleteAfterRun: opts.deleteAfterRun ? true : opts.keepAfterRun ? false : undefined,
agentId,
sessionKey,
schedule,
sessionTarget,
wakeMode,
payload,
delivery: deliveryMode
? {
mode: deliveryMode,
channel: normalizeOptionalString(opts.channel),
to: normalizeOptionalString(opts.to),
threadId,
accountId,
bestEffort: opts.bestEffortDeliver ? true : undefined,
}
: undefined,
};
const res = await callGatewayFromCli("cron.add", opts, params);
printCronJson(res);
await warnIfCronSchedulerDisabled(opts);
} catch (err) {
handleCronCliError(err);
}
}),
},
),
);
}
+26 -7
View File
@@ -103,6 +103,7 @@ export function registerCronEditCommand(cron: Command) {
.option("--announce", "Fallback-deliver final text to a chat")
.option("--deliver", "Deprecated (use --announce). Fallback-delivers final text to a chat.")
.option("--no-deliver", "Disable runner fallback delivery")
.option("--webhook <url>", "POST the finished payload to a webhook URL")
.option("--channel <channel>", `Delivery channel (${getCronChannelOptions()})`)
.option(
"--to <dest>",
@@ -155,8 +156,14 @@ export function registerCronEditCommand(cron: Command) {
"Isolated jobs cannot use --system-event; use --message or --session main.",
);
}
if (opts.announce && typeof opts.deliver === "boolean") {
throw new Error("Choose --announce or --no-deliver (not multiple).");
const hasWebhookDelivery = typeof opts.webhook === "string";
const deliveryModeFlagCount = [
Boolean(opts.announce),
typeof opts.deliver === "boolean",
hasWebhookDelivery,
].filter(Boolean).length;
if (deliveryModeFlagCount > 1) {
throw new Error("Choose at most one of --announce, --no-deliver, or --webhook.");
}
const patch: Record<string, unknown> = {};
if (typeof opts.name === "string") {
@@ -248,13 +255,17 @@ export function registerCronEditCommand(cron: Command) {
if (rawTimeoutSeconds !== undefined && !hasTimeoutSeconds) {
throw new Error("Invalid --timeout-seconds (must be a positive integer).");
}
const hasDeliveryModeFlag = opts.announce || typeof opts.deliver === "boolean";
const hasDeliveryModeFlag =
opts.announce || typeof opts.deliver === "boolean" || hasWebhookDelivery;
const threadId = parseCronThreadIdOption(opts.threadId);
const hasDeliveryThreadId = typeof threadId === "number";
const hasDeliveryTarget =
typeof opts.channel === "string" || typeof opts.to === "string" || hasDeliveryThreadId;
const hasDeliveryAccount = typeof opts.account === "string";
const hasBestEffort = typeof opts.bestEffortDeliver === "boolean";
if (hasWebhookDelivery && (hasDeliveryTarget || hasDeliveryAccount)) {
throw new Error("--webhook cannot be combined with chat delivery options.");
}
const hasAgentTurnPayloadField =
typeof opts.message === "string" ||
Boolean(model) ||
@@ -266,10 +277,11 @@ export function registerCronEditCommand(cron: Command) {
opts.clearTools;
const hasAgentTurnPatch =
hasAgentTurnPayloadField ||
hasDeliveryModeFlag ||
Boolean(opts.announce) ||
opts.deliver === true ||
hasDeliveryTarget ||
hasDeliveryAccount ||
hasBestEffort;
(hasBestEffort && !hasWebhookDelivery);
if (hasSystemEventPatch && hasAgentTurnPatch) {
throw new Error("Choose at most one payload change");
}
@@ -301,7 +313,11 @@ export function registerCronEditCommand(cron: Command) {
if (hasDeliveryModeFlag || hasDeliveryTarget || hasDeliveryAccount || hasBestEffort) {
const delivery: Record<string, unknown> = {};
if (hasDeliveryModeFlag) {
delivery.mode = opts.announce || opts.deliver === true ? "announce" : "none";
delivery.mode = hasWebhookDelivery
? "webhook"
: opts.announce || opts.deliver === true
? "announce"
: "none";
} else if (
opts.bestEffortDeliver === true ||
(hasAgentTurnPayloadField && hasBestEffort)
@@ -313,7 +329,10 @@ export function registerCronEditCommand(cron: Command) {
const channel = opts.channel.trim();
delivery.channel = channel ? channel : undefined;
}
if (typeof opts.to === "string") {
if (hasWebhookDelivery) {
const webhook = normalizeOptionalString(opts.webhook) ?? "";
delivery.to = webhook ? webhook : undefined;
} else if (typeof opts.to === "string") {
const to = opts.to.trim();
delivery.to = to ? to : undefined;
}
+38
View File
@@ -11,6 +11,10 @@ type ScheduleOptionInput = {
tz?: unknown;
};
type PositionalScheduleInput = {
positionalSchedule?: unknown;
};
type NormalizedScheduleOptions = {
at: string;
cronExpr: string;
@@ -37,6 +41,30 @@ export function resolveCronCreateSchedule(options: ScheduleOptionInput): CronSch
return schedule;
}
export function resolveCronCreateScheduleFromArgs(
options: ScheduleOptionInput & PositionalScheduleInput,
): CronSchedule {
const positionalSchedule = normalizeOptionalString(options.positionalSchedule);
if (!positionalSchedule) {
return resolveCronCreateSchedule(options);
}
const normalized = normalizeScheduleOptions(options);
if (countChosenSchedules(normalized) > 0) {
throw new Error("Choose a positional schedule or one of --at, --every, or --cron.");
}
const every = parseEverySchedule(positionalSchedule);
return resolveCronCreateSchedule({
...options,
at: every
? undefined
: looksLikeCronExpression(positionalSchedule)
? undefined
: positionalSchedule,
cron: looksLikeCronExpression(positionalSchedule) ? positionalSchedule : undefined,
every,
});
}
export function resolveCronEditScheduleRequest(
options: ScheduleOptionInput,
): CronEditScheduleRequest {
@@ -94,6 +122,16 @@ function countChosenSchedules(options: NormalizedScheduleOptions): number {
.length;
}
function parseEverySchedule(value: string): string | undefined {
const match = /^every\s+(.+)$/iu.exec(value.trim());
return match?.[1]?.trim() || undefined;
}
function looksLikeCronExpression(value: string): boolean {
const parts = value.trim().split(/\s+/u);
return parts.length === 5 || parts.length === 6;
}
function resolveDirectSchedule(options: NormalizedScheduleOptions): CronSchedule | undefined {
if (options.tz && options.every) {
throw new Error("--tz is only valid with --cron or offset-less --at");
+6 -1
View File
@@ -1,5 +1,6 @@
import { normalizeOptionalString } from "../shared/string-coerce.js";
import { coerceFiniteScheduleNumber } from "./schedule-number.js";
import { normalizeCronStaggerMs } from "./stagger.js";
function readString(record: Record<string, unknown>, key: string): string | undefined {
return normalizeOptionalString(record[key]);
@@ -9,6 +10,10 @@ function readNumber(record: Record<string, unknown>, key: string): number | unde
return coerceFiniteScheduleNumber(record[key]);
}
function readStaggerMs(record: Record<string, unknown>): number | undefined {
return normalizeCronStaggerMs(record.staggerMs);
}
function schedulePayloadFromRecord(
schedule: Record<string, unknown>,
):
@@ -23,7 +28,7 @@ function schedulePayloadFromRecord(
const everyMs = readNumber(schedule, "everyMs");
const anchorMs = readNumber(schedule, "anchorMs");
const tz = readString(schedule, "tz");
const staggerMs = readNumber(schedule, "staggerMs");
const staggerMs = readStaggerMs(schedule);
const kind =
rawKind === "at" || rawKind === "every" || rawKind === "cron"
? rawKind
+59
View File
@@ -76,6 +76,65 @@ describe("applyJobPatch", () => {
expect(job.delivery).toEqual({ mode: "webhook", to: "https://example.invalid/cron" });
});
it("clears chat delivery fields when switching delivery to webhook", () => {
const job = createIsolatedAgentTurnJob("job-webhook-switch", {
mode: "announce",
channel: "telegram",
to: "-100123",
threadId: 42,
accountId: "coordinator",
});
applyJobPatch(job, {
delivery: { mode: "webhook", to: "https://example.invalid/cron" },
});
expect(job.delivery).toEqual({
mode: "webhook",
to: "https://example.invalid/cron",
bestEffort: undefined,
failureDestination: undefined,
});
});
it("clears webhook delivery targets when switching delivery to announce", () => {
const job = createIsolatedAgentTurnJob("job-announce-switch", {
mode: "webhook",
to: "https://example.invalid/cron",
});
applyJobPatch(job, {
delivery: { mode: "announce" },
});
expect(job.delivery).toEqual({
mode: "announce",
channel: undefined,
to: undefined,
threadId: undefined,
accountId: undefined,
bestEffort: undefined,
failureDestination: undefined,
});
});
it("keeps explicit chat targets when switching webhook delivery to announce", () => {
const job = createIsolatedAgentTurnJob("job-announce-switch-target", {
mode: "webhook",
to: "https://example.invalid/cron",
});
applyJobPatch(job, {
delivery: { mode: "announce", channel: "telegram", to: "-100123" },
});
expect(job.delivery).toMatchObject({
mode: "announce",
channel: "telegram",
to: "-100123",
});
});
it("applies explicit delivery patches", () => {
const job = createIsolatedAgentTurnJob("job-2", {
mode: "announce",
+9
View File
@@ -889,7 +889,16 @@ function mergeCronDelivery(
};
if (typeof patch.mode === "string") {
const previousMode = next.mode;
next.mode = (patch.mode as string) === "deliver" ? "announce" : patch.mode;
if (previousMode !== next.mode && (previousMode === "webhook" || next.mode === "webhook")) {
next.to = undefined;
}
if (next.mode === "webhook") {
next.channel = undefined;
next.threadId = undefined;
next.accountId = undefined;
}
}
if ("channel" in patch) {
next.channel = normalizeOptionalString(patch.channel);
+1 -8
View File
@@ -1,5 +1,4 @@
import { describe, expect, it } from "vitest";
import {
dependencyGuardCommentHeadSha,
dependencyFieldChanges,
@@ -71,13 +70,7 @@ describe("dependency guard script", () => {
scripts: { test: "new" },
},
),
).toEqual([
"optionalDependencies",
"peerDependencies",
"overrides",
"packageManager",
"pnpm",
]);
).toEqual(["optionalDependencies", "peerDependencies", "overrides", "packageManager", "pnpm"]);
});
it("accepts only security-member override commands for the current head sha", () => {
@@ -146,9 +146,7 @@ describe("dependency guard workflow", () => {
expect(codeowners).toContain(
"/test/scripts/dependency-guard-workflow.test.ts @openclaw/openclaw-secops",
);
expect(codeowners).toContain(
"/scripts/github/dependency-guard.mjs @openclaw/openclaw-secops",
);
expect(codeowners).toContain("/scripts/github/dependency-guard.mjs @openclaw/openclaw-secops");
expect(codeowners).toContain("/package-lock.json @openclaw/openclaw-secops");
expect(codeowners).toContain("/npm-shrinkwrap.json @openclaw/openclaw-secops");
expect(codeowners).toContain("/extensions/*/package-lock.json @openclaw/openclaw-secops");