From 115cfb4430d0526ea64f7a9f5f77c3d81da75e3e Mon Sep 17 00:00:00 2001 From: Advait Paliwal Date: Sun, 15 Feb 2026 16:14:17 -0800 Subject: [PATCH] gateway: add cron finished-run webhook (#14535) * gateway: add cron finished webhook delivery * config: allow cron webhook in runtime schema * cron: require notify flag for webhook posts * ui/docs: add cron notify toggle and webhook docs * fix: harden cron webhook auth and fill notify coverage (#14535) (thanks @advaitpaliwal) --------- Co-authored-by: Tyler Yust --- CHANGELOG.md | 1 + .../OpenClawProtocol/GatewayModels.swift | 8 + .../OpenClawProtocol/GatewayModels.swift | 8 + docs/automation/cron-jobs.md | 12 +- docs/gateway/configuration-reference.md | 4 + docs/web/control-ui.md | 3 + src/agents/tools/cron-tool.ts | 5 +- src/config/config.cron-webhook-schema.test.ts | 26 +++ src/config/types.cron.ts | 2 + src/config/zod-schema.ts | 10 ++ src/cron/service.get-job.test.ts | 87 ++++++++++ src/cron/service.jobs.test.ts | 20 +++ src/cron/service.ts | 6 +- src/cron/service/jobs.ts | 4 + src/cron/types.ts | 1 + src/gateway/protocol/schema/cron.ts | 3 + src/gateway/server-cron.ts | 45 ++++++ src/gateway/server.cron.e2e.test.ts | 149 +++++++++++++++++- ui/src/ui/app-defaults.ts | 1 + ui/src/ui/controllers/cron.test.ts | 63 ++++++++ ui/src/ui/controllers/cron.ts | 1 + ui/src/ui/types.ts | 1 + ui/src/ui/ui-types.ts | 1 + ui/src/ui/views/cron.test.ts | 46 ++++++ ui/src/ui/views/cron.ts | 16 ++ 25 files changed, 519 insertions(+), 4 deletions(-) create mode 100644 src/config/config.cron-webhook-schema.test.ts create mode 100644 src/cron/service.get-job.test.ts create mode 100644 ui/src/ui/controllers/cron.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index caadbd0ef9b..4179961a1c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai ### Changes +- 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. - Plugins: expose `llm_input` and `llm_output` hook payloads so extensions can observe prompt/input context and model output usage details. (#16724) Thanks @SecondThread. - 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. - Discord: components v2 UI + embeds passthrough + exec approval UX refinements (CV2 containers, button layout, Discord-forwarding skip). Thanks @thewilloftheshadow. diff --git a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift index 29a4059b334..31763115ae0 100644 --- a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift @@ -2087,6 +2087,7 @@ 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 @@ -2103,6 +2104,7 @@ public struct CronJob: Codable, Sendable { name: String, description: String?, enabled: Bool, + notify: Bool?, deleteafterrun: Bool?, createdatms: Int, updatedatms: Int, @@ -2118,6 +2120,7 @@ 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 @@ -2134,6 +2137,7 @@ public struct CronJob: Codable, Sendable { case name case description case enabled + case notify case deleteafterrun = "deleteAfterRun" case createdatms = "createdAtMs" case updatedatms = "updatedAtMs" @@ -2167,6 +2171,7 @@ 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 @@ -2179,6 +2184,7 @@ public struct CronAddParams: Codable, Sendable { agentid: AnyCodable?, description: String?, enabled: Bool?, + notify: Bool?, deleteafterrun: Bool?, schedule: AnyCodable, sessiontarget: AnyCodable, @@ -2190,6 +2196,7 @@ public struct CronAddParams: Codable, Sendable { self.agentid = agentid self.description = description self.enabled = enabled + self.notify = notify self.deleteafterrun = deleteafterrun self.schedule = schedule self.sessiontarget = sessiontarget @@ -2202,6 +2209,7 @@ 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 29a4059b334..31763115ae0 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -2087,6 +2087,7 @@ 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 @@ -2103,6 +2104,7 @@ public struct CronJob: Codable, Sendable { name: String, description: String?, enabled: Bool, + notify: Bool?, deleteafterrun: Bool?, createdatms: Int, updatedatms: Int, @@ -2118,6 +2120,7 @@ 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 @@ -2134,6 +2137,7 @@ public struct CronJob: Codable, Sendable { case name case description case enabled + case notify case deleteafterrun = "deleteAfterRun" case createdatms = "createdAtMs" case updatedatms = "updatedAtMs" @@ -2167,6 +2171,7 @@ 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 @@ -2179,6 +2184,7 @@ public struct CronAddParams: Codable, Sendable { agentid: AnyCodable?, description: String?, enabled: Bool?, + notify: Bool?, deleteafterrun: Bool?, schedule: AnyCodable, sessiontarget: AnyCodable, @@ -2190,6 +2196,7 @@ public struct CronAddParams: Codable, Sendable { self.agentid = agentid self.description = description self.enabled = enabled + self.notify = notify self.deleteafterrun = deleteafterrun self.schedule = schedule self.sessiontarget = sessiontarget @@ -2202,6 +2209,7 @@ 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 b1e5ef9a10c..82d66c23e7c 100644 --- a/docs/automation/cron-jobs.md +++ b/docs/automation/cron-jobs.md @@ -27,6 +27,7 @@ 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`. ## Quick start (actionable) @@ -288,7 +289,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`, `deleteAfterRun` (defaults to true for `at`), +- Optional fields: `agentId`, `description`, `enabled`, `notify`, `deleteAfterRun` (defaults to true for `at`), `delivery`. - `wakeMode` defaults to `"now"` when omitted. @@ -333,10 +334,19 @@ 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 behavior: + +- The Gateway posts finished run events to `cron.webhook` only when the job has `notify: true`. +- 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. + Disable cron entirely: - `cron.enabled: false` (config) diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index eeb1eaea7b5..d94551ca81f 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -2295,12 +2295,16 @@ 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:// + webhookToken: "replace-with-dedicated-token", // optional bearer token for outbound webhook auth sessionRetention: "24h", // duration string or false }, } ``` - `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. See [Cron Jobs](/automation/cron-jobs). diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 233a67c48b0..1c6e5ea57c5 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -83,6 +83,9 @@ 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. +- Set `cron.webhookToken` to send a dedicated bearer token, if omitted the webhook is sent without an auth header. ## Chat behavior diff --git a/src/agents/tools/cron-tool.ts b/src/agents/tools/cron-tool.ts index d69bf949796..6bc57e386d1 100644 --- a/src/agents/tools/cron-tool.ts +++ b/src/agents/tools/cron-tool.ts @@ -219,7 +219,8 @@ JOB SCHEMA (for add action): "payload": { ... }, // Required: what to execute "delivery": { ... }, // Optional: announce summary (isolated only) "sessionTarget": "main" | "isolated", // Required - "enabled": true | false // Optional, default true + "enabled": true | false, // Optional, default true + "notify": true | false // Optional webhook opt-in; set true for user-facing reminders } SCHEDULE TYPES (schedule.kind): @@ -246,6 +247,7 @@ DELIVERY (isolated-only, top-level): CRITICAL CONSTRAINTS: - sessionTarget="main" REQUIRES payload.kind="systemEvent" - sessionTarget="isolated" REQUIRES payload.kind="agentTurn" +- For reminders users should be notified about, set notify=true. Default: prefer isolated agentTurn jobs unless the user explicitly wants a main-session system event. WAKE MODES (for wake action): @@ -292,6 +294,7 @@ Use jobId as the canonical identifier; id is accepted for compatibility. Use con "payload", "delivery", "enabled", + "notify", "description", "deleteAfterRun", "agentId", diff --git a/src/config/config.cron-webhook-schema.test.ts b/src/config/config.cron-webhook-schema.test.ts new file mode 100644 index 00000000000..e6f64bf5890 --- /dev/null +++ b/src/config/config.cron-webhook-schema.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { OpenClawSchema } from "./zod-schema.js"; + +describe("cron webhook schema", () => { + it("accepts cron.webhook and cron.webhookToken", () => { + const res = OpenClawSchema.safeParse({ + cron: { + enabled: true, + webhook: "https://example.invalid/cron", + webhookToken: "secret-token", + }, + }); + + expect(res.success).toBe(true); + }); + + it("rejects non-http(s) cron.webhook URLs", () => { + const res = OpenClawSchema.safeParse({ + cron: { + webhook: "ftp://example.invalid/cron", + }, + }); + + expect(res.success).toBe(false); + }); +}); diff --git a/src/config/types.cron.ts b/src/config/types.cron.ts index 62a9c1da139..d1704b30b12 100644 --- a/src/config/types.cron.ts +++ b/src/config/types.cron.ts @@ -2,6 +2,8 @@ export type CronConfig = { enabled?: boolean; store?: string; maxConcurrentRuns?: number; + webhook?: string; + webhookToken?: string; /** * How long to retain completed cron run sessions before automatic pruning. * Accepts a duration string (e.g. "24h", "7d", "1h30m") or `false` to disable pruning. diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 7f43b4b1a08..3d718f2f1a5 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -93,6 +93,14 @@ const MemorySchema = z .strict() .optional(); +const HttpUrlSchema = z + .string() + .url() + .refine((value) => { + const protocol = new URL(value).protocol; + return protocol === "http:" || protocol === "https:"; + }, "Expected http:// or https:// URL"); + export const OpenClawSchema = z .object({ $schema: z.string().optional(), @@ -295,6 +303,8 @@ export const OpenClawSchema = z enabled: z.boolean().optional(), store: z.string().optional(), maxConcurrentRuns: z.number().int().positive().optional(), + webhook: HttpUrlSchema.optional(), + webhookToken: z.string().optional().register(sensitive), sessionRetention: z.union([z.string(), z.literal(false)]).optional(), }) .strict() diff --git a/src/cron/service.get-job.test.ts b/src/cron/service.get-job.test.ts new file mode 100644 index 00000000000..6d07189765f --- /dev/null +++ b/src/cron/service.get-job.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it, vi } from "vitest"; +import { CronService } from "./service.js"; +import { + createCronStoreHarness, + createNoopLogger, + installCronTestHooks, +} from "./service.test-harness.js"; + +const logger = createNoopLogger(); +const { makeStorePath } = createCronStoreHarness({ prefix: "openclaw-cron-get-job-" }); +installCronTestHooks({ logger }); + +function createCronService(storePath: string) { + return new CronService({ + storePath, + cronEnabled: true, + log: logger, + enqueueSystemEvent: vi.fn(), + requestHeartbeatNow: vi.fn(), + runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })), + }); +} + +describe("CronService.getJob", () => { + it("returns added jobs and undefined for missing ids", async () => { + const { storePath } = await makeStorePath(); + const cron = createCronService(storePath); + await cron.start(); + + try { + const added = await cron.add({ + name: "lookup-test", + enabled: true, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "main", + wakeMode: "next-heartbeat", + payload: { kind: "systemEvent", text: "ping" }, + }); + + expect(cron.getJob(added.id)?.id).toBe(added.id); + expect(cron.getJob("missing-job-id")).toBeUndefined(); + } finally { + cron.stop(); + } + }); + + it("preserves notify on create for true, false, and omitted", 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", + enabled: true, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "main", + wakeMode: "next-heartbeat", + payload: { kind: "systemEvent", text: "ping" }, + }); + + 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 b11ca9854b1..edb95f0792a 100644 --- a/src/cron/service.jobs.test.ts +++ b/src/cron/service.jobs.test.ts @@ -100,4 +100,24 @@ describe("applyJobPatch", () => { bestEffort: undefined, }); }); + + it("updates notify via patch", () => { + const now = Date.now(); + const job: CronJob = { + id: "job-4", + name: "job-4", + enabled: true, + notify: false, + createdAtMs: now, + updatedAtMs: now, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "isolated", + wakeMode: "now", + payload: { kind: "agentTurn", message: "do it" }, + state: {}, + }; + + expect(() => applyJobPatch(job, { notify: true })).not.toThrow(); + expect(job.notify).toBe(true); + }); }); diff --git a/src/cron/service.ts b/src/cron/service.ts index 8f82a2e6947..8891ee9915b 100644 --- a/src/cron/service.ts +++ b/src/cron/service.ts @@ -1,4 +1,4 @@ -import type { CronJobCreate, CronJobPatch } from "./types.js"; +import type { CronJob, CronJobCreate, CronJobPatch } from "./types.js"; import * as ops from "./service/ops.js"; import { type CronServiceDeps, createCronServiceState } from "./service/state.js"; @@ -42,6 +42,10 @@ export class CronService { return await ops.run(this.state, id, mode); } + getJob(id: string): CronJob | undefined { + return this.state.store?.jobs.find((job) => job.id === id); + } + wake(opts: { mode: "now" | "next-heartbeat"; text: string }) { return ops.wakeNow(this.state, opts); } diff --git a/src/cron/service/jobs.ts b/src/cron/service/jobs.ts index 71a11af7bca..3db5c3ebe58 100644 --- a/src/cron/service/jobs.ts +++ b/src/cron/service/jobs.ts @@ -256,6 +256,7 @@ 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, @@ -284,6 +285,9 @@ 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; } diff --git a/src/cron/types.ts b/src/cron/types.ts index c3168346fb4..22363851357 100644 --- a/src/cron/types.ts +++ b/src/cron/types.ts @@ -71,6 +71,7 @@ export type CronJob = { name: string; description?: string; enabled: boolean; + notify?: boolean; deleteAfterRun?: boolean; createdAtMs: number; updatedAtMs: number; diff --git a/src/gateway/protocol/schema/cron.ts b/src/gateway/protocol/schema/cron.ts index 345690c8327..8772c9195e5 100644 --- a/src/gateway/protocol/schema/cron.ts +++ b/src/gateway/protocol/schema/cron.ts @@ -119,6 +119,7 @@ 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 }), @@ -147,6 +148,7 @@ 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")]), @@ -163,6 +165,7 @@ 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 51865d1d0a4..9a5bb40412b 100644 --- a/src/gateway/server-cron.ts +++ b/src/gateway/server-cron.ts @@ -20,6 +20,17 @@ export type GatewayCronState = { cronEnabled: boolean; }; +const CRON_WEBHOOK_TIMEOUT_MS = 10_000; + +function redactWebhookUrl(url: string): string { + try { + const parsed = new URL(url); + return `${parsed.origin}${parsed.pathname}`; + } catch { + return ""; + } +} + export function buildGatewayCronService(params: { cfg: ReturnType; deps: CliDeps; @@ -93,6 +104,40 @@ 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 job = cron.getJob(evt.jobId); + if (webhookUrl && evt.summary && job?.notify === true) { + const headers: Record = { + "Content-Type": "application/json", + }; + if (webhookToken) { + headers.Authorization = `Bearer ${webhookToken}`; + } + const abortController = new AbortController(); + const timeout = setTimeout(() => { + abortController.abort(); + }, CRON_WEBHOOK_TIMEOUT_MS); + void fetch(webhookUrl, { + method: "POST", + headers, + body: JSON.stringify(evt), + signal: abortController.signal, + }) + .catch((err) => { + cronLogger.warn( + { + err: String(err), + jobId: evt.jobId, + webhookUrl: redactWebhookUrl(webhookUrl), + }, + "cron: webhook delivery failed", + ); + }) + .finally(() => { + clearTimeout(timeout); + }); + } const logPath = resolveCronRunLogPath({ storePath, jobId: evt.jobId, diff --git a/src/gateway/server.cron.e2e.test.ts b/src/gateway/server.cron.e2e.test.ts index 94e52d99b4d..682720487d0 100644 --- a/src/gateway/server.cron.e2e.test.ts +++ b/src/gateway/server.cron.e2e.test.ts @@ -1,9 +1,10 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { describe, expect, test } from "vitest"; +import { describe, expect, test, vi } from "vitest"; import { connectOk, + cronIsolatedRun, installGatewayTestHooks, rpcReq, startServerWithClient, @@ -50,6 +51,20 @@ async function waitForNonEmptyFile(pathname: string, timeoutMs = 2000) { } } +async function waitForCondition(check: () => boolean, timeoutMs = 2000) { + const startedAt = process.hrtime.bigint(); + for (;;) { + if (check()) { + return; + } + const elapsedMs = Number(process.hrtime.bigint() - startedAt) / 1e6; + if (elapsedMs >= timeoutMs) { + throw new Error("timeout waiting for condition"); + } + await yieldToEventLoop(); + } +} + describe("gateway server cron", () => { test("handles cron CRUD, normalization, and patch semantics", { timeout: 120_000 }, async () => { const prevSkipCron = process.env.OPENCLAW_SKIP_CRON; @@ -68,6 +83,7 @@ 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", @@ -84,6 +100,9 @@ describe("gateway server cron", () => { expect(Array.isArray(jobs)).toBe(true); 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); const routeAtMs = Date.now() - 1; const routeRes = await rpcReq(ws, "cron.add", { @@ -403,4 +422,132 @@ describe("gateway server cron", () => { } } }, 45_000); + + test("posts webhooks only when notify is true and 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 configPath = process.env.OPENCLAW_CONFIG_PATH; + expect(typeof configPath).toBe("string"); + await fs.mkdir(path.dirname(configPath as string), { recursive: true }); + await fs.writeFile( + configPath as string, + JSON.stringify( + { + cron: { + webhook: "https://example.invalid/cron-finished", + webhookToken: "cron-webhook-token", + }, + }, + null, + 2, + ), + "utf-8", + ); + + const fetchMock = vi.fn(async () => new Response("ok", { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + + const { server, ws } = await startServerWithClient(); + await connectOk(ws); + + try { + const notifyRes = await rpcReq(ws, "cron.add", { + name: "notify true", + enabled: true, + notify: true, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "main", + wakeMode: "next-heartbeat", + payload: { kind: "systemEvent", text: "send webhook" }, + }); + expect(notifyRes.ok).toBe(true); + const notifyJobIdValue = (notifyRes.payload as { id?: unknown } | null)?.id; + const notifyJobId = typeof notifyJobIdValue === "string" ? notifyJobIdValue : ""; + expect(notifyJobId.length > 0).toBe(true); + + const notifyRunRes = await rpcReq(ws, "cron.run", { id: notifyJobId, mode: "force" }, 20_000); + expect(notifyRunRes.ok).toBe(true); + + await waitForCondition(() => fetchMock.mock.calls.length === 1, 5000); + const [notifyUrl, notifyInit] = fetchMock.mock.calls[0] as [ + string, + { + method?: string; + headers?: Record; + body?: string; + }, + ]; + expect(notifyUrl).toBe("https://example.invalid/cron-finished"); + expect(notifyInit.method).toBe("POST"); + expect(notifyInit.headers?.Authorization).toBe("Bearer cron-webhook-token"); + expect(notifyInit.headers?.["Content-Type"]).toBe("application/json"); + const notifyBody = JSON.parse(notifyInit.body ?? "{}"); + expect(notifyBody.action).toBe("finished"); + expect(notifyBody.jobId).toBe(notifyJobId); + + const silentRes = await rpcReq(ws, "cron.add", { + name: "notify false", + enabled: true, + notify: false, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "main", + wakeMode: "next-heartbeat", + payload: { kind: "systemEvent", text: "do not send" }, + }); + expect(silentRes.ok).toBe(true); + const silentJobIdValue = (silentRes.payload as { id?: unknown } | null)?.id; + const silentJobId = typeof silentJobIdValue === "string" ? silentJobIdValue : ""; + expect(silentJobId.length > 0).toBe(true); + + const silentRunRes = await rpcReq(ws, "cron.run", { id: silentJobId, mode: "force" }, 20_000); + expect(silentRunRes.ok).toBe(true); + await yieldToEventLoop(); + await yieldToEventLoop(); + expect(fetchMock).toHaveBeenCalledTimes(1); + + cronIsolatedRun.mockResolvedValueOnce({ status: "ok" }); + const noSummaryRes = await rpcReq(ws, "cron.add", { + name: "notify no summary", + enabled: true, + notify: true, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "isolated", + wakeMode: "next-heartbeat", + payload: { kind: "agentTurn", message: "test" }, + }); + expect(noSummaryRes.ok).toBe(true); + const noSummaryJobIdValue = (noSummaryRes.payload as { id?: unknown } | null)?.id; + const noSummaryJobId = typeof noSummaryJobIdValue === "string" ? noSummaryJobIdValue : ""; + expect(noSummaryJobId.length > 0).toBe(true); + + const noSummaryRunRes = await rpcReq( + ws, + "cron.run", + { id: noSummaryJobId, mode: "force" }, + 20_000, + ); + expect(noSummaryRunRes.ok).toBe(true); + await yieldToEventLoop(); + await yieldToEventLoop(); + expect(fetchMock).toHaveBeenCalledTimes(1); + } finally { + ws.close(); + await server.close(); + await rmTempDir(dir); + vi.unstubAllGlobals(); + testState.cronStorePath = undefined; + testState.cronEnabled = undefined; + if (prevSkipCron === undefined) { + delete process.env.OPENCLAW_SKIP_CRON; + } else { + process.env.OPENCLAW_SKIP_CRON = prevSkipCron; + } + } + }, 60_000); }); diff --git a/ui/src/ui/app-defaults.ts b/ui/src/ui/app-defaults.ts index 89bdaf11d1b..ee394802b09 100644 --- a/ui/src/ui/app-defaults.ts +++ b/ui/src/ui/app-defaults.ts @@ -15,6 +15,7 @@ export const DEFAULT_CRON_FORM: CronFormState = { description: "", agentId: "", enabled: true, + notify: false, scheduleKind: "every", scheduleAt: "", everyAmount: "30", diff --git a/ui/src/ui/controllers/cron.test.ts b/ui/src/ui/controllers/cron.test.ts new file mode 100644 index 00000000000..aef1a219032 --- /dev/null +++ b/ui/src/ui/controllers/cron.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from "vitest"; +import { DEFAULT_CRON_FORM } from "../app-defaults.ts"; +import { addCronJob, type CronState } from "./cron.ts"; + +function createState(overrides: Partial = {}): CronState { + return { + client: null, + connected: true, + cronLoading: false, + cronJobs: [], + cronStatus: null, + cronError: null, + cronForm: { ...DEFAULT_CRON_FORM }, + cronRunsJobId: null, + cronRuns: [], + cronBusy: false, + ...overrides, + }; +} + +describe("cron controller", () => { + it("forwards notify in cron.add payload", async () => { + const request = vi.fn(async (method: string) => { + if (method === "cron.add") { + return { id: "job-1" }; + } + 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: "notify job", + notify: true, + scheduleKind: "every", + everyAmount: "1", + everyUnit: "minutes", + sessionTarget: "main", + wakeMode: "next-heartbeat", + payloadKind: "systemEvent", + payloadText: "ping", + }, + }); + + await addCronJob(state); + + const addCall = request.mock.calls.find(([method]) => method === "cron.add"); + expect(addCall).toBeDefined(); + expect(addCall?.[1]).toMatchObject({ + notify: true, + name: "notify job", + }); + }); +}); diff --git a/ui/src/ui/controllers/cron.ts b/ui/src/ui/controllers/cron.ts index 190311bca6c..29330c6d8eb 100644 --- a/ui/src/ui/controllers/cron.ts +++ b/ui/src/ui/controllers/cron.ts @@ -122,6 +122,7 @@ export async function addCronJob(state: CronState) { description: state.cronForm.description.trim() || undefined, agentId: agentId || undefined, enabled: state.cronForm.enabled, + notify: state.cronForm.notify, schedule, sessionTarget: state.cronForm.sessionTarget, wakeMode: state.cronForm.wakeMode, diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts index 2d53a9ccbb5..6763fe3a6b8 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -473,6 +473,7 @@ 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 7ce3c73998e..5a583fcedcc 100644 --- a/ui/src/ui/ui-types.ts +++ b/ui/src/ui/ui-types.ts @@ -19,6 +19,7 @@ export type CronFormState = { description: string; agentId: string; enabled: boolean; + notify: boolean; scheduleKind: "at" | "every" | "cron"; scheduleAt: string; everyAmount: string; diff --git a/ui/src/ui/views/cron.test.ts b/ui/src/ui/views/cron.test.ts index ea74093afec..91c724b1d5a 100644 --- a/ui/src/ui/views/cron.test.ts +++ b/ui/src/ui/views/cron.test.ts @@ -158,4 +158,50 @@ describe("cron view", () => { expect(summaries[0]).toBe("newer run"); expect(summaries[1]).toBe("older run"); }); + + it("forwards notify checkbox updates from the form", () => { + const container = document.createElement("div"); + const onFormChange = vi.fn(); + render( + renderCron( + createProps({ + onFormChange, + }), + ), + container, + ); + + const notifyLabel = Array.from(container.querySelectorAll("label.field.checkbox")).find( + (label) => label.querySelector("span")?.textContent?.trim() === "Notify webhook", + ); + 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 }); + }); + + it("shows notify chip for webhook-enabled jobs", () => { + const container = document.createElement("div"); + const job = { ...createJob("job-2"), notify: true }; + render( + renderCron( + createProps({ + jobs: [job], + }), + ), + container, + ); + + const chips = Array.from(container.querySelectorAll(".chip")).map((el) => + (el.textContent ?? "").trim(), + ); + expect(chips).toContain("notify"); + }); }); diff --git a/ui/src/ui/views/cron.ts b/ui/src/ui/views/cron.ts index a51cbfbbd3b..790c6d42988 100644 --- a/ui/src/ui/views/cron.ts +++ b/ui/src/ui/views/cron.ts @@ -127,6 +127,15 @@ export function renderCron(props: CronProps) { props.onFormChange({ enabled: (e.target as HTMLInputElement).checked })} /> +