From 6fb8d8850e952a61f13947f2c41fc28b0603f039 Mon Sep 17 00:00:00 2001 From: Tyler Yust Date: Tue, 3 Feb 2026 20:15:43 -0800 Subject: [PATCH] feat(cron): enhance legacy delivery handling in job patches - Introduced logic to map legacy payload delivery updates onto the delivery object for `agentTurn` jobs, ensuring backward compatibility with legacy clients. - Added tests to validate the correct application of legacy delivery settings in job patches, improving reliability in job configuration. - Refactored delivery handling functions to streamline the merging of legacy delivery fields into the current job structure. This update enhances the flexibility of delivery configurations, ensuring that legacy settings are properly handled in the context of new job patches. --- src/cron/service.jobs.test.ts | 71 ++++++++++++++++++++++++++++++++++ src/cron/service/jobs.ts | 52 +++++++++++++++++++++++++ src/cron/service/store.ts | 73 +++++++++++++++++++++++++++++++++++ 3 files changed, 196 insertions(+) diff --git a/src/cron/service.jobs.test.ts b/src/cron/service.jobs.test.ts index c2080fa06..b11ca9854 100644 --- a/src/cron/service.jobs.test.ts +++ b/src/cron/service.jobs.test.ts @@ -29,4 +29,75 @@ describe("applyJobPatch", () => { expect(job.payload.kind).toBe("systemEvent"); expect(job.delivery).toBeUndefined(); }); + + it("maps legacy payload delivery updates onto delivery", () => { + const now = Date.now(); + const job: CronJob = { + id: "job-2", + name: "job-2", + enabled: true, + createdAtMs: now, + updatedAtMs: now, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "isolated", + wakeMode: "now", + payload: { kind: "agentTurn", message: "do it" }, + delivery: { mode: "announce", channel: "telegram", to: "123" }, + state: {}, + }; + + const patch: CronJobPatch = { + payload: { + kind: "agentTurn", + deliver: false, + channel: "Signal", + to: "555", + bestEffortDeliver: true, + }, + }; + + expect(() => applyJobPatch(job, patch)).not.toThrow(); + expect(job.payload.kind).toBe("agentTurn"); + if (job.payload.kind === "agentTurn") { + expect(job.payload.deliver).toBe(false); + expect(job.payload.channel).toBe("Signal"); + expect(job.payload.to).toBe("555"); + expect(job.payload.bestEffortDeliver).toBe(true); + } + expect(job.delivery).toEqual({ + mode: "none", + channel: "signal", + to: "555", + bestEffort: true, + }); + }); + + it("treats legacy payload targets as announce requests", () => { + const now = Date.now(); + const job: CronJob = { + id: "job-3", + name: "job-3", + enabled: true, + createdAtMs: now, + updatedAtMs: now, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "isolated", + wakeMode: "now", + payload: { kind: "agentTurn", message: "do it" }, + delivery: { mode: "none", channel: "telegram" }, + state: {}, + }; + + const patch: CronJobPatch = { + payload: { kind: "agentTurn", to: " 999 " }, + }; + + expect(() => applyJobPatch(job, patch)).not.toThrow(); + expect(job.delivery).toEqual({ + mode: "announce", + channel: "telegram", + to: "999", + bestEffort: undefined, + }); + }); }); diff --git a/src/cron/service/jobs.ts b/src/cron/service/jobs.ts index d814d44c6..a9eda476c 100644 --- a/src/cron/service/jobs.ts +++ b/src/cron/service/jobs.ts @@ -155,6 +155,17 @@ export function applyJobPatch(job: CronJob, patch: CronJobPatch) { if (patch.payload) { job.payload = mergeCronPayload(job.payload, patch.payload); } + if (!patch.delivery && patch.payload?.kind === "agentTurn") { + // Back-compat: legacy clients still update delivery via payload fields. + const legacyDeliveryPatch = buildLegacyDeliveryPatch(patch.payload); + if ( + legacyDeliveryPatch && + job.sessionTarget === "isolated" && + job.payload.kind === "agentTurn" + ) { + job.delivery = mergeCronDelivery(job.delivery, legacyDeliveryPatch); + } + } if (patch.delivery) { job.delivery = mergeCronDelivery(job.delivery, patch.delivery); } @@ -216,6 +227,47 @@ function mergeCronPayload(existing: CronPayload, patch: CronPayloadPatch): CronP return next; } +function buildLegacyDeliveryPatch( + payload: Extract, +): CronDeliveryPatch | null { + const deliver = payload.deliver; + const toRaw = typeof payload.to === "string" ? payload.to.trim() : ""; + const hasLegacyHints = + typeof deliver === "boolean" || + typeof payload.bestEffortDeliver === "boolean" || + Boolean(toRaw); + if (!hasLegacyHints) { + return null; + } + + const patch: CronDeliveryPatch = {}; + let hasPatch = false; + + if (deliver === false) { + patch.mode = "none"; + hasPatch = true; + } else if (deliver === true || toRaw) { + patch.mode = "announce"; + hasPatch = true; + } + + if (typeof payload.channel === "string") { + const channel = payload.channel.trim().toLowerCase(); + patch.channel = channel ? channel : undefined; + hasPatch = true; + } + if (typeof payload.to === "string") { + patch.to = payload.to.trim(); + hasPatch = true; + } + if (typeof payload.bestEffortDeliver === "boolean") { + patch.bestEffort = payload.bestEffortDeliver; + hasPatch = true; + } + + return hasPatch ? patch : null; +} + function buildPayloadFromPatch(patch: CronPayloadPatch): CronPayload { if (patch.kind === "systemEvent") { if (typeof patch.text !== "string" || patch.text.length === 0) { diff --git a/src/cron/service/store.ts b/src/cron/service/store.ts index 5797f2ee1..3c771a577 100644 --- a/src/cron/service/store.ts +++ b/src/cron/service/store.ts @@ -39,6 +39,69 @@ function buildDeliveryFromLegacyPayload(payload: Record) { return next; } +function buildDeliveryPatchFromLegacyPayload(payload: Record) { + const deliver = payload.deliver; + const channelRaw = + typeof payload.channel === "string" ? payload.channel.trim().toLowerCase() : ""; + const toRaw = typeof payload.to === "string" ? payload.to.trim() : ""; + const next: Record = {}; + let hasPatch = false; + + if (deliver === false) { + next.mode = "none"; + hasPatch = true; + } else if (deliver === true || toRaw) { + next.mode = "announce"; + hasPatch = true; + } + if (channelRaw) { + next.channel = channelRaw; + hasPatch = true; + } + if (toRaw) { + next.to = toRaw; + hasPatch = true; + } + if (typeof payload.bestEffortDeliver === "boolean") { + next.bestEffort = payload.bestEffortDeliver; + hasPatch = true; + } + + return hasPatch ? next : null; +} + +function mergeLegacyDeliveryInto( + delivery: Record, + payload: Record, +) { + const patch = buildDeliveryPatchFromLegacyPayload(payload); + if (!patch) { + return { delivery, mutated: false }; + } + + const next = { ...delivery }; + let mutated = false; + + if ("mode" in patch && patch.mode !== next.mode) { + next.mode = patch.mode; + mutated = true; + } + if ("channel" in patch && patch.channel !== next.channel) { + next.channel = patch.channel; + mutated = true; + } + if ("to" in patch && patch.to !== next.to) { + next.to = patch.to; + mutated = true; + } + if ("bestEffort" in patch && patch.bestEffort !== next.bestEffort) { + next.bestEffort = patch.bestEffort; + mutated = true; + } + + return { delivery: next, mutated }; +} + function stripLegacyDeliveryFields(payload: Record) { if ("deliver" in payload) { delete payload.deliver; @@ -180,6 +243,16 @@ export async function ensureLoaded(state: CronServiceState) { mutated = true; } if (payloadRecord && hasLegacyDelivery) { + if (hasDelivery) { + const merged = mergeLegacyDeliveryInto( + delivery as Record, + payloadRecord, + ); + if (merged.mutated) { + raw.delivery = merged.delivery; + mutated = true; + } + } stripLegacyDeliveryFields(payloadRecord); mutated = true; }