From 9540c75dcae647ccea17e16ea4bb5954af5e5650 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 16 Jul 2026 12:55:01 +0000 Subject: [PATCH] fix(projects): close project update races --- .../app/e2e/empty-project-persists.spec.ts | 13 ++- packages/app/e2e/projects-settings.spec.ts | 2 +- packages/app/e2e/sidebar-workspace.spec.ts | 18 ++-- packages/app/src/contexts/session-context.tsx | 96 +++++++++++++------ .../session-workspace-hydration.test.ts | 63 ++++++++++++ .../contexts/session-workspace-hydration.ts | 31 ++++++ .../src/server/agent/mcp-server.test.ts | 20 ++++ .../bootstrap-provider-availability.test.ts | 14 ++- packages/server/src/server/bootstrap.ts | 8 +- .../src/server/paseo-worktree-service.test.ts | 6 +- .../project-git-observer-service.test.ts | 9 +- .../server/project-git-observer-service.ts | 4 +- packages/server/src/server/session.test.ts | 31 ++++-- packages/server/src/server/session.ts | 13 ++- .../server/src/server/websocket-server.ts | 15 +-- .../server/src/server/wire-compat.test.ts | 8 +- .../workspace-registry-bootstrap.test.ts | 94 +++++++++++++++++- .../server/workspace-registry-bootstrap.ts | 6 +- .../src/server/worktree-session.test.ts | 24 ++++- 19 files changed, 377 insertions(+), 98 deletions(-) create mode 100644 packages/app/src/contexts/session-workspace-hydration.test.ts create mode 100644 packages/app/src/contexts/session-workspace-hydration.ts diff --git a/packages/app/e2e/empty-project-persists.spec.ts b/packages/app/e2e/empty-project-persists.spec.ts index 2344fd597..c915d809f 100644 --- a/packages/app/e2e/empty-project-persists.spec.ts +++ b/packages/app/e2e/empty-project-persists.spec.ts @@ -219,15 +219,20 @@ test.describe("Project remove", () => { const readded = await workspace.client.addProject(workspace.repoPath); expect(readded.error).toBeNull(); + expect(readded.project).not.toBeNull(); + const readdedProjectId = readded.project?.projectId ?? ""; + expect(readdedProjectId).not.toBe(workspace.projectId); expect(readded.project?.projectDisplayName).toBe(workspace.projectDisplayName); await page.reload(); await waitForSidebarHydration(page); - await expect(projectRow).toBeVisible({ timeout: 30_000 }); - await expect(projectRow).toContainText(workspace.projectDisplayName); - await expect(projectRow).not.toContainText(workspace.repoPath); + await expect(projectRow).toHaveCount(0, { timeout: 30_000 }); + const readdedProjectRow = page.getByTestId(`sidebar-project-row-${readdedProjectId}`); + await expect(readdedProjectRow).toBeVisible({ timeout: 30_000 }); + await expect(readdedProjectRow).toContainText(workspace.projectDisplayName); + await expect(readdedProjectRow).not.toContainText(workspace.repoPath); await expect( - page.getByTestId(`sidebar-project-new-workspace-row-${workspace.projectId}`), + page.getByTestId(`sidebar-project-new-workspace-row-${readdedProjectId}`), ).toBeVisible({ timeout: 30_000 }); } finally { await workspace.cleanup(); diff --git a/packages/app/e2e/projects-settings.spec.ts b/packages/app/e2e/projects-settings.spec.ts index 1669586b5..6330b8cef 100644 --- a/packages/app/e2e/projects-settings.spec.ts +++ b/packages/app/e2e/projects-settings.spec.ts @@ -208,7 +208,7 @@ test.describe("Projects settings", () => { page, gitlabRemoteProject, }) => { - expect(gitlabRemoteProject.name).toBe("acme/app"); + expect(gitlabRemoteProject.name).toBe(path.basename(gitlabRemoteProject.path)); await openProjects(page); await openProjectSettings(page, gitlabRemoteProject.name); await editWorktreeSetup(page, updatedSetup); diff --git a/packages/app/e2e/sidebar-workspace.spec.ts b/packages/app/e2e/sidebar-workspace.spec.ts index d23085852..be68e73a7 100644 --- a/packages/app/e2e/sidebar-workspace.spec.ts +++ b/packages/app/e2e/sidebar-workspace.spec.ts @@ -46,24 +46,25 @@ async function waitForSidebarWorkspace(page: import("@playwright/test").Page, wo } test.describe("Sidebar workspace list", () => { - test("project with GitHub remote shows owner/repo name in sidebar", async ({ page }) => { + test("project with GitHub remote shows its selected folder name in sidebar", async ({ page }) => { const workspace = await seedWorkspace({ repoPrefix: "sidebar-remote-", repo: { withRemote: true, originUrl: GITHUB_REMOTE_URL }, }); try { + const projectName = path.basename(workspace.repoPath); await gotoAppShell(page); - await waitForSidebarProject(page, "test-owner/test-repo"); + await waitForSidebarProject(page, projectName); await waitForSidebarWorkspace(page, workspace.workspaceId); const projectRow = page .locator('[data-testid^="sidebar-project-row-"]') - .filter({ hasText: "test-owner/test-repo" }) + .filter({ hasText: projectName }) .first(); await expect(projectRow).toBeVisible({ timeout: 30_000 }); - await expect(projectRow).not.toContainText(path.basename(workspace.repoPath)); + await expect(projectRow).not.toContainText("test-owner/test-repo"); } finally { await workspace.cleanup(); } @@ -96,21 +97,24 @@ test.describe("Sidebar workspace list", () => { } }); - test("workspace header shows correct title and subtitle", async ({ page }) => { + test("workspace header uses the selected folder name instead of its GitHub remote", async ({ + page, + }) => { const workspace = await seedWorkspace({ repoPrefix: "sidebar-header-", repo: { withRemote: true, originUrl: GITHUB_REMOTE_URL }, }); try { + const projectName = path.basename(workspace.repoPath); await gotoAppShell(page); - await waitForSidebarProject(page, "test-owner/test-repo"); + await waitForSidebarProject(page, projectName); await waitForSidebarWorkspace(page, workspace.workspaceId); await openWorkspaceFromSidebar(page, workspace.workspaceId); await expectWorkspaceHeader(page, { title: workspace.workspaceName, - subtitle: "test-owner/test-repo", + subtitle: projectName, }); } finally { await workspace.cleanup(); diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 43e928caa..6fd0d7f98 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -65,6 +65,7 @@ import type { AttachmentMetadata } from "@/attachments/types"; import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit"; import { reconcilePreviousAgentStatuses } from "@/contexts/session-status-tracking"; import { patchWorkspaceScripts } from "@/contexts/session-workspace-scripts"; +import { createProjectHydrationBuffer } from "@/contexts/session-workspace-hydration"; import { clearWorkspaceArchivePending, shouldSuppressWorkspaceForLocalArchive, @@ -126,6 +127,8 @@ interface WorkspaceHydrationSnapshot { emptyProjects: Map; } +type ProjectUpdatePayload = Extract["payload"]; + async function fetchWorkspaceHydrationSnapshot(input: { client: DaemonClient; serverId: string; @@ -586,6 +589,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider const wasConnectedRef = useRef(isConnected); const audioOutputBuffersRef = useRef>(new Map()); const activeAudioGroupsRef = useRef>(new Set()); + const projectHydrationBufferRef = useRef(createProjectHydrationBuffer()); useEffect(() => { const subscription = AppState.addEventListener("change", (nextState) => { @@ -604,38 +608,74 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider ); }, [sessionAgents]); + const applyProjectUpdate = useCallback( + (update: ProjectUpdatePayload) => { + useSessionStore + .getState() + .applyProjectUpdate( + serverId, + update.kind === "upsert" + ? { kind: "upsert", project: normalizeEmptyProjectDescriptor(update.project) } + : update, + ); + }, + [serverId], + ); + const hydrateWorkspaces = useCallback( async (options?: { subscribe?: boolean; isCancelled?: () => boolean }) => { if (!client || !isConnected) { return; } - const snapshot = await fetchWorkspaceHydrationSnapshot({ - client, - serverId, - subscribe: options?.subscribe ?? false, - isCancelled: options?.isCancelled, - }); - if (!snapshot || options?.isCancelled?.()) { - return; - } + const hydration = projectHydrationBufferRef.current.begin(); + try { + const snapshot = await fetchWorkspaceHydrationSnapshot({ + client, + serverId, + subscribe: options?.subscribe ?? false, + isCancelled: options?.isCancelled, + }); + if (!snapshot || options?.isCancelled?.()) { + projectHydrationBufferRef.current.cancel(hydration); + return; + } - const didBackfillLegacy = await backfillLegacyDaemonWorkspaceDirectoryIfEmpty({ - client, - serverId, - workspaces: snapshot.workspaces, - emptyProjects: snapshot.emptyProjects, - isCancelled: options?.isCancelled, - }); - if (didBackfillLegacy) { - return; - } + const didBackfillLegacy = await backfillLegacyDaemonWorkspaceDirectoryIfEmpty({ + client, + serverId, + workspaces: snapshot.workspaces, + emptyProjects: snapshot.emptyProjects, + isCancelled: options?.isCancelled, + }); + if (didBackfillLegacy) { + projectHydrationBufferRef.current.commit(hydration, () => {}, applyProjectUpdate); + return; + } - setWorkspaces(serverId, snapshot.workspaces); - setEmptyProjects(serverId, snapshot.emptyProjects.values()); - setHasHydratedWorkspaces(serverId, true); + projectHydrationBufferRef.current.commit( + hydration, + () => { + setWorkspaces(serverId, snapshot.workspaces); + setEmptyProjects(serverId, snapshot.emptyProjects.values()); + setHasHydratedWorkspaces(serverId, true); + }, + applyProjectUpdate, + ); + } catch (error) { + projectHydrationBufferRef.current.cancel(hydration); + throw error; + } }, - [client, isConnected, serverId, setEmptyProjects, setHasHydratedWorkspaces, setWorkspaces], + [ + applyProjectUpdate, + client, + isConnected, + serverId, + setEmptyProjects, + setHasHydratedWorkspaces, + setWorkspaces, + ], ); const applyAuthoritativeAgentSnapshot = useCallback( @@ -1393,14 +1433,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider const unsubProjectUpdate = client.on("project.update", (message) => { const update = message.payload; - if (update.kind === "remove") { - useSessionStore.getState().applyProjectUpdate(serverId, update); - return; - } - useSessionStore.getState().applyProjectUpdate(serverId, { - kind: "upsert", - project: normalizeEmptyProjectDescriptor(update.project), - }); + if (!projectHydrationBufferRef.current.buffer(update)) applyProjectUpdate(update); }); const unsubScriptStatusUpdate = client.on("script_status_update", (message) => { @@ -1832,6 +1865,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider notifyAgentAttention, requestCanonicalCatchUp, applyAgentUpdatePayload, + applyProjectUpdate, applyWorkspaceSetupProgress, applyTimelineResponse, updateSessionServerInfo, diff --git a/packages/app/src/contexts/session-workspace-hydration.test.ts b/packages/app/src/contexts/session-workspace-hydration.test.ts new file mode 100644 index 000000000..457e47892 --- /dev/null +++ b/packages/app/src/contexts/session-workspace-hydration.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; + +import { createProjectHydrationBuffer } from "./session-workspace-hydration"; + +type ProjectUpdate = + | { kind: "upsert"; projectId: string; name: string } + | { kind: "remove"; projectId: string }; + +function applyProjectUpdate(projects: Map, update: ProjectUpdate): void { + if (update.kind === "remove") projects.delete(update.projectId); + else projects.set(update.projectId, update.name); +} + +describe("workspace hydration project updates", () => { + it("replays project updates after replacing the stale hydration snapshot", () => { + const projects = new Map([["project-1", "before hydration"]]); + const hydration = createProjectHydrationBuffer(); + const lease = hydration.begin(); + + hydration.buffer({ kind: "upsert", projectId: "project-2", name: "arrived live" }); + hydration.buffer({ kind: "remove", projectId: "project-1" }); + + hydration.commit( + lease, + () => { + projects.clear(); + projects.set("project-1", "stale snapshot"); + }, + (update) => applyProjectUpdate(projects, update), + ); + + expect(projects).toEqual(new Map([["project-2", "arrived live"]])); + }); + + it("does not let an older hydration overwrite the current one", () => { + const projects = new Map(); + const hydration = createProjectHydrationBuffer(); + const older = hydration.begin(); + const current = hydration.begin(); + hydration.buffer({ kind: "upsert", projectId: "project-2", name: "current" }); + + expect( + hydration.commit( + older, + () => projects.set("project-1", "stale"), + () => {}, + ), + ).toBe(false); + expect( + hydration.commit( + current, + () => projects.set("project-1", "current snapshot"), + (update) => applyProjectUpdate(projects, update), + ), + ).toBe(true); + expect(projects).toEqual( + new Map([ + ["project-1", "current snapshot"], + ["project-2", "current"], + ]), + ); + }); +}); diff --git a/packages/app/src/contexts/session-workspace-hydration.ts b/packages/app/src/contexts/session-workspace-hydration.ts new file mode 100644 index 000000000..d1fe77795 --- /dev/null +++ b/packages/app/src/contexts/session-workspace-hydration.ts @@ -0,0 +1,31 @@ +/** Orders project updates around an in-flight workspace snapshot. */ +export function createProjectHydrationBuffer() { + let active: { updates: Update[] } | null = null; + + return { + begin() { + const hydration = { updates: [] as Update[] }; + active = hydration; + return hydration; + }, + buffer(update: Update): boolean { + if (!active) return false; + active.updates.push(update); + return true; + }, + commit( + hydration: { updates: Update[] }, + applySnapshot: () => void, + applyUpdate: (update: Update) => void, + ): boolean { + if (active !== hydration) return false; + active = null; + applySnapshot(); + for (const update of hydration.updates) applyUpdate(update); + return true; + }, + cancel(hydration: { updates: Update[] }): void { + if (active === hydration) active = null; + }, + }; +} diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index b2515f154..af00a621b 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -22,6 +22,7 @@ import { AgentSnapshotPayloadSchema, } from "@getpaseo/protocol/messages"; import { + createPersistedProjectRecord, createPersistedWorkspaceRecord, type PersistedProjectRecord, type PersistedWorkspaceRecord, @@ -46,6 +47,7 @@ import { WorkspaceAutoName } from "../workspace-auto-name.js"; import { createGitMutationService } from "../session/git-mutation/git-mutation-service.js"; import type { GeneratedWorkspaceName } from "../worktree-branch-name-generator.js"; import type { GitHubService } from "../../services/github-service.js"; +import { areEquivalentPaths } from "../../utils/path.js"; import type { TerminalManager } from "../../terminal/terminal-manager.js"; import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; import type { BrowserToolsBroker, BrowserToolsExecuteInput } from "../browser-tools/broker.js"; @@ -699,6 +701,24 @@ function createPaseoWorktreeForMcpTest(options: { : {}), projectRegistry: { get: async (projectId) => projects.get(projectId) ?? null, + getOrCreateActiveByRoot: async (allocation) => { + const existing = Array.from(projects.values()).find( + (project) => + areEquivalentPaths(project.rootPath, allocation.rootPath) && + !project.archivedAt, + ); + if (existing) return existing; + const project = createPersistedProjectRecord({ + projectId: `prj_test_${projects.size + 1}`, + rootPath: allocation.rootPath, + kind: allocation.kind, + displayName: allocation.displayName, + createdAt: allocation.timestamp, + updatedAt: allocation.timestamp, + }); + projects.set(project.projectId, project); + return project; + }, upsert: async (record) => { projects.set(record.projectId, record); }, diff --git a/packages/server/src/server/bootstrap-provider-availability.test.ts b/packages/server/src/server/bootstrap-provider-availability.test.ts index 552441639..175379b3f 100644 --- a/packages/server/src/server/bootstrap-provider-availability.test.ts +++ b/packages/server/src/server/bootstrap-provider-availability.test.ts @@ -1,4 +1,5 @@ -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { execFileSync } from "node:child_process"; +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import pino from "pino"; @@ -28,10 +29,21 @@ describe("bootstrap provider availability", () => { tempRoots.push(root); const binDir = await mkdtemp(path.join(os.tmpdir(), "paseo-bootstrap-provider-bin-")); tempRoots.push(binDir); + const gitPath = execFileSync(process.platform === "win32" ? "where" : "which", ["git"], { + encoding: "utf8", + }) + .split(/\r?\n/)[0] + .trim(); + if (process.platform === "win32") { + await writeFile(path.join(binDir, "git.cmd"), `@"${gitPath}" %*\r\n`); + } else { + await symlink(gitPath, path.join(binDir, "git")); + } process.env.PATH = binDir; if (process.platform === "win32") { process.env.PATHEXT = ".CMD"; } + expect(execFileSync("git", ["--version"], { encoding: "utf8" })).toMatch(/git version/i); const paseoHome = path.join(root, ".paseo"); const staticDir = path.join(root, "static"); const agentStoragePath = path.join(paseoHome, "agents"); diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 51efc33ec..1850dd0f2 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -797,13 +797,7 @@ export async function createPaseoDaemon( projectRegistry, reconciliation: workspaceReconciliation, logger, - onProjectUpdate: (update) => { - if (update.kind === "upsert") { - wsServer?.publishProjectUpdate(update.project); - } else { - wsServer?.publishProjectRemove(update.projectId); - } - }, + onProjectUpdate: (update) => wsServer?.publishProjectUpdate(update), onWorkspacesChanged: async (workspaceIds) => { await Promise.all( (wsServer?.listActiveSessions() ?? []).map((session) => diff --git a/packages/server/src/server/paseo-worktree-service.test.ts b/packages/server/src/server/paseo-worktree-service.test.ts index 4b3d9af51..79632f120 100644 --- a/packages/server/src/server/paseo-worktree-service.test.ts +++ b/packages/server/src/server/paseo-worktree-service.test.ts @@ -20,6 +20,7 @@ import { readPaseoWorktreeMetadata } from "../utils/worktree-metadata.js"; import { createWorktree } from "../utils/worktree.js"; import { isPlatform } from "../test-utils/platform.js"; import { existsSync } from "node:fs"; +import { areEquivalentPaths } from "../utils/path.js"; const cleanupPaths: string[] = []; @@ -97,12 +98,13 @@ test("repairs a legacy source workspace whose project record is missing", async expect(result.workspace.projectId).toMatch(/^prj_[0-9a-f]{16}$/); expect(result.workspace.projectId).not.toBe(sourceWorkspace.projectId); - expect(deps.projects.get(result.workspace.projectId)).toMatchObject({ + const repairedProject = deps.projects.get(result.workspace.projectId); + expect(repairedProject).toMatchObject({ projectId: result.workspace.projectId, - rootPath: repoDir, kind: "git", archivedAt: null, }); + expect(areEquivalentPaths(repairedProject?.rootPath ?? "", repoDir)).toBe(true); }); test("registers a new worktree in the existing root project after the main checkout workspace is removed", async () => { diff --git a/packages/server/src/server/project-git-observer-service.test.ts b/packages/server/src/server/project-git-observer-service.test.ts index 9eda8bcbe..8994718a3 100644 --- a/packages/server/src/server/project-git-observer-service.test.ts +++ b/packages/server/src/server/project-git-observer-service.test.ts @@ -1,10 +1,7 @@ import type pino from "pino"; import { describe, expect, test } from "vitest"; -import { - ProjectGitObserverService, - type ProjectGitObserverUpdate, -} from "./project-git-observer-service.js"; +import { ProjectGitObserverService, type ProjectUpdate } from "./project-git-observer-service.js"; import { createPersistedProjectRecord, type PersistedProjectRecord, @@ -344,7 +341,7 @@ class ObservedProjects { private readonly roots = new FakeProjectRoots(this.lifecycleEvents); private readonly registry: FakeProjectRegistry; private readonly gitMetadata = new FakeGitMetadata(); - private readonly projectEvents: ProjectGitObserverUpdate[] = []; + private readonly projectEvents: ProjectUpdate[] = []; private readonly workspaceEvents: string[][] = []; private readonly logRecords: LogRecord[] = []; private readonly service: ProjectGitObserverService; @@ -459,7 +456,7 @@ class ObservedProjects { return [...this.lifecycleEvents]; } - get publishedProjects(): ProjectGitObserverUpdate[] { + get publishedProjects(): ProjectUpdate[] { return [...this.projectEvents]; } diff --git a/packages/server/src/server/project-git-observer-service.ts b/packages/server/src/server/project-git-observer-service.ts index 662ffc1d7..31edb2127 100644 --- a/packages/server/src/server/project-git-observer-service.ts +++ b/packages/server/src/server/project-git-observer-service.ts @@ -9,7 +9,7 @@ import type { WorkspaceReconciliationService } from "./workspace-reconciliation- const DEFAULT_RESCAN_INTERVAL_MS = 5 * 60_000; const DEFAULT_DEBOUNCE_MS = 100; -export type ProjectGitObserverUpdate = +export type ProjectUpdate = | { kind: "upsert"; project: PersistedProjectRecord } | { kind: "remove"; projectId: string }; @@ -69,7 +69,7 @@ export class ProjectGitObserverService { projectRegistry: ProjectRegistry; reconciliation: Pick; logger: pino.Logger; - onProjectUpdate: (update: ProjectGitObserverUpdate) => void; + onProjectUpdate: (update: ProjectUpdate) => void; onWorkspacesChanged: (workspaceIds: string[]) => Promise; watch?: ProjectRootWatch; clock?: ObserverClock; diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index 4c03520c4..eced7d6ea 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -18,6 +18,7 @@ import { DownloadTokenStore } from "./file-download/token-store.js"; import { StructuredAgentFallbackError } from "./agent/agent-response-loop.js"; import type { StoredAgentRecord } from "./agent/agent-storage.js"; import type { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js"; +import { createPersistedProjectRecord } from "./workspace-registry.js"; import type { SessionOptions } from "./session.js"; import type { SessionInboundMessage, SessionOutboundMessage } from "./messages.js"; import { @@ -352,14 +353,16 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session { list: vi.fn().mockResolvedValue([]), ...options.agentStorage, }), - projectRegistry: options.projectRegistry ?? { + projectRegistry: { list: vi.fn().mockResolvedValue([]), get: vi.fn(), + getOrCreateActiveByRoot: vi.fn(), upsert: vi.fn(), archive: vi.fn(), remove: vi.fn(), initialize: vi.fn(), existsOnDisk: vi.fn(), + ...options.projectRegistry, }, workspaceRegistry: options.workspaceRegistry ?? { get: vi.fn(), @@ -508,12 +511,20 @@ describe("project command-center RPCs", () => { const parentDirectory = realpathSync(mkdtempSync(join(tmpdir(), "paseo-project-session-"))); const directoryPath = join(parentDirectory, "new-project"); const messages: SessionOutboundMessage[] = []; - const projectUpsert = vi.fn().mockResolvedValue(undefined); + const projectAllocation = vi.fn(async (input) => + createPersistedProjectRecord({ + projectId: "prj_created_directory", + rootPath: input.rootPath, + kind: input.kind, + displayName: input.displayName, + createdAt: input.timestamp, + updatedAt: input.timestamp, + }), + ); const session = createSessionForTest({ messages, projectRegistry: { - list: vi.fn().mockResolvedValue([]), - upsert: projectUpsert, + getOrCreateActiveByRoot: projectAllocation, }, workspaceGitService: { getCheckout: vi.fn(async (cwd: string) => ({ @@ -537,7 +548,12 @@ describe("project command-center RPCs", () => { }); expect(existsSync(directoryPath)).toBe(true); - expect(projectUpsert).toHaveBeenCalledOnce(); + expect(projectAllocation).toHaveBeenCalledWith({ + rootPath: directoryPath, + kind: "non_git", + displayName: "new-project", + timestamp: expect.any(String), + }); expect(messages).toEqual([ { type: "project.create_directory.response", @@ -545,7 +561,7 @@ describe("project command-center RPCs", () => { requestId: "req-create-directory", directoryPath, project: { - projectId: directoryPath, + projectId: "prj_created_directory", projectDisplayName: "new-project", projectCustomName: null, projectRootPath: directoryPath, @@ -568,8 +584,7 @@ describe("project command-center RPCs", () => { const session = createSessionForTest({ messages, projectRegistry: { - list: vi.fn().mockResolvedValue([]), - upsert: vi.fn().mockRejectedValue(new Error("registry unavailable")), + getOrCreateActiveByRoot: vi.fn().mockRejectedValue(new Error("registry unavailable")), }, workspaceGitService: { getCheckout: vi.fn(async (cwd: string) => ({ diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 5f8ac4420..2f1729e92 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -61,6 +61,7 @@ import { getErrorMessage, getErrorMessageOr } from "@getpaseo/protocol/error-uti import { getAgentStatusPriority } from "@getpaseo/protocol/agent-state-bucket"; import { getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels"; import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js"; +import type { ProjectUpdate } from "./project-git-observer-service.js"; import { CLIENT_SHUTDOWN_RPC_REASON, normalizeClientRestartRpcReason, @@ -970,19 +971,17 @@ export class Session { return this.clientCapabilities.has(capability); } - emitProjectUpdate(project: PersistedProjectRecord): void { + emitProjectUpdate(update: ProjectUpdate): void { if (!this.supports(CLIENT_CAPS.projectUpdates)) return; this.emit({ type: "project.update", - payload: { kind: "upsert", project: this.buildProjectDescriptor(project) }, + payload: + update.kind === "upsert" + ? { kind: "upsert", project: this.buildProjectDescriptor(update.project) } + : update, }); } - emitProjectRemove(projectId: string): void { - if (!this.supports(CLIENT_CAPS.projectUpdates)) return; - this.emit({ type: "project.update", payload: { kind: "remove", projectId } }); - } - async syncWorkspaceGitObserverForWorkspace(workspace: PersistedWorkspaceRecord): Promise { await this.workspaceGitObserver.syncObserverForWorkspace(workspace); } diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 951fb5081..4256abeb0 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -9,11 +9,8 @@ import type { AgentStorage } from "./agent/agent-storage.js"; import type { DownloadTokenStore } from "./file-download/token-store.js"; import type { TerminalManager } from "../terminal/terminal-manager.js"; import type pino from "pino"; -import type { - PersistedProjectRecord, - ProjectRegistry, - WorkspaceRegistry, -} from "./workspace-registry.js"; +import type { ProjectRegistry, WorkspaceRegistry } from "./workspace-registry.js"; +import type { ProjectUpdate } from "./project-git-observer-service.js"; import type { FileBackedChatService } from "./chat/chat-service.js"; import type { LoopService } from "./loop-service.js"; import type { ScheduleService } from "./schedule/service.js"; @@ -769,12 +766,8 @@ export class VoiceAssistantWebSocketServer { ); } - public publishProjectUpdate(project: PersistedProjectRecord): void { - for (const session of this.listActiveSessions()) session.emitProjectUpdate(project); - } - - public publishProjectRemove(projectId: string): void { - for (const session of this.listActiveSessions()) session.emitProjectRemove(projectId); + public publishProjectUpdate(update: ProjectUpdate): void { + for (const session of this.listActiveSessions()) session.emitProjectUpdate(update); } public publishSpeechReadiness(readiness: SpeechReadinessSnapshot | null): void { diff --git a/packages/server/src/server/wire-compat.test.ts b/packages/server/src/server/wire-compat.test.ts index c1510e70a..a08baa42e 100644 --- a/packages/server/src/server/wire-compat.test.ts +++ b/packages/server/src/server/wire-compat.test.ts @@ -348,10 +348,10 @@ describe("wire compatibility", () => { messages: capableMessages, }); - legacy.emitProjectUpdate(project); - legacy.emitProjectRemove(project.projectId); - capable.emitProjectUpdate(project); - capable.emitProjectRemove(project.projectId); + legacy.emitProjectUpdate({ kind: "upsert", project }); + legacy.emitProjectUpdate({ kind: "remove", projectId: project.projectId }); + capable.emitProjectUpdate({ kind: "upsert", project }); + capable.emitProjectUpdate({ kind: "remove", projectId: project.projectId }); expect(legacyMessages).toEqual([]); expect(capableMessages.map((message) => SessionOutboundMessageSchema.parse(message))).toEqual([ diff --git a/packages/server/src/server/workspace-registry-bootstrap.test.ts b/packages/server/src/server/workspace-registry-bootstrap.test.ts index 0cdd97c88..a1ae11b12 100644 --- a/packages/server/src/server/workspace-registry-bootstrap.test.ts +++ b/packages/server/src/server/workspace-registry-bootstrap.test.ts @@ -1,6 +1,6 @@ import os from "node:os"; import path from "node:path"; -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; @@ -11,10 +11,10 @@ import type { WorkspaceGitService } from "./workspace-git-service.js"; import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry } from "./workspace-registry.js"; import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js"; -const NON_GIT_PROJECT = path.resolve("/tmp/non-git-project"); -const ARCHIVED_PROJECT = path.resolve("/tmp/archived-project"); -const GIT_PROJECT = path.resolve("/tmp/legacy-git-project"); -const GIT_WORKTREE = path.resolve("/tmp/legacy-git-project-feature"); +let NON_GIT_PROJECT: string; +let ARCHIVED_PROJECT: string; +let GIT_PROJECT: string; +let GIT_WORKTREE: string; describe("bootstrapWorkspaceRegistries", () => { let tmpDir: string; @@ -27,6 +27,10 @@ describe("bootstrapWorkspaceRegistries", () => { beforeEach(() => { tmpDir = mkdtempSync(path.join(os.tmpdir(), "workspace-bootstrap-")); + NON_GIT_PROJECT = path.join(tmpDir, "non-git-project"); + ARCHIVED_PROJECT = path.join(tmpDir, "archived-project"); + GIT_PROJECT = path.join(tmpDir, "legacy-git-project"); + GIT_WORKTREE = path.join(tmpDir, "legacy-git-project-feature"); paseoHome = path.join(tmpDir, ".paseo"); agentStorage = new AgentStorage(path.join(paseoHome, "agents"), logger); projectRegistry = new FileBackedProjectRegistry( @@ -38,12 +42,92 @@ describe("bootstrapWorkspaceRegistries", () => { logger, ); workspaceGitService = createNoopWorkspaceGitService(); + for (const directory of [NON_GIT_PROJECT, ARCHIVED_PROJECT, GIT_PROJECT, GIT_WORKTREE]) { + mkdirSync(directory, { recursive: true }); + } }); afterEach(() => { rmSync(tmpDir, { recursive: true, force: true }); }); + test("skips a legacy agent whose directory no longer exists", async () => { + const missingDirectory = path.join(tmpDir, "missing-project"); + const getCheckout = async () => { + throw new Error("Git must not inspect a missing directory"); + }; + workspaceGitService = { ...createNoopWorkspaceGitService(), getCheckout }; + await agentStorage.initialize(); + await agentStorage.upsert({ + id: "agent-missing-directory", + provider: "codex", + cwd: missingDirectory, + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + lastActivityAt: null, + lastUserMessageAt: null, + title: null, + labels: {}, + lastStatus: "idle", + lastModeId: null, + config: null, + runtimeInfo: { provider: "codex", sessionId: null }, + persistence: null, + archivedAt: null, + }); + + await bootstrapWorkspaceRegistries({ + paseoHome, + agentStorage, + projectRegistry, + workspaceRegistry, + workspaceGitService, + logger, + }); + + expect(await projectRegistry.list()).toEqual([]); + expect(await workspaceRegistry.list()).toEqual([]); + }); + + test("propagates a Git failure for an existing legacy directory", async () => { + const gitFailure = new Error("Git is unavailable"); + workspaceGitService = { + ...createNoopWorkspaceGitService(), + getCheckout: async () => { + throw gitFailure; + }, + }; + await agentStorage.initialize(); + await agentStorage.upsert({ + id: "agent-existing-directory", + provider: "codex", + cwd: NON_GIT_PROJECT, + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + lastActivityAt: null, + lastUserMessageAt: null, + title: null, + labels: {}, + lastStatus: "idle", + lastModeId: null, + config: null, + runtimeInfo: { provider: "codex", sessionId: null }, + persistence: null, + archivedAt: null, + }); + + await expect( + bootstrapWorkspaceRegistries({ + paseoHome, + agentStorage, + projectRegistry, + workspaceRegistry, + workspaceGitService, + logger, + }), + ).rejects.toBe(gitFailure); + }); + test("materializes workspace registries from non-archived agent records", async () => { await agentStorage.initialize(); await agentStorage.upsert({ diff --git a/packages/server/src/server/workspace-registry-bootstrap.ts b/packages/server/src/server/workspace-registry-bootstrap.ts index b2a825353..18c536b1c 100644 --- a/packages/server/src/server/workspace-registry-bootstrap.ts +++ b/packages/server/src/server/workspace-registry-bootstrap.ts @@ -1,4 +1,5 @@ import path from "node:path"; +import { existsSync } from "node:fs"; import type { Logger } from "pino"; @@ -70,7 +71,10 @@ export async function bootstrapWorkspaceRegistries(options: { ]), ); const records = await options.agentStorage.list(); - const activeRecords = records.filter((record) => !record.archivedAt); + // A legacy agent can outlive its working directory. Reconciliation treats a + // missing directory as absent rather than asking Git about it; bootstrap must + // do the same before materializing its first workspace record. + const activeRecords = records.filter((record) => !record.archivedAt && existsSync(record.cwd)); const recordsByDirectoryKey = new Map< string, { diff --git a/packages/server/src/server/worktree-session.test.ts b/packages/server/src/server/worktree-session.test.ts index 970771823..c6da36461 100644 --- a/packages/server/src/server/worktree-session.test.ts +++ b/packages/server/src/server/worktree-session.test.ts @@ -32,8 +32,13 @@ import { import type { TerminalManager } from "../terminal/terminal-manager.js"; import type { TerminalSession } from "../terminal/terminal.js"; import type { AgentStorage, StoredAgentRecord } from "./agent/agent-storage.js"; -import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "./workspace-registry.js"; +import { + createPersistedProjectRecord, + type PersistedProjectRecord, + type PersistedWorkspaceRecord, +} from "./workspace-registry.js"; import type { GitHubService } from "../services/github-service.js"; +import { areEquivalentPaths } from "../utils/path.js"; import { createPaseoWorktree as createPaseoWorktreeService, type CreatePaseoWorktreeFn, @@ -280,6 +285,23 @@ function createPaseoWorktreeForTest(options: { : {}), projectRegistry: { get: async (projectId) => projects.get(projectId) ?? null, + getOrCreateActiveByRoot: async (allocation) => { + const existing = Array.from(projects.values()).find( + (project) => + areEquivalentPaths(project.rootPath, allocation.rootPath) && !project.archivedAt, + ); + if (existing) return existing; + const project = createPersistedProjectRecord({ + projectId: `prj_test_${projects.size + 1}`, + rootPath: allocation.rootPath, + kind: allocation.kind, + displayName: allocation.displayName, + createdAt: allocation.timestamp, + updatedAt: allocation.timestamp, + }); + projects.set(project.projectId, project); + return project; + }, upsert: async (record) => { options.events?.push(`project:${record.projectId}`); projects.set(record.projectId, record);