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:
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
+6
-1
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
+47
-5
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user