Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
104 changes: 104 additions & 0 deletions src/bun/__tests__/data-projects-backup.test.ts
Original file line number Diff line number Diff line change
@@ -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 <T>(_filePath: string, fn: () => Promise<T>): Promise<T> => 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<Project>[]): 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);
});
});
30 changes: 30 additions & 0 deletions src/bun/__tests__/repo-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
29 changes: 29 additions & 0 deletions src/bun/__tests__/rpc-handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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", () => {
Expand Down
37 changes: 37 additions & 0 deletions src/bun/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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$/;
Expand Down Expand Up @@ -177,11 +179,46 @@ async function rawLoadAllProjects(options?: { strict?: boolean; persistMigration
async function rawSaveProjects(projects: Project[]): Promise<void> {
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<void> {
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. */
Expand Down
10 changes: 10 additions & 0 deletions src/bun/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
21 changes: 19 additions & 2 deletions src/bun/repo-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand Down
17 changes: 15 additions & 2 deletions src/bun/rpc-handlers/app-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,21 @@ async function setWindowForeground(params: { focused: boolean }): Promise<void>
async function getProjects(): Promise<Project[]> {
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;
}
Expand Down
2 changes: 2 additions & 0 deletions src/mainview/i18n/translations/en/tips.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
2 changes: 2 additions & 0 deletions src/mainview/i18n/translations/es/tips.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
2 changes: 2 additions & 0 deletions src/mainview/i18n/translations/ru/tips.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
7 changes: 7 additions & 0 deletions src/mainview/tips.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading