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