Files
Moltbot/src/cron/service.store-migration.test.ts
Sid 504c1f3607 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
2026-03-01 06:53:39 -06:00

207 lines
6.9 KiB
TypeScript

import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { CronService } from "./service.js";
import { setupCronServiceSuite } from "./service.test-harness.js";
const { logger: noopLogger, makeStorePath } = setupCronServiceSuite({
prefix: "openclaw-cron-",
baseTimeIso: "2026-02-06T17:00:00.000Z",
});
function createStartedCron(storePath: string) {
const cron = new CronService({
storePath,
cronEnabled: true,
log: noopLogger,
enqueueSystemEvent: vi.fn(),
requestHeartbeatNow: vi.fn(),
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const, summary: "ok" })),
});
return {
cron,
start: async () => {
await cron.start();
return cron;
},
};
}
describe("CronService store migrations", () => {
it("migrates legacy top-level agentTurn fields and initializes missing state", async () => {
const store = await makeStorePath();
await fs.mkdir(path.dirname(store.storePath), { recursive: true });
await fs.writeFile(
store.storePath,
JSON.stringify(
{
version: 1,
jobs: [
{
id: "legacy-agentturn-job",
name: "legacy agentturn",
enabled: true,
createdAtMs: Date.parse("2026-02-01T12:00:00.000Z"),
updatedAtMs: Date.parse("2026-02-05T12:00:00.000Z"),
schedule: { kind: "cron", expr: "0 23 * * *", tz: "UTC" },
sessionTarget: "isolated",
wakeMode: "next-heartbeat",
model: "openrouter/deepseek/deepseek-r1",
thinking: "high",
timeoutSeconds: 120,
allowUnsafeExternalContent: true,
deliver: true,
channel: "telegram",
to: "12345",
bestEffortDeliver: true,
payload: { kind: "agentTurn", message: "legacy payload fields" },
},
],
},
null,
2,
),
"utf-8",
);
const cron = await createStartedCron(store.storePath).start();
const status = await cron.status();
expect(status.enabled).toBe(true);
const jobs = await cron.list({ includeDisabled: true });
const job = jobs.find((entry) => entry.id === "legacy-agentturn-job");
expect(job).toBeDefined();
expect(job?.state).toBeDefined();
expect(job?.sessionTarget).toBe("isolated");
expect(job?.payload.kind).toBe("agentTurn");
if (job?.payload.kind === "agentTurn") {
expect(job.payload.model).toBe("openrouter/deepseek/deepseek-r1");
expect(job.payload.thinking).toBe("high");
expect(job.payload.timeoutSeconds).toBe(120);
expect(job.payload.allowUnsafeExternalContent).toBe(true);
}
expect(job?.delivery).toEqual({
mode: "announce",
channel: "telegram",
to: "12345",
bestEffort: true,
});
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-agentturn-job");
expect(persistedJob).toBeDefined();
expect(persistedJob?.state).toEqual(expect.any(Object));
expect(persistedJob?.model).toBeUndefined();
expect(persistedJob?.thinking).toBeUndefined();
expect(persistedJob?.timeoutSeconds).toBeUndefined();
expect(persistedJob?.deliver).toBeUndefined();
expect(persistedJob?.channel).toBeUndefined();
expect(persistedJob?.to).toBeUndefined();
expect(persistedJob?.bestEffortDeliver).toBeUndefined();
cron.stop();
await store.cleanup();
});
it("preserves legacy timeoutSeconds=0 during top-level agentTurn field migration", async () => {
const store = await makeStorePath();
await fs.mkdir(path.dirname(store.storePath), { recursive: true });
await fs.writeFile(
store.storePath,
JSON.stringify(
{
version: 1,
jobs: [
{
id: "legacy-agentturn-no-timeout",
name: "legacy no-timeout",
enabled: true,
createdAtMs: Date.parse("2026-02-01T12:00:00.000Z"),
updatedAtMs: Date.parse("2026-02-05T12:00:00.000Z"),
schedule: { kind: "cron", expr: "0 23 * * *", tz: "UTC" },
sessionTarget: "isolated",
wakeMode: "next-heartbeat",
timeoutSeconds: 0,
payload: { kind: "agentTurn", message: "legacy payload fields" },
},
],
},
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-agentturn-no-timeout");
expect(job).toBeDefined();
expect(job?.payload.kind).toBe("agentTurn");
if (job?.payload.kind === "agentTurn") {
expect(job.payload.timeoutSeconds).toBe(0);
}
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<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();
});
});