diff --git a/change-logs/2026/06/11/fix-deleted-project-folder-empty-board.md b/change-logs/2026/06/11/fix-deleted-project-folder-empty-board.md new file mode 100644 index 00000000..22448955 --- /dev/null +++ b/change-logs/2026/06/11/fix-deleted-project-folder-empty-board.md @@ -0,0 +1 @@ +Fixed the dashboard showing an empty project list when one project's folder was deleted from disk — config resolution for a missing folder rejected the whole getProjects call. Each project now resolves independently and a broken one falls back to its stored settings. Also added daily projects.json backups (~/.dev3.0/projects-YYYY-MM-DD.json.bak, kept 7 days), written on startup and before each save. diff --git a/src/bun/__tests__/data-projects-backup.test.ts b/src/bun/__tests__/data-projects-backup.test.ts new file mode 100644 index 00000000..dc355f35 --- /dev/null +++ b/src/bun/__tests__/data-projects-backup.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync, existsSync } from "node:fs"; +import type { Project } from "../../shared/types"; + +const TEST_HOME = "/tmp/dev3-test-projects-backup"; + +vi.mock("../logger", () => ({ + createLogger: () => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), +})); + +vi.mock("../paths", () => ({ + DEV3_HOME: "/tmp/dev3-test-projects-backup", +})); + +vi.mock("../cow-clone", () => ({ + detectClonePaths: vi.fn(() => Promise.resolve([])), +})); + +vi.mock("../file-lock", () => ({ + withFileLock: async (_filePath: string, fn: () => Promise): Promise => fn(), +})); + +beforeEach(() => { + rmSync(TEST_HOME, { recursive: true, force: true }); + mkdirSync(TEST_HOME, { recursive: true }); +}); + +import { addProject, backupProjectsDaily, updateProject } from "../data"; + +const PROJECTS_FILE = `${TEST_HOME}/projects.json`; + +function todayBackupFile(): string { + return `${TEST_HOME}/projects-${new Date().toISOString().slice(0, 10)}.json.bak`; +} + +function listBackups(): string[] { + return readdirSync(TEST_HOME) + .filter((f) => /^projects-\d{4}-\d{2}-\d{2}\.json\.bak$/.test(f)) + .sort(); +} + +function seedProjects(projects: Partial[]): void { + writeFileSync(PROJECTS_FILE, JSON.stringify(projects, null, 2)); +} + +describe("daily projects.json backups", () => { + it("writes a daily .bak with the pre-save content on first save of the day", async () => { + seedProjects([{ id: "old", name: "Old", path: "/tmp/old" } as Project]); + + await addProject("/tmp/new-repo", "New Repo"); + + const backup = todayBackupFile(); + expect(existsSync(backup)).toBe(true); + const backedUp = JSON.parse(readFileSync(backup, "utf-8")); + expect(backedUp).toHaveLength(1); + expect(backedUp[0].id).toBe("old"); + }); + + it("does not overwrite the same day's backup on subsequent saves", async () => { + seedProjects([{ id: "old", name: "Old", path: "/tmp/old" } as Project]); + + const added = await addProject("/tmp/new-repo", "New Repo"); + await updateProject(added.id, { devScript: "bun run dev" }); + + const backedUp = JSON.parse(readFileSync(todayBackupFile(), "utf-8")); + expect(backedUp).toHaveLength(1); + expect(backedUp[0].id).toBe("old"); + }); + + it("skips backup when there is no projects.json yet", async () => { + await addProject("/tmp/first-repo", "First Repo"); + + expect(listBackups()).toHaveLength(0); + }); + + it("prunes backups older than 7 days, keeping the newest 7", async () => { + seedProjects([{ id: "old", name: "Old", path: "/tmp/old" } as Project]); + for (let i = 1; i <= 9; i++) { + writeFileSync(`${TEST_HOME}/projects-2026-05-0${i}.json.bak`, "[]"); + } + + await addProject("/tmp/new-repo", "New Repo"); + + const backups = listBackups(); + expect(backups).toHaveLength(7); + expect(backups).not.toContain("projects-2026-05-01.json.bak"); + expect(backups).not.toContain("projects-2026-05-02.json.bak"); + expect(backups).not.toContain("projects-2026-05-03.json.bak"); + expect(backups[backups.length - 1]).toBe(todayBackupFile().split("/").pop()); + }); + + it("backupProjectsDaily can be called standalone (startup hook)", async () => { + seedProjects([{ id: "p", name: "P", path: "/tmp/p" } as Project]); + + await backupProjectsDaily(); + + expect(existsSync(todayBackupFile())).toBe(true); + }); +}); diff --git a/src/bun/__tests__/repo-config.test.ts b/src/bun/__tests__/repo-config.test.ts index 3f50aadf..3bf2b839 100644 --- a/src/bun/__tests__/repo-config.test.ts +++ b/src/bun/__tests__/repo-config.test.ts @@ -397,6 +397,36 @@ describe("migrateProjectConfig", () => { expect(existsSync(join(TEST_DIR, ".dev3", "config.json"))).toBe(false); }); + + it("does not recreate a deleted project folder", async () => { + const missingPath = join(TEST_DIR, "deleted-project"); + const project = makeProject({ path: missingPath, setupScript: "bun install" }); + + await migrateProjectConfig(project); + + expect(existsSync(missingPath)).toBe(false); + }); +}); + +describe("resolveProjectConfig — deleted project folder resilience", () => { + it("resolves with fallback compare ref when detection fails on an existing path", async () => { + detectDefaultCompareRef.mockRejectedValue(new Error("spawn failed")); + + const project = makeProject({ defaultCompareRef: undefined }); + const resolved = await resolveProjectConfig(project); + + expect(resolved.defaultCompareRef).toBe("main"); + }); + + it("skips compare-ref detection entirely when the project folder is missing", async () => { + detectDefaultCompareRef.mockRejectedValue(new Error("ENOENT: no such cwd")); + + const project = makeProject({ path: join(TEST_DIR, "gone"), defaultCompareRef: undefined }); + const resolved = await resolveProjectConfig(project); + + expect(detectDefaultCompareRef).not.toHaveBeenCalled(); + expect(resolved.defaultCompareRef).toBe("main"); + }); }); describe("hasRepoConfig", () => { diff --git a/src/bun/__tests__/rpc-handlers.test.ts b/src/bun/__tests__/rpc-handlers.test.ts index 44d57dc5..a151fe85 100644 --- a/src/bun/__tests__/rpc-handlers.test.ts +++ b/src/bun/__tests__/rpc-handlers.test.ts @@ -939,6 +939,13 @@ describe("end-to-end: task description → shell command escaping", () => { describe("handlers.getProjects", () => { beforeEach(() => vi.clearAllMocks()); + // Restore module-level mock defaults — overridden implementations would + // otherwise leak into later describes (clearAllMocks keeps implementations). + afterEach(() => { + vi.mocked(repoConfig.resolveProjectConfig).mockImplementation((async (project: any) => project) as any); + vi.mocked(repoConfig.migrateProjectConfig).mockReset(); + }); + it("returns projects from data layer", async () => { const projects = [makeProject(), makeProject({ id: "proj-2", name: "Second" })]; vi.mocked(data.loadProjects).mockResolvedValue(projects); @@ -953,6 +960,28 @@ describe("handlers.getProjects", () => { const result = await handlers.getProjects(); expect(result).toEqual([]); }); + + it("still returns every project when config resolution fails for one (deleted folder)", async () => { + const ok = makeProject(); + const broken = makeProject({ id: "proj-broken", path: "/tmp/deleted-from-disk" }); + vi.mocked(data.loadProjects).mockResolvedValue([ok, broken]); + vi.mocked(repoConfig.resolveProjectConfig).mockImplementation(async (project: any) => { + if (project.id === "proj-broken") throw new Error("ENOENT: no such file or directory, posix_spawn"); + return project; + }); + + const result = await handlers.getProjects(); + expect(result.map((p) => p.id)).toEqual([ok.id, "proj-broken"]); + }); + + it("does not let a failing config migration drop the project list", async () => { + const project = makeProject(); + vi.mocked(data.loadProjects).mockResolvedValue([project]); + vi.mocked(repoConfig.migrateProjectConfig).mockRejectedValue(new Error("disk gone")); + + const result = await handlers.getProjects(); + expect(result.map((p) => p.id)).toEqual([project.id]); + }); }); describe("handlers.reorderProjects", () => { diff --git a/src/bun/data.ts b/src/bun/data.ts index 44d6f4e6..674e09f7 100644 --- a/src/bun/data.ts +++ b/src/bun/data.ts @@ -10,6 +10,8 @@ import { projectSlug } from "./git"; const log = createLogger("data"); const PROJECTS_FILE = `${DEV3_HOME}/projects.json`; +const PROJECTS_BACKUP_RETENTION_DAYS = 7; +const PROJECTS_BACKUP_FILE_PATTERN = /^projects-\d{4}-\d{2}-\d{2}\.json\.bak$/; const TASK_BACKUPS_DIR = "tasks-backups"; const TASK_BACKUP_RETENTION_HOURS = 72; const TASK_BACKUP_FILE_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}Z\.json$/; @@ -177,11 +179,46 @@ async function rawLoadAllProjects(options?: { strict?: boolean; persistMigration async function rawSaveProjects(projects: Project[]): Promise { log.debug("Saving projects", { count: projects.length, file: PROJECTS_FILE }); await ensureDir(PROJECTS_FILE); + await backupProjectsDaily().catch((err) => { + log.warn("Failed to write daily projects backup (non-fatal)", { err }); + }); await writeFile(PROJECTS_FILE, JSON.stringify(projects, null, 2)); projectsCache.delete(PROJECTS_FILE); log.info(`Saved ${projects.length} project(s)`); } +/** + * Snapshot projects.json to projects-YYYY-MM-DD.json.bak (once per day) and + * prune snapshots beyond the retention window. Called before every save and + * once at app startup. Writes new sibling files only — never moves or renames + * anything under ~/.dev3.0/ (see on-disk layout invariants in AGENTS.md). + */ +export async function backupProjectsDaily(now: Date = new Date()): Promise { + let currentContent: string; + try { + currentContent = await readFile(PROJECTS_FILE, "utf8"); + } catch (err: any) { + if (err.code === "ENOENT") return; + throw err; + } + + const backupFile = `${DEV3_HOME}/projects-${now.toISOString().slice(0, 10)}.json.bak`; + try { + await readFile(backupFile, "utf8"); + } catch (err: any) { + if (err.code !== "ENOENT") throw err; + await writeFile(backupFile, currentContent); + log.info("Wrote daily projects backup", { file: backupFile }); + } + + const backupFiles = (await readdir(DEV3_HOME)) + .filter((entry) => PROJECTS_BACKUP_FILE_PATTERN.test(entry)) + .sort(); + for (const staleFile of backupFiles.slice(0, Math.max(0, backupFiles.length - PROJECTS_BACKUP_RETENTION_DAYS))) { + await unlink(`${DEV3_HOME}/${staleFile}`); + } +} + // ---- Projects (public API — all mutators use file lock) ---- /** Load active (non-deleted) projects. */ diff --git a/src/bun/index.ts b/src/bun/index.ts index 68679456..ea7ef1bb 100644 --- a/src/bun/index.ts +++ b/src/bun/index.ts @@ -216,6 +216,16 @@ if (shellEnv.sshAuthSock) { const cliSocketPath = startSocketServer(); log.info("CLI socket server ready", { path: cliSocketPath }); +// Daily projects.json safety snapshot (projects-YYYY-MM-DD.json.bak, 7 days kept). +// Saves also trigger it, but projects.json can go untouched for weeks — the +// startup hook guarantees at least one fresh backup per day the app is used. +{ + const { backupProjectsDaily } = await import("./data"); + backupProjectsDaily().catch((err) => { + log.warn("Startup projects backup failed (non-fatal)", { err }); + }); +} + // Side-effect: starts the PTY WebSocket server (dynamic import so PATH is patched first) const { setOnPtyDied, setOnBell, setOnIdle, setOnPaneExited, setOnOsc52Copy, getActiveSessionIds, getPtyPort } = await import("./pty-server"); const { startPortScanPoller, stopPortScanPoller } = await import("./port-scanner"); diff --git a/src/bun/repo-config.ts b/src/bun/repo-config.ts index 1a97cc0b..34a43ac0 100644 --- a/src/bun/repo-config.ts +++ b/src/bun/repo-config.ts @@ -212,8 +212,22 @@ export async function resolveProjectConfig(project: Project, configPath?: string ? resolved.defaultBaseBranch : `origin/${resolved.defaultBaseBranch}`; } else if (resolved.defaultCompareRef === undefined) { - // Only auto-detect if no explicit value was set at any level (including project) - resolved.defaultCompareRef = await git.detectDefaultCompareRef(basePath, resolved.defaultBaseBranch); + // Only auto-detect if no explicit value was set at any level (including project). + // A deleted project folder (or any git/spawn failure) must not reject — one broken + // project would otherwise blow up the whole project list (Promise.all in getProjects). + if (!existsSync(basePath)) { + resolved.defaultCompareRef = resolved.defaultBaseBranch; + } else { + try { + resolved.defaultCompareRef = await git.detectDefaultCompareRef(basePath, resolved.defaultBaseBranch); + } catch (err) { + log.warn("Failed to detect default compare ref, falling back to base branch", { + path: basePath, + error: String(err), + }); + resolved.defaultCompareRef = resolved.defaultBaseBranch; + } + } } return resolved; @@ -230,6 +244,9 @@ export async function migrateProjectConfig(project: Project, configPath?: string const repoPath = `${basePath}/${CONFIG_FILE}`; const localPath = `${basePath}/${LOCAL_CONFIG_FILE}`; + // A deleted project folder must not be resurrected by mkdirSync in saveRepoConfig + if (!existsSync(basePath)) return; + // Skip if any .dev3/ config already exists if (existsSync(repoPath) || existsSync(localPath)) return; diff --git a/src/bun/rpc-handlers/app-handlers.ts b/src/bun/rpc-handlers/app-handlers.ts index 970b0a53..0e43baf9 100644 --- a/src/bun/rpc-handlers/app-handlers.ts +++ b/src/bun/rpc-handlers/app-handlers.ts @@ -84,8 +84,21 @@ async function setWindowForeground(params: { focused: boolean }): Promise async function getProjects(): Promise { log.info("→ getProjects"); const rawProjects = await data.loadProjects(); - await Promise.all(rawProjects.map((project) => repoConfig.migrateProjectConfig(project))); - const projects = await Promise.all(rawProjects.map((project) => repoConfig.resolveProjectConfig(project))); + // Per-project isolation: one broken project (e.g. its folder was deleted from + // disk) must never reject the whole list — that left users with an empty board. + const projects = await Promise.all(rawProjects.map(async (project) => { + try { + await repoConfig.migrateProjectConfig(project); + return await repoConfig.resolveProjectConfig(project); + } catch (err) { + log.warn("Failed to resolve project config, returning project as-is", { + id: project.id, + path: project.path, + error: String(err), + }); + return project; + } + })); log.info(`← getProjects: ${projects.length} project(s)`); return projects; } diff --git a/src/mainview/i18n/translations/en/tips.ts b/src/mainview/i18n/translations/en/tips.ts index e14e8af1..743ce6e0 100644 --- a/src/mainview/i18n/translations/en/tips.ts +++ b/src/mainview/i18n/translations/en/tips.ts @@ -212,6 +212,8 @@ const tips = { "tip.statusColorRails.body": "Each task in the Active Tasks list has a colored left rail matching its status — scan who's working vs. waiting at a glance.", "tip.skillAutocomplete.title": "Autocomplete agent skills with /", "tip.skillAutocomplete.body": "Type / in a new task's description to pick an installed agent skill from the suggestion list.", + "tip.projectsDailyBackup.title": "Project list is backed up daily", + "tip.projectsDailyBackup.body": "Lost your projects? Restore from ~/.dev3.0/projects-YYYY-MM-DD.json.bak — snapshots are kept for 7 days.", } as const; export default tips; diff --git a/src/mainview/i18n/translations/es/tips.ts b/src/mainview/i18n/translations/es/tips.ts index ef10b101..65a0f612 100644 --- a/src/mainview/i18n/translations/es/tips.ts +++ b/src/mainview/i18n/translations/es/tips.ts @@ -212,6 +212,8 @@ const tips = { "tip.statusColorRails.body": "Cada tarea de la lista Tareas activas tiene una barra de color a la izquierda según su estado: distingue de un vistazo quién trabaja y quién espera.", "tip.skillAutocomplete.title": "Autocompleta skills con /", "tip.skillAutocomplete.body": "Escribe / en la descripción de una nueva tarea para elegir un skill de agente instalado de la lista de sugerencias.", + "tip.projectsDailyBackup.title": "Copia diaria de tus proyectos", + "tip.projectsDailyBackup.body": "¿Perdiste tus proyectos? Restáuralos desde ~/.dev3.0/projects-YYYY-MM-DD.json.bak — las copias se guardan 7 días.", }; export default tips; diff --git a/src/mainview/i18n/translations/ru/tips.ts b/src/mainview/i18n/translations/ru/tips.ts index 74957c5d..91c5f056 100644 --- a/src/mainview/i18n/translations/ru/tips.ts +++ b/src/mainview/i18n/translations/ru/tips.ts @@ -212,6 +212,8 @@ const tips = { "tip.statusColorRails.body": "У каждой задачи в списке Active Tasks слева цветная полоса по её статусу — сразу видно, кто работает, а кто ждёт.", "tip.skillAutocomplete.title": "Автодополнение скиллов через /", "tip.skillAutocomplete.body": "Введите / в описании новой задачи, чтобы выбрать установленный скилл агента из списка подсказок.", + "tip.projectsDailyBackup.title": "Список проектов бэкапится ежедневно", + "tip.projectsDailyBackup.body": "Пропали проекты? Восстановите из ~/.dev3.0/projects-YYYY-MM-DD.json.bak — снимки хранятся 7 дней.", }; export default tips; diff --git a/src/mainview/tips.ts b/src/mainview/tips.ts index 73614fba..f7e7165d 100644 --- a/src/mainview/tips.ts +++ b/src/mainview/tips.ts @@ -675,6 +675,13 @@ const ALL_TIPS: Tip[] = [ bodyKey: "tip.skillAutocomplete.body", icon: "\u{F0349}", // nf-md-magic_staff }, + // Batch 45: daily projects.json backups + { + id: "projects-daily-backup", + titleKey: "tip.projectsDailyBackup.title", + bodyKey: "tip.projectsDailyBackup.body", + icon: "\u{F006F}", // nf-md-backup_restore + }, ]; const COOLDOWN_MS = 3 * 24 * 60 * 60 * 1000; // 3 days