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.
This commit is contained in:
Mohamed Boudra
2026-07-29 09:12:08 +00:00
parent 39e5006882
commit 649592fe73
40 changed files with 201 additions and 221 deletions

View File

@@ -4,13 +4,13 @@
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.
`projectGroupKey` is a persisted, opaque equivalence key used only to group the same logical project
`projectKey` 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
`kind` and `projectKey` 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
@@ -454,17 +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` | 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 |
| Field | Type | Description |
| ------------- | --------------------------- | -------------------------------------------------------------------------------- |
| `projectId` | `string` | Host-local primary key; new records use opaque `prj_<16 hex>` IDs |
| `projectKey` | `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,8 +2,7 @@
Authoritative terminology. UI label wins. Don't invent synonyms; use what's here.
- **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`.
- **Project** — A stable, exact selected-root record. Its host-local `projectId` is an opaque `prj_<16 hex>` value. Its persisted `projectKey` is an opaque equivalence key that may group the logical project across hosts. A normalized Git remote is the current key producer, but consumers must not parse or rederive it. 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.
- **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).

View File

@@ -222,9 +222,9 @@ test.describe("Project remove", () => {
expect(readded.error).toBeNull();
expect(readded.project).not.toBeNull();
readdedProjectId = readded.project?.projectId ?? "";
const readdedProjectGroupKey = readded.project?.projectGroupKey ?? "";
const readdedProjectKey = readded.project?.projectKey ?? "";
expect(readdedProjectId).not.toBe(workspace.projectId);
expect(readdedProjectGroupKey).toBe(workspace.projectKey);
expect(readdedProjectKey).toBe(workspace.projectKey);
expect(readded.project?.projectDisplayName).toBe(workspace.projectDisplayName);
await page.reload();
@@ -233,7 +233,7 @@ test.describe("Project remove", () => {
await expect(projectRow).toContainText(workspace.projectDisplayName);
await expect(projectRow).not.toContainText(workspace.repoPath);
await expect(
page.getByTestId(`sidebar-project-new-workspace-row-${readdedProjectGroupKey}`),
page.getByTestId(`sidebar-project-new-workspace-row-${readdedProjectKey}`),
).toBeVisible({ timeout: 30_000 });
} finally {
if (readdedProjectId) {

View File

@@ -46,9 +46,9 @@ function requireWorkspace(payload: WorkspacePayload) {
}
function openedProjectFromWorkspace(workspace: WorkspaceDescriptor): OpenedProject {
const projectKey = workspace.projectGroupKey ?? workspace.project?.projectGroupKey;
const projectKey = workspace.projectKey ?? workspace.project?.projectKey;
if (!projectKey) {
throw new Error(`Workspace ${workspace.id} has no project group key`);
throw new Error(`Workspace ${workspace.id} has no project key`);
}
return {
workspaceId: workspace.id,

View File

@@ -8,8 +8,8 @@ export interface SeedWorkspaceDescriptor {
id: string;
name: string;
projectId: string;
projectGroupKey?: string;
project?: { projectGroupKey?: string };
projectKey?: string;
project?: { projectKey?: string };
projectDisplayName: string;
projectRootPath: string;
workspaceDirectory: string;
@@ -27,7 +27,7 @@ export interface SeedDaemonClient {
addProject(cwd: string): Promise<{
project: {
projectId: string;
projectGroupKey?: string;
projectKey?: string;
projectDisplayName: string;
projectRootPath: string;
} | null;
@@ -213,9 +213,9 @@ export async function seedWorkspace(options: {
throw new Error(created.error ?? `Failed to create workspace ${project.path}`);
}
const workspace = created.workspace;
const projectKey = workspace.projectGroupKey ?? workspace.project?.projectGroupKey;
const projectKey = workspace.projectKey ?? workspace.project?.projectKey;
if (!projectKey) {
throw new Error(`Created workspace ${workspace.id} has no project group key`);
throw new Error(`Created workspace ${workspace.id} has no project key`);
}
return {
client,

View File

@@ -65,12 +65,9 @@ async function seedPaseoWorkspaceWithOpenCodeSession(): Promise<OpenCodeImportSc
throw new Error(createdWorkspace.error ?? `Failed to create workspace ${PASEO_REPO_PATH}`);
}
const projectKey =
createdWorkspace.workspace.projectGroupKey ??
createdWorkspace.workspace.project?.projectGroupKey;
createdWorkspace.workspace.projectKey ?? createdWorkspace.workspace.project?.projectKey;
if (!projectKey) {
throw new Error(
`Created workspace ${createdWorkspace.workspace.id} has no project group key`,
);
throw new Error(`Created workspace ${createdWorkspace.workspace.id} has no project key`);
}
return {
prompt,

View File

@@ -78,27 +78,27 @@ async function openGroupedProjectSettings(page: Page): Promise<void> {
await expect(page.getByTestId("host-picker")).toBeVisible({ timeout: 30_000 });
}
async function readPersistedProjectGroupKey(host: IsolatedHostDaemon): Promise<unknown> {
async function readPersistedProjectKey(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;
return projects[0]?.projectKey;
}
async function removePersistedProjectGroupKeys(host: IsolatedHostDaemon): Promise<void> {
async function removePersistedProjectKeys(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;
delete project.projectKey;
}
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);
expect(persisted.every((project) => !("projectKey" in project))).toBe(true);
}
async function rewritePersistedProjectId(
@@ -147,8 +147,8 @@ async function createReconciliationFixture(options?: { sharedLegacyProjectId?: s
let secondary = await createProject(secondaryClient, secondaryRepo, secondaryHost.serverId);
await primaryClient.close();
await secondaryClient.close();
await removePersistedProjectGroupKeys(primaryHost);
await removePersistedProjectGroupKeys(secondaryHost);
await removePersistedProjectKeys(primaryHost);
await removePersistedProjectKeys(secondaryHost);
if (options?.sharedLegacyProjectId) {
await Promise.all([
rewritePersistedProjectId(primaryHost, primary.projectId, options.sharedLegacyProjectId),
@@ -256,7 +256,7 @@ test.describe("Sidebar project grouping", () => {
await expectOneProjectContainsBothWorkspaces(page, crossHostProject);
});
test("groups persisted projects missing group keys after app boot", async ({
test("groups persisted projects missing project keys after app boot", async ({
page,
reconciledCrossHostProject,
}) => {
@@ -286,10 +286,10 @@ test.describe("Sidebar project grouping", () => {
});
await expectOneProjectContainsBothWorkspaces(page, reconciledCrossHostProject);
await expect
.poll(() => readPersistedProjectGroupKey(reconciledCrossHostProject.primaryHost))
.poll(() => readPersistedProjectKey(reconciledCrossHostProject.primaryHost))
.toBe("remote:github.com/paseo-e2e/grouped-project");
await expect
.poll(() => readPersistedProjectGroupKey(reconciledCrossHostProject.secondaryHost))
.poll(() => readPersistedProjectKey(reconciledCrossHostProject.secondaryHost))
.toBe("remote:github.com/paseo-e2e/grouped-project");
});

View File

@@ -32,8 +32,8 @@ interface RestartDaemonClient {
name: string;
status: string;
workspaceDirectory: string;
projectGroupKey?: string;
project?: { projectGroupKey?: string };
projectKey?: string;
project?: { projectKey?: string };
}>;
}>;
fetchAgents(options?: { scope?: "active" }): Promise<{
@@ -467,7 +467,7 @@ test.describe("Workspace model restart regressions", () => {
(workspace) => workspace.id === seeded.workspaceA,
);
const reconciledProjectKey =
reconciledWorkspace?.projectGroupKey ?? reconciledWorkspace?.project?.projectGroupKey;
reconciledWorkspace?.projectKey ?? reconciledWorkspace?.project?.projectKey;
if (!reconciledProjectKey) {
throw new Error(`Workspace ${seeded.workspaceA} was not reconciled with a project key`);
}

View File

@@ -27,13 +27,13 @@ describe("selectActiveGitWorkspaceProject", () => {
});
});
it("uses the persisted project group key for the settings route", () => {
it("uses the persisted project key for the settings route", () => {
expect(
selectActiveGitWorkspaceProject(
"server-1",
gitWorkspace({
projectId: "prj_local",
projectGroupKey: "remote:github.com/acme/project",
projectKey: "remote:github.com/acme/project",
}),
),
).toMatchObject({
@@ -101,7 +101,7 @@ describe("buildWorktreeSetupCalloutPolicy", () => {
});
});
it("keeps the action route stable when the structural group key changes", () => {
it("keeps the action route stable when the structural project key changes", () => {
expect(
buildWorktreeSetupCalloutPolicy({
serverId: "server-1",

View File

@@ -1,12 +1,12 @@
import type { PaseoConfigRaw } from "@getpaseo/protocol/messages";
import { i18n } from "@/i18n/i18next";
import { resolveProjectGroupKey } from "@/projects/project-group-key";
import { resolveProjectKey } from "@/projects/project-key";
import { resolveHostProjectSettingsRouteKey } from "@/projects/project-settings-target";
import { buildProjectSettingsRoute } from "@/utils/host-routes";
export interface WorktreeSetupWorkspaceInput {
projectId: string;
projectGroupKey?: string | null;
projectKey?: string | null;
projectKind: string;
projectRootPath: string;
}
@@ -43,10 +43,10 @@ export function selectActiveGitWorkspaceProject(
}
const projectId = workspace.projectId.trim();
const projectKey = resolveProjectGroupKey({
const projectKey = resolveProjectKey({
serverId,
projectId,
projectGroupKey: workspace.projectGroupKey,
projectKey: workspace.projectKey,
});
const repoRoot = workspace.projectRootPath.trim();
if (!projectId || !repoRoot) {

View File

@@ -115,14 +115,14 @@ function workspace(input: {
name: string;
projectId: string;
projectDisplayName: string;
projectGroupKey?: string;
projectKey?: string;
status?: WorkspaceDescriptor["status"];
statusEnteredAt?: Date | null;
}): WorkspaceDescriptor {
return {
id: input.id,
projectId: input.projectId,
projectGroupKey: input.projectGroupKey,
projectKey: input.projectKey,
projectDisplayName: input.projectDisplayName,
projectRootPath: `/repo/${input.projectId}`,
workspaceDirectory: `/repo/${input.projectId}/${input.id}`,
@@ -422,7 +422,7 @@ describe("shared sidebar workspace model", () => {
id: "clone-a",
name: "main",
projectId: "prj_a",
projectGroupKey: "remote:github.com/acme/app",
projectKey: "remote:github.com/acme/app",
projectDisplayName: "acme/app",
}),
],

View File

@@ -8,7 +8,7 @@ import type {
WorkspaceStructureProject,
} from "@/projects/workspace-structure";
import { projectDisplayNameFromProjectId } from "@/utils/project-display-name";
import { resolveProjectGroupKey } from "@/projects/project-group-key";
import { resolveProjectKey } from "@/projects/project-key";
import type { WorkspaceAgentActivity } from "@/utils/workspace-agent-activity";
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
@@ -148,10 +148,10 @@ export function createSidebarWorkspaceEntry(input: {
}): SidebarWorkspaceEntry {
const projectKey =
input.projectKey ??
resolveProjectGroupKey({
resolveProjectKey({
serverId: input.serverId,
projectId: input.workspace.projectId,
projectGroupKey: input.workspace.projectGroupKey,
projectKey: input.workspace.projectKey,
});
const effectiveStatus = deriveEffectiveWorkspaceStatus(input);
return {

View File

@@ -93,7 +93,7 @@ describe("openProjectDirectly", () => {
serverId: SERVER_ID,
project: {
projectId: "project-1",
projectGroupKey: null,
projectKey: null,
projectDisplayName: "project",
projectCustomName: null,
projectKind: "git",
@@ -192,7 +192,7 @@ describe("cloneGithubProjectDirectly", () => {
project: {
...projectPayload,
projectCustomName: null,
projectGroupKey: null,
projectKey: null,
},
},
]);

View File

@@ -3,7 +3,7 @@ import type {
WorkspaceStructureHostPlacement,
WorkspaceStructureProject,
} from "@/projects/workspace-structure";
import { resolveProjectGroupKey } from "@/projects/project-group-key";
import { resolveProjectKey } from "@/projects/project-key";
export interface HostProjectListItem {
projectKey: string;
@@ -75,10 +75,10 @@ export function hostProjectFromWorkspace(input: {
if (!input.workspace) {
return null;
}
const projectKey = resolveProjectGroupKey({
const projectKey = resolveProjectKey({
serverId: input.serverId,
projectId: input.workspace.projectId.trim(),
projectGroupKey: input.workspace.projectGroupKey,
projectKey: input.workspace.projectKey,
});
const iconWorkingDir = input.workspace.projectRootPath.trim();
if (!projectKey || !iconWorkingDir) {

View File

@@ -1,10 +1,10 @@
export function resolveProjectGroupKey(input: {
export function resolveProjectKey(input: {
serverId: string;
projectId: string;
projectGroupKey?: string | null;
projectKey?: 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.
if (input.projectKey) return input.projectKey;
// COMPAT(projectKey): 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_") ? frameHostProjectKey(input) : input.projectId;

View File

@@ -1,4 +1,4 @@
import { frameHostProjectKey } from "./project-group-key";
import { frameHostProjectKey } from "./project-key";
interface ProjectSettingsTarget {
projectKey: string;

View File

@@ -11,7 +11,7 @@ function workspace(input: {
return {
id: input.id,
projectId: input.projectId ?? `project-${input.id}`,
projectGroupKey: "remote:github.com/acme/app",
projectKey: "remote:github.com/acme/app",
projectDisplayName: input.projectName,
projectCustomName: input.projectCustomName,
projectRootPath: `/repo/${input.id}`,

View File

@@ -1,6 +1,6 @@
import type { EmptyProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store";
import { projectDisplayNameFromProjectId } from "@/utils/project-display-name";
import { frameHostProjectKey, resolveProjectGroupKey } from "@/projects/project-group-key";
import { frameHostProjectKey, resolveProjectKey } from "@/projects/project-key";
export interface WorkspaceStructureHostPlacement {
serverId: string;
@@ -65,23 +65,21 @@ interface MaterializedWorkspaceStructureSession {
emptyProjects: EmptyProjectDescriptor[];
}
function findAmbiguousProjectGroupKeys(
sessions: MaterializedWorkspaceStructureSession[],
): Set<string> {
function findAmbiguousProjectKeys(sessions: MaterializedWorkspaceStructureSession[]): Set<string> {
const projectIdsByHostByGroupKey = new Map<string, Map<string, Set<string>>>();
for (const session of sessions) {
const projects = [
...session.emptyProjects.map((project) => ({
projectId: project.projectId,
projectGroupKey: project.projectGroupKey,
projectKey: project.projectKey,
})),
...session.workspaces.map((workspace) => ({
projectId: workspace.projectId,
projectGroupKey: workspace.projectGroupKey,
projectKey: workspace.projectKey,
})),
];
for (const project of projects) {
const groupKey = resolveProjectGroupKey({ serverId: session.serverId, ...project });
const groupKey = resolveProjectKey({ serverId: session.serverId, ...project });
const byHost = projectIdsByHostByGroupKey.get(groupKey) ?? new Map();
const projectIds = byHost.get(session.serverId) ?? new Set();
projectIds.add(project.projectId);
@@ -97,13 +95,13 @@ function findAmbiguousProjectGroupKeys(
);
}
function resolveUnambiguousProjectGroupKey(input: {
function resolveUnambiguousProjectKey(input: {
serverId: string;
projectId: string;
projectGroupKey?: string | null;
projectKey?: string | null;
ambiguousGroupKeys: ReadonlySet<string>;
}): string {
const groupKey = resolveProjectGroupKey(input);
const groupKey = resolveProjectKey(input);
return input.ambiguousGroupKeys.has(groupKey) ? frameHostProjectKey(input) : groupKey;
}
@@ -115,7 +113,7 @@ export function buildWorkspaceStructureProjects(input: {
workspaces: [...session.workspaces],
emptyProjects: [...(session.emptyProjects ?? [])],
}));
const ambiguousGroupKeys = findAmbiguousProjectGroupKeys(sessions);
const ambiguousGroupKeys = findAmbiguousProjectKeys(sessions);
const byProject = new Map<
string,
{
@@ -131,10 +129,10 @@ export function buildWorkspaceStructureProjects(input: {
for (const session of sessions) {
for (const emptyProject of session.emptyProjects) {
const projectKey = resolveUnambiguousProjectGroupKey({
const projectKey = resolveUnambiguousProjectKey({
serverId: session.serverId,
projectId: emptyProject.projectId,
projectGroupKey: emptyProject.projectGroupKey,
projectKey: emptyProject.projectKey,
ambiguousGroupKeys,
});
const placement = {
@@ -169,10 +167,10 @@ export function buildWorkspaceStructureProjects(input: {
}
for (const workspace of session.workspaces) {
const projectKey = resolveUnambiguousProjectGroupKey({
const projectKey = resolveUnambiguousProjectKey({
serverId: session.serverId,
projectId: workspace.projectId,
projectGroupKey: workspace.projectGroupKey,
projectKey: workspace.projectKey,
ambiguousGroupKeys,
});
const existing = byProject.get(projectKey);

View File

@@ -103,7 +103,7 @@ it("commits the authoritative snapshot before buffered project updates", () => {
kind: "upsert",
project: {
projectId: "attached",
projectGroupKey: "remote:github.com/acme/attached",
projectKey: "remote:github.com/acme/attached",
projectDisplayName: "Renamed attached project",
projectCustomName: "Personal name",
projectRootPath: "/moved/attached",
@@ -125,14 +125,14 @@ it("commits the authoritative snapshot before buffered project updates", () => {
const session = useSessionStore.getState().sessions[serverId];
expect(session?.workspaces.get(attachedMain.id)).toMatchObject({
projectGroupKey: "remote:github.com/acme/attached",
projectKey: "remote:github.com/acme/attached",
projectDisplayName: "Renamed attached project",
projectCustomName: "Personal name",
projectRootPath: "/moved/attached",
projectKind: "directory",
});
expect(session?.workspaces.get(attachedFeature.id)).toMatchObject({
projectGroupKey: "remote:github.com/acme/attached",
projectKey: "remote:github.com/acme/attached",
projectDisplayName: "Renamed attached project",
projectRootPath: "/moved/attached",
});

View File

@@ -42,7 +42,7 @@ function applyProjectDelta(
hasAttachedWorkspace = true;
snapshot.workspaces.set(workspaceId, {
...workspace,
projectGroupKey: project.projectGroupKey,
projectKey: project.projectKey,
projectDisplayName: project.projectDisplayName,
projectCustomName: project.projectCustomName,
projectRootPath: project.projectRootPath,

View File

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

View File

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

View File

@@ -30,7 +30,7 @@ function workspace(input: {
repoRoot: string;
project?: ProjectPlacementPayload;
projectId?: string;
projectGroupKey?: string;
projectKey?: string;
projectCustomName?: string | null;
projectName?: string;
remoteUrl?: string | null;
@@ -40,7 +40,7 @@ function workspace(input: {
return {
id: input.id,
projectId: input.projectId ?? input.project?.projectKey ?? input.repoRoot,
projectGroupKey: input.projectGroupKey,
projectKey: input.projectKey,
projectDisplayName: input.projectName ?? input.project?.projectName ?? "Project",
projectCustomName: input.projectCustomName,
projectRootPath: input.repoRoot,
@@ -68,25 +68,21 @@ function workspace(input: {
}
describe("buildProjects", () => {
it("groups distinct host-local project IDs by their persisted group key", () => {
const projectGroupKey = "remote:github.com/acme/app";
it("groups distinct host-local project IDs by their persisted project key", () => {
const projectKey = "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 }),
],
workspaces: [workspace({ id: "main-a", repoRoot: "/a", projectId: "prj_a", projectKey })],
},
{
serverId: "laptop",
serverName: "Laptop",
isOnline: true,
workspaces: [
workspace({ id: "main-b", repoRoot: "/b", projectId: "prj_b", projectGroupKey }),
],
workspaces: [workspace({ id: "main-b", repoRoot: "/b", projectId: "prj_b", projectKey })],
},
],
});
@@ -98,8 +94,8 @@ describe("buildProjects", () => {
]);
});
it("keeps independent same-host clones separate when their group key is ambiguous", () => {
const projectGroupKey = "remote:github.com/acme/app";
it("keeps independent same-host clones separate when their project key is ambiguous", () => {
const projectKey = "remote:github.com/acme/app";
const result = buildProjects({
hosts: [
{
@@ -107,8 +103,8 @@ describe("buildProjects", () => {
serverName: "Desktop",
isOnline: true,
workspaces: [
workspace({ id: "clone-a", repoRoot: "/a", projectId: "prj_a", projectGroupKey }),
workspace({ id: "clone-b", repoRoot: "/b", projectId: "prj_b", projectGroupKey }),
workspace({ id: "clone-a", repoRoot: "/a", projectId: "prj_a", projectKey }),
workspace({ id: "clone-b", repoRoot: "/b", projectId: "prj_b", projectKey }),
],
},
],
@@ -122,7 +118,7 @@ describe("buildProjects", () => {
});
it("uses one ambiguity scope across every host", () => {
const projectGroupKey = "remote:github.com/acme/app";
const projectKey = "remote:github.com/acme/app";
const result = buildProjects({
hosts: [
{
@@ -130,8 +126,8 @@ describe("buildProjects", () => {
serverName: "Desktop",
isOnline: true,
workspaces: [
workspace({ id: "clone-a", repoRoot: "/a", projectId: "prj_a", projectGroupKey }),
workspace({ id: "clone-b", repoRoot: "/b", projectId: "prj_b", projectGroupKey }),
workspace({ id: "clone-a", repoRoot: "/a", projectId: "prj_a", projectKey }),
workspace({ id: "clone-b", repoRoot: "/b", projectId: "prj_b", projectKey }),
],
},
{
@@ -139,21 +135,21 @@ describe("buildProjects", () => {
serverName: "Laptop",
isOnline: true,
workspaces: [
workspace({ id: "clone-c", repoRoot: "/c", projectId: "prj_c", projectGroupKey }),
workspace({ id: "clone-c", repoRoot: "/c", projectId: "prj_c", projectKey }),
],
},
],
});
expect(result.projects.map((project) => project.projectKey).sort()).toEqual([
"host:desktop:project:prj_a",
"host:desktop:project:prj_b",
"host:laptop:project:prj_c",
"host:6:laptop:project:5:prj_c",
"host:7:desktop:project:5:prj_a",
"host:7:desktop:project:5:prj_b",
]);
});
it("keeps host-local metadata when the grouping key differs from the project ID", () => {
const projectGroupKey = "remote:github.com/acme/app";
const projectKey = "remote:github.com/acme/app";
const result = buildProjects({
hosts: [
{
@@ -165,7 +161,7 @@ describe("buildProjects", () => {
id: "main",
repoRoot: "/repo/app",
projectId: "prj_app",
projectGroupKey,
projectKey,
projectName: "acme/app",
projectCustomName: "My App",
}),
@@ -175,14 +171,14 @@ describe("buildProjects", () => {
});
expect(result.projects[0]).toMatchObject({
projectKey: projectGroupKey,
projectKey: projectKey,
projectName: "acme/app",
projectCustomName: "My App",
});
});
it("keeps custom-name state on the host that owns it", () => {
const projectGroupKey = "remote:github.com/acme/app";
const projectKey = "remote:github.com/acme/app";
const result = buildProjects({
hosts: [
{
@@ -194,7 +190,7 @@ describe("buildProjects", () => {
id: "desktop-main",
repoRoot: "/desktop/app",
projectId: "prj_desktop",
projectGroupKey,
projectKey,
projectName: "My App",
projectCustomName: "My App",
}),
@@ -209,7 +205,7 @@ describe("buildProjects", () => {
id: "laptop-main",
repoRoot: "/laptop/app",
projectId: "prj_laptop",
projectGroupKey,
projectKey,
projectName: "acme/app",
projectCustomName: null,
}),
@@ -235,7 +231,7 @@ describe("buildProjects", () => {
emptyProjects: [
{
projectId: "prj_app",
projectGroupKey: "remote:github.com/acme/app",
projectKey: "remote:github.com/acme/app",
projectDisplayName: "My Empty Project",
projectCustomName: "My Empty Project",
projectRootPath: "/repo/app",

View File

@@ -1,7 +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";
import { resolveProjectKey } from "@/projects/project-key";
export interface WorkspaceSummary {
id: string;
@@ -238,10 +238,10 @@ function attachHostWorkspaces(
for (const workspace of host.workspaces) {
const key =
projectKeyByProjectId.get(workspace.projectId) ??
resolveProjectGroupKey({
resolveProjectKey({
serverId: host.serverId,
projectId: workspace.projectId,
projectGroupKey: workspace.projectGroupKey,
projectKey: workspace.projectKey,
});
groups.get(key)?.hostsByServerId.get(host.serverId)?.workspaces.push(workspace);
}

View File

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

View File

@@ -3001,8 +3001,6 @@ 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,
@@ -3092,8 +3090,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(),
// COMPAT(projectKey): added in v0.2.4 on 2026-07-28; remove optional after 2027-01-28.
projectKey: 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
@@ -3234,8 +3232,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(),
// COMPAT(projectKey): added in v0.2.4 on 2026-07-28; remove optional after 2027-01-28.
projectKey: z.string().optional(),
projectDisplayName: z.string(),
projectCustomName: z.string().nullable().optional(),
projectRootPath: z.string(),

View File

@@ -75,7 +75,7 @@ test("creates a worktree and registers it in the source workspace project withou
expect(deps.workspaceGitService.getSnapshot).not.toHaveBeenCalled();
expect(deps.projects.get(sourceProject.projectId)).toEqual({
...sourceProject,
projectGroupKey: path.resolve(result.repoRoot),
projectKey: path.resolve(result.repoRoot),
updatedAt: expect.any(String),
});
expect(events).toEqual([`workspace:${result.workspace.workspaceId}`]);
@@ -442,7 +442,7 @@ 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,
projectGroupKey: path.resolve(project.rootPath),
projectKey: path.resolve(project.rootPath),
updatedAt: expect.any(String),
});
});

View File

@@ -2,14 +2,14 @@ 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 { deriveProjectGroupKey, deriveProjectGroupingDisplayName } from "./project-group-key.js";
import { deriveProjectKey, deriveProjectGroupingDisplayName } from "./project-key.js";
describe("deriveProjectGroupKey", () => {
describe("deriveProjectKey", () => {
test("preserves an explicit remote port", () => {
const rootPath = path.resolve("repo");
expect(
deriveProjectGroupKey({
deriveProjectKey({
rootPath,
remoteUrl: "https://git.example.com:8443/acme/app.git",
worktreeRoot: rootPath,
@@ -21,7 +21,7 @@ describe("deriveProjectGroupKey", () => {
test("normalizes the default SSH port", () => {
const rootPath = path.resolve("repo");
const derive = (remoteUrl: string) =>
deriveProjectGroupKey({
deriveProjectKey({
rootPath,
remoteUrl,
worktreeRoot: rootPath,
@@ -37,7 +37,7 @@ describe("deriveProjectGroupKey", () => {
const rootPath = path.resolve("repo");
expect(
deriveProjectGroupKey({
deriveProjectKey({
rootPath,
remoteUrl: "https://git.example.com/repo.git",
worktreeRoot: rootPath,
@@ -49,7 +49,7 @@ describe("deriveProjectGroupKey", () => {
test("preserves meaningful dot-git suffixes on unknown Git servers", () => {
const rootPath = path.resolve("repo");
const derive = (remoteUrl: string) =>
deriveProjectGroupKey({
deriveProjectKey({
rootPath,
remoteUrl,
worktreeRoot: rootPath,
@@ -62,7 +62,7 @@ describe("deriveProjectGroupKey", () => {
test("normalizes dot-git suffixes on known cloud forges", () => {
const rootPath = path.resolve("repo");
const derive = (remoteUrl: string) =>
deriveProjectGroupKey({
deriveProjectKey({
rootPath,
remoteUrl,
worktreeRoot: rootPath,
@@ -75,7 +75,7 @@ describe("deriveProjectGroupKey", () => {
test("normalizes GitHub owner and repository casing", () => {
const rootPath = path.resolve("repo");
const derive = (remoteUrl: string) =>
deriveProjectGroupKey({
deriveProjectKey({
rootPath,
remoteUrl,
worktreeRoot: rootPath,
@@ -92,7 +92,7 @@ describe("deriveProjectGroupKey", () => {
(remoteUrl) => {
const rootPath = path.resolve("repo");
const derive = (value: string) =>
deriveProjectGroupKey({
deriveProjectKey({
rootPath,
remoteUrl: value,
worktreeRoot: rootPath,
@@ -107,7 +107,7 @@ describe("deriveProjectGroupKey", () => {
const rootPath = path.resolve("repo");
expect(
deriveProjectGroupKey({
deriveProjectKey({
rootPath,
remoteUrl: "github.com:getpaseo/paseo.git",
worktreeRoot: rootPath,
@@ -119,7 +119,7 @@ describe("deriveProjectGroupKey", () => {
test("distinguishes absolute and home-relative SCP paths", () => {
const rootPath = path.resolve("repo");
const derive = (remoteUrl: string) =>
deriveProjectGroupKey({
deriveProjectKey({
rootPath,
remoteUrl,
worktreeRoot: rootPath,
@@ -132,7 +132,7 @@ describe("deriveProjectGroupKey", () => {
test("distinguishes absolute SSH URL paths from home-relative SCP paths", () => {
const rootPath = path.resolve("repo");
const derive = (remoteUrl: string) =>
deriveProjectGroupKey({
deriveProjectKey({
rootPath,
remoteUrl,
worktreeRoot: rootPath,
@@ -147,7 +147,7 @@ describe("deriveProjectGroupKey", () => {
test("distinguishes SSH users for home-relative SCP paths", () => {
const rootPath = path.resolve("repo");
const derive = (remoteUrl: string) =>
deriveProjectGroupKey({
deriveProjectKey({
rootPath,
remoteUrl,
worktreeRoot: rootPath,
@@ -162,7 +162,7 @@ describe("deriveProjectGroupKey", () => {
test("keeps percent sequences literal in SCP paths", () => {
const rootPath = path.resolve("repo");
const derive = (remoteUrl: string) =>
deriveProjectGroupKey({
deriveProjectKey({
rootPath,
remoteUrl,
worktreeRoot: rootPath,
@@ -177,7 +177,7 @@ describe("deriveProjectGroupKey", () => {
test("preserves leading whitespace in SCP repository paths", () => {
const rootPath = path.resolve("repo");
const derive = (remoteUrl: string) =>
deriveProjectGroupKey({
deriveProjectKey({
rootPath,
remoteUrl,
worktreeRoot: rootPath,
@@ -193,7 +193,7 @@ describe("deriveProjectGroupKey", () => {
const rootPath = path.resolve("repo");
expect(
deriveProjectGroupKey({
deriveProjectKey({
rootPath,
remoteUrl: "C:repo",
worktreeRoot: rootPath,
@@ -205,7 +205,7 @@ describe("deriveProjectGroupKey", () => {
test.each(["git+ssh:", "ssh+git:"])("normalizes SSH alias default ports for %s", (scheme) => {
const rootPath = path.resolve("repo");
const derive = (remoteUrl: string) =>
deriveProjectGroupKey({
deriveProjectKey({
rootPath,
remoteUrl,
worktreeRoot: rootPath,
@@ -221,7 +221,7 @@ describe("deriveProjectGroupKey", () => {
const rootPath = path.resolve("repo");
expect(
deriveProjectGroupKey({
deriveProjectKey({
rootPath,
remoteUrl: "git@[2001:db8::1]:getpaseo/paseo.git",
worktreeRoot: rootPath,
@@ -235,7 +235,7 @@ describe("deriveProjectGroupKey", () => {
const rootPath = path.join(worktreeRoot, "packages", "app");
expect(
deriveProjectGroupKey({
deriveProjectKey({
rootPath,
remoteUrl: "git@github.com:getpaseo/paseo.git",
worktreeRoot,
@@ -246,13 +246,13 @@ describe("deriveProjectGroupKey", () => {
test("keeps a selected path distinct from remote path syntax", () => {
const worktreeRoot = path.resolve("repo");
const selectedKey = deriveProjectGroupKey({
const selectedKey = deriveProjectKey({
rootPath: path.join(worktreeRoot, "packages", "app"),
remoteUrl: "example.com:acme/repo.git",
worktreeRoot,
mainRepoRoot: null,
});
const remoteSyntaxKey = deriveProjectGroupKey({
const remoteSyntaxKey = deriveProjectKey({
rootPath: worktreeRoot,
remoteUrl: "example.com:acme/repo#subdir:packages/app.git",
worktreeRoot,
@@ -266,7 +266,7 @@ describe("deriveProjectGroupKey", () => {
const rootPath = path.resolve("repo");
expect(
deriveProjectGroupKey({
deriveProjectKey({
rootPath,
remoteUrl: "git@github.com:getpaseo/paseo.git",
worktreeRoot: rootPath,
@@ -280,7 +280,7 @@ describe("deriveProjectGroupKey", () => {
const mainRepoRoot = path.resolve("main");
expect(
deriveProjectGroupKey({
deriveProjectKey({
rootPath: path.join(worktreeRoot, "packages", "app"),
remoteUrl: null,
worktreeRoot,
@@ -292,7 +292,7 @@ describe("deriveProjectGroupKey", () => {
test("keeps path-only project identities scoped to their host", () => {
const rootPath = path.resolve("repo");
const derive = (serverId: string) =>
deriveProjectGroupKey({
deriveProjectKey({
rootPath,
remoteUrl: null,
worktreeRoot: null,
@@ -306,7 +306,7 @@ describe("deriveProjectGroupKey", () => {
test("preserves selected-path casing across Windows and POSIX hosts", () => {
expect(
deriveProjectGroupKey({
deriveProjectKey({
rootPath: "c:\\repo\\Packages\\App",
remoteUrl: "git@github.com:getpaseo/paseo.git",
worktreeRoot: "C:\\Repo",
@@ -318,7 +318,7 @@ describe("deriveProjectGroupKey", () => {
test.skipIf(process.platform === "win32")(
"preserves a selected subproject reached through a symlink",
() => {
const tempDir = mkdtempSync(path.join(tmpdir(), "project-group-key-"));
const tempDir = mkdtempSync(path.join(tmpdir(), "project-key-"));
try {
const worktreeRoot = path.join(tempDir, "repo");
const selectedRoot = path.join(worktreeRoot, "packages", "app");
@@ -327,7 +327,7 @@ describe("deriveProjectGroupKey", () => {
symlinkSync(selectedRoot, linkedRoot, "dir");
expect(
deriveProjectGroupKey({
deriveProjectKey({
rootPath: linkedRoot,
remoteUrl: "git@github.com:getpaseo/paseo.git",
worktreeRoot,

View File

@@ -23,14 +23,14 @@ const DEFAULT_REMOTE_PORTS: Partial<Record<string, string>> = {
* 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: {
export function deriveProjectKey(input: {
rootPath: string;
remoteUrl: string | null;
worktreeRoot: string | null;
mainRepoRoot: string | null;
serverId?: string;
}): string {
const remoteKey = deriveRemoteProjectGroupKey(input.remoteUrl);
const remoteKey = deriveRemoteProjectKey(input.remoteUrl);
const selectedPath = deriveSelectedPath(input.rootPath, input.worktreeRoot);
if (!remoteKey) {
const localPath =
@@ -54,7 +54,7 @@ function encodeSelectedPath(selectedPath: string): string {
return selectedPath.split(/[\\/]/u).map(encodeURIComponent).join("/");
}
function deriveRemoteProjectGroupKey(remoteUrl: string | null): string | null {
function deriveRemoteProjectKey(remoteUrl: string | null): string | null {
if (!remoteUrl?.trim()) return null;
const remote = parseRemoteLocation(remoteUrl);
if (!remote) return null;

View File

@@ -649,7 +649,7 @@ describe("project command-center RPCs", () => {
rootPath: directoryPath,
kind: "non_git",
displayName: "new-project",
projectGroupKey: directoryPath,
projectKey: directoryPath,
timestamp: expect.any(String),
});
expect(messages).toEqual([

View File

@@ -1690,8 +1690,7 @@ export class Session {
fallbackWorktreeRoot: snapshot?.git.repoRoot,
});
return {
projectKey: project.projectId,
...(project.projectGroupKey ? { projectGroupKey: project.projectGroupKey } : {}),
projectKey: project.projectKey ?? project.projectId,
projectName: resolveProjectDisplayName(project),
workspaceName: resolveWorkspaceDisplayName(workspace),
checkout,
@@ -4290,7 +4289,7 @@ export class Session {
return {
id: result.workspace.workspaceId,
projectId: result.workspace.projectId,
...(projectRecord?.projectGroupKey ? { projectGroupKey: projectRecord.projectGroupKey } : {}),
...(projectRecord?.projectKey ? { projectKey: projectRecord.projectKey } : {}),
projectDisplayName: projectRecord
? resolveProjectDisplayName(projectRecord)
: result.workspace.projectId,
@@ -4463,7 +4462,7 @@ export class Session {
): WorkspaceProjectDescriptorPayload {
return {
projectId: project.projectId,
...(project.projectGroupKey ? { projectGroupKey: project.projectGroupKey } : {}),
...(project.projectKey ? { projectKey: project.projectKey } : {}),
projectDisplayName: resolveProjectDisplayName(project),
projectCustomName: project.customName ?? null,
projectRootPath: project.rootPath,

View File

@@ -3699,7 +3699,7 @@ test("create paseo worktree response preserves an explicit non-Git project", asy
expect(response?.payload.error).toBeNull();
expect(response?.payload.workspace).toMatchObject({
projectId: explicitProject.projectId,
projectGroupKey: explicitProject.rootPath,
projectKey: explicitProject.rootPath,
projectDisplayName: explicitProject.displayName,
projectRootPath: explicitProject.rootPath,
projectKind: "non_git",
@@ -3713,7 +3713,7 @@ test("create paseo worktree response preserves an explicit non-Git project", asy
expect(workspaces.has(response?.payload.workspace?.id ?? "")).toBe(true);
expect(projects.get(explicitProject.projectId)).toEqual({
...explicitProject,
projectGroupKey: explicitProject.rootPath,
projectKey: explicitProject.rootPath,
});
});

View File

@@ -208,7 +208,7 @@ test("reopening archived exact-root records restores the fresh Git project", asy
});
await projectRegistry.upsert({
...project,
projectGroupKey: "remote:github.com/acme/old-repo",
projectKey: "remote:github.com/acme/old-repo",
});
const workspace = createPersistedWorkspaceRecord({
workspaceId: "ws-archived-root",
@@ -248,7 +248,7 @@ test("reopening archived exact-root records restores the fresh Git project", asy
});
expect(await projectRegistry.get(project.projectId)).toMatchObject({
kind: "git",
projectGroupKey: "remote:github.com/acme/new-repo",
projectKey: "remote:github.com/acme/new-repo",
archivedAt: null,
});
});

View File

@@ -14,7 +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 { deriveProjectKey } from "../../project-key.js";
import { areEquivalentPaths, createRealpathAwarePathMatcher } from "../../../utils/path.js";
export interface ResolveOrCreateWorkspaceIdInput {
@@ -164,7 +164,7 @@ export function createWorkspaceProvisioningService(deps: {
rootPath,
kind: checkout.isGit ? "git" : "non_git",
displayName: basename(rootPath) || rootPath,
projectGroupKey: deriveProjectGroupKey({
projectKey: deriveProjectKey({
rootPath,
remoteUrl: checkout.remoteUrl,
worktreeRoot: checkout.worktreeRoot,
@@ -270,7 +270,7 @@ export function createWorkspaceProvisioningService(deps: {
rootPath: input.repoRoot,
kind: "git",
displayName: basename(input.repoRoot) || input.repoRoot,
projectGroupKey: deriveProjectGroupKey({
projectKey: deriveProjectKey({
rootPath: input.repoRoot,
remoteUrl: checkout.remoteUrl,
worktreeRoot: checkout.worktreeRoot,
@@ -351,22 +351,18 @@ export function createWorkspaceProvisioningService(deps: {
? checkout
: await workspaceGitService.getCheckout(project.rootPath);
const kind = projectCheckout.isGit ? "git" : "non_git";
const projectGroupKey = deriveProjectGroupKey({
const projectKey = deriveProjectKey({
rootPath: project.rootPath,
remoteUrl: projectCheckout.remoteUrl,
worktreeRoot: projectCheckout.worktreeRoot,
mainRepoRoot: projectCheckout.mainRepoRoot,
serverId,
});
if (
project.archivedAt ||
project.kind !== kind ||
project.projectGroupKey !== projectGroupKey
) {
if (project.archivedAt || project.kind !== kind || project.projectKey !== projectKey) {
await projectRegistry.upsert({
...project,
kind,
projectGroupKey,
projectKey,
archivedAt: null,
updatedAt: timestamp,
});
@@ -405,18 +401,18 @@ export function createWorkspaceProvisioningService(deps: {
? workspaceCheckout
: await workspaceGitService.getCheckout(project.rootPath);
const kind: PersistedProjectRecord["kind"] = projectCheckout.isGit ? "git" : "non_git";
const projectGroupKey = deriveProjectGroupKey({
const projectKey = deriveProjectKey({
rootPath: project.rootPath,
remoteUrl: projectCheckout.remoteUrl,
worktreeRoot: projectCheckout.worktreeRoot,
mainRepoRoot: projectCheckout.mainRepoRoot,
serverId,
});
if (project.kind === kind && project.projectGroupKey === projectGroupKey) return project;
if (project.kind === kind && project.projectKey === projectKey) return project;
const refreshed = {
...project,
kind,
projectGroupKey,
projectKey,
updatedAt: new Date().toISOString(),
};
await projectRegistry.upsert(refreshed);

View File

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

View File

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

View File

@@ -258,7 +258,7 @@ describe("WorkspaceReconciliationService", () => {
kind: "project_updated",
projectId: "p1",
directory: projectRoot,
fields: { projectGroupKey: projectRoot },
fields: { projectKey: projectRoot },
},
]);
expect(workspaces.get("w1")?.archivedAt).toBeNull();
@@ -316,7 +316,7 @@ describe("WorkspaceReconciliationService", () => {
kind: "project_updated",
projectId: "p1",
directory: projectRoot,
fields: { projectGroupKey: projectRoot },
fields: { projectKey: projectRoot },
},
]);
expect(afterGitInit.changesApplied).toEqual([
@@ -405,13 +405,13 @@ describe("WorkspaceReconciliationService", () => {
kind: "project_updated",
projectId: "p1",
directory: projectRoot,
fields: { projectGroupKey: projectRoot },
fields: { projectKey: projectRoot },
},
{
kind: "project_updated",
projectId: "p2",
directory: equivalentProjectRoot,
fields: { projectGroupKey: projectRoot },
fields: { projectKey: projectRoot },
},
]);
expect(git.reads).toEqual([projectRoot, workspaceRoot]);
@@ -472,7 +472,7 @@ describe("WorkspaceReconciliationService", () => {
kind: "project_updated",
projectId: "p1",
directory: projectRoot,
fields: { kind: "git", projectGroupKey: projectRoot },
fields: { kind: "git", projectKey: projectRoot },
},
{
kind: "workspace_updated",
@@ -489,7 +489,7 @@ describe("WorkspaceReconciliationService", () => {
expect(projects.get("p1")).toEqual({
...originalProject,
kind: "git",
projectGroupKey: projectRoot,
projectKey: projectRoot,
updatedAt: expect.any(String),
});
expect(workspaces.get("w1")).toEqual({
@@ -815,7 +815,7 @@ describe("WorkspaceReconciliationService", () => {
rootPath: repoDir,
displayName: "blank-dot-page/editor",
customName: null,
projectGroupKey: "remote:github.com/blank-dot-page/editor",
projectKey: "remote:github.com/blank-dot-page/editor",
archivedAt: null,
});
expect(projects.get(repoDir)).toMatchObject({
@@ -823,7 +823,7 @@ describe("WorkspaceReconciliationService", () => {
rootPath: repoDir,
displayName: "editor",
customName: "Editor",
projectGroupKey: "remote:github.com/blank-dot-page/editor",
projectKey: "remote:github.com/blank-dot-page/editor",
archivedAt: null,
});
expect(workspaces.get("focused-bat")).toMatchObject({
@@ -836,7 +836,7 @@ describe("WorkspaceReconciliationService", () => {
});
});
test("updates the group key while keeping the project display name stable", async () => {
test("updates the project key while keeping the project display name stable", async () => {
const dir = createTempGitRepo("reconcile-remote-");
tempDirs.push(dir);
@@ -889,10 +889,10 @@ describe("WorkspaceReconciliationService", () => {
const result = await service.runOnce();
expect(result.changesApplied.find((c) => c.kind === "project_updated")).toMatchObject({
fields: { projectGroupKey: "remote:github.com/new-owner/new-repo" },
fields: { projectKey: "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");
expect(projects.get("p1")!.projectKey).toBe("remote:github.com/new-owner/new-repo");
});
test("falls back to the persisted root when a Git remote disappears", async () => {
@@ -904,7 +904,7 @@ describe("WorkspaceReconciliationService", () => {
"p1",
createPersistedProjectRecord({
projectId: "p1",
projectGroupKey: "remote:github.com/acme/old-repo",
projectKey: "remote:github.com/acme/old-repo",
rootPath: dir,
kind: "git",
displayName: "acme/old-repo",
@@ -941,7 +941,7 @@ describe("WorkspaceReconciliationService", () => {
await service.runOnce();
expect(projects.get("p1")?.projectGroupKey).toBe(path.resolve(dir));
expect(projects.get("p1")?.projectKey).toBe(path.resolve(dir));
});
test("keeps custom and default names stable when the remote changes", async () => {
@@ -1121,7 +1121,7 @@ describe("WorkspaceReconciliationService", () => {
kind: "project_updated",
projectId: "p1",
directory: projectRoot,
fields: { kind: "git", projectGroupKey: projectRoot },
fields: { kind: "git", projectKey: projectRoot },
},
{
kind: "workspace_updated",
@@ -1409,7 +1409,7 @@ describe("WorkspaceReconciliationService", () => {
kind: "project_updated",
projectId: "p1",
directory: rootPath,
fields: { projectGroupKey: path.resolve("/tmp/main-repo") },
fields: { projectKey: path.resolve("/tmp/main-repo") },
},
{
kind: "workspace_updated",

View File

@@ -15,7 +15,7 @@ import {
type MutableWorkspacePlacement,
} from "./workspace-registry-model.js";
import { workspaceIdsForProjects } from "./workspace-directory.js";
import { deriveProjectGroupKey } from "./project-group-key.js";
import { deriveProjectKey } from "./project-key.js";
const DEFAULT_RESCAN_INTERVAL_MS = 5 * 60_000;
const DEFAULT_DEBOUNCE_MS = 100;
@@ -67,7 +67,7 @@ export type ReconciliationChange =
kind: "project_updated";
projectId: string;
directory: string;
fields: Partial<Pick<PersistedProjectRecord, "kind" | "projectGroupKey">>;
fields: Partial<Pick<PersistedProjectRecord, "kind" | "projectKey">>;
}
| {
kind: "workspace_updated";
@@ -343,9 +343,9 @@ export class WorkspaceReconciliationService {
checkout: await readCheckout(workspace.cwd),
})),
);
const projectUpdates: Partial<Pick<PersistedProjectRecord, "kind" | "projectGroupKey">> = {};
const projectUpdates: Partial<Pick<PersistedProjectRecord, "kind" | "projectKey">> = {};
const mappedKind = deriveProjectKind(currentGit);
const projectGroupKey = deriveProjectGroupKey({
const projectKey = deriveProjectKey({
rootPath: project.rootPath,
remoteUrl: currentGit.remoteUrl,
worktreeRoot: currentGit.worktreeRoot,
@@ -356,8 +356,8 @@ export class WorkspaceReconciliationService {
if (project.kind !== mappedKind) {
projectUpdates.kind = mappedKind;
}
if (project.projectGroupKey !== projectGroupKey) {
projectUpdates.projectGroupKey = projectGroupKey;
if (project.projectKey !== projectKey) {
projectUpdates.projectKey = projectKey;
}
if (Object.keys(projectUpdates).length > 0) {

View File

@@ -4,7 +4,7 @@ import type { ProjectCheckoutLitePayload } from "@getpaseo/protocol/messages";
import { parseGitRevParsePath } from "../utils/git-rev-parse-path.js";
import { getRealpathAwareRelativePath } from "../utils/path.js";
import { deriveProjectGroupKey, deriveProjectGroupingDisplayName } from "./project-group-key.js";
import { deriveProjectKey, deriveProjectGroupingDisplayName } from "./project-key.js";
import {
deriveProjectKind,
deriveWorkspaceDisplayName,
@@ -34,7 +34,7 @@ export function classifyDirectoryForProjectMembership(input: {
}): DirectoryProjectMembership {
const cwd = resolve(input.cwd);
const checkout: ProjectCheckoutLitePayload = { ...input.checkout, cwd };
const projectKey = deriveProjectGroupKey({
const projectKey = deriveProjectKey({
rootPath: cwd,
remoteUrl: checkout.remoteUrl,
worktreeRoot: checkout.worktreeRoot,

View File

@@ -16,8 +16,8 @@ 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
// COMPAT(projectKey): added in v0.2.4 on 2026-07-28; remove optional after 2027-01-28.
projectKey: z
.string()
.nullable()
.optional()
@@ -110,7 +110,7 @@ export interface ProjectRegistry {
rootPath: string;
kind: PersistedProjectKind;
displayName: string;
projectGroupKey?: string;
projectKey?: string;
timestamp: string;
}): Promise<PersistedProjectRecord>;
upsert(record: PersistedProjectRecord): Promise<void>;
@@ -315,7 +315,7 @@ export class FileBackedProjectRegistry
rootPath: string;
kind: PersistedProjectKind;
displayName: string;
projectGroupKey?: string;
projectKey?: string;
timestamp: string;
}): Promise<PersistedProjectRecord> {
const previous = this.allocationQueue;
@@ -333,15 +333,12 @@ export class FileBackedProjectRegistry
left.projectId.localeCompare(right.projectId),
)[0];
if (active) {
if (
active.kind === input.kind &&
active.projectGroupKey === (input.projectGroupKey ?? null)
)
if (active.kind === input.kind && active.projectKey === (input.projectKey ?? null))
return active;
const refreshed = {
...active,
kind: input.kind,
projectGroupKey: input.projectGroupKey ?? null,
projectKey: input.projectKey ?? null,
updatedAt: input.timestamp,
};
await this.upsert(refreshed);
@@ -356,7 +353,7 @@ export class FileBackedProjectRegistry
rootPath: input.rootPath,
kind: input.kind,
displayName: input.displayName,
projectGroupKey: input.projectGroupKey ?? null,
projectKey: input.projectKey ?? null,
createdAt: input.timestamp,
updatedAt: input.timestamp,
});
@@ -477,7 +474,7 @@ export function createPersistedProjectRecord(input: {
kind: PersistedProjectKind;
displayName: string;
customName?: string | null;
projectGroupKey?: string | null;
projectKey?: string | null;
createdAt: string;
updatedAt: string;
archivedAt?: string | null;
@@ -485,7 +482,7 @@ export function createPersistedProjectRecord(input: {
return PersistedProjectRecordSchema.parse({
...input,
customName: input.customName ?? null,
projectGroupKey: input.projectGroupKey ?? null,
projectKey: input.projectKey ?? null,
archivedAt: input.archivedAt ?? null,
});
}