fix: migrate cron payload channel alias

This commit is contained in:
Peter Steinberger
2026-01-09 22:40:51 +01:00
parent bdee50da6b
commit 22b3bd4415
4 changed files with 110 additions and 0 deletions

View File

@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { normalizeCronJobCreate } from "./normalize.js";
describe("normalizeCronJobCreate", () => {
it("maps legacy payload.channel to payload.provider and strips channel", () => {
const normalized = normalizeCronJobCreate({
name: "legacy",
enabled: true,
schedule: { kind: "cron", expr: "* * * * *" },
sessionTarget: "isolated",
wakeMode: "now",
payload: {
kind: "agentTurn",
message: "hi",
deliver: true,
channel: "telegram",
to: "7200373102",
},
}) as unknown as Record<string, unknown>;
const payload = normalized.payload as Record<string, unknown>;
expect(payload.provider).toBe("telegram");
expect("channel" in payload).toBe(false);
});
});

View File

@@ -32,6 +32,17 @@ function coercePayload(payload: UnknownRecord) {
if (typeof payload.text === "string") next.kind = "systemEvent";
else if (typeof payload.message === "string") next.kind = "agentTurn";
}
// Back-compat: older configs used `channel` for delivery provider.
const providerRaw =
typeof payload.provider === "string" ? payload.provider.trim() : "";
const channelRaw =
typeof payload.channel === "string" ? payload.channel.trim() : "";
const provider =
(providerRaw || channelRaw).trim().toLowerCase() ||
(providerRaw || channelRaw).trim();
if (!providerRaw && provider) next.provider = provider;
if ("channel" in next) delete next.channel;
return next;
}

View File

@@ -118,6 +118,57 @@ describe("CronService", () => {
await store.cleanup();
});
it("migrates legacy payload.channel to payload.provider on load", async () => {
const store = await makeStorePath();
const enqueueSystemEvent = vi.fn();
const requestHeartbeatNow = vi.fn();
const rawJob = {
id: "legacy-1",
name: "legacy",
enabled: true,
createdAtMs: Date.now(),
updatedAtMs: Date.now(),
schedule: { kind: "cron", expr: "* * * * *" },
sessionTarget: "isolated",
wakeMode: "now",
payload: {
kind: "agentTurn",
message: "hi",
deliver: true,
channel: "telegram",
to: "7200373102",
},
state: {},
};
await fs.mkdir(path.dirname(store.storePath), { recursive: true });
await fs.writeFile(
store.storePath,
JSON.stringify({ version: 1, jobs: [rawJob] }, null, 2),
"utf-8",
);
const cron = new CronService({
storePath: store.storePath,
cronEnabled: true,
log: noopLogger,
enqueueSystemEvent,
requestHeartbeatNow,
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })),
});
await cron.start();
const jobs = await cron.list({ includeDisabled: true });
const job = jobs.find((j) => j.id === rawJob.id);
const payload = job?.payload as unknown as Record<string, unknown>;
expect(payload.provider).toBe("telegram");
expect("channel" in payload).toBe(false);
cron.stop();
await store.cleanup();
});
it("posts last output to main even when isolated job errors", async () => {
const store = await makeStorePath();
const enqueueSystemEvent = vi.fn();

View File

@@ -317,6 +317,28 @@ export class CronService {
raw.description = desc;
mutated = true;
}
const payload = raw.payload;
if (payload && typeof payload === "object" && !Array.isArray(payload)) {
const legacyChannel =
typeof (payload as Record<string, unknown>).channel === "string"
? String((payload as Record<string, unknown>).channel).trim()
: "";
const provider =
typeof (payload as Record<string, unknown>).provider === "string"
? String((payload as Record<string, unknown>).provider).trim()
: "";
// Back-compat: older cron payloads used `channel` for delivery provider.
if (!provider && legacyChannel) {
(payload as Record<string, unknown>).provider =
legacyChannel.toLowerCase();
mutated = true;
}
if ("channel" in (payload as Record<string, unknown>)) {
delete (payload as Record<string, unknown>).channel;
mutated = true;
}
}
}
this.store = { version: 1, jobs: jobs as unknown as CronJob[] };
if (mutated) await this.persist();