From 4cd04e4652a261ea1f12f1f6ee0c374c07c2480c Mon Sep 17 00:00:00 2001 From: bmendonca3 Date: Mon, 2 Mar 2026 09:50:15 -0700 Subject: [PATCH] fix(cron): migrate legacy string schedule and command jobs --- src/cron/service.store.migration.test.ts | 43 ++++++++++++++++++++++++ src/cron/service/store.ts | 36 +++++++++++++++++++- 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/src/cron/service.store.migration.test.ts b/src/cron/service.store.migration.test.ts index db7f1d0bc..99c6c65f1 100644 --- a/src/cron/service.store.migration.test.ts +++ b/src/cron/service.store.migration.test.ts @@ -178,4 +178,47 @@ describe("cron store migration", () => { expect(schedule.kind).toBe("cron"); expect(schedule.staggerMs).toBeUndefined(); }); + + it("migrates legacy string schedules and command-only payloads (#18445)", async () => { + const store = await makeStorePath(); + try { + await writeLegacyStore(store.storePath, { + id: "imessage-refresh", + name: "iMessage Refresh", + enabled: true, + createdAtMs: 1_700_000_000_000, + updatedAtMs: 1_700_000_000_000, + schedule: "0 */2 * * *", + command: "bash /tmp/imessage-refresh.sh", + timeout: 120, + state: {}, + }); + + await migrateAndLoadFirstJob(store.storePath); + const loaded = await loadCronStore(store.storePath); + const migrated = loaded.jobs[0] as Record; + + expect(migrated.schedule).toEqual( + expect.objectContaining({ + kind: "cron", + expr: "0 */2 * * *", + }), + ); + expect(migrated.sessionTarget).toBe("main"); + expect(migrated.wakeMode).toBe("now"); + expect(migrated.payload).toEqual({ + kind: "systemEvent", + text: "bash /tmp/imessage-refresh.sh", + }); + expect("command" in migrated).toBe(false); + expect("timeout" in migrated).toBe(false); + + const scheduleWarn = noopLogger.warn.mock.calls.find((args) => + String(args[1] ?? "").includes("failed to compute next run for job (skipping)"), + ); + expect(scheduleWarn).toBeUndefined(); + } finally { + await store.cleanup(); + } + }); }); diff --git a/src/cron/service/store.ts b/src/cron/service/store.ts index 843625244..693c18141 100644 --- a/src/cron/service/store.ts +++ b/src/cron/service/store.ts @@ -92,6 +92,7 @@ function normalizePayloadKind(payload: Record) { function inferPayloadIfMissing(raw: Record) { const message = typeof raw.message === "string" ? raw.message.trim() : ""; const text = typeof raw.text === "string" ? raw.text.trim() : ""; + const command = typeof raw.command === "string" ? raw.command.trim() : ""; if (message) { raw.payload = { kind: "agentTurn", message }; return true; @@ -100,6 +101,10 @@ function inferPayloadIfMissing(raw: Record) { raw.payload = { kind: "systemEvent", text }; return true; } + if (command) { + raw.payload = { kind: "systemEvent", text: command }; + return true; + } return false; } @@ -209,6 +214,12 @@ function stripLegacyTopLevelFields(raw: Record) { if ("provider" in raw) { delete raw.provider; } + if ("command" in raw) { + delete raw.command; + } + if ("timeout" in raw) { + delete raw.timeout; + } } async function getFileMtimeMs(path: string): Promise { @@ -262,6 +273,12 @@ export async function ensureLoaded( mutated = true; } + if (typeof raw.schedule === "string") { + const expr = raw.schedule.trim(); + raw.schedule = { kind: "cron", expr }; + mutated = true; + } + const nameRaw = raw.name; if (typeof nameRaw !== "string" || nameRaw.trim().length === 0) { raw.name = inferLegacyName({ @@ -353,7 +370,9 @@ export async function ensureLoaded( "channel" in raw || "to" in raw || "bestEffortDeliver" in raw || - "provider" in raw; + "provider" in raw || + "command" in raw || + "timeout" in raw; if (hadLegacyTopLevelFields) { stripLegacyTopLevelFields(raw); mutated = true; @@ -469,6 +488,21 @@ export async function ensureLoaded( const payloadKind = payloadRecord && typeof payloadRecord.kind === "string" ? payloadRecord.kind : ""; + const normalizedSessionTarget = + typeof raw.sessionTarget === "string" ? raw.sessionTarget.trim().toLowerCase() : ""; + if (normalizedSessionTarget === "main" || normalizedSessionTarget === "isolated") { + if (raw.sessionTarget !== normalizedSessionTarget) { + raw.sessionTarget = normalizedSessionTarget; + mutated = true; + } + } else { + const inferredSessionTarget = payloadKind === "agentTurn" ? "isolated" : "main"; + if (raw.sessionTarget !== inferredSessionTarget) { + raw.sessionTarget = inferredSessionTarget; + mutated = true; + } + } + const sessionTarget = typeof raw.sessionTarget === "string" ? raw.sessionTarget.trim().toLowerCase() : ""; const isIsolatedAgentTurn =