From 2209faef405f495c97515045e8c7746946f816ad Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 29 May 2026 04:34:50 +0100 Subject: [PATCH] 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 --- docs/automation/cron-jobs.md | 20 +- docs/cli/cron.md | 28 +- scripts/github/dependency-guard.mjs | 86 +++-- src/cli/cron-cli.test.ts | 150 +++++++- src/cli/cron-cli/register.cron-add.ts | 352 ++++++++++-------- src/cli/cron-cli/register.cron-edit.ts | 33 +- src/cli/cron-cli/schedule-options.ts | 38 ++ src/cron/schedule-identity.ts | 7 +- src/cron/service.jobs.test.ts | 59 +++ src/cron/service/jobs.ts | 9 + test/scripts/dependency-guard-script.test.ts | 9 +- .../scripts/dependency-guard-workflow.test.ts | 4 +- 12 files changed, 576 insertions(+), 219 deletions(-) diff --git a/docs/automation/cron-jobs.md b/docs/automation/cron-jobs.md index fef5fa6d0df..32c09cfbafb 100644 --- a/docs/automation/cron-jobs.md +++ b/docs/automation/cron-jobs.md @@ -15,9 +15,8 @@ Cron is the Gateway's built-in scheduler. It persists jobs, wakes the agent at t ```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: ```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 ``` + + ```bash + openclaw cron create "0 18 * * 1-5" \ + "Summarize today's deploys as JSON." \ + --name "Deploy digest" \ + --webhook "https://example.invalid/openclaw/cron" + ``` + ## Webhooks @@ -411,12 +417,14 @@ openclaw cron runs --id --run-id openclaw cron remove # 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 --clear-agent ``` `openclaw cron run ` 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 ` 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`. + Model override note: diff --git a/docs/cli/cron.md b/docs/cli/cron.md index bf65a0102b7..5f8f71e95b1 100644 --- a/docs/cli/cron.md +++ b/docs/cli/cron.md @@ -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. +## 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 ` 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:`. @@ -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 ` or `cron edit --webhook ` 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 --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 --announce --channel slack --to "channel:C1234567890" +openclaw cron edit --webhook "https://example.invalid/openclaw/cron" openclaw cron edit --best-effort-deliver openclaw cron edit --no-best-effort-deliver openclaw cron edit --no-deliver diff --git a/scripts/github/dependency-guard.mjs b/scripts/github/dependency-guard.mjs index a8e5fcb4d28..f91e08de608 100644 --- a/scripts/github/dependency-guard.mjs +++ b/scripts/github/dependency-guard.mjs @@ -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) { diff --git a/src/cli/cron-cli.test.ts b/src/cli/cron-cli.test.ts index d595a6c974e..7726de99a5a 100644 --- a/src/cli/cron-cli.test.ts +++ b/src/cli/cron-cli.test.ts @@ -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"); diff --git a/src/cli/cron-cli/register.cron-add.ts b/src/cli/cron-cli/register.cron-add.ts index e5b4e38323c..f120f398416 100644 --- a/src/cli/cron-cli/register.cron-add.ts +++ b/src/cli/cron-cli/register.cron-add.ts @@ -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 ", "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 ", "Job name") .option("--description ", "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 ", "POST the finished payload to a webhook URL") .option("--channel ", `Delivery channel (${getCronChannelOptions()})`, "last") .option( "--to ", @@ -122,161 +125,206 @@ export function registerCronAddCommand(cron: Command) { .option("--account ", "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, 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, + 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:"); + } + + 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 ."); + } + + 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:"); + 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 ."); - } - - 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); - } - }), + }, + ), ); } diff --git a/src/cli/cron-cli/register.cron-edit.ts b/src/cli/cron-cli/register.cron-edit.ts index 61f7e461d52..365d42e48e2 100644 --- a/src/cli/cron-cli/register.cron-edit.ts +++ b/src/cli/cron-cli/register.cron-edit.ts @@ -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 ", "POST the finished payload to a webhook URL") .option("--channel ", `Delivery channel (${getCronChannelOptions()})`) .option( "--to ", @@ -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 = {}; 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 = {}; 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; } diff --git a/src/cli/cron-cli/schedule-options.ts b/src/cli/cron-cli/schedule-options.ts index 3f3fde68bc5..bf8cd1e407f 100644 --- a/src/cli/cron-cli/schedule-options.ts +++ b/src/cli/cron-cli/schedule-options.ts @@ -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"); diff --git a/src/cron/schedule-identity.ts b/src/cron/schedule-identity.ts index f5629ed9cbd..f7eb7559279 100644 --- a/src/cron/schedule-identity.ts +++ b/src/cron/schedule-identity.ts @@ -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, key: string): string | undefined { return normalizeOptionalString(record[key]); @@ -9,6 +10,10 @@ function readNumber(record: Record, key: string): number | unde return coerceFiniteScheduleNumber(record[key]); } +function readStaggerMs(record: Record): number | undefined { + return normalizeCronStaggerMs(record.staggerMs); +} + function schedulePayloadFromRecord( schedule: Record, ): @@ -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 diff --git a/src/cron/service.jobs.test.ts b/src/cron/service.jobs.test.ts index 92ae081718c..4f66764a662 100644 --- a/src/cron/service.jobs.test.ts +++ b/src/cron/service.jobs.test.ts @@ -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", diff --git a/src/cron/service/jobs.ts b/src/cron/service/jobs.ts index b5565325690..be1c34317c1 100644 --- a/src/cron/service/jobs.ts +++ b/src/cron/service/jobs.ts @@ -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); diff --git a/test/scripts/dependency-guard-script.test.ts b/test/scripts/dependency-guard-script.test.ts index 4ab9cfd8fa3..cb6d0f606e8 100644 --- a/test/scripts/dependency-guard-script.test.ts +++ b/test/scripts/dependency-guard-script.test.ts @@ -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", () => { diff --git a/test/scripts/dependency-guard-workflow.test.ts b/test/scripts/dependency-guard-workflow.test.ts index 7202be6824c..550bf2ca195 100644 --- a/test/scripts/dependency-guard-workflow.test.ts +++ b/test/scripts/dependency-guard-workflow.test.ts @@ -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");