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

@@ -15,6 +15,7 @@ export const DEFAULT_CRON_FORM: CronFormState = {
description: "",
agentId: "",
enabled: true,
notify: false,
scheduleKind: "every",
scheduleAt: "",
everyAmount: "30",

View File

@@ -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> = {}): 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",
});
});
});

View File

@@ -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,

View File

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

View File

@@ -19,6 +19,7 @@ export type CronFormState = {
description: string;
agentId: string;
enabled: boolean;
notify: boolean;
scheduleKind: "at" | "every" | "cron";
scheduleAt: string;
everyAmount: string;

View File

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

View File

@@ -127,6 +127,15 @@ 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
@@ -398,6 +407,13 @@ 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>