diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index aa3f7bad2..a72247fc1 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -97,6 +97,8 @@ Closing a subagent's tab on one client doesn't affect other clients' layouts. Th $PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json ``` +`{cwd-with-dashes}` is derived from the agent's filesystem `cwd`. It is not the workspace id; agent storage stays cwd-keyed while workspace identity is the opaque workspace id. + Each agent is a single JSON file. Fields relevant to this doc: | Field | Type | Meaning | diff --git a/docs/architecture.md b/docs/architecture.md index dfa8f7e10..d9d7565fd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -87,7 +87,7 @@ code imports from `@getpaseo/client`. Cross-platform React Native app that connects to one or more daemons. -- Expo Router navigation (`/h/[serverId]/workspace/[workspaceId]`, `/h/[serverId]/agent/[agentId]`, etc.) +- Expo Router navigation (`/h/[serverId]/workspace/[workspaceId]`, `/h/[serverId]/agent/[agentId]`, etc.). The `workspaceId` URL segment is an opaque workspace id (path-shaped today and opaque-encoded for routing), not a directly meaningful filesystem path. - `HostRuntimeController` manages saved host connections, reconnection, and per-host runtime state - `SessionContext` wraps the daemon client for the active session - Composer UI and submit/draft behavior live in `packages/app/src/composer/`; screens and panels should integrate it from there instead of dropping composer internals into `components/`, `hooks/`, or `screens/workspace/` @@ -232,7 +232,7 @@ initializing → idle ⇄ running - Timeline is append-only with epochs (each run starts a new epoch). Storage uses sequence numbers for client-side dedup; the default fetch page is 200 items - Timeline row `timestamp` values are canonical daemon-owned timestamps. Providers may supply original replay timestamps, but clients must not guess timestamp trust or hide time UI based on local clock heuristics. - Events stream to connected clients in real time; correctness is backed by authoritative timeline fetches and paged-to-completion catch-up. -- Agent state persists to `$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json` (timeline rows live alongside the record) +- Agent state persists to `$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json` (timeline rows live alongside the record). That storage path is derived from `cwd`, not from workspace id. ## Agent providers diff --git a/docs/data-model.md b/docs/data-model.md index 306e9552b..2371eb5d9 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -386,16 +386,18 @@ emptied duplicate. Array of workspace records. A workspace is a specific working directory within a project. -| Field | Type | Description | -| ------------- | ----------------------------------------------- | ------------------------------ | -| `workspaceId` | `string` | Primary key | -| `projectId` | `string` | FK to Project.projectId | -| `cwd` | `string` | Filesystem path | -| `kind` | `"local_checkout" \| "worktree" \| "directory"` | | -| `displayName` | `string` | | -| `createdAt` | `string` (ISO 8601) | | -| `updatedAt` | `string` (ISO 8601) | | -| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable | +| Field | Type | Description | +| ------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `workspaceId` | `string` | Opaque stable identifier (`wks_`), generated independently of the directory. MUST NOT be treated as a path; compare by exact equality. Use the `cwd` field for directory access. | +| `projectId` | `string` | FK to Project.projectId | +| `cwd` | `string` | Filesystem path | +| `kind` | `"local_checkout" \| "worktree" \| "directory"` | | +| `displayName` | `string` | | +| `createdAt` | `string` (ISO 8601) | | +| `updatedAt` | `string` (ISO 8601) | | +| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable | + +> **Opaque-ID invariant:** `workspaceId` is opaque identity, never a filesystem path. Filesystem and git operations take `cwd`/`workspaceDirectory` only — never the id. Path-derived grouping keys (e.g. `deriveWorkspaceDirectoryKey`, used at bootstrap to group agents into a workspace) are directory keys, not workspace identity, and must not be persisted or compared as ids. --- diff --git a/docs/glossary.md b/docs/glossary.md index 08778e432..8448e450d 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -3,7 +3,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here. - **Project** — Logical grouping of workspaces sharing a git remote (or main repo root). UI: "Project" / "Add project". Code: `ProjectSummary` (`packages/app/src/utils/projects.ts:22`), `projectKey` (`packages/server/src/server/workspace-registry-model.ts:16`). Forbidden: "Repo", "Repository" as UI label. -- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. 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. +- **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. - **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. Code: `PersistedWorkspaceKind` (`packages/server/src/server/workspace-registry-model.ts:8`). - **Agent** — See **Agent session**. UI still says "Agent" / "New Agent" in places, but moving toward **Agent session** as the canonical term. Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`). Forbidden: "Task", "Job", "Run". - **Daemon** — Local Paseo server process; identified by `serverId`. UI: "Daemon" (system contexts only). Code: `serverId` in `ServerInfoStatusPayloadSchema` (`packages/protocol/src/messages.ts:1936`), `DaemonClient` (`packages/client/src/daemon-client.ts`). diff --git a/packages/app/e2e/archive-tab.spec.ts b/packages/app/e2e/archive-tab.spec.ts index 58ac6f6c1..516914c09 100644 --- a/packages/app/e2e/archive-tab.spec.ts +++ b/packages/app/e2e/archive-tab.spec.ts @@ -68,7 +68,7 @@ test.describe("Archive tab reconciliation", () => { archivedAgentId: archived.id, survivingAgentId: surviving.id, }); - await reloadWorkspace(passivePage, tempRepo.path); + await reloadWorkspace(passivePage, surviving.workspaceId); await expectWorkspaceTabHidden(passivePage, archived.id); } finally { await passivePage.close(); @@ -94,7 +94,7 @@ test.describe("Archive tab reconciliation", () => { await openWorkspaceWithAgents(passivePage, [archived, surviving]); await openSessions(page); await archiveAgentFromSessions(page, { agentId: archived.id, title: archived.title }); - await reloadWorkspace(page, tempRepo.path); + await reloadWorkspace(page, surviving.workspaceId); await expectWorkspaceTabHidden(page, archived.id); await expectWorkspaceArchiveOutcome(passivePage, { archivedAgentId: archived.id, diff --git a/packages/app/e2e/branch-switcher.spec.ts b/packages/app/e2e/branch-switcher.spec.ts new file mode 100644 index 000000000..e1e2f5132 --- /dev/null +++ b/packages/app/e2e/branch-switcher.spec.ts @@ -0,0 +1,44 @@ +import { expect, test } from "./fixtures"; +import { gotoAppShell } from "./helpers/app"; +import { expectWorkspaceBranch, switchBranchFromHeader } from "./helpers/branch-switcher"; +import { seedWorkspace } from "./helpers/seed-client"; +import { getServerId } from "./helpers/server-id"; +import { readWorktreeBranchInfo } from "./helpers/workspace"; +import { switchWorkspaceViaSidebar, waitForSidebarHydration } from "./helpers/workspace-ui"; + +test.describe("Branch switcher", () => { + // The first test after a spec-file switch can fail while the shared daemon + // releases stale sessions from the previous spec; one retry stabilizes it. + test.describe.configure({ retries: 1 }); + + test("switches the workspace branch from the header for an opaque workspace id", async ({ + page, + }) => { + test.setTimeout(90_000); + const serverId = getServerId(); + const workspace = await seedWorkspace({ + repoPrefix: "branch-switch-", + repo: { branches: ["main", "dev"] }, + }); + + try { + await gotoAppShell(page); + await waitForSidebarHydration(page); + await switchWorkspaceViaSidebar({ page, serverId, workspaceId: workspace.workspaceId }); + + await expectWorkspaceBranch(page, "main"); + await switchBranchFromHeader(page, { from: "main", to: "dev" }); + await expectWorkspaceBranch(page, "dev"); + + await expect + .poll( + async () => + (await readWorktreeBranchInfo({ worktreePath: workspace.repoPath })).currentBranch, + { timeout: 30_000 }, + ) + .toBe("dev"); + } finally { + await workspace.cleanup(); + } + }); +}); diff --git a/packages/app/e2e/composer-attachments.spec.ts b/packages/app/e2e/composer-attachments.spec.ts index 14460763d..b13e14ef5 100644 --- a/packages/app/e2e/composer-attachments.spec.ts +++ b/packages/app/e2e/composer-attachments.spec.ts @@ -244,7 +244,7 @@ test.describe("Composer attachments", () => { await switchWorkspaceViaSidebar({ page, serverId, - targetWorkspacePath: workspace.workspaceId, + workspaceId: workspace.workspaceId, }); await openNewWorkspaceComposer(page, { diff --git a/packages/app/e2e/helpers/archive-tab.ts b/packages/app/e2e/helpers/archive-tab.ts index ec0ef57b7..beaab3ccf 100644 --- a/packages/app/e2e/helpers/archive-tab.ts +++ b/packages/app/e2e/helpers/archive-tab.ts @@ -14,6 +14,7 @@ export interface ArchiveTabAgent { id: string; title: string; cwd: string; + workspaceId: string; } function buildSeededStoragePayload() { @@ -34,6 +35,10 @@ function buildSeededStoragePayload() { * idle agent from the same client it uses for everything else. */ export interface IdleAgentSeedClient { + openProject(cwd: string): Promise<{ + workspace: { id: string } | null; + error: string | null; + }>; createAgent(options: { provider: string; model: string; @@ -52,6 +57,10 @@ export async function createIdleAgent( client: IdleAgentSeedClient, input: { cwd: string; title: string }, ): Promise { + const opened = await client.openProject(input.cwd); + if (!opened.workspace) { + throw new Error(opened.error ?? `Failed to open project ${input.cwd}`); + } const created = await client.createAgent({ provider: "opencode", model: "opencode/gpt-5-nano", @@ -71,6 +80,7 @@ export async function createIdleAgent( id: created.id, title: input.title, cwd: input.cwd, + workspaceId: opened.workspace.id, }; } @@ -133,7 +143,7 @@ export async function openWorkspaceWithAgents( ): Promise { const serverId = getServerId(); for (const agent of agents) { - await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.cwd)); + await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.workspaceId)); // The workspace layout consumes `?open=agent:xxx`, returns null during the effect, // then replaces the URL with the clean workspace route after preparing the tab. diff --git a/packages/app/e2e/helpers/branch-switcher.ts b/packages/app/e2e/helpers/branch-switcher.ts new file mode 100644 index 000000000..6fa78e426 --- /dev/null +++ b/packages/app/e2e/helpers/branch-switcher.ts @@ -0,0 +1,40 @@ +import { expect, type Page } from "@playwright/test"; +import { escapeRegex } from "./regex"; + +// The header branch switcher renders as a button whose accessible name carries the +// current branch ("Current branch: . Press to switch branch."). Matching on the +// accessible name keeps these helpers tied to what a screen reader user hears, and it +// proves the header resolved a real checkout directory from the opaque workspace id. +function branchSwitcherTrigger(page: Page, branchName: string) { + return page + .getByRole("button", { name: new RegExp(`Current branch: ${escapeRegex(branchName)}\\b`) }) + .filter({ visible: true }) + .first(); +} + +export async function expectWorkspaceBranch(page: Page, branchName: string): Promise { + await expect(branchSwitcherTrigger(page, branchName)).toBeVisible({ timeout: 30_000 }); +} + +export async function switchBranchFromHeader( + page: Page, + input: { from: string; to: string }, +): Promise { + await branchSwitcherTrigger(page, input.from).click(); + + const picker = page.getByTestId("combobox-desktop-container"); + await expect(picker).toBeVisible({ timeout: 30_000 }); + + // The branch switcher combobox renders its options as plain text rows with no ARIA + // role, so filter by the visible branch name and click the matching row. Filtering + // first guarantees a single, unambiguous match. + const search = page.getByPlaceholder("Filter branches..."); + await expect(search).toBeVisible({ timeout: 30_000 }); + await search.fill(input.to); + + const option = picker.getByText(input.to, { exact: true }); + await expect(option).toBeVisible({ timeout: 30_000 }); + await option.click(); + + await expect(picker).not.toBeVisible({ timeout: 30_000 }); +} diff --git a/packages/app/e2e/helpers/composer.ts b/packages/app/e2e/helpers/composer.ts index 31f65cb86..f35c6f1dd 100644 --- a/packages/app/e2e/helpers/composer.ts +++ b/packages/app/e2e/helpers/composer.ts @@ -166,7 +166,7 @@ export async function startRunningMockAgent( cwd: repo.path, model: opts.model, }); - const agentUrl = `${buildHostWorkspaceRoute(serverId, repo.path)}?open=${encodeURIComponent(`agent:${agent.id}`)}`; + const agentUrl = `${buildHostWorkspaceRoute(serverId, opened.workspace.id)}?open=${encodeURIComponent(`agent:${agent.id}`)}`; await page.goto(agentUrl); await expectComposerVisible(page); await client.sendAgentMessage(agent.id, opts.prompt); diff --git a/packages/app/e2e/helpers/launcher.ts b/packages/app/e2e/helpers/launcher.ts index 38af80480..87a9bcab1 100644 --- a/packages/app/e2e/helpers/launcher.ts +++ b/packages/app/e2e/helpers/launcher.ts @@ -6,8 +6,8 @@ import { getServerId } from "./server-id"; // ─── Navigation ──────────────────────────────────────────────────────────── /** Navigate to a workspace and wait for the tab bar to appear. */ -export async function gotoWorkspace(page: Page, cwd: string): Promise { - const route = buildHostWorkspaceRoute(getServerId(), cwd); +export async function gotoWorkspace(page: Page, workspaceId: string): Promise { + const route = buildHostWorkspaceRoute(getServerId(), workspaceId); await page.goto(route); await waitForTabBar(page); } diff --git a/packages/app/e2e/helpers/mock-agent.ts b/packages/app/e2e/helpers/mock-agent.ts index 4eef1d7b3..9187cbc7b 100644 --- a/packages/app/e2e/helpers/mock-agent.ts +++ b/packages/app/e2e/helpers/mock-agent.ts @@ -5,6 +5,7 @@ import { getServerId } from "./server-id"; export interface MockAgentWorkspace { agentId: string; + workspaceId: string; cwd: string; client: SeedDaemonClient; cleanup(): Promise; @@ -40,6 +41,7 @@ export async function seedMockAgentWorkspace( }); return { agentId: agent.id, + workspaceId: workspace.workspaceId, cwd: workspace.repoPath, client: workspace.client, cleanup: workspace.cleanup, @@ -50,8 +52,8 @@ export async function seedMockAgentWorkspace( } } -export function buildAgentRoute(cwd: string, agentId: string): string { - return `${buildHostWorkspaceRoute(getServerId(), cwd)}?open=${encodeURIComponent( +export function buildAgentRoute(workspaceId: string, agentId: string): string { + return `${buildHostWorkspaceRoute(getServerId(), workspaceId)}?open=${encodeURIComponent( `agent:${agentId}`, )}`; } @@ -59,9 +61,9 @@ export function buildAgentRoute(cwd: string, agentId: string): string { /** Boots the app directly at the agent's workspace route and waits for the open intent to settle. */ export async function openAgentRoute( page: Page, - input: { cwd: string; agentId: string }, + input: { workspaceId: string; agentId: string }, ): Promise { - await page.goto(buildAgentRoute(input.cwd, input.agentId)); + await page.goto(buildAgentRoute(input.workspaceId, input.agentId)); await page.waitForURL( (url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"), { timeout: 60_000 }, diff --git a/packages/app/e2e/helpers/new-workspace.ts b/packages/app/e2e/helpers/new-workspace.ts index ab30d07c2..343761575 100644 --- a/packages/app/e2e/helpers/new-workspace.ts +++ b/packages/app/e2e/helpers/new-workspace.ts @@ -3,7 +3,7 @@ import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/inte import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes"; import { connectDaemonClient } from "./daemon-client-loader"; import { daemonWsRoutePattern } from "./daemon-port"; -import { expectWorkspaceHeader, workspaceLabelFromPath } from "./workspace-ui"; +import { expectWorkspaceHeader } from "./workspace-ui"; type NewWorkspaceDaemonClient = Pick< InternalDaemonClient, @@ -12,17 +12,20 @@ type NewWorkspaceDaemonClient = Pick< | "close" | "connect" | "createPaseoWorktree" + | "fetchWorkspaces" | "openProject" >; type OpenProjectPayload = Awaited>; type WorkspacePayload = Pick; +type WorkspaceDescriptor = NonNullable; export interface OpenedProject { workspaceId: string; projectKey: string; projectDisplayName: string; workspaceName: string; + workspaceDirectory: string; } function requireWorkspace(payload: WorkspacePayload) { @@ -35,6 +38,39 @@ function requireWorkspace(payload: WorkspacePayload) { return payload.workspace; } +function openedProjectFromWorkspace(workspace: WorkspaceDescriptor): OpenedProject { + return { + workspaceId: workspace.id, + projectKey: workspace.projectId, + projectDisplayName: workspace.projectDisplayName, + workspaceName: workspace.name, + workspaceDirectory: workspace.workspaceDirectory, + }; +} + +async function fetchWorkspaceById( + client: NewWorkspaceDaemonClient, + workspaceId: string, +): Promise { + const payload = await client.fetchWorkspaces(); + return payload.entries.find((entry) => entry.id === workspaceId) ?? null; +} + +async function waitForWorkspaceDescriptor( + client: NewWorkspaceDaemonClient, + workspaceId: string, +): Promise { + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + const workspace = await fetchWorkspaceById(client, workspaceId); + if (workspace) { + return workspace; + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error(`Workspace descriptor not found: ${workspaceId}`); +} + function parseWorkspaceIdFromPageUrl(page: Page, serverId: string): string | null { const pathname = new URL(page.url()).pathname; const match = pathname.match( @@ -57,24 +93,19 @@ export async function openProjectViaDaemon( repoPath: string, ): Promise { const workspace = requireWorkspace(await client.openProject(repoPath)); - return { - workspaceId: workspace.id, - projectKey: workspace.projectId, - projectDisplayName: workspace.projectDisplayName, - workspaceName: workspace.name, - }; + return openedProjectFromWorkspace(workspace); } export async function archiveWorkspaceFromDaemon( client: NewWorkspaceDaemonClient, - workspaceId: string, + workspaceDirectory: string, ): Promise { - const payload = await client.archivePaseoWorktree({ worktreePath: workspaceId }); + const payload = await client.archivePaseoWorktree({ worktreePath: workspaceDirectory }); if (payload.error) { throw new Error(payload.error.message); } if (!payload.success) { - throw new Error(`Failed to archive workspace: ${workspaceId}`); + throw new Error(`Failed to archive workspace: ${workspaceDirectory}`); } } @@ -100,12 +131,7 @@ export async function createWorktreeViaDaemon( worktreeSlug: input.slug, }); const workspace = requireWorkspace(payload); - return { - workspaceId: workspace.id, - projectKey: workspace.projectId, - projectDisplayName: workspace.projectDisplayName, - workspaceName: workspace.name, - }; + return openedProjectFromWorkspace(workspace); } export async function openNewWorkspaceComposer( @@ -241,12 +267,13 @@ export async function assertNewWorkspaceSidebarAndHeader( page: Page, input: { serverId: string; + client: NewWorkspaceDaemonClient; previousWorkspaceId: string; projectDisplayName: string; assertSidebarRow?: boolean; assertHeader?: boolean; }, -): Promise<{ workspaceId: string }> { +): Promise<{ workspaceId: string; workspaceName: string; workspaceDirectory: string }> { // Wait for URL to redirect to the newly created workspace. // Uses URL as source of truth to avoid picking up sidebar rows from concurrent tests. let workspaceId: string | null = null; @@ -263,21 +290,27 @@ export async function assertNewWorkspaceSidebarAndHeader( throw new Error(`Expected URL to redirect to a new workspace.\nCurrent URL: ${page.url()}`); } + const workspace = await waitForWorkspaceDescriptor(input.client, workspaceId); + if (input.assertSidebarRow !== false) { const createdWorkspaceRow = page.getByTestId( - `sidebar-workspace-row-${input.serverId}:${workspaceId}`, + `sidebar-workspace-row-${input.serverId}:${workspace.id}`, ); await expect(createdWorkspaceRow.first()).toBeVisible({ timeout: 30_000 }); } if (input.assertHeader !== false) { await expectWorkspaceHeader(page, { - title: workspaceLabelFromPath(workspaceId), + title: workspace.name, subtitle: input.projectDisplayName, }); } - return { workspaceId }; + return { + workspaceId: workspace.id, + workspaceName: workspace.name, + workspaceDirectory: workspace.workspaceDirectory, + }; } type WebSocketMessage = string | Buffer; diff --git a/packages/app/e2e/helpers/rewind-flow.ts b/packages/app/e2e/helpers/rewind-flow.ts index 57ce79a87..58d5347de 100644 --- a/packages/app/e2e/helpers/rewind-flow.ts +++ b/packages/app/e2e/helpers/rewind-flow.ts @@ -14,6 +14,7 @@ export interface AgentHandle { page: Page; client: SeedDaemonClient; agentId: string; + workspaceId: string; cwd: string; provider: RewindFlowProvider; } @@ -61,14 +62,17 @@ function fullAccessConfig(provider: RewindFlowProvider): ProviderLaunchConfig { } } -function agentRoute(cwd: string, agentId: string): string { - return `${buildHostWorkspaceRoute(getServerId(), cwd)}?open=${encodeURIComponent( +function agentRoute(workspaceId: string, agentId: string): string { + return `${buildHostWorkspaceRoute(getServerId(), workspaceId)}?open=${encodeURIComponent( `agent:${agentId}`, )}`; } -async function openAgent(page: Page, input: { cwd: string; agentId: string }): Promise { - await page.goto(agentRoute(input.cwd, input.agentId)); +async function openAgent( + page: Page, + input: { workspaceId: string; agentId: string }, +): Promise { + await page.goto(agentRoute(input.workspaceId, input.agentId)); await page.waitForURL( (url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"), { timeout: 60_000 }, @@ -180,10 +184,11 @@ export async function launchAgent(input: { page: input.page, client, agentId: agent.id, + workspaceId: opened.workspace.id, cwd: input.cwd, provider: input.provider, }; - await openAgent(input.page, { cwd: input.cwd, agentId: agent.id }); + await openAgent(input.page, { workspaceId: opened.workspace.id, agentId: agent.id }); return handle; } diff --git a/packages/app/e2e/helpers/sidebar.ts b/packages/app/e2e/helpers/sidebar.ts index e39f0a543..ebc3c9aa5 100644 --- a/packages/app/e2e/helpers/sidebar.ts +++ b/packages/app/e2e/helpers/sidebar.ts @@ -13,6 +13,38 @@ export async function expectWorkspaceListed(page: Page, name: string): Promise { + const serverId = getServerId(); + const row = page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`); + await expect(row).toBeVisible({ timeout: 30_000 }); + await row.hover(); + + const kebab = page.getByTestId(`sidebar-workspace-kebab-${serverId}:${workspaceId}`); + await expect(kebab).toBeVisible({ timeout: 10_000 }); + await kebab.click(); + + // A clean worktree archives with no prompt; if the host reports unsynced work the app + // raises a browser confirm. Accept it so the user-confirmed archive stays deterministic + // either way. + page.once("dialog", (dialog) => void dialog.accept()); + + const archiveItem = page.getByTestId(`sidebar-workspace-menu-archive-${serverId}:${workspaceId}`); + await expect(archiveItem).toBeVisible({ timeout: 10_000 }); + await archiveItem.click(); +} + +export async function expectWorkspaceAbsentFromSidebar( + page: Page, + workspaceId: string, +): Promise { + await expect( + page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`), + ).toHaveCount(0, { timeout: 30_000 }); +} + export async function openMobileAgentSidebar(page: Page): Promise { await page.getByRole("button", { name: "Open menu" }).click(); } diff --git a/packages/app/e2e/helpers/timeline-pagination.ts b/packages/app/e2e/helpers/timeline-pagination.ts index c4f019b70..f675eff3c 100644 --- a/packages/app/e2e/helpers/timeline-pagination.ts +++ b/packages/app/e2e/helpers/timeline-pagination.ts @@ -38,7 +38,7 @@ export async function seedLongMockAgentTimeline( } export async function openAgentTimeline(page: Page, agent: LongTimelineAgent): Promise { - await page.goto(buildAgentRoute(agent.cwd, agent.agentId)); + await page.goto(buildAgentRoute(agent.workspaceId, agent.agentId)); await page.waitForURL( (url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"), { timeout: 60_000 }, diff --git a/packages/app/e2e/helpers/workspace-setup.ts b/packages/app/e2e/helpers/workspace-setup.ts index 1c0a5afae..4ba3177c1 100644 --- a/packages/app/e2e/helpers/workspace-setup.ts +++ b/packages/app/e2e/helpers/workspace-setup.ts @@ -261,7 +261,7 @@ export async function navigateToWorkspaceViaSidebar( await switchWorkspaceViaSidebar({ page, serverId: getServerId(), - targetWorkspacePath: workspaceId, + workspaceId, }); } diff --git a/packages/app/e2e/helpers/workspace-ui.ts b/packages/app/e2e/helpers/workspace-ui.ts index 81617a78d..62bb8af56 100644 --- a/packages/app/e2e/helpers/workspace-ui.ts +++ b/packages/app/e2e/helpers/workspace-ui.ts @@ -18,29 +18,8 @@ export async function waitForSidebarHydration(page: Page, timeout = 60_000): Pro .waitFor({ state: "visible", timeout }); } -export function workspaceLabelFromPath(value: string): string { - const normalized = value.replace(/\\/g, "/").replace(/\/+$/, ""); - const parts = normalized.split("/").filter(Boolean); - return parts[parts.length - 1] ?? normalized; -} - -function candidateWorkspaceIds(inputPath: string): string[] { - const trimmed = inputPath.replace(/\/+$/, ""); - const candidates = new Set([trimmed]); - if (trimmed.startsWith("/var/")) { - candidates.add(`/private${trimmed}`); - } - if (trimmed.startsWith("/private/var/")) { - candidates.add(trimmed.replace(/^\/private/, "")); - } - return Array.from(candidates); -} - -function workspaceRowLocator(page: Page, serverId: string, workspacePath: string) { - const ids = candidateWorkspaceIds(workspacePath).map( - (id) => `[data-testid="sidebar-workspace-row-${serverId}:${id}"]`, - ); - return page.locator(ids.join(",")).first(); +function workspaceRowLocator(page: Page, serverId: string, workspaceId: string) { + return page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`).first(); } export async function expectSidebarWorkspaceSelected(input: { @@ -69,13 +48,13 @@ export async function expectSidebarWorkspaceSelected(input: { export async function switchWorkspaceViaSidebar(input: { page: Page; serverId: string; - targetWorkspacePath: string; + workspaceId: string; }): Promise { - const row = workspaceRowLocator(input.page, input.serverId, input.targetWorkspacePath); + const row = workspaceRowLocator(input.page, input.serverId, input.workspaceId); await expect(row).toBeVisible({ timeout: 30_000 }); await row.click(); - const targetWorkspaceRoute = buildHostWorkspaceRoute(input.serverId, input.targetWorkspacePath); + const targetWorkspaceRoute = buildHostWorkspaceRoute(input.serverId, input.workspaceId); await expect(input.page).toHaveURL(new RegExp(escapeRegex(targetWorkspaceRoute)), { timeout: 30_000, }); @@ -89,11 +68,10 @@ export async function waitForWorkspaceInSidebar( page: Page, input: { serverId: string; workspaceId: string }, ): Promise { - const candidates = candidateWorkspaceIds(input.workspaceId); - const selector = candidates - .map((id) => `[data-testid="sidebar-workspace-row-${input.serverId}:${id}"]`) - .join(","); - await page.locator(selector).first().waitFor({ state: "visible", timeout: 60_000 }); + await workspaceRowLocator(page, input.serverId, input.workspaceId).waitFor({ + state: "visible", + timeout: 60_000, + }); } export async function expectWorkspaceHeader( diff --git a/packages/app/e2e/new-workspace.spec.ts b/packages/app/e2e/new-workspace.spec.ts index 4115ae691..a8ce137aa 100644 --- a/packages/app/e2e/new-workspace.spec.ts +++ b/packages/app/e2e/new-workspace.spec.ts @@ -36,7 +36,6 @@ import { switchWorkspaceViaSidebar, waitForSidebarHydration, waitForWorkspaceInSidebar, - workspaceLabelFromPath, } from "./helpers/workspace-ui"; interface WorkspaceStatusGroupEvent { @@ -183,7 +182,7 @@ async function submitNewWorkspaceWithoutPrompt(page: import("@playwright/test"). test.describe("New workspace flow", () => { let client: Awaited>; const localWorkspaceIds = new Set(); - const createdWorktreeIds = new Set(); + const createdWorktreeDirectories = new Set(); test.describe.configure({ timeout: 240_000 }); @@ -193,14 +192,14 @@ test.describe("New workspace flow", () => { test.afterEach(async () => { if (client) { - for (const workspaceId of createdWorktreeIds) { - await archiveWorkspaceFromDaemon(client, workspaceId).catch(() => undefined); + for (const workspaceDirectory of createdWorktreeDirectories) { + await archiveWorkspaceFromDaemon(client, workspaceDirectory).catch(() => undefined); } for (const workspaceId of localWorkspaceIds) { await archiveLocalWorkspaceFromDaemon(client, workspaceId).catch(() => undefined); } } - createdWorktreeIds.clear(); + createdWorktreeDirectories.clear(); localWorkspaceIds.clear(); await client?.close().catch(() => undefined); }); @@ -223,7 +222,7 @@ test.describe("New workspace flow", () => { await switchWorkspaceViaSidebar({ page, serverId, - targetWorkspacePath: firstWorkspace.workspaceId, + workspaceId: firstWorkspace.workspaceId, }); await expectWorkspaceHeader(page, { title: firstWorkspace.workspaceName, @@ -233,7 +232,7 @@ test.describe("New workspace flow", () => { await switchWorkspaceViaSidebar({ page, serverId, - targetWorkspacePath: secondWorkspace.workspaceId, + workspaceId: secondWorkspace.workspaceId, }); await waitForWorkspaceInSidebar(page, { serverId, @@ -247,7 +246,7 @@ test.describe("New workspace flow", () => { await switchWorkspaceViaSidebar({ page, serverId, - targetWorkspacePath: firstWorkspace.workspaceId, + workspaceId: firstWorkspace.workspaceId, }); await expectWorkspaceHeader(page, { title: firstWorkspace.workspaceName, @@ -271,7 +270,7 @@ test.describe("New workspace flow", () => { slug: `nav-${Date.now()}`, }); localWorkspaceIds.add(rootWorkspace.workspaceId); - createdWorktreeIds.add(worktreeWorkspace.workspaceId); + createdWorktreeDirectories.add(worktreeWorkspace.workspaceDirectory); await gotoAppShell(page); await waitForSidebarHydration(page); @@ -279,7 +278,7 @@ test.describe("New workspace flow", () => { await switchWorkspaceViaSidebar({ page, serverId, - targetWorkspacePath: rootWorkspace.workspaceId, + workspaceId: rootWorkspace.workspaceId, }); await expectWorkspaceHeader(page, { title: rootWorkspace.workspaceName, @@ -294,7 +293,7 @@ test.describe("New workspace flow", () => { await switchWorkspaceViaSidebar({ page, serverId, - targetWorkspacePath: worktreeWorkspace.workspaceId, + workspaceId: worktreeWorkspace.workspaceId, }); await expectWorkspaceHeader(page, { title: worktreeWorkspace.workspaceName, @@ -315,7 +314,7 @@ test.describe("New workspace flow", () => { await switchWorkspaceViaSidebar({ page, serverId, - targetWorkspacePath: rootWorkspace.workspaceId, + workspaceId: rootWorkspace.workspaceId, }); await expectWorkspaceHeader(page, { title: rootWorkspace.workspaceName, @@ -354,7 +353,7 @@ test.describe("New workspace flow", () => { await switchWorkspaceViaSidebar({ page, serverId, - targetWorkspacePath: openedProject.workspaceId, + workspaceId: openedProject.workspaceId, }); await expectWorkspaceHeader(page, { title: openedProject.workspaceName, @@ -367,12 +366,13 @@ test.describe("New workspace flow", () => { const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, { serverId, + client, previousWorkspaceId: openedProject.workspaceId, projectDisplayName: openedProject.projectDisplayName, assertSidebarRow: false, assertHeader: false, }); - createdWorktreeIds.add(createdWorkspace.workspaceId); + createdWorktreeDirectories.add(createdWorkspace.workspaceDirectory); expect(createdWorkspace.workspaceId).not.toBe(openedProject.workspaceId); await expect(page).toHaveURL( @@ -388,7 +388,7 @@ test.describe("New workspace flow", () => { await expect(createdWorkspaceRow).toBeVisible({ timeout: 30_000 }); await expectWorkspaceHeader(page, { - title: workspaceLabelFromPath(createdWorkspace.workspaceId), + title: createdWorkspace.workspaceName, subtitle: openedProject.projectDisplayName, }); @@ -428,7 +428,7 @@ test.describe("New workspace flow", () => { await switchWorkspaceViaSidebar({ page, serverId, - targetWorkspacePath: openedProject.workspaceId, + workspaceId: openedProject.workspaceId, }); await expectWorkspaceHeader(page, { title: openedProject.workspaceName, @@ -455,12 +455,13 @@ test.describe("New workspace flow", () => { const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, { serverId, + client, previousWorkspaceId: openedProject.workspaceId, projectDisplayName: openedProject.projectDisplayName, assertSidebarRow: false, assertHeader: false, }); - createdWorktreeIds.add(createdWorkspace.workspaceId); + createdWorktreeDirectories.add(createdWorkspace.workspaceDirectory); await expect(page).toHaveURL( buildHostWorkspaceRoute(serverId, createdWorkspace.workspaceId), @@ -507,7 +508,7 @@ test.describe("New workspace flow", () => { await switchWorkspaceViaSidebar({ page, serverId, - targetWorkspacePath: openedProject.workspaceId, + workspaceId: openedProject.workspaceId, }); await expectWorkspaceHeader(page, { title: openedProject.workspaceName, @@ -523,12 +524,13 @@ test.describe("New workspace flow", () => { const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, { serverId, + client, previousWorkspaceId: openedProject.workspaceId, projectDisplayName: openedProject.projectDisplayName, assertSidebarRow: false, assertHeader: false, }); - createdWorktreeIds.add(createdWorkspace.workspaceId); + createdWorktreeDirectories.add(createdWorkspace.workspaceDirectory); const rowTestId = `sidebar-workspace-row-${serverId}:${createdWorkspace.workspaceId}`; await expectWorkspaceStatusGroupEvents({ @@ -560,7 +562,7 @@ test.describe("New workspace flow", () => { await switchWorkspaceViaSidebar({ page, serverId, - targetWorkspacePath: openedProject.workspaceId, + workspaceId: openedProject.workspaceId, }); await expectWorkspaceHeader(page, { title: openedProject.workspaceName, @@ -576,10 +578,11 @@ test.describe("New workspace flow", () => { const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, { serverId, + client, previousWorkspaceId: openedProject.workspaceId, projectDisplayName: openedProject.projectDisplayName, }); - createdWorktreeIds.add(createdWorkspace.workspaceId); + createdWorktreeDirectories.add(createdWorkspace.workspaceDirectory); const rowTestId = `sidebar-workspace-row-${serverId}:${createdWorkspace.workspaceId}`; await expectWorkspaceStatusGroupEvents({ @@ -618,7 +621,7 @@ test.describe("New workspace flow", () => { await switchWorkspaceViaSidebar({ page, serverId, - targetWorkspacePath: openedProject.workspaceId, + workspaceId: openedProject.workspaceId, }); await expectWorkspaceHeader(page, { title: openedProject.workspaceName, @@ -640,17 +643,18 @@ test.describe("New workspace flow", () => { const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, { serverId, + client, previousWorkspaceId: openedProject.workspaceId, projectDisplayName: openedProject.projectDisplayName, }); - createdWorktreeIds.add(createdWorkspace.workspaceId); + createdWorktreeDirectories.add(createdWorkspace.workspaceDirectory); - expect(existsSync(createdWorkspace.workspaceId)).toBe(true); + expect(existsSync(createdWorkspace.workspaceDirectory)).toBe(true); const branchInfo = await readWorktreeBranchInfo({ - worktreePath: createdWorkspace.workspaceId, + worktreePath: createdWorkspace.workspaceDirectory, }); - expect(branchInfo.currentBranch).toBe(path.basename(createdWorkspace.workspaceId)); + expect(branchInfo.currentBranch).toBe(path.basename(createdWorkspace.workspaceDirectory)); expect(branchInfo.hasAncestor(tempRepo.branchHeads.main)).toBe(true); expect(branchInfo.hasAncestor(tempRepo.branchHeads.dev)).toBe(true); } finally { diff --git a/packages/app/e2e/provider-settings-refresh.spec.ts b/packages/app/e2e/provider-settings-refresh.spec.ts index 7bf9583b5..fb11bf952 100644 --- a/packages/app/e2e/provider-settings-refresh.spec.ts +++ b/packages/app/e2e/provider-settings-refresh.spec.ts @@ -9,7 +9,6 @@ async function openMockAgentAtMobileBreakpoint(page: Page) { const session = await seedMockAgentWorkspace({ repoPrefix: "provider-sheet-stack-", title: "Provider sheet stack e2e", - initialPrompt: "Prepare provider sheet stack test agent.", }); await openAgentRoute(page, session); await expectComposerVisible(page); @@ -58,6 +57,12 @@ async function closeTopSheet(page: Page) { await page.mouse.up(); } +async function closeSheetByHeaderButton(page: Page, testId: string) { + const sheet = page.getByTestId(testId); + await sheet.getByLabel("Close", { exact: true }).click({ force: true }); + await expect(sheet).not.toBeVisible({ timeout: 10_000 }); +} + async function expectProviderSettingsVisible(page: Page) { await expect(page.getByTestId("provider-settings-sheet")).toBeVisible({ timeout: 10_000 }); await expect(page.getByRole("button", { name: "Add model" })).toBeVisible(); @@ -69,14 +74,15 @@ async function exerciseProviderSettingsStack(page: Page) { await page.getByRole("button", { name: "Add model" }).click(); await expect(page.getByTestId("add-custom-model-sheet")).toBeVisible({ timeout: 10_000 }); - await closeTopSheet(page); + await closeSheetByHeaderButton(page, "add-custom-model-sheet"); + await expect(page.getByPlaceholder("e.g. openai/gpt-5")).not.toBeVisible({ timeout: 10_000 }); await expectProviderSettingsVisible(page); await page.getByRole("button", { name: "Diagnostic", exact: true }).click(); await expect(page.getByTestId("provider-diagnostic-sheet")).toBeVisible({ timeout: 10_000 }); await page.getByRole("button", { name: /Refresh diagnostic/ }).click(); await expect(page.getByTestId("provider-diagnostic-sheet")).toBeVisible({ timeout: 10_000 }); - await closeTopSheet(page); + await closeSheetByHeaderButton(page, "provider-diagnostic-sheet"); await expectProviderSettingsVisible(page); await page.getByRole("button", { name: "Refresh", exact: true }).click(); @@ -87,18 +93,20 @@ test.describe("provider settings bottom-sheet stack", () => { test("provider settings and children close back through the model selector stack", async ({ page, }) => { + test.setTimeout(180_000); + const session = await openMockAgentAtMobileBreakpoint(page); try { await openProviderSettingsFromModelSelector(page); await exerciseProviderSettingsStack(page); - await closeTopSheet(page); + await closeSheetByHeaderButton(page, "provider-settings-sheet"); await expectModelSelectorVisible(page); await page.getByRole("button", { name: /Open .* settings/ }).click(); await expect(page.getByTestId("provider-settings-sheet")).toBeVisible({ timeout: 10_000 }); await exerciseProviderSettingsStack(page); - await closeTopSheet(page); + await closeSheetByHeaderButton(page, "provider-settings-sheet"); await expectModelSelectorVisible(page); await closeTopSheet(page); diff --git a/packages/app/e2e/settings-toggle-tab-regression.spec.ts b/packages/app/e2e/settings-toggle-tab-regression.spec.ts index 6edd3f1a5..259ec758e 100644 --- a/packages/app/e2e/settings-toggle-tab-regression.spec.ts +++ b/packages/app/e2e/settings-toggle-tab-regression.spec.ts @@ -74,7 +74,7 @@ test.describe("Settings toggle tab regression", () => { await expectSendBehavior(page, "interrupt"); await pressSettingsToggleShortcut(page); - await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, workspace.repoPath)); + await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, workspace.workspaceId)); await waitForTabBar(page); await expectAgentTabActive(page, secondAgent.id); @@ -105,13 +105,13 @@ test.describe("Settings toggle tab regression", () => { await openAgentRouteAndExpectFocused({ page, serverId, - workspaceId: workspace.repoPath, + workspaceId: workspace.workspaceId, agentId: firstAgent.id, }); await openAgentRouteAndExpectFocused({ page, serverId, - workspaceId: workspace.repoPath, + workspaceId: workspace.workspaceId, agentId: secondAgent.id, }); diff --git a/packages/app/e2e/workspace-agent-tab-rename.spec.ts b/packages/app/e2e/workspace-agent-tab-rename.spec.ts index 7e8185d19..b1d178371 100644 --- a/packages/app/e2e/workspace-agent-tab-rename.spec.ts +++ b/packages/app/e2e/workspace-agent-tab-rename.spec.ts @@ -7,8 +7,8 @@ import { buildHostAgentDetailRoute } from "@/utils/host-routes"; import { captureWsSessionFrames, renameModalInput, renameModalSubmit } from "./helpers/rename"; import { getServerId } from "./helpers/server-id"; -async function openAgentInWorkspace(page: Page, agent: { id: string; cwd: string }) { - await page.goto(buildHostAgentDetailRoute(getServerId(), agent.id, agent.cwd)); +async function openAgentInWorkspace(page: Page, agent: { id: string; workspaceId: string }) { + await page.goto(buildHostAgentDetailRoute(getServerId(), agent.id, agent.workspaceId)); await page.waitForURL( (url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"), { timeout: 60_000 }, diff --git a/packages/app/e2e/workspace-navigation-regression.spec.ts b/packages/app/e2e/workspace-navigation-regression.spec.ts index 998e816d4..20581687c 100644 --- a/packages/app/e2e/workspace-navigation-regression.spec.ts +++ b/packages/app/e2e/workspace-navigation-regression.spec.ts @@ -92,7 +92,7 @@ async function openWorkspaceThroughApp( await switchWorkspaceViaSidebar({ page, serverId: input.serverId, - targetWorkspacePath: input.workspace.workspaceId, + workspaceId: input.workspace.workspaceId, }); await waitForWorkspaceTabsVisible(page); await expectWorkspaceLocation(page, input); @@ -209,7 +209,7 @@ test.describe("Workspace navigation regression", () => { await gotoAppShell(page); await waitForSidebarHydration(page); - await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.cwd)); + await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.workspaceId)); await page.waitForURL( (url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"), { timeout: 60_000 }, @@ -365,7 +365,7 @@ test.describe("Workspace navigation regression", () => { await switchWorkspaceViaSidebar({ page, serverId, - targetWorkspacePath: firstWorkspace.workspaceId, + workspaceId: firstWorkspace.workspaceId, }); await waitForWorkspaceTabsVisible(page); await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId), { @@ -397,7 +397,7 @@ test.describe("Workspace navigation regression", () => { await switchWorkspaceViaSidebar({ page, serverId, - targetWorkspacePath: secondWorkspace.workspaceId, + workspaceId: secondWorkspace.workspaceId, }); await waitForWorkspaceTabsVisible(page); await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, secondWorkspace.workspaceId), { @@ -461,7 +461,7 @@ test.describe("Workspace navigation regression", () => { await switchWorkspaceViaSidebar({ page, serverId, - targetWorkspacePath: firstWorkspace.workspaceId, + workspaceId: firstWorkspace.workspaceId, }); await waitForWorkspaceTabsVisible(page); await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId), { diff --git a/packages/app/e2e/workspace-pane-remount.spec.ts b/packages/app/e2e/workspace-pane-remount.spec.ts index 6762cdb1f..552d2498b 100644 --- a/packages/app/e2e/workspace-pane-remount.spec.ts +++ b/packages/app/e2e/workspace-pane-remount.spec.ts @@ -21,7 +21,7 @@ test.describe("Workspace pane mounting", () => { title: `pane-remount-${Date.now()}`, }); - await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.cwd)); + await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.workspaceId)); await page.waitForURL( (url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"), { timeout: 60_000 }, diff --git a/packages/app/e2e/worktree-archive.spec.ts b/packages/app/e2e/worktree-archive.spec.ts new file mode 100644 index 000000000..b61e90cfe --- /dev/null +++ b/packages/app/e2e/worktree-archive.spec.ts @@ -0,0 +1,59 @@ +import { existsSync } from "node:fs"; +import { expect, test } from "./fixtures"; +import { gotoAppShell } from "./helpers/app"; +import { + archiveWorkspaceFromDaemon, + connectNewWorkspaceDaemonClient, + createWorktreeViaDaemon, + openProjectViaDaemon, +} from "./helpers/new-workspace"; +import { getServerId } from "./helpers/server-id"; +import { archiveWorktreeFromSidebar, expectWorkspaceAbsentFromSidebar } from "./helpers/sidebar"; +import { createTempGitRepo } from "./helpers/workspace"; +import { waitForSidebarHydration, waitForWorkspaceInSidebar } from "./helpers/workspace-ui"; + +test.describe("Worktree archive", () => { + let client: Awaited>; + let tempRepo: { path: string; cleanup: () => Promise }; + const createdWorktreeDirectories = new Set(); + + test.describe.configure({ retries: 1, timeout: 120_000 }); + + test.beforeEach(async () => { + client = await connectNewWorkspaceDaemonClient(); + tempRepo = await createTempGitRepo("wt-archive-"); + }); + + test.afterEach(async () => { + for (const directory of createdWorktreeDirectories) { + await archiveWorkspaceFromDaemon(client, directory).catch(() => undefined); + } + createdWorktreeDirectories.clear(); + await client?.close().catch(() => undefined); + await tempRepo?.cleanup().catch(() => undefined); + }); + + test("archiving a worktree from the sidebar removes its row and worktree directory", async ({ + page, + }) => { + const serverId = getServerId(); + await openProjectViaDaemon(client, tempRepo.path); + const worktree = await createWorktreeViaDaemon(client, { + cwd: tempRepo.path, + slug: `archive-${Date.now()}`, + }); + createdWorktreeDirectories.add(worktree.workspaceDirectory); + expect(existsSync(worktree.workspaceDirectory)).toBe(true); + + await gotoAppShell(page); + await waitForSidebarHydration(page); + await waitForWorkspaceInSidebar(page, { serverId, workspaceId: worktree.workspaceId }); + + await archiveWorktreeFromSidebar(page, worktree.workspaceId); + + await expectWorkspaceAbsentFromSidebar(page, worktree.workspaceId); + await expect + .poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 }) + .toBe(false); + }); +}); diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index 7461b81c1..db042bbde 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -75,7 +75,7 @@ import { type OpenFileDisposition, type WorkspaceFileOpenRequest, } from "@/workspace/file-open"; -import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution"; +import { resolveWorkspaceIdByDirectory } from "@/utils/workspace-identity"; import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation"; import { useStableEvent } from "@/hooks/use-stable-event"; import { isWeb } from "@/constants/platform"; @@ -276,7 +276,7 @@ const AgentStreamViewComponent = forwardRef @@ -1580,7 +1577,7 @@ function WorkspaceRowWithMenu({ } let archiveDirectory: string; try { - archiveDirectory = requireWorkspaceExecutionDirectory({ + archiveDirectory = requireWorkspaceDirectory({ workspaceId: workspace.workspaceId, workspaceDirectory: workspace.workspaceDirectory, }); @@ -1660,7 +1657,7 @@ function WorkspaceRowWithMenu({ const handleCopyPath = useCallback(() => { let copyTargetDirectory: string; try { - copyTargetDirectory = requireWorkspaceExecutionDirectory({ + copyTargetDirectory = requireWorkspaceDirectory({ workspaceId: workspace.workspaceId, workspaceDirectory: workspace.workspaceDirectory, }); @@ -1687,7 +1684,7 @@ function WorkspaceRowWithMenu({ if (!client) { throw new Error(t("sidebar.workspace.toasts.hostDisconnected")); } - const targetCwd = requireWorkspaceExecutionDirectory({ + const targetCwd = requireWorkspaceDirectory({ workspaceId: workspace.workspaceId, workspaceDirectory: workspace.workspaceDirectory, }); diff --git a/packages/app/src/components/sidebar/sidebar-status-list.tsx b/packages/app/src/components/sidebar/sidebar-status-list.tsx index c861fdc74..1f9122346 100644 --- a/packages/app/src/components/sidebar/sidebar-status-list.tsx +++ b/packages/app/src/components/sidebar/sidebar-status-list.tsx @@ -40,10 +40,7 @@ import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys"; import { slugify, validateBranchSlug, MAX_SLUG_LENGTH } from "@getpaseo/protocol/branch-slug"; import { AdaptiveRenameModal } from "@/components/rename-modal"; -import { - requireWorkspaceExecutionDirectory, - resolveWorkspaceExecutionDirectory, -} from "@/utils/workspace-execution"; +import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "@/utils/workspace-directory"; import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect"; import { archiveWorkspaceOptimistically } from "@/workspace/workspace-archive"; import { useCheckoutGitActionsStore } from "@/git/actions-store"; @@ -355,7 +352,7 @@ function StatusWorkspaceRowWithMenu({ const queryClient = useQueryClient(); const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false); const [isRenameOpen, setIsRenameOpen] = useState(false); - const workspaceDirectory = resolveWorkspaceExecutionDirectory({ + const workspaceDirectory = resolveWorkspaceDirectory({ workspaceDirectory: workspace.workspaceDirectory, }); const archiveStatus = useCheckoutGitActionsStore((state) => @@ -391,7 +388,7 @@ function StatusWorkspaceRowWithMenu({ if (!confirmed) return; let archiveDirectory: string; try { - archiveDirectory = requireWorkspaceExecutionDirectory({ + archiveDirectory = requireWorkspaceDirectory({ workspaceId: workspace.workspaceId, workspaceDirectory: workspace.workspaceDirectory, }); @@ -441,7 +438,7 @@ function StatusWorkspaceRowWithMenu({ const handleCopyPath = useCallback(() => { let copyTargetDirectory: string; try { - copyTargetDirectory = requireWorkspaceExecutionDirectory({ + copyTargetDirectory = requireWorkspaceDirectory({ workspaceId: workspace.workspaceId, workspaceDirectory: workspace.workspaceDirectory, }); @@ -462,7 +459,7 @@ function StatusWorkspaceRowWithMenu({ mutationFn: async (branch: string) => { const client = getHostRuntimeStore().getClient(workspace.serverId); if (!client) throw new Error(t("workspace.terminal.hostDisconnected")); - const targetCwd = requireWorkspaceExecutionDirectory({ + const targetCwd = requireWorkspaceDirectory({ workspaceId: workspace.workspaceId, workspaceDirectory: workspace.workspaceDirectory, }); diff --git a/packages/app/src/components/workspace-setup-dialog.tsx b/packages/app/src/components/workspace-setup-dialog.tsx index e999aed87..a7b7d2d73 100644 --- a/packages/app/src/components/workspace-setup-dialog.tsx +++ b/packages/app/src/components/workspace-setup-dialog.tsx @@ -22,7 +22,7 @@ import type { DaemonClient, } from "@getpaseo/client/internal/daemon-client"; import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name"; -import { requireWorkspaceExecutionAuthority } from "@/utils/workspace-execution"; +import { requireWorkspaceDirectory } from "@/utils/workspace-directory"; import { navigateToAgent } from "@/utils/navigate-to-agent"; import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation"; import type { ImageAttachment, MessagePayload } from "@/composer/types"; @@ -300,9 +300,10 @@ export function WorkspaceSetupDialog() { const wirePayload = splitComposerAttachmentsForSubmit(attachments); const encodedImages = await encodeImages(wirePayload.images); - const workspaceDirectory = requireWorkspaceExecutionAuthority({ - workspace: ensuredWorkspace, - }).workspaceDirectory; + const workspaceDirectory = requireWorkspaceDirectory({ + workspaceId: ensuredWorkspace.id, + workspaceDirectory: ensuredWorkspace.workspaceDirectory, + }); const agent = await connectedClient.createAgent( buildCreateAgentOptions({ composerState, diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index 461dbcaa4..49ed69218 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -23,7 +23,7 @@ import { buildDraftStoreKey } from "@/stores/draft-keys"; import { usePanelStore } from "@/stores/panel-store"; import { useCreateFlowStore } from "@/stores/create-flow-store"; import type { Agent } from "@/stores/session-store"; -import { useWorkspaceExecutionAuthority } from "@/stores/session-store-hooks"; +import { useWorkspace } from "@/stores/session-store-hooks"; import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store"; import { encodeImages } from "@/utils/encode-images"; import type { WorkspaceFileOpenRequest } from "@/workspace/file-open"; @@ -121,7 +121,7 @@ async function submitDraftCreateRequest(input: { attachments?: unknown; client: DaemonClient | null; workspaceDirectory: string | null; - workspaceExecutionAuthority: { workspaceId: string } | null; + workspaceId: string | null; autoSubmitConfig: AutoSubmitConfig | null; composerState: { selectedProvider: string | null; @@ -141,13 +141,13 @@ async function submitDraftCreateRequest(input: { attachments, client, workspaceDirectory, - workspaceExecutionAuthority, + workspaceId, autoSubmitConfig, composerState, } = input; invariant(workspaceDirectory, "Workspace directory is required"); - invariant(workspaceExecutionAuthority, "Workspace authority is required"); + invariant(workspaceId, "Workspace id is required"); if (!client) { throw new Error(input.hostDisconnectedMessage); } @@ -175,7 +175,7 @@ async function submitDraftCreateRequest(input: { const attachmentsArray = Array.isArray(attachments) ? attachments : undefined; const result = await client.createAgent({ config, - workspaceId: workspaceExecutionAuthority.workspaceId, + workspaceId, ...(text ? { initialPrompt: text } : {}), clientMessageId: attempt.clientMessageId, ...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}), @@ -317,9 +317,8 @@ export function WorkspaceDraftAgentTab({ const insets = useSafeAreaInsets(); const client = useHostRuntimeClient(serverId); const isConnected = useHostRuntimeIsConnected(serverId); - const workspaceAuthority = useWorkspaceExecutionAuthority(serverId, workspaceId); - const workspaceExecutionAuthority = workspaceAuthority?.ok ? workspaceAuthority.authority : null; - const workspaceDirectory = workspaceExecutionAuthority?.workspaceDirectory ?? null; + const workspace = useWorkspace(serverId, workspaceId); + const workspaceDirectory = workspace?.workspaceDirectory || null; const draftSetup = initialSetup ?? null; const draftWorkingDirectory = resolveDraftWorkingDirectory({ workspaceDirectory, @@ -471,7 +470,7 @@ export function WorkspaceDraftAgentTab({ attachments, client, workspaceDirectory: draftWorkingDirectory, - workspaceExecutionAuthority, + workspaceId: workspace?.id ?? null, autoSubmitConfig, composerState, hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"), diff --git a/packages/app/src/contexts/session-workspace-scripts.ts b/packages/app/src/contexts/session-workspace-scripts.ts index d43f6697c..30d600ec9 100644 --- a/packages/app/src/contexts/session-workspace-scripts.ts +++ b/packages/app/src/contexts/session-workspace-scripts.ts @@ -1,7 +1,7 @@ import equal from "fast-deep-equal"; import type { ScriptStatusUpdateMessage } from "@getpaseo/protocol/messages"; import type { WorkspaceDescriptor } from "@/stores/session-store"; -import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-execution"; +import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity"; export function patchWorkspaceScripts( workspaces: Map, diff --git a/packages/app/src/git/actions-store.test.ts b/packages/app/src/git/actions-store.test.ts index f13cc4341..b7e120ed9 100644 --- a/packages/app/src/git/actions-store.test.ts +++ b/packages/app/src/git/actions-store.test.ts @@ -37,7 +37,7 @@ function workspace(input: Partial & Pick & Pick { const serverId = "server-1"; - const cwd = "/tmp/repo"; + const cwd = "/tmp/repo/worktrees/feature"; + const workspaceId = "ws-feature"; beforeEach(() => { vi.useFakeTimers(); __resetCheckoutGitActionsStoreForTests(); - clearWorkspaceArchivePending({ serverId, workspaceId: cwd }); - clearWorkspaceArchivePending({ serverId, workspaceId: "ws-feature" }); + clearWorkspaceArchivePending({ serverId, workspaceId }); appQueryClient.clear(); useSessionStore.setState((state) => ({ ...state, sessions: {} })); }); @@ -65,8 +65,7 @@ describe("checkout-git-actions-store", () => { afterEach(() => { vi.useRealTimers(); __resetCheckoutGitActionsStoreForTests(); - clearWorkspaceArchivePending({ serverId, workspaceId: cwd }); - clearWorkspaceArchivePending({ serverId, workspaceId: "ws-feature" }); + clearWorkspaceArchivePending({ serverId, workspaceId }); appQueryClient.clear(); useSessionStore.setState((state) => ({ ...state, sessions: {} })); }); @@ -301,9 +300,13 @@ describe("checkout-git-actions-store", () => { const client = { archivePaseoWorktree: vi.fn(() => deferred.promise), }; - const featureWorkspace = workspace({ id: cwd, name: "feature" }); + const featureWorkspace = workspace({ + id: workspaceId, + name: "feature", + workspaceDirectory: cwd, + }); useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient); - useSessionStore.getState().setWorkspaces(serverId, new Map([[cwd, featureWorkspace]])); + useSessionStore.getState().setWorkspaces(serverId, new Map([[workspaceId, featureWorkspace]])); appQueryClient.setQueryData( ["sidebarPaseoWorktreeList", serverId, "/tmp"], [{ worktreePath: cwd }, { worktreePath: "/tmp/other" }], @@ -313,6 +316,7 @@ describe("checkout-git-actions-store", () => { .getState() .archiveWorktree({ serverId, cwd, worktreePath: cwd }); + expect(useSessionStore.getState().sessions[serverId]?.workspaces.has(workspaceId)).toBe(false); expect(useSessionStore.getState().sessions[serverId]?.workspaces.has(cwd)).toBe(false); expect(appQueryClient.getQueryData(["sidebarPaseoWorktreeList", serverId, "/tmp"])).toEqual([ { worktreePath: "/tmp/other" }, @@ -325,49 +329,52 @@ describe("checkout-git-actions-store", () => { expect( isWorkspaceArchivePending({ serverId, - workspaceId: cwd, + workspaceId, }), ).toBe(true); + expect( + isWorkspaceArchivePending({ + serverId, + workspaceId: cwd, + }), + ).toBe(false); }); - it("hides an archived worktree when the workspace map is keyed by opaque id", async () => { - const deferred = createDeferred>(); + it("archives on the server even when its workspace cannot be resolved", async () => { const client = { - archivePaseoWorktree: vi.fn(() => deferred.promise), + archivePaseoWorktree: vi.fn(async () => ({})), }; - const featureWorkspace = workspace({ - id: "ws-feature", - name: "feature", - workspaceDirectory: cwd, - }); useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient); - useSessionStore.getState().setWorkspaces(serverId, new Map([["ws-feature", featureWorkspace]])); - const archive = useCheckoutGitActionsStore + await useCheckoutGitActionsStore .getState() .archiveWorktree({ serverId, cwd, worktreePath: cwd }); - expect(useSessionStore.getState().sessions[serverId]?.workspaces.has("ws-feature")).toBe(false); - - deferred.resolve({}); - await archive; + // The server archive is keyed by worktreePath and must run regardless. + expect(client.archivePaseoWorktree).toHaveBeenCalledWith({ worktreePath: cwd }); + // The optimistic client-side mark is never keyed by the path. + expect(isWorkspaceArchivePending({ serverId, workspaceId: cwd })).toBe(false); }); it("restores an optimistically hidden worktree when archive fails", async () => { const client = { archivePaseoWorktree: vi.fn(async () => ({ error: { message: "archive failed" } })), }; - const featureWorkspace = workspace({ id: cwd, name: "feature" }); + const featureWorkspace = workspace({ + id: workspaceId, + name: "feature", + workspaceDirectory: cwd, + }); const listSnapshot = [{ worktreePath: cwd }, { worktreePath: "/tmp/other" }]; useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient); - useSessionStore.getState().setWorkspaces(serverId, new Map([[cwd, featureWorkspace]])); + useSessionStore.getState().setWorkspaces(serverId, new Map([[workspaceId, featureWorkspace]])); appQueryClient.setQueryData(["sidebarPaseoWorktreeList", serverId, "/tmp"], listSnapshot); await expect( useCheckoutGitActionsStore.getState().archiveWorktree({ serverId, cwd, worktreePath: cwd }), ).rejects.toThrow("archive failed"); - expect(useSessionStore.getState().sessions[serverId]?.workspaces.get(cwd)).toEqual( + expect(useSessionStore.getState().sessions[serverId]?.workspaces.get(workspaceId)).toEqual( featureWorkspace, ); expect(appQueryClient.getQueryData(["sidebarPaseoWorktreeList", serverId, "/tmp"])).toEqual( @@ -380,9 +387,13 @@ describe("checkout-git-actions-store", () => { const client = { archivePaseoWorktree: vi.fn(() => deferred.promise), }; - const featureWorkspace = workspace({ id: cwd, name: "feature" }); + const featureWorkspace = workspace({ + id: workspaceId, + name: "feature", + workspaceDirectory: cwd, + }); useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient); - useSessionStore.getState().setWorkspaces(serverId, new Map([[cwd, featureWorkspace]])); + useSessionStore.getState().setWorkspaces(serverId, new Map([[workspaceId, featureWorkspace]])); const archive = useCheckoutGitActionsStore .getState() diff --git a/packages/app/src/git/actions-store.ts b/packages/app/src/git/actions-store.ts index 3f3a094c7..34d007199 100644 --- a/packages/app/src/git/actions-store.ts +++ b/packages/app/src/git/actions-store.ts @@ -14,9 +14,9 @@ import { markWorkspaceArchivePending, } from "@/contexts/session-workspace-upserts"; import { - resolveWorkspaceIdByExecutionDirectory, + resolveWorkspaceIdByDirectory, resolveWorkspaceMapKeyByIdentity, -} from "@/utils/workspace-execution"; +} from "@/utils/workspace-identity"; import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys"; import { i18n } from "@/i18n/i18next"; @@ -158,12 +158,13 @@ function snapshotWorktreeArchiveState(input: { worktreePath: string; }): WorktreeArchiveSnapshot { const workspaces = useSessionStore.getState().sessions[input.serverId]?.workspaces; - const workspaceId = - resolveWorkspaceIdByExecutionDirectory({ - workspaces: workspaces?.values(), - workspaceDirectory: input.worktreePath, - }) ?? input.worktreePath; - const workspaceKey = resolveWorkspaceMapKeyByIdentity({ workspaces, workspaceId }); + const workspaceId = resolveWorkspaceIdByDirectory({ + workspaces: workspaces?.values(), + workspaceDirectory: input.worktreePath, + }); + const workspaceKey = workspaceId + ? resolveWorkspaceMapKeyByIdentity({ workspaces, workspaceId }) + : null; return { workspace: workspaceKey ? (workspaces?.get(workspaceKey) ?? null) : null, worktreeLists: appQueryClient.getQueriesData({ @@ -173,13 +174,13 @@ function snapshotWorktreeArchiveState(input: { }; } -function removeWorktreeFromSessionStore(input: { serverId: string; worktreePath: string }): void { +function removeWorktreeFromSessionStore(input: { serverId: string; workspaceId: string }): void { const serverId = input.serverId.trim(); - const worktreePath = input.worktreePath.trim(); - if (!serverId || !worktreePath) { + const workspaceId = input.workspaceId.trim(); + if (!serverId || !workspaceId) { return; } - useSessionStore.getState().removeWorkspace(serverId, worktreePath); + useSessionStore.getState().removeWorkspace(serverId, workspaceId); } function restoreWorktreeArchiveState(input: { @@ -195,9 +196,9 @@ function restoreWorktreeArchiveState(input: { } } -function purgeArchivedWorkspaceState(input: { serverId: string; worktreePath: string }): void { +function purgeArchivedWorkspaceState(input: { serverId: string; workspaceId: string }): void { const serverId = input.serverId.trim(); - const workspaceId = input.worktreePath.trim(); + const workspaceId = input.workspaceId.trim(); if (!serverId || !workspaceId) { return; } @@ -504,31 +505,35 @@ export const useCheckoutGitActionsStore = create() run: async () => { const client = resolveClient(serverId); const snapshot = snapshotWorktreeArchiveState({ serverId, worktreePath }); - markWorkspaceArchivePending({ - serverId, - workspaceId: snapshot.workspace?.id ?? worktreePath, - workspaceDirectory: snapshot.workspace?.workspaceDirectory ?? worktreePath, - }); + // The server archive is keyed by worktreePath and must always run. The + // optimistic client-side updates are keyed by workspace id, so they only + // apply when the workspace is resolved in the local store. + const workspace = snapshot.workspace; + if (workspace) { + markWorkspaceArchivePending({ + serverId, + workspaceId: workspace.id, + workspaceDirectory: workspace.workspaceDirectory, + }); + removeWorktreeFromSessionStore({ serverId, workspaceId: workspace.id }); + } removeWorktreeFromCachedLists({ serverId, worktreePath }); - removeWorktreeFromSessionStore({ - serverId, - worktreePath: snapshot.workspace?.id ?? worktreePath, - }); try { const payload = await client.archivePaseoWorktree({ worktreePath }); if (payload.error) { throw new Error(payload.error.message); } } catch (error) { - clearWorkspaceArchivePending({ - serverId, - workspaceId: snapshot.workspace?.id ?? worktreePath, - }); + if (workspace) { + clearWorkspaceArchivePending({ serverId, workspaceId: workspace.id }); + } restoreWorktreeArchiveState({ serverId, snapshot }); throw error; } invalidateWorktreeList(); - purgeArchivedWorkspaceState({ serverId, worktreePath }); + if (workspace) { + purgeArchivedWorkspaceState({ serverId, workspaceId: workspace.id }); + } }, }); }, diff --git a/packages/app/src/git/branch-switcher-operations.test.ts b/packages/app/src/git/branch-switcher-operations.test.ts new file mode 100644 index 000000000..618550d62 --- /dev/null +++ b/packages/app/src/git/branch-switcher-operations.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { createBranchSwitcherOperations } from "./branch-switcher-operations"; + +function createRecordingClient() { + const cwds: string[] = []; + const client = { + getBranchSuggestions: async (options: { cwd: string; limit?: number }) => { + cwds.push(options.cwd); + return { branches: [], error: null }; + }, + stashList: async (cwd: string) => { + cwds.push(cwd); + return { entries: [] }; + }, + stashSave: async (cwd: string) => { + cwds.push(cwd); + return { error: null }; + }, + stashPop: async (cwd: string) => { + cwds.push(cwd); + return { error: null }; + }, + checkoutSwitchBranch: async (cwd: string) => { + cwds.push(cwd); + return { error: null }; + }, + } as unknown as DaemonClient; + return { client, cwds }; +} + +describe("createBranchSwitcherOperations", () => { + it("sends the workspace directory as cwd to every git operation, never the workspace id", async () => { + const workspaceDirectory = "/Users/dev/project"; + const workspaceId = "wks_3f9a2b1c"; + const { client, cwds } = createRecordingClient(); + + const operations = createBranchSwitcherOperations(client, workspaceDirectory); + await operations.getBranchSuggestions(200); + await operations.listPaseoStashes(); + await operations.saveStash("main"); + await operations.popStash(0); + await operations.switchBranch("feature"); + + expect(cwds).toEqual([ + workspaceDirectory, + workspaceDirectory, + workspaceDirectory, + workspaceDirectory, + workspaceDirectory, + ]); + expect(cwds).not.toContain(workspaceId); + }); +}); diff --git a/packages/app/src/git/branch-switcher-operations.ts b/packages/app/src/git/branch-switcher-operations.ts new file mode 100644 index 000000000..ad3ba2154 --- /dev/null +++ b/packages/app/src/git/branch-switcher-operations.ts @@ -0,0 +1,16 @@ +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; + +// Binds the branch switcher's git operations to a single workspace directory, so a +// workspace id can never be passed where a cwd is expected. `cwd` is set once here; +// callers choose the operation, never the directory. +export function createBranchSwitcherOperations(client: DaemonClient, cwd: string) { + return { + getBranchSuggestions: (limit: number) => client.getBranchSuggestions({ cwd, limit }), + listPaseoStashes: () => client.stashList(cwd, { paseoOnly: true }), + saveStash: (branch: string | undefined) => client.stashSave(cwd, { branch }), + popStash: (stashIndex: number) => client.stashPop(cwd, stashIndex), + switchBranch: (branch: string) => client.checkoutSwitchBranch(cwd, branch), + }; +} + +export type BranchSwitcherOperations = ReturnType; diff --git a/packages/app/src/git/use-actions.ts b/packages/app/src/git/use-actions.ts index 445e1d08b..a182661b3 100644 --- a/packages/app/src/git/use-actions.ts +++ b/packages/app/src/git/use-actions.ts @@ -15,8 +15,9 @@ import type { CheckoutPrMergeMethod } from "@getpaseo/protocol/messages"; import { openExternalUrl } from "@/utils/open-external-url"; import { useToast } from "@/contexts/toast-context"; import { useSessionStore } from "@/stores/session-store"; -import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution"; +import { resolveWorkspaceIdByDirectory } from "@/utils/workspace-identity"; import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation"; +import { buildHostRootRoute } from "@/utils/host-routes"; import { confirmRiskyWorktreeArchive, type WorktreeArchiveWarningLabels, @@ -495,18 +496,18 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use return; } - const archivedWorkspaceId = - resolveWorkspaceIdByExecutionDirectory({ - workspaces: workspaceList, - workspaceDirectory: worktreePath, - }) ?? worktreePath; - router.replace( - buildWorkspaceArchiveRedirectRoute({ - serverId, - archivedWorkspaceId, - workspaces: workspaceList, - }) as Href, - ); + const archivedWorkspaceId = resolveWorkspaceIdByDirectory({ + workspaces: workspaceList, + workspaceDirectory: worktreePath, + }); + const redirectRoute = archivedWorkspaceId + ? buildWorkspaceArchiveRedirectRoute({ + serverId, + archivedWorkspaceId, + workspaces: workspaceList, + }) + : buildHostRootRoute(serverId); + router.replace(redirectRoute as Href); void runArchiveWorktree({ serverId, cwd, worktreePath }).catch((err) => { toastActionError(err, t("workspace.git.actions.toasts.failedArchive")); }); diff --git a/packages/app/src/hooks/sidebar-workspaces-view-model.ts b/packages/app/src/hooks/sidebar-workspaces-view-model.ts index 3bac1196d..320eb2729 100644 --- a/packages/app/src/hooks/sidebar-workspaces-view-model.ts +++ b/packages/app/src/hooks/sidebar-workspaces-view-model.ts @@ -8,6 +8,12 @@ import type { WorkspaceStructureProject } from "@/projects/workspace-structure"; const EMPTY_PROJECTS: SidebarProjectEntry[] = []; +function workspaceNameFromDirectory(directory: string): string { + const trimmed = directory.trim().replace(/[\\/]+$/g, ""); + const separator = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\")); + return separator >= 0 ? trimmed.slice(separator + 1) : trimmed; +} + export type SidebarStateBucket = WorkspaceDescriptor["status"]; export interface SidebarWorkspaceEntry { @@ -54,7 +60,7 @@ function createStructuralWorkspaceEntry(input: { workspaceDirectory: undefined, projectKind: input.project.projectKind, workspaceKind: "checkout", - name: input.workspaceId, + name: workspaceNameFromDirectory(input.project.iconWorkingDir) || input.workspaceId, statusBucket: "done", statusEnteredAt: null, archivingAt: null, diff --git a/packages/app/src/hooks/use-branch-switcher.ts b/packages/app/src/hooks/use-branch-switcher.ts index 9fe9c1acf..e8e33b7f2 100644 --- a/packages/app/src/hooks/use-branch-switcher.ts +++ b/packages/app/src/hooks/use-branch-switcher.ts @@ -5,12 +5,14 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { ComboboxOption } from "@/components/ui/combobox"; import type { ToastApi } from "@/components/toast-host"; import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys"; +import { createBranchSwitcherOperations } from "@/git/branch-switcher-operations"; import { confirmDialog } from "@/utils/confirm-dialog"; interface UseBranchSwitcherInput { client: DaemonClient | null; normalizedServerId: string; normalizedWorkspaceId: string; + workspaceDirectory: string | null; currentBranchName: string | null; isGitCheckout: boolean; isConnected: boolean; @@ -30,6 +32,7 @@ export function useBranchSwitcher({ client, normalizedServerId, normalizedWorkspaceId, + workspaceDirectory, currentBranchName, isGitCheckout, isConnected, @@ -39,22 +42,29 @@ export function useBranchSwitcher({ const { t } = useTranslation(); const [isOpen, setIsOpen] = useState(false); + // Git operations are bound to the workspace directory; the opaque workspace id is + // used only for query cache identity below, never as a cwd. + const operations = useMemo( + () => + client && workspaceDirectory + ? createBranchSwitcherOperations(client, workspaceDirectory) + : null, + [client, workspaceDirectory], + ); + const branchSuggestionsQuery = useQuery({ queryKey: ["branchSuggestions", normalizedServerId, normalizedWorkspaceId], queryFn: async () => { - if (!client) { + if (!operations) { throw new Error(t("common.errors.daemonClientUnavailable")); } - const payload = await client.getBranchSuggestions({ - cwd: normalizedWorkspaceId, - limit: 200, - }); + const payload = await operations.getBranchSuggestions(200); if (payload.error) { throw new Error(payload.error); } return payload.branches ?? []; }, - enabled: isOpen && isGitCheckout && Boolean(client) && isConnected, + enabled: isOpen && isGitCheckout && Boolean(operations) && isConnected, retry: false, staleTime: 15_000, }); @@ -70,20 +80,21 @@ export function useBranchSwitcher({ ); const invalidateStashAndCheckout = useCallback(async () => { + if (!workspaceDirectory) return; await Promise.all([ queryClient.invalidateQueries({ queryKey: stashListQueryKey }), invalidateCheckoutGitQueriesForClient(queryClient, { serverId: normalizedServerId, - cwd: normalizedWorkspaceId, + cwd: workspaceDirectory, }), ]); - }, [queryClient, stashListQueryKey, normalizedServerId, normalizedWorkspaceId]); + }, [queryClient, stashListQueryKey, normalizedServerId, workspaceDirectory]); const maybeRestoreStashForBranch = useCallback( async (branchId: string) => { - if (!client) return; + if (!operations) return; try { - const stashPayload = await client.stashList(normalizedWorkspaceId, { paseoOnly: true }); + const stashPayload = await operations.listPaseoStashes(); const targetStash = stashPayload.entries.find((e) => e.branch === branchId); if (!targetStash) return; const shouldRestore = await confirmDialog({ @@ -93,7 +104,7 @@ export function useBranchSwitcher({ cancelLabel: t("branchSwitcher.later"), }); if (!shouldRestore) return; - const popPayload = await client.stashPop(normalizedWorkspaceId, targetStash.index); + const popPayload = await operations.popStash(targetStash.index); if (popPayload.error) { toast.error(popPayload.error.message); } else { @@ -104,12 +115,12 @@ export function useBranchSwitcher({ // Non-critical — user can still restore on next branch switch } }, - [client, invalidateStashAndCheckout, normalizedWorkspaceId, toast, t], + [operations, invalidateStashAndCheckout, toast, t], ); const stashAndSwitch = useCallback( async (branchId: string) => { - if (!client) return; + if (!operations) return; const shouldStash = await confirmDialog({ title: t("branchSwitcher.uncommittedTitle"), message: t("branchSwitcher.uncommittedMessage"), @@ -119,15 +130,13 @@ export function useBranchSwitcher({ if (!shouldStash) return; try { - const stashPayload = await client.stashSave(normalizedWorkspaceId, { - branch: currentBranchName ?? undefined, - }); + const stashPayload = await operations.saveStash(currentBranchName ?? undefined); if (stashPayload.error) { toast.error(stashPayload.error.message); return; } await invalidateStashAndCheckout(); - const switchPayload = await client.checkoutSwitchBranch(normalizedWorkspaceId, branchId); + const switchPayload = await operations.switchBranch(branchId); if (switchPayload.error) { toast.error(switchPayload.error.message); return; @@ -137,7 +146,7 @@ export function useBranchSwitcher({ toast.error(err instanceof Error ? err.message : t("branchSwitcher.failedToStash")); } }, - [client, currentBranchName, invalidateStashAndCheckout, normalizedWorkspaceId, toast, t], + [operations, currentBranchName, invalidateStashAndCheckout, toast, t], ); const handleBranchSelect = useCallback( @@ -145,9 +154,9 @@ export function useBranchSwitcher({ if (branchId === currentBranchName) return; void (async () => { - if (!client) return; + if (!operations) return; try { - const payload = await client.checkoutSwitchBranch(normalizedWorkspaceId, branchId); + const payload = await operations.switchBranch(branchId); if (payload.error) { // If the error is about uncommitted changes, offer the stash dialog if (payload.error.message.toLowerCase().includes("uncommitted")) { @@ -166,11 +175,10 @@ export function useBranchSwitcher({ })(); }, [ - client, + operations, currentBranchName, invalidateStashAndCheckout, maybeRestoreStashForBranch, - normalizedWorkspaceId, stashAndSwitch, t, toast, diff --git a/packages/app/src/i18n/resources.test.ts b/packages/app/src/i18n/resources.test.ts index 570256730..7283edf83 100644 --- a/packages/app/src/i18n/resources.test.ts +++ b/packages/app/src/i18n/resources.test.ts @@ -230,9 +230,7 @@ describe("translation resources", () => { expect(en.workspace.browser.unavailable.title).toBe("Browser is desktop-only"); expect(en.workspace.browser.controls.enterUrl).toBe("Enter URL"); expect(en.workspace.terminal.hostDisconnected).toBe("Host is not connected"); - expect(en.panels.file.executionDirectoryMissing).toBe( - "Workspace execution directory not found.", - ); + expect(en.panels.file.directoryMissing).toBe("Workspace directory not found."); }); it("includes workspace Git and review keys for the Batch 4B migration", () => { diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index f32c150f8..5ed79e4ee 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1292,7 +1292,7 @@ export const ar: TranslationResources = { creatingAgent: "وكيل الخلق", }, file: { - executionDirectoryMissing: "لم يتم العثور على دليل تنفيذ Workspace.", + directoryMissing: "لم يتم العثور على دليل Workspace.", loading: "جارٍ تحميل الملف...", noPreview: "لا تتوفر معاينة", binaryPreviewUnavailable: "المعاينة الثنائية غير متاحة", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 864f28b44..d2a186cf4 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1298,7 +1298,7 @@ export const en = { creatingAgent: "Creating agent", }, file: { - executionDirectoryMissing: "Workspace execution directory not found.", + directoryMissing: "Workspace directory not found.", loading: "Loading file...", noPreview: "No preview available", binaryPreviewUnavailable: "Binary preview unavailable", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 3c38068e1..b374b73b6 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1327,7 +1327,7 @@ export const es: TranslationResources = { creatingAgent: "Agente creador", }, file: { - executionDirectoryMissing: "No se encontró el directorio de ejecución deWorkspace.", + directoryMissing: "No se encontró el directorio de Workspace.", loading: "Cargando archivo...", noPreview: "No hay vista previa disponible", binaryPreviewUnavailable: "Vista previa binaria no disponible", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index fce2175ea..192a1ac65 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1330,7 +1330,7 @@ export const fr: TranslationResources = { creatingAgent: "Agent créateur", }, file: { - executionDirectoryMissing: "Répertoire d'exécutionWorkspaceintrouvable.", + directoryMissing: "Répertoire Workspace introuvable.", loading: "Chargement du fichier...", noPreview: "Aucun aperçu disponible", binaryPreviewUnavailable: "Aperçu binaire indisponible", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 8422dd4bb..84b08c222 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1319,7 +1319,7 @@ export const ru: TranslationResources = { creatingAgent: "Создание агента", }, file: { - executionDirectoryMissing: "Каталог выполнения Workspace не найден.", + directoryMissing: "Каталог Workspace не найден.", loading: "Загрузка файла...", noPreview: "Предварительный просмотр недоступен", binaryPreviewUnavailable: "Предварительный просмотр двоичного файла недоступен.", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 6316f25bf..41a74df7d 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1275,7 +1275,7 @@ export const zhCN: TranslationResources = { creatingAgent: "正在创建 Agent", }, file: { - executionDirectoryMissing: "未找到 workspace 执行目录。", + directoryMissing: "未找到 workspace 目录。", loading: "正在加载文件...", noPreview: "没有可用预览", binaryPreviewUnavailable: "二进制预览不可用", diff --git a/packages/app/src/panels/browser-panel.tsx b/packages/app/src/panels/browser-panel.tsx index 037ce487f..82ac2da9c 100644 --- a/packages/app/src/panels/browser-panel.tsx +++ b/packages/app/src/panels/browser-panel.tsx @@ -6,7 +6,7 @@ import { BrowserPane } from "@/components/browser-pane"; import { usePaneContext, usePaneFocus } from "@/panels/pane-context"; import type { PanelDescriptor, PanelIconProps, PanelRegistration } from "@/panels/panel-registry"; import { useBrowserStore } from "@/stores/browser-store"; -import { useWorkspaceExecutionAuthority } from "@/stores/session-store-hooks"; +import { useWorkspaceDirectory } from "@/stores/session-store-hooks"; function getBrowserLabel(input: { title: string; url: string }): string { const title = input.title.trim(); @@ -55,8 +55,7 @@ function useBrowserPanelDescriptor(target: { function BrowserPanel() { const { serverId, workspaceId, target } = usePaneContext(); const { focusPane, isInteractive } = usePaneFocus(); - const workspaceAuthority = useWorkspaceExecutionAuthority(serverId, workspaceId)!; - const cwd = workspaceAuthority.ok ? workspaceAuthority.authority.workspaceDirectory : null; + const cwd = useWorkspaceDirectory(serverId, workspaceId); invariant(target.kind === "browser", "BrowserPanel requires browser target"); return ( - {t("panels.file.executionDirectoryMissing")} + {t("panels.file.directoryMissing")} ); } diff --git a/packages/app/src/panels/terminal-panel.tsx b/packages/app/src/panels/terminal-panel.tsx index b760fac21..9056910c1 100644 --- a/packages/app/src/panels/terminal-panel.tsx +++ b/packages/app/src/panels/terminal-panel.tsx @@ -11,7 +11,7 @@ import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry import { queryClient } from "@/query/query-client"; import { usePanelStore } from "@/stores/panel-store"; import { useSessionStore } from "@/stores/session-store"; -import { useWorkspaceExecutionAuthority } from "@/stores/session-store-hooks"; +import { useWorkspace } from "@/stores/session-store-hooks"; type ListTerminalsPayload = ListTerminalsResponse["payload"]; @@ -37,21 +37,15 @@ function useTerminalPanelDescriptor( ): PanelDescriptor { const { t } = useTranslation(); const client = useSessionStore((state) => state.sessions[context.serverId]?.client ?? null); - const workspaceAuthority = useWorkspaceExecutionAuthority(context.serverId, context.workspaceId)!; - const workspaceDirectory = workspaceAuthority.ok - ? workspaceAuthority.authority.workspaceDirectory - : null; + const workspace = useWorkspace(context.serverId, context.workspaceId); + const workspaceDirectory = workspace?.workspaceDirectory || null; const terminalsQuery = useQuery( { queryKey: ["terminals", context.serverId, workspaceDirectory] as const, enabled: Boolean(client && workspaceDirectory), queryFn: async (): Promise => { if (!client || !workspaceDirectory) { - throw new Error( - workspaceAuthority.ok - ? "Workspace execution directory not found" - : workspaceAuthority.message, - ); + throw new Error("Workspace directory not found"); } return client.listTerminals(workspaceDirectory); }, @@ -76,13 +70,9 @@ function useTerminalPanelDescriptor( function TerminalPanel() { const { serverId, workspaceId, target, openFileInWorkspace } = usePaneContext(); const { isWorkspaceFocused, isPaneFocused } = usePaneFocus(); - const workspaceAuthority = useWorkspaceExecutionAuthority(serverId, workspaceId)!; - const workspaceDirectory = workspaceAuthority.ok - ? workspaceAuthority.authority.workspaceDirectory - : null; - const isGitCheckout = workspaceAuthority.ok - ? workspaceAuthority.authority.workspace.projectKind === "git" - : false; + const workspace = useWorkspace(serverId, workspaceId); + const workspaceDirectory = workspace?.workspaceDirectory || null; + const isGitCheckout = workspace?.projectKind === "git"; const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout); const handleOpenFileExplorer = useCallback(() => { if (!workspaceDirectory) { @@ -102,11 +92,7 @@ function TerminalPanel() { if (!workspaceDirectory) { return ( - - {workspaceAuthority.ok - ? "Workspace execution directory not found." - : workspaceAuthority.message} - + Workspace directory not found. ); } diff --git a/packages/app/src/review/store.ts b/packages/app/src/review/store.ts index 84c39a344..d1cc5e16b 100644 --- a/packages/app/src/review/store.ts +++ b/packages/app/src/review/store.ts @@ -96,6 +96,7 @@ function normalizeBaseRef(baseRef: string | null | undefined): string { function buildReviewDraftScopeParts(input: BuildReviewDraftScopeKeyInput): string[] { const workspaceId = input.workspaceId?.trim(); + // workspaceId is opaque; do not parse this key back into a path. const workspacePart = workspaceId ? `workspace=${encodeKeyPart(workspaceId)}` : `cwd=${encodeKeyPart(normalizeCwd(input.cwd))}`; diff --git a/packages/app/src/screens/workspace/terminals/use-workspace-terminals.ts b/packages/app/src/screens/workspace/terminals/use-workspace-terminals.ts index 3d350fb66..ca1bd6128 100644 --- a/packages/app/src/screens/workspace/terminals/use-workspace-terminals.ts +++ b/packages/app/src/screens/workspace/terminals/use-workspace-terminals.ts @@ -38,7 +38,7 @@ interface UseWorkspaceTerminalsInput { workspaceDirectory: string | null; workspaceScripts: WorkspaceDescriptor["scripts"]; hasHydratedWorkspaces: boolean; - isMissingWorkspaceExecutionAuthority: boolean; + isMissingWorkspaceDirectory: boolean; onTerminalCreated: (input: { terminalId: string; paneId?: string }) => void; onScriptTerminalSelected: (terminalId: string) => void; onWorkspacePathUnavailable: () => void; @@ -56,7 +56,7 @@ export function useWorkspaceTerminals(input: UseWorkspaceTerminalsInput) { workspaceDirectory, workspaceScripts, hasHydratedWorkspaces, - isMissingWorkspaceExecutionAuthority, + isMissingWorkspaceDirectory, onTerminalCreated, onScriptTerminalSelected, onWorkspacePathUnavailable, @@ -209,7 +209,7 @@ export function useWorkspaceTerminals(input: UseWorkspaceTerminalsInput) { return; } - if (hasHydratedWorkspaces && isMissingWorkspaceExecutionAuthority) { + if (hasHydratedWorkspaces && isMissingWorkspaceDirectory) { setPendingCreateInput(null); onWorkspacePathUnavailable(); } @@ -217,7 +217,7 @@ export function useWorkspaceTerminals(input: UseWorkspaceTerminalsInput) { canCreateNow, createMutation, hasHydratedWorkspaces, - isMissingWorkspaceExecutionAuthority, + isMissingWorkspaceDirectory, onWorkspacePathUnavailable, pendingCreateInput, ]); @@ -233,7 +233,7 @@ export function useWorkspaceTerminals(input: UseWorkspaceTerminalsInput) { return; } - if (hasHydratedWorkspaces && isMissingWorkspaceExecutionAuthority) { + if (hasHydratedWorkspaces && isMissingWorkspaceDirectory) { onWorkspacePathUnavailable(); return; } @@ -245,7 +245,7 @@ export function useWorkspaceTerminals(input: UseWorkspaceTerminalsInput) { canCreateNow, createMutation, hasHydratedWorkspaces, - isMissingWorkspaceExecutionAuthority, + isMissingWorkspaceDirectory, onTerminalCreateQueued, onWorkspacePathUnavailable, pendingCreateInput, diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 8d7986ba7..10e81944a 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -110,11 +110,7 @@ import { createWorkspaceBrowser, useBrowserStore } from "@/stores/browser-store" import { getDesktopHost } from "@/desktop/host"; import { buildProviderCommand } from "@/utils/provider-command-templates"; import { generateDraftId } from "@/stores/draft-keys"; -import { - getWorkspaceExecutionAuthority, - resolveWorkspaceRouteId, - type WorkspaceExecutionAuthorityResult, -} from "@/utils/workspace-execution"; +import { resolveWorkspaceRouteId } from "@/utils/workspace-identity"; import { WorkspaceTabPresentationResolver, WorkspaceTabIcon, @@ -170,7 +166,6 @@ import { } from "@/screens/workspace/workspace-bulk-close"; import { resolveCloseAgentTabPolicy } from "@/subagents"; import { findAdjacentPane } from "@/utils/split-navigation"; -import { isAbsolutePath } from "@/utils/path"; import { useIsCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/layout"; import { getIsElectron, isNative, isWeb } from "@/constants/platform"; import { useContainerWidthBelow } from "@/hooks/use-container-width"; @@ -966,13 +961,13 @@ function useCloseTabs(): UseCloseTabsResult { interface WorkspaceHeaderMenuProps { normalizedServerId: string; - normalizedWorkspaceId: string; currentBranchName: string | null; showWorkspaceSetup: boolean; showCreateBrowserTab: boolean; isMobile: boolean; createTerminalDisabled: boolean; importAgentDisabled: boolean; + copyPathDisabled: boolean; menuNewAgentIcon: ReactElement; menuNewTerminalIcon: ReactElement; menuNewBrowserIcon: ReactElement; @@ -1047,13 +1042,13 @@ function WorkspaceHeaderMenuTriggerIcon({ function WorkspaceHeaderMenu({ normalizedServerId, - normalizedWorkspaceId, currentBranchName, showWorkspaceSetup, showCreateBrowserTab, isMobile, createTerminalDisabled, importAgentDisabled, + copyPathDisabled, menuNewAgentIcon, menuNewTerminalIcon, menuNewBrowserIcon, @@ -1126,7 +1121,7 @@ function WorkspaceHeaderMenu({ {t("workspace.header.actions.copyPath")} @@ -1198,6 +1193,7 @@ interface WorkspaceHeaderTitleBarProps { isMobile: boolean; createTerminalDisabled: boolean; importAgentDisabled: boolean; + copyPathDisabled: boolean; menuNewAgentIcon: ReactElement; menuNewTerminalIcon: ReactElement; menuNewBrowserIcon: ReactElement; @@ -1233,6 +1229,7 @@ function WorkspaceHeaderTitleBar({ isMobile, createTerminalDisabled, importAgentDisabled, + copyPathDisabled, menuNewAgentIcon, menuNewTerminalIcon, menuNewBrowserIcon, @@ -1280,13 +1277,13 @@ function WorkspaceHeaderTitleBar({ - Workspace execution directory is missing. Reload workspace data before opening tabs. + Workspace directory is missing. Reload workspace data before opening tabs. ); @@ -1454,22 +1451,6 @@ function deriveWorkspaceHeaderFields(input: { }; } -interface WorkspaceAuthorityState { - workspaceDirectory: string | null; - isMissingWorkspaceExecutionAuthority: boolean; -} - -function resolveWorkspaceAuthorityState( - workspaceAuthority: WorkspaceExecutionAuthorityResult, - workspaceDescriptor: WorkspaceDescriptor | null | undefined, -): WorkspaceAuthorityState { - const authority = workspaceAuthority.ok ? workspaceAuthority.authority : null; - return { - workspaceDirectory: authority?.workspaceDirectory ?? null, - isMissingWorkspaceExecutionAuthority: Boolean(workspaceDescriptor && !authority), - }; -} - function getHostDisplayName(host: { label?: string | null } | null, fallback: string): string { const trimmed = host?.label?.trim(); return trimmed ? trimmed : fallback; @@ -1777,15 +1758,8 @@ function WorkspaceScreenContent({ const client = useHostRuntimeClient(normalizedServerId); const isConnected = useHostRuntimeIsConnected(normalizedServerId); - const workspaceAuthority = useMemo( - () => - getWorkspaceExecutionAuthority({ - workspace: workspaceDescriptor, - }), - [workspaceDescriptor], - ); - const { workspaceDirectory, isMissingWorkspaceExecutionAuthority } = - resolveWorkspaceAuthorityState(workspaceAuthority, workspaceDescriptor); + const workspaceDirectory = workspaceDescriptor?.workspaceDirectory || null; + const isMissingWorkspaceDirectory = Boolean(workspaceDescriptor) && !workspaceDirectory; const [isImportSheetVisible, setIsImportSheetVisible] = useState(false); const canOpenImportSheet = [client, isConnected, workspaceDirectory].every(Boolean); const openImportSheet = useCallback(() => { @@ -1870,7 +1844,7 @@ function WorkspaceScreenContent({ workspaceDirectory, workspaceScripts, hasHydratedWorkspaces, - isMissingWorkspaceExecutionAuthority, + isMissingWorkspaceDirectory, onTerminalCreated: handleTerminalCreated, onScriptTerminalSelected: handleScriptTerminalSelected, onWorkspacePathUnavailable: handleWorkspacePathUnavailable, @@ -3260,7 +3234,7 @@ function WorkspaceScreenContent({ [buildPaneContentModel], ); const content = renderWorkspaceContent({ - isMissingWorkspaceExecutionAuthority, + isMissingWorkspaceDirectory, activeTabDescriptor, hasHydratedAgents, mountedFocusedPaneTabIds, @@ -3411,11 +3385,13 @@ function WorkspaceScreenContent({ ) : null} {!isMobile && isGitCheckout ? ( <> - + {workspaceDirectory ? ( + + ) : null} {}); } diff --git a/packages/app/src/stores/navigation-active-workspace-store/navigation.ts b/packages/app/src/stores/navigation-active-workspace-store/navigation.ts index e1dc11bf7..d1a4f9fc5 100644 --- a/packages/app/src/stores/navigation-active-workspace-store/navigation.ts +++ b/packages/app/src/stores/navigation-active-workspace-store/navigation.ts @@ -6,9 +6,9 @@ import { parseHostWorkspaceRouteFromPathname, } from "@/utils/host-routes"; import { - resolveWorkspaceIdByExecutionDirectory, + resolveWorkspaceIdByDirectory, resolveWorkspaceMapKeyByIdentity, -} from "@/utils/workspace-execution"; +} from "@/utils/workspace-identity"; import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection"; export interface RouteSelectionInput { @@ -77,7 +77,7 @@ export function navigateToWorkspace( const workspaceAgents = resolvedWorkspaceId ? Array.from(deps.getSessionAgents(serverId)).filter( (agent) => - resolveWorkspaceIdByExecutionDirectory({ + resolveWorkspaceIdByDirectory({ workspaces: workspaces?.values(), workspaceDirectory: agent.cwd, }) === resolvedWorkspaceId, diff --git a/packages/app/src/stores/session-store-hooks/index.ts b/packages/app/src/stores/session-store-hooks/index.ts index a6cea46e3..c44042e8c 100644 --- a/packages/app/src/stores/session-store-hooks/index.ts +++ b/packages/app/src/stores/session-store-hooks/index.ts @@ -9,8 +9,8 @@ import { selectRecommendedProjectPaths, selectResolveWorkspaceIdByCwd, selectWorkspace, + selectWorkspaceDirectory, selectWorkspaceExists, - selectWorkspaceExecutionAuthority, selectWorkspaceFields, selectWorkspaceKeys, selectWorkspaceOrderByScopeForServer, @@ -20,7 +20,6 @@ import { type WorkspaceStructure, } from "./selectors"; import { useSessionStore, type WorkspaceDescriptor } from "../session-store"; -import type { WorkspaceExecutionAuthorityResult } from "@/utils/workspace-execution"; import type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state"; // These are the ONLY supported ways to read workspaces from the session store. @@ -72,14 +71,14 @@ export function useHasHydratedWorkspaces(serverId: string | null): boolean { ); } -export function useWorkspaceExecutionAuthority( +export function useWorkspaceDirectory( serverId: string | null, workspaceId: string | null, -): WorkspaceExecutionAuthorityResult | null { +): string | null { return useStoreWithEqualityFn( useSessionStore, - (state) => selectWorkspaceExecutionAuthority(state, serverId, workspaceId), - workspaceEqualityFns.deep, + (state) => selectWorkspaceDirectory(state, serverId, workspaceId), + workspaceEqualityFns.identity, ); } diff --git a/packages/app/src/stores/session-store-hooks/selectors.test.ts b/packages/app/src/stores/session-store-hooks/selectors.test.ts index 5be68fe84..e9f1a6108 100644 --- a/packages/app/src/stores/session-store-hooks/selectors.test.ts +++ b/packages/app/src/stores/session-store-hooks/selectors.test.ts @@ -8,7 +8,7 @@ import { selectRecommendedProjectPaths, selectResolveWorkspaceIdByCwd, selectWorkspace, - selectWorkspaceExecutionAuthority, + selectWorkspaceDirectory, selectWorkspaceFields, selectWorkspaceKeys, selectWorkspaceOrderByScopeForServer, @@ -142,34 +142,30 @@ describe("selectWorkspace", () => { }); }); -describe("selectWorkspaceExecutionAuthority", () => { - it("preserves the old missing-workspace message with the requested id", () => { +describe("selectWorkspaceDirectory", () => { + it("returns the workspace directory, never the opaque workspace id", () => { + const workspace = createWorkspace({ + id: "wks_3f9a2b1c", + workspaceDirectory: "/Users/dev/project", + }); + initializeWorkspaces([workspace]); + + const directory = selectWorkspaceDirectory( + useSessionStore.getState(), + SERVER_ID, + "wks_3f9a2b1c", + ); + + expect(directory).toBe("/Users/dev/project"); + expect(directory).not.toBe("wks_3f9a2b1c"); + }); + + it("returns null when the workspace is missing", () => { initializeWorkspaces([]); expect( - selectWorkspaceExecutionAuthority(useSessionStore.getState(), SERVER_ID, "missing-id"), - ).toMatchObject({ - ok: false, - message: "Workspace not found: missing-id", - }); - }); - - it("keeps deep-equal authority references under unrelated workspace updates", () => { - const workspaceA = createWorkspace({ id: "workspace-a", name: "A" }); - const workspaceB = createWorkspace({ id: "workspace-b", name: "B" }); - initializeWorkspaces([workspaceA, workspaceB]); - - const tracked = trackSelector( - useSessionStore, - (state) => selectWorkspaceExecutionAuthority(state, SERVER_ID, workspaceA.id), - workspaceEqualityFns.deep, - ); - const before = tracked.current; - - useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceB, status: "running" }]); - expect(tracked.current).toBe(before); - - tracked.stop(); + selectWorkspaceDirectory(useSessionStore.getState(), SERVER_ID, "missing-id"), + ).toBeNull(); }); }); diff --git a/packages/app/src/stores/session-store-hooks/selectors.ts b/packages/app/src/stores/session-store-hooks/selectors.ts index adb2bc0d4..18d5748af 100644 --- a/packages/app/src/stores/session-store-hooks/selectors.ts +++ b/packages/app/src/stores/session-store-hooks/selectors.ts @@ -6,11 +6,9 @@ import { } from "@/projects/workspace-structure"; import type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state"; import { - getWorkspaceExecutionAuthority, - resolveWorkspaceIdByExecutionDirectory, + resolveWorkspaceIdByDirectory, resolveWorkspaceMapKeyByIdentity, - type WorkspaceExecutionAuthorityResult, -} from "@/utils/workspace-execution"; +} from "@/utils/workspace-identity"; import type { WorkspaceDescriptor } from "../session-store"; export type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state"; @@ -113,6 +111,14 @@ export function selectWorkspaceFields( return workspace ? project(workspace) : null; } +export function selectWorkspaceDirectory( + state: SessionsSnapshot, + serverId: string | null, + workspaceId: string | null, +): string | null { + return selectWorkspace(state, serverId, workspaceId)?.workspaceDirectory || null; +} + export function selectWorkspaceExists( state: SessionsSnapshot, serverId: string | null, @@ -128,20 +134,6 @@ export function selectHasHydratedWorkspaces( return serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false; } -export function selectWorkspaceExecutionAuthority( - state: SessionsSnapshot, - serverId: string | null, - workspaceId: string | null, -): WorkspaceExecutionAuthorityResult | null { - if (serverId === null || workspaceId === null) { - return null; - } - return getWorkspaceExecutionAuthority({ - workspaces: state.sessions[serverId]?.workspaces, - workspaceId, - }); -} - export function selectWorkspaceStructureProjects( state: SessionsSnapshot, serverId: string | null, @@ -257,7 +249,7 @@ export function selectResolveWorkspaceIdByCwd( return null; } const workspaces = state.sessions[serverId]?.workspaces; - return resolveWorkspaceIdByExecutionDirectory({ + return resolveWorkspaceIdByDirectory({ workspaces: workspaces?.values(), workspaceDirectory: cwd, }); diff --git a/packages/app/src/stores/session-store.test.ts b/packages/app/src/stores/session-store.test.ts index 076a92712..650f92168 100644 --- a/packages/app/src/stores/session-store.test.ts +++ b/packages/app/src/stores/session-store.test.ts @@ -99,6 +99,44 @@ describe("normalizeWorkspaceDescriptor", () => { expect(workspace.scripts).not.toBe(scripts); }); + it("canonicalizes the workspace directory and treats a blank one as empty", () => { + const canonical = normalizeWorkspaceDescriptor({ + id: "1", + projectId: "1", + projectDisplayName: "Project 1", + projectRootPath: "/repo", + workspaceDirectory: "/repo/app/", + projectKind: "git", + workspaceKind: "checkout", + name: "main", + archivingAt: null, + status: "done", + statusEnteredAt: null, + activityAt: null, + diffStat: null, + scripts: [], + }); + expect(canonical.workspaceDirectory).toBe("/repo/app"); + + const blank = normalizeWorkspaceDescriptor({ + id: "1", + projectId: "1", + projectDisplayName: "Project 1", + projectRootPath: "/repo", + workspaceDirectory: " ", + projectKind: "git", + workspaceKind: "checkout", + name: "main", + archivingAt: null, + status: "done", + statusEnteredAt: null, + activityAt: null, + diffStat: null, + scripts: [], + }); + expect(blank.workspaceDirectory).toBe(""); + }); + it("defaults missing scripts to an empty array", () => { const payload = { id: "1", diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index 42bbed725..2ce3b2a33 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -27,8 +27,11 @@ import type { ServerCapabilities, WorkspaceDescriptorPayload, } from "@getpaseo/protocol/messages"; -import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity"; -import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-execution"; +import { + normalizeWorkspaceOpaqueId, + normalizeWorkspacePath, + resolveWorkspaceMapKeyByIdentity, +} from "@/utils/workspace-identity"; import { createAgentLastActivityCoalescer, type AgentLastActivityCommitter, @@ -149,7 +152,10 @@ export function normalizeWorkspaceDescriptor( projectDisplayName: payload.projectDisplayName, projectCustomName: payload.projectCustomName ?? null, projectRootPath: payload.projectRootPath, - workspaceDirectory: payload.workspaceDirectory, + // Canonicalize the workspace directory once, at the store boundary, so every + // consumer can read workspace.workspaceDirectory directly. Empty means "no + // usable directory" (older daemons may omit it; the wire field is optional). + workspaceDirectory: normalizeWorkspacePath(payload.workspaceDirectory) ?? "", projectKind: payload.projectKind, workspaceKind: payload.workspaceKind, name: payload.name, diff --git a/packages/app/src/stores/workspace-tabs-store/state.ts b/packages/app/src/stores/workspace-tabs-store/state.ts index 409e17150..509f187fb 100644 --- a/packages/app/src/stores/workspace-tabs-store/state.ts +++ b/packages/app/src/stores/workspace-tabs-store/state.ts @@ -59,6 +59,7 @@ export function buildWorkspaceTabPersistenceKey(input: { if (!serverId || !workspaceId) { return null; } + // workspaceId is opaque; do not parse this key back into a path. return `${serverId}:${workspaceId}`; } diff --git a/packages/app/src/utils/host-routes.test.ts b/packages/app/src/utils/host-routes.test.ts index 00fb6f7e5..8758da968 100644 --- a/packages/app/src/utils/host-routes.test.ts +++ b/packages/app/src/utils/host-routes.test.ts @@ -31,6 +31,7 @@ describe("workspace route parsing", () => { it("keeps URL-safe workspace IDs unencoded", () => { expect(encodeWorkspaceIdForPathSegment("164")).toBe("164"); expect(decodeWorkspaceIdFromPathSegment("164")).toBe("164"); + expect(decodeWorkspaceIdFromPathSegment("wks_10b3479c955fcc4c")).toBe("wks_10b3479c955fcc4c"); }); it("encodes non-URL-safe workspace IDs as base64url", () => { diff --git a/packages/app/src/utils/host-routes.ts b/packages/app/src/utils/host-routes.ts index e9bceccea..d277e4b87 100644 --- a/packages/app/src/utils/host-routes.ts +++ b/packages/app/src/utils/host-routes.ts @@ -100,6 +100,19 @@ function isLegacyPathLikeWorkspaceValue(value: string): boolean { return value.includes("/") || value.includes("\\") || /^[A-Za-z]:[\\/]/.test(value); } +function hasLegacyDecodeNoise(value: string): boolean { + for (const character of value) { + const codePoint = character.codePointAt(0); + if (codePoint == null) continue; + if (codePoint < 0x20 || codePoint === 0x7f || codePoint === 0xfffd) return true; + } + return false; +} + +function isCleanLegacyPathDecode(value: string): boolean { + return isLegacyPathLikeWorkspaceValue(value) && !hasLegacyDecodeNoise(value); +} + export type WorkspaceOpenIntent = | { kind: "agent"; agentId: string } | { kind: "terminal"; terminalId: string } @@ -193,13 +206,14 @@ export function decodeWorkspaceIdFromPathSegment(workspaceIdSegment: string): st return prefixedDecoded ? normalizeWorkspaceId(prefixedDecoded) : null; } + // COMPAT(legacyPathWorkspaceId): IDs were path-shaped before v0.1.95. Remove when deep-link floor >= v0.2.0. const base64Decoded = tryDecodeBase64UrlNoPadUtf8(decoded); - if (base64Decoded && isLegacyPathLikeWorkspaceValue(base64Decoded)) { + if (base64Decoded && isCleanLegacyPathDecode(base64Decoded)) { return normalizeWorkspaceId(base64Decoded); } const relaxedBase64Decoded = decodeBase64UrlNoPadUtf8(decoded); - if (relaxedBase64Decoded && isLegacyPathLikeWorkspaceValue(relaxedBase64Decoded)) { + if (relaxedBase64Decoded && isCleanLegacyPathDecode(relaxedBase64Decoded)) { return normalizeWorkspaceId(relaxedBase64Decoded); } diff --git a/packages/app/src/utils/navigate-to-agent/resolve.ts b/packages/app/src/utils/navigate-to-agent/resolve.ts index a9c286924..bbe876d72 100644 --- a/packages/app/src/utils/navigate-to-agent/resolve.ts +++ b/packages/app/src/utils/navigate-to-agent/resolve.ts @@ -1,6 +1,6 @@ import type { WorkspaceDescriptor } from "@/stores/session-store"; import { buildHostAgentDetailRoute } from "@/utils/host-routes"; -import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution"; +import { resolveWorkspaceIdByDirectory } from "@/utils/workspace-identity"; import type { NavigateToPreparedWorkspaceTabInput } from "@/utils/prepare-workspace-tab"; export interface NavigateToAgentInput { @@ -29,7 +29,7 @@ export function resolveNavigateToAgent( serverId: input.serverId, agentId: input.agentId, }); - const workspaceId = resolveWorkspaceIdByExecutionDirectory({ + const workspaceId = resolveWorkspaceIdByDirectory({ workspaces, workspaceDirectory: agentCwd, }); diff --git a/packages/app/src/utils/workspace-archive-navigation.ts b/packages/app/src/utils/workspace-archive-navigation.ts index 3ca2d22f9..6117d3b74 100644 --- a/packages/app/src/utils/workspace-archive-navigation.ts +++ b/packages/app/src/utils/workspace-archive-navigation.ts @@ -1,7 +1,7 @@ import type { Href } from "expo-router"; import type { WorkspaceDescriptor } from "@/stores/session-store"; import { buildHostNewWorkspaceRoute, buildHostRootRoute } from "@/utils/host-routes"; -import { resolveWorkspaceRouteId } from "@/utils/workspace-execution"; +import { resolveWorkspaceRouteId } from "@/utils/workspace-identity"; export function buildWorkspaceArchiveRedirectRoute(input: { serverId: string; diff --git a/packages/app/src/utils/workspace-directory.test.ts b/packages/app/src/utils/workspace-directory.test.ts new file mode 100644 index 000000000..b40bdd970 --- /dev/null +++ b/packages/app/src/utils/workspace-directory.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "./workspace-directory"; + +describe("resolveWorkspaceDirectory", () => { + it("canonicalizes a workspace directory and returns null when blank", () => { + expect(resolveWorkspaceDirectory({ workspaceDirectory: "C:\\repo\\app\\" })).toBe( + "C:/repo/app", + ); + expect(resolveWorkspaceDirectory({ workspaceDirectory: " " })).toBeNull(); + }); +}); + +describe("requireWorkspaceDirectory", () => { + it("returns the canonical directory when present", () => { + expect(requireWorkspaceDirectory({ workspaceDirectory: "/repo/app/" })).toBe("/repo/app"); + }); + + it("throws naming the workspace when the directory is missing", () => { + expect(() => + requireWorkspaceDirectory({ workspaceId: "wks_1", workspaceDirectory: " " }), + ).toThrow("Workspace directory is missing for workspace wks_1"); + }); +}); diff --git a/packages/app/src/utils/workspace-directory.ts b/packages/app/src/utils/workspace-directory.ts new file mode 100644 index 000000000..74b6d610a --- /dev/null +++ b/packages/app/src/utils/workspace-directory.ts @@ -0,0 +1,24 @@ +import { normalizeWorkspacePath } from "@/utils/workspace-identity"; + +export function resolveWorkspaceDirectory(input: { + workspaceDirectory: string | null | undefined; +}): string | null { + return normalizeWorkspacePath(input.workspaceDirectory); +} + +export function requireWorkspaceDirectory(input: { + workspaceId?: string; + workspaceDirectory: string | null | undefined; +}): string { + const workspaceDirectory = resolveWorkspaceDirectory({ + workspaceDirectory: input.workspaceDirectory, + }); + if (!workspaceDirectory) { + throw new Error( + input.workspaceId + ? `Workspace directory is missing for workspace ${input.workspaceId}` + : "Workspace directory is missing.", + ); + } + return workspaceDirectory; +} diff --git a/packages/app/src/utils/workspace-execution.ts b/packages/app/src/utils/workspace-execution.ts deleted file mode 100644 index 35d032c5a..000000000 --- a/packages/app/src/utils/workspace-execution.ts +++ /dev/null @@ -1,191 +0,0 @@ -import type { WorkspaceDescriptor } from "@/stores/session-store"; -import { normalizeWorkspaceOpaqueId, normalizeWorkspacePath } from "@/utils/workspace-identity"; - -export interface WorkspaceAuthorityResult { - workspaceId: string; - workspaceDirectory: string; - workspace: WorkspaceDescriptor; -} - -export type WorkspaceExecutionAuthorityFailureReason = - | "workspace_id_missing" - | "workspace_missing" - | "workspace_directory_missing"; - -export type WorkspaceExecutionAuthorityResult = - | { ok: true; authority: WorkspaceAuthorityResult } - | { - ok: false; - reason: WorkspaceExecutionAuthorityFailureReason; - message: string; - }; - -export function resolveWorkspaceRouteId(input: { - routeWorkspaceId: string | null | undefined; -}): string | null { - return normalizeWorkspaceOpaqueId(input.routeWorkspaceId); -} - -export function resolveWorkspaceIdByExecutionDirectory(input: { - workspaces: Iterable | null | undefined; - workspaceDirectory: string | null | undefined; -}): string | null { - const normalizedWorkspaceDirectory = normalizeWorkspacePath(input.workspaceDirectory); - if (!normalizedWorkspaceDirectory) { - return null; - } - - for (const workspace of input.workspaces ?? []) { - if (normalizeWorkspacePath(workspace.workspaceDirectory) === normalizedWorkspaceDirectory) { - return workspace.id; - } - } - - return null; -} - -export function resolveWorkspaceMapKeyByIdentity(input: { - workspaces: Map | null | undefined; - workspaceId: string | null | undefined; -}): string | null { - const normalizedWorkspaceId = normalizeWorkspaceOpaqueId(input.workspaceId); - if (!normalizedWorkspaceId) { - return null; - } - - const workspaces = input.workspaces; - if (!workspaces) { - return null; - } - - if (workspaces.has(normalizedWorkspaceId)) { - return normalizedWorkspaceId; - } - - for (const [workspaceKey, workspace] of workspaces) { - if (normalizeWorkspaceOpaqueId(workspace.id) === normalizedWorkspaceId) { - return workspaceKey; - } - } - - return null; -} - -export function getWorkspaceExecutionAuthority( - input: - | { - workspace: WorkspaceDescriptor | null | undefined; - } - | { - workspaces: Map | undefined; - workspaceId: string | null | undefined; - }, -): WorkspaceExecutionAuthorityResult { - const workspace = - "workspace" in input - ? input.workspace - : (() => { - const workspaceKey = resolveWorkspaceMapKeyByIdentity({ - workspaces: input.workspaces, - workspaceId: input.workspaceId, - }); - if (!workspaceKey) { - return null; - } - return input.workspaces?.get(workspaceKey) ?? null; - })(); - - if ("workspaces" in input) { - const normalizedWorkspaceId = normalizeWorkspaceOpaqueId(input.workspaceId); - if (!normalizedWorkspaceId) { - return { - ok: false, - reason: "workspace_id_missing", - message: "Workspace id is required.", - }; - } - } - - if (!workspace) { - return { - ok: false, - reason: "workspace_missing", - message: - "workspaces" in input - ? `Workspace not found: ${input.workspaceId ?? ""}` - : "Workspace not found.", - }; - } - - const workspaceDirectory = normalizeWorkspacePath(workspace.workspaceDirectory); - if (!workspaceDirectory) { - return { - ok: false, - reason: "workspace_directory_missing", - message: `Workspace directory is missing for workspace ${workspace.id}`, - }; - } - - return { - ok: true, - authority: { - workspaceId: workspace.id, - workspaceDirectory, - workspace, - }, - }; -} - -export function requireWorkspaceExecutionAuthority( - input: - | { - workspace: WorkspaceDescriptor | null | undefined; - } - | { - workspaces: Map | undefined; - workspaceId: string | null | undefined; - }, -): WorkspaceAuthorityResult { - const result = getWorkspaceExecutionAuthority(input); - if (!result.ok) { - throw new Error(result.message); - } - return result.authority; -} - -export function resolveWorkspaceExecutionDirectory(input: { - workspaceDirectory: string | null | undefined; -}): string | null { - return normalizeWorkspacePath(input.workspaceDirectory); -} - -export function requireWorkspaceExecutionDirectory(input: { - workspaceId?: string; - workspaceDirectory: string | null | undefined; -}): string { - const workspaceDirectory = resolveWorkspaceExecutionDirectory({ - workspaceDirectory: input.workspaceDirectory, - }); - if (!workspaceDirectory) { - throw new Error( - input.workspaceId - ? `Workspace directory is missing for workspace ${input.workspaceId}` - : "Workspace directory is missing.", - ); - } - return workspaceDirectory; -} - -export function resolveWorkspaceExecutionAuthority( - input: - | { - workspace: WorkspaceDescriptor | null | undefined; - } - | { - workspaces: Map | undefined; - workspaceId: string | null | undefined; - }, -): WorkspaceAuthorityResult | null { - const result = getWorkspaceExecutionAuthority(input); - return result.ok ? result.authority : null; -} diff --git a/packages/app/src/utils/workspace-execution.test.ts b/packages/app/src/utils/workspace-identity.test.ts similarity index 60% rename from packages/app/src/utils/workspace-execution.test.ts rename to packages/app/src/utils/workspace-identity.test.ts index 10aa2c22a..34a9cada8 100644 --- a/packages/app/src/utils/workspace-execution.test.ts +++ b/packages/app/src/utils/workspace-identity.test.ts @@ -1,12 +1,10 @@ import { describe, expect, it } from "vitest"; import type { WorkspaceDescriptor } from "@/stores/session-store"; import { - getWorkspaceExecutionAuthority, - requireWorkspaceExecutionAuthority, + resolveWorkspaceIdByDirectory, resolveWorkspaceMapKeyByIdentity, - resolveWorkspaceIdByExecutionDirectory, resolveWorkspaceRouteId, -} from "./workspace-execution"; +} from "./workspace-identity"; function createWorkspace( input: Partial & Pick, @@ -40,7 +38,7 @@ describe("resolveWorkspaceRouteId", () => { }); }); -describe("resolveWorkspaceIdByExecutionDirectory", () => { +describe("resolveWorkspaceIdByDirectory", () => { it("matches workspace directories", () => { const workspaces = [ createWorkspace({ @@ -51,7 +49,7 @@ describe("resolveWorkspaceIdByExecutionDirectory", () => { ]; expect( - resolveWorkspaceIdByExecutionDirectory({ + resolveWorkspaceIdByDirectory({ workspaces, workspaceDirectory: "/repo/.paseo/worktrees/feature", }), @@ -68,7 +66,7 @@ describe("resolveWorkspaceIdByExecutionDirectory", () => { ]; expect( - resolveWorkspaceIdByExecutionDirectory({ + resolveWorkspaceIdByDirectory({ workspaces, workspaceDirectory: "/repo", }), @@ -115,66 +113,3 @@ describe("resolveWorkspaceMapKeyByIdentity", () => { ).toBeNull(); }); }); - -describe("workspace execution authority", () => { - it("returns an explicit failure when workspace id is missing", () => { - expect( - getWorkspaceExecutionAuthority({ - workspaces: new Map(), - workspaceId: null, - }), - ).toEqual({ - ok: false, - reason: "workspace_id_missing", - message: "Workspace id is required.", - }); - }); - - it("returns an explicit failure when workspace directory is missing", () => { - const workspaces = new Map([ - [ - "workspace-1", - createWorkspace({ - id: "workspace-1", - workspaceDirectory: " ", - projectRootPath: "/repo", - }), - ], - ]); - - expect( - getWorkspaceExecutionAuthority({ - workspaces, - workspaceId: "workspace-1", - }), - ).toEqual({ - ok: false, - reason: "workspace_directory_missing", - message: "Workspace directory is missing for workspace workspace-1", - }); - }); - - it("never falls back to project root metadata", () => { - const workspaces = new Map([ - [ - "workspace-1", - createWorkspace({ - id: "workspace-1", - projectRootPath: "/repo", - workspaceDirectory: "/repo/.paseo/worktrees/feature", - }), - ], - ]); - - expect( - requireWorkspaceExecutionAuthority({ - workspaces, - workspaceId: "workspace-1", - }), - ).toEqual({ - workspaceId: "workspace-1", - workspaceDirectory: "/repo/.paseo/worktrees/feature", - workspace: workspaces.get("workspace-1"), - }); - }); -}); diff --git a/packages/app/src/utils/workspace-identity.ts b/packages/app/src/utils/workspace-identity.ts index bb982c23b..76c587a60 100644 --- a/packages/app/src/utils/workspace-identity.ts +++ b/packages/app/src/utils/workspace-identity.ts @@ -1,3 +1,5 @@ +import type { WorkspaceDescriptor } from "@/stores/session-store"; + function trimNonEmpty(value: string | null | undefined): string | null { if (typeof value !== "string") { return null; @@ -22,3 +24,56 @@ export function normalizeWorkspacePath(value: string | null | undefined): string const withoutTrailingSlash = withUnixSeparators.replace(/\/+$/, ""); return withoutTrailingSlash.length > 0 ? withoutTrailingSlash : "/"; } + +export function resolveWorkspaceRouteId(input: { + routeWorkspaceId: string | null | undefined; +}): string | null { + return normalizeWorkspaceOpaqueId(input.routeWorkspaceId); +} + +// Single approved cwd→workspaceId inference site. +// Do not add cwd-to-id inference elsewhere; prefer agent.workspaceId when the daemon provides it. +export function resolveWorkspaceIdByDirectory(input: { + workspaces: Iterable | null | undefined; + workspaceDirectory: string | null | undefined; +}): string | null { + const normalizedWorkspaceDirectory = normalizeWorkspacePath(input.workspaceDirectory); + if (!normalizedWorkspaceDirectory) { + return null; + } + + for (const workspace of input.workspaces ?? []) { + if (normalizeWorkspacePath(workspace.workspaceDirectory) === normalizedWorkspaceDirectory) { + return workspace.id; + } + } + + return null; +} + +export function resolveWorkspaceMapKeyByIdentity(input: { + workspaces: Map | null | undefined; + workspaceId: string | null | undefined; +}): string | null { + const normalizedWorkspaceId = normalizeWorkspaceOpaqueId(input.workspaceId); + if (!normalizedWorkspaceId) { + return null; + } + + const workspaces = input.workspaces; + if (!workspaces) { + return null; + } + + if (workspaces.has(normalizedWorkspaceId)) { + return normalizedWorkspaceId; + } + + for (const [workspaceKey, workspace] of workspaces) { + if (normalizeWorkspaceOpaqueId(workspace.id) === normalizedWorkspaceId) { + return workspaceKey; + } + } + + return null; +} diff --git a/packages/app/src/workspace-tabs/agent-visibility.ts b/packages/app/src/workspace-tabs/agent-visibility.ts index 45235f857..06d920fe1 100644 --- a/packages/app/src/workspace-tabs/agent-visibility.ts +++ b/packages/app/src/workspace-tabs/agent-visibility.ts @@ -3,7 +3,7 @@ import type { WorkspaceTabSnapshot } from "@/stores/workspace-layout-actions"; import { shouldAutoOpenAgentTab } from "@/subagents/policies"; import { normalizeWorkspacePath } from "@/utils/workspace-identity"; -function normalizeWorkspaceId(value: string | null | undefined): string { +function normalizeWorkspaceDirectory(value: string | null | undefined): string { return normalizeWorkspacePath(value) ?? ""; } @@ -19,7 +19,7 @@ export function deriveWorkspaceAgentVisibility(input: { workspaceDirectory: string | null | undefined; }): WorkspaceAgentVisibility { const { sessionAgents, agentDetails, workspaceDirectory } = input; - const normalizedWorkspaceDirectory = normalizeWorkspaceId(workspaceDirectory); + const normalizedWorkspaceDirectory = normalizeWorkspaceDirectory(workspaceDirectory); if ((!sessionAgents && !agentDetails) || !normalizedWorkspaceDirectory) { return { activeAgentIds: new Set(), @@ -32,7 +32,7 @@ export function deriveWorkspaceAgentVisibility(input: { const autoOpenAgentIds = new Set(); const knownAgentIds = new Set(); for (const agent of sessionAgents?.values() ?? []) { - if (normalizeWorkspaceId(agent.cwd) !== normalizedWorkspaceDirectory) { + if (normalizeWorkspaceDirectory(agent.cwd) !== normalizedWorkspaceDirectory) { continue; } knownAgentIds.add(agent.id); @@ -44,7 +44,7 @@ export function deriveWorkspaceAgentVisibility(input: { } } for (const agent of agentDetails?.values() ?? []) { - if (normalizeWorkspaceId(agent.cwd) !== normalizedWorkspaceDirectory) { + if (normalizeWorkspaceDirectory(agent.cwd) !== normalizedWorkspaceDirectory) { continue; } knownAgentIds.add(agent.id); diff --git a/packages/app/src/workspace/workspace-archive.ts b/packages/app/src/workspace/workspace-archive.ts index 7d0f68621..e3181f2eb 100644 --- a/packages/app/src/workspace/workspace-archive.ts +++ b/packages/app/src/workspace/workspace-archive.ts @@ -3,7 +3,7 @@ import { markWorkspaceArchivePending, } from "@/contexts/session-workspace-upserts"; import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; -import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-execution"; +import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity"; export interface WorkspaceArchiveTarget { serverId: string; diff --git a/packages/cli/src/commands/worktree/create.ts b/packages/cli/src/commands/worktree/create.ts index e59259b4c..257fadc67 100644 --- a/packages/cli/src/commands/worktree/create.ts +++ b/packages/cli/src/commands/worktree/create.ts @@ -55,7 +55,13 @@ export async function runCreateCommand( ); } - const worktreePath = workspace.workspaceDirectory ?? workspace.id; + if (!workspace.workspaceDirectory) { + throw cmdError( + "WORKTREE_CREATE_FAILED", + "Failed to create worktree: workspace directory missing from daemon response", + ); + } + const worktreePath = workspace.workspaceDirectory; return { type: "single", diff --git a/packages/client/examples/events-and-timeline.ts b/packages/client/examples/events-and-timeline.ts index 533210f96..84855426d 100644 --- a/packages/client/examples/events-and-timeline.ts +++ b/packages/client/examples/events-and-timeline.ts @@ -15,7 +15,6 @@ export async function subscribeToEvents( await client.connect(); await client.workspaces.list({ - filter: { idPrefix: workspaceId }, subscribe: { subscriptionId: `workspace-${workspaceId}` }, }); diff --git a/packages/client/src/index.test.ts b/packages/client/src/index.test.ts index 658e69123..f5b92c0f5 100644 --- a/packages/client/src/index.test.ts +++ b/packages/client/src/index.test.ts @@ -241,7 +241,6 @@ test("workspace handles keep identity and refresh snapshots through existing dri expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({ type: "fetch_workspaces_request", requestId: "workspace-refetch-request", - filter: { idPrefix: "workspace_sdk" }, page: { limit: 25 }, }); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 3522d4995..0f20d9270 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -414,9 +414,11 @@ function createWorkspaceHandleFactory(daemonClient: DaemonClient): WorkspaceHand id, latest: () => latest, refetch: async (options) => { + // Best-effort: fetches one page and matches by id client-side, so a workspace beyond + // the first page won't be found. TODO: add a "get workspace by id" lookup and resolve + // by exact id instead of paging. const result = await daemonClient.fetchWorkspaces({ requestId: options?.requestId, - filter: { idPrefix: id }, page: { limit: 25 }, }); latest = result.entries.find((entry) => entry.id === id) ?? null; diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 099e0ba27..8aa905db8 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -948,6 +948,7 @@ export const FetchWorkspacesRequestMessageSchema = z.object({ .object({ query: z.string().optional(), projectId: z.string().optional(), + // Unused: accepted so older clients still parse, but the server does not filter on it. idPrefix: z.string().optional(), }) .optional(), @@ -1627,6 +1628,7 @@ export const LegacyOpenInEditorRequestSchema = z.object({ export const OpenProjectRequestSchema = z.object({ type: z.literal("open_project_request"), + // Path used only for workspace lookup/creation. Use the returned workspace.id for all subsequent references. cwd: z.string(), requestId: z.string(), }); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 13156ad13..b4b95d191 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -849,7 +849,6 @@ export class AgentManager { agentId?: string, options?: { labels?: Record; - workspaceId?: string; initialPrompt?: string; env?: Record; persistSession?: boolean; @@ -867,7 +866,6 @@ export class AgentManager { const session = await client.createSession(launchConfig, launchContext, createOptions); return this.registerSession(session, storedConfig, resolvedAgentId, { labels: options?.labels, - workspaceId: options?.workspaceId, initialTitle: options?.initialTitle, }); } @@ -2321,7 +2319,6 @@ export class AgentManager { config: AgentSessionConfig, agentId: string, options?: { - workspaceId?: string; createdAt?: Date; updatedAt?: Date; lastUserMessageAt?: Date | null; @@ -2369,7 +2366,6 @@ export class AgentManager { this.previousStatuses.set(resolvedAgentId, managed.lifecycle); await this.refreshRuntimeInfo(managed, { emit: !options?.publishWhenReady }); await this.persistSnapshot(managed, { - workspaceId: options?.workspaceId, title: initialPersistedTitle, }); if (!options?.publishWhenReady) { @@ -2378,7 +2374,7 @@ export class AgentManager { await this.refreshSessionState(managed, { emit: !options?.publishWhenReady }); managed.lifecycle = "idle"; - await this.persistSnapshot(managed, { workspaceId: options?.workspaceId }); + await this.persistSnapshot(managed); this.emitState(managed, { persist: false }); this.subscribeToSession(managed); return { ...managed }; @@ -2639,7 +2635,7 @@ export class AgentManager { private async persistSnapshot( agent: ManagedAgent, - options?: { workspaceId?: string; title?: string | null; internal?: boolean }, + options?: { title?: string | null; internal?: boolean }, ): Promise { if (!this.registry) { return; @@ -2648,10 +2644,6 @@ export class AgentManager { if (agent.internal) { return; } - if (options?.workspaceId !== undefined) { - await this.registry.applySnapshot(agent, options.workspaceId, options); - return; - } await this.registry.applySnapshot(agent, options); } diff --git a/packages/server/src/server/agent/agent-storage.ts b/packages/server/src/server/agent/agent-storage.ts index 03501fbec..92762e772 100644 --- a/packages/server/src/server/agent/agent-storage.ts +++ b/packages/server/src/server/agent/agent-storage.ts @@ -191,23 +191,19 @@ export class AgentStorage { async applySnapshot( agent: ManagedAgent, - workspaceIdOrOptions?: string | { title?: string | null; internal?: boolean }, options?: { title?: string | null; internal?: boolean }, ): Promise { - const nextOptions = typeof workspaceIdOrOptions === "string" ? options : workspaceIdOrOptions; await this.load(); await this.waitForPendingWrite(agent.id); const existing = (await this.get(agent.id)) ?? null; const hasTitleOverride = - nextOptions !== undefined && Object.prototype.hasOwnProperty.call(nextOptions, "title"); + options !== undefined && Object.prototype.hasOwnProperty.call(options, "title"); const hasInternalOverride = - nextOptions !== undefined && Object.prototype.hasOwnProperty.call(nextOptions, "internal"); + options !== undefined && Object.prototype.hasOwnProperty.call(options, "internal"); const record = toStoredAgentRecord(agent, { - title: hasTitleOverride ? (nextOptions?.title ?? null) : (existing?.title ?? null), + title: hasTitleOverride ? (options?.title ?? null) : (existing?.title ?? null), createdAt: existing?.createdAt, - internal: hasInternalOverride - ? nextOptions?.internal - : (agent.internal ?? existing?.internal), + internal: hasInternalOverride ? options?.internal : (agent.internal ?? existing?.internal), }); // Preserve soft-delete/archive status across snapshot flushes. diff --git a/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts b/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts index 53e25a5a9..5bbff68e6 100644 --- a/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts +++ b/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts @@ -26,6 +26,7 @@ interface CreateAgentLifecycleDispatchDependencies { workspaceGitService: WorkspaceGitService; createPaseoWorktreeWorkflow: CreatePaseoWorktreeWorkflowFn; archiveAgentForClose: (agentId: string) => Promise; + resolveWorkspaceIdForCwd: (cwd: string) => Promise; archiveWorkspaceRecord: (workspaceId: string) => Promise; emit: (message: SessionOutboundMessage) => void; emitAgentRemove: (agentId: string) => void; @@ -207,6 +208,7 @@ export class CreateAgentLifecycleDispatch { workspaceGitService: this.dependencies.workspaceGitService, agentManager: this.dependencies.agentManager, agentStorage: this.dependencies.agentStorage, + resolveWorkspaceIdForCwd: this.dependencies.resolveWorkspaceIdForCwd, archiveWorkspaceRecord: this.dependencies.archiveWorkspaceRecord, emitWorkspaceUpdatesForWorkspaceIds: this.dependencies.emitWorkspaceUpdatesForWorkspaceIds, markWorkspaceArchiving: this.dependencies.markWorkspaceArchiving, diff --git a/packages/server/src/server/agent/create-agent/create.test.ts b/packages/server/src/server/agent/create-agent/create.test.ts index 3a97c4a1e..1f3fda1da 100644 --- a/packages/server/src/server/agent/create-agent/create.test.ts +++ b/packages/server/src/server/agent/create-agent/create.test.ts @@ -38,7 +38,6 @@ test("session create forwards clientMessageId to the initial prompt run options" explicitTitle: "Explicit title", firstAgentContext: { attachments: [] }, buildSessionConfig: async (config) => ({ sessionConfig: config }), - resolveWorkspace: async () => ({ workspaceId: "workspace-1" }), }); expect(streamAgent).toHaveBeenCalledWith("agent-1", "hello from create", { diff --git a/packages/server/src/server/agent/create-agent/create.ts b/packages/server/src/server/agent/create-agent/create.ts index 4e69ed466..e636d2481 100644 --- a/packages/server/src/server/agent/create-agent/create.ts +++ b/packages/server/src/server/agent/create-agent/create.ts @@ -32,10 +32,6 @@ import { emitLiveTimelineItemIfAgentKnown, } from "../timeline-append.js"; -export interface CreateAgentWorkspace { - workspaceId: string; -} - export interface CreateAgentSessionWorktreeResult { sessionConfig: AgentSessionConfig; setupContinuation?: AgentWorktreeSetupContinuation; @@ -79,7 +75,6 @@ export interface CreateAgentFromSessionInput { legacyWorktreeName?: string, firstAgentContext?: FirstAgentContext, ) => Promise; - resolveWorkspace: (input: { cwd: string; workspaceId?: string }) => Promise; } export interface CreateAgentFromMcpInput { @@ -134,7 +129,6 @@ interface ResolvedCreateAgent { interface AgentCreateOptions { labels?: Record; - workspaceId?: string; initialPrompt?: string; env?: Record; initialTitle?: string | null; @@ -196,10 +190,6 @@ async function resolveSessionCreateAgent( input.worktreeName, input.firstAgentContext, ); - const workspace = await input.resolveWorkspace({ - cwd: sessionConfig.cwd, - workspaceId: input.workspaceId, - }); const prompt = buildAgentPrompt(trimmedPrompt ?? "", input.images, input.attachments); const hasPromptContent = Array.isArray(prompt) ? prompt.length > 0 : prompt.length > 0; const clientMessageId = normalizeClientMessageId(input.clientMessageId); @@ -215,7 +205,6 @@ async function resolveSessionCreateAgent( config: sessionConfig, createOptions: { labels: input.labels, - workspaceId: workspace.workspaceId, initialPrompt: trimmedPrompt, env: input.env, initialTitle: input.provisionalTitle, diff --git a/packages/server/src/server/agent/lifecycle-command.ts b/packages/server/src/server/agent/lifecycle-command.ts index 1a88c7e45..77f618181 100644 --- a/packages/server/src/server/agent/lifecycle-command.ts +++ b/packages/server/src/server/agent/lifecycle-command.ts @@ -96,15 +96,16 @@ export async function archiveAgentCommand( agentId: string, ): Promise { const liveAgent = dependencies.agentManager.getAgent(agentId); + let record: StoredAgentRecord | null; if (liveAgent) { await cancelAgentRunCommand(dependencies, agentId); await dependencies.agentManager.clearAgentAttention(agentId).catch(() => undefined); await dependencies.agentManager.archiveAgent(agentId); + record = await dependencies.agentStorage.get(agentId); } else { - await archiveStoredAgent(dependencies, agentId); + record = await archiveStoredAgent(dependencies, agentId); } - const record = await dependencies.agentStorage.get(agentId); if (!record) { throw new Error(`Agent not found in storage after archive: ${agentId}`); } @@ -174,16 +175,16 @@ export async function setAgentModeCommand( async function archiveStoredAgent( dependencies: Pick, agentId: string, -): Promise { +): Promise { const existing = await dependencies.agentStorage.get(agentId); if (!existing) { throw new Error(`Agent not found: ${agentId}`); } if (existing.archivedAt) { - return; + return existing; } const archivedAt = new Date().toISOString(); - await dependencies.agentManager.archiveSnapshot(agentId, archivedAt); + return dependencies.agentManager.archiveSnapshot(agentId, archivedAt); } diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index f88c50e56..6ea4b386b 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -1258,7 +1258,7 @@ describe("create_agent MCP tool", () => { baseRefName: "main", }, workspace: { - workspaceId: "/tmp/worktrees/pr-123", + workspaceId: "ws-pr-123", projectId: REPO_CWD, cwd: "/tmp/worktrees/pr-123", kind: "worktree" as const, @@ -1395,7 +1395,7 @@ describe("create_agent MCP tool", () => { }); expect(setupContinuations).toEqual([undefined]); expect(broadcasts).toHaveLength(1); - expect(broadcasts[0]).toContain("tool-worktree"); + expect(broadcasts[0]).toMatch(/^wks_[0-9a-f]{16}$/); } finally { await rm(tempDir, { recursive: true, force: true }); } @@ -1444,6 +1444,7 @@ describe("create_agent MCP tool", () => { WorkspaceGitService, "getSnapshot" | "listWorktrees" | "resolveRepoRoot" >, + resolveWorkspaceIdForCwd: vi.fn(async () => "ws-archive-tool-worktree"), archiveWorkspaceRecord, emitWorkspaceUpdatesForWorkspaceIds, markWorkspaceArchiving, @@ -1473,16 +1474,14 @@ describe("create_agent MCP tool", () => { force: true, reason: "mcp:archive-worktree", }); - expect(archiveWorkspaceRecord).toHaveBeenCalledWith(created.structuredContent.worktreePath); + expect(archiveWorkspaceRecord).toHaveBeenCalledWith("ws-archive-tool-worktree"); expect(markWorkspaceArchiving).toHaveBeenCalledWith( - [created.structuredContent.worktreePath], + ["ws-archive-tool-worktree"], expect.any(String), ); - expect(clearWorkspaceArchiving).toHaveBeenCalledWith([ - created.structuredContent.worktreePath, - ]); + expect(clearWorkspaceArchiving).toHaveBeenCalledWith(["ws-archive-tool-worktree"]); expect(Array.from(emitWorkspaceUpdatesForWorkspaceIds.mock.calls[0]?.[0] ?? [])).toEqual([ - created.structuredContent.worktreePath, + "ws-archive-tool-worktree", ]); } finally { await rm(tempDir, { recursive: true, force: true }); @@ -1528,6 +1527,7 @@ describe("create_agent MCP tool", () => { WorkspaceGitService, "getSnapshot" | "listWorktrees" | "resolveRepoRoot" >, + resolveWorkspaceIdForCwd: vi.fn(async () => "ws-archive-mcp"), archiveWorkspaceRecord: vi.fn(async () => undefined), emitWorkspaceUpdatesForWorkspaceIds: vi.fn(async () => undefined), markWorkspaceArchiving: vi.fn(), diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index 79ca55489..14f6b97e3 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -93,6 +93,7 @@ export interface AgentMcpServerOptions { WorkspaceGitService, "getSnapshot" | "listWorktrees" | "resolveRepoRoot" >; + resolveWorkspaceIdForCwd?: ArchivePaseoWorktreeDependencies["resolveWorkspaceIdForCwd"]; archiveWorkspaceRecord?: ArchivePaseoWorktreeDependencies["archiveWorkspaceRecord"]; emitWorkspaceUpdatesForWorkspaceIds?: ArchivePaseoWorktreeDependencies["emitWorkspaceUpdatesForWorkspaceIds"]; markWorkspaceArchiving?: ArchivePaseoWorktreeDependencies["markWorkspaceArchiving"]; @@ -2406,6 +2407,9 @@ function archiveWorktreeDependencies( if (!options.archiveWorkspaceRecord) { throw new Error("Workspace registry archiver is required to archive worktrees"); } + if (!options.resolveWorkspaceIdForCwd) { + throw new Error("Workspace resolver is required to archive worktrees"); + } if (!options.emitWorkspaceUpdatesForWorkspaceIds) { throw new Error("Workspace update emitter is required to archive worktrees"); } @@ -2422,6 +2426,7 @@ function archiveWorktreeDependencies( workspaceGitService: options.workspaceGitService, agentManager: context.agentManager, agentStorage: context.agentStorage, + resolveWorkspaceIdForCwd: options.resolveWorkspaceIdForCwd, archiveWorkspaceRecord: options.archiveWorkspaceRecord, emitWorkspaceUpdatesForWorkspaceIds: options.emitWorkspaceUpdatesForWorkspaceIds, markWorkspaceArchiving: options.markWorkspaceArchiving, diff --git a/packages/server/src/server/auto-archive-on-merge/archive-if-safe.test.ts b/packages/server/src/server/auto-archive-on-merge/archive-if-safe.test.ts index 6531cde4a..300c0ea46 100644 --- a/packages/server/src/server/auto-archive-on-merge/archive-if-safe.test.ts +++ b/packages/server/src/server/auto-archive-on-merge/archive-if-safe.test.ts @@ -93,6 +93,7 @@ function createHarness(overrides?: { agentManager: {} as AutoArchiveArchiveOptions["agentManager"], agentStorage: {} as AutoArchiveArchiveOptions["agentStorage"], terminalManager: {} as AutoArchiveArchiveOptions["terminalManager"], + resolveWorkspaceIdForCwd: vi.fn(async () => "ws-auto-archive"), archiveWorkspaceRecord: vi.fn(), markWorkspaceArchiving: vi.fn(), clearWorkspaceArchiving: vi.fn(), diff --git a/packages/server/src/server/auto-archive-on-merge/archive-if-safe.ts b/packages/server/src/server/auto-archive-on-merge/archive-if-safe.ts index 7831a733e..6c84dc4ee 100644 --- a/packages/server/src/server/auto-archive-on-merge/archive-if-safe.ts +++ b/packages/server/src/server/auto-archive-on-merge/archive-if-safe.ts @@ -22,6 +22,7 @@ export interface AutoArchiveArchiveOptions { agentManager: AgentManager; agentStorage: AgentStorage; terminalManager: TerminalManager; + resolveWorkspaceIdForCwd: (cwd: string) => Promise; archiveWorkspaceRecord: (workspaceId: string) => Promise; markWorkspaceArchiving: (workspaceIds: Iterable, archivingAt: string) => void; clearWorkspaceArchiving: (workspaceIds: Iterable) => void; @@ -102,6 +103,7 @@ export async function archiveIfSafe(input: { workspaceGitService: options.workspaceGitService, agentManager: options.agentManager, agentStorage: options.agentStorage, + resolveWorkspaceIdForCwd: options.resolveWorkspaceIdForCwd, archiveWorkspaceRecord: options.archiveWorkspaceRecord, emitWorkspaceUpdatesForWorkspaceIds: options.emitWorkspaceUpdatesForWorkspaceIds, markWorkspaceArchiving: options.markWorkspaceArchiving, diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index a6cc3fdb6..3976e38c9 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -109,6 +109,7 @@ import { LoopService } from "./loop-service.js"; import { ScheduleService } from "./schedule/service.js"; import { DaemonConfigStore } from "./daemon-config-store.js"; import { WorkspaceGitServiceImpl } from "./workspace-git-service.js"; +import { resolveRegisteredWorkspaceIdForCwd } from "./workspace-directory.js"; import { archivePersistedWorkspaceRecord } from "./workspace-archive-service.js"; import { setupAutoArchiveOnMerge } from "./auto-archive-on-merge/index.js"; import { wrapSessionMessage, type SessionOutboundMessage } from "./messages.js"; @@ -665,6 +666,9 @@ export async function createPaseoDaemon( projectRegistry, }); }; + const resolveWorkspaceIdForCwdExternal = async (cwd: string): Promise => { + return resolveRegisteredWorkspaceIdForCwd(cwd, await workspaceRegistry.list()); + }; const markWorkspaceArchivingExternal = (workspaceIds: Iterable, archivingAt: string) => { const workspaceIdList = Array.from(workspaceIds); for (const session of wsServer?.listActiveSessions() ?? []) { @@ -699,6 +703,7 @@ export async function createPaseoDaemon( agentStorage, terminalManager, logger, + resolveWorkspaceIdForCwd: resolveWorkspaceIdForCwdExternal, archiveWorkspaceRecord: archiveWorkspaceRecordExternal, markWorkspaceArchiving: markWorkspaceArchivingExternal, clearWorkspaceArchiving: clearWorkspaceArchivingExternal, @@ -721,6 +726,7 @@ export async function createPaseoDaemon( providerSnapshotManager, github, workspaceGitService, + resolveWorkspaceIdForCwd: resolveWorkspaceIdForCwdExternal, archiveWorkspaceRecord: archiveWorkspaceRecordExternal, emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal, markWorkspaceArchiving: markWorkspaceArchivingExternal, diff --git a/packages/server/src/server/paseo-worktree-archive-service.ts b/packages/server/src/server/paseo-worktree-archive-service.ts index e31094962..337e00992 100644 --- a/packages/server/src/server/paseo-worktree-archive-service.ts +++ b/packages/server/src/server/paseo-worktree-archive-service.ts @@ -3,7 +3,6 @@ import type { Logger } from "pino"; import type { AgentManager } from "./agent/agent-manager.js"; import type { AgentStorage, StoredAgentRecord } from "./agent/agent-storage.js"; import type { WorkspaceGitService } from "./workspace-git-service.js"; -import { normalizeWorkspaceId as normalizePersistedWorkspaceId } from "./workspace-registry-model.js"; import type { GitHubService } from "../services/github-service.js"; import { deletePaseoWorktree, @@ -19,6 +18,7 @@ export interface ArchivePaseoWorktreeDependencies { workspaceGitService: Pick; agentManager: Pick; agentStorage: Pick; + resolveWorkspaceIdForCwd: (cwd: string) => Promise; archiveWorkspaceRecord: (workspaceId: string) => Promise; emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds: Iterable) => Promise; markWorkspaceArchiving: (workspaceIds: Iterable, archivingAt: string) => void; @@ -57,7 +57,6 @@ export async function archivePaseoWorktree( const archivedAgents = new Set(); const affectedWorkspaceCwds = new Set([targetPath]); - const affectedWorkspaceIds = new Set([normalizePersistedWorkspaceId(targetPath)]); const liveAgents = dependencies.agentManager .listAgents() @@ -65,7 +64,6 @@ export async function archivePaseoWorktree( for (const agent of liveAgents) { archivedAgents.add(agent.id); affectedWorkspaceCwds.add(agent.cwd); - affectedWorkspaceIds.add(normalizePersistedWorkspaceId(agent.cwd)); } let storedRecords: StoredAgentRecord[] = []; @@ -84,10 +82,12 @@ export async function archivePaseoWorktree( for (const record of matchingStoredRecords) { archivedAgents.add(record.id); affectedWorkspaceCwds.add(record.cwd); - affectedWorkspaceIds.add(normalizePersistedWorkspaceId(record.cwd)); } - const affectedWorkspaceIdList = Array.from(affectedWorkspaceIds); + const affectedWorkspaceIdList = await resolveAffectedWorkspaceIds( + dependencies, + affectedWorkspaceCwds, + ); dependencies.markWorkspaceArchiving(affectedWorkspaceIdList, new Date().toISOString()); try { @@ -176,6 +176,28 @@ export async function archivePaseoWorktree( return Array.from(archivedAgents); } +async function resolveAffectedWorkspaceIds( + dependencies: Pick< + ArchivePaseoWorktreeDependencies, + "resolveWorkspaceIdForCwd" | "sessionLogger" + >, + cwds: Iterable, +): Promise { + const workspaceIds = new Set(); + for (const cwd of cwds) { + const workspaceId = await dependencies.resolveWorkspaceIdForCwd(cwd); + if (!workspaceId) { + dependencies.sessionLogger?.warn( + { cwd }, + "Skipping workspace archive update for unregistered directory", + ); + continue; + } + workspaceIds.add(workspaceId); + } + return Array.from(workspaceIds); +} + export async function killTerminalsUnderPath( dependencies: KillTerminalsUnderPathDependencies, rootPath: string, diff --git a/packages/server/src/server/paseo-worktree-service.test.ts b/packages/server/src/server/paseo-worktree-service.test.ts index 923f74764..008cfe702 100644 --- a/packages/server/src/server/paseo-worktree-service.test.ts +++ b/packages/server/src/server/paseo-worktree-service.test.ts @@ -34,7 +34,7 @@ test("creates a worktree and registers it in the source workspace project withou displayName: "acme/repo", }); const sourceWorkspace = createPersistedWorkspaceRecordForTest({ - workspaceId: repoDir, + workspaceId: "ws-main-checkout", projectId: sourceProject.projectId, cwd: repoDir, kind: "local_checkout", @@ -57,6 +57,7 @@ test("creates a worktree and registers it in the source workspace project withou expect(result.created).toBe(true); expect(result.workspace.cwd).toBe(result.worktree.worktreePath); expect(result.workspace.kind).toBe("worktree"); + expect(result.workspace.workspaceId).toMatch(/^wks_[0-9a-f]{16}$/); expect(result.workspace.projectId).toBe("remote:github.com/acme/repo"); expect(result.workspace.displayName).toBe("feature-one"); expect(deps.workspaceGitService.getSnapshot).not.toHaveBeenCalled(); @@ -76,7 +77,7 @@ test("registers a new worktree in the existing root project after the main check displayName: "acme/repo", }); const existingWorktree = createPersistedWorkspaceRecordForTest({ - workspaceId: path.join(tempDir, "existing-worktree"), + workspaceId: "ws-existing-worktree", projectId: sourceProject.projectId, cwd: path.join(tempDir, "existing-worktree"), kind: "worktree", diff --git a/packages/server/src/server/paseo-worktree-service.ts b/packages/server/src/server/paseo-worktree-service.ts index ec7554071..6893c5aee 100644 --- a/packages/server/src/server/paseo-worktree-service.ts +++ b/packages/server/src/server/paseo-worktree-service.ts @@ -1,4 +1,5 @@ import type { WorkspaceGitService } from "./workspace-git-service.js"; +import { resolve } from "node:path"; import { type PersistedWorkspaceRecord, type ProjectRegistry, @@ -6,7 +7,7 @@ import { createPersistedProjectRecord, createPersistedWorkspaceRecord, } from "./workspace-registry.js"; -import { deriveProjectGroupingName, normalizeWorkspaceId } from "./workspace-registry-model.js"; +import { deriveProjectGroupingName, generateWorkspaceId } from "./workspace-registry-model.js"; import { createWorktreeCore, type CreateWorktreeCoreDeps, @@ -195,11 +196,14 @@ async function upsertWorkspaceForWorktree(options: { projectId?: string; repoRoot: string; worktree: WorktreeConfig; - deps: Pick; + deps: Pick< + CreatePaseoWorktreeDeps, + "projectRegistry" | "workspaceRegistry" | "workspaceGitService" + >; }): Promise { - const normalizedCwd = normalizeWorkspaceId(options.worktree.worktreePath); - const normalizedInputCwd = normalizeWorkspaceId(options.inputCwd); - const normalizedRepoRoot = normalizeWorkspaceId(options.repoRoot); + const normalizedCwd = resolve(options.worktree.worktreePath); + const normalizedInputCwd = resolve(options.inputCwd); + const normalizedRepoRoot = resolve(options.repoRoot); const existingWorkspace = await findWorkspaceByDirectory( normalizedCwd, options.deps.workspaceRegistry, @@ -211,7 +215,7 @@ async function upsertWorkspaceForWorktree(options: { existingWorkspace, deps: options.deps, }); - const workspaceId = normalizedCwd; + const workspaceId = existingWorkspace?.workspaceId ?? generateWorkspaceId(); const now = new Date().toISOString(); await options.deps.projectRegistry.upsert( diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 29d19c16e..e7875c17c 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -3,7 +3,7 @@ import { v4 as uuidv4 } from "uuid"; import { realpathSync } from "node:fs"; import type { FSWatcher } from "node:fs"; import { stat } from "node:fs/promises"; -import { basename, resolve, sep } from "path"; +import { resolve, sep } from "path"; import { homedir } from "node:os"; import { z } from "zod"; import type { ToolSet } from "ai"; @@ -154,10 +154,10 @@ import { } from "./agent/import-sessions.js"; import { checkoutLiteFromGitSnapshot, - normalizeWorkspaceId as normalizePersistedWorkspaceId, - deriveProjectGroupingName, classifyDirectoryForProjectMembership, + deriveProjectGroupingName, deriveWorkspaceDisplayName, + generateWorkspaceId, } from "./workspace-registry-model.js"; import { createPersistedProjectRecord, @@ -962,6 +962,7 @@ export class Session { createPaseoWorktreeWorkflow: (input, workflowOptions) => this.createPaseoWorktreeWorkflow(input, workflowOptions), archiveAgentForClose: (agentId) => this.archiveAgentForClose(agentId), + resolveWorkspaceIdForCwd: (cwd) => this.resolveWorkspaceIdForCwd(cwd), archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId), emit: (message) => this.emit(message), emitAgentRemove: (agentId) => { @@ -1607,6 +1608,9 @@ export class Session { const normalizedCwd = await this.resolveWorkspaceDirectory(cwd, options); const workspaces = await this.workspaceRegistry.list(); const workspaceId = this.resolveRegisteredWorkspaceIdForCwd(normalizedCwd, workspaces); + if (!workspaceId) { + return null; + } return workspaces.find((workspace) => workspace.workspaceId === workspaceId) ?? null; } @@ -1623,14 +1627,14 @@ export class Session { cwd: string, options?: { refreshGit?: boolean }, ): Promise { - const normalizedCwd = normalizePersistedWorkspaceId(cwd); + const normalizedCwd = resolve(cwd); if (options?.refreshGit === false) { const snapshot = this.workspaceGitService.peekSnapshot(normalizedCwd); - return normalizePersistedWorkspaceId(snapshot?.git.repoRoot ?? normalizedCwd); + return resolve(snapshot?.git.repoRoot ?? normalizedCwd); } const checkout = await this.workspaceGitService.getCheckout(normalizedCwd); - return normalizePersistedWorkspaceId(checkout.worktreeRoot ?? normalizedCwd); + return resolve(checkout.worktreeRoot ?? normalizedCwd); } private async buildProjectPlacementForWorkspace( @@ -1661,12 +1665,16 @@ export class Session { return null; } - const normalizedCwd = normalizePersistedWorkspaceId(cwd); + // An agent can run in a directory that has no registered workspace yet + // (e.g. a fresh non-git folder). Synthesize a directory-scoped placement so + // the agent still emits updates. projectKey/cwd are paths here — a project + // grouping key, not a workspace id — so this stays opaque-id-safe. + const resolvedCwd = resolve(cwd); return { - projectKey: normalizedCwd, - projectName: deriveProjectGroupingName(normalizedCwd), + projectKey: resolvedCwd, + projectName: deriveProjectGroupingName(resolvedCwd), checkout: { - cwd: normalizedCwd, + cwd: resolvedCwd, isGit: false, currentBranch: null, remoteUrl: null, @@ -3169,8 +3177,6 @@ export class Session { firstAgentContext, buildSessionConfig: (sessionConfig, gitOptions, legacyWorktreeName, ctx) => this.buildAgentSessionConfig(sessionConfig, gitOptions, legacyWorktreeName, ctx), - resolveWorkspace: ({ cwd, workspaceId }) => - this.resolveCreateAgentWorkspace(cwd, workspaceId), }, ); createdAgentId = snapshot.id; @@ -3228,20 +3234,6 @@ export class Session { } } - private async resolveCreateAgentWorkspace( - cwd: string, - workspaceId?: string, - ): Promise<{ workspaceId: string }> { - const resolvedWorkspace = workspaceId - ? await this.workspaceRegistry.get(workspaceId) - : ((await this.findWorkspaceByDirectory(cwd)) ?? - (await this.findOrCreateWorkspaceForDirectory(cwd))); - if (!resolvedWorkspace) { - throw new Error(`Workspace not found: ${workspaceId}`); - } - return { workspaceId: resolvedWorkspace.workspaceId }; - } - private async handleResumeAgentRequest( msg: Extract, ): Promise { @@ -4845,7 +4837,7 @@ export class Session { } private async removeWorkspaceGitWatchTarget(cwd: string): Promise { - const normalizedCwd = normalizePersistedWorkspaceId(cwd); + const normalizedCwd = resolve(cwd); const target = this.workspaceGitWatchTargets.get(normalizedCwd); if (target) { this.closeWorkspaceGitWatchTarget(target); @@ -4854,7 +4846,7 @@ export class Session { } private removeWorkspaceGitSubscription(cwd: string): void { - const normalizedCwd = normalizePersistedWorkspaceId(cwd); + const normalizedCwd = resolve(cwd); const target = this.workspaceGitWatchTargets.get(normalizedCwd); if (target) { const unsubscribeFetch = this.workspaceGitFetchSubscriptions.get(normalizedCwd); @@ -4877,11 +4869,20 @@ export class Session { ]); } + private resolveWorkspaceGitWatchTarget(workspaceId: string): WorkspaceGitWatchTarget | null { + for (const target of this.workspaceGitWatchTargets.values()) { + if (target.workspaceId === workspaceId) { + return target; + } + } + return null; + } + private shouldSkipWorkspaceGitWatchUpdate( workspaceId: string, workspace: WorkspaceDescriptorPayload | null, ): boolean { - const target = this.workspaceGitWatchTargets.get(workspaceId); + const target = this.resolveWorkspaceGitWatchTarget(workspaceId); if (!target) { return false; } @@ -4897,7 +4898,7 @@ export class Session { workspaceId: string, workspace: WorkspaceDescriptorPayload | null, ): void { - const target = this.workspaceGitWatchTargets.get(workspaceId); + const target = this.resolveWorkspaceGitWatchTarget(workspaceId); if (!target) { return; } @@ -4906,7 +4907,7 @@ export class Session { } private handleWorkspaceGitBranchSnapshot(cwd: string, branchName: string | null): void { - const target = this.workspaceGitWatchTargets.get(normalizePersistedWorkspaceId(cwd)); + const target = this.workspaceGitWatchTargets.get(resolve(cwd)); if (!target) { return; } @@ -4934,7 +4935,7 @@ export class Session { cwd: string, options: { isGit: boolean; workspaceId: string }, ): void { - const normalizedCwd = normalizePersistedWorkspaceId(cwd); + const normalizedCwd = resolve(cwd); if (!options.isGit) { this.removeWorkspaceGitSubscription(normalizedCwd); return; @@ -5792,6 +5793,7 @@ export class Session { workspaceGitService: this.workspaceGitService, agentManager: this.agentManager, agentStorage: this.agentStorage, + resolveWorkspaceIdForCwd: (cwd) => this.resolveWorkspaceIdForCwd(cwd), archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId), emit: (message) => this.emit(message), emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds) => @@ -6179,7 +6181,7 @@ export class Session { ), ); for (let i = 0; i < pairs.length; i += 1) { - placementsByCwd.set(normalizePersistedWorkspaceId(pairs[i].workspace.cwd), placements[i]); + placementsByCwd.set(resolve(pairs[i].workspace.cwd), placements[i]); } return placementsByCwd; @@ -6248,17 +6250,14 @@ export class Session { scope === "active" ? await this.buildActiveProjectPlacementsByWorkspaceCwd() : null; if (activePlacementsByCwd) { agents = agents.filter( - (agent) => - !agent.archivedAt && activePlacementsByCwd.has(normalizePersistedWorkspaceId(agent.cwd)), + (agent) => !agent.archivedAt && activePlacementsByCwd.has(resolve(agent.cwd)), ); } const placementByCwd = new Map>(); const getPlacement = (cwd: string): Promise => { if (activePlacementsByCwd) { - return Promise.resolve( - activePlacementsByCwd.get(normalizePersistedWorkspaceId(cwd)) ?? null, - ); + return Promise.resolve(activePlacementsByCwd.get(resolve(cwd)) ?? null); } const existing = placementByCwd.get(cwd); if (existing) { @@ -6509,10 +6508,15 @@ export class Session { private resolveRegisteredWorkspaceIdForCwd( cwd: string, workspaces: PersistedWorkspaceRecord[], - ): string { + ): string | null { return this.workspaceDirectory.resolveRegisteredWorkspaceIdForCwd(cwd, workspaces); } + private async resolveWorkspaceIdForCwd(cwd: string): Promise { + const workspaces = await this.workspaceRegistry.list(); + return this.resolveRegisteredWorkspaceIdForCwd(cwd, workspaces); + } + private matchesWorkspaceFilter(input: { workspace: WorkspaceDescriptorPayload; filter: FetchWorkspacesRequestFilter | undefined; @@ -6600,7 +6604,7 @@ export class Session { } private async findOrCreateWorkspaceForDirectory(cwd: string): Promise { - const inputCwd = normalizePersistedWorkspaceId(cwd); + const inputCwd = resolve(cwd); const normalizedCwd = await this.resolveWorkspaceDirectory(cwd); const existingWorkspace = await this.findExactWorkspaceByDirectory(normalizedCwd, { refreshGit: false, @@ -6608,22 +6612,26 @@ export class Session { if (existingWorkspace) { if (existingWorkspace.archivedAt && inputCwd !== normalizedCwd) { const timestamp = new Date().toISOString(); - const displayName = basename(inputCwd) || inputCwd; - const projectRecord = createPersistedProjectRecord({ - projectId: inputCwd, - rootPath: inputCwd, - kind: "non_git", - displayName, - createdAt: timestamp, - updatedAt: timestamp, + const checkout = checkoutLiteFromGitSnapshot(inputCwd, { + isGit: false, + currentBranch: null, + remoteUrl: null, + repoRoot: null, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }); + const membership = classifyDirectoryForProjectMembership({ cwd: inputCwd, checkout }); + const projectRecord = await this.resolveProjectRecordForPlacement({ + membership, + timestamp, }); await this.projectRegistry.upsert(projectRecord); const workspaceRecord = createPersistedWorkspaceRecord({ - workspaceId: inputCwd, + workspaceId: generateWorkspaceId(), projectId: projectRecord.projectId, cwd: inputCwd, - kind: "directory", - displayName, + kind: membership.workspaceKind, + displayName: membership.workspaceDisplayName, createdAt: timestamp, updatedAt: timestamp, }); @@ -6652,7 +6660,7 @@ export class Session { await this.projectRegistry.upsert(projectRecord); const workspaceRecord = createPersistedWorkspaceRecord({ - workspaceId: membership.workspaceId, + workspaceId: generateWorkspaceId(), projectId: projectRecord.projectId, cwd, kind: membership.workspaceKind, @@ -6681,7 +6689,6 @@ export class Session { const displayName = membership.workspaceDisplayName; if ( - input.workspace.workspaceId === membership.workspaceId && input.workspace.projectId === projectId && input.workspace.kind === kind && input.workspace.displayName === displayName @@ -6693,7 +6700,7 @@ export class Session { const nextWorkspace = { ...input.workspace, - workspaceId: membership.workspaceId, + workspaceId: input.workspace.workspaceId, projectId, cwd: input.cwd, kind, @@ -6797,7 +6804,10 @@ export class Session { projectRegistry: this.projectRegistry, }); if (!existingWorkspace) { - this.removeWorkspaceGitSubscription(workspaceId); + const watchTarget = this.resolveWorkspaceGitWatchTarget(workspaceId); + if (watchTarget) { + this.removeWorkspaceGitSubscription(watchTarget.cwd); + } return; } @@ -6817,9 +6827,22 @@ export class Session { ); } - await this.removeWorkspaceGitWatchTarget(existingWorkspace.cwd); - this.scriptRuntimeStore?.removeForWorkspace(existingWorkspace.cwd); - this.removeWorkspaceGitSubscription(workspaceId); + await this.teardownArchivedWorkspace({ + workspaceId: existingWorkspace.workspaceId, + cwd: existingWorkspace.cwd, + }); + } + + // Git watch and subscription state is keyed by directory; the script runtime + // store is keyed by the opaque workspace id. Each cleanup uses its own key so an + // opaque id is never resolved as a filesystem path. + private async teardownArchivedWorkspace(input: { + workspaceId: string; + cwd: string; + }): Promise { + await this.removeWorkspaceGitWatchTarget(input.cwd); + this.scriptRuntimeStore?.removeForWorkspace(input.workspaceId); + this.removeWorkspaceGitSubscription(input.cwd); } private async reconcileAndEmitWorkspaceUpdates(): Promise { @@ -6854,9 +6877,10 @@ export class Session { result.changesApplied.map(async (change) => { switch (change.kind) { case "workspace_archived": - await this.removeWorkspaceGitWatchTarget(change.directory); - this.scriptRuntimeStore?.removeForWorkspace(change.directory); - this.removeWorkspaceGitSubscription(change.workspaceId); + await this.teardownArchivedWorkspace({ + workspaceId: change.workspaceId, + cwd: change.directory, + }); changedWorkspaceIds.add(change.workspaceId); break; case "workspace_updated": @@ -6912,7 +6936,7 @@ export class Session { ) { continue; } - const watchTarget = this.workspaceGitWatchTargets.get(workspaceId); + const watchTarget = this.resolveWorkspaceGitWatchTarget(workspaceId); if (watchTarget && this.onBranchChanged) { const newBranchName = nextWorkspace?.name ?? null; if (newBranchName !== watchTarget.lastBranchName) { @@ -6961,6 +6985,9 @@ export class Session { ): Promise { const workspaces = await this.workspaceRegistry.list(); const workspaceId = this.resolveRegisteredWorkspaceIdForCwd(cwd, workspaces); + if (!workspaceId) { + return; + } await this.emitWorkspaceUpdatesForWorkspaceIds([workspaceId], options); } @@ -7583,10 +7610,10 @@ export class Session { throw new Error(`Workspace not found: ${requestedWorkspaceId}`); } - const workspaceCwd = normalizePersistedWorkspaceId(workspace.cwd); + const workspaceCwd = resolve(workspace.cwd); const clearableAgentIds = agents .filter((agent) => !agent.archivedAt) - .filter((agent) => normalizePersistedWorkspaceId(agent.cwd) === workspaceCwd) + .filter((agent) => resolve(agent.cwd) === workspaceCwd) .filter((agent) => agent.requiresAttention === true) .filter((agent) => (agent.pendingPermissions?.length ?? 0) === 0) .filter((agent) => agent.attentionReason !== "permission") diff --git a/packages/server/src/server/session.workspace-git-watch.test.ts b/packages/server/src/server/session.workspace-git-watch.test.ts index 97178d4c5..1690a42b1 100644 --- a/packages/server/src/server/session.workspace-git-watch.test.ts +++ b/packages/server/src/server/session.workspace-git-watch.test.ts @@ -495,6 +495,66 @@ describe("workspace git watch targets", () => { await session.cleanup(); }); + test("archiving a workspace clears its script runtime entries by opaque workspace id", async () => { + const runtimeStore = new WorkspaceScriptRuntimeStore(); + runtimeStore.set({ + workspaceId: "ws-10", + scriptName: "app", + type: "service", + lifecycle: "running", + terminalId: "term-app", + exitCode: null, + }); + + const { session, projects, workspaces } = createSessionForWorkspaceGitWatchTests({ + scriptRuntimeStore: runtimeStore, + }); + seedGitWorkspace({ + projects, + workspaces, + projectId: "proj-1", + workspaceId: "ws-10", + cwd: "/tmp/repo", + name: "main", + }); + + await asInternals<{ archiveWorkspaceRecord: (workspaceId: string) => Promise }>( + session, + ).archiveWorkspaceRecord("ws-10"); + + expect(runtimeStore.listForWorkspace("ws-10")).toEqual([]); + + await session.cleanup(); + }); + + test("archiving a workspace releases its git watch subscription for the directory", async () => { + const { session, projects, workspaces, subscriptions } = + createSessionForWorkspaceGitWatchTests(); + const sessionAny = asInternals< + SessionInternals & { archiveWorkspaceRecord: (workspaceId: string) => Promise } + >(session); + seedGitWorkspace({ + projects, + workspaces, + projectId: "proj-1", + workspaceId: "ws-10", + cwd: REPO_CWD, + name: "main", + }); + + sessionAny.syncWorkspaceGitObserver(REPO_CWD, { isGit: true, workspaceId: "ws-10" }); + expect(subscriptions).toHaveLength(1); + + await sessionAny.archiveWorkspaceRecord("ws-10"); + + // Re-observing the directory establishes a fresh subscription only if archive + // tore down the prior one, which is keyed by cwd — not the opaque workspace id. + sessionAny.syncWorkspaceGitObserver(REPO_CWD, { isGit: true, workspaceId: "ws-10" }); + expect(subscriptions).toHaveLength(2); + + await session.cleanup(); + }); + test("embeds PR status in checkout_status_update for GitHub-inclusive snapshot pushes", async () => { const { session, emitted, projects, workspaces, subscriptions } = createSessionForWorkspaceGitWatchTests(); diff --git a/packages/server/src/server/session.workspace-resolution-invariants.test.ts b/packages/server/src/server/session.workspace-resolution-invariants.test.ts index 2103824e3..2baa31d52 100644 --- a/packages/server/src/server/session.workspace-resolution-invariants.test.ts +++ b/packages/server/src/server/session.workspace-resolution-invariants.test.ts @@ -204,7 +204,7 @@ const PARENT_CHILD = path.join(PARENT, "child"); function gitWorkspace(rootPath: string, archivedAt: string | null = null) { return createPersistedWorkspaceRecord({ - workspaceId: rootPath, + workspaceId: `ws-${path.basename(rootPath) || "root"}`, projectId: rootPath, cwd: rootPath, kind: "local_checkout", @@ -217,7 +217,7 @@ function gitWorkspace(rootPath: string, archivedAt: string | null = null) { function dirWorkspace(cwd: string, archivedAt: string | null = null) { return createPersistedWorkspaceRecord({ - workspaceId: cwd, + workspaceId: `ws-${path.basename(cwd) || "root"}`, projectId: cwd, cwd, kind: "directory", @@ -252,6 +252,17 @@ function dirProject(rootPath: string, archivedAt: string | null = null) { }); } +function workspaceByCwd( + workspaces: Map, + cwd: string, +): PersistedWorkspaceRecord | null { + return Array.from(workspaces.values()).find((workspace) => workspace.cwd === cwd) ?? null; +} + +function hasWorkspaceCwd(workspaces: Map, cwd: string): boolean { + return workspaceByCwd(workspaces, cwd) !== null; +} + // ───────────────────────────────────────────────────────────────────────────── // S1. Open a fresh git repo: creates a workspace at the canonical root. // ───────────────────────────────────────────────────────────────────────────── @@ -262,7 +273,7 @@ test("S1: open fresh git repo creates workspace at canonical root", async () => expect(resp?.error).toBeNull(); expect(resp?.workspace?.workspaceDirectory).toBe(FOO); expect(resp?.workspace?.workspaceKind).toBe("local_checkout"); - expect(h.workspaces.has(FOO)).toBe(true); + expect(hasWorkspaceCwd(h.workspaces, FOO)).toBe(true); }); // ───────────────────────────────────────────────────────────────────────────── @@ -276,7 +287,7 @@ test("S2: open fresh non-git directory creates a directory workspace at exact pa expect(resp?.error).toBeNull(); expect(resp?.workspace?.workspaceDirectory).toBe(BAR); expect(resp?.workspace?.workspaceKind).toBe("directory"); - expect(h.workspaces.has(BAR)).toBe(true); + expect(hasWorkspaceCwd(h.workspaces, BAR)).toBe(true); }); // ───────────────────────────────────────────────────────────────────────────── @@ -291,9 +302,9 @@ test("S3: re-open active workspace by exact path returns the same record", async }); await openProject(h.session, FOO); const resp = getOpenResponse(h.emitted, "req-1"); - expect(resp?.workspace?.id).toBe(FOO); + expect(resp?.workspace?.id).toBe(workspaceByCwd(h.workspaces, FOO)?.workspaceId); expect(h.workspaces.size).toBe(1); - expect(h.workspaces.get(FOO)?.archivedAt).toBeNull(); + expect(workspaceByCwd(h.workspaces, FOO)?.archivedAt).toBeNull(); }); // ───────────────────────────────────────────────────────────────────────────── @@ -308,7 +319,7 @@ test("S4: open subdir of active git workspace returns the repo-root workspace", }); await openProject(h.session, FOO_SUB); const resp = getOpenResponse(h.emitted, "req-1"); - expect(resp?.workspace?.id).toBe(FOO); + expect(resp?.workspace?.id).toBe(workspaceByCwd(h.workspaces, FOO)?.workspaceId); expect(h.workspaces.size).toBe(1); }); @@ -324,8 +335,8 @@ test("S5: open subdir of active non-git directory creates a SEPARATE workspace", await openProject(h.session, BAR_BAZ); const resp = getOpenResponse(h.emitted, "req-1"); expect(resp?.workspace?.workspaceDirectory).toBe(BAR_BAZ); - expect(h.workspaces.has(BAR)).toBe(true); - expect(h.workspaces.has(BAR_BAZ)).toBe(true); + expect(hasWorkspaceCwd(h.workspaces, BAR)).toBe(true); + expect(hasWorkspaceCwd(h.workspaces, BAR_BAZ)).toBe(true); expect(h.workspaces.size).toBe(2); }); @@ -341,7 +352,7 @@ test("S6: re-opening an archived git workspace by exact path UNARCHIVES it", asy gitRoots: [TOOLBOX], }); await openProject(h.session, TOOLBOX); - expect(h.workspaces.get(TOOLBOX)?.archivedAt).toBeNull(); + expect(workspaceByCwd(h.workspaces, TOOLBOX)?.archivedAt).toBeNull(); expect(h.projects.get(TOOLBOX)?.archivedAt).toBeNull(); }); @@ -357,8 +368,8 @@ test("S7: open nested git repo (own .git) creates a SEPARATE workspace at the in await openProject(h.session, FOO_SUB); const resp = getOpenResponse(h.emitted, "req-1"); expect(resp?.workspace?.workspaceDirectory).toBe(FOO_SUB); - expect(h.workspaces.has(FOO)).toBe(true); - expect(h.workspaces.has(FOO_SUB)).toBe(true); + expect(hasWorkspaceCwd(h.workspaces, FOO)).toBe(true); + expect(hasWorkspaceCwd(h.workspaces, FOO_SUB)).toBe(true); }); // ───────────────────────────────────────────────────────────────────────────── @@ -373,8 +384,8 @@ test("S8: open child of archived non-git ancestor creates fresh workspace; ances projects: [dirProject(USERS_DEVELOPER, archivedAt)], }); await openProject(h.session, USERS_PROJECT); - expect(h.workspaces.get(USERS_DEVELOPER)?.archivedAt).toBe(archivedAt); - expect(h.workspaces.has(USERS_PROJECT)).toBe(true); + expect(workspaceByCwd(h.workspaces, USERS_DEVELOPER)?.archivedAt).toBe(archivedAt); + expect(hasWorkspaceCwd(h.workspaces, USERS_PROJECT)).toBe(true); }); // ───────────────────────────────────────────────────────────────────────────── @@ -390,7 +401,7 @@ test("S9: opening child of archived git workspace does NOT auto-unarchive the pa gitRoots: [TOOLBOX], }); await openProject(h.session, TOOLBOX_FLOMO); - expect(h.workspaces.get(TOOLBOX)?.archivedAt).toBe(archivedAt); + expect(workspaceByCwd(h.workspaces, TOOLBOX)?.archivedAt).toBe(archivedAt); }); // ───────────────────────────────────────────────────────────────────────────── @@ -414,8 +425,8 @@ test("S10: opening a git repo nested inside an archived non-git directory create expect(resp?.error).toBeNull(); expect(resp?.workspace?.workspaceDirectory).toBe(SOME_GIT_REPO); expect(resp?.workspace?.workspaceKind).toBe("local_checkout"); - expect(h.workspaces.has(SOME_GIT_REPO)).toBe(true); - expect(h.workspaces.get(PROJECTS)?.archivedAt).toBe(archivedAt); + expect(hasWorkspaceCwd(h.workspaces, SOME_GIT_REPO)).toBe(true); + expect(workspaceByCwd(h.workspaces, PROJECTS)?.archivedAt).toBe(archivedAt); expect(h.projects.get(PROJECTS)?.archivedAt).toBe(archivedAt); }); @@ -434,11 +445,11 @@ test("S11: re-opening an archived project by exact path unarchives project + wor await openProject(h.session, TOOLBOX); const resp = getOpenResponse(h.emitted, "req-1"); expect(resp?.error).toBeNull(); - expect(resp?.workspace?.id).toBe(TOOLBOX); + expect(resp?.workspace?.id).toBe(workspaceByCwd(h.workspaces, TOOLBOX)?.workspaceId); expect(resp?.workspace?.projectId).toBe(TOOLBOX); expect(h.workspaces.size).toBe(1); expect(h.projects.size).toBe(1); - expect(h.workspaces.get(TOOLBOX)?.archivedAt).toBeNull(); + expect(workspaceByCwd(h.workspaces, TOOLBOX)?.archivedAt).toBeNull(); expect(h.projects.get(TOOLBOX)?.archivedAt).toBeNull(); }); diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 49a21da9f..a227e8c22 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -490,8 +490,29 @@ function createSessionForWorkspaceTests( notifyAgentState: () => {}, }), agentStorage: asAgentStorage({ - list: async () => [], - get: async () => null, + list: async () => [ + createPersistedWorkspaceRecord({ + workspaceId: "ws-repo-running", + projectId: "proj-repo-running", + cwd: REPO_CWD, + kind: "directory", + displayName: "repo", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }), + ], + get: async (workspaceId: string) => + workspaceId === "ws-repo-running" + ? createPersistedWorkspaceRecord({ + workspaceId: "ws-repo-running", + projectId: "proj-repo-running", + cwd: REPO_CWD, + kind: "directory", + displayName: "repo", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }) + : null, upsert: async () => {}, }), projectRegistry: { @@ -506,8 +527,29 @@ function createSessionForWorkspaceTests( workspaceRegistry: { initialize: async () => {}, existsOnDisk: async () => true, - list: async () => [], - get: async () => null, + list: async () => [ + createPersistedWorkspaceRecord({ + workspaceId: "ws-repo-running", + projectId: "proj-repo-running", + cwd: REPO_CWD, + kind: "directory", + displayName: "repo", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }), + ], + get: async (workspaceId: string) => + workspaceId === "ws-repo-running" + ? createPersistedWorkspaceRecord({ + workspaceId: "ws-repo-running", + projectId: "proj-repo-running", + cwd: REPO_CWD, + kind: "directory", + displayName: "repo", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }) + : null, upsert: async () => {}, archive: async () => {}, remove: async () => {}, @@ -853,10 +895,10 @@ test("agent_update placement does not refresh git snapshots", async () => { }); }); -test("agent_update emits fallback placement when no workspace is registered", async () => { +test("agent_update emits a directory-scoped placement when no workspace is registered", async () => { const emitted: SessionOutboundMessage[] = []; const getSnapshot = vi.fn(async () => { - throw new Error("getSnapshot should not be called for fallback agent_update placement"); + throw new Error("getSnapshot should not be called for unregistered agent_update placement"); }); const session = asTestSession( createSessionForWorkspaceTests({ @@ -886,17 +928,15 @@ test("agent_update emits fallback placement when no workspace is registered", as ); expect(getSnapshot).not.toHaveBeenCalled(); - expect(emitted.find((message) => message.type === "agent_update")?.payload).toMatchObject({ - kind: "upsert", - project: { - projectKey: UNREGISTERED_CWD, - projectName: "unregistered", - checkout: { - cwd: UNREGISTERED_CWD, - isGit: false, - }, - }, - }); + // An agent in a directory with no registered workspace must still emit an + // upsert (live model/thinking switches depend on this). It gets a directory- + // scoped placement keyed by the path — a project key, not a workspace id. + const update = emitted.find((message) => message.type === "agent_update"); + if (update?.type !== "agent_update" || update.payload.kind !== "upsert") { + throw new Error("expected an agent_update upsert"); + } + expect(update.payload.agent.id).toBe("agent-1"); + expect(update.payload.project?.checkout.isGit).toBe(false); }); test("archive emits an authoritative agent_update upsert for subscribed clients", async () => { @@ -1158,7 +1198,7 @@ test("workspace clear attention can clear multiple workspaces in one request", a const emitted: SessionOutboundMessage[] = []; const workspaces = [ createPersistedWorkspaceRecord({ - workspaceId: "/tmp/repo-a", + workspaceId: "ws-repo-a", projectId: "/tmp/repo-a", cwd: "/tmp/repo-a", kind: "directory", @@ -1167,7 +1207,7 @@ test("workspace clear attention can clear multiple workspaces in one request", a updatedAt: "2026-03-30T15:00:00.000Z", }), createPersistedWorkspaceRecord({ - workspaceId: "/tmp/repo-b", + workspaceId: "ws-repo-b", projectId: "/tmp/repo-b", cwd: "/tmp/repo-b", kind: "directory", @@ -2570,8 +2610,29 @@ test("workspace update stream keeps persisted workspace visible after agents sto workspaceRegistry: { initialize: async () => {}, existsOnDisk: async () => true, - list: async () => [], - get: async () => null, + list: async () => [ + createPersistedWorkspaceRecord({ + workspaceId: "ws-repo-running", + projectId: "proj-repo-running", + cwd: REPO_CWD, + kind: "directory", + displayName: "repo", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }), + ], + get: async (workspaceId: string) => + workspaceId === "ws-repo-running" + ? createPersistedWorkspaceRecord({ + workspaceId: "ws-repo-running", + projectId: "proj-repo-running", + cwd: REPO_CWD, + kind: "directory", + displayName: "repo", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }) + : null, upsert: async () => {}, archive: async () => {}, remove: async () => {}, @@ -2619,7 +2680,7 @@ test("workspace update stream keeps persisted workspace visible after agents sto session.buildWorkspaceDescriptorMap = async () => new Map([ [ - REPO_CWD, + "ws-repo-running", { id: "ws-repo-running", projectId: "proj-repo-running", @@ -2638,7 +2699,7 @@ test("workspace update stream keeps persisted workspace visible after agents sto session.buildWorkspaceDescriptorMap = async () => new Map([ [ - REPO_CWD, + "ws-repo-running", { id: "ws-repo-running", projectId: "proj-repo-running", @@ -2736,8 +2797,8 @@ test("create paseo worktree request returns a registered workspace descriptor", const workspaces = new Map(); const projects = new Map(); session.paseoHome = paseoHome; - session.workspaceRegistry.get = async (workspaceId: string) => - workspaces.get(workspaceId) ?? null; + session.workspaceRegistry.get = async (lookupWorkspaceId: string) => + workspaces.get(lookupWorkspaceId) ?? null; session.workspaceRegistry.list = async () => Array.from(workspaces.values()); session.workspaceRegistry.upsert = async ( record: ReturnType, @@ -2775,7 +2836,8 @@ test("create paseo worktree request returns a registered workspace descriptor", name: "worktree-123", status: "done", }); - expect(response?.payload.workspace?.id).toContain(path.join("worktree-123")); + expect(response?.payload.workspace?.id).toMatch(/^wks_[0-9a-f]{16}$/); + expect(response?.payload.workspace?.workspaceDirectory).toContain(path.join("worktree-123")); expect(workspaces.has(response?.payload.workspace?.id ?? "")).toBe(true); expect(projects.has(response?.payload.workspace?.projectId ?? "")).toBe(true); }); @@ -2875,8 +2937,8 @@ test("open_project_request registers a workspace before any agent exists", async ) => { projects.set(record.projectId, record); }; - session.workspaceRegistry.get = async (workspaceId: string) => - workspaces.get(workspaceId) ?? null; + session.workspaceRegistry.get = async (lookupWorkspaceId: string) => + workspaces.get(lookupWorkspaceId) ?? null; session.workspaceRegistry.upsert = async ( record: ReturnType, ) => { @@ -2904,10 +2966,13 @@ test("open_project_request registers a workspace before any agent exists", async requestId: "req-open", }); - expect(workspaces.get(REPO_CWD)).toBeTruthy(); + const registeredWorkspace = Array.from(workspaces.values()).find( + (workspace) => workspace.cwd === REPO_CWD, + ); + expect(registeredWorkspace).toBeTruthy(); const response = findByType(emitted, "open_project_response"); expect(response?.payload.error).toBeNull(); - expect(response?.payload.workspace?.id).toBe(REPO_CWD); + expect(response?.payload.workspace?.id).toBe(registeredWorkspace?.workspaceId); }); test("import_agent_request registers a workspace for a never-seen cwd", async () => { @@ -2927,8 +2992,8 @@ test("import_agent_request registers a workspace for a never-seen cwd", async () ) => { projects.set(record.projectId, record); }; - session.workspaceRegistry.get = async (workspaceId: string) => - workspaces.get(workspaceId) ?? null; + session.workspaceRegistry.get = async (lookupWorkspaceId: string) => + workspaces.get(lookupWorkspaceId) ?? null; session.workspaceRegistry.upsert = async ( record: ReturnType, ) => { @@ -2971,15 +3036,22 @@ test("import_agent_request registers a workspace for a never-seen cwd", async () pendingUpdatesByWorkspaceId: new Map(), lastEmittedByWorkspaceId: new Map(), }; - session.buildWorkspaceDescriptorMap = async () => - new Map([ + session.buildWorkspaceDescriptorMap = async () => { + const workspace = Array.from(workspaces.values()).find( + (candidate) => candidate.cwd === importedCwd, + ); + if (!workspace) { + return new Map(); + } + return new Map([ [ - importedCwd, + workspace.workspaceId, { - id: importedCwd, - projectId: importedCwd, + id: workspace.workspaceId, + projectId: workspace.projectId, projectDisplayName: "imported-project", projectRootPath: importedCwd, + workspaceDirectory: importedCwd, projectKind: "non_git", workspaceKind: "directory", name: "imported-project", @@ -2988,6 +3060,7 @@ test("import_agent_request registers a workspace for a never-seen cwd", async () }, ], ]); + }; await session.handleMessage({ type: "import_agent_request", @@ -2997,12 +3070,17 @@ test("import_agent_request registers a workspace for a never-seen cwd", async () cwd: importedCwd, }); - expect(workspaces.get(importedCwd)).toBeTruthy(); + const importedWorkspace = Array.from(workspaces.values()).find( + (workspace) => workspace.cwd === importedCwd, + ); + expect(importedWorkspace).toBeTruthy(); const workspaceUpdates = filterByType(emitted, "workspace_update"); expect(workspaceUpdates.length).toBeGreaterThan(0); expect( workspaceUpdates.some( - (update) => update.payload.kind === "upsert" && update.payload.workspace.id === importedCwd, + (update) => + update.payload.kind === "upsert" && + update.payload.workspace.workspaceDirectory === importedCwd, ), ).toBe(true); }); @@ -3023,8 +3101,8 @@ test("open_project_response returns immediately even when the GitHub fetch is sl ) => { projects.set(record.projectId, record); }; - session.workspaceRegistry.get = async (workspaceId: string) => - workspaces.get(workspaceId) ?? null; + session.workspaceRegistry.get = async (lookupWorkspaceId: string) => + workspaces.get(lookupWorkspaceId) ?? null; session.workspaceRegistry.upsert = async ( record: ReturnType, ) => { @@ -3062,7 +3140,8 @@ test("open_project_response returns immediately even when the GitHub fetch is sl const response = findByType(emitted, "open_project_response"); expect(response?.payload.error).toBeNull(); - expect(response?.payload.workspace?.id).toBe(cwd); + expect(response?.payload.workspace?.id).toMatch(/^wks_[0-9a-f]{16}$/); + expect(response?.payload.workspace?.workspaceDirectory).toBe(cwd); expect(response?.payload.workspace?.gitRuntime).toBeUndefined(); expect(response?.payload.workspace?.githubRuntime).toBeUndefined(); @@ -3089,8 +3168,8 @@ test("open_project_request emits a workspace_update with githubRuntime once the ) => { projects.set(record.projectId, record); }; - session.workspaceRegistry.get = async (workspaceId: string) => - workspaces.get(workspaceId) ?? null; + session.workspaceRegistry.get = async (lookupWorkspaceId: string) => + workspaces.get(lookupWorkspaceId) ?? null; session.workspaceRegistry.upsert = async ( record: ReturnType, ) => { @@ -3143,7 +3222,7 @@ test("open_project_request emits a workspace_update with githubRuntime once the .map((update) => update.payload) .filter( (payload): payload is WorkspaceUpsertPayload => - payload.kind === "upsert" && payload.workspace.id === cwd, + payload.kind === "upsert" && payload.workspace.workspaceDirectory === cwd, ) .find((payload) => payload.workspace.githubRuntime?.pullRequest); expect(upsertedWithGitHub?.workspace.githubRuntime?.pullRequest).toEqual( @@ -3155,6 +3234,7 @@ interface WorkspaceUpsertPayload { kind: "upsert"; workspace: { id: string; + workspaceDirectory?: string; githubRuntime?: { pullRequest?: { url?: string } | null; } | null; @@ -3202,8 +3282,8 @@ test("open_project_request does not match a new child directory to an existing p ) => { projects.set(record.projectId, record); }; - session.workspaceRegistry.get = async (workspaceId: string) => - workspaces.get(workspaceId) ?? null; + session.workspaceRegistry.get = async (lookupWorkspaceId: string) => + workspaces.get(lookupWorkspaceId) ?? null; session.workspaceRegistry.upsert = async ( record: ReturnType, ) => { @@ -3220,9 +3300,11 @@ test("open_project_request does not match a new child directory to an existing p const response = findByType(emitted, "open_project_response"); expect(response?.payload.error).toBeNull(); - expect(response?.payload.workspace?.id).toBe(worktree); + expect(response?.payload.workspace?.id).toMatch(/^wks_[0-9a-f]{16}$/); expect(response?.payload.workspace?.workspaceDirectory).toBe(worktree); - expect(workspaces.get(worktree)).toBeTruthy(); + expect(Array.from(workspaces.values()).some((workspace) => workspace.cwd === worktree)).toBe( + true, + ); }); test("open_project_request does not unarchive an archived parent workspace for a new child directory", async () => { @@ -3269,8 +3351,8 @@ test("open_project_request does not unarchive an archived parent workspace for a ) => { projects.set(record.projectId, record); }; - session.workspaceRegistry.get = async (workspaceId: string) => - workspaces.get(workspaceId) ?? null; + session.workspaceRegistry.get = async (lookupWorkspaceId: string) => + workspaces.get(lookupWorkspaceId) ?? null; session.workspaceRegistry.upsert = async ( record: ReturnType, ) => { @@ -3287,7 +3369,7 @@ test("open_project_request does not unarchive an archived parent workspace for a const response = findByType(emitted, "open_project_response"); expect(response?.payload.error).toBeNull(); - expect(response?.payload.workspace?.id).toBe(worktree); + expect(response?.payload.workspace?.id).toMatch(/^wks_[0-9a-f]{16}$/); expect(response?.payload.workspace?.workspaceDirectory).toBe(worktree); expect(workspaces.get(home)?.archivedAt).toBe(archivedAt); expect(projects.get(home)?.archivedAt).toBe(archivedAt); @@ -3308,6 +3390,7 @@ test("open_project_request reclassifies an archived directory workspace when git ); const remoteProjectId = "remote:github.com/getpaseo/paseo"; const archivedAt = "2026-04-24T09:48:36.168Z"; + const workspaceId = "ws-desktop-daemon-settings"; projects.set( cwd, @@ -3322,9 +3405,9 @@ test("open_project_request reclassifies an archived directory workspace when git }), ); workspaces.set( - cwd, + workspaceId, createPersistedWorkspaceRecord({ - workspaceId: cwd, + workspaceId, projectId: cwd, cwd, kind: "directory", @@ -3344,8 +3427,8 @@ test("open_project_request reclassifies an archived directory workspace when git ) => { projects.set(record.projectId, record); }; - session.workspaceRegistry.get = async (workspaceId: string) => - workspaces.get(workspaceId) ?? null; + session.workspaceRegistry.get = async (lookupWorkspaceId: string) => + workspaces.get(lookupWorkspaceId) ?? null; session.workspaceRegistry.upsert = async ( record: ReturnType, ) => { @@ -3386,9 +3469,9 @@ test("open_project_request reclassifies an archived directory workspace when git expect(response?.payload.workspace?.projectId).toBe(remoteProjectId); expect(response?.payload.workspace?.workspaceKind).toBe("worktree"); expect(projects.get(remoteProjectId)?.kind).toBe("git"); - expect(workspaces.get(cwd)?.projectId).toBe(remoteProjectId); - expect(workspaces.get(cwd)?.kind).toBe("worktree"); - expect(workspaces.get(cwd)?.displayName).toBe("feature/desktop-daemon-settings"); + expect(workspaces.get(workspaceId)?.projectId).toBe(remoteProjectId); + expect(workspaces.get(workspaceId)?.kind).toBe("worktree"); + expect(workspaces.get(workspaceId)?.displayName).toBe("feature/desktop-daemon-settings"); }); test("open_project_request reclassifies an active directory workspace when git metadata becomes available", async () => { @@ -3427,10 +3510,12 @@ test("open_project_request reclassifies an active directory workspace when git m updatedAt: "2026-04-24T09:40:00.000Z", }), ); + const workspaceId = "ws-desktop-daemon-settings-active"; + const repoWorkspaceId = "ws-paseo-main"; workspaces.set( - cwd, + workspaceId, createPersistedWorkspaceRecord({ - workspaceId: cwd, + workspaceId, projectId: cwd, cwd, kind: "directory", @@ -3440,9 +3525,9 @@ test("open_project_request reclassifies an active directory workspace when git m }), ); workspaces.set( - repoRoot, + repoWorkspaceId, createPersistedWorkspaceRecord({ - workspaceId: repoRoot, + workspaceId: repoWorkspaceId, projectId: repoRoot, cwd: repoRoot, kind: "local_checkout", @@ -3461,8 +3546,8 @@ test("open_project_request reclassifies an active directory workspace when git m ) => { projects.set(record.projectId, record); }; - session.workspaceRegistry.get = async (workspaceId: string) => - workspaces.get(workspaceId) ?? null; + session.workspaceRegistry.get = async (lookupWorkspaceId: string) => + workspaces.get(lookupWorkspaceId) ?? null; session.workspaceRegistry.upsert = async ( record: ReturnType, ) => { @@ -3502,9 +3587,9 @@ test("open_project_request reclassifies an active directory workspace when git m expect(response?.payload.error).toBeNull(); expect(response?.payload.workspace?.projectId).toBe(repoRoot); expect(response?.payload.workspace?.workspaceKind).toBe("worktree"); - expect(workspaces.get(cwd)?.projectId).toBe(repoRoot); - expect(workspaces.get(cwd)?.kind).toBe("worktree"); - expect(workspaces.get(cwd)?.displayName).toBe("feature/desktop-daemon-settings"); + expect(workspaces.get(workspaceId)?.projectId).toBe(repoRoot); + expect(workspaces.get(workspaceId)?.kind).toBe("worktree"); + expect(workspaces.get(workspaceId)?.displayName).toBe("feature/desktop-daemon-settings"); }); test("open_project_request groups a plain git worktree under an existing repo project", async () => { @@ -3554,8 +3639,8 @@ test("open_project_request groups a plain git worktree under an existing repo pr ) => { projects.set(record.projectId, record); }; - session.workspaceRegistry.get = async (workspaceId: string) => - workspaces.get(workspaceId) ?? null; + session.workspaceRegistry.get = async (lookupWorkspaceId: string) => + workspaces.get(lookupWorkspaceId) ?? null; session.workspaceRegistry.upsert = async ( record: ReturnType, ) => { @@ -3595,8 +3680,11 @@ test("open_project_request groups a plain git worktree under an existing repo pr expect(response?.payload.error).toBeNull(); expect(response?.payload.workspace?.projectId).toBe(repoRoot); expect(response?.payload.workspace?.workspaceKind).toBe("worktree"); - expect(workspaces.get(cwd)?.projectId).toBe(repoRoot); - expect(workspaces.get(cwd)?.kind).toBe("worktree"); + const worktreeWorkspace = Array.from(workspaces.values()).find( + (workspace) => workspace.cwd === cwd, + ); + expect(worktreeWorkspace?.projectId).toBe(repoRoot); + expect(worktreeWorkspace?.kind).toBe("worktree"); }); test("open_project_request unarchives an existing archived workspace and project", async () => { @@ -3606,6 +3694,7 @@ test("open_project_request unarchives an existing archived workspace and project const workspaces = new Map>(); const cwd = REPO_CWD; + const workspaceId = "ws-repo-archived"; projects.set( cwd, createPersistedProjectRecord({ @@ -3619,9 +3708,9 @@ test("open_project_request unarchives an existing archived workspace and project }), ); workspaces.set( - cwd, + workspaceId, createPersistedWorkspaceRecord({ - workspaceId: cwd, + workspaceId, projectId: cwd, cwd, kind: "directory", @@ -3641,8 +3730,8 @@ test("open_project_request unarchives an existing archived workspace and project ) => { projects.set(record.projectId, record); }; - session.workspaceRegistry.get = async (workspaceId: string) => - workspaces.get(workspaceId) ?? null; + session.workspaceRegistry.get = async (lookupWorkspaceId: string) => + workspaces.get(lookupWorkspaceId) ?? null; session.workspaceRegistry.upsert = async ( record: ReturnType, ) => { @@ -3657,11 +3746,11 @@ test("open_project_request unarchives an existing archived workspace and project requestId: "req-open-unarchive", }); - expect(workspaces.get(cwd)?.archivedAt).toBeNull(); + expect(workspaces.get(workspaceId)?.archivedAt).toBeNull(); expect(projects.get(cwd)?.archivedAt).toBeNull(); const response = findByType(emitted, "open_project_response"); expect(response?.payload.error).toBeNull(); - expect(response?.payload.workspace?.id).toBe(cwd); + expect(response?.payload.workspace?.id).toBe(workspaceId); }); test.skip("open_project_request collapses a git subdirectory onto the repo root workspace", async () => { @@ -3681,8 +3770,8 @@ test.skip("open_project_request collapses a git subdirectory onto the repo root ) => { projects.set(record.projectId, record); }; - session.workspaceRegistry.get = async (workspaceId: string) => - workspaces.get(workspaceId) ?? null; + session.workspaceRegistry.get = async (lookupWorkspaceId: string) => + workspaces.get(lookupWorkspaceId) ?? null; session.workspaceRegistry.upsert = async ( record: ReturnType, ) => { @@ -3842,8 +3931,8 @@ test.skip("opening a new worktree reconciles older local workspaces into the rem if (!existing) return; projects.set(projectId, { ...existing, archivedAt, updatedAt: archivedAt }); }; - session.workspaceRegistry.get = async (workspaceId: string) => - workspaces.get(workspaceId) ?? null; + session.workspaceRegistry.get = async (lookupWorkspaceId: string) => + workspaces.get(lookupWorkspaceId) ?? null; session.workspaceRegistry.list = async () => Array.from(workspaces.values()); session.workspaceRegistry.upsert = async ( record: ReturnType, @@ -3948,8 +4037,8 @@ test.skip("fetch_workspaces_request reconciles remote URL changes for existing w if (!existing) return; projects.set(projectId, { ...existing, archivedAt, updatedAt: archivedAt }); }; - session.workspaceRegistry.get = async (workspaceId: string) => - workspaces.get(workspaceId) ?? null; + session.workspaceRegistry.get = async (lookupWorkspaceId: string) => + workspaces.get(lookupWorkspaceId) ?? null; session.workspaceRegistry.list = async () => Array.from(workspaces.values()); session.workspaceRegistry.upsert = async ( record: ReturnType, @@ -4044,8 +4133,8 @@ test.skip("reconcile archives stale subdirectory workspace records when collapsi ) => { projects.set(record.projectId, record); }; - session.workspaceRegistry.get = async (workspaceId: string) => - workspaces.get(workspaceId) ?? null; + session.workspaceRegistry.get = async (lookupWorkspaceId: string) => + workspaces.get(lookupWorkspaceId) ?? null; session.workspaceRegistry.list = async () => Array.from(workspaces.values()); session.workspaceRegistry.upsert = async ( record: ReturnType, @@ -5040,7 +5129,7 @@ test("resolveRegisteredWorkspaceIdForCwd does not match home directory as a pref const home = homedir(); const childCwd = path.join(home, "projects/new-app"); const homeWorkspace = createPersistedWorkspaceRecord({ - workspaceId: home, + workspaceId: "ws-home", projectId: "proj-home", cwd: home, kind: "directory", @@ -5049,6 +5138,6 @@ test("resolveRegisteredWorkspaceIdForCwd does not match home directory as a pref updatedAt: "2026-03-01T12:00:00.000Z", }); - expect(session.resolveRegisteredWorkspaceIdForCwd(childCwd, [homeWorkspace])).toBe(childCwd); - expect(session.resolveRegisteredWorkspaceIdForCwd(home, [homeWorkspace])).toBe(home); + expect(session.resolveRegisteredWorkspaceIdForCwd(childCwd, [homeWorkspace])).toBeNull(); + expect(session.resolveRegisteredWorkspaceIdForCwd(home, [homeWorkspace])).toBe("ws-home"); }); diff --git a/packages/server/src/server/workspace-directory.ts b/packages/server/src/server/workspace-directory.ts index 2bebb41bf..229e5cccb 100644 --- a/packages/server/src/server/workspace-directory.ts +++ b/packages/server/src/server/workspace-directory.ts @@ -1,5 +1,5 @@ import { homedir } from "node:os"; -import { sep } from "node:path"; +import { resolve, sep } from "node:path"; import type pino from "pino"; import type { AgentSnapshotPayload, @@ -15,7 +15,6 @@ import { import { getParentAgentIdFromLabels, isDelegatedAgent } from "@getpaseo/protocol/agent-labels"; import { SortablePager } from "./pagination/sortable-pager.js"; import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "./workspace-registry.js"; -import { normalizeWorkspaceId } from "./workspace-registry-model.js"; const FETCH_WORKSPACES_SORT_KEYS = [ "status_priority", @@ -52,6 +51,33 @@ type FetchWorkspacesResponsePageInfo = FetchWorkspacesResponsePayload["pageInfo" export type WorkspaceUpdatesFilter = FetchWorkspacesRequestFilter; +export function resolveRegisteredWorkspaceIdForCwd( + cwd: string, + workspaces: PersistedWorkspaceRecord[], +): string | null { + const resolvedCwd = resolve(cwd); + const exact = workspaces.find((workspace) => workspace.cwd === resolvedCwd); + if (exact) { + return exact.workspaceId; + } + + const userHome = homedir(); + let bestMatch: PersistedWorkspaceRecord | null = null; + for (const workspace of workspaces) { + if (workspace.cwd === userHome) continue; + if (workspace.archivedAt) continue; + const prefix = workspace.cwd.endsWith(sep) ? workspace.cwd : `${workspace.cwd}${sep}`; + if (!resolvedCwd.startsWith(prefix)) { + continue; + } + if (!bestMatch || workspace.cwd.length > bestMatch.cwd.length) { + bestMatch = workspace; + } + } + + return bestMatch?.workspaceId ?? null; +} + export interface WorkspaceDirectoryDeps { logger: pino.Logger; projectRegistry: { @@ -176,9 +202,7 @@ export class WorkspaceDirectory { const descriptorsByWorkspaceId = new Map(); const workspaceIds = options.workspaceIds ? new Set(options.workspaceIds) : null; const workspaceIdsByDirectory = new Map( - activeRecords.map( - (workspace) => [normalizeWorkspaceId(workspace.cwd), workspace.workspaceId] as const, - ), + activeRecords.map((workspace) => [resolve(workspace.cwd), workspace.workspaceId] as const), ); const includedWorkspaces = activeRecords.filter( @@ -228,7 +252,7 @@ export class WorkspaceDirectory { }); } - const workspaceId = workspaceIdsByDirectory.get(normalizeWorkspaceId(workspaceAgent.cwd)); + const workspaceId = workspaceIdsByDirectory.get(resolve(workspaceAgent.cwd)); if (workspaceId === undefined) { continue; } @@ -252,7 +276,7 @@ export class WorkspaceDirectory { (agent) => !agent.archivedAt && this.deps.isProviderVisibleToClient(agent.provider) && - workspaceIdsByDirectory.get(normalizeWorkspaceId(agent.cwd)) === workspaceId, + workspaceIdsByDirectory.get(resolve(agent.cwd)) === workspaceId, ); const result = this.resolveStatusEnteredAt({ workspaceId, @@ -366,28 +390,11 @@ export class WorkspaceDirectory { return candidates.at(-1) ?? null; } - resolveRegisteredWorkspaceIdForCwd(cwd: string, workspaces: PersistedWorkspaceRecord[]): string { - const normalizedCwd = normalizeWorkspaceId(cwd); - const exact = workspaces.find((workspace) => workspace.cwd === normalizedCwd); - if (exact) { - return exact.workspaceId; - } - - const userHome = homedir(); - let bestMatch: PersistedWorkspaceRecord | null = null; - for (const workspace of workspaces) { - if (workspace.cwd === userHome) continue; - if (workspace.archivedAt) continue; - const prefix = workspace.cwd.endsWith(sep) ? workspace.cwd : `${workspace.cwd}${sep}`; - if (!normalizedCwd.startsWith(prefix)) { - continue; - } - if (!bestMatch || workspace.cwd.length > bestMatch.cwd.length) { - bestMatch = workspace; - } - } - - return bestMatch?.workspaceId ?? normalizedCwd; + resolveRegisteredWorkspaceIdForCwd( + cwd: string, + workspaces: PersistedWorkspaceRecord[], + ): string | null { + return resolveRegisteredWorkspaceIdForCwd(cwd, workspaces); } async listDescriptors(): Promise { @@ -415,12 +422,6 @@ export class WorkspaceDirectory { } } - if (filter.idPrefix && filter.idPrefix.trim().length > 0) { - if (!workspace.id.startsWith(filter.idPrefix.trim())) { - return false; - } - } - if (filter.query && filter.query.trim().length > 0) { const query = filter.query.trim().toLocaleLowerCase(); const haystacks = [workspace.name, workspace.projectId, workspace.id]; diff --git a/packages/server/src/server/workspace-git-service.ts b/packages/server/src/server/workspace-git-service.ts index f86b0719c..f5bf13133 100644 --- a/packages/server/src/server/workspace-git-service.ts +++ b/packages/server/src/server/workspace-git-service.ts @@ -1,6 +1,6 @@ import { watch, type FSWatcher } from "node:fs"; import { readFile, readdir } from "node:fs/promises"; -import { join, resolve } from "node:path"; +import { basename, join, resolve } from "node:path"; import { LRUCache } from "lru-cache"; import pLimit from "p-limit"; import type pino from "pino"; @@ -38,7 +38,7 @@ import { buildWorkspaceGitMetadataFromSnapshot, type WorkspaceGitMetadata, } from "./workspace-git-metadata.js"; -import { checkoutLiteFromGitSnapshot, normalizeWorkspaceId } from "./workspace-registry-model.js"; +import { checkoutLiteFromGitSnapshot } from "./workspace-registry-model.js"; const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 1_000; const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000; @@ -401,7 +401,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { params: { cwd: string }, listener: WorkspaceGitListener, ): WorkspaceGitSubscription { - const cwd = normalizeWorkspaceId(params.cwd); + const cwd = resolve(params.cwd); const target = this.ensureWorkspaceTarget(cwd); target.listeners.add(listener); if (target.listeners.size === 1) { @@ -432,7 +432,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { cwd: string, options?: WorkspaceGitSnapshotOptions, ): Promise { - cwd = normalizeWorkspaceId(cwd); + cwd = resolve(cwd); const request = this.normalizeRefreshRequest(options, "getSnapshot", true); const target = this.ensureWorkspaceTarget(cwd); if (!request.force && target.latestSnapshot) { @@ -443,7 +443,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { } async getCheckout(cwd: string): Promise { - const normalizedCwd = normalizeWorkspaceId(cwd); + const normalizedCwd = resolve(cwd); try { const status = await this.deps.getCheckoutStatus(normalizedCwd, { paseoHome: this.paseoHome, @@ -481,7 +481,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { } peekSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot | null { - cwd = normalizeWorkspaceId(cwd); + cwd = resolve(cwd); return this.workspaceTargets.get(cwd)?.latestSnapshot ?? null; } @@ -490,7 +490,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { options: CheckoutDiffCompare, readOptions?: WorkspaceGitReadOptions, ): Promise { - const normalizedCwd = normalizeWorkspaceId(cwd); + const normalizedCwd = resolve(cwd); const normalizedOptions = this.normalizeCheckoutDiffOptions(options); const key = this.buildCheckoutDiffCacheKey(normalizedCwd, normalizedOptions); return this.readAuxiliaryCache(this.checkoutDiffCache, key, readOptions, () => @@ -530,7 +530,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { ref: string, options?: WorkspaceGitReadOptions, ): Promise { - const normalizedCwd = normalizeWorkspaceId(cwd); + const normalizedCwd = resolve(cwd); const normalizedRef = ref.trim(); const key = JSON.stringify(["branch-validation", normalizedCwd, normalizedRef]); return this.readAuxiliaryCache(this.branchValidationCache, key, options, () => @@ -539,7 +539,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { } hasLocalBranch(cwd: string, branch: string, options?: WorkspaceGitReadOptions): Promise { - const normalizedCwd = normalizeWorkspaceId(cwd); + const normalizedCwd = resolve(cwd); const normalizedBranch = branch.trim(); const ref = `refs/heads/${normalizedBranch}`; const key = JSON.stringify(["local-branch", normalizedCwd, ref]); @@ -558,7 +558,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { options?: WorkspaceGitBranchSuggestionsOptions, readOptions?: WorkspaceGitReadOptions, ): Promise { - const normalizedCwd = normalizeWorkspaceId(cwd); + const normalizedCwd = resolve(cwd); const query = options?.query ?? ""; const limit = options?.limit; const key = JSON.stringify(["branch-suggestions", normalizedCwd, query, limit ?? null]); @@ -572,7 +572,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { options?: WorkspaceGitStashListOptions, readOptions?: WorkspaceGitReadOptions, ): Promise { - const normalizedCwd = normalizeWorkspaceId(cwd); + const normalizedCwd = resolve(cwd); const paseoOnly = options?.paseoOnly !== false; const key = JSON.stringify(["stashes", normalizedCwd, paseoOnly]); return this.readAuxiliaryCache(this.stashListCache, key, readOptions, async () => { @@ -606,15 +606,15 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { } return snapshot.git.isPaseoOwnedWorktree - ? (snapshot.git.mainRepoRoot ?? snapshot.git.repoRoot ?? normalizeWorkspaceId(cwd)) - : (snapshot.git.repoRoot ?? normalizeWorkspaceId(cwd)); + ? (snapshot.git.mainRepoRoot ?? snapshot.git.repoRoot ?? resolve(cwd)) + : (snapshot.git.repoRoot ?? resolve(cwd)); } async resolveDefaultBranch( cwdOrRepoRoot: string, options?: WorkspaceGitReadOptions, ): Promise { - const cwd = normalizeWorkspaceId(cwdOrRepoRoot); + const cwd = resolve(cwdOrRepoRoot); const key = JSON.stringify(["default-branch", cwd]); return this.readAuxiliaryCache(this.defaultBranchCache, key, options, async () => { const defaultBranch = await this.deps.resolveRepositoryDefaultBranch(cwd); @@ -630,10 +630,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { options?: WorkspaceGitReadOptions & { directoryName?: string }, ): Promise { const snapshot = await this.getSnapshot(cwd, options); - const directoryName = - options?.directoryName ?? normalizeWorkspaceId(cwd).split(/[\\/]/).findLast(Boolean) ?? cwd; + const directoryName = options?.directoryName ?? basename(cwd) ?? cwd; return buildWorkspaceGitMetadataFromSnapshot({ - cwd: normalizeWorkspaceId(cwd), + cwd: resolve(cwd), directoryName, isGit: snapshot.git.isGit, repoRoot: snapshot.git.repoRoot, @@ -652,7 +651,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { } async refresh(cwd: string, _options?: { priority?: "normal" | "high" }): Promise { - cwd = normalizeWorkspaceId(cwd); + cwd = resolve(cwd); const target = this.ensureWorkspaceTarget(cwd); await this.refreshWorkspaceTarget(target, { force: false, @@ -667,7 +666,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { cwd: string, onChange: () => void, ): Promise<{ repoRoot: string | null; unsubscribe: () => void }> { - cwd = normalizeWorkspaceId(cwd); + cwd = resolve(cwd); const target = await this.ensureWorkingTreeWatchTarget(cwd); target.listeners.add(onChange); @@ -680,7 +679,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { } scheduleRefreshForCwd(cwd: string): void { - cwd = normalizeWorkspaceId(cwd); + cwd = resolve(cwd); const target = this.workspaceTargets.get(cwd); if (target) { this.scheduleWorkspaceRefresh(target); @@ -688,7 +687,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { } onWorkspaceStateMayHaveChanged(cwd: string): void { - const normalizedCwd = normalizeWorkspaceId(cwd); + const normalizedCwd = resolve(cwd); const target = this.workspaceTargets.get(normalizedCwd); if (!target || target.closed) { return; @@ -1120,7 +1119,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { ): void { const target = typeof targetOrCwd === "string" - ? this.workspaceTargets.get(normalizeWorkspaceId(targetOrCwd)) + ? this.workspaceTargets.get(resolve(targetOrCwd)) : targetOrCwd; if (!target || target.closed || this.workspaceTargets.get(target.cwd) !== target) { return; diff --git a/packages/server/src/server/workspace-reconciliation-service.ts b/packages/server/src/server/workspace-reconciliation-service.ts index 4225b1855..26d0e1966 100644 --- a/packages/server/src/server/workspace-reconciliation-service.ts +++ b/packages/server/src/server/workspace-reconciliation-service.ts @@ -1,4 +1,5 @@ import { existsSync } from "node:fs"; +import { resolve } from "node:path"; import type pino from "pino"; import type { ProjectRegistry, @@ -7,7 +8,6 @@ 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; @@ -220,7 +220,7 @@ export class WorkspaceReconciliationService { if (project.kind !== "git") { continue; } - const rootKey = normalizeWorkspaceId(project.rootPath); + const rootKey = resolve(project.rootPath); const group = projectsByRoot.get(rootKey) ?? []; group.push(project); projectsByRoot.set(rootKey, group); diff --git a/packages/server/src/server/workspace-registry-bootstrap.test.ts b/packages/server/src/server/workspace-registry-bootstrap.test.ts index d960b56b8..0aabadf8c 100644 --- a/packages/server/src/server/workspace-registry-bootstrap.test.ts +++ b/packages/server/src/server/workspace-registry-bootstrap.test.ts @@ -107,7 +107,8 @@ describe("bootstrapWorkspaceRegistries", () => { const workspaces = await workspaceRegistry.list(); expect(workspaces).toHaveLength(1); - expect(workspaces[0]?.workspaceId).toBe(NON_GIT_PROJECT); + expect(workspaces[0]?.workspaceId).toMatch(/^wks_[0-9a-f]{16}$/); + expect(workspaces[0]?.cwd).toBe(NON_GIT_PROJECT); expect(workspaces[0]?.createdAt).toBe("2026-03-01T00:00:00.000Z"); expect(workspaces[0]?.updatedAt).toBe("2026-03-03T00:00:00.000Z"); @@ -122,7 +123,7 @@ describe("bootstrapWorkspaceRegistries", () => { await projectRegistry.initialize(); await workspaceRegistry.initialize(); await projectRegistry.upsert({ - projectId: "/tmp/existing", + projectId: "proj-existing", rootPath: "/tmp/existing", kind: "non_git", displayName: "existing", @@ -131,8 +132,8 @@ describe("bootstrapWorkspaceRegistries", () => { archivedAt: null, }); await workspaceRegistry.upsert({ - workspaceId: "/tmp/existing", - projectId: "/tmp/existing", + workspaceId: "ws-existing", + projectId: "proj-existing", cwd: "/tmp/existing", kind: "directory", displayName: "existing", @@ -171,6 +172,57 @@ describe("bootstrapWorkspaceRegistries", () => { expect(await projectRegistry.list()).toHaveLength(1); expect(await workspaceRegistry.list()).toHaveLength(1); - expect((await workspaceRegistry.list())[0]?.workspaceId).toBe("/tmp/existing"); + expect((await workspaceRegistry.list())[0]?.workspaceId).toBe("ws-existing"); + }); + + test("preserves existing workspace IDs when only the projects file is missing", async () => { + await workspaceRegistry.initialize(); + await workspaceRegistry.upsert({ + workspaceId: "ws-existing", + projectId: NON_GIT_PROJECT, + cwd: NON_GIT_PROJECT, + kind: "directory", + displayName: "non-git-project", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + archivedAt: null, + }); + + await agentStorage.initialize(); + await agentStorage.upsert({ + id: "agent-1", + provider: "codex", + cwd: NON_GIT_PROJECT, + createdAt: "2026-03-02T00:00:00.000Z", + updatedAt: "2026-03-02T00:00:00.000Z", + lastActivityAt: "2026-03-02T00:00:00.000Z", + 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, + }); + + const workspaces = await workspaceRegistry.list(); + expect(workspaces).toHaveLength(1); + expect(workspaces[0]?.workspaceId).toBe("ws-existing"); + expect(workspaces[0]?.cwd).toBe(NON_GIT_PROJECT); + + const projects = await projectRegistry.list(); + expect(projects).toHaveLength(1); + expect(projects[0]?.projectId).toBe(NON_GIT_PROJECT); }); }); diff --git a/packages/server/src/server/workspace-registry-bootstrap.ts b/packages/server/src/server/workspace-registry-bootstrap.ts index d75316d26..bbe54f4ec 100644 --- a/packages/server/src/server/workspace-registry-bootstrap.ts +++ b/packages/server/src/server/workspace-registry-bootstrap.ts @@ -6,7 +6,7 @@ import type { StoredAgentRecord } from "./agent/agent-storage.js"; import type { AgentStorage } from "./agent/agent-storage.js"; import { classifyDirectoryForProjectMembership, - normalizeWorkspaceId, + generateWorkspaceId, } from "./workspace-registry-model.js"; import type { WorkspaceGitService } from "./workspace-git-service.js"; import { @@ -63,9 +63,15 @@ export async function bootstrapWorkspaceRegistries(options: { return; } + const existingWorkspaceIdsByCwd = new Map( + (await options.workspaceRegistry.list()).map((workspace) => [ + path.resolve(workspace.cwd), + workspace.workspaceId, + ]), + ); const records = await options.agentStorage.list(); const activeRecords = records.filter((record) => !record.archivedAt); - const recordsByWorkspaceId = new Map< + const recordsByDirectoryKey = new Map< string, { membership: ReturnType; @@ -74,19 +80,19 @@ export async function bootstrapWorkspaceRegistries(options: { >(); const placements = await Promise.all( activeRecords.map(async (record) => { - const normalizedCwd = normalizeWorkspaceId(record.cwd); + const normalizedCwd = path.resolve(record.cwd); const checkout = await options.workspaceGitService.getCheckout(normalizedCwd); const membership = classifyDirectoryForProjectMembership({ cwd: normalizedCwd, checkout, }); - return { record, membership, workspaceId: membership.workspaceId }; + return { record, membership, directoryKey: membership.workspaceDirectoryKey }; }), ); - for (const { record, membership, workspaceId } of placements) { - const existing = recordsByWorkspaceId.get(workspaceId) ?? { membership, records: [] }; + for (const { record, membership, directoryKey } of placements) { + const existing = recordsByDirectoryKey.get(directoryKey) ?? { membership, records: [] }; existing.records.push(record); - recordsByWorkspaceId.set(workspaceId, existing); + recordsByDirectoryKey.set(directoryKey, existing); } const projectRanges = new Map(); @@ -98,7 +104,7 @@ export async function bootstrapWorkspaceRegistries(options: { updatedAt: string; }[] = []; - for (const [workspaceId, entry] of recordsByWorkspaceId.entries()) { + for (const entry of recordsByDirectoryKey.values()) { const { membership, records: workspaceRecords } = entry; const workspaceCwd = membership.checkout.cwd; let workspaceCreatedAt: string | null = null; @@ -119,7 +125,13 @@ export async function bootstrapWorkspaceRegistries(options: { existingProjectRange.updatedAt = maxIsoDate(existingProjectRange.updatedAt, updatedAt); projectRanges.set(membership.projectKey, existingProjectRange); - workspaceUpsertInputs.push({ workspaceId, membership, workspaceCwd, createdAt, updatedAt }); + workspaceUpsertInputs.push({ + workspaceId: existingWorkspaceIdsByCwd.get(workspaceCwd) ?? generateWorkspaceId(), + membership, + workspaceCwd, + createdAt, + updatedAt, + }); } await Promise.all( @@ -161,7 +173,7 @@ export async function bootstrapWorkspaceRegistries(options: { projectsFile: path.join(options.paseoHome, "projects", "projects.json"), workspacesFile: path.join(options.paseoHome, "projects", "workspaces.json"), materializedProjects: projectRanges.size, - materializedWorkspaces: recordsByWorkspaceId.size, + materializedWorkspaces: recordsByDirectoryKey.size, }, "Workspace registries bootstrapped from existing agent storage", ); diff --git a/packages/server/src/server/workspace-registry-model.test.ts b/packages/server/src/server/workspace-registry-model.test.ts index fb5390fe9..7bbded841 100644 --- a/packages/server/src/server/workspace-registry-model.test.ts +++ b/packages/server/src/server/workspace-registry-model.test.ts @@ -1,23 +1,24 @@ import { describe, expect, test, vi } from "vitest"; +import { basename, isAbsolute, resolve } from "node:path"; import { classifyDirectoryForProjectMembership, deriveProjectGroupingName, deriveProjectRootPath, + deriveWorkspaceDirectoryKey, deriveWorkspaceKind, - deriveWorkspaceId, detectStaleWorkspaces, - normalizeWorkspaceId, + generateWorkspaceId, } from "./workspace-registry-model.js"; import { createPersistedWorkspaceRecord } from "./workspace-registry.js"; -function createWorkspaceRecord(workspaceId: string) { +function createWorkspaceRecord(cwd: string, workspaceId: string) { return createPersistedWorkspaceRecord({ workspaceId, projectId: workspaceId, - cwd: workspaceId, + cwd, kind: "directory", - displayName: workspaceId.split("/").at(-1) ?? workspaceId, + displayName: basename(cwd) || cwd, createdAt: "2026-03-01T00:00:00.000Z", updatedAt: "2026-03-01T00:00:00.000Z", }); @@ -59,19 +60,22 @@ describe("detectStaleWorkspaces", () => { const staleWorkspaceIds = await detectStaleWorkspaces({ activeWorkspaces: [ - createWorkspaceRecord("/tmp/existing"), - createWorkspaceRecord("/tmp/missing"), + createWorkspaceRecord("/tmp/existing", "ws-existing"), + createWorkspaceRecord("/tmp/missing", "ws-missing"), ], checkDirectoryExists, }); - expect(Array.from(staleWorkspaceIds)).toEqual(["/tmp/missing"]); + expect(Array.from(staleWorkspaceIds)).toEqual(["ws-missing"]); expect(checkDirectoryExists.mock.calls).toEqual([["/tmp/existing"], ["/tmp/missing"]]); }); test("keeps workspaces whose directories exist even when all agents are archived", async () => { const staleWorkspaceIds = await detectStaleWorkspaces({ - activeWorkspaces: [createWorkspaceRecord("/tmp/repo"), createWorkspaceRecord("/tmp/other")], + activeWorkspaces: [ + createWorkspaceRecord("/tmp/repo", "ws-repo"), + createWorkspaceRecord("/tmp/other", "ws-other"), + ], checkDirectoryExists: async () => true, }); @@ -81,8 +85,8 @@ describe("detectStaleWorkspaces", () => { test("keeps workspaces with no agents when directory exists", async () => { const staleWorkspaceIds = await detectStaleWorkspaces({ activeWorkspaces: [ - createWorkspaceRecord("/tmp/active"), - createWorkspaceRecord("/tmp/no-agents"), + createWorkspaceRecord("/tmp/active", "ws-active"), + createWorkspaceRecord("/tmp/no-agents", "ws-no-agents"), ], checkDirectoryExists: async () => true, }); @@ -91,10 +95,10 @@ describe("detectStaleWorkspaces", () => { }); }); -describe("deriveWorkspaceId", () => { +describe("deriveWorkspaceDirectoryKey", () => { test("uses git worktree root when available", () => { expect( - deriveWorkspaceId("/tmp/repo/packages/app", { + deriveWorkspaceDirectoryKey("/tmp/repo/packages/app", { cwd: "/tmp/repo/packages/app", isGit: true, currentBranch: "main", @@ -110,7 +114,7 @@ describe("deriveWorkspaceId", () => { const cwd = String.raw`E:\project\node-ai`; expect( - deriveWorkspaceId(cwd, { + deriveWorkspaceDirectoryKey(cwd, { cwd, isGit: true, currentBranch: "main", @@ -119,14 +123,14 @@ describe("deriveWorkspaceId", () => { isPaseoOwnedWorktree: false, mainRepoRoot: null, }), - ).toBe(normalizeWorkspaceId(cwd)); + ).toBe(resolve(cwd)); }); test("falls back to normalized cwd for non-git directories", () => { const cwd = "/tmp/repo/../repo/scratch"; expect( - deriveWorkspaceId(cwd, { + deriveWorkspaceDirectoryKey(cwd, { cwd, isGit: false, currentBranch: null, @@ -135,7 +139,31 @@ describe("deriveWorkspaceId", () => { isPaseoOwnedWorktree: false, mainRepoRoot: null, }), - ).toBe(normalizeWorkspaceId("/tmp/repo/scratch")); + ).toBe(resolve("/tmp/repo/scratch")); + }); +}); + +describe("opaque workspace id versus directory key", () => { + test("generates opaque workspace ids that are not filesystem paths", () => { + const workspaceId = generateWorkspaceId(); + + expect(workspaceId).toMatch(/^wks_[0-9a-f]+$/); + expect(isAbsolute(workspaceId)).toBe(false); + }); + + test("derives a path-shaped directory key that is never an opaque workspace id", () => { + const directoryKey = deriveWorkspaceDirectoryKey("/tmp/repo/scratch", { + cwd: "/tmp/repo/scratch", + isGit: false, + currentBranch: null, + remoteUrl: null, + worktreeRoot: null, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }); + + expect(directoryKey).toBe(resolve("/tmp/repo/scratch")); + expect(directoryKey.startsWith("wks_")).toBe(false); }); }); @@ -155,8 +183,9 @@ describe("git worktree grouping", () => { }); expect(membership).toMatchObject({ - cwd: normalizeWorkspaceId("/tmp/repo-feature"), - workspaceId: "/tmp/repo-feature", + // Path-derived directory key, distinct from the opaque workspace id (generated separately). + cwd: resolve("/tmp/repo-feature"), + workspaceDirectoryKey: "/tmp/repo-feature", workspaceKind: "worktree", workspaceDisplayName: "feature/plain", projectKey: "remote:github.com/acme/repo", diff --git a/packages/server/src/server/workspace-registry-model.ts b/packages/server/src/server/workspace-registry-model.ts index eef8f10f1..08af1ad27 100644 --- a/packages/server/src/server/workspace-registry-model.ts +++ b/packages/server/src/server/workspace-registry-model.ts @@ -1,3 +1,4 @@ +import { randomBytes } from "node:crypto"; import { resolve } from "node:path"; import type { @@ -13,7 +14,7 @@ export type PersistedWorkspaceKind = "local_checkout" | "worktree" | "directory" export interface DirectoryProjectMembership { cwd: string; checkout: ProjectCheckoutLitePayload; - workspaceId: string; + workspaceDirectoryKey: string; workspaceKind: PersistedWorkspaceKind; workspaceDisplayName: string; projectKey: string; @@ -27,17 +28,18 @@ export interface DetectStaleWorkspacesInput { checkDirectoryExists: (cwd: string) => Promise; } -export function normalizeWorkspaceId(cwd: string): string { - const trimmed = cwd.trim(); - if (!trimmed) { - return cwd; - } - return resolve(trimmed); +export function generateWorkspaceId(): string { + return `wks_${randomBytes(8).toString("hex")}`; } -export function deriveWorkspaceId(cwd: string, checkout: ProjectCheckoutLitePayload): string { +// Path-derived grouping key for a workspace directory. This is NOT the opaque +// workspace identity (see generateWorkspaceId); never persist or compare it as one. +export function deriveWorkspaceDirectoryKey( + cwd: string, + checkout: ProjectCheckoutLitePayload, +): string { const worktreeRoot = checkout.worktreeRoot ? parseGitRevParsePath(checkout.worktreeRoot) : null; - return worktreeRoot ?? normalizeWorkspaceId(cwd); + return worktreeRoot ?? resolve(cwd); } function deriveRemoteProjectKey(remoteUrl: string | null): string | null { @@ -240,7 +242,7 @@ export function classifyDirectoryForProjectMembership(input: { cwd: string; checkout: ProjectCheckoutLitePayload; }): DirectoryProjectMembership { - const normalizedCwd = normalizeWorkspaceId(input.cwd); + const normalizedCwd = resolve(input.cwd); const checkout: ProjectCheckoutLitePayload = { ...input.checkout, cwd: normalizedCwd, @@ -255,7 +257,7 @@ export function classifyDirectoryForProjectMembership(input: { return { cwd: normalizedCwd, checkout, - workspaceId: deriveWorkspaceId(normalizedCwd, checkout), + workspaceDirectoryKey: deriveWorkspaceDirectoryKey(normalizedCwd, checkout), workspaceKind: deriveWorkspaceKind(checkout), workspaceDisplayName: deriveWorkspaceDisplayName({ cwd: normalizedCwd, diff --git a/packages/server/src/server/worktree-session.test.ts b/packages/server/src/server/worktree-session.test.ts index fbfc472f7..43900df46 100644 --- a/packages/server/src/server/worktree-session.test.ts +++ b/packages/server/src/server/worktree-session.test.ts @@ -479,6 +479,7 @@ function createAgentStorageStub(): Pick { function createWorkspaceArchivingDeps() { return { + resolveWorkspaceIdForCwd: vi.fn(async () => "ws-archive-test"), emitWorkspaceUpdatesForWorkspaceIds: vi.fn(async () => {}), markWorkspaceArchiving: vi.fn(), clearWorkspaceArchiving: vi.fn(), @@ -1317,7 +1318,7 @@ describe("handleCreatePaseoWorktreeRequest", () => { branchName: "fix-attached-pr-context", }, workspace: { - workspaceId: "/tmp/worktrees/fix-attached-pr-context", + workspaceId: "ws-fix-attached-pr-context", projectId: "/tmp/repo", cwd: "/tmp/worktrees/fix-attached-pr-context", kind: "worktree" as const, @@ -1722,7 +1723,6 @@ describe("archivePaseoWorktree", () => { runSetup: false, paseoHome, }); - const teardownStartTimes: Record = {}; const teardownEndTimes: Record = {}; const archiveAgentSpy = vi.fn(async (agentId: string) => { @@ -1793,13 +1793,14 @@ describe("archivePaseoWorktree", () => { runSetup: false, paseoHome, }); - const affectedIds = [created.worktreePath]; + const workspaceId = "ws-archive-marked-during-close"; + const affectedIds = [workspaceId]; const liveAgent = createManagedAgentForArchive({ id: "agent-1", cwd: created.worktreePath, }); const workspaceRecord: PersistedWorkspaceRecord = { - workspaceId: created.worktreePath, + workspaceId, projectId: repoDir, cwd: created.worktreePath, kind: "worktree", @@ -1813,13 +1814,13 @@ describe("archivePaseoWorktree", () => { const archivedWorkspaceIds = new Set(); const archivingByWorkspaceId = new Map(); const emitWorkspaceUpdatesForWorkspaceIds = vi.fn(async (workspaceIds: Iterable) => { - for (const workspaceId of workspaceIds) { - if (archivedWorkspaceIds.has(workspaceId)) { + for (const emittedWorkspaceId of workspaceIds) { + if (archivedWorkspaceIds.has(emittedWorkspaceId)) { emitted.push({ type: "workspace_update", payload: { kind: "remove", - id: workspaceId, + id: emittedWorkspaceId, }, }); continue; @@ -1830,7 +1831,7 @@ describe("archivePaseoWorktree", () => { kind: "upsert", workspace: { ...createWorkspaceDescriptor({ workspace: workspaceRecord, repoDir }), - archivingAt: archivingByWorkspaceId.get(workspaceId) ?? null, + archivingAt: archivingByWorkspaceId.get(emittedWorkspaceId) ?? null, }, }, }); @@ -1861,21 +1862,24 @@ describe("archivePaseoWorktree", () => { archiveSnapshot, }, agentStorage: createAgentStorageStub(), - archiveWorkspaceRecord: vi.fn(async (workspaceId: string) => { - archivedWorkspaceIds.add(workspaceId); + resolveWorkspaceIdForCwd: vi.fn(async (cwd: string) => + cwd === created.worktreePath ? workspaceId : null, + ), + archiveWorkspaceRecord: vi.fn(async (archivedWorkspaceId: string) => { + archivedWorkspaceIds.add(archivedWorkspaceId); }), emit: (message) => emitted.push(message), emitWorkspaceUpdatesForWorkspaceIds, markWorkspaceArchiving: (workspaceIds: Iterable, archivingAt: string) => { events.push(`mark:${Array.from(workspaceIds).join(",")}`); - for (const workspaceId of workspaceIds) { - archivingByWorkspaceId.set(workspaceId, archivingAt); + for (const markedWorkspaceId of workspaceIds) { + archivingByWorkspaceId.set(markedWorkspaceId, archivingAt); } }, clearWorkspaceArchiving: (workspaceIds: Iterable) => { events.push(`clear:${Array.from(workspaceIds).join(",")}`); - for (const workspaceId of workspaceIds) { - archivingByWorkspaceId.delete(workspaceId); + for (const clearedWorkspaceId of workspaceIds) { + archivingByWorkspaceId.delete(clearedWorkspaceId); } }, isPathWithinRoot: createIsPathWithinRoot(), @@ -1895,14 +1899,14 @@ describe("archivePaseoWorktree", () => { message.type === "workspace_update", ); expect(events.slice(0, 3)).toEqual([ - `mark:${created.worktreePath}`, - `emit:${created.worktreePath}`, + `mark:${workspaceId}`, + `emit:${workspaceId}`, "close:start", ]); expect(workspaceUpdates[0]?.payload).toEqual({ kind: "upsert", workspace: expect.objectContaining({ - id: created.worktreePath, + id: workspaceId, archivingAt: expect.any(String), }), }); @@ -1913,18 +1917,15 @@ describe("archivePaseoWorktree", () => { expect(workspaceUpdates[1]?.payload).toEqual({ kind: "upsert", workspace: expect.objectContaining({ - id: created.worktreePath, + id: workspaceId, archivingAt, }), }); expect(workspaceUpdates.at(-1)?.payload).toEqual({ kind: "remove", - id: created.worktreePath, + id: workspaceId, }); - expect(events.slice(-2)).toEqual([ - `clear:${created.worktreePath}`, - `emit:${created.worktreePath}`, - ]); + expect(events.slice(-2)).toEqual([`clear:${workspaceId}`, `emit:${workspaceId}`]); expect( emitted.find((message) => message.type === "paseo_worktree_archive_response"), ).toMatchObject({ @@ -1960,6 +1961,7 @@ describe("archivePaseoWorktree", () => { runSetup: false, paseoHome, }); + const workspaceId = "ws-archive-delete-fails"; const archivingByWorkspaceId = new Map(); const archivedWorkspaceIds = new Set(); const emittedUpdates: Array< @@ -1973,8 +1975,8 @@ describe("archivePaseoWorktree", () => { workspaceId: string; } > = []; - const archiveWorkspaceRecord = vi.fn(async (workspaceId: string) => { - archivedWorkspaceIds.add(workspaceId); + const archiveWorkspaceRecord = vi.fn(async (archivedWorkspaceId: string) => { + archivedWorkspaceIds.add(archivedWorkspaceId); }); await expect( @@ -1991,31 +1993,34 @@ describe("archivePaseoWorktree", () => { }), }, agentStorage: createAgentStorageStub(), + resolveWorkspaceIdForCwd: vi.fn(async (cwd: string) => + cwd === created.worktreePath ? workspaceId : null, + ), archiveWorkspaceRecord, emitWorkspaceUpdatesForWorkspaceIds: vi.fn(async (workspaceIds: Iterable) => { - for (const workspaceId of workspaceIds) { - if (archivedWorkspaceIds.has(workspaceId)) { + for (const emittedWorkspaceId of workspaceIds) { + if (archivedWorkspaceIds.has(emittedWorkspaceId)) { emittedUpdates.push({ kind: "remove", - workspaceId, + workspaceId: emittedWorkspaceId, }); continue; } emittedUpdates.push({ kind: "upsert", - workspaceId, - archivingAt: archivingByWorkspaceId.get(workspaceId) ?? null, + workspaceId: emittedWorkspaceId, + archivingAt: archivingByWorkspaceId.get(emittedWorkspaceId) ?? null, }); } }), markWorkspaceArchiving: (workspaceIds: Iterable, archivingAt: string) => { - for (const workspaceId of workspaceIds) { - archivingByWorkspaceId.set(workspaceId, archivingAt); + for (const markedWorkspaceId of workspaceIds) { + archivingByWorkspaceId.set(markedWorkspaceId, archivingAt); } }, clearWorkspaceArchiving: (workspaceIds: Iterable) => { - for (const workspaceId of workspaceIds) { - archivingByWorkspaceId.delete(workspaceId); + for (const clearedWorkspaceId of workspaceIds) { + archivingByWorkspaceId.delete(clearedWorkspaceId); } }, isPathWithinRoot: createIsPathWithinRoot(), @@ -2032,15 +2037,15 @@ describe("archivePaseoWorktree", () => { expect(existsSync(created.worktreePath)).toBe(true); expect(existsSync(path.join(repoDir, "teardown-start.log"))).toBe(true); - expect(archiveWorkspaceRecord).toHaveBeenCalledWith(created.worktreePath); + expect(archiveWorkspaceRecord).toHaveBeenCalledWith(workspaceId); expect(emittedUpdates[0]).toEqual({ kind: "upsert", - workspaceId: created.worktreePath, + workspaceId, archivingAt: expect.any(String), }); expect(emittedUpdates.at(-1)).toEqual({ kind: "remove", - workspaceId: created.worktreePath, + workspaceId, }); });