From c7d6c65130146493c7080bb5659bce131784e6b9 Mon Sep 17 00:00:00 2001 From: m4 Date: Tue, 11 Aug 2026 11:00:52 +0800 Subject: [PATCH] feat(webui): scheduled backups via instrumentation-started interval Co-Authored-By: Claude Opus 4.7 --- src/instrumentation.ts | 6 +++ src/lib/server/backupScheduler.test.ts | 60 ++++++++++++++++++++++++++ src/lib/server/backupScheduler.ts | 55 +++++++++++++++++++++++ 3 files changed, 121 insertions(+) create mode 100644 src/instrumentation.ts create mode 100644 src/lib/server/backupScheduler.test.ts create mode 100644 src/lib/server/backupScheduler.ts diff --git a/src/instrumentation.ts b/src/instrumentation.ts new file mode 100644 index 0000000..588fb62 --- /dev/null +++ b/src/instrumentation.ts @@ -0,0 +1,6 @@ +export async function register(): Promise { + if (process.env.NEXT_RUNTIME === "nodejs") { + const { startBackupScheduler } = await import("./lib/server/backupScheduler"); + startBackupScheduler(); + } +} diff --git a/src/lib/server/backupScheduler.test.ts b/src/lib/server/backupScheduler.test.ts new file mode 100644 index 0000000..a3bcfde --- /dev/null +++ b/src/lib/server/backupScheduler.test.ts @@ -0,0 +1,60 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("server-only", () => ({})); + +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "evosci-sched-")); +const originalDataDir = process.env.EVOSCIENTIST_DATA_DIR; +process.env.EVOSCIENTIST_DATA_DIR = dataDir; + +const scheduler = await import("./backupScheduler"); +const { listBackups, backupsDir } = await import("./backup"); +const { getSystemConfig, saveSystemConfig, resetSystemConfigCacheForTests } = + await import("./systemConfig"); + +const HOUR = 3600_000; +let now = 1_800_000_000_000; + +describe("backupScheduler.tickBackupSchedule", () => { + beforeEach(() => { + fs.rmSync(backupsDir(), { recursive: true, force: true }); + scheduler.stopBackupSchedulerForTests(); + const config = getSystemConfig(); + config.backup.backendDataDir = ""; + config.backup.schedule = { enabled: true, intervalHours: 1, keepCount: 2 }; + saveSystemConfig(config); + resetSystemConfigCacheForTests(); + }); + + afterAll(() => { + if (originalDataDir === undefined) delete process.env.EVOSCIENTIST_DATA_DIR; + else process.env.EVOSCIENTIST_DATA_DIR = originalDataDir; + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + it("runs when due, skips when not due, and prunes", async () => { + await scheduler.tickBackupSchedule(now); + expect(listBackups()).toHaveLength(1); + + await scheduler.tickBackupSchedule(now + 30 * 60_000); // 30 min later: not due + expect(listBackups()).toHaveLength(1); + + await scheduler.tickBackupSchedule(now + HOUR); + expect(listBackups()).toHaveLength(2); + + await scheduler.tickBackupSchedule(now + 2 * HOUR); // creates 3rd, prunes to 2 + expect(listBackups()).toHaveLength(2); + expect(listBackups()[0].origin).toBe("auto"); + }); + + it("does nothing when disabled", async () => { + const config = getSystemConfig(); + config.backup.schedule.enabled = false; + saveSystemConfig(config); + resetSystemConfigCacheForTests(); + await scheduler.tickBackupSchedule(now + 10 * HOUR); + expect(listBackups()).toHaveLength(0); + }); +}); diff --git a/src/lib/server/backupScheduler.ts b/src/lib/server/backupScheduler.ts new file mode 100644 index 0000000..50d9e7f --- /dev/null +++ b/src/lib/server/backupScheduler.ts @@ -0,0 +1,55 @@ +import "server-only"; + +import { createBackup, pruneBackups } from "./backup"; +import { getSystemConfig } from "./systemConfig"; +import { appendErrorLog } from "./errorLogStore"; + +const TICK_MS = 60_000; + +const globalState = globalThis as { + __evoscientistBackupTimer?: NodeJS.Timeout; + __evoscientistBackupLastRunAt?: number; +}; + +/** One scheduler tick: run an auto-backup when the schedule says one is due, + * then prune to the configured keep count. Never throws — the interval must + * survive individual failures. */ +export async function tickBackupSchedule(now = Date.now()): Promise { + const schedule = getSystemConfig().backup.schedule; + if (!schedule.enabled) return; + const lastRunAt = globalState.__evoscientistBackupLastRunAt ?? 0; + if (now - lastRunAt < schedule.intervalHours * 3_600_000) return; + try { + await createBackup("auto"); + pruneBackups(schedule.keepCount); + globalState.__evoscientistBackupLastRunAt = now; + } catch (error) { + try { + appendErrorLog("system", { + source: "backup", + code: null, + message: + error instanceof Error ? error.message : "Scheduled backup failed.", + }); + } catch { + // Logging must never mask scheduler liveness. + } + } +} + +export function startBackupScheduler(): void { + if (globalState.__evoscientistBackupTimer) return; + const timer = setInterval(() => { + void tickBackupSchedule(); + }, TICK_MS); + timer.unref(); + globalState.__evoscientistBackupTimer = timer; +} + +export function stopBackupSchedulerForTests(): void { + if (globalState.__evoscientistBackupTimer) { + clearInterval(globalState.__evoscientistBackupTimer); + } + globalState.__evoscientistBackupTimer = undefined; + globalState.__evoscientistBackupLastRunAt = undefined; +}