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.
This commit is contained in:
Mohamed Boudra
2026-07-28 21:19:07 +00:00
parent 76e336a1be
commit 47e095270a
35 changed files with 646 additions and 136 deletions

View File

@@ -4,9 +4,15 @@
Projects are allocated for the exact root selected by the caller, normalized lexically with `path.resolve` (never `realpath`). New project IDs are opaque `prj_<16 hex>` values. Existing remote-shaped or path-shaped IDs are retained as readable compatibility records and are never rekeyed. An active exact root is idempotent; archived-only matches do not resurrect an old project. Workspace `projectId` is stable membership: reconciliation may update git-derived kind and branch metadata, but never rehomes a workspace or changes a project's root, ID, or default name.
`kind` is mutable metadata, not identity. Workspace reconciliation watches active project roots and
updates only a project's `kind` and `updatedAt` when `.git` appears or disappears, preserving its
ID, root path, names, and workspace foreign keys. Attached workspaces are independently refreshed
`projectGroupKey` is a persisted, opaque equivalence key used only to group the same logical project
across hosts. It is separate from the host-local `projectId`; today's producer prefers a normalized
Git remote and otherwise uses the local project root. Consumers never derive it from live Git.
Creation persists it with the project, and normal boot reconciliation fills or refreshes it for
older records where the field is absent—there is no migration.
`kind` and `projectGroupKey` are mutable metadata, not identity. Workspace reconciliation watches active project roots and
updates those fields and `updatedAt` when Git facts change, preserving the project's ID, root path,
names, and workspace foreign keys. Attached workspaces are independently refreshed
from their own cwd, so an explicit project root never implies a workspace checkout. Empty projects
are observed too.
@@ -448,16 +454,17 @@ Single file containing an array of all loop records. Writes are direct (not atom
Array of project records.
| Field | Type | Description |
| ------------- | --------------------------- | -------------------------------------------------------------------------------- |
| `projectId` | `string` | Primary key; new records use opaque `prj_<16 hex>` IDs |
| `rootPath` | `string` | Exact lexically normalized selected root; never realpathed |
| `kind` | `"git" \| "non_git"` | Mutable Git observation about `rootPath`, never a membership key |
| `displayName` | `string` | Selected-root basename, stable across remote and Git changes |
| `customName` | `string \| null` | User-set override layered over `displayName`. Null means "use the derived name". |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | |
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete timestamp; required nullable |
| Field | Type | Description |
| ----------------- | --------------------------- | -------------------------------------------------------------------------------- |
| `projectId` | `string` | Host-local primary key; new records use opaque `prj_<16 hex>` IDs |
| `projectGroupKey` | `string \| null` | Persisted opaque cross-host grouping key; reconciliation backfills absent values |
| `rootPath` | `string` | Exact lexically normalized selected root; never realpathed |
| `kind` | `"git" \| "non_git"` | Mutable Git observation about `rootPath`, never a membership key |
| `displayName` | `string` | Selected-root basename, stable across remote and Git changes |
| `customName` | `string \| null` | User-set override layered over `displayName`. Null means "use the derived name". |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | |
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete timestamp; required nullable |
Active exact roots are idempotent using lexical platform-equivalence semantics. Existing legacy
remote-shaped and path-shaped IDs remain readable, including duplicate roots; reconciliation never

View File

@@ -2,7 +2,8 @@
Authoritative terminology. UI label wins. Don't invent synonyms; use what's here.
- **Project** — A stable, exact selected-root record. New IDs are opaque `prj_<16 hex>` values; older remote-shaped and path-shaped IDs remain readable compatibility records. Git facts can update mutable kind metadata but never project identity, root, or default display name. UI: "Project" / "Add project". Forbidden: "Repo", "Repository" as UI label.
- **Project** — A stable, exact selected-root record. Its host-local `projectId` is an opaque `prj_<16 hex>` value. Its separate persisted `projectGroupKey` is an opaque equivalence key that may group the logical project across hosts. Git facts can update mutable kind and grouping metadata but never the ID, root, or default display name. UI: "Project" / "Add project". Forbidden: "Repo", "Repository" as UI label.
- **Project group key** — Persisted opaque equivalence key used to group projects across hosts. A normalized Git remote is the current producer, but the concept is wider than repository identity and consumers must not parse or rederive it. Code: `projectGroupKey`.
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. Its `id` is opaque workspace identity; its `cwd` is the filesystem directory. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/protocol/src/messages.ts:2178`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
- **Archive workspace** — Removes one workspace from active use and archives everything it owns. UI, CLI, and MCP always say "Archive workspace", regardless of backing. The daemon leaves ordinary directories intact and removes a Paseo-owned worktree only when no active workspace still references it.
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. The git-derived, persisted property of a workspace, used across its lifetime (archive safety, sidebar, grouping). Derived from the cwd's git reality by `deriveWorkspaceKind` in `workspace-registry-model.ts`, not stored from a user choice. Don't confuse with **Isolation** (the create-time intent).
@@ -17,7 +18,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Change request** — Forge-neutral term for a proposed branch-to-branch code change. UI normally renders the forge noun instead: GitHub/Gitea/Forgejo "PR", GitLab "MR". Code: `forge_change_request` attachments, `checkoutSource: { kind: "change_request" }`, and PR/MR status payloads.
- **MR** — GitLab merge request. UI label for GitLab change requests only; do not use MR for GitHub/Gitea/Forgejo.
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. User-facing creation treats it as the `worktree` workspace isolation choice. Code and `paseo.json` retain worktree terminology for git lifecycle implementation. Forbidden: "Checkout" as a product synonym.
- **Repository / Remote** — Internal Git observations. They may affect mutable kind/branch metadata but never project identity, root, display name, or workspace membership. No UI label.
- **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. No UI label.
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
- **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`.

View File

@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
import { mkdtemp, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { test, expect, type Page } from "./fixtures";
import { test, expect } from "./fixtures";
import {
addProjectFlow,
addProjectFlowBack,
@@ -13,36 +13,21 @@ import {
expectAddProjectPage,
expectNewWorkspaceForAddedProject,
openAddProjectFlow,
waitForConnectedHost,
} from "./helpers/add-project-flow";
import { gotoAppShell } from "./helpers/app";
import { buildSeededHost } from "./helpers/daemon-registry";
import { addOfflineHostAndReload } from "./helpers/hosts";
import {
addConnectedHostAndReload,
addOfflineHostAndReload,
waitForConnectedHost,
} from "./helpers/hosts";
import { type IsolatedHostDaemon, startIsolatedHostDaemon } from "./helpers/isolated-host-daemon";
import { expectOpenedProject } from "./helpers/project-picker-ui";
import { connectSeedClient } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
const EXTRA_HOSTS_KEY = "@paseo:e2e-extra-hosts";
const SECONDARY_HOST_ID = "add-project-flow-secondary";
const SECONDARY_HOST_LABEL = "Secondary Host";
async function addConnectedHostAndReload(page: Page, host: IsolatedHostDaemon): Promise<void> {
const registryEntry = buildSeededHost({
serverId: host.serverId,
label: SECONDARY_HOST_LABEL,
endpoint: `127.0.0.1:${host.port}`,
nowIso: new Date().toISOString(),
});
await page.evaluate(
({ key, entry }) => {
localStorage.setItem(key, JSON.stringify([entry]));
},
{ key: EXTRA_HOSTS_KEY, entry: registryEntry },
);
await page.reload();
}
async function expectProjectDirectory(pathname: string): Promise<void> {
await expect.poll(async () => (await stat(pathname)).isDirectory()).toBe(true);
}
@@ -149,7 +134,11 @@ test.describe("Add Project command-center flow", () => {
test("keyboard selection chooses the second host", async ({ page }) => {
await gotoAppShell(page);
await addConnectedHostAndReload(page, secondaryHost);
await addConnectedHostAndReload(page, {
serverId: secondaryHost.serverId,
label: SECONDARY_HOST_LABEL,
port: secondaryHost.port,
});
await waitForConnectedHost(page, {
serverId: SECONDARY_HOST_ID,
endpoint: `localhost:${secondaryHost.port}`,
@@ -167,7 +156,11 @@ test.describe("Add Project command-center flow", () => {
page,
}) => {
await gotoAppShell(page);
await addConnectedHostAndReload(page, secondaryHost);
await addConnectedHostAndReload(page, {
serverId: secondaryHost.serverId,
label: SECONDARY_HOST_LABEL,
port: secondaryHost.port,
});
await waitForConnectedHost(page, {
serverId: SECONDARY_HOST_ID,
endpoint: `localhost:${secondaryHost.port}`,
@@ -211,7 +204,11 @@ test.describe("Add Project command-center flow", () => {
try {
await gotoAppShell(page);
await addConnectedHostAndReload(page, secondaryHost);
await addConnectedHostAndReload(page, {
serverId: secondaryHost.serverId,
label: SECONDARY_HOST_LABEL,
port: secondaryHost.port,
});
await waitForConnectedHost(page, {
serverId: SECONDARY_HOST_ID,
endpoint: `localhost:${secondaryHost.port}`,

View File

@@ -37,17 +37,6 @@ export function addProjectFlowMethod(page: Page, method: AddProjectMethod): Loca
return page.getByTestId(`add-project-flow-method-${method}`);
}
export async function waitForConnectedHost(
page: Page,
input: { serverId: string; endpoint: string },
): Promise<void> {
await page.getByTestId("sidebar-hosts-trigger").click();
const host = page.getByTestId(`sidebar-host-row-${input.serverId}`);
await expect(host).toContainText(input.endpoint, { timeout: 30_000 });
await page.keyboard.press("Escape");
await expect(host).not.toBeVisible();
}
export async function expectAddProjectPage(page: Page, kind: AddProjectFlowPage): Promise<Locator> {
const currentPage = page.getByTestId(`add-project-flow-page-${kind}`);
await expect(currentPage).toBeVisible({ timeout: 30_000 });

View File

@@ -57,6 +57,66 @@ export async function addOfflineHostAndReload(
await page.reload();
}
export async function addConnectedHostAndReload(
page: Page,
input: { serverId: string; label: string; port: number },
): Promise<void> {
await addConnectedHostsAndReload(page, [input]);
}
export async function addConnectedHostsAndReload(
page: Page,
inputs: Array<{ serverId: string; label: string; port: number }>,
): Promise<void> {
const connectedHosts = inputs.map((input) =>
buildSeededHost({
serverId: input.serverId,
label: input.label,
endpoint: `127.0.0.1:${input.port}`,
nowIso: new Date().toISOString(),
}),
);
await page.evaluate(
({ hosts, keys }) => {
const nonce = localStorage.getItem(keys.nonce);
if (!nonce) {
throw new Error("Expected the e2e seed nonce before overriding the host registry.");
}
const raw = localStorage.getItem(keys.registry);
const registry: Array<{ serverId: string }> = raw ? JSON.parse(raw) : [];
for (const host of hosts) {
if (!registry.some((entry) => entry.serverId === host.serverId)) {
registry.push(host);
}
}
localStorage.setItem(keys.registry, JSON.stringify(registry));
localStorage.setItem(keys.disableSeedOnce, nonce);
},
{
hosts: connectedHosts,
keys: {
registry: REGISTRY_KEY,
nonce: SEED_NONCE_KEY,
disableSeedOnce: DISABLE_DEFAULT_SEED_ONCE_KEY,
},
},
);
await page.reload();
}
export async function waitForConnectedHost(
page: Page,
input: { serverId: string; endpoint: string },
): Promise<void> {
await page.getByTestId("sidebar-hosts-trigger").click();
const host = page.getByTestId(`sidebar-host-row-${input.serverId}`);
await expect(host).toContainText(input.endpoint, { timeout: 30_000 });
await page.keyboard.press("Escape");
await expect(host).not.toBeVisible();
}
export async function openSidebarDisplayPreferences(page: Page): Promise<void> {
await page.getByTestId("sidebar-display-preferences-menu").click();
await expect(page.getByTestId("sidebar-display-preferences-content")).toBeVisible({

View File

@@ -9,6 +9,7 @@ import { withDisabledE2ESpeechEnv } from "./speech-env";
export interface IsolatedHostDaemon {
serverId: string;
port: number;
paseoHome: string;
restart(): Promise<void>;
close(): Promise<void>;
}
@@ -133,6 +134,7 @@ export async function startIsolatedHostDaemon(serverId: string): Promise<Isolate
return {
serverId,
port,
paseoHome,
restart: async () => {
if (closed) throw new Error(`Cannot restart closed isolated daemon ${serverId}`);
await stopProcess(child);

View File

@@ -0,0 +1,234 @@
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test as base, type Page } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import {
addConnectedHostAndReload,
addConnectedHostsAndReload,
waitForConnectedHost,
} from "./helpers/hosts";
import { type IsolatedHostDaemon, startIsolatedHostDaemon } from "./helpers/isolated-host-daemon";
import { connectSeedClient, type SeedDaemonClient } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
import { createTempGitRepo, type TempDirectory } from "./helpers/workspace";
const SECONDARY_HOST_ID = "project-grouping-secondary";
const SECONDARY_HOST_LABEL = "Secondary Host";
const LEGACY_PRIMARY_HOST_ID = "project-grouping-legacy-primary";
const LEGACY_SECONDARY_HOST_ID = "project-grouping-legacy-secondary";
const SHARED_REMOTE_URL = "https://github.com/paseo-e2e/grouped-project.git";
interface HostProject {
serverId: string;
projectId: string;
workspaceId: string;
}
interface CrossHostProject {
secondaryHost: IsolatedHostDaemon;
primary: HostProject;
secondary: HostProject;
}
interface ReconciledCrossHostProject extends CrossHostProject {
primaryHost: IsolatedHostDaemon;
}
async function createProject(
client: SeedDaemonClient,
repo: TempDirectory,
serverId: string,
): Promise<HostProject> {
const created = await client.createWorkspace({ source: { kind: "directory", path: repo.path } });
if (!created.workspace) {
throw new Error(created.error ?? `Failed to create project on ${serverId}`);
}
return {
serverId,
projectId: created.workspace.projectId,
workspaceId: created.workspace.id,
};
}
async function expectOneProjectContainsBothWorkspaces(
page: Page,
fixture: CrossHostProject,
): Promise<void> {
const primaryWorkspace = page.getByTestId(
`sidebar-workspace-row-${fixture.primary.serverId}:${fixture.primary.workspaceId}`,
);
const secondaryWorkspace = page.getByTestId(
`sidebar-workspace-row-${fixture.secondary.serverId}:${fixture.secondary.workspaceId}`,
);
await expect(primaryWorkspace).toBeVisible({ timeout: 30_000 });
await expect(secondaryWorkspace).toBeVisible({ timeout: 30_000 });
await expect(page.locator('[data-testid^="sidebar-project-row-"]')).toHaveCount(1);
}
async function readPersistedProjectGroupKey(host: IsolatedHostDaemon): Promise<unknown> {
const projectsPath = path.join(host.paseoHome, "projects", "projects.json");
const projects = JSON.parse(await readFile(projectsPath, "utf8")) as Array<
Record<string, unknown>
>;
return projects[0]?.projectGroupKey;
}
async function removePersistedProjectGroupKeys(host: IsolatedHostDaemon): Promise<void> {
const projectsPath = path.join(host.paseoHome, "projects", "projects.json");
const projects = JSON.parse(await readFile(projectsPath, "utf8")) as Array<
Record<string, unknown>
>;
for (const project of projects) {
delete project.projectGroupKey;
}
await writeFile(projectsPath, JSON.stringify(projects));
const persisted = JSON.parse(await readFile(projectsPath, "utf8")) as Array<
Record<string, unknown>
>;
expect(persisted.every((project) => !("projectGroupKey" in project))).toBe(true);
}
async function createReconciliationFixture(): Promise<{
fixture: ReconciledCrossHostProject;
cleanup: () => Promise<void>;
}> {
const primaryHost = await startIsolatedHostDaemon(LEGACY_PRIMARY_HOST_ID);
const secondaryHost = await startIsolatedHostDaemon(LEGACY_SECONDARY_HOST_ID);
const primaryRepo = await createTempGitRepo("grouped-legacy-primary-", {
originUrl: SHARED_REMOTE_URL,
});
const secondaryRepo = await createTempGitRepo("grouped-legacy-secondary-", {
originUrl: SHARED_REMOTE_URL,
});
const primaryClient = await connectSeedClient({ port: primaryHost.port });
const secondaryClient = await connectSeedClient({ port: secondaryHost.port });
try {
const primary = await createProject(primaryClient, primaryRepo, primaryHost.serverId);
const secondary = await createProject(secondaryClient, secondaryRepo, secondaryHost.serverId);
await primaryClient.close();
await secondaryClient.close();
await removePersistedProjectGroupKeys(primaryHost);
await removePersistedProjectGroupKeys(secondaryHost);
await Promise.all([primaryHost.restart(), secondaryHost.restart()]);
return {
fixture: { primaryHost, secondaryHost, primary, secondary },
cleanup: async () => {
await primaryHost.close().catch(() => undefined);
await secondaryHost.close().catch(() => undefined);
await primaryRepo.cleanup().catch(() => undefined);
await secondaryRepo.cleanup().catch(() => undefined);
},
};
} catch (error) {
await primaryClient.close().catch(() => undefined);
await secondaryClient.close().catch(() => undefined);
await primaryHost.close().catch(() => undefined);
await secondaryHost.close().catch(() => undefined);
await primaryRepo.cleanup().catch(() => undefined);
await secondaryRepo.cleanup().catch(() => undefined);
throw error;
}
}
const test = base.extend<{
crossHostProject: CrossHostProject;
reconciledCrossHostProject: ReconciledCrossHostProject;
}>({
crossHostProject: async ({ page: _page }, provide) => {
const secondaryHost = await startIsolatedHostDaemon(SECONDARY_HOST_ID);
const primaryRepo = await createTempGitRepo("grouped-primary-", {
originUrl: SHARED_REMOTE_URL,
});
const secondaryRepo = await createTempGitRepo("grouped-secondary-", {
originUrl: SHARED_REMOTE_URL,
});
const primaryClient = await connectSeedClient();
const secondaryClient = await connectSeedClient({ port: secondaryHost.port });
let primary: HostProject | null = null;
let secondary: HostProject | null = null;
try {
primary = await createProject(primaryClient, primaryRepo, getServerId());
secondary = await createProject(secondaryClient, secondaryRepo, secondaryHost.serverId);
await provide({ secondaryHost, primary, secondary });
} finally {
if (primary) await primaryClient.removeProject(primary.projectId).catch(() => undefined);
if (secondary)
await secondaryClient.removeProject(secondary.projectId).catch(() => undefined);
await primaryClient.close().catch(() => undefined);
await secondaryClient.close().catch(() => undefined);
await primaryRepo.cleanup().catch(() => undefined);
await secondaryRepo.cleanup().catch(() => undefined);
await secondaryHost.close().catch(() => undefined);
}
},
reconciledCrossHostProject: async ({ page: _page }, provide) => {
const resource = await createReconciliationFixture();
try {
await provide(resource.fixture);
} finally {
await resource.cleanup();
}
},
});
test.describe("Sidebar project grouping", () => {
test.describe.configure({ timeout: 120_000 });
test("groups projects with the same Git remote across hosts", async ({
page,
crossHostProject,
}) => {
expect(crossHostProject.primary.projectId).not.toBe(crossHostProject.secondary.projectId);
await gotoAppShell(page);
await addConnectedHostAndReload(page, {
serverId: crossHostProject.secondaryHost.serverId,
label: SECONDARY_HOST_LABEL,
port: crossHostProject.secondaryHost.port,
});
await waitForConnectedHost(page, {
serverId: crossHostProject.secondaryHost.serverId,
endpoint: `localhost:${crossHostProject.secondaryHost.port}`,
});
await expectOneProjectContainsBothWorkspaces(page, crossHostProject);
});
test("groups persisted projects missing group keys after app boot", async ({
page,
reconciledCrossHostProject,
}) => {
expect(reconciledCrossHostProject.primary.projectId).not.toBe(
reconciledCrossHostProject.secondary.projectId,
);
await gotoAppShell(page);
await addConnectedHostsAndReload(page, [
{
serverId: reconciledCrossHostProject.primaryHost.serverId,
label: "Legacy Primary Host",
port: reconciledCrossHostProject.primaryHost.port,
},
{
serverId: reconciledCrossHostProject.secondaryHost.serverId,
label: "Legacy Secondary Host",
port: reconciledCrossHostProject.secondaryHost.port,
},
]);
await waitForConnectedHost(page, {
serverId: reconciledCrossHostProject.primaryHost.serverId,
endpoint: `localhost:${reconciledCrossHostProject.primaryHost.port}`,
});
await waitForConnectedHost(page, {
serverId: reconciledCrossHostProject.secondaryHost.serverId,
endpoint: `localhost:${reconciledCrossHostProject.secondaryHost.port}`,
});
await expectOneProjectContainsBothWorkspaces(page, reconciledCrossHostProject);
await expect
.poll(() => readPersistedProjectGroupKey(reconciledCrossHostProject.primaryHost))
.toBe("remote:github.com/paseo-e2e/grouped-project");
await expect
.poll(() => readPersistedProjectGroupKey(reconciledCrossHostProject.secondaryHost))
.toBe("remote:github.com/paseo-e2e/grouped-project");
});
});

View File

@@ -908,7 +908,7 @@ function NewWorkspaceGhostRow({
serverId: worktreeTarget.serverId,
sourceDirectory: worktreeTarget.iconWorkingDir,
displayName,
projectId: project.projectKey,
projectId: worktreeTarget.projectId ?? project.projectKey,
}) as Href,
);
}, [displayName, onWorkspacePress, project.projectKey, worktreeTarget]);
@@ -988,7 +988,7 @@ function ProjectHeaderRow({
serverId: worktreeTarget.serverId,
sourceDirectory: worktreeTarget.iconWorkingDir,
displayName,
projectId: project.projectKey,
projectId: worktreeTarget.projectId ?? project.projectKey,
}) as Href,
);
}, [displayName, onWorkspacePress, project.projectKey, worktreeTarget]);
@@ -1755,7 +1755,6 @@ function ProjectBlock({
}
void removeProjectFromHosts({
projectKey: project.projectKey,
targets: readiness.targets,
getClient: (serverId) => getHostRuntimeStore().getClient(serverId),
})

View File

@@ -8,6 +8,7 @@ import type {
WorkspaceStructureProject,
} from "@/projects/workspace-structure";
import { projectDisplayNameFromProjectId } from "@/utils/project-display-name";
import { resolveProjectGroupKey } from "@/projects/project-group-key";
import type { WorkspaceAgentActivity } from "@/utils/workspace-agent-activity";
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
@@ -144,7 +145,11 @@ export function createSidebarWorkspaceEntry(input: {
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
workspaceAgentActivity?: ReadonlyMap<string, WorkspaceAgentActivity>;
}): SidebarWorkspaceEntry {
const projectKey = input.workspace.project?.projectKey ?? input.workspace.projectId;
const projectKey = resolveProjectGroupKey({
serverId: input.serverId,
projectId: input.workspace.projectId,
projectGroupKey: input.workspace.projectGroupKey,
});
const effectiveStatus = deriveEffectiveWorkspaceStatus(input);
return {
workspaceKey: `${input.serverId}:${input.workspace.id}`,

View File

@@ -3,6 +3,7 @@ import type {
WorkspaceStructureHostPlacement,
WorkspaceStructureProject,
} from "@/projects/workspace-structure";
import { resolveProjectGroupKey } from "@/projects/project-group-key";
export interface HostProjectListItem {
projectKey: string;
@@ -58,6 +59,7 @@ export function hostProjectFromRoute(route: HostProjectRouteContext): HostProjec
hosts: [
{
serverId: route.serverId,
projectId: projectKey,
iconWorkingDir,
canCreateWorktree: true,
},
@@ -73,7 +75,11 @@ export function hostProjectFromWorkspace(input: {
if (!input.workspace) {
return null;
}
const projectKey = input.workspace.projectId.trim();
const projectKey = resolveProjectGroupKey({
serverId: input.serverId,
projectId: input.workspace.projectId.trim(),
projectGroupKey: input.workspace.projectGroupKey,
});
const iconWorkingDir = input.workspace.projectRootPath.trim();
if (!projectKey || !iconWorkingDir) {
return null;
@@ -87,6 +93,7 @@ export function hostProjectFromWorkspace(input: {
hosts: [
{
serverId: input.serverId,
projectId: input.workspace.projectId,
iconWorkingDir,
canCreateWorktree: canCreate,
},
@@ -106,6 +113,10 @@ export function getHostProjectSourceDirectory(
return project.hosts.find((host) => host.serverId === serverId)?.iconWorkingDir ?? null;
}
export function getHostProjectId(project: HostProjectListItem, serverId: string): string | null {
return project.hosts.find((host) => host.serverId === serverId)?.projectId ?? project.projectKey;
}
export function canCreateWorkspaceForHostProject(input: {
project: HostProjectListItem;
serverId: string;

View File

@@ -189,7 +189,14 @@ describe("host project list", () => {
it("filters new-workspace projects to the selected host", () => {
const hostAOnly = hostProject({
projectKey: "host-a-project",
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true }],
hosts: [
{
serverId: "host-a",
projectId: "project-a",
iconWorkingDir: "/repo/a",
canCreateWorktree: true,
},
],
});
const hostBOnly = hostProject({
projectKey: "host-b-project",
@@ -283,7 +290,14 @@ describe("host project list", () => {
projectName: "Project A",
projectKind: "git",
iconWorkingDir: "/repo/a",
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true }],
hosts: [
{
serverId: "host-a",
projectId: "project-a",
iconWorkingDir: "/repo/a",
canCreateWorktree: true,
},
],
workspaceKeys: [],
});
expect(hostProjectFromRoute({ serverId: "host-a", projectId: "project-a" })).toBeNull();
@@ -295,7 +309,14 @@ describe("host project list", () => {
projectName: "Project A",
projectKind: "git",
iconWorkingDir: "/repo/a",
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true }],
hosts: [
{
serverId: "host-a",
projectId: "project-a",
iconWorkingDir: "/repo/a",
canCreateWorktree: true,
},
],
workspaceKeys: ["host-a:workspace-a"],
});

View File

@@ -8,6 +8,7 @@ export {
canCreateWorktreeForProjectKind,
filterWorkspaceProjectsForHost,
getHostProjectSourceDirectory,
getHostProjectId,
hostProjectFromRoute,
hostProjectFromWorkspace,
resolveInitialWorkspaceProject,

View File

@@ -0,0 +1,13 @@
export function resolveProjectGroupKey(input: {
serverId: string;
projectId: string;
projectGroupKey?: string | null;
}): string {
if (input.projectGroupKey) return input.projectGroupKey;
// COMPAT(projectGroupKey): added in v0.2.4 on 2026-07-28; remove after 2027-01-28.
// Older daemons used remote/path-shaped project IDs as their grouping key. New opaque IDs
// must remain host-local when the new field is absent.
return input.projectId.startsWith("prj_")
? `host:${input.serverId}:project:${input.projectId}`
: input.projectId;
}

View File

@@ -7,7 +7,10 @@ import {
const project: ProjectRemoveProject = {
projectKey: "remote:github.com/acme/app",
hosts: [{ serverId: "host-a" }, { serverId: "host-b" }],
hosts: [
{ serverId: "host-a", projectId: "prj_host_a" },
{ serverId: "host-b", projectId: "prj_host_b" },
],
};
function createProjectRemoveClient() {
@@ -46,12 +49,14 @@ describe("project remove policy", () => {
expect(readiness).toEqual({
kind: "ready",
targets: [{ serverId: "host-a" }, { serverId: "host-b" }],
targets: [
{ serverId: "host-a", projectId: "prj_host_a" },
{ serverId: "host-b", projectId: "prj_host_b" },
],
});
const outcome = await removeProjectFromHosts({
projectKey: project.projectKey,
targets: [{ serverId: "host-a" }, { serverId: "host-b" }],
targets: readiness.kind === "ready" ? readiness.targets : [],
getClient: (serverId) => {
if (serverId === "host-a") return hostA.client;
if (serverId === "host-b") return hostB.client;
@@ -60,8 +65,8 @@ describe("project remove policy", () => {
});
expect(outcome).toEqual({ kind: "removed", serverIds: ["host-a", "host-b"] });
expect(hostA.removedProjectKeys).toEqual([project.projectKey]);
expect(hostB.removedProjectKeys).toEqual([project.projectKey]);
expect(hostA.removedProjectKeys).toEqual(["prj_host_a"]);
expect(hostB.removedProjectKeys).toEqual(["prj_host_b"]);
});
it("reports disconnected hosts before sending any remove request", async () => {
@@ -69,7 +74,10 @@ describe("project remove policy", () => {
const outcome = await removeProjectFromHosts({
projectKey: project.projectKey,
targets: [{ serverId: "host-a" }, { serverId: "host-b" }],
targets: [
{ serverId: "host-a", projectId: "prj_host_a" },
{ serverId: "host-b", projectId: "prj_host_b" },
],
getClient: (serverId) => (serverId === "host-a" ? hostA.client : null),
});

View File

@@ -4,6 +4,7 @@ import { selectHostFeature } from "@/runtime/host-features";
interface ProjectRemoveHost {
serverId: string;
projectId?: string;
}
export interface ProjectRemoveProject {
@@ -13,6 +14,7 @@ export interface ProjectRemoveProject {
export interface ProjectRemoveTarget {
serverId: string;
projectId?: string;
}
export type ProjectRemoveReadiness =
@@ -38,7 +40,10 @@ export function getProjectRemoveReadiness(input: {
unsupportedServerIds.push(host.serverId);
continue;
}
targets.push({ serverId: host.serverId });
targets.push({
serverId: host.serverId,
projectId: host.projectId ?? input.project.projectKey,
});
}
if (unsupportedServerIds.length > 0) {
@@ -59,11 +64,11 @@ export function getCurrentProjectRemoveReadiness(
}
export async function removeProjectFromHosts(input: {
projectKey: string;
projectKey?: string;
targets: readonly ProjectRemoveTarget[];
getClient: (serverId: string) => ProjectRemoveClient | null;
}): Promise<ProjectRemoveOutcome> {
const clients: Array<{ serverId: string; client: ProjectRemoveClient }> = [];
const clients: Array<{ serverId: string; projectId: string; client: ProjectRemoveClient }> = [];
const disconnectedServerIds: string[] = [];
for (const target of input.targets) {
@@ -72,7 +77,11 @@ export async function removeProjectFromHosts(input: {
disconnectedServerIds.push(target.serverId);
continue;
}
clients.push({ serverId: target.serverId, client });
const projectId = target.projectId ?? input.projectKey;
if (!projectId) {
return { kind: "failed", serverIds: [target.serverId] };
}
clients.push({ serverId: target.serverId, projectId, client });
}
if (disconnectedServerIds.length > 0) {
@@ -80,8 +89,8 @@ export async function removeProjectFromHosts(input: {
}
const results = await Promise.allSettled(
clients.map(async ({ client }) => {
await client.removeProject(input.projectKey);
clients.map(async ({ client, projectId }) => {
await client.removeProject(projectId);
}),
);
const failedServerIds: string[] = [];

View File

@@ -1,8 +1,10 @@
import type { EmptyProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store";
import { projectDisplayNameFromProjectId } from "@/utils/project-display-name";
import { resolveProjectGroupKey } from "@/projects/project-group-key";
export interface WorkspaceStructureHostPlacement {
serverId: string;
projectId?: string;
iconWorkingDir: string;
canCreateWorktree: boolean;
}
@@ -74,9 +76,14 @@ export function buildWorkspaceStructureProjects(input: {
for (const session of input.sessions) {
for (const emptyProject of session.emptyProjects ?? []) {
const projectKey = emptyProject.projectId;
const projectKey = resolveProjectGroupKey({
serverId: session.serverId,
projectId: emptyProject.projectId,
projectGroupKey: emptyProject.projectGroupKey,
});
const placement = {
serverId: session.serverId,
projectId: emptyProject.projectId,
iconWorkingDir: emptyProject.projectRootPath,
canCreateWorktree: canCreateWorktreeForProjectKind(emptyProject.projectKind),
};
@@ -101,7 +108,11 @@ export function buildWorkspaceStructureProjects(input: {
}
for (const workspace of session.workspaces) {
const projectKey = workspace.project?.projectKey ?? workspace.projectId;
const projectKey = resolveProjectGroupKey({
serverId: session.serverId,
projectId: workspace.projectId,
projectGroupKey: workspace.projectGroupKey,
});
const existing = byProject.get(projectKey);
if (!existing) {
@@ -118,6 +129,7 @@ export function buildWorkspaceStructureProjects(input: {
session.serverId,
{
serverId: session.serverId,
projectId: workspace.projectId,
iconWorkingDir: workspace.projectRootPath,
canCreateWorktree: canCreateWorktreeForProjectKind(workspace.projectKind),
},
@@ -136,6 +148,7 @@ export function buildWorkspaceStructureProjects(input: {
existing.hosts.set(session.serverId, {
serverId: session.serverId,
projectId: workspace.projectId,
iconWorkingDir: workspace.projectRootPath,
canCreateWorktree: canCreateWorktreeForProjectKind(workspace.projectKind),
});

View File

@@ -193,6 +193,7 @@ function serializeWorkspace(workspace: WorkspaceDescriptor): WorkspaceDescriptor
return {
id: workspace.id,
projectId: workspace.projectId,
...(workspace.projectGroupKey ? { projectGroupKey: workspace.projectGroupKey } : {}),
projectDisplayName: workspace.projectDisplayName,
projectCustomName: workspace.projectCustomName ?? null,
projectRootPath: workspace.projectRootPath,

View File

@@ -66,6 +66,7 @@ import { toErrorMessage } from "@/utils/error-messages";
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
import {
getHostProjectSourceDirectory,
getHostProjectId,
hostProjectFromRoute,
hostProjectFromWorkspace,
useHostProjects,
@@ -823,6 +824,8 @@ async function createMultiplicityWorkspace(input: {
serverId: string;
createFailedMessage: string;
}): Promise<ReturnType<typeof normalizeWorkspaceDescriptor>> {
const projectId = getHostProjectId(input.project, input.serverId);
if (!projectId) throw new Error("Project is not available on the selected host");
const isWorktree = input.isolation === "worktree";
const checkoutRequest = isWorktree
? resolveCheckoutRequest(input.selectedItem, input.currentBranch)
@@ -836,14 +839,14 @@ async function createMultiplicityWorkspace(input: {
? {
kind: "worktree",
cwd: input.sourceDirectory,
projectId: input.project.projectKey,
projectId,
worktreeSlug: createNameId(),
...checkoutRequest,
}
: {
kind: "directory",
path: input.sourceDirectory,
projectId: input.project.projectKey,
projectId,
},
...(firstAgentContext ? { firstAgentContext } : {}),
});
@@ -1889,16 +1892,20 @@ export function NewWorkspaceScreen({
}
const checkoutRequest = resolveCheckoutRequest(selectedItem, currentBranch);
const firstAgentContext = buildFirstAgentContext(input);
const hostProjectId = getHostProjectId(selectedProject, selectedServerId);
if (!hostProjectId) {
throw new Error("Project is not available on the selected host");
}
return {
cwd: selectedSourceDirectory,
projectId: selectedProject.projectKey,
projectId: hostProjectId,
worktreeSlug: createNameId(),
...(firstAgentContext ? { firstAgentContext } : {}),
...checkoutRequest,
};
},
[currentBranch, selectedItem, selectedProject, selectedSourceDirectory],
[currentBranch, selectedItem, selectedProject, selectedServerId, selectedSourceDirectory],
);
const ensureWorkspace = useCallback(

View File

@@ -241,7 +241,11 @@ function ProjectSettingsBody({
projectName={project.projectName}
projectKey={project.projectKey}
/>
<ProjectNameEditor project={project} client={client} />
<ProjectNameEditor
project={project}
projectId={selectedHost.projectId ?? project.projectKey}
client={client}
/>
</View>
<HostContext hosts={hosts} selectedHost={selectedHost} onSelectHost={onSelectHost} />
</View>
@@ -790,10 +794,11 @@ function ResolveSpinnerColor(): string {
interface ProjectNameEditorProps {
project: ProjectSummary;
projectId: string;
client: DaemonClient;
}
function ProjectNameEditor({ project, client }: ProjectNameEditorProps) {
function ProjectNameEditor({ project, projectId, client }: ProjectNameEditorProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const toast = useToast();
@@ -801,7 +806,7 @@ function ProjectNameEditor({ project, client }: ProjectNameEditorProps) {
const [value, setValue] = useState(project.projectCustomName ?? "");
const renameMutation = useMutation({
mutationFn: (customName: string | null) => client.renameProject(project.projectKey, customName),
mutationFn: (customName: string | null) => client.renameProject(projectId, customName),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["projects"] });
setIsEditing(false);

View File

@@ -138,6 +138,7 @@ export interface Agent {
export interface WorkspaceDescriptor {
id: string;
projectId: string;
projectGroupKey?: string | null;
projectDisplayName: string;
projectCustomName?: string | null;
projectRootPath: string;
@@ -169,6 +170,7 @@ export function normalizeWorkspaceDescriptor(
return {
id: normalizeWorkspaceOpaqueId(payload.id) ?? payload.id,
projectId: payload.projectId,
projectGroupKey: payload.projectGroupKey ?? payload.project?.projectGroupKey ?? null,
projectDisplayName: payload.projectDisplayName,
projectCustomName: payload.projectCustomName ?? null,
projectRootPath: payload.projectRootPath,
@@ -195,6 +197,7 @@ export function normalizeWorkspaceDescriptor(
export interface EmptyProjectDescriptor {
projectId: string;
projectGroupKey?: string | null;
projectDisplayName: string;
projectCustomName: string | null;
projectRootPath: string;
@@ -206,6 +209,7 @@ export function normalizeEmptyProjectDescriptor(
): EmptyProjectDescriptor {
return {
projectId: payload.projectId,
projectGroupKey: payload.projectGroupKey ?? null,
projectDisplayName: payload.projectDisplayName,
projectCustomName: payload.projectCustomName ?? null,
projectRootPath: payload.projectRootPath,
@@ -253,6 +257,7 @@ function emptyProjectDescriptorFromWorkspace(
): EmptyProjectDescriptor {
return {
projectId: workspace.projectId,
...(workspace.projectGroupKey ? { projectGroupKey: workspace.projectGroupKey } : {}),
projectDisplayName: workspace.projectDisplayName,
projectCustomName: workspace.projectCustomName ?? null,
projectRootPath: workspace.projectRootPath,

View File

@@ -30,6 +30,7 @@ function workspace(input: {
repoRoot: string;
project?: ProjectPlacementPayload;
projectId?: string;
projectGroupKey?: string;
projectName?: string;
remoteUrl?: string | null;
currentBranch?: string;
@@ -38,6 +39,7 @@ function workspace(input: {
return {
id: input.id,
projectId: input.projectId ?? input.project?.projectKey ?? input.repoRoot,
projectGroupKey: input.projectGroupKey,
projectDisplayName: input.projectName ?? input.project?.projectName ?? "Project",
projectRootPath: input.repoRoot,
workspaceDirectory: input.repoRoot,
@@ -64,6 +66,36 @@ function workspace(input: {
}
describe("buildProjects", () => {
it("groups distinct host-local project IDs by their persisted group key", () => {
const projectGroupKey = "remote:github.com/acme/app";
const result = buildProjects({
hosts: [
{
serverId: "desktop",
serverName: "Desktop",
isOnline: true,
workspaces: [
workspace({ id: "main-a", repoRoot: "/a", projectId: "prj_a", projectGroupKey }),
],
},
{
serverId: "laptop",
serverName: "Laptop",
isOnline: true,
workspaces: [
workspace({ id: "main-b", repoRoot: "/b", projectId: "prj_b", projectGroupKey }),
],
},
],
});
expect(result.projects).toHaveLength(1);
expect(result.projects[0]?.hosts).toMatchObject([
{ serverId: "desktop", projectId: "prj_a" },
{ serverId: "laptop", projectId: "prj_b" },
]);
});
it("normalizes detached and blank branches out of workspace summaries", () => {
const result = buildProjects({
hosts: [

View File

@@ -1,6 +1,7 @@
import type { EmptyProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store";
import { buildHostProjectList, type HostProjectListItem } from "@/projects/host-project-model";
import { buildWorkspaceStructureProjects } from "@/projects/workspace-structure";
import { resolveProjectGroupKey } from "@/projects/project-group-key";
export interface WorkspaceSummary {
id: string;
@@ -14,6 +15,7 @@ export interface WorkspaceSummary {
export interface ProjectHostEntry {
serverId: string;
projectId?: string;
serverName: string;
isOnline: boolean;
repoRoot: string;
@@ -55,6 +57,7 @@ const GITHUB_PROJECT_KEY_PATTERN = /^remote:github\.com\/([^/]+)\/([^/]+)$/;
interface HostGroup {
serverId: string;
projectId: string;
serverName: string;
isOnline: boolean;
workspaces: WorkspaceDescriptor[];
@@ -137,6 +140,7 @@ function toHostEntry(group: HostGroup): ProjectHostEntry {
group.workspaces[0];
return {
serverId: group.serverId,
projectId: group.projectId,
serverName: group.serverName,
isOnline: group.isOnline,
repoRoot,
@@ -198,8 +202,11 @@ export function buildProjects(input: BuildProjectsInput): BuildProjectsResult {
}
if (!group.hostsByServerId.has(host.serverId)) {
const placement = hostProject.hosts.find((entry) => entry.serverId === host.serverId);
if (!placement) continue;
group.hostsByServerId.set(host.serverId, {
serverId: host.serverId,
projectId: placement.projectId ?? hostProject.projectKey,
serverName: host.serverName,
isOnline: host.isOnline,
workspaces: [],
@@ -209,7 +216,12 @@ export function buildProjects(input: BuildProjectsInput): BuildProjectsResult {
}
for (const workspace of host.workspaces) {
const group = groups.get(workspace.projectId);
const key = resolveProjectGroupKey({
serverId: host.serverId,
projectId: workspace.projectId,
projectGroupKey: workspace.projectGroupKey,
});
const group = groups.get(key);
const hostGroup = group?.hostsByServerId.get(host.serverId);
if (!hostGroup) continue;
hostGroup.workspaces.push(workspace);

View File

@@ -2,6 +2,7 @@ import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list";
export interface SidebarProjectHostTarget {
serverId: string;
projectId?: string;
iconWorkingDir: string;
}
@@ -21,13 +22,14 @@ const EMPTY_MULTIPLICITY_MAP: ReadonlyMap<string, boolean> = new Map();
function hostTarget(input: {
serverId: string;
projectId?: string;
iconWorkingDir: string;
}): SidebarProjectHostTarget | null {
const iconWorkingDir = input.iconWorkingDir.trim();
if (!input.serverId || !iconWorkingDir) {
return null;
}
return { serverId: input.serverId, iconWorkingDir };
return { serverId: input.serverId, projectId: input.projectId, iconWorkingDir };
}
export function resolveSidebarProjectIconTarget(

View File

@@ -299,6 +299,7 @@ function createLegacyWorkspace(
return {
id: workspaceDirectory,
projectId: entry.project.projectKey,
projectGroupKey: entry.project.projectGroupKey ?? null,
projectDisplayName: entry.project.projectName,
projectCustomName: null,
projectRootPath,

View File

@@ -3001,6 +3001,8 @@ export const ProjectCheckoutLitePayloadSchema = z.union([
export const ProjectPlacementPayloadSchema = z.object({
projectKey: z.string(),
// COMPAT(projectGroupKey): added in v0.2.4 on 2026-07-28; remove optional after 2027-01-28.
projectGroupKey: z.string().optional(),
projectName: z.string(),
workspaceName: z.string().nullable().optional(),
checkout: ProjectCheckoutLitePayloadSchema,
@@ -3090,6 +3092,8 @@ export const WorkspaceDescriptorPayloadSchema = z
.object({
id: z.string(),
projectId: z.string(),
// COMPAT(projectGroupKey): added in v0.2.4 on 2026-07-28; remove optional after 2027-01-28.
projectGroupKey: z.string().optional(),
projectDisplayName: z.string(),
// COMPAT(projectCustomName): added in v0.1.76, drop the optional gate when floor >= v0.1.76.
// When the user has renamed a project, projectDisplayName carries the resolved
@@ -3230,6 +3234,8 @@ export const FetchRecentProviderSessionsResponseMessageSchema = z.object({
// workspace is archived.
export const WorkspaceProjectDescriptorPayloadSchema = z.object({
projectId: z.string(),
// COMPAT(projectGroupKey): added in v0.2.4 on 2026-07-28; remove optional after 2027-01-28.
projectGroupKey: z.string().optional(),
projectDisplayName: z.string(),
projectCustomName: z.string().nullable().optional(),
projectRootPath: z.string(),

View File

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

View File

@@ -0,0 +1,42 @@
import { resolve } from "node:path";
/**
* Derives the persisted, opaque equivalence key used to group projects across hosts.
* A Git remote is today's strongest shared fact, but callers treat the result as opaque.
*/
export function deriveProjectGroupKey(input: {
rootPath: string;
remoteUrl: string | null;
mainRepoRoot: string | null;
}): string {
return (
deriveRemoteProjectGroupKey(input.remoteUrl) ?? resolve(input.mainRepoRoot ?? input.rootPath)
);
}
function deriveRemoteProjectGroupKey(remoteUrl: string | null): string | 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}`;
}

View File

@@ -1690,6 +1690,7 @@ export class Session {
});
return {
projectKey: project.projectId,
...(project.projectGroupKey ? { projectGroupKey: project.projectGroupKey } : {}),
projectName: resolveProjectDisplayName(project),
workspaceName: resolveWorkspaceDisplayName(workspace),
checkout,
@@ -4460,6 +4461,7 @@ export class Session {
): WorkspaceProjectDescriptorPayload {
return {
projectId: project.projectId,
...(project.projectGroupKey ? { projectGroupKey: project.projectGroupKey } : {}),
projectDisplayName: resolveProjectDisplayName(project),
projectCustomName: project.customName ?? null,
projectRootPath: project.rootPath,

View File

@@ -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 { deriveProjectGroupKey } from "../../project-group-key.js";
import { areEquivalentPaths, createRealpathAwarePathMatcher } from "../../../utils/path.js";
export interface ResolveOrCreateWorkspaceIdInput {
@@ -162,6 +163,11 @@ export function createWorkspaceProvisioningService(deps: {
rootPath,
kind: checkout.isGit ? "git" : "non_git",
displayName: basename(rootPath) || rootPath,
projectGroupKey: deriveProjectGroupKey({
rootPath,
remoteUrl: checkout.remoteUrl,
mainRepoRoot: checkout.mainRepoRoot,
}),
timestamp,
});
}
@@ -256,10 +262,16 @@ 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,
projectGroupKey: deriveProjectGroupKey({
rootPath: input.repoRoot,
remoteUrl: checkout.remoteUrl,
mainRepoRoot: checkout.mainRepoRoot,
}),
timestamp: new Date().toISOString(),
});
return refreshProjectKind(project);
@@ -371,8 +383,18 @@ 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 projectGroupKey = deriveProjectGroupKey({
rootPath: project.rootPath,
remoteUrl: projectCheckout.remoteUrl,
mainRepoRoot: projectCheckout.mainRepoRoot,
});
if (project.kind === kind && project.projectGroupKey === projectGroupKey) return project;
const refreshed = {
...project,
kind,
projectGroupKey,
updatedAt: new Date().toISOString(),
};
await projectRegistry.upsert(refreshed);
return refreshed;
}

View File

@@ -260,6 +260,7 @@ function createNoopProjectRegistry(): ProjectRegistry {
rootPath: input.rootPath,
kind: input.kind,
displayName: input.displayName,
projectGroupKey: input.projectGroupKey ?? null,
customName: null,
createdAt: input.timestamp,
updatedAt: input.timestamp,

View File

@@ -502,6 +502,7 @@ export class WorkspaceDirectory {
)
.map((project) => ({
projectId: project.projectId,
projectGroupKey: project.projectGroupKey ?? undefined,
projectDisplayName: resolveProjectDisplayName(project),
projectCustomName: project.customName ?? null,
projectRootPath: project.rootPath,

View File

@@ -777,6 +777,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 +787,7 @@ describe("WorkspaceReconciliationService", () => {
rootPath: repoDir,
displayName: "blank-dot-page/editor",
customName: null,
projectGroupKey: "remote:github.com/blank-dot-page/editor",
archivedAt: null,
});
expect(projects.get(repoDir)).toMatchObject({
@@ -792,6 +795,7 @@ describe("WorkspaceReconciliationService", () => {
rootPath: repoDir,
displayName: "editor",
customName: "Editor",
projectGroupKey: "remote:github.com/blank-dot-page/editor",
archivedAt: null,
});
expect(workspaces.get("focused-bat")).toMatchObject({
@@ -804,7 +808,7 @@ describe("WorkspaceReconciliationService", () => {
});
});
test("keeps project display name stable when git remote changes", async () => {
test("updates the group key while keeping the project display name stable", async () => {
const dir = createTempGitRepo("reconcile-remote-");
tempDirs.push(dir);
@@ -856,8 +860,11 @@ 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: { projectGroupKey: "remote:github.com/new-owner/new-repo" },
});
expect(projects.get("p1")!.displayName).toBe("old-owner/old-repo");
expect(projects.get("p1")!.projectGroupKey).toBe("remote:github.com/new-owner/new-repo");
});
test("keeps custom and default names stable when the remote changes", async () => {

View File

@@ -15,6 +15,7 @@ import {
type MutableWorkspacePlacement,
} from "./workspace-registry-model.js";
import { workspaceIdsForProjects } from "./workspace-directory.js";
import { deriveProjectGroupKey } from "./project-group-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" | "projectGroupKey">>;
}
| {
kind: "workspace_updated";
@@ -280,6 +281,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 +340,22 @@ export class WorkspaceReconciliationService {
checkout: await readCheckout(workspace.cwd),
})),
);
const projectUpdates: Partial<Pick<PersistedProjectRecord, "kind">> = {};
const projectUpdates: Partial<Pick<PersistedProjectRecord, "kind" | "projectGroupKey">> = {};
const mappedKind = deriveProjectKind(currentGit);
const projectGroupKey = currentGit.remoteUrl
? deriveProjectGroupKey({
rootPath: project.rootPath,
remoteUrl: currentGit.remoteUrl,
mainRepoRoot: currentGit.mainRepoRoot,
})
: null;
if (project.kind !== mappedKind) {
projectUpdates.kind = mappedKind;
}
if (projectGroupKey && project.projectGroupKey !== projectGroupKey) {
projectUpdates.projectGroupKey = projectGroupKey;
}
if (Object.keys(projectUpdates).length > 0) {
const timestamp = new Date().toISOString();

View File

@@ -3,6 +3,7 @@ import { resolve } from "node:path";
import type { ProjectCheckoutLitePayload } from "@getpaseo/protocol/messages";
import { parseGitRevParsePath } from "../utils/git-rev-parse-path.js";
import { deriveProjectGroupKey } from "./project-group-key.js";
import {
deriveProjectKind,
deriveWorkspaceDisplayName,
@@ -31,8 +32,8 @@ export function classifyDirectoryForProjectMembership(input: {
}): DirectoryProjectMembership {
const cwd = resolve(input.cwd);
const checkout: ProjectCheckoutLitePayload = { ...input.checkout, cwd };
const projectKey = deriveProjectGroupingKey({
cwd: checkout.worktreeRoot ?? cwd,
const projectKey = deriveProjectGroupKey({
rootPath: checkout.worktreeRoot ?? cwd,
remoteUrl: checkout.remoteUrl,
mainRepoRoot: checkout.mainRepoRoot,
});
@@ -55,49 +56,6 @@ function deriveWorkspaceDirectoryKey(cwd: string, checkout: ProjectCheckoutLiteP
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);

View File

@@ -16,6 +16,12 @@ const PersistedProjectRecordSchema = z.object({
rootPath: z.string(),
kind: z.enum(["git", "non_git"]),
displayName: z.string(),
// COMPAT(projectGroupKey): added in v0.2.4 on 2026-07-28; remove optional after 2027-01-28.
projectGroupKey: 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;
projectGroupKey?: string;
timestamp: string;
}): Promise<PersistedProjectRecord>;
upsert(record: PersistedProjectRecord): Promise<void>;
@@ -308,6 +315,7 @@ export class FileBackedProjectRegistry
rootPath: string;
kind: PersistedProjectKind;
displayName: string;
projectGroupKey?: string;
timestamp: string;
}): Promise<PersistedProjectRecord> {
const previous = this.allocationQueue;
@@ -325,8 +333,17 @@ 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.projectGroupKey === (input.projectGroupKey ?? null)
)
return active;
const refreshed = {
...active,
kind: input.kind,
projectGroupKey: input.projectGroupKey ?? null,
updatedAt: input.timestamp,
};
await this.upsert(refreshed);
return refreshed;
}
@@ -339,6 +356,7 @@ export class FileBackedProjectRegistry
rootPath: input.rootPath,
kind: input.kind,
displayName: input.displayName,
projectGroupKey: input.projectGroupKey ?? null,
createdAt: input.timestamp,
updatedAt: input.timestamp,
});
@@ -459,6 +477,7 @@ export function createPersistedProjectRecord(input: {
kind: PersistedProjectKind;
displayName: string;
customName?: string | null;
projectGroupKey?: string | null;
createdAt: string;
updatedAt: string;
archivedAt?: string | null;
@@ -466,6 +485,7 @@ export function createPersistedProjectRecord(input: {
return PersistedProjectRecordSchema.parse({
...input,
customName: input.customName ?? null,
projectGroupKey: input.projectGroupKey ?? null,
archivedAt: input.archivedAt ?? null,
});
}