diff --git a/src/cron/normalize.test.ts b/src/cron/normalize.test.ts index 17a353d0f..b75a23aca 100644 --- a/src/cron/normalize.test.ts +++ b/src/cron/normalize.test.ts @@ -138,6 +138,25 @@ describe("normalizeCronJobCreate", () => { expectNormalizedAtSchedule({ kind: "at", atMs: "2026-01-12T18:00:00" }); }); + it("migrates legacy schedule.cron into schedule.expr", () => { + const normalized = normalizeCronJobCreate({ + name: "legacy-cron-field", + enabled: true, + schedule: { kind: "cron", cron: "*/10 * * * *", tz: "UTC" }, + sessionTarget: "main", + wakeMode: "next-heartbeat", + payload: { + kind: "systemEvent", + text: "tick", + }, + }) as unknown as Record; + + const schedule = normalized.schedule as Record; + expect(schedule.kind).toBe("cron"); + expect(schedule.expr).toBe("*/10 * * * *"); + expect(schedule.cron).toBeUndefined(); + }); + it("defaults cron stagger for recurring top-of-hour schedules", () => { const normalized = normalizeCronJobCreate({ name: "hourly", diff --git a/src/cron/normalize.ts b/src/cron/normalize.ts index f7a4b210c..fe06eaf2f 100644 --- a/src/cron/normalize.ts +++ b/src/cron/normalize.ts @@ -25,6 +25,9 @@ function coerceSchedule(schedule: UnknownRecord) { const next: UnknownRecord = { ...schedule }; const rawKind = typeof schedule.kind === "string" ? schedule.kind.trim().toLowerCase() : ""; const kind = rawKind === "at" || rawKind === "every" || rawKind === "cron" ? rawKind : undefined; + const exprRaw = typeof schedule.expr === "string" ? schedule.expr.trim() : ""; + const legacyCronRaw = typeof schedule.cron === "string" ? schedule.cron.trim() : ""; + const normalizedExpr = exprRaw || legacyCronRaw; const atMsRaw = schedule.atMs; const atRaw = schedule.at; const atString = typeof atRaw === "string" ? atRaw.trim() : ""; @@ -48,7 +51,7 @@ function coerceSchedule(schedule: UnknownRecord) { next.kind = "at"; } else if (typeof schedule.everyMs === "number") { next.kind = "every"; - } else if (typeof schedule.expr === "string") { + } else if (normalizedExpr) { next.kind = "cron"; } } @@ -62,6 +65,15 @@ function coerceSchedule(schedule: UnknownRecord) { delete next.atMs; } + if (normalizedExpr) { + next.expr = normalizedExpr; + } else if ("expr" in next) { + delete next.expr; + } + if ("cron" in next) { + delete next.cron; + } + const staggerMs = normalizeCronStaggerMs(schedule.staggerMs); if (staggerMs !== undefined) { next.staggerMs = staggerMs; diff --git a/src/cron/schedule.test.ts b/src/cron/schedule.test.ts index 1bea936b2..6c132e282 100644 --- a/src/cron/schedule.test.ts +++ b/src/cron/schedule.test.ts @@ -25,6 +25,19 @@ describe("cron schedule", () => { ).toThrow("invalid cron schedule: expr is required"); }); + it("supports legacy cron field when expr is missing", () => { + const nowMs = Date.parse("2025-12-13T00:00:00.000Z"); + const next = computeNextRunAtMs( + { + kind: "cron", + cron: "0 9 * * 3", + tz: "America/Los_Angeles", + } as unknown as { kind: "cron"; expr: string; tz?: string }, + nowMs, + ); + expect(next).toBe(Date.parse("2025-12-17T17:00:00.000Z")); + }); + it("computes next run for every schedule", () => { const anchor = Date.parse("2025-12-13T00:00:00.000Z"); const now = anchor + 10_000; diff --git a/src/cron/schedule.ts b/src/cron/schedule.ts index d80aaa440..bb7bdfc0e 100644 --- a/src/cron/schedule.ts +++ b/src/cron/schedule.ts @@ -41,7 +41,8 @@ export function computeNextRunAtMs(schedule: CronSchedule, nowMs: number): numbe return anchor + steps * everyMs; } - const exprSource = (schedule as { expr?: unknown }).expr; + const cronSchedule = schedule as { expr?: unknown; cron?: unknown }; + const exprSource = typeof cronSchedule.expr === "string" ? cronSchedule.expr : cronSchedule.cron; if (typeof exprSource !== "string") { throw new Error("invalid cron schedule: expr is required"); } diff --git a/src/cron/service.store-migration.test.ts b/src/cron/service.store-migration.test.ts index adaeec2b1..e25a0cd7c 100644 --- a/src/cron/service.store-migration.test.ts +++ b/src/cron/service.store-migration.test.ts @@ -148,4 +148,59 @@ describe("CronService store migrations", () => { cron.stop(); await store.cleanup(); }); + + it("migrates legacy cron fields (jobId + schedule.cron) and defaults wakeMode", async () => { + const store = await makeStorePath(); + await fs.mkdir(path.dirname(store.storePath), { recursive: true }); + await fs.writeFile( + store.storePath, + JSON.stringify( + { + version: 1, + jobs: [ + { + jobId: "legacy-cron-field-job", + name: "legacy cron field", + enabled: true, + createdAtMs: Date.parse("2026-02-01T12:00:00.000Z"), + updatedAtMs: Date.parse("2026-02-05T12:00:00.000Z"), + schedule: { kind: "cron", cron: "*/5 * * * *", tz: "UTC" }, + payload: { kind: "systemEvent", text: "tick" }, + state: {}, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const cron = await createStartedCron(store.storePath).start(); + const jobs = await cron.list({ includeDisabled: true }); + const job = jobs.find((entry) => entry.id === "legacy-cron-field-job"); + expect(job).toBeDefined(); + expect(job?.wakeMode).toBe("now"); + expect(job?.schedule.kind).toBe("cron"); + if (job?.schedule.kind === "cron") { + expect(job.schedule.expr).toBe("*/5 * * * *"); + } + + const persisted = JSON.parse(await fs.readFile(store.storePath, "utf-8")) as { + jobs: Array>; + }; + const persistedJob = persisted.jobs.find((entry) => entry.id === "legacy-cron-field-job"); + expect(persistedJob).toBeDefined(); + expect(persistedJob?.jobId).toBeUndefined(); + expect(persistedJob?.wakeMode).toBe("now"); + const persistedSchedule = + persistedJob?.schedule && typeof persistedJob.schedule === "object" + ? (persistedJob.schedule as Record) + : null; + expect(persistedSchedule?.cron).toBeUndefined(); + expect(persistedSchedule?.expr).toBe("*/5 * * * *"); + + cron.stop(); + await store.cleanup(); + }); }); diff --git a/src/cron/service/store.ts b/src/cron/service/store.ts index d15070864..843625244 100644 --- a/src/cron/service/store.ts +++ b/src/cron/service/store.ts @@ -248,6 +248,20 @@ export async function ensureLoaded( mutated = true; } + const rawId = typeof raw.id === "string" ? raw.id.trim() : ""; + const legacyJobId = typeof raw.jobId === "string" ? raw.jobId.trim() : ""; + if (!rawId && legacyJobId) { + raw.id = legacyJobId; + mutated = true; + } else if (rawId && raw.id !== rawId) { + raw.id = rawId; + mutated = true; + } + if ("jobId" in raw) { + delete raw.jobId; + mutated = true; + } + const nameRaw = raw.name; if (typeof nameRaw !== "string" || nameRaw.trim().length === 0) { raw.name = inferLegacyName({ @@ -279,6 +293,22 @@ export async function ensureLoaded( mutated = true; } + const wakeModeRaw = typeof raw.wakeMode === "string" ? raw.wakeMode.trim().toLowerCase() : ""; + if (wakeModeRaw === "next-heartbeat") { + if (raw.wakeMode !== "next-heartbeat") { + raw.wakeMode = "next-heartbeat"; + mutated = true; + } + } else if (wakeModeRaw === "now") { + if (raw.wakeMode !== "now") { + raw.wakeMode = "now"; + mutated = true; + } + } else { + raw.wakeMode = "now"; + mutated = true; + } + const payload = raw.payload; if ( (!payload || typeof payload !== "object" || Array.isArray(payload)) && @@ -383,13 +413,24 @@ export async function ensureLoaded( } const exprRaw = typeof sched.expr === "string" ? sched.expr.trim() : ""; - if (typeof sched.expr === "string" && sched.expr !== exprRaw) { - sched.expr = exprRaw; + const legacyCronRaw = typeof sched.cron === "string" ? sched.cron.trim() : ""; + let normalizedExpr = exprRaw; + if (!normalizedExpr && legacyCronRaw) { + normalizedExpr = legacyCronRaw; + sched.expr = normalizedExpr; mutated = true; } - if ((kind === "cron" || sched.kind === "cron") && exprRaw) { + if (typeof sched.expr === "string" && sched.expr !== normalizedExpr) { + sched.expr = normalizedExpr; + mutated = true; + } + if ("cron" in sched) { + delete sched.cron; + mutated = true; + } + if ((kind === "cron" || sched.kind === "cron") && normalizedExpr) { const explicitStaggerMs = normalizeCronStaggerMs(sched.staggerMs); - const defaultStaggerMs = resolveDefaultCronStaggerMs(exprRaw); + const defaultStaggerMs = resolveDefaultCronStaggerMs(normalizedExpr); const targetStaggerMs = explicitStaggerMs ?? defaultStaggerMs; if (targetStaggerMs === undefined) { if ("staggerMs" in sched) {