From 39e500688217d5ace631ce300cb913a153e8bf46 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 29 Jul 2026 07:40:36 +0000 Subject: [PATCH] fix(projects): harden grouping identity and routing --- .../app/e2e/empty-project-persists.spec.ts | 18 ++++++----- packages/app/e2e/helpers/seed-client.ts | 1 + .../src/components/sidebar-workspace-list.tsx | 14 ++++++++- .../worktree-setup-callout-policy.test.ts | 10 +++---- .../app/src/projects/project-group-key.ts | 2 +- .../projects/project-settings-target.test.ts | 6 ++-- packages/server/src/server/bootstrap.ts | 3 ++ .../src/server/project-group-key.test.ts | 30 +++++++++++++++++++ .../server/src/server/project-group-key.ts | 15 +++++++--- packages/server/src/server/session.ts | 1 + .../workspace-provisioning-service.ts | 7 ++++- .../workspace-reconciliation-service.ts | 4 +++ .../workspace-registry-bootstrap-legacy.ts | 2 ++ .../server/workspace-registry-bootstrap.ts | 2 ++ 14 files changed, 93 insertions(+), 22 deletions(-) diff --git a/packages/app/e2e/empty-project-persists.spec.ts b/packages/app/e2e/empty-project-persists.spec.ts index 8e715af87..613b4255d 100644 --- a/packages/app/e2e/empty-project-persists.spec.ts +++ b/packages/app/e2e/empty-project-persists.spec.ts @@ -195,6 +195,7 @@ test.describe("Project with no workspaces persists", () => { test.describe("Project remove", () => { test("removing a project from project actions removes it from the sidebar", async ({ page }) => { const workspace = await seedWorkspace({ repoPrefix: "project-remove-sidebar-" }); + let readdedProjectId: string | null = null; try { const projectRow = page.getByTestId(`sidebar-project-row-${workspace.projectKey}`); @@ -220,21 +221,24 @@ test.describe("Project remove", () => { const readded = await workspace.client.addProject(workspace.repoPath); expect(readded.error).toBeNull(); expect(readded.project).not.toBeNull(); - const readdedProjectId = readded.project?.projectId ?? ""; + readdedProjectId = readded.project?.projectId ?? ""; + const readdedProjectGroupKey = readded.project?.projectGroupKey ?? ""; expect(readdedProjectId).not.toBe(workspace.projectId); + expect(readdedProjectGroupKey).toBe(workspace.projectKey); expect(readded.project?.projectDisplayName).toBe(workspace.projectDisplayName); await page.reload(); await waitForSidebarHydration(page); - await expect(projectRow).toHaveCount(0, { timeout: 30_000 }); - const readdedProjectRow = page.getByTestId(`sidebar-project-row-${readdedProjectId}`); - await expect(readdedProjectRow).toBeVisible({ timeout: 30_000 }); - await expect(readdedProjectRow).toContainText(workspace.projectDisplayName); - await expect(readdedProjectRow).not.toContainText(workspace.repoPath); + await expect(projectRow).toBeVisible({ timeout: 30_000 }); + await expect(projectRow).toContainText(workspace.projectDisplayName); + await expect(projectRow).not.toContainText(workspace.repoPath); await expect( - page.getByTestId(`sidebar-project-new-workspace-row-${readdedProjectId}`), + page.getByTestId(`sidebar-project-new-workspace-row-${readdedProjectGroupKey}`), ).toBeVisible({ timeout: 30_000 }); } finally { + if (readdedProjectId) { + await workspace.client.removeProject(readdedProjectId).catch(() => undefined); + } await workspace.cleanup(); } }); diff --git a/packages/app/e2e/helpers/seed-client.ts b/packages/app/e2e/helpers/seed-client.ts index e55b7d570..2e06a5326 100644 --- a/packages/app/e2e/helpers/seed-client.ts +++ b/packages/app/e2e/helpers/seed-client.ts @@ -27,6 +27,7 @@ export interface SeedDaemonClient { addProject(cwd: string): Promise<{ project: { projectId: string; + projectGroupKey?: string; projectDisplayName: string; projectRootPath: string; } | null; diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 5fac3cc13..b982f567d 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -265,6 +265,7 @@ interface ProjectHeaderRowProps { chevron: "expand" | "collapse" | null; onPress: () => void; worktreeTarget: SidebarProjectHostTarget | null; + onlineServerIds: ReadonlySet; isProjectActive?: boolean; onWorkspacePress?: () => void; onWorktreeCreated?: (workspaceId: string) => void; @@ -494,6 +495,7 @@ function ProjectRowTrailingActions({ project, displayName, worktreeTarget, + onlineServerIds, isHovered, isMobileBreakpoint, isProjectActive, @@ -504,6 +506,7 @@ function ProjectRowTrailingActions({ project: SidebarProjectEntry; displayName: string; worktreeTarget: SidebarProjectHostTarget | null; + onlineServerIds: ReadonlySet; isHovered: boolean; isMobileBreakpoint: boolean; isProjectActive: boolean; @@ -532,7 +535,13 @@ function ProjectRowTrailingActions({ > ({ + ...host, + isOnline: onlineServerIds.has(host.serverId), + })), + })} projectPath={localProjectPath} onRemoveProject={onRemoveProject} removeProjectStatus={removeProjectStatus} @@ -975,6 +984,7 @@ function ProjectHeaderRow({ chevron, onPress, worktreeTarget, + onlineServerIds, isProjectActive = false, onWorkspacePress, onWorktreeCreated: _onWorktreeCreated, @@ -1060,6 +1070,7 @@ function ProjectHeaderRow({ project={project} displayName={displayName} worktreeTarget={worktreeTarget} + onlineServerIds={onlineServerIds} isHovered={isHovered} isMobileBreakpoint={isMobileBreakpoint} isProjectActive={isProjectActive} @@ -1850,6 +1861,7 @@ function ProjectBlock({ worktreeTarget={ rowModel.trailingAction.kind === "new_workspace" ? rowModel.trailingAction.target : null } + onlineServerIds={onlineServerIds} isProjectActive={active} onWorkspacePress={onWorkspacePress} onWorktreeCreated={onWorktreeCreated} diff --git a/packages/app/src/components/worktree-setup-callout-policy.test.ts b/packages/app/src/components/worktree-setup-callout-policy.test.ts index 45d0d807c..e63d3ebcc 100644 --- a/packages/app/src/components/worktree-setup-callout-policy.test.ts +++ b/packages/app/src/components/worktree-setup-callout-policy.test.ts @@ -89,14 +89,14 @@ describe("buildWorktreeSetupCalloutPolicy", () => { repoRoot: "/repo/project-1", }), ).toEqual({ - id: "worktree-setup-missing:host:server-1:project:project-1", - dismissalKey: "worktree-setup-missing:host:server-1:project:project-1", + id: "worktree-setup-missing:host:8:server-1:project:9:project-1", + dismissalKey: "worktree-setup-missing:host:8:server-1:project:9:project-1", priority: 100, title: "Set up worktree scripts", description: "Add setup commands so new worktrees can install dependencies and prepare themselves automatically.", actionLabel: "Open project settings", - projectSettingsRoute: "/settings/projects/host%3Aserver-1%3Aproject%3Aproject-1", + projectSettingsRoute: "/settings/projects/host%3A8%3Aserver-1%3Aproject%3A9%3Aproject-1", testID: "worktree-setup-callout-project-1", }); }); @@ -109,7 +109,7 @@ describe("buildWorktreeSetupCalloutPolicy", () => { projectKey: "remote:github.com/acme/project", repoRoot: "/repo/project", }).projectSettingsRoute, - ).toBe("/settings/projects/host%3Aserver-1%3Aproject%3Aprj_local"); + ).toBe("/settings/projects/host%3A8%3Aserver-1%3Aproject%3A9%3Aprj_local"); }); it("keeps dismissals scoped to the host placement", () => { @@ -138,7 +138,7 @@ describe("buildWorktreeSetupCalloutPolicy", () => { repoRoot: "/repo/project", }).projectSettingsRoute, ).toBe( - "/settings/projects/host%3Aserver-2%3Aproject%3Aremote%253Agithub.com%252Facme%252Fproject", + "/settings/projects/host%3A8%3Aserver-2%3Aproject%3A30%3Aremote%3Agithub.com%2Facme%2Fproject", ); }); }); diff --git a/packages/app/src/projects/project-group-key.ts b/packages/app/src/projects/project-group-key.ts index fd0aec778..0ef3d2f02 100644 --- a/packages/app/src/projects/project-group-key.ts +++ b/packages/app/src/projects/project-group-key.ts @@ -11,5 +11,5 @@ export function resolveProjectGroupKey(input: { } export function frameHostProjectKey(input: { serverId: string; projectId: string }): string { - return `host:${encodeURIComponent(input.serverId)}:project:${encodeURIComponent(input.projectId)}`; + return `host:${input.serverId.length}:${input.serverId}:project:${input.projectId.length}:${input.projectId}`; } diff --git a/packages/app/src/projects/project-settings-target.test.ts b/packages/app/src/projects/project-settings-target.test.ts index c2ddc87b6..d271030d1 100644 --- a/packages/app/src/projects/project-settings-target.test.ts +++ b/packages/app/src/projects/project-settings-target.test.ts @@ -14,11 +14,11 @@ describe("project settings target", () => { }; it("builds settings routes from stable host-local identity", () => { - expect(resolveProjectSettingsRouteKey(project)).toBe("host:host-a:project:prj_1234"); + expect(resolveProjectSettingsRouteKey(project)).toBe("host:6:host-a:project:8:prj_1234"); }); it("keeps a host-local route valid after the structural key changes", () => { - expect(findProjectSettingsTarget([project], "host:host-a:project:prj_1234")).toBe(project); + expect(findProjectSettingsTarget([project], "host:6:host-a:project:8:prj_1234")).toBe(project); }); it("keeps legacy project IDs scoped to their host", () => { @@ -33,7 +33,7 @@ describe("project settings target", () => { }; const routeKey = resolveProjectSettingsRouteKey(changedProject); - expect(routeKey).toBe(`host:host-b:project:${encodeURIComponent(legacyKey)}`); + expect(routeKey).toBe(`host:6:host-b:project:${legacyKey.length}:${legacyKey}`); expect(findProjectSettingsTarget([unchangedProject, changedProject], routeKey)).toBe( changedProject, ); diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index dad894f74..e3434b356 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -795,6 +795,7 @@ export async function createPaseoDaemon( }, }); const workspaceProvisioning = createWorkspaceProvisioningService({ + serverId, projectRegistry, workspaceRegistry, workspaceGitService, @@ -831,6 +832,7 @@ export async function createPaseoDaemon( await agentStorage.initialize(); logger.info({ elapsed: elapsed() }, "Agent storage initialized"); await bootstrapWorkspaceRegistries({ + serverId, paseoHome: config.paseoHome, agentStorage, projectRegistry, @@ -844,6 +846,7 @@ export async function createPaseoDaemon( releaseWorkspaceServicePortPlan(workspaceId); }; const workspaceReconciliation = new WorkspaceReconciliationService({ + serverId, projectRegistry, workspaceRegistry, logger, diff --git a/packages/server/src/server/project-group-key.test.ts b/packages/server/src/server/project-group-key.test.ts index 8a9b6faee..39c2af157 100644 --- a/packages/server/src/server/project-group-key.test.ts +++ b/packages/server/src/server/project-group-key.test.ts @@ -72,6 +72,21 @@ describe("deriveProjectGroupKey", () => { expect(derive("git@github.com:acme/foo.git")).toBe(derive("https://github.com/acme/foo")); }); + test("normalizes GitHub owner and repository casing", () => { + const rootPath = path.resolve("repo"); + const derive = (remoteUrl: string) => + deriveProjectGroupKey({ + rootPath, + remoteUrl, + worktreeRoot: rootPath, + mainRepoRoot: null, + }); + + expect(derive("git@github.com:GetPaseo/Paseo.git")).toBe( + derive("https://github.com/getpaseo/paseo.git"), + ); + }); + test.each(["ssh://git@github.com/acme/foo.git", "ssh://git@ssh.github.com:443/acme/foo.git"])( "normalizes known forge SSH URLs across remote forms: %s", (remoteUrl) => { @@ -274,6 +289,21 @@ describe("deriveProjectGroupKey", () => { ).toBe(path.join(mainRepoRoot, "packages", "app")); }); + test("keeps path-only project identities scoped to their host", () => { + const rootPath = path.resolve("repo"); + const derive = (serverId: string) => + deriveProjectGroupKey({ + rootPath, + remoteUrl: null, + worktreeRoot: null, + mainRepoRoot: null, + serverId, + }); + + expect(derive("host-a")).not.toBe(derive("host-b")); + expect(derive("host-a")).toBe(`host:6:host-a:path:${rootPath}`); + }); + test("preserves selected-path casing across Windows and POSIX hosts", () => { expect( deriveProjectGroupKey({ diff --git a/packages/server/src/server/project-group-key.ts b/packages/server/src/server/project-group-key.ts index 0698919ce..25a168097 100644 --- a/packages/server/src/server/project-group-key.ts +++ b/packages/server/src/server/project-group-key.ts @@ -28,13 +28,18 @@ export function deriveProjectGroupKey(input: { remoteUrl: string | null; worktreeRoot: string | null; mainRepoRoot: string | null; + serverId?: string; }): string { const remoteKey = deriveRemoteProjectGroupKey(input.remoteUrl); const selectedPath = deriveSelectedPath(input.rootPath, input.worktreeRoot); if (!remoteKey) { - return selectedPath && input.mainRepoRoot - ? resolve(input.mainRepoRoot, selectedPath) - : resolve(input.mainRepoRoot ?? input.rootPath); + const localPath = + selectedPath && input.mainRepoRoot + ? resolve(input.mainRepoRoot, selectedPath) + : resolve(input.mainRepoRoot ?? input.rootPath); + return input.serverId + ? `host:${input.serverId.length}:${input.serverId}:path:${localPath}` + : localPath; } return selectedPath ? `${remoteKey}#subdir:${encodeSelectedPath(selectedPath)}` : remoteKey; @@ -63,7 +68,9 @@ function deriveRemoteProjectGroupKey(remoteUrl: string | null): string | null { const userPrefix = remote.relativePathUser ? `${encodeURIComponent(remote.relativePathUser)}@` : ""; - return `remote:${userPrefix}${remote.host.toLowerCase()}/${cleanedPath}`; + const normalizedHost = remote.host.toLowerCase(); + const normalizedPath = normalizedHost === "github.com" ? cleanedPath.toLowerCase() : cleanedPath; + return `remote:${userPrefix}${normalizedHost}/${normalizedPath}`; } interface RemoteLocation { diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 80e0820e7..0132d37f9 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -739,6 +739,7 @@ export class Session { }); this.workspaceAutoName = workspaceAutoName; this.workspaceProvisioning = createWorkspaceProvisioningService({ + serverId, workspaceRegistry: this.workspaceRegistry, projectRegistry: this.projectRegistry, workspaceGitService: this.workspaceGitService, diff --git a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts index ea12646e6..d6e79ae9a 100644 --- a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts +++ b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts @@ -85,12 +85,13 @@ export class WorkspaceProvisioningError extends Error { } export function createWorkspaceProvisioningService(deps: { + serverId?: string; workspaceRegistry: WorkspaceRegistry; projectRegistry: ProjectRegistry; workspaceGitService: Pick; logger: Logger; }): WorkspaceProvisioningService { - const { workspaceRegistry, projectRegistry, workspaceGitService, logger } = deps; + const { serverId, workspaceRegistry, projectRegistry, workspaceGitService, logger } = deps; async function runInImportWorkspace( input: ImportWorkspaceInput, @@ -168,6 +169,7 @@ export function createWorkspaceProvisioningService(deps: { remoteUrl: checkout.remoteUrl, worktreeRoot: checkout.worktreeRoot, mainRepoRoot: checkout.mainRepoRoot, + serverId, }), timestamp, }); @@ -273,6 +275,7 @@ export function createWorkspaceProvisioningService(deps: { remoteUrl: checkout.remoteUrl, worktreeRoot: checkout.worktreeRoot, mainRepoRoot: checkout.mainRepoRoot, + serverId, }), timestamp: new Date().toISOString(), }); @@ -353,6 +356,7 @@ export function createWorkspaceProvisioningService(deps: { remoteUrl: projectCheckout.remoteUrl, worktreeRoot: projectCheckout.worktreeRoot, mainRepoRoot: projectCheckout.mainRepoRoot, + serverId, }); if ( project.archivedAt || @@ -406,6 +410,7 @@ export function createWorkspaceProvisioningService(deps: { remoteUrl: projectCheckout.remoteUrl, worktreeRoot: projectCheckout.worktreeRoot, mainRepoRoot: projectCheckout.mainRepoRoot, + serverId, }); if (project.kind === kind && project.projectGroupKey === projectGroupKey) return project; const refreshed = { diff --git a/packages/server/src/server/workspace-reconciliation-service.ts b/packages/server/src/server/workspace-reconciliation-service.ts index 5b24145a4..49dc1e4d8 100644 --- a/packages/server/src/server/workspace-reconciliation-service.ts +++ b/packages/server/src/server/workspace-reconciliation-service.ts @@ -82,6 +82,7 @@ export interface ReconciliationResult { } export interface WorkspaceReconciliationServiceOptions { + serverId?: string; projectRegistry: ProjectRegistry; workspaceRegistry: WorkspaceRegistry; logger: pino.Logger; @@ -112,6 +113,7 @@ interface CachedCheckoutRead { type DirectoryState = "directory" | "missing" | "unreadable"; export class WorkspaceReconciliationService { + private readonly serverId: string | undefined; private readonly projectRegistry: ProjectRegistry; private readonly workspaceRegistry: WorkspaceRegistry; private readonly logger: pino.Logger; @@ -134,6 +136,7 @@ export class WorkspaceReconciliationService { private reconcileQueuedMode: "metadata" | "full" | null = null; constructor(options: WorkspaceReconciliationServiceOptions) { + this.serverId = options.serverId; this.projectRegistry = options.projectRegistry; this.workspaceRegistry = options.workspaceRegistry; this.logger = options.logger.child({ module: "workspace-reconciliation" }); @@ -347,6 +350,7 @@ export class WorkspaceReconciliationService { remoteUrl: currentGit.remoteUrl, worktreeRoot: currentGit.worktreeRoot, mainRepoRoot: currentGit.mainRepoRoot, + serverId: this.serverId, }); if (project.kind !== mappedKind) { diff --git a/packages/server/src/server/workspace-registry-bootstrap-legacy.ts b/packages/server/src/server/workspace-registry-bootstrap-legacy.ts index 2ed18f5de..0bc326f0e 100644 --- a/packages/server/src/server/workspace-registry-bootstrap-legacy.ts +++ b/packages/server/src/server/workspace-registry-bootstrap-legacy.ts @@ -30,6 +30,7 @@ interface DirectoryProjectMembership { export function classifyDirectoryForProjectMembership(input: { cwd: string; checkout: ProjectCheckoutLitePayload; + serverId?: string; }): DirectoryProjectMembership { const cwd = resolve(input.cwd); const checkout: ProjectCheckoutLitePayload = { ...input.checkout, cwd }; @@ -38,6 +39,7 @@ export function classifyDirectoryForProjectMembership(input: { remoteUrl: checkout.remoteUrl, worktreeRoot: checkout.worktreeRoot, mainRepoRoot: checkout.mainRepoRoot, + serverId: input.serverId, }); return { diff --git a/packages/server/src/server/workspace-registry-bootstrap.ts b/packages/server/src/server/workspace-registry-bootstrap.ts index 8a75d5323..ca8e69320 100644 --- a/packages/server/src/server/workspace-registry-bootstrap.ts +++ b/packages/server/src/server/workspace-registry-bootstrap.ts @@ -45,6 +45,7 @@ function resolveAgentUpdatedAt(record: StoredAgentRecord): string { } export async function bootstrapWorkspaceRegistries(options: { + serverId?: string; paseoHome: string; agentStorage: AgentStorage; projectRegistry: ProjectRegistry; @@ -96,6 +97,7 @@ export async function bootstrapWorkspaceRegistries(options: { const membership = classifyDirectoryForProjectMembership({ cwd: normalizedCwd, checkout, + serverId: options.serverId, }); return { record, membership, directoryKey: membership.workspaceDirectoryKey }; }),