Files
Moltbot/src/cron/service.store.migration.test.ts
2026-03-03 01:54:27 +00:00

229 lines
6.9 KiB
TypeScript

import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CronService } from "./service.js";
import { createCronStoreHarness, createNoopLogger } from "./service.test-harness.js";
import { DEFAULT_TOP_OF_HOUR_STAGGER_MS } from "./stagger.js";
import { loadCronStore } from "./store.js";
const noopLogger = createNoopLogger();
const { makeStorePath } = createCronStoreHarness({ prefix: "openclaw-cron-migrate-" });
async function writeLegacyStore(storePath: string, legacyJob: Record<string, unknown>) {
await fs.mkdir(path.dirname(storePath), { recursive: true });
await fs.writeFile(storePath, JSON.stringify({ version: 1, jobs: [legacyJob] }, null, 2));
}
async function migrateAndLoadFirstJob(storePath: string): Promise<Record<string, unknown>> {
const cron = new CronService({
storePath,
cronEnabled: true,
log: noopLogger,
enqueueSystemEvent: vi.fn(),
requestHeartbeatNow: vi.fn(),
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })),
});
await cron.start();
cron.stop();
const loaded = await loadCronStore(storePath);
return loaded.jobs[0] as Record<string, unknown>;
}
function makeLegacyJob(overrides: Record<string, unknown>): Record<string, unknown> {
return {
id: "job-legacy",
agentId: undefined,
name: "Legacy job",
description: null,
enabled: true,
deleteAfterRun: false,
createdAtMs: 1_700_000_000_000,
updatedAtMs: 1_700_000_000_000,
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: {
kind: "systemEvent",
text: "tick",
},
state: {},
...overrides,
};
}
async function migrateLegacyJob(legacyJob: Record<string, unknown>) {
const store = await makeStorePath();
try {
await writeLegacyStore(store.storePath, legacyJob);
return await migrateAndLoadFirstJob(store.storePath);
} finally {
await store.cleanup();
}
}
async function expectDefaultCronStaggerForLegacySchedule(params: {
id: string;
name: string;
expr: string;
}) {
const createdAtMs = 1_700_000_000_000;
const migrated = await migrateLegacyJob(
makeLegacyJob({
id: params.id,
name: params.name,
createdAtMs,
updatedAtMs: createdAtMs,
schedule: { kind: "cron", expr: params.expr, tz: "UTC" },
}),
);
const schedule = migrated.schedule as Record<string, unknown>;
expect(schedule.kind).toBe("cron");
expect(schedule.staggerMs).toBe(DEFAULT_TOP_OF_HOUR_STAGGER_MS);
}
describe("cron store migration", () => {
beforeEach(() => {
noopLogger.debug.mockClear();
noopLogger.info.mockClear();
noopLogger.warn.mockClear();
noopLogger.error.mockClear();
});
afterEach(() => {
vi.useRealTimers();
});
it("migrates isolated jobs to announce delivery and drops isolation", async () => {
const atMs = 1_700_000_000_000;
const migrated = await migrateLegacyJob(
makeLegacyJob({
id: "job-1",
sessionKey: " agent:main:discord:channel:ops ",
schedule: { kind: "at", atMs },
sessionTarget: "isolated",
payload: {
kind: "agentTurn",
message: "hi",
deliver: true,
channel: "telegram",
to: "7200373102",
bestEffortDeliver: true,
},
isolation: { postToMainPrefix: "Cron" },
}),
);
expect(migrated.sessionKey).toBe("agent:main:discord:channel:ops");
expect(migrated.delivery).toEqual({
mode: "announce",
channel: "telegram",
to: "7200373102",
bestEffort: true,
});
expect("isolation" in migrated).toBe(false);
const payload = migrated.payload as Record<string, unknown>;
expect(payload.deliver).toBeUndefined();
expect(payload.channel).toBeUndefined();
expect(payload.to).toBeUndefined();
expect(payload.bestEffortDeliver).toBeUndefined();
const schedule = migrated.schedule as Record<string, unknown>;
expect(schedule.kind).toBe("at");
expect(schedule.at).toBe(new Date(atMs).toISOString());
});
it("adds anchorMs to legacy every schedules", async () => {
const createdAtMs = 1_700_000_000_000;
const migrated = await migrateLegacyJob(
makeLegacyJob({
id: "job-every-legacy",
name: "Legacy every",
createdAtMs,
updatedAtMs: createdAtMs,
schedule: { kind: "every", everyMs: 120_000 },
}),
);
const schedule = migrated.schedule as Record<string, unknown>;
expect(schedule.kind).toBe("every");
expect(schedule.anchorMs).toBe(createdAtMs);
});
it("adds default staggerMs to legacy recurring top-of-hour cron schedules", async () => {
await expectDefaultCronStaggerForLegacySchedule({
id: "job-cron-legacy",
name: "Legacy cron",
expr: "0 */2 * * *",
});
});
it("adds default staggerMs to legacy 6-field top-of-hour cron schedules", async () => {
await expectDefaultCronStaggerForLegacySchedule({
id: "job-cron-seconds-legacy",
name: "Legacy cron seconds",
expr: "0 0 */3 * * *",
});
});
it("removes invalid legacy staggerMs from non top-of-hour cron schedules", async () => {
const migrated = await migrateLegacyJob(
makeLegacyJob({
id: "job-cron-minute-legacy",
name: "Legacy minute cron",
schedule: {
kind: "cron",
expr: "17 * * * *",
tz: "UTC",
staggerMs: "bogus",
},
}),
);
const schedule = migrated.schedule as Record<string, unknown>;
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<string, unknown>;
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();
}
});
});