Fix duplicate project worktree registration (#1024)

* Fix duplicate project worktree registration

* Update archive redirect test for project-aware routes
This commit is contained in:
Mohamed Boudra
2026-05-14 20:57:32 +08:00
committed by GitHub
parent 7e255a5249
commit fbeda4510e
16 changed files with 415 additions and 43 deletions

View File

@@ -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

View File

@@ -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}
/>
);
}

View File

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

View File

@@ -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(

View File

@@ -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;
}

View File

@@ -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",
);
});
});

View File

@@ -24,5 +24,6 @@ export function buildWorkspaceArchiveRedirectRoute(input: {
return buildHostNewWorkspaceRoute(input.serverId, sourceDirectory, {
displayName: archivedWorkspace.projectDisplayName,
projectId: archivedWorkspace.projectId,
});
}

View File

@@ -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",

View File

@@ -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 }

View File

@@ -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({

View File

@@ -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",

View File

@@ -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<CreatePaseoWorktreeDeps, "projectRegistry" | "workspaceRegistry">;
@@ -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<CreatePaseoWorktreeDeps, "projectRegistry" | "workspaceRegistry">;
}): 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<ProjectRegistry, "get">;
}): Promise<SourceProjectForWorktree> {
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<ProjectRegistry, "get">;
}): Promise<SourceProjectForWorktree> {
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<ProjectRegistry, "get">;
}): Promise<SourceProjectForWorktree> {
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<CreatePaseoWorktreeDeps, "projectRegistry" | "workspaceRegistry">;
}): Promise<SourceProjectForWorktree> {
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: {

View File

@@ -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);

View File

@@ -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<Pick<PersistedProjectRecord, "kind" | "displayName" | "rootPath">>;
fields: Partial<
Pick<PersistedProjectRecord, "kind" | "displayName" | "rootPath" | "customName">
>;
}
| {
kind: "workspace_updated";
workspaceId: string;
directory: string;
fields: Partial<Pick<PersistedWorkspaceRecord, "displayName" | "kind">>;
fields: Partial<Pick<PersistedWorkspaceRecord, "projectId" | "displayName" | "kind">>;
};
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<string, PersistedWorkspaceRecord[]>,
changes: ReconciliationChange[],
): Promise<void> {
const projectsByRoot = new Map<string, PersistedProjectRecord[]>();
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<void> {
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[],

View File

@@ -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,

View File

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