mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
feat(cron): configurable failure alerts for repeated job errors (openclaw#24789) thanks @0xbrak
Verified: - pnpm install --frozen-lockfile - pnpm check - pnpm test -- --run src/cron/service.failure-alert.test.ts src/cli/cron-cli.test.ts src/gateway/protocol/cron-validators.test.ts Co-authored-by: 0xbrak <181251288+0xbrak@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
@@ -36,5 +36,10 @@ export const DEFAULT_CRON_FORM: CronFormState = {
|
||||
deliveryChannel: "last",
|
||||
deliveryTo: "",
|
||||
deliveryBestEffort: false,
|
||||
failureAlertMode: "inherit",
|
||||
failureAlertAfter: "2",
|
||||
failureAlertCooldownSeconds: "3600",
|
||||
failureAlertChannel: "last",
|
||||
failureAlertTo: "",
|
||||
timeoutSeconds: "",
|
||||
};
|
||||
|
||||
@@ -298,6 +298,87 @@ describe("cron controller", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("includes custom failureAlert fields in cron.update patch", async () => {
|
||||
const request = vi.fn(async (method: string, _payload?: unknown) => {
|
||||
if (method === "cron.update") {
|
||||
return { id: "job-alert" };
|
||||
}
|
||||
if (method === "cron.list") {
|
||||
return { jobs: [{ id: "job-alert" }] };
|
||||
}
|
||||
if (method === "cron.status") {
|
||||
return { enabled: true, jobs: 1, nextWakeAtMs: null };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
const state = createState({
|
||||
client: { request } as unknown as CronState["client"],
|
||||
cronEditingJobId: "job-alert",
|
||||
cronForm: {
|
||||
...DEFAULT_CRON_FORM,
|
||||
name: "alert job",
|
||||
payloadKind: "agentTurn",
|
||||
payloadText: "run it",
|
||||
failureAlertMode: "custom",
|
||||
failureAlertAfter: "3",
|
||||
failureAlertCooldownSeconds: "120",
|
||||
failureAlertChannel: "telegram",
|
||||
failureAlertTo: "123456",
|
||||
},
|
||||
});
|
||||
|
||||
await addCronJob(state);
|
||||
|
||||
const updateCall = request.mock.calls.find(([method]) => method === "cron.update");
|
||||
expect(updateCall).toBeDefined();
|
||||
expect(updateCall?.[1]).toMatchObject({
|
||||
id: "job-alert",
|
||||
patch: {
|
||||
failureAlert: {
|
||||
after: 3,
|
||||
cooldownMs: 120_000,
|
||||
channel: "telegram",
|
||||
to: "123456",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("includes failureAlert=false when disabled per job", async () => {
|
||||
const request = vi.fn(async (method: string, _payload?: unknown) => {
|
||||
if (method === "cron.update") {
|
||||
return { id: "job-no-alert" };
|
||||
}
|
||||
if (method === "cron.list") {
|
||||
return { jobs: [{ id: "job-no-alert" }] };
|
||||
}
|
||||
if (method === "cron.status") {
|
||||
return { enabled: true, jobs: 1, nextWakeAtMs: null };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
const state = createState({
|
||||
client: { request } as unknown as CronState["client"],
|
||||
cronEditingJobId: "job-no-alert",
|
||||
cronForm: {
|
||||
...DEFAULT_CRON_FORM,
|
||||
name: "alert off",
|
||||
payloadKind: "agentTurn",
|
||||
payloadText: "run it",
|
||||
failureAlertMode: "disabled",
|
||||
},
|
||||
});
|
||||
|
||||
await addCronJob(state);
|
||||
|
||||
const updateCall = request.mock.calls.find(([method]) => method === "cron.update");
|
||||
expect(updateCall).toBeDefined();
|
||||
expect(updateCall?.[1]).toMatchObject({
|
||||
id: "job-no-alert",
|
||||
patch: { failureAlert: false },
|
||||
});
|
||||
});
|
||||
|
||||
it("maps cron stagger, model, thinking, and best effort into form", () => {
|
||||
const state = createState();
|
||||
const job = {
|
||||
@@ -331,6 +412,36 @@ describe("cron controller", () => {
|
||||
expect(state.cronForm.deliveryBestEffort).toBe(true);
|
||||
});
|
||||
|
||||
it("maps failureAlert overrides into form fields", () => {
|
||||
const state = createState();
|
||||
const job = {
|
||||
id: "job-11",
|
||||
name: "Failure alerts",
|
||||
enabled: true,
|
||||
createdAtMs: 0,
|
||||
updatedAtMs: 0,
|
||||
schedule: { kind: "every" as const, everyMs: 60_000 },
|
||||
sessionTarget: "isolated" as const,
|
||||
wakeMode: "next-heartbeat" as const,
|
||||
payload: { kind: "agentTurn" as const, message: "hello" },
|
||||
failureAlert: {
|
||||
after: 4,
|
||||
cooldownMs: 30_000,
|
||||
channel: "telegram",
|
||||
to: "999",
|
||||
},
|
||||
state: {},
|
||||
};
|
||||
|
||||
startCronEdit(state, job);
|
||||
|
||||
expect(state.cronForm.failureAlertMode).toBe("custom");
|
||||
expect(state.cronForm.failureAlertAfter).toBe("4");
|
||||
expect(state.cronForm.failureAlertCooldownSeconds).toBe("30");
|
||||
expect(state.cronForm.failureAlertChannel).toBe("telegram");
|
||||
expect(state.cronForm.failureAlertTo).toBe("999");
|
||||
});
|
||||
|
||||
it("validates key cron form errors", () => {
|
||||
const errors = validateCronForm({
|
||||
...DEFAULT_CRON_FORM,
|
||||
|
||||
@@ -29,7 +29,9 @@ export type CronFieldKey =
|
||||
| "payloadModel"
|
||||
| "payloadThinking"
|
||||
| "timeoutSeconds"
|
||||
| "deliveryTo";
|
||||
| "deliveryTo"
|
||||
| "failureAlertAfter"
|
||||
| "failureAlertCooldownSeconds";
|
||||
|
||||
export type CronFieldErrors = Partial<Record<CronFieldKey, string>>;
|
||||
|
||||
@@ -145,6 +147,22 @@ export function validateCronForm(form: CronFormState): CronFieldErrors {
|
||||
errors.deliveryTo = "cron.errors.webhookUrlInvalid";
|
||||
}
|
||||
}
|
||||
if (form.failureAlertMode === "custom") {
|
||||
const afterRaw = form.failureAlertAfter.trim();
|
||||
if (afterRaw) {
|
||||
const after = toNumber(afterRaw, 0);
|
||||
if (!Number.isFinite(after) || after <= 0) {
|
||||
errors.failureAlertAfter = "Failure alert threshold must be greater than 0.";
|
||||
}
|
||||
}
|
||||
const cooldownRaw = form.failureAlertCooldownSeconds.trim();
|
||||
if (cooldownRaw) {
|
||||
const cooldown = toNumber(cooldownRaw, -1);
|
||||
if (!Number.isFinite(cooldown) || cooldown < 0) {
|
||||
errors.failureAlertCooldownSeconds = "Cooldown must be 0 or greater.";
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
@@ -374,6 +392,7 @@ function parseStaggerSchedule(
|
||||
}
|
||||
|
||||
function jobToForm(job: CronJob, prev: CronFormState): CronFormState {
|
||||
const failureAlert = job.failureAlert;
|
||||
const next: CronFormState = {
|
||||
...prev,
|
||||
name: job.name,
|
||||
@@ -401,6 +420,27 @@ function jobToForm(job: CronJob, prev: CronFormState): CronFormState {
|
||||
deliveryChannel: job.delivery?.channel ?? CRON_CHANNEL_LAST,
|
||||
deliveryTo: job.delivery?.to ?? "",
|
||||
deliveryBestEffort: job.delivery?.bestEffort ?? false,
|
||||
failureAlertMode:
|
||||
failureAlert === false
|
||||
? "disabled"
|
||||
: failureAlert && typeof failureAlert === "object"
|
||||
? "custom"
|
||||
: "inherit",
|
||||
failureAlertAfter:
|
||||
failureAlert && typeof failureAlert === "object" && typeof failureAlert.after === "number"
|
||||
? String(failureAlert.after)
|
||||
: DEFAULT_CRON_FORM.failureAlertAfter,
|
||||
failureAlertCooldownSeconds:
|
||||
failureAlert &&
|
||||
typeof failureAlert === "object" &&
|
||||
typeof failureAlert.cooldownMs === "number"
|
||||
? String(Math.floor(failureAlert.cooldownMs / 1000))
|
||||
: DEFAULT_CRON_FORM.failureAlertCooldownSeconds,
|
||||
failureAlertChannel:
|
||||
failureAlert && typeof failureAlert === "object"
|
||||
? (failureAlert.channel ?? CRON_CHANNEL_LAST)
|
||||
: CRON_CHANNEL_LAST,
|
||||
failureAlertTo: failureAlert && typeof failureAlert === "object" ? (failureAlert.to ?? "") : "",
|
||||
timeoutSeconds:
|
||||
job.payload.kind === "agentTurn" && typeof job.payload.timeoutSeconds === "number"
|
||||
? String(job.payload.timeoutSeconds)
|
||||
@@ -495,6 +535,26 @@ export function buildCronPayload(form: CronFormState) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
function buildFailureAlert(form: CronFormState) {
|
||||
if (form.failureAlertMode === "disabled") {
|
||||
return false as const;
|
||||
}
|
||||
if (form.failureAlertMode !== "custom") {
|
||||
return undefined;
|
||||
}
|
||||
const after = toNumber(form.failureAlertAfter.trim(), 0);
|
||||
const cooldownSeconds = toNumber(form.failureAlertCooldownSeconds.trim(), 0);
|
||||
return {
|
||||
after: after > 0 ? Math.floor(after) : undefined,
|
||||
channel: form.failureAlertChannel.trim() || CRON_CHANNEL_LAST,
|
||||
to: form.failureAlertTo.trim() || undefined,
|
||||
cooldownMs:
|
||||
Number.isFinite(cooldownSeconds) && cooldownSeconds >= 0
|
||||
? Math.floor(cooldownSeconds * 1000)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function addCronJob(state: CronState) {
|
||||
if (!state.client || !state.connected || state.cronBusy) {
|
||||
return;
|
||||
@@ -527,6 +587,7 @@ export async function addCronJob(state: CronState) {
|
||||
bestEffort: form.deliveryBestEffort,
|
||||
}
|
||||
: undefined;
|
||||
const failureAlert = buildFailureAlert(form);
|
||||
const agentId = form.clearAgent ? null : form.agentId.trim();
|
||||
const job = {
|
||||
name: form.name.trim(),
|
||||
@@ -539,6 +600,7 @@ export async function addCronJob(state: CronState) {
|
||||
wakeMode: form.wakeMode,
|
||||
payload,
|
||||
delivery,
|
||||
failureAlert,
|
||||
};
|
||||
if (!job.name) {
|
||||
throw new Error(t("cron.errors.nameRequiredShort"));
|
||||
|
||||
@@ -491,6 +491,13 @@ export type CronDelivery = {
|
||||
bestEffort?: boolean;
|
||||
};
|
||||
|
||||
export type CronFailureAlert = {
|
||||
after?: number;
|
||||
channel?: string;
|
||||
to?: string;
|
||||
cooldownMs?: number;
|
||||
};
|
||||
|
||||
export type CronJobState = {
|
||||
nextRunAtMs?: number;
|
||||
runningAtMs?: number;
|
||||
@@ -498,6 +505,7 @@ export type CronJobState = {
|
||||
lastStatus?: "ok" | "error" | "skipped";
|
||||
lastError?: string;
|
||||
lastDurationMs?: number;
|
||||
lastFailureAlertAtMs?: number;
|
||||
};
|
||||
|
||||
export type CronJob = {
|
||||
@@ -514,6 +522,7 @@ export type CronJob = {
|
||||
wakeMode: CronWakeMode;
|
||||
payload: CronPayload;
|
||||
delivery?: CronDelivery;
|
||||
failureAlert?: CronFailureAlert | false;
|
||||
state?: CronJobState;
|
||||
};
|
||||
|
||||
|
||||
@@ -40,5 +40,10 @@ export type CronFormState = {
|
||||
deliveryChannel: string;
|
||||
deliveryTo: string;
|
||||
deliveryBestEffort: boolean;
|
||||
failureAlertMode: "inherit" | "disabled" | "custom";
|
||||
failureAlertAfter: string;
|
||||
failureAlertCooldownSeconds: string;
|
||||
failureAlertChannel: string;
|
||||
failureAlertTo: string;
|
||||
timeoutSeconds: string;
|
||||
};
|
||||
|
||||
@@ -239,6 +239,12 @@ function inputIdForField(key: CronFieldKey) {
|
||||
if (key === "timeoutSeconds") {
|
||||
return "cron-timeout-seconds";
|
||||
}
|
||||
if (key === "failureAlertAfter") {
|
||||
return "cron-failure-alert-after";
|
||||
}
|
||||
if (key === "failureAlertCooldownSeconds") {
|
||||
return "cron-failure-alert-cooldown-seconds";
|
||||
}
|
||||
return "cron-delivery-to";
|
||||
}
|
||||
|
||||
@@ -266,6 +272,8 @@ function fieldLabelForKey(
|
||||
payloadThinking: t("cron.form.thinking"),
|
||||
timeoutSeconds: t("cron.form.timeoutSeconds"),
|
||||
deliveryTo: t("cron.form.to"),
|
||||
failureAlertAfter: "Failure alert after",
|
||||
failureAlertCooldownSeconds: "Failure alert cooldown",
|
||||
};
|
||||
return labels[key];
|
||||
}
|
||||
@@ -286,6 +294,8 @@ function collectBlockingFields(
|
||||
"payloadThinking",
|
||||
"timeoutSeconds",
|
||||
"deliveryTo",
|
||||
"failureAlertAfter",
|
||||
"failureAlertCooldownSeconds",
|
||||
];
|
||||
const fields: BlockingField[] = [];
|
||||
for (const key of orderedKeys) {
|
||||
@@ -1057,6 +1067,115 @@ export function renderCron(props: CronProps) {
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
isAgentTurn
|
||||
? html`
|
||||
<label class="field cron-span-2">
|
||||
${renderFieldLabel("Failure alerts")}
|
||||
<select
|
||||
.value=${props.form.failureAlertMode}
|
||||
@change=${(e: Event) =>
|
||||
props.onFormChange({
|
||||
failureAlertMode: (e.target as HTMLSelectElement)
|
||||
.value as CronFormState["failureAlertMode"],
|
||||
})}
|
||||
>
|
||||
<option value="inherit">Inherit global setting</option>
|
||||
<option value="disabled">Disable for this job</option>
|
||||
<option value="custom">Custom per-job settings</option>
|
||||
</select>
|
||||
<div class="cron-help">
|
||||
Control when this job sends repeated-failure alerts.
|
||||
</div>
|
||||
</label>
|
||||
${
|
||||
props.form.failureAlertMode === "custom"
|
||||
? html`
|
||||
<label class="field">
|
||||
${renderFieldLabel("Alert after")}
|
||||
<input
|
||||
id="cron-failure-alert-after"
|
||||
.value=${props.form.failureAlertAfter}
|
||||
aria-invalid=${props.fieldErrors.failureAlertAfter ? "true" : "false"}
|
||||
aria-describedby=${ifDefined(
|
||||
props.fieldErrors.failureAlertAfter
|
||||
? errorIdForField("failureAlertAfter")
|
||||
: undefined,
|
||||
)}
|
||||
@input=${(e: Event) =>
|
||||
props.onFormChange({
|
||||
failureAlertAfter: (e.target as HTMLInputElement).value,
|
||||
})}
|
||||
placeholder="2"
|
||||
/>
|
||||
<div class="cron-help">Consecutive errors before alerting.</div>
|
||||
${renderFieldError(
|
||||
props.fieldErrors.failureAlertAfter,
|
||||
errorIdForField("failureAlertAfter"),
|
||||
)}
|
||||
</label>
|
||||
<label class="field">
|
||||
${renderFieldLabel("Cooldown (seconds)")}
|
||||
<input
|
||||
id="cron-failure-alert-cooldown-seconds"
|
||||
.value=${props.form.failureAlertCooldownSeconds}
|
||||
aria-invalid=${props.fieldErrors.failureAlertCooldownSeconds ? "true" : "false"}
|
||||
aria-describedby=${ifDefined(
|
||||
props.fieldErrors.failureAlertCooldownSeconds
|
||||
? errorIdForField("failureAlertCooldownSeconds")
|
||||
: undefined,
|
||||
)}
|
||||
@input=${(e: Event) =>
|
||||
props.onFormChange({
|
||||
failureAlertCooldownSeconds: (e.target as HTMLInputElement)
|
||||
.value,
|
||||
})}
|
||||
placeholder="3600"
|
||||
/>
|
||||
<div class="cron-help">Minimum seconds between alerts.</div>
|
||||
${renderFieldError(
|
||||
props.fieldErrors.failureAlertCooldownSeconds,
|
||||
errorIdForField("failureAlertCooldownSeconds"),
|
||||
)}
|
||||
</label>
|
||||
<label class="field">
|
||||
${renderFieldLabel("Alert channel")}
|
||||
<select
|
||||
.value=${props.form.failureAlertChannel || "last"}
|
||||
@change=${(e: Event) =>
|
||||
props.onFormChange({
|
||||
failureAlertChannel: (e.target as HTMLSelectElement).value,
|
||||
})}
|
||||
>
|
||||
${channelOptions.map(
|
||||
(channel) =>
|
||||
html`<option value=${channel}>
|
||||
${resolveChannelLabel(props, channel)}
|
||||
</option>`,
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
${renderFieldLabel("Alert to")}
|
||||
<input
|
||||
.value=${props.form.failureAlertTo}
|
||||
list="cron-delivery-to-suggestions"
|
||||
@input=${(e: Event) =>
|
||||
props.onFormChange({
|
||||
failureAlertTo: (e.target as HTMLInputElement).value,
|
||||
})}
|
||||
placeholder="+1555... or chat id"
|
||||
/>
|
||||
<div class="cron-help">
|
||||
Optional recipient override for failure alerts.
|
||||
</div>
|
||||
</label>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
selectedDeliveryMode !== "none"
|
||||
? html`
|
||||
|
||||
Reference in New Issue
Block a user