mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-21 16:41:56 +00:00
* feat(cron): add failure destination support with webhook mode and bestEffort handling Extends PR #24789 failure alerts with features from PR #29145: - Add webhook delivery mode for failure alerts (mode: 'webhook') - Add accountId support for multi-account channel configurations - Add bestEffort handling to skip alerts when job has bestEffort=true - Add separate failureDestination config (global + per-job in delivery) - Add duplicate prevention (prevents sending to same as primary delivery) - Add CLI flags: --failure-alert-mode, --failure-alert-account-id - Add UI fields for new options in web cron editor * fix(cron): merge failureAlert mode/accountId and preserve failureDestination on updates - Fix mergeCronFailureAlert to merge mode and accountId fields - Fix mergeCronDelivery to preserve failureDestination on updates - Fix isSameDeliveryTarget to use 'announce' as default instead of 'none' to properly detect duplicates when delivery.mode is undefined * fix(cron): validate webhook mode requires URL in resolveFailureDestination When mode is 'webhook' but no 'to' URL is provided, return null instead of creating an invalid plan that silently fails later. * fix(cron): fail closed on webhook mode without URL and make failureDestination fields clearable - sendCronFailureAlert: fail closed when mode is webhook but URL is missing - mergeCronDelivery: use per-key presence checks so callers can clear nested failureDestination fields via cron.update Note: protocol:check shows missing internalEvents in Swift models - this is a pre-existing issue unrelated to these changes (upstream sync needed). * fix(cron): use separate schema for failureDestination and fix type cast - Create CronFailureDestinationSchema excluding after/cooldownMs fields - Fix type cast in sendFailureNotificationAnnounce to use CronMessageChannel * fix(cron): merge global failureDestination with partial job overrides When job has partial failureDestination config, fall back to global config for unset fields instead of treating it as a full override. * fix(cron): avoid forcing announce mode and clear inherited to on mode change - UI: only include mode in patch if explicitly set to non-default - delivery.ts: clear inherited 'to' when job overrides mode, since URL semantics differ between announce and webhook modes * fix(cron): preserve explicit to on mode override and always include mode in UI patches - delivery.ts: preserve job-level explicit 'to' when overriding mode - UI: always include mode in failureAlert patch so users can switch between announce/webhook * fix(cron): allow clearing accountId and treat undefined global mode as announce - UI: always include accountId in patch so users can clear it - delivery.ts: treat undefined global mode as announce when comparing for clearing inherited 'to' * Cron: harden failure destination routing and add regression coverage * Cron: resolve failure destination review feedback * Cron: drop unrelated timeout assertions from conflict resolution * Cron: format cron CLI regression test * Cron: align gateway cron test mock types --------- Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
181 lines
4.8 KiB
TypeScript
181 lines
4.8 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { resolveCronDeliveryPlan, resolveFailureDestination } from "./delivery.js";
|
|
import type { CronJob } from "./types.js";
|
|
|
|
function makeJob(overrides: Partial<CronJob>): CronJob {
|
|
const now = Date.now();
|
|
return {
|
|
id: "job-1",
|
|
name: "test",
|
|
enabled: true,
|
|
createdAtMs: now,
|
|
updatedAtMs: now,
|
|
schedule: { kind: "every", everyMs: 60_000 },
|
|
sessionTarget: "isolated",
|
|
wakeMode: "next-heartbeat",
|
|
payload: { kind: "agentTurn", message: "hello" },
|
|
state: {},
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("resolveCronDeliveryPlan", () => {
|
|
it("defaults to announce when delivery object has no mode", () => {
|
|
const plan = resolveCronDeliveryPlan(
|
|
makeJob({
|
|
delivery: { channel: "telegram", to: "123", mode: undefined as never },
|
|
}),
|
|
);
|
|
expect(plan.mode).toBe("announce");
|
|
expect(plan.requested).toBe(true);
|
|
expect(plan.channel).toBe("telegram");
|
|
expect(plan.to).toBe("123");
|
|
});
|
|
|
|
it("respects legacy payload deliver=false", () => {
|
|
const plan = resolveCronDeliveryPlan(
|
|
makeJob({
|
|
delivery: undefined,
|
|
payload: { kind: "agentTurn", message: "hello", deliver: false },
|
|
}),
|
|
);
|
|
expect(plan.mode).toBe("none");
|
|
expect(plan.requested).toBe(false);
|
|
});
|
|
|
|
it("resolves mode=none with requested=false and no channel (#21808)", () => {
|
|
const plan = resolveCronDeliveryPlan(
|
|
makeJob({
|
|
delivery: { mode: "none", to: "telegram:123" },
|
|
}),
|
|
);
|
|
expect(plan.mode).toBe("none");
|
|
expect(plan.requested).toBe(false);
|
|
expect(plan.channel).toBeUndefined();
|
|
expect(plan.to).toBe("telegram:123");
|
|
});
|
|
|
|
it("resolves webhook mode without channel routing", () => {
|
|
const plan = resolveCronDeliveryPlan(
|
|
makeJob({
|
|
delivery: { mode: "webhook", to: "https://example.invalid/cron" },
|
|
}),
|
|
);
|
|
expect(plan.mode).toBe("webhook");
|
|
expect(plan.requested).toBe(false);
|
|
expect(plan.channel).toBeUndefined();
|
|
expect(plan.to).toBe("https://example.invalid/cron");
|
|
});
|
|
|
|
it("threads delivery.accountId when explicitly configured", () => {
|
|
const plan = resolveCronDeliveryPlan(
|
|
makeJob({
|
|
delivery: {
|
|
mode: "announce",
|
|
channel: "telegram",
|
|
to: "123",
|
|
accountId: " bot-a ",
|
|
},
|
|
}),
|
|
);
|
|
expect(plan.mode).toBe("announce");
|
|
expect(plan.requested).toBe(true);
|
|
expect(plan.channel).toBe("telegram");
|
|
expect(plan.to).toBe("123");
|
|
expect(plan.accountId).toBe("bot-a");
|
|
});
|
|
});
|
|
|
|
describe("resolveFailureDestination", () => {
|
|
it("merges global defaults with job-level overrides", () => {
|
|
const plan = resolveFailureDestination(
|
|
makeJob({
|
|
delivery: {
|
|
mode: "announce",
|
|
channel: "telegram",
|
|
to: "111",
|
|
failureDestination: { channel: "signal", mode: "announce" },
|
|
},
|
|
}),
|
|
{
|
|
channel: "telegram",
|
|
to: "222",
|
|
mode: "announce",
|
|
accountId: "global-account",
|
|
},
|
|
);
|
|
expect(plan).toEqual({
|
|
mode: "announce",
|
|
channel: "signal",
|
|
to: "222",
|
|
accountId: "global-account",
|
|
});
|
|
});
|
|
|
|
it("returns null for webhook mode without destination URL", () => {
|
|
const plan = resolveFailureDestination(
|
|
makeJob({
|
|
delivery: {
|
|
mode: "announce",
|
|
channel: "telegram",
|
|
to: "111",
|
|
failureDestination: { mode: "webhook" },
|
|
},
|
|
}),
|
|
undefined,
|
|
);
|
|
expect(plan).toBeNull();
|
|
});
|
|
|
|
it("returns null when failure destination matches primary delivery target", () => {
|
|
const plan = resolveFailureDestination(
|
|
makeJob({
|
|
delivery: {
|
|
mode: "announce",
|
|
channel: "telegram",
|
|
to: "111",
|
|
accountId: "bot-a",
|
|
failureDestination: {
|
|
mode: "announce",
|
|
channel: "telegram",
|
|
to: "111",
|
|
accountId: "bot-a",
|
|
},
|
|
},
|
|
}),
|
|
undefined,
|
|
);
|
|
expect(plan).toBeNull();
|
|
});
|
|
|
|
it("allows job-level failure destination fields to clear inherited global values", () => {
|
|
const plan = resolveFailureDestination(
|
|
makeJob({
|
|
delivery: {
|
|
mode: "announce",
|
|
channel: "telegram",
|
|
to: "111",
|
|
failureDestination: {
|
|
mode: "announce",
|
|
channel: undefined as never,
|
|
to: undefined as never,
|
|
accountId: undefined as never,
|
|
},
|
|
},
|
|
}),
|
|
{
|
|
channel: "signal",
|
|
to: "group-abc",
|
|
accountId: "global-account",
|
|
mode: "announce",
|
|
},
|
|
);
|
|
expect(plan).toEqual({
|
|
mode: "announce",
|
|
channel: "last",
|
|
to: undefined,
|
|
accountId: undefined,
|
|
});
|
|
});
|
|
});
|