fix(projects): harden grouping identity and routing

This commit is contained in:
Mohamed Boudra
2026-07-29 07:40:36 +00:00
parent f054e403a6
commit 39e5006882
14 changed files with 93 additions and 22 deletions

View File

@@ -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();
}
});

View File

@@ -27,6 +27,7 @@ export interface SeedDaemonClient {
addProject(cwd: string): Promise<{
project: {
projectId: string;
projectGroupKey?: string;
projectDisplayName: string;
projectRootPath: string;
} | null;

View File

@@ -265,6 +265,7 @@ interface ProjectHeaderRowProps {
chevron: "expand" | "collapse" | null;
onPress: () => void;
worktreeTarget: SidebarProjectHostTarget | null;
onlineServerIds: ReadonlySet<string>;
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<string>;
isHovered: boolean;
isMobileBreakpoint: boolean;
isProjectActive: boolean;
@@ -532,7 +535,13 @@ function ProjectRowTrailingActions({
>
<ProjectKebabMenu
projectKey={project.projectKey}
projectSettingsKey={resolveProjectSettingsRouteKey(project)}
projectSettingsKey={resolveProjectSettingsRouteKey({
...project,
hosts: project.hosts.map((host) => ({
...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}

View File

@@ -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",
);
});
});

View File

@@ -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}`;
}

View File

@@ -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,
);

View File

@@ -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,

View File

@@ -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({

View File

@@ -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 {

View File

@@ -739,6 +739,7 @@ export class Session {
});
this.workspaceAutoName = workspaceAutoName;
this.workspaceProvisioning = createWorkspaceProvisioningService({
serverId,
workspaceRegistry: this.workspaceRegistry,
projectRegistry: this.projectRegistry,
workspaceGitService: this.workspaceGitService,

View File

@@ -85,12 +85,13 @@ export class WorkspaceProvisioningError extends Error {
}
export function createWorkspaceProvisioningService(deps: {
serverId?: string;
workspaceRegistry: WorkspaceRegistry;
projectRegistry: ProjectRegistry;
workspaceGitService: Pick<WorkspaceGitService, "getCheckout" | "peekSnapshot">;
logger: Logger;
}): WorkspaceProvisioningService {
const { workspaceRegistry, projectRegistry, workspaceGitService, logger } = deps;
const { serverId, workspaceRegistry, projectRegistry, workspaceGitService, logger } = deps;
async function runInImportWorkspace<T>(
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 = {

View File

@@ -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) {

View File

@@ -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 {

View File

@@ -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 };
}),