fix(projects): preserve project key compatibility

Resolve legacy workspace project references at the daemon boundary while keeping projectKey as the cross-host identity. Complete generic SSH normalization without collapsing distinct repositories.
This commit is contained in:
Mohamed Boudra
2026-07-29 09:42:05 +00:00
parent 5ae7ca7004
commit 30a6122deb
8 changed files with 122 additions and 9 deletions
@@ -2564,6 +2564,7 @@ describe("create_agent MCP tool", () => {
providerSnapshotManager: createOpenCodeManager().manager,
projectRegistry: {
get: async (projectId) => (projectId === project.projectId ? project : null),
list: async () => [project],
},
createPaseoWorktree,
logger,
@@ -108,7 +108,7 @@ export interface PaseoToolHostDependencies {
archiveWorkspaceRecord?: ArchiveDependencies["archiveWorkspaceRecord"];
emitWorkspaceUpdatesForWorkspaceIds?: ArchiveDependencies["emitWorkspaceUpdatesForWorkspaceIds"];
workspaceRegistry?: Pick<WorkspaceRegistry, "get" | "list" | "upsert">;
projectRegistry?: Pick<ProjectRegistry, "get">;
projectRegistry?: Pick<ProjectRegistry, "get" | "list">;
createDirectoryWorkspace?: (
cwd: string,
title?: string | null,
@@ -174,6 +174,51 @@ describe("deriveProjectKey", () => {
);
});
test("distinguishes SSH users for generic URL remotes", () => {
const rootPath = path.resolve("repo");
const derive = (remoteUrl: string) =>
deriveProjectKey({
rootPath,
remoteUrl,
worktreeRoot: rootPath,
mainRepoRoot: null,
});
expect(derive("ssh://alice@git.example.com/srv/repo.git")).not.toBe(
derive("ssh://bob@git.example.com/srv/repo.git"),
);
});
test("normalizes equivalent absolute SSH remote forms", () => {
const rootPath = path.resolve("repo");
const derive = (remoteUrl: string) =>
deriveProjectKey({
rootPath,
remoteUrl,
worktreeRoot: rootPath,
mainRepoRoot: null,
});
expect(derive("git@example.com:/srv/repo.git")).toBe(
derive("ssh://git@example.com/srv/repo.git"),
);
});
test("preserves query and fragment text in SSH repository paths", () => {
const rootPath = path.resolve("repo");
const derive = (remoteUrl: string) =>
deriveProjectKey({
rootPath,
remoteUrl,
worktreeRoot: rootPath,
mainRepoRoot: null,
});
expect(derive("ssh://git@example.com/repo.git?x=1#one")).not.toBe(
derive("ssh://git@example.com/repo.git?x=2#two"),
);
});
test("keeps percent sequences literal in SCP paths", () => {
const rootPath = path.resolve("repo");
const derive = (remoteUrl: string) =>
+14 -2
View File
@@ -122,13 +122,17 @@ function parseUrlRemote(remoteUrl: string): RemoteLocation | null {
const host = forgeHost ?? deriveRemoteHost(parsed);
const preserveLeadingSlash = isSsh && !forgeHost;
let remotePath = parsed.pathname || null;
if (remotePath && isSsh) remotePath += `${parsed.search}${parsed.hash}`;
if (remotePath && !preserveLeadingSlash) remotePath = remotePath.replace(/^\/+/, "");
if (!host || !remotePath) return null;
return {
host,
path: remotePath,
transport: forgeHost ? null : parsed.protocol.toLowerCase(),
relativePathUser: null,
transport: forgeHost || isSsh ? null : parsed.protocol.toLowerCase(),
relativePathUser:
isSsh && !forgeHost && parsed.username && parsed.username !== "git"
? decodeUrlComponent(parsed.username)
: null,
preserveLeadingSlash,
decodePercentEncoding: true,
stripDotGitSuffix: forgeHost !== null,
@@ -138,6 +142,14 @@ function parseUrlRemote(remoteUrl: string): RemoteLocation | null {
}
}
function decodeUrlComponent(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function normalizeCloudForgeHost(host: string): string {
return CANONICAL_CLOUD_FORGE_HOSTS.get(host.toLowerCase()) ?? host;
}
@@ -0,0 +1,24 @@
import { createRealpathAwarePathMatcher } from "../utils/path.js";
import type { PersistedProjectRecord, ProjectRegistry } from "./workspace-registry.js";
export async function resolveProjectReference(
reference: string,
projectRegistry: Pick<ProjectRegistry, "get" | "list">,
pathHints: readonly string[] = [],
): Promise<PersistedProjectRecord | null> {
const direct = await projectRegistry.get(reference);
if (direct) return direct;
// COMPAT(projectKeyAsProjectId): added in v0.2.4 on 2026-07-29; remove after 2027-01-29.
// Older clients return ProjectPlacementPayload.projectKey in workspace source.projectId.
const candidates = (await projectRegistry.list()).filter(
(project) => !project.archivedAt && project.projectKey === reference,
);
for (const pathHint of pathHints) {
const matches = candidates.filter((project) =>
createRealpathAwarePathMatcher(project.rootPath)(pathHint),
);
if (matches.length === 1) return matches[0] ?? null;
}
return candidates.length === 1 ? (candidates[0] ?? null) : null;
}
@@ -457,6 +457,26 @@ test("createWorkspaceForDirectory refreshes an explicit project's stale Git kind
});
});
test("createWorkspaceForDirectory accepts a legacy project-key reference", async () => {
const rootPath = path.join(tmpDir, "repo");
gitRoots.add(rootPath);
const project = await projectRegistry.getOrCreateActiveByRoot({
rootPath,
kind: "git",
displayName: "repo",
projectKey: "remote:github.com/acme/repo",
timestamp: ARCHIVED_AT,
});
const workspace = await provisioning.createWorkspaceForDirectory(
rootPath,
null,
"remote:github.com/acme/repo",
);
expect(workspace.projectId).toBe(project.projectId);
});
test("createWorkspaceForDirectory classifies unknown and archived explicit projects", async () => {
await expect(
provisioning.createWorkspaceForDirectory(path.join(tmpDir, "directory"), null, "missing"),
@@ -15,6 +15,7 @@ import {
import type { WorkspaceGitService } from "../../workspace-git-service.js";
import type { CreatePaseoWorktreeWorkflowResult } from "../../worktree-session.js";
import { deriveProjectKey } from "../../project-key.js";
import { resolveProjectReference } from "../../project-reference.js";
import { areEquivalentPaths, createRealpathAwarePathMatcher } from "../../../utils/path.js";
export interface ResolveOrCreateWorkspaceIdInput {
@@ -175,8 +176,11 @@ export function createWorkspaceProvisioningService(deps: {
});
}
async function requireActiveProject(projectId: string): Promise<PersistedProjectRecord> {
const project = await projectRegistry.get(projectId);
async function requireActiveProject(
projectId: string,
pathHints: readonly string[] = [],
): Promise<PersistedProjectRecord> {
const project = await resolveProjectReference(projectId, projectRegistry, pathHints);
if (!project) throw new WorkspaceProvisioningError("unknown_project", projectId);
if (project.archivedAt) throw new WorkspaceProvisioningError("archived_project", projectId);
return project;
@@ -191,7 +195,11 @@ export function createWorkspaceProvisioningService(deps: {
const normalizedCwd = resolve(cwd);
const checkout = await workspaceGitService.getCheckout(normalizedCwd);
const project = projectId
? await refreshProjectKind(await requireActiveProject(projectId), normalizedCwd, checkout)
? await refreshProjectKind(
await requireActiveProject(projectId, [normalizedCwd]),
normalizedCwd,
checkout,
)
: // COMPAT(workspaceCreateMissingProjectId): added in v0.1.107, remove after 2027-01-15.
await findOrCreateProjectForDirectory(normalizedCwd);
const timestamp = new Date().toISOString();
@@ -247,7 +255,9 @@ export function createWorkspaceProvisioningService(deps: {
repoRoot: string;
}): Promise<PersistedProjectRecord> {
if (input.projectId) {
return refreshProjectKind(await requireActiveProject(input.projectId));
return refreshProjectKind(
await requireActiveProject(input.projectId, [input.sourceCwd, input.repoRoot]),
);
}
const workspaces = await workspaceRegistry.list();
@@ -1,4 +1,5 @@
import { expandTilde } from "../utils/path.js";
import { resolveProjectReference } from "./project-reference.js";
import type { ProjectRegistry } from "./workspace-registry.js";
export interface WorktreeWorkspaceSource {
@@ -8,7 +9,7 @@ export interface WorktreeWorkspaceSource {
export async function resolveWorktreeSourceCwd(
source: WorktreeWorkspaceSource,
projectRegistry: Pick<ProjectRegistry, "get">,
projectRegistry: Pick<ProjectRegistry, "get" | "list">,
): Promise<string> {
if (source.cwd) {
return expandTilde(source.cwd);
@@ -17,7 +18,7 @@ export async function resolveWorktreeSourceCwd(
throw new Error("cwd or projectId is required for a worktree-backed workspace");
}
const project = await projectRegistry.get(source.projectId);
const project = await resolveProjectReference(source.projectId, projectRegistry);
if (!project || project.archivedAt) {
throw new Error(`Project not found: ${source.projectId}`);
}