diff --git a/CHANGELOG.md b/CHANGELOG.md index b805daa3e..f158ea65e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai - Subagents: nested sub-agents (sub-sub-agents) with configurable depth. Set `agents.defaults.subagents.maxSpawnDepth: 2` to allow sub-agents to spawn their own children. Includes `maxChildrenPerAgent` limit (default 5), depth-aware tool policy, and proper announce chain routing. (#14447) Thanks @tyler6204. - Slack/Discord/Telegram: add per-channel ack reaction overrides (account/channel-level) to support platform-specific emoji formats. (#17092) Thanks @zerone0x. - Cron/Gateway: add finished-run webhook delivery toggle (`notify`) and dedicated webhook auth token support (`cron.webhookToken`) for outbound cron webhook posts. (#14535) Thanks @advaitpaliwal. +- Cron/Gateway: separate per-job webhook delivery (`delivery.mode = "webhook"`) from announce delivery, enforce valid HTTP(S) webhook URLs, and keep a temporary legacy `notify + cron.webhook` fallback for stored jobs. (#17901) Thanks @advaitpaliwal. - Channels: deduplicate probe/token resolution base types across core + extensions while preserving per-channel error typing. (#16986) Thanks @iyoda and @thewilloftheshadow. ### Fixes diff --git a/apps/macos/Sources/OpenClaw/CronModels.swift b/apps/macos/Sources/OpenClaw/CronModels.swift index 43f0fa037..cbfbc061d 100644 --- a/apps/macos/Sources/OpenClaw/CronModels.swift +++ b/apps/macos/Sources/OpenClaw/CronModels.swift @@ -21,6 +21,7 @@ enum CronWakeMode: String, CaseIterable, Identifiable, Codable { enum CronDeliveryMode: String, CaseIterable, Identifiable, Codable { case none case announce + case webhook var id: String { self.rawValue diff --git a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift index 31763115a..8486e4c45 100644 --- a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift @@ -2087,7 +2087,6 @@ public struct CronJob: Codable, Sendable { public let name: String public let description: String? public let enabled: Bool - public let notify: Bool? public let deleteafterrun: Bool? public let createdatms: Int public let updatedatms: Int @@ -2095,7 +2094,7 @@ public struct CronJob: Codable, Sendable { public let sessiontarget: AnyCodable public let wakemode: AnyCodable public let payload: AnyCodable - public let delivery: [String: AnyCodable]? + public let delivery: AnyCodable? public let state: [String: AnyCodable] public init( @@ -2104,7 +2103,6 @@ public struct CronJob: Codable, Sendable { name: String, description: String?, enabled: Bool, - notify: Bool?, deleteafterrun: Bool?, createdatms: Int, updatedatms: Int, @@ -2112,7 +2110,7 @@ public struct CronJob: Codable, Sendable { sessiontarget: AnyCodable, wakemode: AnyCodable, payload: AnyCodable, - delivery: [String: AnyCodable]?, + delivery: AnyCodable?, state: [String: AnyCodable] ) { self.id = id @@ -2120,7 +2118,6 @@ public struct CronJob: Codable, Sendable { self.name = name self.description = description self.enabled = enabled - self.notify = notify self.deleteafterrun = deleteafterrun self.createdatms = createdatms self.updatedatms = updatedatms @@ -2137,7 +2134,6 @@ public struct CronJob: Codable, Sendable { case name case description case enabled - case notify case deleteafterrun = "deleteAfterRun" case createdatms = "createdAtMs" case updatedatms = "updatedAtMs" @@ -2171,32 +2167,29 @@ public struct CronAddParams: Codable, Sendable { public let agentid: AnyCodable? public let description: String? public let enabled: Bool? - public let notify: Bool? public let deleteafterrun: Bool? public let schedule: AnyCodable public let sessiontarget: AnyCodable public let wakemode: AnyCodable public let payload: AnyCodable - public let delivery: [String: AnyCodable]? + public let delivery: AnyCodable? public init( name: String, agentid: AnyCodable?, description: String?, enabled: Bool?, - notify: Bool?, deleteafterrun: Bool?, schedule: AnyCodable, sessiontarget: AnyCodable, wakemode: AnyCodable, payload: AnyCodable, - delivery: [String: AnyCodable]? + delivery: AnyCodable? ) { self.name = name self.agentid = agentid self.description = description self.enabled = enabled - self.notify = notify self.deleteafterrun = deleteafterrun self.schedule = schedule self.sessiontarget = sessiontarget @@ -2209,7 +2202,6 @@ public struct CronAddParams: Codable, Sendable { case agentid = "agentId" case description case enabled - case notify case deleteafterrun = "deleteAfterRun" case schedule case sessiontarget = "sessionTarget" diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 31763115a..8486e4c45 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -2087,7 +2087,6 @@ public struct CronJob: Codable, Sendable { public let name: String public let description: String? public let enabled: Bool - public let notify: Bool? public let deleteafterrun: Bool? public let createdatms: Int public let updatedatms: Int @@ -2095,7 +2094,7 @@ public struct CronJob: Codable, Sendable { public let sessiontarget: AnyCodable public let wakemode: AnyCodable public let payload: AnyCodable - public let delivery: [String: AnyCodable]? + public let delivery: AnyCodable? public let state: [String: AnyCodable] public init( @@ -2104,7 +2103,6 @@ public struct CronJob: Codable, Sendable { name: String, description: String?, enabled: Bool, - notify: Bool?, deleteafterrun: Bool?, createdatms: Int, updatedatms: Int, @@ -2112,7 +2110,7 @@ public struct CronJob: Codable, Sendable { sessiontarget: AnyCodable, wakemode: AnyCodable, payload: AnyCodable, - delivery: [String: AnyCodable]?, + delivery: AnyCodable?, state: [String: AnyCodable] ) { self.id = id @@ -2120,7 +2118,6 @@ public struct CronJob: Codable, Sendable { self.name = name self.description = description self.enabled = enabled - self.notify = notify self.deleteafterrun = deleteafterrun self.createdatms = createdatms self.updatedatms = updatedatms @@ -2137,7 +2134,6 @@ public struct CronJob: Codable, Sendable { case name case description case enabled - case notify case deleteafterrun = "deleteAfterRun" case createdatms = "createdAtMs" case updatedatms = "updatedAtMs" @@ -2171,32 +2167,29 @@ public struct CronAddParams: Codable, Sendable { public let agentid: AnyCodable? public let description: String? public let enabled: Bool? - public let notify: Bool? public let deleteafterrun: Bool? public let schedule: AnyCodable public let sessiontarget: AnyCodable public let wakemode: AnyCodable public let payload: AnyCodable - public let delivery: [String: AnyCodable]? + public let delivery: AnyCodable? public init( name: String, agentid: AnyCodable?, description: String?, enabled: Bool?, - notify: Bool?, deleteafterrun: Bool?, schedule: AnyCodable, sessiontarget: AnyCodable, wakemode: AnyCodable, payload: AnyCodable, - delivery: [String: AnyCodable]? + delivery: AnyCodable? ) { self.name = name self.agentid = agentid self.description = description self.enabled = enabled - self.notify = notify self.deleteafterrun = deleteafterrun self.schedule = schedule self.sessiontarget = sessiontarget @@ -2209,7 +2202,6 @@ public struct CronAddParams: Codable, Sendable { case agentid = "agentId" case description case enabled - case notify case deleteafterrun = "deleteAfterRun" case schedule case sessiontarget = "sessionTarget" diff --git a/docs/automation/cron-jobs.md b/docs/automation/cron-jobs.md index 82d66c23e..4ba650aaf 100644 --- a/docs/automation/cron-jobs.md +++ b/docs/automation/cron-jobs.md @@ -27,7 +27,8 @@ Troubleshooting: [/automation/troubleshooting](/automation/troubleshooting) - **Main session**: enqueue a system event, then run on the next heartbeat. - **Isolated**: run a dedicated agent turn in `cron:`, with delivery (announce by default or none). - Wakeups are first-class: a job can request “wake now” vs “next heartbeat”. -- Webhook posting is opt-in per job: set `notify: true` and configure `cron.webhook`. +- Webhook posting is per job via `delivery.mode = "webhook"` + `delivery.to = ""`. +- Legacy fallback remains for stored jobs with `notify: true` when `cron.webhook` is set, migrate those jobs to webhook delivery mode. ## Quick start (actionable) @@ -100,7 +101,7 @@ A cron job is a stored record with: - a **schedule** (when it should run), - a **payload** (what it should do), -- optional **delivery mode** (announce or none). +- optional **delivery mode** (`announce`, `webhook`, or `none`). - optional **agent binding** (`agentId`): run the job under a specific agent; if missing or unknown, the gateway falls back to the default agent. @@ -141,8 +142,9 @@ Key behaviors: - Prompt is prefixed with `[cron: ]` for traceability. - Each run starts a **fresh session id** (no prior conversation carry-over). - Default behavior: if `delivery` is omitted, isolated jobs announce a summary (`delivery.mode = "announce"`). -- `delivery.mode` (isolated-only) chooses what happens: +- `delivery.mode` chooses what happens: - `announce`: deliver a summary to the target channel and post a brief summary to the main session. + - `webhook`: POST the finished event payload to `delivery.to`. - `none`: internal only (no delivery, no main-session summary). - `wakeMode` controls when the main-session summary posts: - `now`: immediate heartbeat. @@ -164,11 +166,11 @@ Common `agentTurn` fields: - `model` / `thinking`: optional overrides (see below). - `timeoutSeconds`: optional timeout override. -Delivery config (isolated jobs only): +Delivery config: -- `delivery.mode`: `none` | `announce`. +- `delivery.mode`: `none` | `announce` | `webhook`. - `delivery.channel`: `last` or a specific channel. -- `delivery.to`: channel-specific target (phone/chat/channel id). +- `delivery.to`: channel-specific target (announce) or webhook URL (webhook mode). - `delivery.bestEffort`: avoid failing the job if announce delivery fails. Announce delivery suppresses messaging tool sends for the run; use `delivery.channel`/`delivery.to` @@ -193,6 +195,18 @@ Behavior details: - The main-session summary respects `wakeMode`: `now` triggers an immediate heartbeat and `next-heartbeat` waits for the next scheduled heartbeat. +#### Webhook delivery flow + +When `delivery.mode = "webhook"`, cron posts the finished event payload to `delivery.to`. + +Behavior details: + +- The endpoint must be a valid HTTP(S) URL. +- No channel delivery is attempted in webhook mode. +- No main-session summary is posted in webhook mode. +- If `cron.webhookToken` is set, auth header is `Authorization: Bearer `. +- Deprecated fallback: stored legacy jobs with `notify: true` still post to `cron.webhook` (if configured), with a warning so you can migrate to `delivery.mode = "webhook"`. + ### Model and thinking overrides Isolated jobs (`agentTurn`) can override the model and thinking level: @@ -214,11 +228,12 @@ Resolution priority: Isolated jobs can deliver output to a channel via the top-level `delivery` config: -- `delivery.mode`: `announce` (deliver a summary) or `none`. +- `delivery.mode`: `announce` (channel delivery), `webhook` (HTTP POST), or `none`. - `delivery.channel`: `whatsapp` / `telegram` / `discord` / `slack` / `mattermost` (plugin) / `signal` / `imessage` / `last`. - `delivery.to`: channel-specific recipient target. -Delivery config is only valid for isolated jobs (`sessionTarget: "isolated"`). +`announce` delivery is only valid for isolated jobs (`sessionTarget: "isolated"`). +`webhook` delivery is valid for both main and isolated jobs. If `delivery.channel` or `delivery.to` is omitted, cron can fall back to the main session’s “last route” (the last place the agent replied). @@ -289,7 +304,7 @@ Notes: - `schedule.at` accepts ISO 8601 (timezone optional; treated as UTC when omitted). - `everyMs` is milliseconds. - `sessionTarget` must be `"main"` or `"isolated"` and must match `payload.kind`. -- Optional fields: `agentId`, `description`, `enabled`, `notify`, `deleteAfterRun` (defaults to true for `at`), +- Optional fields: `agentId`, `description`, `enabled`, `deleteAfterRun` (defaults to true for `at`), `delivery`. - `wakeMode` defaults to `"now"` when omitted. @@ -334,18 +349,20 @@ Notes: enabled: true, // default true store: "~/.openclaw/cron/jobs.json", maxConcurrentRuns: 1, // default 1 - webhook: "https://example.invalid/cron-finished", // optional finished-run webhook endpoint - webhookToken: "replace-with-dedicated-webhook-token", // optional, do not reuse gateway auth token + webhook: "https://example.invalid/legacy", // deprecated fallback for stored notify:true jobs + webhookToken: "replace-with-dedicated-webhook-token", // optional bearer token for webhook mode }, } ``` Webhook behavior: -- The Gateway posts finished run events to `cron.webhook` only when the job has `notify: true`. +- Preferred: set `delivery.mode: "webhook"` with `delivery.to: "https://..."` per job. +- Webhook URLs must be valid `http://` or `https://` URLs. - Payload is the cron finished event JSON. - If `cron.webhookToken` is set, auth header is `Authorization: Bearer `. - If `cron.webhookToken` is not set, no `Authorization` header is sent. +- Deprecated fallback: stored legacy jobs with `notify: true` still use `cron.webhook` when present. Disable cron entirely: diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index c7f3d29f2..a74d3257a 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -2320,7 +2320,7 @@ Current builds no longer include the TCP bridge. Nodes connect over the Gateway cron: { enabled: true, maxConcurrentRuns: 2, - webhook: "https://example.invalid/cron-finished", // optional, must be http:// or https:// + webhook: "https://example.invalid/legacy", // deprecated fallback for stored notify:true jobs webhookToken: "replace-with-dedicated-token", // optional bearer token for outbound webhook auth sessionRetention: "24h", // duration string or false }, @@ -2328,8 +2328,8 @@ Current builds no longer include the TCP bridge. Nodes connect over the Gateway ``` - `sessionRetention`: how long to keep completed cron sessions before pruning. Default: `24h`. -- `webhook`: finished-run webhook endpoint, only used when the job has `notify: true`. -- `webhookToken`: dedicated bearer token for webhook auth, if omitted no auth header is sent. +- `webhookToken`: bearer token used for cron webhook POST delivery (`delivery.mode = "webhook"`), if omitted no auth header is sent. +- `webhook`: deprecated legacy fallback webhook URL (http/https) used only for stored jobs that still have `notify: true`. See [Cron Jobs](/automation/cron-jobs). diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index a0c9037cb..2547cc0b4 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -83,9 +83,10 @@ Cron jobs panel notes: - For isolated jobs, delivery defaults to announce summary. You can switch to none if you want internal-only runs. - Channel/target fields appear when announce is selected. -- New job form includes a **Notify webhook** toggle (`notify` on the job). -- Gateway webhook posting requires both `notify: true` on the job and `cron.webhook` in config. +- Webhook mode uses `delivery.mode = "webhook"` with `delivery.to` set to a valid HTTP(S) webhook URL. +- For main-session jobs, webhook and none delivery modes are available. - Set `cron.webhookToken` to send a dedicated bearer token, if omitted the webhook is sent without an auth header. +- Deprecated fallback: stored legacy jobs with `notify: true` can still use `cron.webhook` until migrated. ## Chat behavior diff --git a/src/agents/tools/cron-tool.e2e.test.ts b/src/agents/tools/cron-tool.e2e.test.ts index 1adbb2cd8..a43552643 100644 --- a/src/agents/tools/cron-tool.e2e.test.ts +++ b/src/agents/tools/cron-tool.e2e.test.ts @@ -443,4 +443,61 @@ describe("cron tool", () => { }; expect(call?.params?.delivery).toEqual({ mode: "none" }); }); + + it("does not infer announce delivery when mode is webhook", async () => { + callGatewayMock.mockResolvedValueOnce({ ok: true }); + + const tool = createCronTool({ agentSessionKey: "agent:main:discord:dm:buddy" }); + await tool.execute("call-webhook-explicit", { + action: "add", + job: { + name: "reminder", + schedule: { at: new Date(123).toISOString() }, + payload: { kind: "agentTurn", message: "hello" }, + delivery: { mode: "webhook", to: "https://example.invalid/cron-finished" }, + }, + }); + + const call = callGatewayMock.mock.calls[0]?.[0] as { + params?: { delivery?: { mode?: string; channel?: string; to?: string } }; + }; + expect(call?.params?.delivery).toEqual({ + mode: "webhook", + to: "https://example.invalid/cron-finished", + }); + }); + + it("fails fast when webhook mode is missing delivery.to", async () => { + const tool = createCronTool({ agentSessionKey: "agent:main:discord:dm:buddy" }); + + await expect( + tool.execute("call-webhook-missing", { + action: "add", + job: { + name: "reminder", + schedule: { at: new Date(123).toISOString() }, + payload: { kind: "agentTurn", message: "hello" }, + delivery: { mode: "webhook" }, + }, + }), + ).rejects.toThrow('delivery.mode="webhook" requires delivery.to to be a valid http(s) URL'); + expect(callGatewayMock).toHaveBeenCalledTimes(0); + }); + + it("fails fast when webhook mode uses a non-http URL", async () => { + const tool = createCronTool({ agentSessionKey: "agent:main:discord:dm:buddy" }); + + await expect( + tool.execute("call-webhook-invalid", { + action: "add", + job: { + name: "reminder", + schedule: { at: new Date(123).toISOString() }, + payload: { kind: "agentTurn", message: "hello" }, + delivery: { mode: "webhook", to: "ftp://example.invalid/cron-finished" }, + }, + }), + ).rejects.toThrow('delivery.mode="webhook" requires delivery.to to be a valid http(s) URL'); + expect(callGatewayMock).toHaveBeenCalledTimes(0); + }); }); diff --git a/src/agents/tools/cron-tool.ts b/src/agents/tools/cron-tool.ts index 6bc57e386..be5f1e9b8 100644 --- a/src/agents/tools/cron-tool.ts +++ b/src/agents/tools/cron-tool.ts @@ -2,6 +2,7 @@ import { Type } from "@sinclair/typebox"; import type { CronDelivery, CronMessageChannel } from "../../cron/types.js"; import { loadConfig } from "../../config/config.js"; import { normalizeCronJobCreate, normalizeCronJobPatch } from "../../cron/normalize.js"; +import { normalizeHttpWebhookUrl } from "../../cron/webhook-url.js"; import { parseAgentSessionKey } from "../../sessions/session-key-utils.js"; import { extractTextFromChatContent } from "../../shared/chat-content.js"; import { isRecord, truncateUtf16Safe } from "../../utils.js"; @@ -217,10 +218,9 @@ JOB SCHEMA (for add action): "name": "string (optional)", "schedule": { ... }, // Required: when to run "payload": { ... }, // Required: what to execute - "delivery": { ... }, // Optional: announce summary (isolated only) + "delivery": { ... }, // Optional: announce summary or webhook POST "sessionTarget": "main" | "isolated", // Required - "enabled": true | false, // Optional, default true - "notify": true | false // Optional webhook opt-in; set true for user-facing reminders + "enabled": true | false // Optional, default true } SCHEDULE TYPES (schedule.kind): @@ -239,15 +239,17 @@ PAYLOAD TYPES (payload.kind): - "agentTurn": Runs agent with message (isolated sessions only) { "kind": "agentTurn", "message": "", "model": "", "thinking": "", "timeoutSeconds": } -DELIVERY (isolated-only, top-level): - { "mode": "none|announce", "channel": "", "to": "", "bestEffort": } +DELIVERY (top-level): + { "mode": "none|announce|webhook", "channel": "", "to": "", "bestEffort": } - Default for isolated agentTurn jobs (when delivery omitted): "announce" - - If the task needs to send to a specific chat/recipient, set delivery.channel/to here; do not call messaging tools inside the run. + - announce: send to chat channel (optional channel/to target) + - webhook: send finished-run event as HTTP POST to delivery.to (URL required) + - If the task needs to send to a specific chat/recipient, set announce delivery.channel/to; do not call messaging tools inside the run. CRITICAL CONSTRAINTS: - sessionTarget="main" REQUIRES payload.kind="systemEvent" - sessionTarget="isolated" REQUIRES payload.kind="agentTurn" -- For reminders users should be notified about, set notify=true. +- For webhook callbacks, use delivery.mode="webhook" with delivery.to set to a URL. Default: prefer isolated agentTurn jobs unless the user explicitly wants a main-session system event. WAKE MODES (for wake action): @@ -294,7 +296,6 @@ Use jobId as the canonical identifier; id is accepted for compatibility. Use con "payload", "delivery", "enabled", - "notify", "description", "deleteAfterRun", "agentId", @@ -352,11 +353,25 @@ Use jobId as the canonical identifier; id is accepted for compatibility. Use con const delivery = isRecord(deliveryValue) ? deliveryValue : undefined; const modeRaw = typeof delivery?.mode === "string" ? delivery.mode : ""; const mode = modeRaw.trim().toLowerCase(); + if (mode === "webhook") { + const webhookUrl = normalizeHttpWebhookUrl(delivery?.to); + if (!webhookUrl) { + throw new Error( + 'delivery.mode="webhook" requires delivery.to to be a valid http(s) URL', + ); + } + if (delivery) { + delivery.to = webhookUrl; + } + } + const hasTarget = (typeof delivery?.channel === "string" && delivery.channel.trim()) || (typeof delivery?.to === "string" && delivery.to.trim()); const shouldInfer = - (deliveryValue == null || delivery) && mode !== "none" && !hasTarget; + (deliveryValue == null || delivery) && + (mode === "" || mode === "announce") && + !hasTarget; if (shouldInfer) { const inferred = inferDeliveryFromSessionKey(opts.agentSessionKey); if (inferred) { diff --git a/src/config/config-misc.test.ts b/src/config/config-misc.test.ts index d4d9e5558..4f6eaf41b 100644 --- a/src/config/config-misc.test.ts +++ b/src/config/config-misc.test.ts @@ -154,11 +154,11 @@ describe("gateway.tools config", () => { }); describe("cron webhook schema", () => { - it("accepts cron.webhook and cron.webhookToken", () => { + it("accepts cron.webhookToken and legacy cron.webhook", () => { const res = OpenClawSchema.safeParse({ cron: { enabled: true, - webhook: "https://example.invalid/cron", + webhook: "https://example.invalid/legacy-cron-webhook", webhookToken: "secret-token", }, }); @@ -166,10 +166,10 @@ describe("cron webhook schema", () => { expect(res.success).toBe(true); }); - it("rejects non-http(s) cron.webhook URLs", () => { + it("rejects non-http cron.webhook URLs", () => { const res = OpenClawSchema.safeParse({ cron: { - webhook: "ftp://example.invalid/cron", + webhook: "ftp://example.invalid/legacy-cron-webhook", }, }); diff --git a/src/config/types.cron.ts b/src/config/types.cron.ts index d1704b30b..45a8b7151 100644 --- a/src/config/types.cron.ts +++ b/src/config/types.cron.ts @@ -2,7 +2,12 @@ export type CronConfig = { enabled?: boolean; store?: string; maxConcurrentRuns?: number; + /** + * Deprecated legacy fallback webhook URL used only for stored jobs with notify=true. + * Prefer per-job delivery.mode="webhook" with delivery.to. + */ webhook?: string; + /** Bearer token for cron webhook POST delivery. */ webhookToken?: string; /** * How long to retain completed cron run sessions before automatic pruning. diff --git a/src/cron/cron-protocol-conformance.test.ts b/src/cron/cron-protocol-conformance.test.ts index 99a4b05de..51fe8f476 100644 --- a/src/cron/cron-protocol-conformance.test.ts +++ b/src/cron/cron-protocol-conformance.test.ts @@ -5,16 +5,28 @@ import { MACOS_APP_SOURCES_DIR } from "../compat/legacy-names.js"; import { CronDeliverySchema } from "../gateway/protocol/schema.js"; type SchemaLike = { - anyOf?: Array<{ properties?: Record; const?: unknown }>; + anyOf?: Array; properties?: Record; const?: unknown; }; function extractDeliveryModes(schema: SchemaLike): string[] { const modeSchema = schema.properties?.mode as SchemaLike | undefined; - return (modeSchema?.anyOf ?? []) + const directModes = (modeSchema?.anyOf ?? []) .map((entry) => entry?.const) .filter((value): value is string => typeof value === "string"); + if (directModes.length > 0) { + return directModes; + } + + const unionModes = (schema.anyOf ?? []) + .map((entry) => { + const mode = entry.properties?.mode as SchemaLike | undefined; + return mode?.const; + }) + .filter((value): value is string => typeof value === "string"); + + return Array.from(new Set(unionModes)); } const UI_FILES = ["ui/src/ui/types.ts", "ui/src/ui/ui-types.ts", "ui/src/ui/views/cron.ts"]; diff --git a/src/cron/delivery.test.ts b/src/cron/delivery.test.ts index fcbe9e99a..783d0532b 100644 --- a/src/cron/delivery.test.ts +++ b/src/cron/delivery.test.ts @@ -42,4 +42,16 @@ describe("resolveCronDeliveryPlan", () => { expect(plan.mode).toBe("none"); expect(plan.requested).toBe(false); }); + + it("resolves webhook mode without channel routing", () => { + const plan = resolveCronDeliveryPlan( + makeJob({ + delivery: { mode: "webhook", to: "https://example.invalid/cron" }, + }), + ); + expect(plan.mode).toBe("webhook"); + expect(plan.requested).toBe(false); + expect(plan.channel).toBeUndefined(); + expect(plan.to).toBe("https://example.invalid/cron"); + }); }); diff --git a/src/cron/delivery.ts b/src/cron/delivery.ts index f0ba2c2b0..377cdb49b 100644 --- a/src/cron/delivery.ts +++ b/src/cron/delivery.ts @@ -2,7 +2,7 @@ import type { CronDeliveryMode, CronJob, CronMessageChannel } from "./types.js"; export type CronDeliveryPlan = { mode: CronDeliveryMode; - channel: CronMessageChannel; + channel?: CronMessageChannel; to?: string; source: "delivery" | "payload"; requested: boolean; @@ -36,11 +36,13 @@ export function resolveCronDeliveryPlan(job: CronJob): CronDeliveryPlan { const mode = normalizedMode === "announce" ? "announce" - : normalizedMode === "none" - ? "none" - : normalizedMode === "deliver" - ? "announce" - : undefined; + : normalizedMode === "webhook" + ? "webhook" + : normalizedMode === "none" + ? "none" + : normalizedMode === "deliver" + ? "announce" + : undefined; const payloadChannel = normalizeChannel(payload?.channel); const payloadTo = normalizeTo(payload?.to); @@ -55,7 +57,7 @@ export function resolveCronDeliveryPlan(job: CronJob): CronDeliveryPlan { const resolvedMode = mode ?? "announce"; return { mode: resolvedMode, - channel, + channel: resolvedMode === "announce" ? channel : undefined, to, source: "delivery", requested: resolvedMode === "announce", diff --git a/src/cron/normalize.test.ts b/src/cron/normalize.test.ts index f5d791004..bcc2849ae 100644 --- a/src/cron/normalize.test.ts +++ b/src/cron/normalize.test.ts @@ -163,6 +163,25 @@ describe("normalizeCronJobCreate", () => { expect(delivery.to).toBe("7200373102"); }); + it("normalizes webhook delivery mode and target URL", () => { + const normalized = normalizeCronJobCreate({ + name: "webhook delivery", + enabled: true, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "main", + wakeMode: "now", + payload: { kind: "systemEvent", text: "hello" }, + delivery: { + mode: " WeBhOoK ", + to: " https://example.invalid/cron ", + }, + }) as unknown as Record; + + const delivery = normalized.delivery as Record; + expect(delivery.mode).toBe("webhook"); + expect(delivery.to).toBe("https://example.invalid/cron"); + }); + it("defaults isolated agentTurn delivery to announce", () => { const normalized = normalizeCronJobCreate({ name: "default-announce", diff --git a/src/cron/normalize.ts b/src/cron/normalize.ts index c54b81f5b..2e09aefd5 100644 --- a/src/cron/normalize.ts +++ b/src/cron/normalize.ts @@ -151,7 +151,7 @@ function coerceDelivery(delivery: UnknownRecord) { const mode = delivery.mode.trim().toLowerCase(); if (mode === "deliver") { next.mode = "announce"; - } else if (mode === "announce" || mode === "none") { + } else if (mode === "announce" || mode === "none" || mode === "webhook") { next.mode = mode; } else { delete next.mode; diff --git a/src/cron/service.get-job.test.ts b/src/cron/service.get-job.test.ts index 6d0718976..a3e56004d 100644 --- a/src/cron/service.get-job.test.ts +++ b/src/cron/service.get-job.test.ts @@ -44,42 +44,25 @@ describe("CronService.getJob", () => { } }); - it("preserves notify on create for true, false, and omitted", async () => { + it("preserves webhook delivery on create", async () => { const { storePath } = await makeStorePath(); const cron = createCronService(storePath); await cron.start(); try { - const notifyTrue = await cron.add({ - name: "notify-true", - enabled: true, - notify: true, - schedule: { kind: "every", everyMs: 60_000 }, - sessionTarget: "main", - wakeMode: "next-heartbeat", - payload: { kind: "systemEvent", text: "ping" }, - }); - const notifyFalse = await cron.add({ - name: "notify-false", - enabled: true, - notify: false, - schedule: { kind: "every", everyMs: 60_000 }, - sessionTarget: "main", - wakeMode: "next-heartbeat", - payload: { kind: "systemEvent", text: "ping" }, - }); - const notifyOmitted = await cron.add({ - name: "notify-omitted", + const webhookJob = await cron.add({ + name: "webhook-job", enabled: true, schedule: { kind: "every", everyMs: 60_000 }, sessionTarget: "main", wakeMode: "next-heartbeat", payload: { kind: "systemEvent", text: "ping" }, + delivery: { mode: "webhook", to: "https://example.invalid/cron" }, + }); + expect(cron.getJob(webhookJob.id)?.delivery).toEqual({ + mode: "webhook", + to: "https://example.invalid/cron", }); - - expect(cron.getJob(notifyTrue.id)?.notify).toBe(true); - expect(cron.getJob(notifyFalse.id)?.notify).toBe(false); - expect(cron.getJob(notifyOmitted.id)?.notify).toBeUndefined(); } finally { cron.stop(); } diff --git a/src/cron/service.jobs.test.ts b/src/cron/service.jobs.test.ts index edb95f079..ed28d5927 100644 --- a/src/cron/service.jobs.test.ts +++ b/src/cron/service.jobs.test.ts @@ -30,6 +30,32 @@ describe("applyJobPatch", () => { expect(job.delivery).toBeUndefined(); }); + it("keeps webhook delivery when switching to main session", () => { + const now = Date.now(); + const job: CronJob = { + id: "job-webhook", + name: "job-webhook", + enabled: true, + createdAtMs: now, + updatedAtMs: now, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "isolated", + wakeMode: "now", + payload: { kind: "agentTurn", message: "do it" }, + delivery: { mode: "webhook", to: "https://example.invalid/cron" }, + state: {}, + }; + + const patch: CronJobPatch = { + sessionTarget: "main", + payload: { kind: "systemEvent", text: "ping" }, + }; + + expect(() => applyJobPatch(job, patch)).not.toThrow(); + expect(job.sessionTarget).toBe("main"); + expect(job.delivery).toEqual({ mode: "webhook", to: "https://example.invalid/cron" }); + }); + it("maps legacy payload delivery updates onto delivery", () => { const now = Date.now(); const job: CronJob = { @@ -101,23 +127,55 @@ describe("applyJobPatch", () => { }); }); - it("updates notify via patch", () => { + it("rejects webhook delivery without a valid http(s) target URL", () => { const now = Date.now(); const job: CronJob = { - id: "job-4", - name: "job-4", + id: "job-webhook-invalid", + name: "job-webhook-invalid", enabled: true, - notify: false, createdAtMs: now, updatedAtMs: now, schedule: { kind: "every", everyMs: 60_000 }, - sessionTarget: "isolated", + sessionTarget: "main", wakeMode: "now", - payload: { kind: "agentTurn", message: "do it" }, + payload: { kind: "systemEvent", text: "ping" }, + delivery: { mode: "webhook" }, state: {}, }; - expect(() => applyJobPatch(job, { notify: true })).not.toThrow(); - expect(job.notify).toBe(true); + expect(() => applyJobPatch(job, { enabled: true })).toThrow( + "cron webhook delivery requires delivery.to to be a valid http(s) URL", + ); + expect(() => applyJobPatch(job, { delivery: { mode: "webhook", to: "" } })).toThrow( + "cron webhook delivery requires delivery.to to be a valid http(s) URL", + ); + expect(() => + applyJobPatch(job, { delivery: { mode: "webhook", to: "ftp://example.invalid" } }), + ).toThrow("cron webhook delivery requires delivery.to to be a valid http(s) URL"); + expect(() => applyJobPatch(job, { delivery: { mode: "webhook", to: "not-a-url" } })).toThrow( + "cron webhook delivery requires delivery.to to be a valid http(s) URL", + ); + }); + + it("trims webhook delivery target URLs", () => { + const now = Date.now(); + const job: CronJob = { + id: "job-webhook-trim", + name: "job-webhook-trim", + enabled: true, + createdAtMs: now, + updatedAtMs: now, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "main", + wakeMode: "now", + payload: { kind: "systemEvent", text: "ping" }, + delivery: { mode: "webhook", to: "https://example.invalid/original" }, + state: {}, + }; + + expect(() => + applyJobPatch(job, { delivery: { mode: "webhook", to: " https://example.invalid/trim " } }), + ).not.toThrow(); + expect(job.delivery).toEqual({ mode: "webhook", to: "https://example.invalid/trim" }); }); }); diff --git a/src/cron/service/jobs.ts b/src/cron/service/jobs.ts index fa1b6a157..4185987f5 100644 --- a/src/cron/service/jobs.ts +++ b/src/cron/service/jobs.ts @@ -11,6 +11,7 @@ import type { import type { CronServiceState } from "./state.js"; import { parseAbsoluteTimeMs } from "../parse.js"; import { computeNextRunAtMs } from "../schedule.js"; +import { normalizeHttpWebhookUrl } from "../webhook-url.js"; import { normalizeOptionalAgentId, normalizeOptionalText, @@ -41,8 +42,19 @@ export function assertSupportedJobSpec(job: Pick) { - if (job.delivery && job.sessionTarget !== "isolated") { - throw new Error('cron delivery config is only supported for sessionTarget="isolated"'); + if (!job.delivery) { + return; + } + if (job.delivery.mode === "webhook") { + const target = normalizeHttpWebhookUrl(job.delivery.to); + if (!target) { + throw new Error("cron webhook delivery requires delivery.to to be a valid http(s) URL"); + } + job.delivery.to = target; + return; + } + if (job.sessionTarget !== "isolated") { + throw new Error('cron channel delivery config is only supported for sessionTarget="isolated"'); } } @@ -258,7 +270,6 @@ export function createJob(state: CronServiceState, input: CronJobCreate): CronJo name: normalizeRequiredName(input.name), description: normalizeOptionalText(input.description), enabled, - notify: typeof input.notify === "boolean" ? input.notify : undefined, deleteAfterRun, createdAtMs: now, updatedAtMs: now, @@ -287,9 +298,6 @@ export function applyJobPatch(job: CronJob, patch: CronJobPatch) { if (typeof patch.enabled === "boolean") { job.enabled = patch.enabled; } - if (typeof patch.notify === "boolean") { - job.notify = patch.notify; - } if (typeof patch.deleteAfterRun === "boolean") { job.deleteAfterRun = patch.deleteAfterRun; } @@ -319,7 +327,7 @@ export function applyJobPatch(job: CronJob, patch: CronJobPatch) { if (patch.delivery) { job.delivery = mergeCronDelivery(job.delivery, patch.delivery); } - if (job.sessionTarget === "main" && job.delivery) { + if (job.sessionTarget === "main" && job.delivery?.mode !== "webhook") { job.delivery = undefined; } if (patch.state) { diff --git a/src/cron/types.ts b/src/cron/types.ts index 223638513..6c7e7bec0 100644 --- a/src/cron/types.ts +++ b/src/cron/types.ts @@ -10,7 +10,7 @@ export type CronWakeMode = "next-heartbeat" | "now"; export type CronMessageChannel = ChannelId | "last"; -export type CronDeliveryMode = "none" | "announce"; +export type CronDeliveryMode = "none" | "announce" | "webhook"; export type CronDelivery = { mode: CronDeliveryMode; @@ -71,7 +71,6 @@ export type CronJob = { name: string; description?: string; enabled: boolean; - notify?: boolean; deleteAfterRun?: boolean; createdAtMs: number; updatedAtMs: number; diff --git a/src/cron/webhook-url.ts b/src/cron/webhook-url.ts new file mode 100644 index 000000000..7cd6c1541 --- /dev/null +++ b/src/cron/webhook-url.ts @@ -0,0 +1,22 @@ +function isAllowedWebhookProtocol(protocol: string) { + return protocol === "http:" || protocol === "https:"; +} + +export function normalizeHttpWebhookUrl(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + try { + const parsed = new URL(trimmed); + if (!isAllowedWebhookProtocol(parsed.protocol)) { + return null; + } + return trimmed; + } catch { + return null; + } +} diff --git a/src/gateway/protocol/schema/cron.ts b/src/gateway/protocol/schema/cron.ts index 63f5b6900..b4ce34697 100644 --- a/src/gateway/protocol/schema/cron.ts +++ b/src/gateway/protocol/schema/cron.ts @@ -67,24 +67,51 @@ export const CronPayloadPatchSchema = Type.Union([ cronAgentTurnPayloadSchema({ message: Type.Optional(NonEmptyString) }), ]); -const CronDeliveryBaseProperties = { +const CronDeliverySharedProperties = { channel: Type.Optional(Type.Union([Type.Literal("last"), NonEmptyString])), - to: Type.Optional(Type.String()), bestEffort: Type.Optional(Type.Boolean()), }; -export const CronDeliverySchema = Type.Object( +const CronDeliveryNoopSchema = Type.Object( { - mode: Type.Union([Type.Literal("none"), Type.Literal("announce")]), - ...CronDeliveryBaseProperties, + mode: Type.Literal("none"), + ...CronDeliverySharedProperties, + to: Type.Optional(Type.String()), }, { additionalProperties: false }, ); +const CronDeliveryAnnounceSchema = Type.Object( + { + mode: Type.Literal("announce"), + ...CronDeliverySharedProperties, + to: Type.Optional(Type.String()), + }, + { additionalProperties: false }, +); + +const CronDeliveryWebhookSchema = Type.Object( + { + mode: Type.Literal("webhook"), + ...CronDeliverySharedProperties, + to: NonEmptyString, + }, + { additionalProperties: false }, +); + +export const CronDeliverySchema = Type.Union([ + CronDeliveryNoopSchema, + CronDeliveryAnnounceSchema, + CronDeliveryWebhookSchema, +]); + export const CronDeliveryPatchSchema = Type.Object( { - mode: Type.Optional(Type.Union([Type.Literal("none"), Type.Literal("announce")])), - ...CronDeliveryBaseProperties, + mode: Type.Optional( + Type.Union([Type.Literal("none"), Type.Literal("announce"), Type.Literal("webhook")]), + ), + ...CronDeliverySharedProperties, + to: Type.Optional(Type.String()), }, { additionalProperties: false }, ); @@ -111,7 +138,6 @@ export const CronJobSchema = Type.Object( name: NonEmptyString, description: Type.Optional(Type.String()), enabled: Type.Boolean(), - notify: Type.Optional(Type.Boolean()), deleteAfterRun: Type.Optional(Type.Boolean()), createdAtMs: Type.Integer({ minimum: 0 }), updatedAtMs: Type.Integer({ minimum: 0 }), @@ -140,7 +166,6 @@ export const CronAddParamsSchema = Type.Object( agentId: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), description: Type.Optional(Type.String()), enabled: Type.Optional(Type.Boolean()), - notify: Type.Optional(Type.Boolean()), deleteAfterRun: Type.Optional(Type.Boolean()), schedule: CronScheduleSchema, sessionTarget: Type.Union([Type.Literal("main"), Type.Literal("isolated")]), @@ -157,7 +182,6 @@ export const CronJobPatchSchema = Type.Object( agentId: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), description: Type.Optional(Type.String()), enabled: Type.Optional(Type.Boolean()), - notify: Type.Optional(Type.Boolean()), deleteAfterRun: Type.Optional(Type.Boolean()), schedule: Type.Optional(CronScheduleSchema), sessionTarget: Type.Optional(Type.Union([Type.Literal("main"), Type.Literal("isolated")])), diff --git a/src/gateway/server-cron.ts b/src/gateway/server-cron.ts index 9a5bb4041..a5d1c711b 100644 --- a/src/gateway/server-cron.ts +++ b/src/gateway/server-cron.ts @@ -7,6 +7,7 @@ import { runCronIsolatedAgentTurn } from "../cron/isolated-agent.js"; import { appendCronRunLog, resolveCronRunLogPath } from "../cron/run-log.js"; import { CronService } from "../cron/service.js"; import { resolveCronStorePath } from "../cron/store.js"; +import { normalizeHttpWebhookUrl } from "../cron/webhook-url.js"; import { runHeartbeatOnce } from "../infra/heartbeat-runner.js"; import { requestHeartbeatNow } from "../infra/heartbeat-wake.js"; import { enqueueSystemEvent } from "../infra/system-events.js"; @@ -31,6 +32,32 @@ function redactWebhookUrl(url: string): string { } } +type CronWebhookTarget = { + url: string; + source: "delivery" | "legacy"; +}; + +function resolveCronWebhookTarget(params: { + delivery?: { mode?: string; to?: string }; + legacyNotify?: boolean; + legacyWebhook?: string; +}): CronWebhookTarget | null { + const mode = params.delivery?.mode?.trim().toLowerCase(); + if (mode === "webhook") { + const url = normalizeHttpWebhookUrl(params.delivery?.to); + return url ? { url, source: "delivery" } : null; + } + + if (params.legacyNotify) { + const legacyUrl = normalizeHttpWebhookUrl(params.legacyWebhook); + if (legacyUrl) { + return { url: legacyUrl, source: "legacy" }; + } + } + + return null; +} + export function buildGatewayCronService(params: { cfg: ReturnType; deps: CliDeps; @@ -61,6 +88,7 @@ export function buildGatewayCronService(params: { agentId: agentId ?? defaultAgentId, }); const sessionStorePath = resolveSessionStorePath(defaultAgentId); + const warnedLegacyWebhookJobs = new Set(); const cron = new CronService({ storePath, @@ -104,10 +132,41 @@ export function buildGatewayCronService(params: { onEvent: (evt) => { params.broadcast("cron", evt, { dropIfSlow: true }); if (evt.action === "finished") { - const webhookUrl = params.cfg.cron?.webhook?.trim(); const webhookToken = params.cfg.cron?.webhookToken?.trim(); + const legacyWebhook = params.cfg.cron?.webhook?.trim(); const job = cron.getJob(evt.jobId); - if (webhookUrl && evt.summary && job?.notify === true) { + const legacyNotify = (job as { notify?: unknown } | undefined)?.notify === true; + const webhookTarget = resolveCronWebhookTarget({ + delivery: + job?.delivery && typeof job.delivery.mode === "string" + ? { mode: job.delivery.mode, to: job.delivery.to } + : undefined, + legacyNotify, + legacyWebhook, + }); + + if (!webhookTarget && job?.delivery?.mode === "webhook") { + cronLogger.warn( + { + jobId: evt.jobId, + deliveryTo: job.delivery.to, + }, + "cron: skipped webhook delivery, delivery.to must be a valid http(s) URL", + ); + } + + if (webhookTarget?.source === "legacy" && !warnedLegacyWebhookJobs.has(evt.jobId)) { + warnedLegacyWebhookJobs.add(evt.jobId); + cronLogger.warn( + { + jobId: evt.jobId, + legacyWebhook: redactWebhookUrl(webhookTarget.url), + }, + "cron: deprecated notify+cron.webhook fallback in use, migrate to delivery.mode=webhook with delivery.to", + ); + } + + if (webhookTarget && evt.summary) { const headers: Record = { "Content-Type": "application/json", }; @@ -118,7 +177,7 @@ export function buildGatewayCronService(params: { const timeout = setTimeout(() => { abortController.abort(); }, CRON_WEBHOOK_TIMEOUT_MS); - void fetch(webhookUrl, { + void fetch(webhookTarget.url, { method: "POST", headers, body: JSON.stringify(evt), @@ -129,7 +188,7 @@ export function buildGatewayCronService(params: { { err: String(err), jobId: evt.jobId, - webhookUrl: redactWebhookUrl(webhookUrl), + webhookUrl: redactWebhookUrl(webhookTarget.url), }, "cron: webhook delivery failed", ); diff --git a/src/gateway/server.cron.e2e.test.ts b/src/gateway/server.cron.e2e.test.ts index 682720487..cd05a3b96 100644 --- a/src/gateway/server.cron.e2e.test.ts +++ b/src/gateway/server.cron.e2e.test.ts @@ -83,11 +83,11 @@ describe("gateway server cron", () => { const addRes = await rpcReq(ws, "cron.add", { name: "daily", enabled: true, - notify: true, schedule: { kind: "every", everyMs: 60_000 }, sessionTarget: "main", wakeMode: "next-heartbeat", payload: { kind: "systemEvent", text: "hello" }, + delivery: { mode: "webhook", to: "https://example.invalid/cron-finished" }, }); expect(addRes.ok).toBe(true); expect(typeof (addRes.payload as { id?: unknown } | null)?.id).toBe("string"); @@ -101,8 +101,8 @@ describe("gateway server cron", () => { expect((jobs as unknown[]).length).toBe(1); expect(((jobs as Array<{ name?: unknown }>)[0]?.name as string) ?? "").toBe("daily"); expect( - ((jobs as Array<{ notify?: unknown }>)[0]?.notify as boolean | undefined) ?? false, - ).toBe(true); + ((jobs as Array<{ delivery?: { mode?: unknown } }>)[0]?.delivery?.mode as string) ?? "", + ).toBe("webhook"); const routeAtMs = Date.now() - 1; const routeRes = await rpcReq(ws, "cron.add", { @@ -423,14 +423,31 @@ describe("gateway server cron", () => { } }, 45_000); - test("posts webhooks only when notify is true and summary exists", async () => { + test("posts webhooks for delivery mode and legacy notify fallback only when summary exists", async () => { const prevSkipCron = process.env.OPENCLAW_SKIP_CRON; process.env.OPENCLAW_SKIP_CRON = "0"; const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gw-cron-webhook-")); testState.cronStorePath = path.join(dir, "cron", "jobs.json"); testState.cronEnabled = false; await fs.mkdir(path.dirname(testState.cronStorePath), { recursive: true }); - await fs.writeFile(testState.cronStorePath, JSON.stringify({ version: 1, jobs: [] })); + + const legacyNotifyJob = { + id: "legacy-notify-job", + name: "legacy notify job", + enabled: true, + notify: true, + createdAtMs: Date.now(), + updatedAtMs: Date.now(), + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "main", + wakeMode: "next-heartbeat", + payload: { kind: "systemEvent", text: "legacy webhook" }, + state: {}, + }; + await fs.writeFile( + testState.cronStorePath, + JSON.stringify({ version: 1, jobs: [legacyNotifyJob] }), + ); const configPath = process.env.OPENCLAW_CONFIG_PATH; expect(typeof configPath).toBe("string"); @@ -440,7 +457,7 @@ describe("gateway server cron", () => { JSON.stringify( { cron: { - webhook: "https://example.invalid/cron-finished", + webhook: "https://legacy.example.invalid/cron-finished", webhookToken: "cron-webhook-token", }, }, @@ -457,14 +474,25 @@ describe("gateway server cron", () => { await connectOk(ws); try { - const notifyRes = await rpcReq(ws, "cron.add", { - name: "notify true", + const invalidWebhookRes = await rpcReq(ws, "cron.add", { + name: "invalid webhook", + enabled: true, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "main", + wakeMode: "next-heartbeat", + payload: { kind: "systemEvent", text: "invalid" }, + delivery: { mode: "webhook", to: "ftp://example.invalid/cron-finished" }, + }); + expect(invalidWebhookRes.ok).toBe(false); + + const notifyRes = await rpcReq(ws, "cron.add", { + name: "webhook enabled", enabled: true, - notify: true, schedule: { kind: "every", everyMs: 60_000 }, sessionTarget: "main", wakeMode: "next-heartbeat", payload: { kind: "systemEvent", text: "send webhook" }, + delivery: { mode: "webhook", to: "https://example.invalid/cron-finished" }, }); expect(notifyRes.ok).toBe(true); const notifyJobIdValue = (notifyRes.payload as { id?: unknown } | null)?.id; @@ -491,10 +519,32 @@ describe("gateway server cron", () => { expect(notifyBody.action).toBe("finished"); expect(notifyBody.jobId).toBe(notifyJobId); + const legacyRunRes = await rpcReq( + ws, + "cron.run", + { id: "legacy-notify-job", mode: "force" }, + 20_000, + ); + expect(legacyRunRes.ok).toBe(true); + await waitForCondition(() => fetchMock.mock.calls.length === 2, 5000); + const [legacyUrl, legacyInit] = fetchMock.mock.calls[1] as [ + string, + { + method?: string; + headers?: Record; + body?: string; + }, + ]; + expect(legacyUrl).toBe("https://legacy.example.invalid/cron-finished"); + expect(legacyInit.method).toBe("POST"); + expect(legacyInit.headers?.Authorization).toBe("Bearer cron-webhook-token"); + const legacyBody = JSON.parse(legacyInit.body ?? "{}"); + expect(legacyBody.action).toBe("finished"); + expect(legacyBody.jobId).toBe("legacy-notify-job"); + const silentRes = await rpcReq(ws, "cron.add", { - name: "notify false", + name: "webhook disabled", enabled: true, - notify: false, schedule: { kind: "every", everyMs: 60_000 }, sessionTarget: "main", wakeMode: "next-heartbeat", @@ -509,17 +559,17 @@ describe("gateway server cron", () => { expect(silentRunRes.ok).toBe(true); await yieldToEventLoop(); await yieldToEventLoop(); - expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledTimes(2); cronIsolatedRun.mockResolvedValueOnce({ status: "ok" }); const noSummaryRes = await rpcReq(ws, "cron.add", { - name: "notify no summary", + name: "webhook no summary", enabled: true, - notify: true, schedule: { kind: "every", everyMs: 60_000 }, sessionTarget: "isolated", wakeMode: "next-heartbeat", payload: { kind: "agentTurn", message: "test" }, + delivery: { mode: "webhook", to: "https://example.invalid/cron-finished" }, }); expect(noSummaryRes.ok).toBe(true); const noSummaryJobIdValue = (noSummaryRes.payload as { id?: unknown } | null)?.id; @@ -535,7 +585,7 @@ describe("gateway server cron", () => { expect(noSummaryRunRes.ok).toBe(true); await yieldToEventLoop(); await yieldToEventLoop(); - expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledTimes(2); } finally { ws.close(); await server.close(); diff --git a/ui/src/ui/app-defaults.ts b/ui/src/ui/app-defaults.ts index ee394802b..89bdaf11d 100644 --- a/ui/src/ui/app-defaults.ts +++ b/ui/src/ui/app-defaults.ts @@ -15,7 +15,6 @@ export const DEFAULT_CRON_FORM: CronFormState = { description: "", agentId: "", enabled: true, - notify: false, scheduleKind: "every", scheduleAt: "", everyAmount: "30", diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 3e9662b62..c48282461 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -24,6 +24,7 @@ import { runCronJob, removeCronJob, addCronJob, + normalizeCronFormState, } from "./controllers/cron.ts"; import { loadDebug, callDebugMethod } from "./controllers/debug.ts"; import { @@ -323,7 +324,8 @@ export function renderApp(state: AppViewState) { channelMeta: state.channelsSnapshot?.channelMeta ?? [], runsJobId: state.cronRunsJobId, runs: state.cronRuns, - onFormChange: (patch) => (state.cronForm = { ...state.cronForm, ...patch }), + onFormChange: (patch) => + (state.cronForm = normalizeCronFormState({ ...state.cronForm, ...patch })), onRefresh: () => state.loadCron(), onAdd: () => addCronJob(state), onToggle: (job, enabled) => toggleCronJob(state, job, enabled), diff --git a/ui/src/ui/controllers/cron.test.ts b/ui/src/ui/controllers/cron.test.ts index e03cda1e8..66d05286a 100644 --- a/ui/src/ui/controllers/cron.test.ts +++ b/ui/src/ui/controllers/cron.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { DEFAULT_CRON_FORM } from "../app-defaults.ts"; -import { addCronJob, type CronState } from "./cron.ts"; +import { addCronJob, normalizeCronFormState, type CronState } from "./cron.ts"; function createState(overrides: Partial = {}): CronState { return { @@ -19,7 +19,29 @@ function createState(overrides: Partial = {}): CronState { } describe("cron controller", () => { - it("forwards notify in cron.add payload", async () => { + it("normalizes stale announce mode when session/payload no longer support announce", () => { + const normalized = normalizeCronFormState({ + ...DEFAULT_CRON_FORM, + sessionTarget: "main", + payloadKind: "systemEvent", + deliveryMode: "announce", + }); + + expect(normalized.deliveryMode).toBe("none"); + }); + + it("keeps announce mode when isolated agentTurn supports announce", () => { + const normalized = normalizeCronFormState({ + ...DEFAULT_CRON_FORM, + sessionTarget: "isolated", + payloadKind: "agentTurn", + deliveryMode: "announce", + }); + + expect(normalized.deliveryMode).toBe("announce"); + }); + + it("forwards webhook delivery in cron.add payload", async () => { const request = vi.fn(async (method: string, _payload?: unknown) => { if (method === "cron.add") { return { id: "job-1" }; @@ -39,15 +61,16 @@ describe("cron controller", () => { } as unknown as CronState["client"], cronForm: { ...DEFAULT_CRON_FORM, - name: "notify job", - notify: true, + name: "webhook job", scheduleKind: "every", everyAmount: "1", everyUnit: "minutes", - sessionTarget: "main", + sessionTarget: "isolated", wakeMode: "next-heartbeat", - payloadKind: "systemEvent", - payloadText: "ping", + payloadKind: "agentTurn", + payloadText: "run this", + deliveryMode: "webhook", + deliveryTo: "https://example.invalid/cron", }, }); @@ -56,8 +79,52 @@ describe("cron controller", () => { const addCall = request.mock.calls.find(([method]) => method === "cron.add"); expect(addCall).toBeDefined(); expect(addCall?.[1]).toMatchObject({ - notify: true, - name: "notify job", + name: "webhook job", + delivery: { mode: "webhook", to: "https://example.invalid/cron" }, }); }); + + it("does not submit stale announce delivery when unsupported", async () => { + const request = vi.fn(async (method: string, _payload?: unknown) => { + if (method === "cron.add") { + return { id: "job-2" }; + } + if (method === "cron.list") { + return { jobs: [] }; + } + if (method === "cron.status") { + return { enabled: true, jobs: 0, nextWakeAtMs: null }; + } + return {}; + }); + + const state = createState({ + client: { + request, + } as unknown as CronState["client"], + cronForm: { + ...DEFAULT_CRON_FORM, + name: "main job", + scheduleKind: "every", + everyAmount: "1", + everyUnit: "minutes", + sessionTarget: "main", + wakeMode: "next-heartbeat", + payloadKind: "systemEvent", + payloadText: "run this", + deliveryMode: "announce", + deliveryTo: "buddy", + }, + }); + + await addCronJob(state); + + const addCall = request.mock.calls.find(([method]) => method === "cron.add"); + expect(addCall).toBeDefined(); + expect(addCall?.[1]).toMatchObject({ + name: "main job", + }); + expect((addCall?.[1] as { delivery?: unknown } | undefined)?.delivery).toBeUndefined(); + expect(state.cronForm.deliveryMode).toBe("none"); + }); }); diff --git a/ui/src/ui/controllers/cron.ts b/ui/src/ui/controllers/cron.ts index 29330c6d8..fbb7d65db 100644 --- a/ui/src/ui/controllers/cron.ts +++ b/ui/src/ui/controllers/cron.ts @@ -16,6 +16,25 @@ export type CronState = { cronBusy: boolean; }; +export function supportsAnnounceDelivery( + form: Pick, +) { + return form.sessionTarget === "isolated" && form.payloadKind === "agentTurn"; +} + +export function normalizeCronFormState(form: CronFormState): CronFormState { + if (form.deliveryMode !== "announce") { + return form; + } + if (supportsAnnounceDelivery(form)) { + return form; + } + return { + ...form, + deliveryMode: "none", + }; +} + export async function loadCronStatus(state: CronState) { if (!state.client || !state.connected) { return; @@ -104,28 +123,34 @@ export async function addCronJob(state: CronState) { state.cronBusy = true; state.cronError = null; try { - const schedule = buildCronSchedule(state.cronForm); - const payload = buildCronPayload(state.cronForm); + const form = normalizeCronFormState(state.cronForm); + if (form !== state.cronForm) { + state.cronForm = form; + } + + const schedule = buildCronSchedule(form); + const payload = buildCronPayload(form); + const selectedDeliveryMode = form.deliveryMode; const delivery = - state.cronForm.sessionTarget === "isolated" && - state.cronForm.payloadKind === "agentTurn" && - state.cronForm.deliveryMode + selectedDeliveryMode && selectedDeliveryMode !== "none" ? { - mode: state.cronForm.deliveryMode === "announce" ? "announce" : "none", - channel: state.cronForm.deliveryChannel.trim() || "last", - to: state.cronForm.deliveryTo.trim() || undefined, + mode: selectedDeliveryMode, + channel: + selectedDeliveryMode === "announce" + ? form.deliveryChannel.trim() || "last" + : undefined, + to: form.deliveryTo.trim() || undefined, } : undefined; - const agentId = state.cronForm.agentId.trim(); + const agentId = form.agentId.trim(); const job = { - name: state.cronForm.name.trim(), - description: state.cronForm.description.trim() || undefined, + name: form.name.trim(), + description: form.description.trim() || undefined, agentId: agentId || undefined, - enabled: state.cronForm.enabled, - notify: state.cronForm.notify, + enabled: form.enabled, schedule, - sessionTarget: state.cronForm.sessionTarget, - wakeMode: state.cronForm.wakeMode, + sessionTarget: form.sessionTarget, + wakeMode: form.wakeMode, payload, delivery, }; diff --git a/ui/src/ui/presenter.ts b/ui/src/ui/presenter.ts index 13fa32722..009b886ba 100644 --- a/ui/src/ui/presenter.ts +++ b/ui/src/ui/presenter.ts @@ -71,9 +71,13 @@ export function formatCronPayload(job: CronJob) { const delivery = job.delivery; if (delivery && delivery.mode !== "none") { const target = - delivery.channel || delivery.to - ? ` (${delivery.channel ?? "last"}${delivery.to ? ` -> ${delivery.to}` : ""})` - : ""; + delivery.mode === "webhook" + ? delivery.to + ? ` (${delivery.to})` + : "" + : delivery.channel || delivery.to + ? ` (${delivery.channel ?? "last"}${delivery.to ? ` -> ${delivery.to}` : ""})` + : ""; return `${base} · ${delivery.mode}${target}`; } return base; diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts index 6763fe3a6..f985774bc 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -452,7 +452,7 @@ export type CronPayload = }; export type CronDelivery = { - mode: "none" | "announce"; + mode: "none" | "announce" | "webhook"; channel?: string; to?: string; bestEffort?: boolean; @@ -473,7 +473,6 @@ export type CronJob = { name: string; description?: string; enabled: boolean; - notify?: boolean; deleteAfterRun?: boolean; createdAtMs: number; updatedAtMs: number; diff --git a/ui/src/ui/ui-types.ts b/ui/src/ui/ui-types.ts index 5a583fced..724f2a920 100644 --- a/ui/src/ui/ui-types.ts +++ b/ui/src/ui/ui-types.ts @@ -19,7 +19,6 @@ export type CronFormState = { description: string; agentId: string; enabled: boolean; - notify: boolean; scheduleKind: "at" | "every" | "cron"; scheduleAt: string; everyAmount: string; @@ -30,7 +29,7 @@ export type CronFormState = { wakeMode: "next-heartbeat" | "now"; payloadKind: "systemEvent" | "agentTurn"; payloadText: string; - deliveryMode: "none" | "announce"; + deliveryMode: "none" | "announce" | "webhook"; deliveryChannel: string; deliveryTo: string; timeoutSeconds: string; diff --git a/ui/src/ui/views/cron.test.ts b/ui/src/ui/views/cron.test.ts index 91c724b1d..e53509f0d 100644 --- a/ui/src/ui/views/cron.test.ts +++ b/ui/src/ui/views/cron.test.ts @@ -159,37 +159,56 @@ describe("cron view", () => { expect(summaries[1]).toBe("older run"); }); - it("forwards notify checkbox updates from the form", () => { + it("shows webhook delivery option in the form", () => { const container = document.createElement("div"); - const onFormChange = vi.fn(); render( renderCron( createProps({ - onFormChange, + form: { ...DEFAULT_CRON_FORM, payloadKind: "agentTurn" }, }), ), container, ); - const notifyLabel = Array.from(container.querySelectorAll("label.field.checkbox")).find( - (label) => label.querySelector("span")?.textContent?.trim() === "Notify webhook", + const options = Array.from(container.querySelectorAll("option")).map((opt) => + (opt.textContent ?? "").trim(), ); - const notifyInput = - notifyLabel?.querySelector('input[type="checkbox"]') ?? null; - expect(notifyInput).not.toBeNull(); - - if (!notifyInput) { - return; - } - notifyInput.checked = true; - notifyInput.dispatchEvent(new Event("change", { bubbles: true })); - - expect(onFormChange).toHaveBeenCalledWith({ notify: true }); + expect(options).toContain("Webhook POST"); }); - it("shows notify chip for webhook-enabled jobs", () => { + it("normalizes stale announce selection in the form when unsupported", () => { const container = document.createElement("div"); - const job = { ...createJob("job-2"), notify: true }; + render( + renderCron( + createProps({ + form: { + ...DEFAULT_CRON_FORM, + sessionTarget: "main", + payloadKind: "systemEvent", + deliveryMode: "announce", + }, + }), + ), + container, + ); + + const options = Array.from(container.querySelectorAll("option")).map((opt) => + (opt.textContent ?? "").trim(), + ); + expect(options).not.toContain("Announce summary (default)"); + expect(options).toContain("Webhook POST"); + expect(options).toContain("None (internal)"); + expect(container.querySelector('input[placeholder="https://example.invalid/cron"]')).toBeNull(); + }); + + it("shows webhook delivery details for jobs", () => { + const container = document.createElement("div"); + const job = { + ...createJob("job-2"), + sessionTarget: "isolated" as const, + payload: { kind: "agentTurn" as const, message: "do it" }, + delivery: { mode: "webhook" as const, to: "https://example.invalid/cron" }, + }; render( renderCron( createProps({ @@ -199,9 +218,8 @@ describe("cron view", () => { container, ); - const chips = Array.from(container.querySelectorAll(".chip")).map((el) => - (el.textContent ?? "").trim(), - ); - expect(chips).toContain("notify"); + expect(container.textContent).toContain("Delivery"); + expect(container.textContent).toContain("webhook"); + expect(container.textContent).toContain("https://example.invalid/cron"); }); }); diff --git a/ui/src/ui/views/cron.ts b/ui/src/ui/views/cron.ts index 790c6d429..11b5477c3 100644 --- a/ui/src/ui/views/cron.ts +++ b/ui/src/ui/views/cron.ts @@ -60,6 +60,10 @@ export function renderCron(props: CronProps) { props.runsJobId == null ? undefined : props.jobs.find((job) => job.id === props.runsJobId); const selectedRunTitle = selectedJob?.name ?? props.runsJobId ?? "(select a job)"; const orderedRuns = props.runs.toSorted((a, b) => b.ts - a.ts); + const supportsAnnounce = + props.form.sessionTarget === "isolated" && props.form.payloadKind === "agentTurn"; + const selectedDeliveryMode = + props.form.deliveryMode === "announce" && !supportsAnnounce ? "none" : props.form.deliveryMode; return html`
@@ -127,15 +131,6 @@ export function renderCron(props: CronProps) { props.onFormChange({ enabled: (e.target as HTMLInputElement).checked })} /> -