mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-07 22:51:23 +00:00
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 <TYTYYUST@YAHOO.COM>
This commit is contained in:
@@ -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")])),
|
||||
|
||||
@@ -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 "<invalid-webhook-url>";
|
||||
}
|
||||
}
|
||||
|
||||
export function buildGatewayCronService(params: {
|
||||
cfg: ReturnType<typeof loadConfig>;
|
||||
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<string, string> = {
|
||||
"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,
|
||||
|
||||
@@ -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<string, string>;
|
||||
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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user