mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 09:28:37 +00:00
fix(cron): migrate legacy schedule cron fields on load (#28889)
Backfill legacy jobs that still use schedule.cron and jobId so upgraded instances keep firing existing cron schedules instead of failing silently. Closes #28861
This commit is contained in:
@@ -138,6 +138,25 @@ describe("normalizeCronJobCreate", () => {
|
|||||||
expectNormalizedAtSchedule({ kind: "at", atMs: "2026-01-12T18:00:00" });
|
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<string, unknown>;
|
||||||
|
|
||||||
|
const schedule = normalized.schedule as Record<string, unknown>;
|
||||||
|
expect(schedule.kind).toBe("cron");
|
||||||
|
expect(schedule.expr).toBe("*/10 * * * *");
|
||||||
|
expect(schedule.cron).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("defaults cron stagger for recurring top-of-hour schedules", () => {
|
it("defaults cron stagger for recurring top-of-hour schedules", () => {
|
||||||
const normalized = normalizeCronJobCreate({
|
const normalized = normalizeCronJobCreate({
|
||||||
name: "hourly",
|
name: "hourly",
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ function coerceSchedule(schedule: UnknownRecord) {
|
|||||||
const next: UnknownRecord = { ...schedule };
|
const next: UnknownRecord = { ...schedule };
|
||||||
const rawKind = typeof schedule.kind === "string" ? schedule.kind.trim().toLowerCase() : "";
|
const rawKind = typeof schedule.kind === "string" ? schedule.kind.trim().toLowerCase() : "";
|
||||||
const kind = rawKind === "at" || rawKind === "every" || rawKind === "cron" ? rawKind : undefined;
|
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 atMsRaw = schedule.atMs;
|
||||||
const atRaw = schedule.at;
|
const atRaw = schedule.at;
|
||||||
const atString = typeof atRaw === "string" ? atRaw.trim() : "";
|
const atString = typeof atRaw === "string" ? atRaw.trim() : "";
|
||||||
@@ -48,7 +51,7 @@ function coerceSchedule(schedule: UnknownRecord) {
|
|||||||
next.kind = "at";
|
next.kind = "at";
|
||||||
} else if (typeof schedule.everyMs === "number") {
|
} else if (typeof schedule.everyMs === "number") {
|
||||||
next.kind = "every";
|
next.kind = "every";
|
||||||
} else if (typeof schedule.expr === "string") {
|
} else if (normalizedExpr) {
|
||||||
next.kind = "cron";
|
next.kind = "cron";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -62,6 +65,15 @@ function coerceSchedule(schedule: UnknownRecord) {
|
|||||||
delete next.atMs;
|
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);
|
const staggerMs = normalizeCronStaggerMs(schedule.staggerMs);
|
||||||
if (staggerMs !== undefined) {
|
if (staggerMs !== undefined) {
|
||||||
next.staggerMs = staggerMs;
|
next.staggerMs = staggerMs;
|
||||||
|
|||||||
@@ -25,6 +25,19 @@ describe("cron schedule", () => {
|
|||||||
).toThrow("invalid cron schedule: expr is required");
|
).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", () => {
|
it("computes next run for every schedule", () => {
|
||||||
const anchor = Date.parse("2025-12-13T00:00:00.000Z");
|
const anchor = Date.parse("2025-12-13T00:00:00.000Z");
|
||||||
const now = anchor + 10_000;
|
const now = anchor + 10_000;
|
||||||
|
|||||||
@@ -41,7 +41,8 @@ export function computeNextRunAtMs(schedule: CronSchedule, nowMs: number): numbe
|
|||||||
return anchor + steps * everyMs;
|
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") {
|
if (typeof exprSource !== "string") {
|
||||||
throw new Error("invalid cron schedule: expr is required");
|
throw new Error("invalid cron schedule: expr is required");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,4 +148,59 @@ describe("CronService store migrations", () => {
|
|||||||
cron.stop();
|
cron.stop();
|
||||||
await store.cleanup();
|
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<Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
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<string, unknown>)
|
||||||
|
: null;
|
||||||
|
expect(persistedSchedule?.cron).toBeUndefined();
|
||||||
|
expect(persistedSchedule?.expr).toBe("*/5 * * * *");
|
||||||
|
|
||||||
|
cron.stop();
|
||||||
|
await store.cleanup();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -248,6 +248,20 @@ export async function ensureLoaded(
|
|||||||
mutated = true;
|
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;
|
const nameRaw = raw.name;
|
||||||
if (typeof nameRaw !== "string" || nameRaw.trim().length === 0) {
|
if (typeof nameRaw !== "string" || nameRaw.trim().length === 0) {
|
||||||
raw.name = inferLegacyName({
|
raw.name = inferLegacyName({
|
||||||
@@ -279,6 +293,22 @@ export async function ensureLoaded(
|
|||||||
mutated = true;
|
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;
|
const payload = raw.payload;
|
||||||
if (
|
if (
|
||||||
(!payload || typeof payload !== "object" || Array.isArray(payload)) &&
|
(!payload || typeof payload !== "object" || Array.isArray(payload)) &&
|
||||||
@@ -383,13 +413,24 @@ export async function ensureLoaded(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const exprRaw = typeof sched.expr === "string" ? sched.expr.trim() : "";
|
const exprRaw = typeof sched.expr === "string" ? sched.expr.trim() : "";
|
||||||
if (typeof sched.expr === "string" && sched.expr !== exprRaw) {
|
const legacyCronRaw = typeof sched.cron === "string" ? sched.cron.trim() : "";
|
||||||
sched.expr = exprRaw;
|
let normalizedExpr = exprRaw;
|
||||||
|
if (!normalizedExpr && legacyCronRaw) {
|
||||||
|
normalizedExpr = legacyCronRaw;
|
||||||
|
sched.expr = normalizedExpr;
|
||||||
mutated = true;
|
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 explicitStaggerMs = normalizeCronStaggerMs(sched.staggerMs);
|
||||||
const defaultStaggerMs = resolveDefaultCronStaggerMs(exprRaw);
|
const defaultStaggerMs = resolveDefaultCronStaggerMs(normalizedExpr);
|
||||||
const targetStaggerMs = explicitStaggerMs ?? defaultStaggerMs;
|
const targetStaggerMs = explicitStaggerMs ?? defaultStaggerMs;
|
||||||
if (targetStaggerMs === undefined) {
|
if (targetStaggerMs === undefined) {
|
||||||
if ("staggerMs" in sched) {
|
if ("staggerMs" in sched) {
|
||||||
|
|||||||
Reference in New Issue
Block a user