Restore project grouping across hosts (#2565)

* fix(projects): restore cross-host grouping

Random host-local project IDs replaced the remote-derived value that had also served as grouping identity. Persist a separate opaque group key and let normal boot reconciliation backfill older records without a migration.

* fix(projects): keep ambiguous clones separate

A shared remote can identify a project across hosts, but it cannot distinguish two independent clones on one host. Treat those keys as ambiguous so host-local project IDs remain separate.

* fix(projects): preserve host-local project identity

* fix(projects): strengthen grouping identity

* test(app): update project host shape contract

* fix(projects): preserve selected host identity

* fix(projects): close grouped routing gaps

* fix(projects): stabilize legacy subproject grouping

* fix(projects): preserve selected project identity

* fix(projects): isolate legacy host identity

* fix(projects): preserve symlinked bootstrap roots

* fix(projects): reconcile grouped custom names

* fix(projects): stabilize settings and path identity

* fix(projects): normalize default remote ports

* fix(projects): harden host and remote identity

* fix(projects): preserve host-scoped actions

* fix(projects): preserve remote path semantics

* fix(projects): model SCP remote semantics

* fix(projects): keep settings on routed host

* fix(projects): preserve SSH URL path semantics

* fix(projects): separate identity from presentation

* test(projects): use grouped identity in UI specs

* fix(projects): harden grouped project routing

* test(projects): expect framed settings route

* fix(projects): preserve exact placement identity

* fix(projects): harden grouping identity and routing

* fix(projects): restore project key as grouping identity

Random host-local project IDs had replaced the persisted cross-host value carried by projectKey. Keep projectId for routing while reconciliation owns and persists projectKey for grouping.

* fix(projects): prevent project key collisions

Preserve platform path semantics and generic remote transports in cross-host keys, and compare legacy checkout roots using filesystem equivalence before materializing ownership.

* test(projects): assert host-scoped project keys

* 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.

* fix(projects): harden project reference identity

* fix(projects): retain archived reference errors

* fix(projects): scope legacy paths to hosts

* test(app): model persisted project keys

* fix(projects): retain absolute scp users

* fix(projects): resolve legacy mutation aliases

* fix(projects): preserve conservative identity boundaries

* fix(projects): preserve legacy placement identity

* test(projects): assert legacy placement identity

* test(projects): expect canonical Windows keys

* fix(projects): separate bootstrap identity fields

* fix(projects): scope legacy paths by host

* fix(projects): stabilize opaque project identity

* fix(projects): preserve remaining opaque identities

* fix(projects): classify forward-slash UNC roots

* fix(projects): preserve opaque selection keys

* fix(projects): frame host-local project keys

* fix(projects): canonicalize framed identities

* test(projects): isolate repeated remote fixtures

* fix(projects): keep project key as UI identity

* fix(projects): reconcile hydrated project references

* fix(projects): preserve archived project identity

* refactor(projects): group projects from project records, not workspaces

Cross-host grouping had been built by hanging a projectKey off every
workspace descriptor. That scoped the key to the wrong entity: the app
then had to reconstruct project identity per workspace in six modules,
which forced projectKey fallbacks into RPC arguments and a server-side
resolver that turned grouping keys back into project ids.

A workspace has a projectId. The key belongs to the project, and
grouping is a display concern, so the daemon now publishes projects
over project.list plus the existing project.update channel and the app
groups them in one place. Projects are addressed by host-local
projectId everywhere, including the settings route.

Projects persisted without a key are backfilled by ordinary boot
reconciliation, with no migration. Old daemons advertise no projectList
capability and simply do not group.

* test(projects): seed projects alongside workspaces in fixtures

Sidebar fixtures seeded workspaces without their projects, which the
daemon cannot produce now that projects arrive on their own channel, so
nothing rendered. Adds a seeding helper that supplies both.

Also drops the provisioning test for accepting a project key where a
project id belongs; that resolver was removed deliberately.

* fix(projects): keep derived project identity stable

Four gaps left by shrinking the key derivation:

Local keys used a bare path resolve, so one directory reached by a
different spelling produced a different key and reset grouped identity.
Windows CI caught this with the 8.3 and long-name forms of one temp
path. Canonicalize through the shared realpath-aware normalization.

Subproject display names lost their selected-path input, so every
subproject of a monorepo rendered under the same remote-derived name.

Reconciliation only ever backfilled a missing key, so a project whose
remote changed stayed grouped with hosts on the old remote.

Project settings kept a disconnected host selected and rendered a dead
end with no way back to an editable host.

* fix(projects): read scheme remotes as URLs, not scp form

An `ssh://user@host:22/org/repo` remote matched the scp-form pattern
first, because `[^@]+` happily eats `ssh://git`, so the port landed in
the path and the project keyed as `remote:host/22/org/repo`. Two
spellings of one remote therefore never grouped.

Try scp form only when there is no scheme, and drop protocol-default
ports so an explicitly written `:22` matches the same remote without it.
This commit is contained in:
Mohamed Boudra
2026-07-29 23:36:31 +02:00
committed by GitHub
parent 9e5accee3c
commit 20e46e3e3f
113 changed files with 3073 additions and 2038 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,
+4 -1
View File
@@ -793,6 +793,7 @@ export async function createPaseoDaemon(
},
});
const workspaceProvisioning = createWorkspaceProvisioningService({
serverId,
projectRegistry,
workspaceRegistry,
workspaceGitService,
@@ -829,6 +830,7 @@ export async function createPaseoDaemon(
await agentStorage.initialize();
logger.info({ elapsed: elapsed() }, "Agent storage initialized");
await bootstrapWorkspaceRegistries({
serverId,
paseoHome: config.paseoHome,
agentStorage,
projectRegistry,
@@ -842,6 +844,7 @@ export async function createPaseoDaemon(
releaseWorkspaceServicePortPlan(workspaceId);
};
const workspaceReconciliation = new WorkspaceReconciliationService({
serverId,
projectRegistry,
workspaceRegistry,
logger,
@@ -857,7 +860,7 @@ export async function createPaseoDaemon(
},
});
await workspaceReconciliation.start();
void workspaceReconciliation.runOnce().catch((error) => {
void workspaceReconciliation.reconcileNow().catch((error) => {
logger.warn({ err: error }, "Initial workspace reconciliation failed");
});
await chatService.initialize();
@@ -23,6 +23,7 @@ import { readPaseoWorktreeMetadata } from "../utils/worktree-metadata.js";
import { createWorktree, getPaseoWorktreesRoot } from "../utils/worktree.js";
import { isPlatform } from "../test-utils/platform.js";
import { areEquivalentPaths, createRealpathAwarePathMatcher } from "../utils/path.js";
import { deriveProjectKey } from "./project-key.js";
const cleanupPaths: string[] = [];
@@ -73,7 +74,16 @@ 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,
projectKey: deriveProjectKey({
rootPath: result.repoRoot,
remoteUrl: null,
worktreeRoot: null,
mainRepoRoot: null,
}),
updatedAt: expect.any(String),
});
expect(events).toEqual([`workspace:${result.workspace.workspaceId}`]);
});
@@ -436,7 +446,16 @@ 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,
projectKey: deriveProjectKey({
rootPath: project.rootPath,
remoteUrl: null,
worktreeRoot: null,
mainRepoRoot: null,
}),
updatedAt: expect.any(String),
});
});
// POSIX-only: Windows git worktree paths need separate canonicalization coverage.
@@ -0,0 +1,141 @@
import { mkdirSync, mkdtempSync, rmSync, symlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, test } from "vitest";
import { deriveProjectGroupingDisplayName, deriveProjectKey } from "./project-key.js";
describe("deriveProjectKey", () => {
const rootPath = path.resolve("repo");
const derive = (remoteUrl: string) =>
deriveProjectKey({ rootPath, remoteUrl, worktreeRoot: rootPath, mainRepoRoot: null });
test.each([
"https://github.com/getpaseo/paseo.git",
"ssh://git@github.com/getpaseo/paseo.git",
"git@github.com:getpaseo/paseo.git",
])("normalizes common remote form %s", (remoteUrl) => {
expect(derive(remoteUrl)).toBe("remote:github.com/getpaseo/paseo");
});
test("normalizes GitHub casing", () => {
expect(derive("git@github.com:GetPaseo/Paseo.git")).toBe("remote:github.com/getpaseo/paseo");
});
test("preserves self-hosted paths and explicit ports", () => {
expect(derive("https://git.example.com:8443/Team/App.git")).toBe(
"remote:git.example.com:8443/Team/App",
);
});
test("groups an SSH remote whether or not its default port is spelled out", () => {
expect(derive("ssh://git@github.com:22/getpaseo/paseo.git")).toBe(
derive("ssh://git@github.com/getpaseo/paseo.git"),
);
});
test("keeps projects without a remote host-local", () => {
const hostA = deriveProjectKey({
rootPath,
remoteUrl: null,
worktreeRoot: null,
mainRepoRoot: null,
serverId: "host-a",
});
const hostB = deriveProjectKey({
rootPath,
remoteUrl: null,
worktreeRoot: null,
mainRepoRoot: null,
serverId: "host-b",
});
expect(hostA).not.toBe(hostB);
});
test("uses one host-local key for two spellings of the same directory", () => {
const tempRoot = mkdtempSync(path.join(tmpdir(), "project-key-identity-"));
const projectRoot = path.join(tempRoot, "project");
const alternateRoot = path.join(tempRoot, "project-alias");
const deriveLocalProject = (localRootPath: string) =>
deriveProjectKey({
rootPath: localRootPath,
remoteUrl: null,
worktreeRoot: null,
mainRepoRoot: null,
serverId: "host-a",
});
try {
mkdirSync(projectRoot);
symlinkSync(projectRoot, alternateRoot, process.platform === "win32" ? "junction" : "dir");
expect(deriveLocalProject(alternateRoot)).toBe(deriveLocalProject(projectRoot));
} finally {
rmSync(tempRoot, { recursive: true, force: true });
}
});
test("keeps a repository root distinct from one of its subprojects", () => {
const worktreeRoot = path.resolve("host-a", "repo");
const remoteUrl = "git@github.com:getpaseo/paseo.git";
expect(
deriveProjectKey({ rootPath: worktreeRoot, remoteUrl, worktreeRoot, mainRepoRoot: null }),
).toBe("remote:github.com/getpaseo/paseo");
expect(
deriveProjectKey({
rootPath: path.join(worktreeRoot, "packages", "app"),
remoteUrl,
worktreeRoot,
mainRepoRoot: null,
}),
).toBe("remote:github.com/getpaseo/paseo#subdir:packages/app");
});
test("groups the same subproject across different absolute checkout roots", () => {
const remoteUrl = "git@github.com:getpaseo/paseo.git";
const deriveSubproject = (worktreeRoot: string) =>
deriveProjectKey({
rootPath: path.join(worktreeRoot, "packages", "app"),
remoteUrl,
worktreeRoot,
mainRepoRoot: null,
});
expect(deriveSubproject(path.resolve("host-a", "repo"))).toBe(
deriveSubproject(path.resolve("host-b", "different", "repo")),
);
});
});
describe("deriveProjectGroupingDisplayName", () => {
test("uses the owner and repository from a remote", () => {
expect(
deriveProjectGroupingDisplayName({
rootPath: path.resolve("repo"),
remoteUrl: "git@github.com:getpaseo/paseo.git",
worktreeRoot: path.resolve("repo"),
}),
).toBe("getpaseo/paseo");
});
test("uses the selected directory name without a remote", () => {
expect(
deriveProjectGroupingDisplayName({
rootPath: path.resolve("acme", "app"),
remoteUrl: null,
worktreeRoot: null,
}),
).toBe("app");
});
test("uses the selected subproject directory name instead of the repository remote", () => {
const worktreeRoot = path.resolve("acme", "repo");
expect(
deriveProjectGroupingDisplayName({
rootPath: path.join(worktreeRoot, "packages", "server"),
remoteUrl: "git@github.com:acme/repo.git",
worktreeRoot,
}),
).toBe("server");
});
});
+57
View File
@@ -0,0 +1,57 @@
import { resolve, win32 } from "node:path";
import { isGitHubHost, parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
import { getRealpathAwareRelativePath, normalizePathForIdentity } from "../utils/path.js";
/** Persisted opaque key used to join the same remote across hosts. */
export function deriveProjectKey(input: {
rootPath: string;
remoteUrl: string | null;
worktreeRoot: string | null;
mainRepoRoot: string | null;
serverId?: string;
}): string {
const remote = input.remoteUrl ? parseGitRemoteLocation(input.remoteUrl) : null;
const selectedPath = input.worktreeRoot
? getRealpathAwareRelativePath(input.worktreeRoot, input.rootPath) || null
: null;
if (remote) {
const host = remote.port ? `${remote.host}:${remote.port}` : remote.host;
const path = isGitHubHost(remote.host) ? remote.path.toLowerCase() : remote.path;
const remoteKey = `remote:${host}/${path}`;
return selectedPath ? `${remoteKey}#subdir:${selectedPath.replaceAll("\\", "/")}` : remoteKey;
}
const localPathParts = [
selectedPath && input.mainRepoRoot ? input.mainRepoRoot : input.rootPath,
selectedPath && input.mainRepoRoot ? selectedPath : "",
];
const resolvedLocalPath = localPathParts.some(looksLikeWindowsPath)
? win32.resolve(...localPathParts)
: resolve(...localPathParts);
const localPath = normalizePathForIdentity(resolvedLocalPath);
return input.serverId ? `host:${input.serverId}:${localPath}` : localPath;
}
export function deriveProjectGroupingDisplayName(input: {
rootPath: string;
remoteUrl: string | null;
worktreeRoot: string | null;
}): string {
const selectedPath = input.worktreeRoot
? getRealpathAwareRelativePath(input.worktreeRoot, input.rootPath)
: null;
if (selectedPath) return lastPathSegment(input.rootPath);
const remote = input.remoteUrl ? parseGitRemoteLocation(input.remoteUrl) : null;
if (!remote) return lastPathSegment(input.rootPath);
return remote.path.split("/").filter(Boolean).slice(-2).join("/") || input.rootPath;
}
function lastPathSegment(inputPath: string): string {
const segments = inputPath.split(/[\\/]/u).filter(Boolean);
return segments[segments.length - 1] ?? inputPath;
}
function looksLikeWindowsPath(value: string): boolean {
return /^[a-zA-Z]:[\\/]/u.test(value) || /^[/\\]{2}[^/\\]+[/\\][^/\\]+/u.test(value);
}
+63 -4
View File
@@ -25,6 +25,7 @@ import type { StoredAgentRecord } from "./agent/agent-storage.js";
import type { AgentManagerEvent } from "./agent/agent-manager.js";
import type { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js";
import { createPersistedProjectRecord } from "./workspace-registry.js";
import { deriveProjectKey } from "./project-key.js";
import type { SessionOptions } from "./session.js";
import type { SessionInboundMessage, SessionOutboundMessage } from "./messages.js";
import {
@@ -649,6 +650,12 @@ describe("project command-center RPCs", () => {
rootPath: directoryPath,
kind: "non_git",
displayName: "new-project",
projectKey: deriveProjectKey({
rootPath: directoryPath,
remoteUrl: null,
worktreeRoot: null,
mainRepoRoot: null,
}),
timestamp: expect.any(String),
});
expect(messages).toEqual([
@@ -3416,7 +3423,7 @@ describe("session workspace descriptors", () => {
const messages: unknown[] = [];
const workspace = {
workspaceId: "ws-gh",
projectId: "remote:github.com/acme/app",
projectId: "prj_app",
cwd: "/repo/app",
kind: "local_checkout" as const,
displayName: "app",
@@ -3424,7 +3431,8 @@ describe("session workspace descriptors", () => {
archivedAt: null,
};
const project = {
projectId: "remote:github.com/acme/app",
projectId: "prj_app",
projectKey: "remote:github.com/acme/app",
rootPath: "/repo/app",
kind: "git" as const,
displayName: "acme/app",
@@ -3435,7 +3443,10 @@ describe("session workspace descriptors", () => {
const session = createSessionForTest({
messages,
workspaceRegistry: { get: vi.fn(), list: vi.fn().mockResolvedValue([workspace]) },
projectRegistry: { list: vi.fn().mockResolvedValue([project]), get: vi.fn() },
projectRegistry: {
list: vi.fn().mockResolvedValue([project]),
get: vi.fn().mockResolvedValue(project),
},
workspaceGitService: {
getSnapshot: vi.fn(),
peekSnapshot: vi.fn(() =>
@@ -3464,8 +3475,9 @@ describe("session workspace descriptors", () => {
entries: [
expect.objectContaining({
id: "ws-gh",
projectId: "prj_app",
project: expect.objectContaining({
projectKey: "remote:github.com/acme/app",
projectKey: "prj_app",
projectName: "acme/app",
workspaceName: "app",
checkout: expect.objectContaining({
@@ -4900,6 +4912,53 @@ test("sends project updates only to capable sockets in a retained session", () =
]);
});
test("project.list returns every active project descriptor", async () => {
const messages: SessionOutboundMessage[] = [];
const active = createPersistedProjectRecord({
projectId: "project-active",
projectKey: "remote:github.com/acme/app",
rootPath: "/tmp/project-active",
kind: "git",
displayName: "acme/app",
createdAt: "2026-07-17T00:00:00.000Z",
updatedAt: "2026-07-17T00:00:00.000Z",
});
const archived = createPersistedProjectRecord({
projectId: "project-archived",
rootPath: "/tmp/project-archived",
kind: "non_git",
displayName: "archived",
createdAt: "2026-07-17T00:00:00.000Z",
updatedAt: "2026-07-17T00:00:00.000Z",
archivedAt: "2026-07-18T00:00:00.000Z",
});
const session = createSessionForTest({
messages,
projectRegistry: { list: vi.fn().mockResolvedValue([active, archived]) },
});
await session.handleMessage({ type: "project.list.request", requestId: "projects-1" });
expect(messages).toEqual([
{
type: "project.list.response",
payload: {
requestId: "projects-1",
projects: [
{
projectId: "project-active",
projectKey: "remote:github.com/acme/app",
projectDisplayName: "acme/app",
projectCustomName: null,
projectRootPath: "/tmp/project-active",
projectKind: "git",
},
],
},
},
]);
});
describe("agent config setters", () => {
function liveAgentManager(overrides: { [K in keyof SessionOptions["agentManager"]]?: unknown }): {
[K in keyof SessionOptions["agentManager"]]?: unknown;
+32 -3
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,
@@ -2073,6 +2074,8 @@ export class Session {
switch (msg.type) {
case "fetch_workspaces_request":
return this.handleFetchWorkspacesRequest(msg);
case "project.list.request":
return this.handleProjectListRequest(msg.requestId);
case "paseo_worktree_list_request":
return this.handlePaseoWorktreeListRequest(msg);
case "paseo_worktree_archive_request":
@@ -2631,7 +2634,7 @@ export class Session {
// resolved name lands in the UI immediately.
const workspaces = await this.workspaceRegistry.list();
const affectedWorkspaceIds = workspaces
.filter((workspace) => workspace.projectId === projectId)
.filter((workspace) => workspace.projectId === existing.projectId)
.map((workspace) => workspace.workspaceId);
if (affectedWorkspaceIds.length > 0) {
await this.emitWorkspaceUpdatesForWorkspaceIds(affectedWorkspaceIds);
@@ -2670,8 +2673,10 @@ export class Session {
this.sessionLogger.info({ projectId, requestId }, "session: project.remove.request");
try {
const project = await this.projectRegistry.get(projectId);
const resolvedProjectId = project?.projectId ?? projectId;
const projectWorkspaces = (await this.workspaceRegistry.list()).filter(
(workspace) => workspace.projectId === projectId,
(workspace) => workspace.projectId === resolvedProjectId,
);
const activeWorkspaceIds = projectWorkspaces
.filter((workspace) => !workspace.archivedAt)
@@ -2699,7 +2704,7 @@ export class Session {
removedWorkspaceIds.push(workspaceId);
}
await this.projectRegistry.remove(projectId);
await this.projectRegistry.remove(resolvedProjectId);
} finally {
if (activeWorkspaceIds.length > 0) {
this.clearWorkspaceArchiving(activeWorkspaceIds);
@@ -4460,6 +4465,7 @@ export class Session {
): WorkspaceProjectDescriptorPayload {
return {
projectId: project.projectId,
...(project.projectKey ? { projectKey: project.projectKey } : {}),
projectDisplayName: resolveProjectDisplayName(project),
projectCustomName: project.customName ?? null,
projectRootPath: project.rootPath,
@@ -4934,6 +4940,29 @@ export class Session {
}
}
private async handleProjectListRequest(requestId: string): Promise<void> {
try {
const projects = (await this.projectRegistry.list())
.filter((project) => !project.archivedAt)
.map((project) => this.buildProjectDescriptor(project));
this.emit({
type: "project.list.response",
payload: { requestId, projects },
});
} catch (error) {
this.sessionLogger.error({ err: error }, "Failed to handle project.list.request");
this.emit({
type: "rpc_error",
payload: {
requestId,
requestType: "project.list.request",
error: error instanceof Error ? error.message : "Failed to list projects",
code: "project_list_failed",
},
});
}
}
// Build the bootstrap snapshot used by `flushBootstrappedWorkspaceUpdates`
// to decide which pending updates to drop. Captures the status,
// statusEnteredAt, and activityAt (parsed to ms) for each workspace entry
@@ -46,6 +46,7 @@ import type { GeneratedWorkspaceName } from "./worktree-branch-name-generator.js
import { WorkspaceAutoName } from "./workspace-auto-name.js";
import type { ForgeService } from "../services/forge-service.js";
import { createNoopWorkspaceGitService } from "./test-utils/workspace-git-service-stub.js";
import { deriveProjectKey } from "./project-key.js";
import {
asSessionLogger,
asAgentManager,
@@ -819,6 +820,7 @@ test("create_agent_request keeps requested child cwd when grouped under an exist
const session = asTestSession(
new Session({
clientId: "test-client",
serverId: "test-server",
scopes: ["*"],
appVersion: null,
onMessage: (message) => emitted.push(message),
@@ -870,10 +872,21 @@ test("create_agent_request keeps requested child cwd when grouped under an exist
const [createdAgent] = agentManager.listAgents();
expect(createdAgent?.cwd).toBe(child);
const createdWorkspace = await workspaceRegistry.get(createdAgent!.workspaceId!);
expect(createdWorkspace).not.toBeNull();
await expect(projectRegistry.get(createdWorkspace!.projectId)).resolves.toMatchObject({
projectKey: deriveProjectKey({
rootPath: child,
remoteUrl: null,
worktreeRoot: null,
mainRepoRoot: null,
serverId: "test-server",
}),
});
await expect(
session.buildProjectPlacementForWorkspaceId(createdAgent!.workspaceId!),
).resolves.toMatchObject({
projectKey: expect.stringMatching(/^prj_[0-9a-f]{16}$/),
projectKey: createdWorkspace!.projectId,
checkout: { cwd: child },
});
expect(findByType(emitted, "status")?.payload).toMatchObject({
@@ -3405,6 +3418,7 @@ test("project.remove.request archives active workspaces and removes the project
});
const project = createPersistedProjectRecord({
projectId: "proj-remove-with-workspace",
projectKey: "remote:github.com/acme/remove-with-workspace",
rootPath: REPO_CWD,
kind: "git",
displayName: "repo",
@@ -3710,7 +3724,15 @@ 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,
projectKey: deriveProjectKey({
rootPath: explicitProject.rootPath,
remoteUrl: null,
worktreeRoot: null,
mainRepoRoot: null,
}),
});
});
test("workspace updates stay scoped to the matching cwd", async () => {
@@ -7387,7 +7409,8 @@ test("project.rename.request stores customName and emits an updated workspace de
);
const project = createPersistedProjectRecord({
projectId: "remote:github.com/acme/repo",
projectId: "prj_rename",
projectKey: "remote:github.com/acme/repo",
rootPath: REPO_CWD,
kind: "git",
displayName: "acme/repo",
@@ -206,6 +206,10 @@ test("reopening archived exact-root records restores the fresh Git project", asy
displayName: "repo",
timestamp: ARCHIVED_AT,
});
await projectRegistry.upsert({
...project,
projectKey: "remote:github.com/acme/old-repo",
});
const workspace = createPersistedWorkspaceRecord({
workspaceId: "ws-archived-root",
projectId: project.projectId,
@@ -227,7 +231,7 @@ test("reopening archived exact-root records restores the fresh Git project", asy
cwd,
isGit: true,
currentBranch: "main",
remoteUrl: null,
remoteUrl: "https://github.com/acme/new-repo.git",
worktreeRoot: cwd,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
@@ -244,6 +248,7 @@ test("reopening archived exact-root records restores the fresh Git project", asy
});
expect(await projectRegistry.get(project.projectId)).toMatchObject({
kind: "git",
projectKey: "remote:github.com/acme/new-repo",
archivedAt: null,
});
});
@@ -14,6 +14,7 @@ import {
} from "../../workspace-registry.js";
import type { WorkspaceGitService } from "../../workspace-git-service.js";
import type { CreatePaseoWorktreeWorkflowResult } from "../../worktree-session.js";
import { deriveProjectKey } from "../../project-key.js";
import { areEquivalentPaths, createRealpathAwarePathMatcher } from "../../../utils/path.js";
export interface ResolveOrCreateWorkspaceIdInput {
@@ -84,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,
@@ -162,6 +164,13 @@ export function createWorkspaceProvisioningService(deps: {
rootPath,
kind: checkout.isGit ? "git" : "non_git",
displayName: basename(rootPath) || rootPath,
projectKey: deriveProjectKey({
rootPath,
remoteUrl: checkout.remoteUrl,
worktreeRoot: checkout.worktreeRoot,
mainRepoRoot: checkout.mainRepoRoot,
serverId,
}),
timestamp,
});
}
@@ -256,10 +265,18 @@ export function createWorkspaceProvisioningService(deps: {
// Orphaned legacy workspace FKs fall through to exact-root allocation.
}
const checkout = await workspaceGitService.getCheckout(input.repoRoot);
const project = await projectRegistry.getOrCreateActiveByRoot({
rootPath: input.repoRoot,
kind: "git",
displayName: basename(input.repoRoot) || input.repoRoot,
projectKey: deriveProjectKey({
rootPath: input.repoRoot,
remoteUrl: checkout.remoteUrl,
worktreeRoot: checkout.worktreeRoot,
mainRepoRoot: checkout.mainRepoRoot,
serverId,
}),
timestamp: new Date().toISOString(),
});
return refreshProjectKind(project);
@@ -334,8 +351,21 @@ export function createWorkspaceProvisioningService(deps: {
? checkout
: await workspaceGitService.getCheckout(project.rootPath);
const kind = projectCheckout.isGit ? "git" : "non_git";
if (project.archivedAt || project.kind !== kind) {
await projectRegistry.upsert({ ...project, kind, archivedAt: null, updatedAt: timestamp });
const projectKey = deriveProjectKey({
rootPath: project.rootPath,
remoteUrl: projectCheckout.remoteUrl,
worktreeRoot: projectCheckout.worktreeRoot,
mainRepoRoot: projectCheckout.mainRepoRoot,
serverId,
});
if (project.archivedAt || project.kind !== kind || project.projectKey !== projectKey) {
await projectRegistry.upsert({
...project,
kind,
projectKey,
archivedAt: null,
updatedAt: timestamp,
});
}
}
if (!next) return workspace;
@@ -371,8 +401,20 @@ export function createWorkspaceProvisioningService(deps: {
? workspaceCheckout
: await workspaceGitService.getCheckout(project.rootPath);
const kind: PersistedProjectRecord["kind"] = projectCheckout.isGit ? "git" : "non_git";
if (project.kind === kind) return project;
const refreshed = { ...project, kind, updatedAt: new Date().toISOString() };
const projectKey = deriveProjectKey({
rootPath: project.rootPath,
remoteUrl: projectCheckout.remoteUrl,
worktreeRoot: projectCheckout.worktreeRoot,
mainRepoRoot: projectCheckout.mainRepoRoot,
serverId,
});
if (project.kind === kind && project.projectKey === projectKey) return project;
const refreshed = {
...project,
kind,
projectKey,
updatedAt: new Date().toISOString(),
};
await projectRegistry.upsert(refreshed);
return refreshed;
}
@@ -260,6 +260,7 @@ function createNoopProjectRegistry(): ProjectRegistry {
rootPath: input.rootPath,
kind: input.kind,
displayName: input.displayName,
projectKey: input.projectKey ?? null,
customName: null,
createdAt: input.timestamp,
updatedAt: input.timestamp,
@@ -1530,6 +1531,8 @@ export class VoiceAssistantWebSocketServer {
projectRemove: true,
// COMPAT(projectAdd): added in v0.1.97, drop the gate when floor >= v0.1.97.
projectAdd: true,
// COMPAT(projectList): added in v0.2.4, drop the gate when floor >= v0.2.4.
projectList: true,
// COMPAT(worktreeRestore): keep through 2027-01-11 for clients older than v0.1.105.
worktreeRestore: true,
// COMPAT(workspaceRecovery): added in v0.1.105, remove after 2027-01-11 once daemon floor >= v0.1.105.
@@ -502,6 +502,7 @@ export class WorkspaceDirectory {
)
.map((project) => ({
projectId: project.projectId,
projectKey: project.projectKey ?? undefined,
projectDisplayName: resolveProjectDisplayName(project),
projectCustomName: project.customName ?? null,
projectRootPath: project.rootPath,
@@ -19,6 +19,16 @@ import {
type ReconciliationChange,
WorkspaceReconciliationService,
} from "./workspace-reconciliation-service.js";
import { deriveProjectKey } from "./project-key.js";
function canonicalLocalProjectKey(rootPath: string): string {
return deriveProjectKey({
rootPath,
remoteUrl: null,
worktreeRoot: null,
mainRepoRoot: null,
});
}
function createTestRegistries() {
const projects = new Map<string, PersistedProjectRecord>();
@@ -252,8 +262,21 @@ describe("WorkspaceReconciliationService", () => {
});
const metadataResult = await service.reconcileGitMetadata();
const projectKey = deriveProjectKey({
rootPath: projectRoot,
remoteUrl: null,
worktreeRoot: null,
mainRepoRoot: null,
});
expect(metadataResult.changesApplied).toEqual([]);
expect(metadataResult.changesApplied).toEqual([
{
kind: "project_updated",
projectId: "p1",
directory: projectRoot,
fields: { projectKey },
},
]);
expect(workspaces.get("w1")?.archivedAt).toBeNull();
const fullResult = await service.runOnce();
@@ -303,8 +326,21 @@ describe("WorkspaceReconciliationService", () => {
}),
);
const afterGitInit = await service.reconcileGitMetadata();
const projectKey = deriveProjectKey({
rootPath: projectRoot,
remoteUrl: null,
worktreeRoot: null,
mainRepoRoot: null,
});
expect(beforeGitInit.changesApplied).toEqual([]);
expect(beforeGitInit.changesApplied).toEqual([
{
kind: "project_updated",
projectId: "p1",
directory: projectRoot,
fields: { projectKey },
},
]);
expect(afterGitInit.changesApplied).toEqual([
{
kind: "project_updated",
@@ -386,7 +422,20 @@ describe("WorkspaceReconciliationService", () => {
const result = await service.reconcileGitMetadata();
expect(result.changesApplied).toEqual([]);
expect(result.changesApplied).toEqual([
{
kind: "project_updated",
projectId: "p1",
directory: projectRoot,
fields: { projectKey: canonicalLocalProjectKey(projectRoot) },
},
{
kind: "project_updated",
projectId: "p2",
directory: equivalentProjectRoot,
fields: { projectKey: canonicalLocalProjectKey(projectRoot) },
},
]);
expect(git.reads).toEqual([projectRoot, workspaceRoot]);
});
@@ -445,7 +494,7 @@ describe("WorkspaceReconciliationService", () => {
kind: "project_updated",
projectId: "p1",
directory: projectRoot,
fields: { kind: "git" },
fields: { kind: "git", projectKey: canonicalLocalProjectKey(projectRoot) },
},
{
kind: "workspace_updated",
@@ -462,6 +511,7 @@ describe("WorkspaceReconciliationService", () => {
expect(projects.get("p1")).toEqual({
...originalProject,
kind: "git",
projectKey: canonicalLocalProjectKey(projectRoot),
updatedAt: expect.any(String),
});
expect(workspaces.get("w1")).toEqual({
@@ -777,6 +827,8 @@ describe("WorkspaceReconciliationService", () => {
const result = await service.runOnce();
expect(result.changesApplied.map((change) => change.kind).sort()).toEqual([
"project_updated",
"project_updated",
"workspace_updated",
"workspace_updated",
]);
@@ -785,6 +837,7 @@ describe("WorkspaceReconciliationService", () => {
rootPath: repoDir,
displayName: "blank-dot-page/editor",
customName: null,
projectKey: "remote:github.com/blank-dot-page/editor",
archivedAt: null,
});
expect(projects.get(repoDir)).toMatchObject({
@@ -792,6 +845,7 @@ describe("WorkspaceReconciliationService", () => {
rootPath: repoDir,
displayName: "editor",
customName: "Editor",
projectKey: "remote:github.com/blank-dot-page/editor",
archivedAt: null,
});
expect(workspaces.get("focused-bat")).toMatchObject({
@@ -804,7 +858,7 @@ describe("WorkspaceReconciliationService", () => {
});
});
test("keeps project display name stable when git remote changes", async () => {
test("backfills a missing project key while keeping the project display name stable", async () => {
const dir = createTempGitRepo("reconcile-remote-");
tempDirs.push(dir);
@@ -856,8 +910,103 @@ describe("WorkspaceReconciliationService", () => {
const result = await service.runOnce();
expect(result.changesApplied.find((c) => c.kind === "project_updated")).toBeUndefined();
expect(result.changesApplied.find((c) => c.kind === "project_updated")).toMatchObject({
fields: { projectKey: "remote:github.com/new-owner/new-repo" },
});
expect(projects.get("p1")!.displayName).toBe("old-owner/old-repo");
expect(projects.get("p1")!.projectKey).toBe("remote:github.com/new-owner/new-repo");
});
test("refreshes an empty project's persisted key when its Git remote changes", async () => {
const dir = createTempGitRepo("reconcile-empty-remote-change-");
tempDirs.push(dir);
const { projects, projectRegistry, workspaceRegistry } = createTestRegistries();
projects.set(
"p1",
createPersistedProjectRecord({
projectId: "p1",
projectKey: "remote:github.com/old-owner/old-repo",
rootPath: dir,
kind: "git",
displayName: "old-owner/old-repo",
createdAt: timestamp,
updatedAt: timestamp,
}),
);
const service = new WorkspaceReconciliationService({
projectRegistry,
workspaceRegistry,
logger: createTestLogger(),
workspaceGitService: createWorkspaceGitServiceStub({
[dir]: {
projectKind: "git",
projectDisplayName: "new-owner/new-repo",
workspaceDisplayName: "main",
gitRemote: "git@github.com:new-owner/new-repo.git",
},
}),
});
const result = await service.runOnce();
expect(result.changesApplied).toEqual([
expect.objectContaining({
kind: "project_updated",
fields: { projectKey: "remote:github.com/new-owner/new-repo" },
}),
]);
expect(projects.get("p1")?.projectKey).toBe("remote:github.com/new-owner/new-repo");
});
test("refreshes a persisted project key 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",
projectKey: "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")?.projectKey).toBe(canonicalLocalProjectKey(dir));
});
test("keeps custom and default names stable when the remote changes", async () => {
@@ -1037,7 +1186,7 @@ describe("WorkspaceReconciliationService", () => {
kind: "project_updated",
projectId: "p1",
directory: projectRoot,
fields: { kind: "git" },
fields: { kind: "git", projectKey: canonicalLocalProjectKey(projectRoot) },
},
{
kind: "workspace_updated",
@@ -1321,6 +1470,12 @@ describe("WorkspaceReconciliationService", () => {
const result = await service.reconcileGitMetadata();
expect(result.changesApplied).toEqual([
{
kind: "project_updated",
projectId: "p1",
directory: rootPath,
fields: { projectKey: canonicalLocalProjectKey(rootPath) },
},
{
kind: "workspace_updated",
workspaceId: "w1",
@@ -15,6 +15,7 @@ import {
type MutableWorkspacePlacement,
} from "./workspace-registry-model.js";
import { workspaceIdsForProjects } from "./workspace-directory.js";
import { deriveProjectKey } from "./project-key.js";
const DEFAULT_RESCAN_INTERVAL_MS = 5 * 60_000;
const DEFAULT_DEBOUNCE_MS = 100;
@@ -66,7 +67,7 @@ export type ReconciliationChange =
kind: "project_updated";
projectId: string;
directory: string;
fields: Partial<Pick<PersistedProjectRecord, "kind">>;
fields: Partial<Pick<PersistedProjectRecord, "kind" | "projectKey">>;
}
| {
kind: "workspace_updated";
@@ -81,6 +82,7 @@ export interface ReconciliationResult {
}
export interface WorkspaceReconciliationServiceOptions {
serverId?: string;
projectRegistry: ProjectRegistry;
workspaceRegistry: WorkspaceRegistry;
logger: pino.Logger;
@@ -111,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;
@@ -133,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" });
@@ -280,6 +284,11 @@ export class WorkspaceReconciliationService {
return result;
}
/** Runs the boot-time convergence path and publishes every affected workspace. */
async reconcileNow(): Promise<void> {
await this.reconcileObservedGitMetadata("full");
}
private async reconcileGitMetadataForProjects(
projectsToReconcile: PersistedProjectRecord[],
workspacesByProject: Map<string, PersistedWorkspaceRecord[]>,
@@ -334,12 +343,22 @@ export class WorkspaceReconciliationService {
checkout: await readCheckout(workspace.cwd),
})),
);
const projectUpdates: Partial<Pick<PersistedProjectRecord, "kind">> = {};
const projectUpdates: Partial<Pick<PersistedProjectRecord, "kind" | "projectKey">> = {};
const mappedKind = deriveProjectKind(currentGit);
const projectKey = deriveProjectKey({
rootPath: project.rootPath,
remoteUrl: currentGit.remoteUrl,
worktreeRoot: currentGit.worktreeRoot,
mainRepoRoot: currentGit.mainRepoRoot,
serverId: this.serverId,
});
if (project.kind !== mappedKind) {
projectUpdates.kind = mappedKind;
}
if (project.projectKey !== projectKey) {
projectUpdates.projectKey = projectKey;
}
if (Object.keys(projectUpdates).length > 0) {
const timestamp = new Date().toISOString();
@@ -3,6 +3,8 @@ import { resolve } from "node:path";
import type { ProjectCheckoutLitePayload } from "@getpaseo/protocol/messages";
import { parseGitRevParsePath } from "../utils/git-rev-parse-path.js";
import { createRealpathAwarePathMatcher, getRealpathAwareRelativePath } from "../utils/path.js";
import { deriveProjectKey, deriveProjectGroupingDisplayName } from "./project-key.js";
import {
deriveProjectKind,
deriveWorkspaceDisplayName,
@@ -19,6 +21,7 @@ interface DirectoryProjectMembership {
workspaceDirectoryKey: string;
workspaceKind: PersistedWorkspaceKind;
workspaceDisplayName: string;
projectId: string;
projectKey: string;
projectName: string;
projectRootPath: string;
@@ -28,14 +31,18 @@ interface DirectoryProjectMembership {
export function classifyDirectoryForProjectMembership(input: {
cwd: string;
checkout: ProjectCheckoutLitePayload;
serverId?: string;
}): DirectoryProjectMembership {
const cwd = resolve(input.cwd);
const checkout: ProjectCheckoutLitePayload = { ...input.checkout, cwd };
const projectKey = deriveProjectGroupingKey({
cwd: checkout.worktreeRoot ?? cwd,
const projectKey = deriveProjectKey({
rootPath: cwd,
remoteUrl: checkout.remoteUrl,
worktreeRoot: checkout.worktreeRoot,
mainRepoRoot: checkout.mainRepoRoot,
serverId: input.serverId,
});
const projectRootPath = deriveProjectRootPath({ cwd, checkout });
return {
cwd,
@@ -43,78 +50,40 @@ export function classifyDirectoryForProjectMembership(input: {
workspaceDirectoryKey: deriveWorkspaceDirectoryKey(cwd, checkout),
workspaceKind: deriveWorkspaceKind(checkout),
workspaceDisplayName: deriveWorkspaceDisplayName({ cwd, checkout }),
// Legacy registry bootstrap historically used the grouping identity as the
// host-local project ID. Preserve that ID shape while keeping the new,
// canonical cross-host identity in projectKey.
projectId: projectKey.startsWith("remote:") ? projectKey : projectRootPath,
projectKey,
projectName: deriveProjectGroupingName(projectKey),
projectRootPath: deriveProjectRootPath({ cwd, checkout }),
projectName: deriveProjectGroupingDisplayName({
rootPath: cwd,
remoteUrl: checkout.remoteUrl,
worktreeRoot: checkout.worktreeRoot,
}),
projectRootPath,
projectKind: deriveProjectKind(checkout),
};
}
function deriveWorkspaceDirectoryKey(cwd: string, checkout: ProjectCheckoutLitePayload): string {
const worktreeRoot = checkout.worktreeRoot ? parseGitRevParsePath(checkout.worktreeRoot) : null;
return worktreeRoot ?? resolve(cwd);
}
function deriveRemoteProjectKey(remoteUrl: string | null): string | null {
if (!remoteUrl) return null;
const trimmed = remoteUrl.trim();
if (!trimmed) return null;
let host: string | null = null;
let remotePath: string | null = null;
const scpLike = trimmed.match(/^[^@]+@([^:]+):(.+)$/);
if (scpLike) {
host = scpLike[1] ?? null;
remotePath = scpLike[2] ?? null;
} else if (trimmed.includes("://")) {
try {
const parsed = new URL(trimmed);
host = parsed.hostname || null;
remotePath = parsed.pathname ? parsed.pathname.replace(/^\/+/, "") : null;
} catch {
return null;
}
}
if (!host || !remotePath) return null;
let cleanedPath = remotePath.trim().replace(/^\/+/, "").replace(/\/+$/, "");
if (cleanedPath.endsWith(".git")) cleanedPath = cleanedPath.slice(0, -4);
if (!cleanedPath.includes("/")) return null;
return `remote:${host.toLowerCase()}/${cleanedPath}`;
}
function deriveProjectGroupingKey(options: {
cwd: string;
remoteUrl: string | null;
mainRepoRoot: string | null;
}): string {
const remoteKey = deriveRemoteProjectKey(options.remoteUrl);
if (remoteKey) return remoteKey;
const mainRepoRoot = options.mainRepoRoot?.trim();
return mainRepoRoot || options.cwd;
}
function deriveProjectGroupingName(projectKey: string): string {
if (projectKey.startsWith("remote:")) {
const pathSegments = projectKey.slice("remote:".length).split("/").filter(Boolean).slice(1);
if (pathSegments.length >= 2) return pathSegments.slice(-2).join("/");
if (pathSegments.length === 1) return pathSegments[0];
return projectKey;
}
const segments = projectKey.split(/[\\/]/).filter(Boolean);
return segments[segments.length - 1] || projectKey;
const selectedRoot = resolve(cwd);
return worktreeRoot && createRealpathAwarePathMatcher(worktreeRoot)(selectedRoot)
? worktreeRoot
: selectedRoot;
}
function deriveProjectRootPath(input: {
cwd: string;
checkout: ProjectCheckoutLitePayload;
}): string {
return input.checkout.isGit && input.checkout.mainRepoRoot
? input.checkout.mainRepoRoot
: input.cwd;
if (!input.checkout.isGit || !input.checkout.mainRepoRoot) return input.cwd;
const worktreeRoot = input.checkout.worktreeRoot
? parseGitRevParsePath(input.checkout.worktreeRoot)
: null;
if (!worktreeRoot) return input.checkout.mainRepoRoot;
const selectedPath = getRealpathAwareRelativePath(worktreeRoot, input.cwd);
return selectedPath
? resolve(input.checkout.mainRepoRoot, selectedPath)
: input.checkout.mainRepoRoot;
}
@@ -10,6 +10,7 @@ import { createNoopWorkspaceGitService } from "./test-utils/workspace-git-servic
import type { WorkspaceGitService } from "./workspace-git-service.js";
import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry } from "./workspace-registry.js";
import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js";
import { deriveProjectKey } from "./project-key.js";
let NON_GIT_PROJECT: string;
let ARCHIVED_PROJECT: string;
@@ -242,6 +243,14 @@ describe("bootstrapWorkspaceRegistries", () => {
const projects = await projectRegistry.list();
expect(projects).toHaveLength(1);
expect(projects[0]?.projectId).toBe(NON_GIT_PROJECT);
expect(projects[0]?.projectKey).toBe(
deriveProjectKey({
rootPath: NON_GIT_PROJECT,
remoteUrl: null,
worktreeRoot: null,
mainRepoRoot: null,
}),
);
expect(projects[0]?.createdAt).toBe("2026-03-01T00:00:00.000Z");
expect(projects[0]?.updatedAt).toBe("2026-03-03T00:00:00.000Z");
});
@@ -351,6 +360,7 @@ describe("bootstrapWorkspaceRegistries", () => {
expect(projects).toHaveLength(1);
expect(projects[0]).toMatchObject({
projectId: "remote:github.com/acme/legacy-project",
projectKey: "remote:github.com/acme/legacy-project",
rootPath: GIT_PROJECT,
kind: "git",
displayName: "acme/legacy-project",
@@ -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 };
}),
@@ -156,7 +158,7 @@ export async function bootstrapWorkspaceRegistries(options: {
options.workspaceRegistry.upsert(
createPersistedWorkspaceRecord({
workspaceId,
projectId: membership.projectKey,
projectId: membership.projectId,
cwd: workspaceCwd,
kind: membership.workspaceKind,
displayName: membership.workspaceDisplayName,
@@ -166,10 +168,11 @@ export async function bootstrapWorkspaceRegistries(options: {
),
options.projectRegistry.upsert(
createPersistedProjectRecord({
projectId: membership.projectKey,
projectId: membership.projectId,
rootPath: membership.projectRootPath,
kind: membership.projectKind,
displayName: membership.projectName,
projectKey: membership.projectKey,
createdAt: projectRange.createdAt ?? createdAt,
updatedAt: projectRange.updatedAt ?? updatedAt,
}),
@@ -16,6 +16,12 @@ const PersistedProjectRecordSchema = z.object({
rootPath: z.string(),
kind: z.enum(["git", "non_git"]),
displayName: z.string(),
// COMPAT(projectKey): added in v0.2.4 on 2026-07-28; remove optional after 2027-01-28.
projectKey: z
.string()
.nullable()
.optional()
.transform((value) => value ?? null),
// User-set override layered over the derived displayName. Reconciliation
// never touches this. Null means "use the derived name". Added for #987.
customName: z
@@ -104,6 +110,7 @@ export interface ProjectRegistry {
rootPath: string;
kind: PersistedProjectKind;
displayName: string;
projectKey?: string;
timestamp: string;
}): Promise<PersistedProjectRecord>;
upsert(record: PersistedProjectRecord): Promise<void>;
@@ -308,6 +315,7 @@ export class FileBackedProjectRegistry
rootPath: string;
kind: PersistedProjectKind;
displayName: string;
projectKey?: string;
timestamp: string;
}): Promise<PersistedProjectRecord> {
const previous = this.allocationQueue;
@@ -325,8 +333,14 @@ export class FileBackedProjectRegistry
left.projectId.localeCompare(right.projectId),
)[0];
if (active) {
if (active.kind === input.kind) return active;
const refreshed = { ...active, kind: input.kind, updatedAt: input.timestamp };
if (active.kind === input.kind && active.projectKey === (input.projectKey ?? null))
return active;
const refreshed = {
...active,
kind: input.kind,
projectKey: input.projectKey ?? null,
updatedAt: input.timestamp,
};
await this.upsert(refreshed);
return refreshed;
}
@@ -339,6 +353,7 @@ export class FileBackedProjectRegistry
rootPath: input.rootPath,
kind: input.kind,
displayName: input.displayName,
projectKey: input.projectKey ?? null,
createdAt: input.timestamp,
updatedAt: input.timestamp,
});
@@ -459,6 +474,7 @@ export function createPersistedProjectRecord(input: {
kind: PersistedProjectKind;
displayName: string;
customName?: string | null;
projectKey?: string | null;
createdAt: string;
updatedAt: string;
archivedAt?: string | null;
@@ -466,6 +482,7 @@ export function createPersistedProjectRecord(input: {
return PersistedProjectRecordSchema.parse({
...input,
customName: input.customName ?? null,
projectKey: input.projectKey ?? null,
archivedAt: input.archivedAt ?? null,
});
}
+5
View File
@@ -62,6 +62,11 @@ export function isPathInsideRoot(root: string, candidate: string): boolean {
return getRelativePathInsideRoot(root, candidate) !== null;
}
export function normalizePathForIdentity(value: string): string {
const canonicalPath = resolveRealpathVariants(value)[0] ?? value;
return normalizePathForComparison(canonicalPath, looksLikeDefiniteWindowsPath(canonicalPath));
}
/**
* Returns the candidate's relative suffix when it is inside root.
*