feat(webui): scheduled backups via instrumentation-started interval

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
m4
2026-08-11 11:00:52 +08:00
parent 6b009e2c32
commit c7d6c65130
3 changed files with 121 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
export async function register(): Promise<void> {
if (process.env.NEXT_RUNTIME === "nodejs") {
const { startBackupScheduler } = await import("./lib/server/backupScheduler");
startBackupScheduler();
}
}
+60
View File
@@ -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);
});
});
+55
View File
@@ -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<void> {
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;
}