fix(projects): preserve host-local project identity

This commit is contained in:
Mohamed Boudra
2026-07-28 21:40:42 +00:00
parent 9e86b13b1f
commit b92d142fce
16 changed files with 241 additions and 32 deletions
@@ -7,7 +7,8 @@ export async function expectOpenedProject(page: Page, projectName: string): Prom
.first();
await expect(projectRow).toBeVisible({ timeout: 30_000 });
const testId = await projectRow.getAttribute("data-testid");
expect(testId).not.toBeNull();
return testId!.replace("sidebar-project-row-", "");
await expect(page).toHaveURL(/\/new\?.*projectId=/u, { timeout: 30_000 });
const projectId = new URL(page.url()).searchParams.get("projectId");
expect(projectId).not.toBeNull();
return projectId!;
}
@@ -1,8 +1,10 @@
import { test, expect } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import { injectDesktopBridge, waitForDirectoryDialog } from "./helpers/desktop-updates";
import { getE2EDaemonPort } from "./helpers/daemon-port";
import { waitForConnectedHost } from "./helpers/hosts";
import { expectOpenedProject } from "./helpers/project-picker-ui";
import { expectNewWorkspaceForAddedProject } from "./helpers/add-project-flow";
import { expectNewWorkspaceForAddedProject, openAddProjectFlow } from "./helpers/add-project-flow";
import { getServerId } from "./helpers/server-id";
import { connectSeedClient } from "./helpers/seed-client";
@@ -18,8 +20,12 @@ test("Browse opens the folder selected by the desktop dialog", async ({
dialogOpenResult: projectPickerFixture.projectPath,
});
await gotoAppShell(page);
await waitForConnectedHost(page, {
serverId: getServerId(),
endpoint: `localhost:${getE2EDaemonPort()}`,
});
await page.getByTestId("sidebar-add-project").click();
await openAddProjectFlow(page);
const browse = page.getByRole("button", { name: /^Browse/ });
await expect(browse).toBeVisible({ timeout: 30_000 });
await browse.click();
@@ -50,8 +56,12 @@ test("canceling Browse returns to the Add Project methods", async ({
dialogOpenResult: null,
});
await gotoAppShell(page);
await waitForConnectedHost(page, {
serverId: getServerId(),
endpoint: `localhost:${getE2EDaemonPort()}`,
});
await page.getByTestId("sidebar-add-project").click();
await openAddProjectFlow(page);
const browse = page.getByRole("button", { name: /^Browse/ });
await expect(browse).toBeVisible({ timeout: 30_000 });
await browse.click();
@@ -115,12 +115,14 @@ function workspace(input: {
name: string;
projectId: string;
projectDisplayName: string;
projectGroupKey?: string;
status?: WorkspaceDescriptor["status"];
statusEnteredAt?: Date | null;
}): WorkspaceDescriptor {
return {
id: input.id,
projectId: input.projectId,
projectGroupKey: input.projectGroupKey,
projectDisplayName: input.projectDisplayName,
projectRootPath: `/repo/${input.projectId}`,
workspaceDirectory: `/repo/${input.projectId}/${input.id}`,
@@ -401,6 +403,36 @@ describe("shared sidebar workspace model", () => {
expect(nextEntries.get("srv:one")).toBe(previousEntries.get("srv:one"));
expect(nextEntries.get("srv:two")).not.toBe(previousEntries.get("srv:two"));
});
it("keeps a structurally disambiguated project key in status entries", () => {
const projectKey = "host:srv:project:prj_a";
const model = buildSidebarWorkspacePlacementModel({
projects: [project({ projectKey, projectName: "Clone A", workspaceKeys: ["srv:clone-a"] })],
});
const entries = buildSidebarWorkspaceEntries({
placements: model.workspaces,
sessions: [
{
serverId: "srv",
workspaceAgentActivity: new Map(),
workspaces: new Map([
[
"clone-a",
workspace({
id: "clone-a",
name: "main",
projectId: "prj_a",
projectGroupKey: "remote:github.com/acme/app",
projectDisplayName: "acme/app",
}),
],
]),
},
],
});
expect(entries.get("srv:clone-a")?.projectKey).toBe(projectKey);
});
});
describe("shouldShowSidebarHostLabels", () => {
@@ -142,14 +142,17 @@ function normalizeCurrentBranch(currentBranch: string | null | undefined): strin
export function createSidebarWorkspaceEntry(input: {
serverId: string;
workspace: WorkspaceDescriptor;
projectKey?: string;
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
workspaceAgentActivity?: ReadonlyMap<string, WorkspaceAgentActivity>;
}): SidebarWorkspaceEntry {
const projectKey = resolveProjectGroupKey({
serverId: input.serverId,
projectId: input.workspace.projectId,
projectGroupKey: input.workspace.projectGroupKey,
});
const projectKey =
input.projectKey ??
resolveProjectGroupKey({
serverId: input.serverId,
projectId: input.workspace.projectId,
projectGroupKey: input.workspace.projectGroupKey,
});
const effectiveStatus = deriveEffectiveWorkspaceStatus(input);
return {
workspaceKey: `${input.serverId}:${input.workspace.id}`,
@@ -328,6 +331,7 @@ export function buildSidebarWorkspaceEntries(input: {
const entry = createSidebarWorkspaceEntry({
serverId: placement.serverId,
workspace,
projectKey: placement.projectKey,
pendingCreateAttempts: input.pendingCreateAttempts,
workspaceAgentActivity: session.workspaceAgentActivity,
});
@@ -93,6 +93,7 @@ describe("openProjectDirectly", () => {
serverId: SERVER_ID,
project: {
projectId: "project-1",
projectGroupKey: null,
projectDisplayName: "project",
projectCustomName: null,
projectKind: "git",
@@ -191,6 +192,7 @@ describe("cloneGithubProjectDirectly", () => {
project: {
...projectPayload,
projectCustomName: null,
projectGroupKey: null,
},
},
]);
@@ -225,6 +225,7 @@ describe("deriveProjectsFromReplica", () => {
"gitRuntime",
"githubRuntime",
"isOnline",
"projectId",
"repoRoot",
"serverId",
"serverName",
@@ -103,6 +103,7 @@ it("commits the authoritative snapshot before buffered project updates", () => {
kind: "upsert",
project: {
projectId: "attached",
projectGroupKey: "remote:github.com/acme/attached",
projectDisplayName: "Renamed attached project",
projectCustomName: "Personal name",
projectRootPath: "/moved/attached",
@@ -124,12 +125,14 @@ it("commits the authoritative snapshot before buffered project updates", () => {
const session = useSessionStore.getState().sessions[serverId];
expect(session?.workspaces.get(attachedMain.id)).toMatchObject({
projectGroupKey: "remote:github.com/acme/attached",
projectDisplayName: "Renamed attached project",
projectCustomName: "Personal name",
projectRootPath: "/moved/attached",
projectKind: "directory",
});
expect(session?.workspaces.get(attachedFeature.id)).toMatchObject({
projectGroupKey: "remote:github.com/acme/attached",
projectDisplayName: "Renamed attached project",
projectRootPath: "/moved/attached",
});
@@ -42,6 +42,7 @@ function applyProjectDelta(
hasAttachedWorkspace = true;
snapshot.workspaces.set(workspaceId, {
...workspace,
projectGroupKey: project.projectGroupKey,
projectDisplayName: project.projectDisplayName,
projectCustomName: project.projectCustomName,
projectRootPath: project.projectRootPath,
+59
View File
@@ -31,6 +31,7 @@ function workspace(input: {
project?: ProjectPlacementPayload;
projectId?: string;
projectGroupKey?: string;
projectCustomName?: string | null;
projectName?: string;
remoteUrl?: string | null;
currentBranch?: string;
@@ -41,6 +42,7 @@ function workspace(input: {
projectId: input.projectId ?? input.project?.projectKey ?? input.repoRoot,
projectGroupKey: input.projectGroupKey,
projectDisplayName: input.projectName ?? input.project?.projectName ?? "Project",
projectCustomName: input.projectCustomName,
projectRootPath: input.repoRoot,
workspaceDirectory: input.repoRoot,
projectKind: "git",
@@ -119,6 +121,63 @@ describe("buildProjects", () => {
]);
});
it("keeps host-local metadata when the grouping key differs from the project ID", () => {
const projectGroupKey = "remote:github.com/acme/app";
const result = buildProjects({
hosts: [
{
serverId: "desktop",
serverName: "Desktop",
isOnline: true,
workspaces: [
workspace({
id: "main",
repoRoot: "/repo/app",
projectId: "prj_app",
projectGroupKey,
projectName: "acme/app",
projectCustomName: "My App",
}),
],
},
],
});
expect(result.projects[0]).toMatchObject({
projectKey: projectGroupKey,
projectName: "acme/app",
projectCustomName: "My App",
});
});
it("preserves the root of a grouped project without workspaces", () => {
const result = buildProjects({
hosts: [
{
serverId: "desktop",
serverName: "Desktop",
isOnline: true,
workspaces: [],
emptyProjects: [
{
projectId: "prj_app",
projectGroupKey: "remote:github.com/acme/app",
projectDisplayName: "acme/app",
projectCustomName: null,
projectRootPath: "/repo/app",
projectKind: "git",
},
],
},
],
});
expect(result.projects[0]?.hosts[0]).toMatchObject({
projectId: "prj_app",
repoRoot: "/repo/app",
});
});
it("normalizes detached and blank branches out of workspace summaries", () => {
const result = buildProjects({
hosts: [
+6 -5
View File
@@ -193,7 +193,10 @@ export function buildProjects(input: BuildProjectsInput): BuildProjectsResult {
),
);
for (const hostProject of hostProjects) {
const customName = findProjectCustomName(host.workspaces, hostProject.projectKey);
const placement = hostProject.hosts.find((entry) => entry.serverId === host.serverId);
if (!placement) continue;
const projectId = placement.projectId ?? hostProject.projectKey;
const customName = findProjectCustomName(host.workspaces, projectId);
let group = groups.get(hostProject.projectKey);
if (!group) {
group = {
@@ -209,15 +212,13 @@ export function buildProjects(input: BuildProjectsInput): BuildProjectsResult {
}
if (!group.hostsByServerId.has(host.serverId)) {
const placement = hostProject.hosts.find((entry) => entry.serverId === host.serverId);
if (!placement) continue;
group.hostsByServerId.set(host.serverId, {
serverId: host.serverId,
projectId: placement.projectId ?? hostProject.projectKey,
projectId,
serverName: host.serverName,
isOnline: host.isOnline,
workspaces: [],
fallbackRepoRoot: emptyRepoRootByProjectKey.get(hostProject.projectKey) ?? "",
fallbackRepoRoot: emptyRepoRootByProjectKey.get(projectId) ?? "",
});
}
}
@@ -73,7 +73,11 @@ test("creates a worktree and registers it in the source workspace project withou
expect(result.workspace.baseBranch).toBe("main");
expect(result.workspace.title).toBe("Feature One");
expect(deps.workspaceGitService.getSnapshot).not.toHaveBeenCalled();
expect(deps.projects.get(sourceProject.projectId)).toEqual(sourceProject);
expect(deps.projects.get(sourceProject.projectId)).toEqual({
...sourceProject,
projectGroupKey: path.resolve(sourceProject.rootPath),
updatedAt: expect.any(String),
});
expect(events).toEqual([`workspace:${result.workspace.workspaceId}`]);
});
@@ -436,7 +440,11 @@ test("an explicit project FK remains unchanged when its worktree comes from anot
);
expect(result.workspace.projectId).toBe(project.projectId);
expect(deps.projects.get(project.projectId)).toEqual(project);
expect(deps.projects.get(project.projectId)).toEqual({
...project,
projectGroupKey: path.resolve(project.rootPath),
updatedAt: expect.any(String),
});
});
// POSIX-only: Windows git worktree paths need separate canonicalization coverage.
@@ -649,6 +649,7 @@ describe("project command-center RPCs", () => {
rootPath: directoryPath,
kind: "non_git",
displayName: "new-project",
projectGroupKey: directoryPath,
timestamp: expect.any(String),
});
expect(messages).toEqual([
+1
View File
@@ -4289,6 +4289,7 @@ export class Session {
return {
id: result.workspace.workspaceId,
projectId: result.workspace.projectId,
...(projectRecord?.projectGroupKey ? { projectGroupKey: projectRecord.projectGroupKey } : {}),
projectDisplayName: projectRecord
? resolveProjectDisplayName(projectRecord)
: result.workspace.projectId,
@@ -3699,6 +3699,7 @@ test("create paseo worktree response preserves an explicit non-Git project", asy
expect(response?.payload.error).toBeNull();
expect(response?.payload.workspace).toMatchObject({
projectId: explicitProject.projectId,
projectGroupKey: explicitProject.rootPath,
projectDisplayName: explicitProject.displayName,
projectRootPath: explicitProject.rootPath,
projectKind: "non_git",
@@ -3710,7 +3711,10 @@ test("create paseo worktree response preserves an explicit non-Git project", asy
expect(response?.payload.workspace?.id).toMatch(/^wks_[0-9a-f]{16}$/);
expect(response?.payload.workspace?.workspaceDirectory).toContain(path.join("worktree-123"));
expect(workspaces.has(response?.payload.workspace?.id ?? "")).toBe(true);
expect(projects.get(explicitProject.projectId)).toEqual(explicitProject);
expect(projects.get(explicitProject.projectId)).toEqual({
...explicitProject,
projectGroupKey: explicitProject.rootPath,
});
});
test("workspace updates stay scoped to the matching cwd", async () => {
@@ -253,7 +253,14 @@ describe("WorkspaceReconciliationService", () => {
const metadataResult = await service.reconcileGitMetadata();
expect(metadataResult.changesApplied).toEqual([]);
expect(metadataResult.changesApplied).toEqual([
{
kind: "project_updated",
projectId: "p1",
directory: projectRoot,
fields: { projectGroupKey: projectRoot },
},
]);
expect(workspaces.get("w1")?.archivedAt).toBeNull();
const fullResult = await service.runOnce();
@@ -304,7 +311,14 @@ describe("WorkspaceReconciliationService", () => {
);
const afterGitInit = await service.reconcileGitMetadata();
expect(beforeGitInit.changesApplied).toEqual([]);
expect(beforeGitInit.changesApplied).toEqual([
{
kind: "project_updated",
projectId: "p1",
directory: projectRoot,
fields: { projectGroupKey: projectRoot },
},
]);
expect(afterGitInit.changesApplied).toEqual([
{
kind: "project_updated",
@@ -386,7 +400,20 @@ describe("WorkspaceReconciliationService", () => {
const result = await service.reconcileGitMetadata();
expect(result.changesApplied).toEqual([]);
expect(result.changesApplied).toEqual([
{
kind: "project_updated",
projectId: "p1",
directory: projectRoot,
fields: { projectGroupKey: projectRoot },
},
{
kind: "project_updated",
projectId: "p2",
directory: equivalentProjectRoot,
fields: { projectGroupKey: projectRoot },
},
]);
expect(git.reads).toEqual([projectRoot, workspaceRoot]);
});
@@ -445,7 +472,7 @@ describe("WorkspaceReconciliationService", () => {
kind: "project_updated",
projectId: "p1",
directory: projectRoot,
fields: { kind: "git" },
fields: { kind: "git", projectGroupKey: projectRoot },
},
{
kind: "workspace_updated",
@@ -462,6 +489,7 @@ describe("WorkspaceReconciliationService", () => {
expect(projects.get("p1")).toEqual({
...originalProject,
kind: "git",
projectGroupKey: projectRoot,
updatedAt: expect.any(String),
});
expect(workspaces.get("w1")).toEqual({
@@ -867,6 +895,55 @@ describe("WorkspaceReconciliationService", () => {
expect(projects.get("p1")!.projectGroupKey).toBe("remote:github.com/new-owner/new-repo");
});
test("falls back to the persisted root when a Git remote disappears", async () => {
const dir = createTempGitRepo("reconcile-removed-remote-");
tempDirs.push(dir);
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
projects.set(
"p1",
createPersistedProjectRecord({
projectId: "p1",
projectGroupKey: "remote:github.com/acme/old-repo",
rootPath: dir,
kind: "git",
displayName: "acme/old-repo",
createdAt: timestamp,
updatedAt: timestamp,
}),
);
workspaces.set(
"w1",
createPersistedWorkspaceRecord({
workspaceId: "w1",
projectId: "p1",
cwd: dir,
kind: "local_checkout",
displayName: "main",
createdAt: timestamp,
updatedAt: timestamp,
}),
);
const service = new WorkspaceReconciliationService({
projectRegistry,
workspaceRegistry,
logger: createTestLogger(),
workspaceGitService: createWorkspaceGitServiceStub({
[dir]: {
projectKind: "git",
projectDisplayName: "old-repo",
workspaceDisplayName: "main",
gitRemote: null,
},
}),
});
await service.runOnce();
expect(projects.get("p1")?.projectGroupKey).toBe(path.resolve(dir));
});
test("keeps custom and default names stable when the remote changes", async () => {
const dir = createTempGitRepo("reconcile-customname-");
tempDirs.push(dir);
@@ -1044,7 +1121,7 @@ describe("WorkspaceReconciliationService", () => {
kind: "project_updated",
projectId: "p1",
directory: projectRoot,
fields: { kind: "git" },
fields: { kind: "git", projectGroupKey: projectRoot },
},
{
kind: "workspace_updated",
@@ -1328,6 +1405,12 @@ describe("WorkspaceReconciliationService", () => {
const result = await service.reconcileGitMetadata();
expect(result.changesApplied).toEqual([
{
kind: "project_updated",
projectId: "p1",
directory: rootPath,
fields: { projectGroupKey: "/tmp/main-repo" },
},
{
kind: "workspace_updated",
workspaceId: "w1",
@@ -342,18 +342,16 @@ export class WorkspaceReconciliationService {
);
const projectUpdates: Partial<Pick<PersistedProjectRecord, "kind" | "projectGroupKey">> = {};
const mappedKind = deriveProjectKind(currentGit);
const projectGroupKey = currentGit.remoteUrl
? deriveProjectGroupKey({
rootPath: project.rootPath,
remoteUrl: currentGit.remoteUrl,
mainRepoRoot: currentGit.mainRepoRoot,
})
: null;
const projectGroupKey = deriveProjectGroupKey({
rootPath: project.rootPath,
remoteUrl: currentGit.remoteUrl,
mainRepoRoot: currentGit.mainRepoRoot,
});
if (project.kind !== mappedKind) {
projectUpdates.kind = mappedKind;
}
if (projectGroupKey && project.projectGroupKey !== projectGroupKey) {
if (project.projectGroupKey !== projectGroupKey) {
projectUpdates.projectGroupKey = projectGroupKey;
}