diff --git a/packages/server/src/server/workspace-reconciliation-observation.test.ts b/packages/server/src/server/workspace-reconciliation-observation.test.ts index a5fa20c97f..8977641bab 100644 --- a/packages/server/src/server/workspace-reconciliation-observation.test.ts +++ b/packages/server/src/server/workspace-reconciliation-observation.test.ts @@ -6,6 +6,7 @@ import { afterEach, describe, expect, test } from "vitest"; import { createTestLogger } from "../test-utils/test-logger.js"; import { areEquivalentPaths } from "../utils/path.js"; +import { deriveProjectKey } from "./project-key.js"; import { createPersistedProjectRecord, createPersistedWorkspaceRecord, @@ -336,6 +337,14 @@ class ObservedPlacements { projectId: spec.id, rootPath, kind: "non_git", + // Seeded records already carry the key reconciliation derives, so a fixture's + // first pass reports only what its test is about. + projectKey: deriveProjectKey({ + rootPath, + remoteUrl: null, + worktreeRoot: null, + mainRepoRoot: null, + }), displayName: spec.id, createdAt: TIMESTAMP, updatedAt: TIMESTAMP, @@ -473,7 +482,11 @@ describe("observed workspace placement", () => { test("archives missing workspace directories on the periodic pass", async () => { const observed = new ObservedPlacements([ - { id: "project-one", root: "repo", workspaces: [{ id: "workspace-one", cwd: "repo" }] }, + { + id: "project-one", + root: "repo", + workspaces: [{ id: "workspace-one", cwd: "repo/worktree-one" }], + }, ]); await observed.start(); await observed.deleteWorkspaceDirectory("workspace-one"); @@ -487,7 +500,11 @@ describe("observed workspace placement", () => { test("preserves a periodic full pass queued behind metadata reconciliation", async () => { const observed = new ObservedPlacements([ - { id: "project-one", root: "repo", workspaces: [{ id: "workspace-one", cwd: "repo" }] }, + { + id: "project-one", + root: "repo", + workspaces: [{ id: "workspace-one", cwd: "repo/worktree-one" }], + }, ]); await observed.start(); const metadataRead = observed.holdNextReconciliation(); diff --git a/packages/server/src/server/workspace-reconciliation-service.test.ts b/packages/server/src/server/workspace-reconciliation-service.test.ts index 1a885378db..1bceb38715 100644 --- a/packages/server/src/server/workspace-reconciliation-service.test.ts +++ b/packages/server/src/server/workspace-reconciliation-service.test.ts @@ -598,6 +598,11 @@ describe("WorkspaceReconciliationService", () => { }); test("archives workspaces whose directories no longer exist", async () => { + const projectRoot = realpathSync( + mkdtempSync(path.join(tmpdir(), "reconcile-missing-workspace-")), + ); + const missingWorkspace = path.join(projectRoot, "missing-workspace"); + tempDirs.push(projectRoot); const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries(); const archivedWorkspaceIds: string[] = []; @@ -605,7 +610,8 @@ describe("WorkspaceReconciliationService", () => { "p1", createPersistedProjectRecord({ projectId: "p1", - rootPath: "/tmp/does-not-exist-reconcile-test", + rootPath: projectRoot, + projectKey: canonicalLocalProjectKey(projectRoot), kind: "non_git", displayName: "ghost", createdAt: timestamp, @@ -617,7 +623,7 @@ describe("WorkspaceReconciliationService", () => { createPersistedWorkspaceRecord({ workspaceId: "w1", projectId: "p1", - cwd: "/tmp/does-not-exist-reconcile-test", + cwd: missingWorkspace, kind: "directory", displayName: "ghost", createdAt: timestamp, @@ -640,7 +646,7 @@ describe("WorkspaceReconciliationService", () => { { kind: "workspace_archived", workspaceId: "w1", - directory: "/tmp/does-not-exist-reconcile-test", + directory: missingWorkspace, reason: "directory_missing", }, ]); @@ -648,12 +654,59 @@ describe("WorkspaceReconciliationService", () => { expect(workspaces.get("w1")?.archivedAt).toEqual(expect.any(String)); }); + test("keeps workspaces whose project root is missing with them", async () => { + const mountParent = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-unmounted-"))); + tempDirs.push(mountParent); + // The external volume is not mounted, so nothing under it resolves. + const projectRoot = path.join(mountParent, "ExternalSSD", "repo"); + const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries(); + + projects.set( + "p1", + createPersistedProjectRecord({ + projectId: "p1", + rootPath: projectRoot, + kind: "non_git", + displayName: "repo", + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + workspaces.set( + "w1", + createPersistedWorkspaceRecord({ + workspaceId: "w1", + projectId: "p1", + cwd: projectRoot, + kind: "directory", + displayName: "repo", + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + + const service = new WorkspaceReconciliationService({ + projectRegistry, + workspaceRegistry, + logger: createTestLogger(), + }); + + const result = await service.runOnce(); + + expect(result.changesApplied).toEqual([]); + expect(workspaces.get("w1")?.archivedAt).toBeNull(); + }); + test("keeps a project active after all its workspaces are archived", async () => { + const projectRoot = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-orphan-project-"))); + const missingWorkspace = path.join(projectRoot, "missing-workspace"); + tempDirs.push(projectRoot); const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries(); const project = createPersistedProjectRecord({ projectId: "p1", - rootPath: "/tmp/does-not-exist-reconcile-orphan", + rootPath: projectRoot, + projectKey: canonicalLocalProjectKey(projectRoot), kind: "non_git", displayName: "orphan", createdAt: timestamp, @@ -665,7 +718,7 @@ describe("WorkspaceReconciliationService", () => { createPersistedWorkspaceRecord({ workspaceId: "w1", projectId: "p1", - cwd: "/tmp/does-not-exist-reconcile-orphan", + cwd: missingWorkspace, kind: "directory", displayName: "orphan", createdAt: timestamp, @@ -685,14 +738,14 @@ describe("WorkspaceReconciliationService", () => { { kind: "workspace_archived", workspaceId: "w1", - directory: "/tmp/does-not-exist-reconcile-orphan", + directory: missingWorkspace, reason: "directory_missing", }, ]); expect(workspaces.get("w1")).toEqual({ workspaceId: "w1", projectId: "p1", - cwd: "/tmp/does-not-exist-reconcile-orphan", + cwd: missingWorkspace, kind: "directory", displayName: "orphan", title: null, @@ -1382,13 +1435,17 @@ describe("WorkspaceReconciliationService", () => { }); test("calls onChanges callback when changes are applied", async () => { + const projectRoot = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-callback-"))); + const missingWorkspace = path.join(projectRoot, "missing-workspace"); + tempDirs.push(projectRoot); const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries(); projects.set( "p1", createPersistedProjectRecord({ projectId: "p1", - rootPath: "/tmp/does-not-exist-callback-test", + rootPath: projectRoot, + projectKey: canonicalLocalProjectKey(projectRoot), kind: "non_git", displayName: "ghost", createdAt: timestamp, @@ -1400,7 +1457,7 @@ describe("WorkspaceReconciliationService", () => { createPersistedWorkspaceRecord({ workspaceId: "w1", projectId: "p1", - cwd: "/tmp/does-not-exist-callback-test", + cwd: missingWorkspace, kind: "directory", displayName: "ghost", createdAt: timestamp, @@ -1422,13 +1479,16 @@ describe("WorkspaceReconciliationService", () => { { kind: "workspace_archived", workspaceId: "w1", - directory: "/tmp/does-not-exist-callback-test", + directory: missingWorkspace, reason: "directory_missing", }, ]); }); test("logs reconciliation changes with affected paths and reasons", async () => { + const projectRoot = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-log-"))); + const missingWorkspace = path.join(projectRoot, "missing-workspace"); + tempDirs.push(projectRoot); const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries(); const { logger, infoRecords } = createCapturingLogger(); @@ -1436,7 +1496,8 @@ describe("WorkspaceReconciliationService", () => { "p1", createPersistedProjectRecord({ projectId: "p1", - rootPath: "/tmp/does-not-exist-log-test", + rootPath: projectRoot, + projectKey: canonicalLocalProjectKey(projectRoot), kind: "non_git", displayName: "ghost", createdAt: timestamp, @@ -1448,7 +1509,7 @@ describe("WorkspaceReconciliationService", () => { createPersistedWorkspaceRecord({ workspaceId: "w1", projectId: "p1", - cwd: "/tmp/does-not-exist-log-test", + cwd: missingWorkspace, kind: "directory", displayName: "ghost", createdAt: timestamp, @@ -1473,7 +1534,7 @@ describe("WorkspaceReconciliationService", () => { { kind: "workspace_archived", workspaceId: "w1", - directory: "/tmp/does-not-exist-log-test", + directory: missingWorkspace, reason: "directory_missing", }, ]), diff --git a/packages/server/src/server/workspace-reconciliation-service.ts b/packages/server/src/server/workspace-reconciliation-service.ts index 59d2f9c124..eb79dbacd2 100644 --- a/packages/server/src/server/workspace-reconciliation-service.ts +++ b/packages/server/src/server/workspace-reconciliation-service.ts @@ -227,6 +227,14 @@ export class WorkspaceReconciliationService { workspace, state: this.inspectDirectory(workspace.cwd), })); + // Project roots are read after the workspace directories, so a volume that + // goes away mid-pass leaves its project unreachable rather than its workspaces + // alone. The skew can only withhold an archive, never produce one. + const reachableProjectIds = new Set( + activeProjects + .filter((project) => this.inspectDirectory(project.rootPath) === "directory") + .map((project) => project.projectId), + ); const workspacesByProject = new Map(); for (const { workspace, state } of workspaceDirectoryStates) { @@ -236,9 +244,16 @@ export class WorkspaceReconciliationService { workspacesByProject.set(workspace.projectId, list); } - // 1. Archive workspaces whose directories no longer exist + // 1. Archive workspaces whose directories no longer exist, but only when the + // project they belong to is still reachable. A missing project root means the + // whole location is unavailable - an unmounted volume, an offline share, a disk + // that has not appeared yet - and absence there proves nothing about the + // workspace. Projects already persist through that; their workspaces do too. const missingWorkspaces = workspaceDirectoryStates - .filter(({ state }) => state === "missing") + .filter( + ({ workspace, state }) => + state === "missing" && reachableProjectIds.has(workspace.projectId), + ) .map(({ workspace }) => workspace); await Promise.all( missingWorkspaces.map(async (workspace) => { @@ -265,7 +280,7 @@ export class WorkspaceReconciliationService { // Projects persist until explicitly removed, even when they currently have // zero active workspaces, so they still reconcile their own metadata. await this.reconcileGitMetadataForProjects( - activeProjects.filter((project) => this.inspectDirectory(project.rootPath) === "directory"), + activeProjects.filter((project) => reachableProjectIds.has(project.projectId)), workspacesByProject, changes, );