Unverified 提交 620f8145 authored 作者: Mohamed Aziz Mejri's avatar Mohamed Aziz Mejri 提交者: GitHub

Adding a cleanup routine for media files (#2842)

<!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/dyad-sh/dyad/pull/2842" target="_blank"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin"> </picture> </a> <!-- devin-review-badge-end -->
上级 6738f941
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
const fsMocks = vi.hoisted(() => {
return {
readdir: vi.fn(),
stat: vi.fn(),
unlink: vi.fn(),
};
});
const logMocks = vi.hoisted(() => {
return {
log: vi.fn(),
warn: vi.fn(),
};
});
const dbMocks = vi.hoisted(() => {
const mockFrom = vi.fn();
const mockSelect = vi.fn(() => ({ from: mockFrom }));
return { select: mockSelect, from: mockFrom };
});
vi.mock("node:fs/promises", () => ({
default: fsMocks,
...fsMocks,
}));
vi.mock("electron-log", () => ({
default: {
scope: vi.fn(() => logMocks),
},
}));
vi.mock("@/paths/paths", () => ({
getDyadAppPath: vi.fn((appPath: string) => {
const path = require("node:path");
if (path.isAbsolute(appPath)) return appPath;
return `/home/user/dyad-apps/${appPath}`;
}),
}));
vi.mock("@/db", () => ({
db: { select: dbMocks.select },
}));
vi.mock("@/db/schema", () => ({
apps: { path: "path" },
}));
vi.mock("@/ipc/utils/media_path_utils", () => ({
DYAD_MEDIA_DIR_NAME: ".dyad/media",
}));
import {
MEDIA_TTL_DAYS,
cleanupOldMediaFiles,
} from "@/ipc/utils/media_cleanup";
describe("cleanupOldMediaFiles", () => {
beforeEach(() => {
fsMocks.readdir.mockReset();
fsMocks.stat.mockReset();
fsMocks.unlink.mockReset();
logMocks.log.mockClear();
logMocks.warn.mockClear();
dbMocks.select.mockClear();
dbMocks.from.mockReset();
});
afterEach(() => {
vi.useRealTimers();
});
it("should use the expected TTL constant", () => {
expect(MEDIA_TTL_DAYS).toBe(30);
});
it("should delete files older than the cutoff date", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2025-01-31T00:00:00.000Z"));
const now = Date.now();
const oldMtimeMs = now - 31 * 24 * 60 * 60 * 1000;
const recentMtimeMs = now - 5 * 24 * 60 * 60 * 1000;
dbMocks.from.mockResolvedValue([{ path: "my-app" }]);
fsMocks.readdir.mockImplementation((dirPath: string) => {
if (dirPath === "/home/user/dyad-apps/my-app/.dyad/media") {
return Promise.resolve(["old-image.png", "recent-image.png"]);
}
return Promise.reject(new Error("ENOENT"));
});
fsMocks.stat.mockImplementation((filePath: string) => {
if (filePath.includes("old-image.png")) {
return Promise.resolve({ isFile: () => true, mtimeMs: oldMtimeMs });
}
if (filePath.includes("recent-image.png")) {
return Promise.resolve({
isFile: () => true,
mtimeMs: recentMtimeMs,
});
}
return Promise.reject(new Error("ENOENT"));
});
fsMocks.unlink.mockResolvedValue(undefined);
await cleanupOldMediaFiles();
expect(fsMocks.unlink).toHaveBeenCalledTimes(1);
expect(fsMocks.unlink).toHaveBeenCalledWith(
"/home/user/dyad-apps/my-app/.dyad/media/old-image.png",
);
expect(logMocks.log).toHaveBeenCalledWith("Cleaned up 1 old media files");
expect(logMocks.warn).not.toHaveBeenCalled();
});
it("should handle no apps in the database gracefully", async () => {
dbMocks.from.mockResolvedValue([]);
await expect(cleanupOldMediaFiles()).resolves.toBeUndefined();
expect(logMocks.log).toHaveBeenCalledWith("Cleaned up 0 old media files");
expect(logMocks.warn).not.toHaveBeenCalled();
});
it("should skip apps without .dyad/media directory", async () => {
dbMocks.from.mockResolvedValue([{ path: "app-no-media" }]);
fsMocks.readdir.mockRejectedValue(new Error("ENOENT"));
await cleanupOldMediaFiles();
expect(fsMocks.unlink).not.toHaveBeenCalled();
expect(logMocks.log).toHaveBeenCalledWith("Cleaned up 0 old media files");
});
it("should not throw if a per-file operation fails (logs a warning)", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2025-01-31T00:00:00.000Z"));
dbMocks.from.mockResolvedValue([{ path: "my-app" }]);
fsMocks.readdir.mockResolvedValue(["broken-file.png"]);
const statError = new Error("EPERM");
fsMocks.stat.mockRejectedValueOnce(statError);
await expect(cleanupOldMediaFiles()).resolves.toBeUndefined();
expect(logMocks.warn).toHaveBeenCalledTimes(1);
expect(logMocks.warn.mock.calls[0][0]).toContain(
"Failed to process media file",
);
expect(logMocks.warn.mock.calls[0][1]).toBe(statError);
});
it("should skip subdirectories inside .dyad/media", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2025-01-31T00:00:00.000Z"));
const oldMtimeMs = Date.now() - 31 * 24 * 60 * 60 * 1000;
dbMocks.from.mockResolvedValue([{ path: "my-app" }]);
fsMocks.readdir.mockResolvedValue(["some-subdir", "old-file.png"]);
fsMocks.stat.mockImplementation((filePath: string) => {
if (filePath.includes("some-subdir")) {
return Promise.resolve({ isFile: () => false, mtimeMs: oldMtimeMs });
}
return Promise.resolve({ isFile: () => true, mtimeMs: oldMtimeMs });
});
fsMocks.unlink.mockResolvedValue(undefined);
await cleanupOldMediaFiles();
expect(fsMocks.unlink).toHaveBeenCalledTimes(1);
expect(fsMocks.unlink).toHaveBeenCalledWith(
expect.stringContaining("old-file.png"),
);
});
it("should iterate over multiple apps", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2025-01-31T00:00:00.000Z"));
const oldMtimeMs = Date.now() - 31 * 24 * 60 * 60 * 1000;
dbMocks.from.mockResolvedValue([{ path: "app-1" }, { path: "app-2" }]);
fsMocks.readdir.mockResolvedValue(["old.png"]);
fsMocks.stat.mockResolvedValue({ isFile: () => true, mtimeMs: oldMtimeMs });
fsMocks.unlink.mockResolvedValue(undefined);
await cleanupOldMediaFiles();
expect(fsMocks.unlink).toHaveBeenCalledTimes(2);
expect(logMocks.log).toHaveBeenCalledWith("Cleaned up 2 old media files");
});
it("should handle apps with absolute paths (skipCopy imports)", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2025-01-31T00:00:00.000Z"));
const oldMtimeMs = Date.now() - 31 * 24 * 60 * 60 * 1000;
dbMocks.from.mockResolvedValue([
{ path: "/external/projects/my-imported-app" },
]);
fsMocks.readdir.mockImplementation((dirPath: string) => {
if (dirPath === "/external/projects/my-imported-app/.dyad/media") {
return Promise.resolve(["old-image.png"]);
}
return Promise.reject(new Error("ENOENT"));
});
fsMocks.stat.mockResolvedValue({ isFile: () => true, mtimeMs: oldMtimeMs });
fsMocks.unlink.mockResolvedValue(undefined);
await cleanupOldMediaFiles();
expect(fsMocks.unlink).toHaveBeenCalledTimes(1);
expect(fsMocks.unlink).toHaveBeenCalledWith(
"/external/projects/my-imported-app/.dyad/media/old-image.png",
);
});
});
import log from "electron-log";
import fs from "node:fs/promises";
import path from "node:path";
import { getDyadAppPath } from "@/paths/paths";
import { DYAD_MEDIA_DIR_NAME } from "@/ipc/utils/media_path_utils";
import { db } from "@/db";
import { apps } from "@/db/schema";
const logger = log.scope("media_cleanup");
export const MEDIA_TTL_DAYS = 30;
/**
* Delete media files older than TTL from all app .dyad/media directories.
* Run on app startup to reclaim disk space.
*/
export async function cleanupOldMediaFiles(): Promise<void> {
const cutoffMs = Date.now() - MEDIA_TTL_DAYS * 24 * 60 * 60 * 1000;
try {
const allApps = await db.select({ path: apps.path }).from(apps);
const counts = await Promise.all(
allApps.map(async (app) => {
const mediaDir = path.join(
getDyadAppPath(app.path),
DYAD_MEDIA_DIR_NAME,
);
let files: string[];
try {
files = await fs.readdir(mediaDir);
} catch {
return 0;
}
const results = await Promise.all(
files.map(async (file) => {
const filePath = path.join(mediaDir, file);
try {
const stat = await fs.stat(filePath);
if (!stat.isFile()) {
return 0;
}
if (stat.mtimeMs < cutoffMs) {
await fs.unlink(filePath);
return 1;
}
} catch (err) {
logger.warn(`Failed to process media file ${filePath}:`, err);
}
return 0;
}),
);
return results.reduce<number>((sum, n) => sum + n, 0);
}),
);
const totalDeleted = counts.reduce<number>((sum, n) => sum + n, 0);
logger.log(`Cleaned up ${totalDeleted} old media files`);
} catch (err) {
logger.warn("Failed to cleanup old media files:", err);
}
}
......@@ -33,6 +33,7 @@ import {
stopAppGarbageCollection,
} from "./ipc/utils/process_manager";
import { cleanupOldAiMessagesJson } from "./pro/main/ipc/handlers/local_agent/ai_messages_cleanup";
import { cleanupOldMediaFiles } from "./ipc/utils/media_cleanup";
import fs from "fs";
import { gitAddSafeDirectory } from "./ipc/utils/git_utils";
import { getDyadAppsBaseDirectory, getDyadAppPath } from "./paths/paths";
......@@ -100,6 +101,9 @@ export async function onReady() {
// Cleanup old ai_messages_json entries to prevent database bloat
cleanupOldAiMessagesJson();
// Cleanup old media files to reclaim disk space
cleanupOldMediaFiles();
const settings = readSettings();
// Add dyad-apps directory to git safe.directory (required for Windows).
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论