mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
fix(projects): harden grouping identity and routing
This commit is contained in:
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface SeedDaemonClient {
|
||||
addProject(cwd: string): Promise<{
|
||||
project: {
|
||||
projectId: string;
|
||||
projectGroupKey?: string;
|
||||
projectDisplayName: string;
|
||||
projectRootPath: string;
|
||||
} | null;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -739,6 +739,7 @@ export class Session {
|
||||
});
|
||||
this.workspaceAutoName = workspaceAutoName;
|
||||
this.workspaceProvisioning = createWorkspaceProvisioningService({
|
||||
serverId,
|
||||
workspaceRegistry: this.workspaceRegistry,
|
||||
projectRegistry: this.projectRegistry,
|
||||
workspaceGitService: this.workspaceGitService,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user