diff --git a/docs/data-model.md b/docs/data-model.md index 58ec037b0..55107f2fe 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -362,6 +362,11 @@ Array of project records. | `updatedAt` | `string` (ISO 8601) | | | `archivedAt` | `string \| null` (ISO 8601) | Soft-delete timestamp; required nullable | +Active git projects are unique by normalized `rootPath`. Startup reconciliation repairs older bad +states by moving workspaces from duplicate path-keyed projects onto the canonical project, +preferring remote-keyed project IDs such as `remote:github.com/owner/repo`, then archiving the +emptied duplicate. + --- ## 7. Workspace Registry diff --git a/packages/app/src/app/h/[serverId]/new.tsx b/packages/app/src/app/h/[serverId]/new.tsx index 051822df3..901fad6f7 100644 --- a/packages/app/src/app/h/[serverId]/new.tsx +++ b/packages/app/src/app/h/[serverId]/new.tsx @@ -2,10 +2,16 @@ import { useLocalSearchParams } from "expo-router"; import { NewWorkspaceScreen } from "@/screens/new-workspace-screen"; export default function HostNewWorkspaceRoute() { - const params = useLocalSearchParams<{ serverId?: string; dir?: string; name?: string }>(); + const params = useLocalSearchParams<{ + serverId?: string; + dir?: string; + name?: string; + projectId?: string; + }>(); const serverId = typeof params.serverId === "string" ? params.serverId : ""; const sourceDirectory = typeof params.dir === "string" ? params.dir : ""; const displayName = typeof params.name === "string" ? params.name : undefined; + const projectId = typeof params.projectId === "string" ? params.projectId : undefined; if (!sourceDirectory) { return null; @@ -16,6 +22,7 @@ export default function HostNewWorkspaceRoute() { serverId={serverId} sourceDirectory={sourceDirectory} displayName={displayName} + projectId={projectId} /> ); } diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 9bbe408c2..a186af91e 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -1173,10 +1173,13 @@ function ProjectHeaderRow({ return; } router.navigate( - buildHostNewWorkspaceRoute(serverId, project.iconWorkingDir, { displayName }) as Href, + buildHostNewWorkspaceRoute(serverId, project.iconWorkingDir, { + displayName, + projectId: project.projectKey, + }) as Href, ); onWorkspacePress?.(); - }, [displayName, onWorkspacePress, project.iconWorkingDir, serverId]); + }, [displayName, onWorkspacePress, project.iconWorkingDir, project.projectKey, serverId]); const _mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces); const _toast = useToast(); diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index 5ebc0de8f..eb83ee9a6 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -57,6 +57,7 @@ function resolveCheckoutRequest( interface NewWorkspaceScreenProps { serverId: string; sourceDirectory: string; + projectId?: string; displayName?: string; } @@ -496,6 +497,7 @@ function submitWorkspaceDraft(input: SubmitDraftInput): void { export function NewWorkspaceScreen({ serverId, sourceDirectory, + projectId, displayName: displayNameProp, }: NewWorkspaceScreenProps) { const { theme } = useUnistyles(); @@ -711,6 +713,7 @@ export function NewWorkspaceScreen({ return { cwd: input.cwd, + ...(projectId ? { projectId } : {}), worktreeSlug: createNameId(), ...(hasFirstAgentContext ? { @@ -723,7 +726,7 @@ export function NewWorkspaceScreen({ ...checkoutRequest, }; }, - [currentBranch, selectedItem], + [currentBranch, projectId, selectedItem], ); const ensureWorkspace = useCallback( diff --git a/packages/app/src/utils/host-routes.ts b/packages/app/src/utils/host-routes.ts index e3af3a51c..9630087ae 100644 --- a/packages/app/src/utils/host-routes.ts +++ b/packages/app/src/utils/host-routes.ts @@ -358,7 +358,7 @@ export function buildHostOpenProjectRoute(serverId: string) { export function buildHostNewWorkspaceRoute( serverId: string, sourceDirectory: string, - options?: { displayName?: string }, + options?: { displayName?: string; projectId?: string }, ) { const base = buildHostRootRoute(serverId); if (base === "/") { @@ -369,6 +369,9 @@ export function buildHostNewWorkspaceRoute( if (options?.displayName) { params.set("name", options.displayName); } + if (options?.projectId) { + params.set("projectId", options.projectId); + } return `${base}/new?${params.toString()}` as const; } diff --git a/packages/app/src/utils/workspace-archive-navigation.test.ts b/packages/app/src/utils/workspace-archive-navigation.test.ts index 8843c56d1..da9801e20 100644 --- a/packages/app/src/utils/workspace-archive-navigation.test.ts +++ b/packages/app/src/utils/workspace-archive-navigation.test.ts @@ -47,7 +47,7 @@ describe("buildWorkspaceArchiveRedirectRoute", () => { archivedWorkspaceId: "/repo/.paseo/worktrees/feature", workspaces, }), - ).toBe("/h/server-1/new?dir=%2Frepo&name=Project"); + ).toBe("/h/server-1/new?dir=%2Frepo&name=Project&projectId=project-1"); }); it("redirects to the new workspace route when no sibling workspace target exists", () => { @@ -65,7 +65,7 @@ describe("buildWorkspaceArchiveRedirectRoute", () => { archivedWorkspaceId: "/repo/.paseo/worktrees/feature", workspaces, }), - ).toBe("/h/server-1/new?dir=%2Frepo&name=Project"); + ).toBe("/h/server-1/new?dir=%2Frepo&name=Project&projectId=project-1"); }); it("redirects to the new workspace route instead of another workspace", () => { @@ -85,7 +85,7 @@ describe("buildWorkspaceArchiveRedirectRoute", () => { archivedWorkspaceId: "/notes", workspaces, }), - ).toBe("/h/server-1/new?dir=%2Fnotes&name=Project"); + ).toBe("/h/server-1/new?dir=%2Fnotes&name=Project&projectId=notes"); }); }); @@ -132,6 +132,8 @@ describe("redirectIfArchivingActiveWorkspace", () => { }), ).toBe(true); - expect(replaceMock).toHaveBeenCalledWith("/h/server-1/new?dir=%2Frepo&name=Project"); + expect(replaceMock).toHaveBeenCalledWith( + "/h/server-1/new?dir=%2Frepo&name=Project&projectId=project-1", + ); }); }); diff --git a/packages/app/src/utils/workspace-archive-navigation.ts b/packages/app/src/utils/workspace-archive-navigation.ts index 858e59d94..b19425c6f 100644 --- a/packages/app/src/utils/workspace-archive-navigation.ts +++ b/packages/app/src/utils/workspace-archive-navigation.ts @@ -24,5 +24,6 @@ export function buildWorkspaceArchiveRedirectRoute(input: { return buildHostNewWorkspaceRoute(input.serverId, sourceDirectory, { displayName: archivedWorkspace.projectDisplayName, + projectId: archivedWorkspace.projectId, }); } diff --git a/packages/server/src/client/daemon-client.test.ts b/packages/server/src/client/daemon-client.test.ts index 9ee330904..0b120649d 100644 --- a/packages/server/src/client/daemon-client.test.ts +++ b/packages/server/src/client/daemon-client.test.ts @@ -872,6 +872,7 @@ test("sends worktree base-ref fields in create_paseo_worktree_request", async () const createPromise = client.createPaseoWorktree( { cwd: "/tmp/project", + projectId: "remote:github.com/acme/project", worktreeSlug: "review-pr-123", refName: "feature/worktree-base-ref", action: "checkout", @@ -885,6 +886,7 @@ test("sends worktree base-ref fields in create_paseo_worktree_request", async () expect(request).toEqual({ type: "create_paseo_worktree_request", cwd: "/tmp/project", + projectId: "remote:github.com/acme/project", worktreeSlug: "review-pr-123", refName: "feature/worktree-base-ref", action: "checkout", diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index fc44413d3..669c19e98 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -259,7 +259,13 @@ export interface CreateAgentRequestOptions extends AgentConfigOverrides { export interface CreatePaseoWorktreeInput extends Pick< CreatePaseoWorktreeRequest, - "cwd" | "worktreeSlug" | "firstAgentContext" | "refName" | "action" | "githubPrNumber" + | "cwd" + | "projectId" + | "worktreeSlug" + | "firstAgentContext" + | "refName" + | "action" + | "githubPrNumber" > {} type CheckoutStatusPayload = CheckoutStatusResponse["payload"]; @@ -3012,6 +3018,7 @@ export class DaemonClient { message: { type: "create_paseo_worktree_request", cwd: input.cwd, + ...(input.projectId !== undefined ? { projectId: input.projectId } : {}), worktreeSlug: input.worktreeSlug, ...(input.firstAgentContext !== undefined ? { firstAgentContext: input.firstAgentContext } diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 20863a3f8..a8ae12c6c 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -105,6 +105,7 @@ import { shutdownProviders, } from "./agent/provider-registry.js"; import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js"; +import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js"; import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry } from "./workspace-registry.js"; import { FileBackedChatService } from "./chat/chat-service.js"; import { CheckoutDiffManager } from "./checkout-diff-manager.js"; @@ -531,6 +532,26 @@ export async function createPaseoDaemon( logger, }); logger.info({ elapsed: elapsed() }, "Workspace registries bootstrapped"); + const workspaceReconciliation = new WorkspaceReconciliationService({ + projectRegistry, + workspaceRegistry, + logger, + workspaceGitService, + }); + void (async () => { + try { + const result = await workspaceReconciliation.runOnce(); + logger.info( + { + elapsed: elapsed(), + changeCount: result.changesApplied.length, + }, + "Workspace registries reconciled", + ); + } catch (error) { + logger.error({ err: error }, "Background workspace reconciliation failed"); + } + })(); await chatService.initialize(); logger.info({ elapsed: elapsed() }, "Chat service initialized"); const checkoutDiffManager = new CheckoutDiffManager({ diff --git a/packages/server/src/server/paseo-worktree-service.test.ts b/packages/server/src/server/paseo-worktree-service.test.ts index df7350287..0ee8bdde9 100644 --- a/packages/server/src/server/paseo-worktree-service.test.ts +++ b/packages/server/src/server/paseo-worktree-service.test.ts @@ -66,6 +66,40 @@ test("creates a worktree and registers it in the source workspace project withou ]); }); +test("registers a new worktree in the existing root project after the main checkout workspace is removed", async () => { + const { repoDir, tempDir } = createGitRepo(); + cleanupPaths.push(tempDir); + const deps = createDeps(); + const sourceProject = createPersistedProjectRecordForTest({ + projectId: "remote:github.com/acme/repo", + rootPath: repoDir, + displayName: "acme/repo", + }); + const existingWorktree = createPersistedWorkspaceRecordForTest({ + workspaceId: path.join(tempDir, "existing-worktree"), + projectId: sourceProject.projectId, + cwd: path.join(tempDir, "existing-worktree"), + kind: "worktree", + displayName: "existing-worktree", + }); + deps.projects.set(sourceProject.projectId, sourceProject); + deps.workspaces.set(existingWorktree.workspaceId, existingWorktree); + + const result = await createPaseoWorktree( + { + cwd: repoDir, + projectId: sourceProject.projectId, + worktreeSlug: "second-worktree", + runSetup: false, + paseoHome: path.join(tempDir, ".paseo"), + }, + deps, + ); + + expect(result.workspace.projectId).toBe("remote:github.com/acme/repo"); + expect(Array.from(deps.projects.keys()).sort()).toEqual(["remote:github.com/acme/repo"]); +}); + // POSIX-only: Windows git worktree paths need separate canonicalization coverage. test.skipIf(isPlatform("win32"))( "reuses an existing worktree and still upserts the workspace", diff --git a/packages/server/src/server/paseo-worktree-service.ts b/packages/server/src/server/paseo-worktree-service.ts index 2b0951391..7f770fce1 100644 --- a/packages/server/src/server/paseo-worktree-service.ts +++ b/packages/server/src/server/paseo-worktree-service.ts @@ -23,7 +23,9 @@ import type { WorktreeCreationIntent } from "./resolve-worktree-creation-intent. import { buildAgentBranchNameSeed } from "./agent/prompt-attachments.js"; import type { FirstAgentContext } from "../shared/messages.js"; -export type CreatePaseoWorktreeInput = CreateWorktreeCoreInput; +export interface CreatePaseoWorktreeInput extends CreateWorktreeCoreInput { + projectId?: string; +} export interface CreatePaseoWorktreeResult { worktree: WorktreeConfig; @@ -60,6 +62,7 @@ export async function createPaseoWorktree( maybeMarkFirstAgentBranchAutoNameEligible({ createdWorktree }); const workspace = await upsertWorkspaceForWorktree({ inputCwd: input.cwd, + projectId: input.projectId, repoRoot: createdWorktree.repoRoot, worktree: createdWorktree.worktree, deps, @@ -189,6 +192,7 @@ function maybeMarkFirstAgentBranchAutoNameEligible(options: { async function upsertWorkspaceForWorktree(options: { inputCwd: string; + projectId?: string; repoRoot: string; worktree: WorktreeConfig; deps: Pick; @@ -202,6 +206,7 @@ async function upsertWorkspaceForWorktree(options: { ); const sourceProject = await resolveSourceProjectForWorktree({ inputCwd: normalizedInputCwd, + projectId: options.projectId, repoRoot: normalizedRepoRoot, existingWorkspace, deps: options.deps, @@ -215,6 +220,7 @@ async function upsertWorkspaceForWorktree(options: { rootPath: sourceProject.rootPath, kind: sourceProject.kind, displayName: sourceProject.displayName, + customName: sourceProject.customName, createdAt: sourceProject.createdAt ?? now, updatedAt: now, archivedAt: null, @@ -236,18 +242,88 @@ async function upsertWorkspaceForWorktree(options: { return (await options.deps.workspaceRegistry.get(workspace.workspaceId)) ?? workspace; } -async function resolveSourceProjectForWorktree(options: { - inputCwd: string; - repoRoot: string; - existingWorkspace: PersistedWorkspaceRecord | null; - deps: Pick; -}): Promise<{ +interface SourceProjectForWorktree { projectId: string; rootPath: string; kind: "git"; displayName: string; + customName: string | null; createdAt: string | null; -}> { +} + +function sourceProjectFromRecord(record: { + projectId: string; + rootPath: string; + displayName: string; + customName?: string | null; + createdAt?: string | null; +}): SourceProjectForWorktree { + return { + projectId: record.projectId, + rootPath: record.rootPath, + kind: "git", + displayName: record.displayName, + customName: record.customName ?? null, + createdAt: record.createdAt ?? null, + }; +} + +async function resolveExplicitProjectForWorktree(options: { + projectId: string; + projectRegistry: Pick; +}): Promise { + const project = await options.projectRegistry.get(options.projectId); + if (!project || project.archivedAt) { + throw new Error(`Project not found for worktree: ${options.projectId}`); + } + return sourceProjectFromRecord(project); +} + +async function resolveWorkspaceProjectForWorktree(options: { + sourceWorkspace: PersistedWorkspaceRecord; + repoRoot: string; + projectRegistry: Pick; +}): Promise { + const sourceProject = await options.projectRegistry.get(options.sourceWorkspace.projectId); + return sourceProjectFromRecord({ + projectId: options.sourceWorkspace.projectId, + rootPath: sourceProject?.rootPath ?? options.repoRoot, + displayName: + sourceProject?.displayName ?? deriveProjectGroupingName(options.sourceWorkspace.projectId), + customName: sourceProject?.customName ?? null, + createdAt: sourceProject?.createdAt ?? null, + }); +} + +async function resolveFallbackProjectForWorktree(options: { + repoRoot: string; + projectRegistry: Pick; +}): Promise { + const existingFallbackProject = await options.projectRegistry.get(options.repoRoot); + return sourceProjectFromRecord({ + projectId: options.repoRoot, + rootPath: existingFallbackProject?.rootPath ?? options.repoRoot, + displayName: + existingFallbackProject?.displayName ?? deriveProjectGroupingName(options.repoRoot), + customName: existingFallbackProject?.customName ?? null, + createdAt: existingFallbackProject?.createdAt ?? null, + }); +} + +async function resolveSourceProjectForWorktree(options: { + inputCwd: string; + projectId?: string; + repoRoot: string; + existingWorkspace: PersistedWorkspaceRecord | null; + deps: Pick; +}): Promise { + if (options.projectId) { + return resolveExplicitProjectForWorktree({ + projectId: options.projectId, + projectRegistry: options.deps.projectRegistry, + }); + } + const sourceWorkspace = options.existingWorkspace ?? (await findWorkspaceForSource({ @@ -255,30 +331,19 @@ async function resolveSourceProjectForWorktree(options: { repoRoot: options.repoRoot, workspaceRegistry: options.deps.workspaceRegistry, })); - const sourceProject = sourceWorkspace - ? await options.deps.projectRegistry.get(sourceWorkspace.projectId) - : null; if (sourceWorkspace) { - return { - projectId: sourceWorkspace.projectId, - rootPath: sourceProject?.rootPath ?? options.repoRoot, - kind: "git", - displayName: - sourceProject?.displayName ?? deriveProjectGroupingName(sourceWorkspace.projectId), - createdAt: sourceProject?.createdAt ?? null, - }; + return resolveWorkspaceProjectForWorktree({ + sourceWorkspace, + repoRoot: options.repoRoot, + projectRegistry: options.deps.projectRegistry, + }); } - const existingFallbackProject = await options.deps.projectRegistry.get(options.repoRoot); - return { - projectId: options.repoRoot, - rootPath: existingFallbackProject?.rootPath ?? options.repoRoot, - kind: "git", - displayName: - existingFallbackProject?.displayName ?? deriveProjectGroupingName(options.repoRoot), - createdAt: existingFallbackProject?.createdAt ?? null, - }; + return resolveFallbackProjectForWorktree({ + repoRoot: options.repoRoot, + projectRegistry: options.deps.projectRegistry, + }); } async function findWorkspaceForSource(options: { diff --git a/packages/server/src/server/workspace-reconciliation-service.test.ts b/packages/server/src/server/workspace-reconciliation-service.test.ts index 0342e7975..a7655fe91 100644 --- a/packages/server/src/server/workspace-reconciliation-service.test.ts +++ b/packages/server/src/server/workspace-reconciliation-service.test.ts @@ -1,5 +1,5 @@ import { execFileSync } from "node:child_process"; -import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import type pino from "pino"; @@ -336,6 +336,117 @@ describe("WorkspaceReconciliationService", () => { expect(workspaces.get("w1")!.kind).toBe("local_checkout"); }); + test("moves workspaces from a path-keyed duplicate project to the existing remote-keyed project", async () => { + const repoDir = createTempGitRepo("reconcile-duplicate-project-"); + tempDirs.push(repoDir); + const canonicalWorktreeDir = path.join(repoDir, ".paseo", "worktrees", "focused-bat"); + const duplicateWorktreeDir = path.join(repoDir, ".paseo", "worktrees", "gigantic-blowfish"); + mkdirSync(canonicalWorktreeDir, { recursive: true }); + mkdirSync(duplicateWorktreeDir, { recursive: true }); + const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries(); + + projects.set( + "remote:github.com/blank-dot-page/editor", + createPersistedProjectRecord({ + projectId: "remote:github.com/blank-dot-page/editor", + rootPath: repoDir, + kind: "git", + displayName: "blank-dot-page/editor", + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + projects.set( + repoDir, + createPersistedProjectRecord({ + projectId: repoDir, + rootPath: repoDir, + kind: "git", + displayName: "editor", + customName: "Editor", + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + workspaces.set( + "focused-bat", + createPersistedWorkspaceRecord({ + workspaceId: "focused-bat", + projectId: "remote:github.com/blank-dot-page/editor", + cwd: canonicalWorktreeDir, + kind: "worktree", + displayName: "update-og-image", + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + workspaces.set( + "gigantic-blowfish", + createPersistedWorkspaceRecord({ + workspaceId: "gigantic-blowfish", + projectId: repoDir, + cwd: duplicateWorktreeDir, + kind: "worktree", + displayName: "markdown-view", + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + + const service = new WorkspaceReconciliationService({ + projectRegistry, + workspaceRegistry, + logger: createTestLogger(), + workspaceGitService: createWorkspaceGitServiceStub({ + [repoDir]: { + projectKind: "git", + projectDisplayName: "blank-dot-page/editor", + workspaceDisplayName: "main", + gitRemote: "git@github.com:blank-dot-page/editor.git", + }, + [canonicalWorktreeDir]: { + projectKind: "git", + projectDisplayName: "blank-dot-page/editor", + workspaceDisplayName: "update-og-image", + gitRemote: "git@github.com:blank-dot-page/editor.git", + }, + [duplicateWorktreeDir]: { + projectKind: "git", + projectDisplayName: "blank-dot-page/editor", + workspaceDisplayName: "markdown-view", + gitRemote: "git@github.com:blank-dot-page/editor.git", + }, + }), + }); + + const result = await service.runOnce(); + + expect(result.changesApplied).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "workspace_updated", + workspaceId: "gigantic-blowfish", + fields: { projectId: "remote:github.com/blank-dot-page/editor" }, + }), + expect.objectContaining({ + kind: "project_updated", + projectId: "remote:github.com/blank-dot-page/editor", + fields: { customName: "Editor" }, + }), + expect.objectContaining({ + kind: "project_archived", + projectId: repoDir, + reason: "no_active_workspaces", + }), + ]), + ); + expect(workspaces.get("gigantic-blowfish")!.projectId).toBe( + "remote:github.com/blank-dot-page/editor", + ); + expect(projects.get("remote:github.com/blank-dot-page/editor")!.customName).toBe("Editor"); + expect(projects.get(repoDir)!.archivedAt).toBeTruthy(); + }); + test("updates project display name when git remote changes", async () => { const dir = createTempGitRepo("reconcile-remote-"); tempDirs.push(dir); diff --git a/packages/server/src/server/workspace-reconciliation-service.ts b/packages/server/src/server/workspace-reconciliation-service.ts index eb8f63a33..2124fee7d 100644 --- a/packages/server/src/server/workspace-reconciliation-service.ts +++ b/packages/server/src/server/workspace-reconciliation-service.ts @@ -7,6 +7,7 @@ import type { PersistedWorkspaceRecord, } from "./workspace-registry.js"; import type { WorkspaceGitService } from "./workspace-git-service.js"; +import { normalizeWorkspaceId } from "./workspace-registry-model.js"; const DEFAULT_RECONCILE_INTERVAL_MS = 60_000; @@ -19,6 +20,21 @@ function deriveWorkspaceKindFromMetadata(metadata: { return "local_checkout"; } +function chooseCanonicalProject(projects: PersistedProjectRecord[]): PersistedProjectRecord { + return [...projects].sort((left, right) => { + const leftRemote = left.projectId.startsWith("remote:"); + const rightRemote = right.projectId.startsWith("remote:"); + if (leftRemote !== rightRemote) { + return leftRemote ? -1 : 1; + } + const createdAt = Date.parse(left.createdAt) - Date.parse(right.createdAt); + if (createdAt !== 0) { + return createdAt; + } + return left.projectId.localeCompare(right.projectId); + })[0]!; +} + export type ReconciliationChange = | { kind: "workspace_archived"; workspaceId: string; directory: string; reason: string } | { kind: "project_archived"; projectId: string; directory: string; reason: string } @@ -26,13 +42,15 @@ export type ReconciliationChange = kind: "project_updated"; projectId: string; directory: string; - fields: Partial>; + fields: Partial< + Pick + >; } | { kind: "workspace_updated"; workspaceId: string; directory: string; - fields: Partial>; + fields: Partial>; }; export interface ReconciliationResult { @@ -144,7 +162,10 @@ export class WorkspaceReconciliationService { }), ); - // 2. Archive orphaned projects (all workspaces archived/removed) + // 2. Merge duplicate active project records that point at the same repo root. + await this.mergeDuplicateProjectsByRoot(activeProjects, workspacesByProject, changes); + + // 3. Archive orphaned projects (all workspaces archived/removed) const orphanedProjects = activeProjects.filter((project) => { const siblings = workspacesByProject.get(project.projectId) ?? []; return siblings.length === 0; @@ -162,7 +183,7 @@ export class WorkspaceReconciliationService { }), ); - // 3. Reconcile git metadata for active projects whose directories still exist + // 4. Reconcile git metadata for active projects whose directories still exist const projectsToReconcile = activeProjects.filter((project) => { if (project.archivedAt) return false; const siblings = workspacesByProject.get(project.projectId) ?? []; @@ -183,6 +204,91 @@ export class WorkspaceReconciliationService { return { changesApplied: changes, durationMs: Date.now() - start }; } + private async mergeDuplicateProjectsByRoot( + activeProjects: PersistedProjectRecord[], + workspacesByProject: Map, + changes: ReconciliationChange[], + ): Promise { + const projectsByRoot = new Map(); + for (const project of activeProjects) { + if (project.kind !== "git") { + continue; + } + const rootKey = normalizeWorkspaceId(project.rootPath); + const group = projectsByRoot.get(rootKey) ?? []; + group.push(project); + projectsByRoot.set(rootKey, group); + } + + for (const duplicates of projectsByRoot.values()) { + if (duplicates.length < 2) { + continue; + } + const canonical = chooseCanonicalProject(duplicates); + const duplicateProjects = duplicates.filter( + (project) => project.projectId !== canonical.projectId, + ); + await this.mergeDuplicateProjectCustomName(canonical, duplicateProjects, changes); + await Promise.all( + duplicateProjects.flatMap((project) => + (workspacesByProject.get(project.projectId) ?? []).map(async (workspace) => { + const timestamp = new Date().toISOString(); + const updatedWorkspace = { + ...workspace, + projectId: canonical.projectId, + updatedAt: timestamp, + }; + await this.workspaceRegistry.upsert(updatedWorkspace); + changes.push({ + kind: "workspace_updated", + workspaceId: workspace.workspaceId, + directory: workspace.cwd, + fields: { + projectId: canonical.projectId, + }, + }); + + const canonicalSiblings = workspacesByProject.get(canonical.projectId) ?? []; + canonicalSiblings.push(updatedWorkspace); + workspacesByProject.set(canonical.projectId, canonicalSiblings); + }), + ), + ); + + for (const project of duplicateProjects) { + workspacesByProject.set(project.projectId, []); + } + } + } + + private async mergeDuplicateProjectCustomName( + canonical: PersistedProjectRecord, + duplicateProjects: PersistedProjectRecord[], + changes: ReconciliationChange[], + ): Promise { + if (canonical.customName) { + return; + } + const customName = duplicateProjects.find((project) => project.customName)?.customName ?? null; + if (!customName) { + return; + } + + const timestamp = new Date().toISOString(); + await this.projectRegistry.upsert({ + ...canonical, + customName, + updatedAt: timestamp, + }); + canonical.customName = customName; + changes.push({ + kind: "project_updated", + projectId: canonical.projectId, + directory: canonical.rootPath, + fields: { customName }, + }); + } + private async reconcileProject( project: PersistedProjectRecord, siblings: PersistedWorkspaceRecord[], diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts index e7a857dc2..574381d28 100644 --- a/packages/server/src/server/worktree-session.ts +++ b/packages/server/src/server/worktree-session.ts @@ -512,6 +512,7 @@ export async function handleCreatePaseoWorktreeRequest( try { const createdWorktree = await dependencies.createPaseoWorktreeWorkflow({ cwd: request.cwd, + projectId: request.projectId, worktreeSlug: request.worktreeSlug, firstAgentContext: normalizeFirstAgentContext(request), refName: request.refName, diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 965ad8cf2..30b5811a2 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -1463,6 +1463,7 @@ export const FirstAgentContextSchema = z.object({ export const CreatePaseoWorktreeRequestSchema = z.object({ type: z.literal("create_paseo_worktree_request"), cwd: z.string(), + projectId: z.string().optional(), worktreeSlug: z.string().optional(), nameContext: z.string().optional(), attachments: AgentAttachmentsSchema.optional(),