cron: separate webhook POST delivery from announce (#17901)
* cron: split webhook delivery from announce mode * cron: validate webhook delivery target * cron: remove legacy webhook fallback config * fix: finalize cron webhook delivery prep (#17901) (thanks @advaitpaliwal) --------- Co-authored-by: Tyler Yust <TYTYYUST@YAHOO.COM>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:<jobId>`, 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 = "<url>"`.
|
||||
- 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:<jobId> <job name>]` 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 <cron.webhookToken>`.
|
||||
- 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 <cron.webhookToken>`.
|
||||
- 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:
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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": "<prompt>", "model": "<optional>", "thinking": "<optional>", "timeoutSeconds": <optional> }
|
||||
|
||||
DELIVERY (isolated-only, top-level):
|
||||
{ "mode": "none|announce", "channel": "<optional>", "to": "<optional>", "bestEffort": <optional-bool> }
|
||||
DELIVERY (top-level):
|
||||
{ "mode": "none|announce|webhook", "channel": "<optional>", "to": "<optional>", "bestEffort": <optional-bool> }
|
||||
- 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) {
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<string, unknown>; const?: unknown }>;
|
||||
anyOf?: Array<SchemaLike>;
|
||||
properties?: Record<string, unknown>;
|
||||
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"];
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
const delivery = normalized.delivery as Record<string, unknown>;
|
||||
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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<CronJob, "sessionTarget" | "pay
|
||||
}
|
||||
|
||||
function assertDeliverySupport(job: Pick<CronJob, "sessionTarget" | "delivery">) {
|
||||
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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
22
src/cron/webhook-url.ts
Normal file
22
src/cron/webhook-url.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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")])),
|
||||
|
||||
@@ -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<typeof loadConfig>;
|
||||
deps: CliDeps;
|
||||
@@ -61,6 +88,7 @@ export function buildGatewayCronService(params: {
|
||||
agentId: agentId ?? defaultAgentId,
|
||||
});
|
||||
const sessionStorePath = resolveSessionStorePath(defaultAgentId);
|
||||
const warnedLegacyWebhookJobs = new Set<string>();
|
||||
|
||||
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<string, string> = {
|
||||
"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",
|
||||
);
|
||||
|
||||
@@ -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<string, string>;
|
||||
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();
|
||||
|
||||
@@ -15,7 +15,6 @@ export const DEFAULT_CRON_FORM: CronFormState = {
|
||||
description: "",
|
||||
agentId: "",
|
||||
enabled: true,
|
||||
notify: false,
|
||||
scheduleKind: "every",
|
||||
scheduleAt: "",
|
||||
everyAmount: "30",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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> = {}): CronState {
|
||||
return {
|
||||
@@ -19,7 +19,29 @@ function createState(overrides: Partial<CronState> = {}): 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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,25 @@ export type CronState = {
|
||||
cronBusy: boolean;
|
||||
};
|
||||
|
||||
export function supportsAnnounceDelivery(
|
||||
form: Pick<CronFormState, "sessionTarget" | "payloadKind">,
|
||||
) {
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<HTMLInputElement>('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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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`
|
||||
<section class="grid grid-cols-2">
|
||||
<div class="card">
|
||||
@@ -127,15 +131,6 @@ export function renderCron(props: CronProps) {
|
||||
props.onFormChange({ enabled: (e.target as HTMLInputElement).checked })}
|
||||
/>
|
||||
</label>
|
||||
<label class="field checkbox">
|
||||
<span>Notify webhook</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
.checked=${props.form.notify}
|
||||
@change=${(e: Event) =>
|
||||
props.onFormChange({ notify: (e.target as HTMLInputElement).checked })}
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Schedule</span>
|
||||
<select
|
||||
@@ -207,24 +202,31 @@ export function renderCron(props: CronProps) {
|
||||
rows="4"
|
||||
></textarea>
|
||||
</label>
|
||||
${
|
||||
props.form.payloadKind === "agentTurn"
|
||||
? html`
|
||||
<div class="form-grid" style="margin-top: 12px;">
|
||||
<label class="field">
|
||||
<span>Delivery</span>
|
||||
<select
|
||||
.value=${props.form.deliveryMode}
|
||||
@change=${(e: Event) =>
|
||||
props.onFormChange({
|
||||
deliveryMode: (e.target as HTMLSelectElement)
|
||||
.value as CronFormState["deliveryMode"],
|
||||
})}
|
||||
>
|
||||
<div class="form-grid" style="margin-top: 12px;">
|
||||
<label class="field">
|
||||
<span>Delivery</span>
|
||||
<select
|
||||
.value=${selectedDeliveryMode}
|
||||
@change=${(e: Event) =>
|
||||
props.onFormChange({
|
||||
deliveryMode: (e.target as HTMLSelectElement)
|
||||
.value as CronFormState["deliveryMode"],
|
||||
})}
|
||||
>
|
||||
${
|
||||
supportsAnnounce
|
||||
? html`
|
||||
<option value="announce">Announce summary (default)</option>
|
||||
<option value="none">None (internal)</option>
|
||||
</select>
|
||||
</label>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
<option value="webhook">Webhook POST</option>
|
||||
<option value="none">None (internal)</option>
|
||||
</select>
|
||||
</label>
|
||||
${
|
||||
props.form.payloadKind === "agentTurn"
|
||||
? html`
|
||||
<label class="field">
|
||||
<span>Timeout (seconds)</span>
|
||||
<input
|
||||
@@ -235,11 +237,27 @@ export function renderCron(props: CronProps) {
|
||||
})}
|
||||
/>
|
||||
</label>
|
||||
${
|
||||
props.form.deliveryMode === "announce"
|
||||
? html`
|
||||
<label class="field">
|
||||
<span>Channel</span>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
selectedDeliveryMode !== "none"
|
||||
? html`
|
||||
<label class="field">
|
||||
<span>${selectedDeliveryMode === "webhook" ? "Webhook URL" : "Channel"}</span>
|
||||
${
|
||||
selectedDeliveryMode === "webhook"
|
||||
? html`
|
||||
<input
|
||||
.value=${props.form.deliveryTo}
|
||||
@input=${(e: Event) =>
|
||||
props.onFormChange({
|
||||
deliveryTo: (e.target as HTMLInputElement).value,
|
||||
})}
|
||||
placeholder="https://example.invalid/cron"
|
||||
/>
|
||||
`
|
||||
: html`
|
||||
<select
|
||||
.value=${props.form.deliveryChannel || "last"}
|
||||
@change=${(e: Event) =>
|
||||
@@ -254,7 +272,12 @@ export function renderCron(props: CronProps) {
|
||||
</option>`,
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
`
|
||||
}
|
||||
</label>
|
||||
${
|
||||
selectedDeliveryMode === "announce"
|
||||
? html`
|
||||
<label class="field">
|
||||
<span>To</span>
|
||||
<input
|
||||
@@ -269,10 +292,10 @@ export function renderCron(props: CronProps) {
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
<div class="row" style="margin-top: 14px;">
|
||||
<button class="btn primary" ?disabled=${props.busy} @click=${props.onAdd}>
|
||||
${props.busy ? "Saving…" : "Add job"}
|
||||
@@ -407,13 +430,6 @@ function renderJob(job: CronJob, props: CronProps) {
|
||||
<span class=${`chip ${job.enabled ? "chip-ok" : "chip-danger"}`}>
|
||||
${job.enabled ? "enabled" : "disabled"}
|
||||
</span>
|
||||
${
|
||||
job.notify
|
||||
? html`
|
||||
<span class="chip">notify</span>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
<span class="chip">${job.sessionTarget}</span>
|
||||
<span class="chip">${job.wakeMode}</span>
|
||||
</div>
|
||||
@@ -474,9 +490,13 @@ function renderJobPayload(job: CronJob) {
|
||||
|
||||
const delivery = job.delivery;
|
||||
const deliveryTarget =
|
||||
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 html`
|
||||
<div class="cron-job-detail">
|
||||
|
||||
Reference in New Issue
Block a user