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:
Advait Paliwal
2026-02-15 16:14:17 -08:00
committed by GitHub
parent ab000bc411
commit 115cfb4430
25 changed files with 519 additions and 4 deletions

View File

@@ -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();
}
});
});

View File

@@ -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);
});
});

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -71,6 +71,7 @@ export type CronJob = {
name: string;
description?: string;
enabled: boolean;
notify?: boolean;
deleteAfterRun?: boolean;
createdAtMs: number;
updatedAtMs: number;