From 578496360839b9f52dd05579a5a4d677c8b453e9 Mon Sep 17 00:00:00 2001 From: Pierre Date: Sun, 1 Mar 2026 05:10:53 -0800 Subject: [PATCH] fix cron store backup churn (#19484) --- src/cron/store.test.ts | 50 +++++++++++++++++++++++++++++++++++++++++- src/cron/store.ts | 25 ++++++++++++++++----- 2 files changed, 68 insertions(+), 7 deletions(-) diff --git a/src/cron/store.test.ts b/src/cron/store.test.ts index ff32262c324..5a0cff0cc67 100644 --- a/src/cron/store.test.ts +++ b/src/cron/store.test.ts @@ -2,7 +2,8 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { loadCronStore, resolveCronStorePath } from "./store.js"; +import { loadCronStore, resolveCronStorePath, saveCronStore } from "./store.js"; +import type { CronStoreFile } from "./types.js"; async function makeStorePath() { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cron-store-")); @@ -15,6 +16,27 @@ async function makeStorePath() { }; } +function makeStore(jobId: string, enabled: boolean): CronStoreFile { + const now = Date.now(); + return { + version: 1, + jobs: [ + { + id: jobId, + name: `Job ${jobId}`, + enabled, + createdAtMs: now, + updatedAtMs: now, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "main", + wakeMode: "next-heartbeat", + payload: { kind: "systemEvent", text: `tick-${jobId}` }, + state: {}, + }, + ], + }; +} + describe("resolveCronStorePath", () => { afterEach(() => { vi.unstubAllEnvs(); @@ -43,4 +65,30 @@ describe("cron store", () => { await expect(loadCronStore(store.storePath)).rejects.toThrow(/Failed to parse cron store/i); await store.cleanup(); }); + + it("does not create a backup file when saving unchanged content", async () => { + const store = await makeStorePath(); + const payload = makeStore("job-1", true); + + await saveCronStore(store.storePath, payload); + await saveCronStore(store.storePath, payload); + + await expect(fs.stat(`${store.storePath}.bak`)).rejects.toThrow(); + await store.cleanup(); + }); + + it("backs up previous content before replacing the store", async () => { + const store = await makeStorePath(); + const first = makeStore("job-1", true); + const second = makeStore("job-2", false); + + await saveCronStore(store.storePath, first); + await saveCronStore(store.storePath, second); + + const currentRaw = await fs.readFile(store.storePath, "utf-8"); + const backupRaw = await fs.readFile(`${store.storePath}.bak`, "utf-8"); + expect(JSON.parse(currentRaw)).toEqual(second); + expect(JSON.parse(backupRaw)).toEqual(first); + await store.cleanup(); + }); }); diff --git a/src/cron/store.ts b/src/cron/store.ts index 68f2e225cc6..2a460f6602b 100644 --- a/src/cron/store.ts +++ b/src/cron/store.ts @@ -50,13 +50,26 @@ export async function loadCronStore(storePath: string): Promise { export async function saveCronStore(storePath: string, store: CronStoreFile) { await fs.promises.mkdir(path.dirname(storePath), { recursive: true }); const { randomBytes } = await import("node:crypto"); - const tmp = `${storePath}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`; const json = JSON.stringify(store, null, 2); - await fs.promises.writeFile(tmp, json, "utf-8"); - await fs.promises.rename(tmp, storePath); + let previous: string | null = null; try { - await fs.promises.copyFile(storePath, `${storePath}.bak`); - } catch { - // best-effort + previous = await fs.promises.readFile(storePath, "utf-8"); + } catch (err) { + if ((err as { code?: unknown }).code !== "ENOENT") { + throw err; + } } + if (previous === json) { + return; + } + const tmp = `${storePath}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`; + await fs.promises.writeFile(tmp, json, "utf-8"); + if (previous !== null) { + try { + await fs.promises.copyFile(storePath, `${storePath}.bak`); + } catch { + // best-effort + } + } + await fs.promises.rename(tmp, storePath); }