Make workspace IDs opaque, independent of the filesystem path (#1503)

* Make workspace IDs opaque, independent of the filesystem path

Workspace IDs were the resolved checkout/worktree path, so code could
treat an ID as a path: prefix matching, deriving directories from it,
falling back to a path when a lookup missed. IDs are now opaque -
compared only by exact equality and never used as a path. Anything
path-shaped resolves the workspace record first and reads its cwd.

New workspaces get a generated `wks_` ID; existing path-shaped IDs are
read from disk and never regenerated, so there is no migration and no
change to the wire or persisted schemas. Groundwork for running
multiple workspaces in a single directory.

* Keep attachment scope key stable and align refetch test

The attachment scope key keeps `workspace=` instead of `wsid=` so existing
persisted drafts are not orphaned; the rename was cosmetic. The SDK refetch
test no longer asserts a filter, matching refetch fetching one page and
selecting by id client-side.

* Fix live agent updates in directories without a registered workspace

The opaque-ID work removed buildProjectPlacementForCwd's directory
fallback, so an agent running in a folder with no registered workspace
(e.g. a fresh non-git dir) produced a null placement. forwardAgentUpdate
then threw "Workspace not found", the error was swallowed by its catch,
and no agent_update was emitted — live model/thinking switches and status
updates silently stopped. Caught by the live-preferences e2e suite.

The fallback builds a directory-scoped project placement keyed by the
path. That key is a project grouping key (non-git projects group by
path), not a workspace id, so it stays within the opaque-id rule.

* Always run server worktree archive, even without a resolved workspace

Archiving bailed out entirely when the workspace was not found in the
client store, so a race or stale state could make "archive" do nothing
server-side with only a console.warn. The server archive is keyed by
worktreePath, which is always available, so it now runs regardless; only
the optimistic client-side updates (keyed by workspace id) are gated on
the workspace being resolved.

* Expect a directory-scoped placement for unregistered agent dirs

This unit test asserted the no-placement behavior reverted in the live
agent-update fix, which had broken live model/thinking switching. Update
it to expect the directory-scoped placement now emitted for an agent in a
directory with no registered workspace.

* Fix opaque workspace routing in app E2E

* Preserve workspace IDs during partial bootstrap

* Fix archive flows for opaque workspace IDs

* Stop treating opaque workspace IDs as filesystem paths

Workspace IDs are opaque (wks_<hex>), but several call sites still passed
the id where a directory was expected, which broke those flows for opaque
IDs. The branch switcher sent the id as a cwd to branch/stash/checkout git
operations, and server archive/reconcile cleanup keyed git-watch and
subscription teardown by id, leaking that state.

Git and filesystem operations now take the workspace directory; the opaque
id is used only for identity and cache keys. Archive/reconcile cleanup
routes through a single teardownArchivedWorkspace helper that keeps the key
split explicit: runtime store by id, git watch and subscription by cwd.
Path-derived grouping keys are renamed to directory keys, and the helpers
are split into workspace-identity and workspace-directory so the id-vs-path
boundary is obvious.
This commit is contained in:
Mohamed Boudra
2026-06-14 22:03:27 +08:00
committed by GitHub
parent f352072dac
commit 21deb08673
105 changed files with 1530 additions and 1045 deletions
+2
View File
@@ -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 $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: Each agent is a single JSON file. Fields relevant to this doc:
| Field | Type | Meaning | | Field | Type | Meaning |
+2 -2
View File
@@ -87,7 +87,7 @@ code imports from `@getpaseo/client`.
Cross-platform React Native app that connects to one or more daemons. 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 - `HostRuntimeController` manages saved host connections, reconnection, and per-host runtime state
- `SessionContext` wraps the daemon client for the active session - `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/` - 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 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. - 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. - 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 ## Agent providers
+12 -10
View File
@@ -386,16 +386,18 @@ emptied duplicate.
Array of workspace records. A workspace is a specific working directory within a project. Array of workspace records. A workspace is a specific working directory within a project.
| Field | Type | Description | | Field | Type | Description |
| ------------- | ----------------------------------------------- | ------------------------------ | | ------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `workspaceId` | `string` | Primary key | | `workspaceId` | `string` | Opaque stable identifier (`wks_<hex>`), 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 | | `projectId` | `string` | FK to Project.projectId |
| `cwd` | `string` | Filesystem path | | `cwd` | `string` | Filesystem path |
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | | | `kind` | `"local_checkout" \| "worktree" \| "directory"` | |
| `displayName` | `string` | | | `displayName` | `string` | |
| `createdAt` | `string` (ISO 8601) | | | `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | | | `updatedAt` | `string` (ISO 8601) | |
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable | | `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.
--- ---
+1 -1
View File
@@ -3,7 +3,7 @@
Authoritative terminology. UI label wins. Don't invent synonyms; use what's here. 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. - **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`). - **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". - **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`). - **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`).
+2 -2
View File
@@ -68,7 +68,7 @@ test.describe("Archive tab reconciliation", () => {
archivedAgentId: archived.id, archivedAgentId: archived.id,
survivingAgentId: surviving.id, survivingAgentId: surviving.id,
}); });
await reloadWorkspace(passivePage, tempRepo.path); await reloadWorkspace(passivePage, surviving.workspaceId);
await expectWorkspaceTabHidden(passivePage, archived.id); await expectWorkspaceTabHidden(passivePage, archived.id);
} finally { } finally {
await passivePage.close(); await passivePage.close();
@@ -94,7 +94,7 @@ test.describe("Archive tab reconciliation", () => {
await openWorkspaceWithAgents(passivePage, [archived, surviving]); await openWorkspaceWithAgents(passivePage, [archived, surviving]);
await openSessions(page); await openSessions(page);
await archiveAgentFromSessions(page, { agentId: archived.id, title: archived.title }); 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 expectWorkspaceTabHidden(page, archived.id);
await expectWorkspaceArchiveOutcome(passivePage, { await expectWorkspaceArchiveOutcome(passivePage, {
archivedAgentId: archived.id, archivedAgentId: archived.id,
+44
View File
@@ -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();
}
});
});
@@ -244,7 +244,7 @@ test.describe("Composer attachments", () => {
await switchWorkspaceViaSidebar({ await switchWorkspaceViaSidebar({
page, page,
serverId, serverId,
targetWorkspacePath: workspace.workspaceId, workspaceId: workspace.workspaceId,
}); });
await openNewWorkspaceComposer(page, { await openNewWorkspaceComposer(page, {
+11 -1
View File
@@ -14,6 +14,7 @@ export interface ArchiveTabAgent {
id: string; id: string;
title: string; title: string;
cwd: string; cwd: string;
workspaceId: string;
} }
function buildSeededStoragePayload() { function buildSeededStoragePayload() {
@@ -34,6 +35,10 @@ function buildSeededStoragePayload() {
* idle agent from the same client it uses for everything else. * idle agent from the same client it uses for everything else.
*/ */
export interface IdleAgentSeedClient { export interface IdleAgentSeedClient {
openProject(cwd: string): Promise<{
workspace: { id: string } | null;
error: string | null;
}>;
createAgent(options: { createAgent(options: {
provider: string; provider: string;
model: string; model: string;
@@ -52,6 +57,10 @@ export async function createIdleAgent(
client: IdleAgentSeedClient, client: IdleAgentSeedClient,
input: { cwd: string; title: string }, input: { cwd: string; title: string },
): Promise<ArchiveTabAgent> { ): Promise<ArchiveTabAgent> {
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({ const created = await client.createAgent({
provider: "opencode", provider: "opencode",
model: "opencode/gpt-5-nano", model: "opencode/gpt-5-nano",
@@ -71,6 +80,7 @@ export async function createIdleAgent(
id: created.id, id: created.id,
title: input.title, title: input.title,
cwd: input.cwd, cwd: input.cwd,
workspaceId: opened.workspace.id,
}; };
} }
@@ -133,7 +143,7 @@ export async function openWorkspaceWithAgents(
): Promise<void> { ): Promise<void> {
const serverId = getServerId(); const serverId = getServerId();
for (const agent of agents) { 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, // 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. // then replaces the URL with the clean workspace route after preparing the tab.
@@ -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: <name>. 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<void> {
await expect(branchSwitcherTrigger(page, branchName)).toBeVisible({ timeout: 30_000 });
}
export async function switchBranchFromHeader(
page: Page,
input: { from: string; to: string },
): Promise<void> {
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 });
}
+1 -1
View File
@@ -166,7 +166,7 @@ export async function startRunningMockAgent(
cwd: repo.path, cwd: repo.path,
model: opts.model, 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 page.goto(agentUrl);
await expectComposerVisible(page); await expectComposerVisible(page);
await client.sendAgentMessage(agent.id, opts.prompt); await client.sendAgentMessage(agent.id, opts.prompt);
+2 -2
View File
@@ -6,8 +6,8 @@ import { getServerId } from "./server-id";
// ─── Navigation ──────────────────────────────────────────────────────────── // ─── Navigation ────────────────────────────────────────────────────────────
/** Navigate to a workspace and wait for the tab bar to appear. */ /** Navigate to a workspace and wait for the tab bar to appear. */
export async function gotoWorkspace(page: Page, cwd: string): Promise<void> { export async function gotoWorkspace(page: Page, workspaceId: string): Promise<void> {
const route = buildHostWorkspaceRoute(getServerId(), cwd); const route = buildHostWorkspaceRoute(getServerId(), workspaceId);
await page.goto(route); await page.goto(route);
await waitForTabBar(page); await waitForTabBar(page);
} }
+6 -4
View File
@@ -5,6 +5,7 @@ import { getServerId } from "./server-id";
export interface MockAgentWorkspace { export interface MockAgentWorkspace {
agentId: string; agentId: string;
workspaceId: string;
cwd: string; cwd: string;
client: SeedDaemonClient; client: SeedDaemonClient;
cleanup(): Promise<void>; cleanup(): Promise<void>;
@@ -40,6 +41,7 @@ export async function seedMockAgentWorkspace(
}); });
return { return {
agentId: agent.id, agentId: agent.id,
workspaceId: workspace.workspaceId,
cwd: workspace.repoPath, cwd: workspace.repoPath,
client: workspace.client, client: workspace.client,
cleanup: workspace.cleanup, cleanup: workspace.cleanup,
@@ -50,8 +52,8 @@ export async function seedMockAgentWorkspace(
} }
} }
export function buildAgentRoute(cwd: string, agentId: string): string { export function buildAgentRoute(workspaceId: string, agentId: string): string {
return `${buildHostWorkspaceRoute(getServerId(), cwd)}?open=${encodeURIComponent( return `${buildHostWorkspaceRoute(getServerId(), workspaceId)}?open=${encodeURIComponent(
`agent:${agentId}`, `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. */ /** Boots the app directly at the agent's workspace route and waits for the open intent to settle. */
export async function openAgentRoute( export async function openAgentRoute(
page: Page, page: Page,
input: { cwd: string; agentId: string }, input: { workspaceId: string; agentId: string },
): Promise<void> { ): Promise<void> {
await page.goto(buildAgentRoute(input.cwd, input.agentId)); await page.goto(buildAgentRoute(input.workspaceId, input.agentId));
await page.waitForURL( await page.waitForURL(
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"), (url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
{ timeout: 60_000 }, { timeout: 60_000 },
+53 -20
View File
@@ -3,7 +3,7 @@ import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/inte
import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes"; import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes";
import { connectDaemonClient } from "./daemon-client-loader"; import { connectDaemonClient } from "./daemon-client-loader";
import { daemonWsRoutePattern } from "./daemon-port"; import { daemonWsRoutePattern } from "./daemon-port";
import { expectWorkspaceHeader, workspaceLabelFromPath } from "./workspace-ui"; import { expectWorkspaceHeader } from "./workspace-ui";
type NewWorkspaceDaemonClient = Pick< type NewWorkspaceDaemonClient = Pick<
InternalDaemonClient, InternalDaemonClient,
@@ -12,17 +12,20 @@ type NewWorkspaceDaemonClient = Pick<
| "close" | "close"
| "connect" | "connect"
| "createPaseoWorktree" | "createPaseoWorktree"
| "fetchWorkspaces"
| "openProject" | "openProject"
>; >;
type OpenProjectPayload = Awaited<ReturnType<NewWorkspaceDaemonClient["openProject"]>>; type OpenProjectPayload = Awaited<ReturnType<NewWorkspaceDaemonClient["openProject"]>>;
type WorkspacePayload = Pick<OpenProjectPayload, "error" | "workspace">; type WorkspacePayload = Pick<OpenProjectPayload, "error" | "workspace">;
type WorkspaceDescriptor = NonNullable<OpenProjectPayload["workspace"]>;
export interface OpenedProject { export interface OpenedProject {
workspaceId: string; workspaceId: string;
projectKey: string; projectKey: string;
projectDisplayName: string; projectDisplayName: string;
workspaceName: string; workspaceName: string;
workspaceDirectory: string;
} }
function requireWorkspace(payload: WorkspacePayload) { function requireWorkspace(payload: WorkspacePayload) {
@@ -35,6 +38,39 @@ function requireWorkspace(payload: WorkspacePayload) {
return payload.workspace; 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<WorkspaceDescriptor | null> {
const payload = await client.fetchWorkspaces();
return payload.entries.find((entry) => entry.id === workspaceId) ?? null;
}
async function waitForWorkspaceDescriptor(
client: NewWorkspaceDaemonClient,
workspaceId: string,
): Promise<WorkspaceDescriptor> {
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 { function parseWorkspaceIdFromPageUrl(page: Page, serverId: string): string | null {
const pathname = new URL(page.url()).pathname; const pathname = new URL(page.url()).pathname;
const match = pathname.match( const match = pathname.match(
@@ -57,24 +93,19 @@ export async function openProjectViaDaemon(
repoPath: string, repoPath: string,
): Promise<OpenedProject> { ): Promise<OpenedProject> {
const workspace = requireWorkspace(await client.openProject(repoPath)); const workspace = requireWorkspace(await client.openProject(repoPath));
return { return openedProjectFromWorkspace(workspace);
workspaceId: workspace.id,
projectKey: workspace.projectId,
projectDisplayName: workspace.projectDisplayName,
workspaceName: workspace.name,
};
} }
export async function archiveWorkspaceFromDaemon( export async function archiveWorkspaceFromDaemon(
client: NewWorkspaceDaemonClient, client: NewWorkspaceDaemonClient,
workspaceId: string, workspaceDirectory: string,
): Promise<void> { ): Promise<void> {
const payload = await client.archivePaseoWorktree({ worktreePath: workspaceId }); const payload = await client.archivePaseoWorktree({ worktreePath: workspaceDirectory });
if (payload.error) { if (payload.error) {
throw new Error(payload.error.message); throw new Error(payload.error.message);
} }
if (!payload.success) { 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, worktreeSlug: input.slug,
}); });
const workspace = requireWorkspace(payload); const workspace = requireWorkspace(payload);
return { return openedProjectFromWorkspace(workspace);
workspaceId: workspace.id,
projectKey: workspace.projectId,
projectDisplayName: workspace.projectDisplayName,
workspaceName: workspace.name,
};
} }
export async function openNewWorkspaceComposer( export async function openNewWorkspaceComposer(
@@ -241,12 +267,13 @@ export async function assertNewWorkspaceSidebarAndHeader(
page: Page, page: Page,
input: { input: {
serverId: string; serverId: string;
client: NewWorkspaceDaemonClient;
previousWorkspaceId: string; previousWorkspaceId: string;
projectDisplayName: string; projectDisplayName: string;
assertSidebarRow?: boolean; assertSidebarRow?: boolean;
assertHeader?: boolean; assertHeader?: boolean;
}, },
): Promise<{ workspaceId: string }> { ): Promise<{ workspaceId: string; workspaceName: string; workspaceDirectory: string }> {
// Wait for URL to redirect to the newly created workspace. // 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. // Uses URL as source of truth to avoid picking up sidebar rows from concurrent tests.
let workspaceId: string | null = null; 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()}`); 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) { if (input.assertSidebarRow !== false) {
const createdWorkspaceRow = page.getByTestId( 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 }); await expect(createdWorkspaceRow.first()).toBeVisible({ timeout: 30_000 });
} }
if (input.assertHeader !== false) { if (input.assertHeader !== false) {
await expectWorkspaceHeader(page, { await expectWorkspaceHeader(page, {
title: workspaceLabelFromPath(workspaceId), title: workspace.name,
subtitle: input.projectDisplayName, subtitle: input.projectDisplayName,
}); });
} }
return { workspaceId }; return {
workspaceId: workspace.id,
workspaceName: workspace.name,
workspaceDirectory: workspace.workspaceDirectory,
};
} }
type WebSocketMessage = string | Buffer; type WebSocketMessage = string | Buffer;
+10 -5
View File
@@ -14,6 +14,7 @@ export interface AgentHandle {
page: Page; page: Page;
client: SeedDaemonClient; client: SeedDaemonClient;
agentId: string; agentId: string;
workspaceId: string;
cwd: string; cwd: string;
provider: RewindFlowProvider; provider: RewindFlowProvider;
} }
@@ -61,14 +62,17 @@ function fullAccessConfig(provider: RewindFlowProvider): ProviderLaunchConfig {
} }
} }
function agentRoute(cwd: string, agentId: string): string { function agentRoute(workspaceId: string, agentId: string): string {
return `${buildHostWorkspaceRoute(getServerId(), cwd)}?open=${encodeURIComponent( return `${buildHostWorkspaceRoute(getServerId(), workspaceId)}?open=${encodeURIComponent(
`agent:${agentId}`, `agent:${agentId}`,
)}`; )}`;
} }
async function openAgent(page: Page, input: { cwd: string; agentId: string }): Promise<void> { async function openAgent(
await page.goto(agentRoute(input.cwd, input.agentId)); page: Page,
input: { workspaceId: string; agentId: string },
): Promise<void> {
await page.goto(agentRoute(input.workspaceId, input.agentId));
await page.waitForURL( await page.waitForURL(
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"), (url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
{ timeout: 60_000 }, { timeout: 60_000 },
@@ -180,10 +184,11 @@ export async function launchAgent(input: {
page: input.page, page: input.page,
client, client,
agentId: agent.id, agentId: agent.id,
workspaceId: opened.workspace.id,
cwd: input.cwd, cwd: input.cwd,
provider: input.provider, 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; return handle;
} }
+32
View File
@@ -13,6 +13,38 @@ export async function expectWorkspaceListed(page: Page, name: string): Promise<v
).toBeVisible({ timeout: 30_000 }); ).toBeVisible({ timeout: 30_000 });
} }
// The workspace row kebab and its menu items carry no web ARIA role, so the sidebar
// suite addresses them by the stable test ids the app assigns per workspace — the same
// convention the rename flow uses. The kebab only reveals on hover.
export async function archiveWorktreeFromSidebar(page: Page, workspaceId: string): Promise<void> {
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<void> {
await expect(
page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`),
).toHaveCount(0, { timeout: 30_000 });
}
export async function openMobileAgentSidebar(page: Page): Promise<void> { export async function openMobileAgentSidebar(page: Page): Promise<void> {
await page.getByRole("button", { name: "Open menu" }).click(); await page.getByRole("button", { name: "Open menu" }).click();
} }
@@ -38,7 +38,7 @@ export async function seedLongMockAgentTimeline(
} }
export async function openAgentTimeline(page: Page, agent: LongTimelineAgent): Promise<void> { export async function openAgentTimeline(page: Page, agent: LongTimelineAgent): Promise<void> {
await page.goto(buildAgentRoute(agent.cwd, agent.agentId)); await page.goto(buildAgentRoute(agent.workspaceId, agent.agentId));
await page.waitForURL( await page.waitForURL(
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"), (url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
{ timeout: 60_000 }, { timeout: 60_000 },
+1 -1
View File
@@ -261,7 +261,7 @@ export async function navigateToWorkspaceViaSidebar(
await switchWorkspaceViaSidebar({ await switchWorkspaceViaSidebar({
page, page,
serverId: getServerId(), serverId: getServerId(),
targetWorkspacePath: workspaceId, workspaceId,
}); });
} }
+9 -31
View File
@@ -18,29 +18,8 @@ export async function waitForSidebarHydration(page: Page, timeout = 60_000): Pro
.waitFor({ state: "visible", timeout }); .waitFor({ state: "visible", timeout });
} }
export function workspaceLabelFromPath(value: string): string { function workspaceRowLocator(page: Page, serverId: string, workspaceId: string) {
const normalized = value.replace(/\\/g, "/").replace(/\/+$/, ""); return page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`).first();
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<string>([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();
} }
export async function expectSidebarWorkspaceSelected(input: { export async function expectSidebarWorkspaceSelected(input: {
@@ -69,13 +48,13 @@ export async function expectSidebarWorkspaceSelected(input: {
export async function switchWorkspaceViaSidebar(input: { export async function switchWorkspaceViaSidebar(input: {
page: Page; page: Page;
serverId: string; serverId: string;
targetWorkspacePath: string; workspaceId: string;
}): Promise<void> { }): Promise<void> {
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 expect(row).toBeVisible({ timeout: 30_000 });
await row.click(); 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)), { await expect(input.page).toHaveURL(new RegExp(escapeRegex(targetWorkspaceRoute)), {
timeout: 30_000, timeout: 30_000,
}); });
@@ -89,11 +68,10 @@ export async function waitForWorkspaceInSidebar(
page: Page, page: Page,
input: { serverId: string; workspaceId: string }, input: { serverId: string; workspaceId: string },
): Promise<void> { ): Promise<void> {
const candidates = candidateWorkspaceIds(input.workspaceId); await workspaceRowLocator(page, input.serverId, input.workspaceId).waitFor({
const selector = candidates state: "visible",
.map((id) => `[data-testid="sidebar-workspace-row-${input.serverId}:${id}"]`) timeout: 60_000,
.join(","); });
await page.locator(selector).first().waitFor({ state: "visible", timeout: 60_000 });
} }
export async function expectWorkspaceHeader( export async function expectWorkspaceHeader(
+30 -26
View File
@@ -36,7 +36,6 @@ import {
switchWorkspaceViaSidebar, switchWorkspaceViaSidebar,
waitForSidebarHydration, waitForSidebarHydration,
waitForWorkspaceInSidebar, waitForWorkspaceInSidebar,
workspaceLabelFromPath,
} from "./helpers/workspace-ui"; } from "./helpers/workspace-ui";
interface WorkspaceStatusGroupEvent { interface WorkspaceStatusGroupEvent {
@@ -183,7 +182,7 @@ async function submitNewWorkspaceWithoutPrompt(page: import("@playwright/test").
test.describe("New workspace flow", () => { test.describe("New workspace flow", () => {
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>; let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
const localWorkspaceIds = new Set<string>(); const localWorkspaceIds = new Set<string>();
const createdWorktreeIds = new Set<string>(); const createdWorktreeDirectories = new Set<string>();
test.describe.configure({ timeout: 240_000 }); test.describe.configure({ timeout: 240_000 });
@@ -193,14 +192,14 @@ test.describe("New workspace flow", () => {
test.afterEach(async () => { test.afterEach(async () => {
if (client) { if (client) {
for (const workspaceId of createdWorktreeIds) { for (const workspaceDirectory of createdWorktreeDirectories) {
await archiveWorkspaceFromDaemon(client, workspaceId).catch(() => undefined); await archiveWorkspaceFromDaemon(client, workspaceDirectory).catch(() => undefined);
} }
for (const workspaceId of localWorkspaceIds) { for (const workspaceId of localWorkspaceIds) {
await archiveLocalWorkspaceFromDaemon(client, workspaceId).catch(() => undefined); await archiveLocalWorkspaceFromDaemon(client, workspaceId).catch(() => undefined);
} }
} }
createdWorktreeIds.clear(); createdWorktreeDirectories.clear();
localWorkspaceIds.clear(); localWorkspaceIds.clear();
await client?.close().catch(() => undefined); await client?.close().catch(() => undefined);
}); });
@@ -223,7 +222,7 @@ test.describe("New workspace flow", () => {
await switchWorkspaceViaSidebar({ await switchWorkspaceViaSidebar({
page, page,
serverId, serverId,
targetWorkspacePath: firstWorkspace.workspaceId, workspaceId: firstWorkspace.workspaceId,
}); });
await expectWorkspaceHeader(page, { await expectWorkspaceHeader(page, {
title: firstWorkspace.workspaceName, title: firstWorkspace.workspaceName,
@@ -233,7 +232,7 @@ test.describe("New workspace flow", () => {
await switchWorkspaceViaSidebar({ await switchWorkspaceViaSidebar({
page, page,
serverId, serverId,
targetWorkspacePath: secondWorkspace.workspaceId, workspaceId: secondWorkspace.workspaceId,
}); });
await waitForWorkspaceInSidebar(page, { await waitForWorkspaceInSidebar(page, {
serverId, serverId,
@@ -247,7 +246,7 @@ test.describe("New workspace flow", () => {
await switchWorkspaceViaSidebar({ await switchWorkspaceViaSidebar({
page, page,
serverId, serverId,
targetWorkspacePath: firstWorkspace.workspaceId, workspaceId: firstWorkspace.workspaceId,
}); });
await expectWorkspaceHeader(page, { await expectWorkspaceHeader(page, {
title: firstWorkspace.workspaceName, title: firstWorkspace.workspaceName,
@@ -271,7 +270,7 @@ test.describe("New workspace flow", () => {
slug: `nav-${Date.now()}`, slug: `nav-${Date.now()}`,
}); });
localWorkspaceIds.add(rootWorkspace.workspaceId); localWorkspaceIds.add(rootWorkspace.workspaceId);
createdWorktreeIds.add(worktreeWorkspace.workspaceId); createdWorktreeDirectories.add(worktreeWorkspace.workspaceDirectory);
await gotoAppShell(page); await gotoAppShell(page);
await waitForSidebarHydration(page); await waitForSidebarHydration(page);
@@ -279,7 +278,7 @@ test.describe("New workspace flow", () => {
await switchWorkspaceViaSidebar({ await switchWorkspaceViaSidebar({
page, page,
serverId, serverId,
targetWorkspacePath: rootWorkspace.workspaceId, workspaceId: rootWorkspace.workspaceId,
}); });
await expectWorkspaceHeader(page, { await expectWorkspaceHeader(page, {
title: rootWorkspace.workspaceName, title: rootWorkspace.workspaceName,
@@ -294,7 +293,7 @@ test.describe("New workspace flow", () => {
await switchWorkspaceViaSidebar({ await switchWorkspaceViaSidebar({
page, page,
serverId, serverId,
targetWorkspacePath: worktreeWorkspace.workspaceId, workspaceId: worktreeWorkspace.workspaceId,
}); });
await expectWorkspaceHeader(page, { await expectWorkspaceHeader(page, {
title: worktreeWorkspace.workspaceName, title: worktreeWorkspace.workspaceName,
@@ -315,7 +314,7 @@ test.describe("New workspace flow", () => {
await switchWorkspaceViaSidebar({ await switchWorkspaceViaSidebar({
page, page,
serverId, serverId,
targetWorkspacePath: rootWorkspace.workspaceId, workspaceId: rootWorkspace.workspaceId,
}); });
await expectWorkspaceHeader(page, { await expectWorkspaceHeader(page, {
title: rootWorkspace.workspaceName, title: rootWorkspace.workspaceName,
@@ -354,7 +353,7 @@ test.describe("New workspace flow", () => {
await switchWorkspaceViaSidebar({ await switchWorkspaceViaSidebar({
page, page,
serverId, serverId,
targetWorkspacePath: openedProject.workspaceId, workspaceId: openedProject.workspaceId,
}); });
await expectWorkspaceHeader(page, { await expectWorkspaceHeader(page, {
title: openedProject.workspaceName, title: openedProject.workspaceName,
@@ -367,12 +366,13 @@ test.describe("New workspace flow", () => {
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, { const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
serverId, serverId,
client,
previousWorkspaceId: openedProject.workspaceId, previousWorkspaceId: openedProject.workspaceId,
projectDisplayName: openedProject.projectDisplayName, projectDisplayName: openedProject.projectDisplayName,
assertSidebarRow: false, assertSidebarRow: false,
assertHeader: false, assertHeader: false,
}); });
createdWorktreeIds.add(createdWorkspace.workspaceId); createdWorktreeDirectories.add(createdWorkspace.workspaceDirectory);
expect(createdWorkspace.workspaceId).not.toBe(openedProject.workspaceId); expect(createdWorkspace.workspaceId).not.toBe(openedProject.workspaceId);
await expect(page).toHaveURL( await expect(page).toHaveURL(
@@ -388,7 +388,7 @@ test.describe("New workspace flow", () => {
await expect(createdWorkspaceRow).toBeVisible({ timeout: 30_000 }); await expect(createdWorkspaceRow).toBeVisible({ timeout: 30_000 });
await expectWorkspaceHeader(page, { await expectWorkspaceHeader(page, {
title: workspaceLabelFromPath(createdWorkspace.workspaceId), title: createdWorkspace.workspaceName,
subtitle: openedProject.projectDisplayName, subtitle: openedProject.projectDisplayName,
}); });
@@ -428,7 +428,7 @@ test.describe("New workspace flow", () => {
await switchWorkspaceViaSidebar({ await switchWorkspaceViaSidebar({
page, page,
serverId, serverId,
targetWorkspacePath: openedProject.workspaceId, workspaceId: openedProject.workspaceId,
}); });
await expectWorkspaceHeader(page, { await expectWorkspaceHeader(page, {
title: openedProject.workspaceName, title: openedProject.workspaceName,
@@ -455,12 +455,13 @@ test.describe("New workspace flow", () => {
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, { const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
serverId, serverId,
client,
previousWorkspaceId: openedProject.workspaceId, previousWorkspaceId: openedProject.workspaceId,
projectDisplayName: openedProject.projectDisplayName, projectDisplayName: openedProject.projectDisplayName,
assertSidebarRow: false, assertSidebarRow: false,
assertHeader: false, assertHeader: false,
}); });
createdWorktreeIds.add(createdWorkspace.workspaceId); createdWorktreeDirectories.add(createdWorkspace.workspaceDirectory);
await expect(page).toHaveURL( await expect(page).toHaveURL(
buildHostWorkspaceRoute(serverId, createdWorkspace.workspaceId), buildHostWorkspaceRoute(serverId, createdWorkspace.workspaceId),
@@ -507,7 +508,7 @@ test.describe("New workspace flow", () => {
await switchWorkspaceViaSidebar({ await switchWorkspaceViaSidebar({
page, page,
serverId, serverId,
targetWorkspacePath: openedProject.workspaceId, workspaceId: openedProject.workspaceId,
}); });
await expectWorkspaceHeader(page, { await expectWorkspaceHeader(page, {
title: openedProject.workspaceName, title: openedProject.workspaceName,
@@ -523,12 +524,13 @@ test.describe("New workspace flow", () => {
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, { const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
serverId, serverId,
client,
previousWorkspaceId: openedProject.workspaceId, previousWorkspaceId: openedProject.workspaceId,
projectDisplayName: openedProject.projectDisplayName, projectDisplayName: openedProject.projectDisplayName,
assertSidebarRow: false, assertSidebarRow: false,
assertHeader: false, assertHeader: false,
}); });
createdWorktreeIds.add(createdWorkspace.workspaceId); createdWorktreeDirectories.add(createdWorkspace.workspaceDirectory);
const rowTestId = `sidebar-workspace-row-${serverId}:${createdWorkspace.workspaceId}`; const rowTestId = `sidebar-workspace-row-${serverId}:${createdWorkspace.workspaceId}`;
await expectWorkspaceStatusGroupEvents({ await expectWorkspaceStatusGroupEvents({
@@ -560,7 +562,7 @@ test.describe("New workspace flow", () => {
await switchWorkspaceViaSidebar({ await switchWorkspaceViaSidebar({
page, page,
serverId, serverId,
targetWorkspacePath: openedProject.workspaceId, workspaceId: openedProject.workspaceId,
}); });
await expectWorkspaceHeader(page, { await expectWorkspaceHeader(page, {
title: openedProject.workspaceName, title: openedProject.workspaceName,
@@ -576,10 +578,11 @@ test.describe("New workspace flow", () => {
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, { const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
serverId, serverId,
client,
previousWorkspaceId: openedProject.workspaceId, previousWorkspaceId: openedProject.workspaceId,
projectDisplayName: openedProject.projectDisplayName, projectDisplayName: openedProject.projectDisplayName,
}); });
createdWorktreeIds.add(createdWorkspace.workspaceId); createdWorktreeDirectories.add(createdWorkspace.workspaceDirectory);
const rowTestId = `sidebar-workspace-row-${serverId}:${createdWorkspace.workspaceId}`; const rowTestId = `sidebar-workspace-row-${serverId}:${createdWorkspace.workspaceId}`;
await expectWorkspaceStatusGroupEvents({ await expectWorkspaceStatusGroupEvents({
@@ -618,7 +621,7 @@ test.describe("New workspace flow", () => {
await switchWorkspaceViaSidebar({ await switchWorkspaceViaSidebar({
page, page,
serverId, serverId,
targetWorkspacePath: openedProject.workspaceId, workspaceId: openedProject.workspaceId,
}); });
await expectWorkspaceHeader(page, { await expectWorkspaceHeader(page, {
title: openedProject.workspaceName, title: openedProject.workspaceName,
@@ -640,17 +643,18 @@ test.describe("New workspace flow", () => {
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, { const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
serverId, serverId,
client,
previousWorkspaceId: openedProject.workspaceId, previousWorkspaceId: openedProject.workspaceId,
projectDisplayName: openedProject.projectDisplayName, 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({ 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.main)).toBe(true);
expect(branchInfo.hasAncestor(tempRepo.branchHeads.dev)).toBe(true); expect(branchInfo.hasAncestor(tempRepo.branchHeads.dev)).toBe(true);
} finally { } finally {
@@ -9,7 +9,6 @@ async function openMockAgentAtMobileBreakpoint(page: Page) {
const session = await seedMockAgentWorkspace({ const session = await seedMockAgentWorkspace({
repoPrefix: "provider-sheet-stack-", repoPrefix: "provider-sheet-stack-",
title: "Provider sheet stack e2e", title: "Provider sheet stack e2e",
initialPrompt: "Prepare provider sheet stack test agent.",
}); });
await openAgentRoute(page, session); await openAgentRoute(page, session);
await expectComposerVisible(page); await expectComposerVisible(page);
@@ -58,6 +57,12 @@ async function closeTopSheet(page: Page) {
await page.mouse.up(); 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) { async function expectProviderSettingsVisible(page: Page) {
await expect(page.getByTestId("provider-settings-sheet")).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId("provider-settings-sheet")).toBeVisible({ timeout: 10_000 });
await expect(page.getByRole("button", { name: "Add model" })).toBeVisible(); 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 page.getByRole("button", { name: "Add model" }).click();
await expect(page.getByTestId("add-custom-model-sheet")).toBeVisible({ timeout: 10_000 }); 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 expectProviderSettingsVisible(page);
await page.getByRole("button", { name: "Diagnostic", exact: true }).click(); await page.getByRole("button", { name: "Diagnostic", exact: true }).click();
await expect(page.getByTestId("provider-diagnostic-sheet")).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId("provider-diagnostic-sheet")).toBeVisible({ timeout: 10_000 });
await page.getByRole("button", { name: /Refresh diagnostic/ }).click(); await page.getByRole("button", { name: /Refresh diagnostic/ }).click();
await expect(page.getByTestId("provider-diagnostic-sheet")).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId("provider-diagnostic-sheet")).toBeVisible({ timeout: 10_000 });
await closeTopSheet(page); await closeSheetByHeaderButton(page, "provider-diagnostic-sheet");
await expectProviderSettingsVisible(page); await expectProviderSettingsVisible(page);
await page.getByRole("button", { name: "Refresh", exact: true }).click(); 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 ({ test("provider settings and children close back through the model selector stack", async ({
page, page,
}) => { }) => {
test.setTimeout(180_000);
const session = await openMockAgentAtMobileBreakpoint(page); const session = await openMockAgentAtMobileBreakpoint(page);
try { try {
await openProviderSettingsFromModelSelector(page); await openProviderSettingsFromModelSelector(page);
await exerciseProviderSettingsStack(page); await exerciseProviderSettingsStack(page);
await closeTopSheet(page); await closeSheetByHeaderButton(page, "provider-settings-sheet");
await expectModelSelectorVisible(page); await expectModelSelectorVisible(page);
await page.getByRole("button", { name: /Open .* settings/ }).click(); await page.getByRole("button", { name: /Open .* settings/ }).click();
await expect(page.getByTestId("provider-settings-sheet")).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId("provider-settings-sheet")).toBeVisible({ timeout: 10_000 });
await exerciseProviderSettingsStack(page); await exerciseProviderSettingsStack(page);
await closeTopSheet(page); await closeSheetByHeaderButton(page, "provider-settings-sheet");
await expectModelSelectorVisible(page); await expectModelSelectorVisible(page);
await closeTopSheet(page); await closeTopSheet(page);
@@ -74,7 +74,7 @@ test.describe("Settings toggle tab regression", () => {
await expectSendBehavior(page, "interrupt"); await expectSendBehavior(page, "interrupt");
await pressSettingsToggleShortcut(page); await pressSettingsToggleShortcut(page);
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, workspace.repoPath)); await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, workspace.workspaceId));
await waitForTabBar(page); await waitForTabBar(page);
await expectAgentTabActive(page, secondAgent.id); await expectAgentTabActive(page, secondAgent.id);
@@ -105,13 +105,13 @@ test.describe("Settings toggle tab regression", () => {
await openAgentRouteAndExpectFocused({ await openAgentRouteAndExpectFocused({
page, page,
serverId, serverId,
workspaceId: workspace.repoPath, workspaceId: workspace.workspaceId,
agentId: firstAgent.id, agentId: firstAgent.id,
}); });
await openAgentRouteAndExpectFocused({ await openAgentRouteAndExpectFocused({
page, page,
serverId, serverId,
workspaceId: workspace.repoPath, workspaceId: workspace.workspaceId,
agentId: secondAgent.id, agentId: secondAgent.id,
}); });
@@ -7,8 +7,8 @@ import { buildHostAgentDetailRoute } from "@/utils/host-routes";
import { captureWsSessionFrames, renameModalInput, renameModalSubmit } from "./helpers/rename"; import { captureWsSessionFrames, renameModalInput, renameModalSubmit } from "./helpers/rename";
import { getServerId } from "./helpers/server-id"; import { getServerId } from "./helpers/server-id";
async function openAgentInWorkspace(page: Page, agent: { id: string; cwd: string }) { async function openAgentInWorkspace(page: Page, agent: { id: string; workspaceId: string }) {
await page.goto(buildHostAgentDetailRoute(getServerId(), agent.id, agent.cwd)); await page.goto(buildHostAgentDetailRoute(getServerId(), agent.id, agent.workspaceId));
await page.waitForURL( await page.waitForURL(
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"), (url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
{ timeout: 60_000 }, { timeout: 60_000 },
@@ -92,7 +92,7 @@ async function openWorkspaceThroughApp(
await switchWorkspaceViaSidebar({ await switchWorkspaceViaSidebar({
page, page,
serverId: input.serverId, serverId: input.serverId,
targetWorkspacePath: input.workspace.workspaceId, workspaceId: input.workspace.workspaceId,
}); });
await waitForWorkspaceTabsVisible(page); await waitForWorkspaceTabsVisible(page);
await expectWorkspaceLocation(page, input); await expectWorkspaceLocation(page, input);
@@ -209,7 +209,7 @@ test.describe("Workspace navigation regression", () => {
await gotoAppShell(page); await gotoAppShell(page);
await waitForSidebarHydration(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( await page.waitForURL(
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"), (url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
{ timeout: 60_000 }, { timeout: 60_000 },
@@ -365,7 +365,7 @@ test.describe("Workspace navigation regression", () => {
await switchWorkspaceViaSidebar({ await switchWorkspaceViaSidebar({
page, page,
serverId, serverId,
targetWorkspacePath: firstWorkspace.workspaceId, workspaceId: firstWorkspace.workspaceId,
}); });
await waitForWorkspaceTabsVisible(page); await waitForWorkspaceTabsVisible(page);
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId), { await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId), {
@@ -397,7 +397,7 @@ test.describe("Workspace navigation regression", () => {
await switchWorkspaceViaSidebar({ await switchWorkspaceViaSidebar({
page, page,
serverId, serverId,
targetWorkspacePath: secondWorkspace.workspaceId, workspaceId: secondWorkspace.workspaceId,
}); });
await waitForWorkspaceTabsVisible(page); await waitForWorkspaceTabsVisible(page);
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, secondWorkspace.workspaceId), { await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, secondWorkspace.workspaceId), {
@@ -461,7 +461,7 @@ test.describe("Workspace navigation regression", () => {
await switchWorkspaceViaSidebar({ await switchWorkspaceViaSidebar({
page, page,
serverId, serverId,
targetWorkspacePath: firstWorkspace.workspaceId, workspaceId: firstWorkspace.workspaceId,
}); });
await waitForWorkspaceTabsVisible(page); await waitForWorkspaceTabsVisible(page);
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId), { await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId), {
@@ -21,7 +21,7 @@ test.describe("Workspace pane mounting", () => {
title: `pane-remount-${Date.now()}`, 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( await page.waitForURL(
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"), (url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
{ timeout: 60_000 }, { timeout: 60_000 },
+59
View File
@@ -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<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
let tempRepo: { path: string; cleanup: () => Promise<void> };
const createdWorktreeDirectories = new Set<string>();
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);
});
});
+2 -2
View File
@@ -75,7 +75,7 @@ import {
type OpenFileDisposition, type OpenFileDisposition,
type WorkspaceFileOpenRequest, type WorkspaceFileOpenRequest,
} from "@/workspace/file-open"; } from "@/workspace/file-open";
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution"; import { resolveWorkspaceIdByDirectory } from "@/utils/workspace-identity";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation"; import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import { useStableEvent } from "@/hooks/use-stable-event"; import { useStableEvent } from "@/hooks/use-stable-event";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
@@ -276,7 +276,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
); );
const workspaceRoot = agent.cwd?.trim() || ""; const workspaceRoot = agent.cwd?.trim() || "";
const workspaceId = resolveWorkspaceIdByExecutionDirectory({ const workspaceId = resolveWorkspaceIdByDirectory({
workspaces: useSessionStore.getState().sessions[resolvedServerId]?.workspaces?.values(), workspaces: useSessionStore.getState().sessions[resolvedServerId]?.workspaces?.values(),
workspaceDirectory: workspaceRoot, workspaceDirectory: workspaceRoot,
}); });
@@ -5,7 +5,7 @@ import { useSessionStore } from "@/stores/session-store";
import { useResolveWorkspaceIdByCwd } from "@/stores/session-store-hooks"; import { useResolveWorkspaceIdByCwd } from "@/stores/session-store-hooks";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { buildHostRootRoute } from "@/utils/host-routes"; import { buildHostRootRoute } from "@/utils/host-routes";
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution"; import { resolveWorkspaceIdByDirectory } from "@/utils/workspace-identity";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation"; import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
export default function HostAgentReadyRoute() { export default function HostAgentReadyRoute() {
@@ -93,7 +93,7 @@ function HostAgentReadyRouteContent() {
} }
const cwd = result?.agent?.cwd?.trim(); const cwd = result?.agent?.cwd?.trim();
const workspaces = useSessionStore.getState().sessions[serverId]?.workspaces; const workspaces = useSessionStore.getState().sessions[serverId]?.workspaces;
const workspaceId = resolveWorkspaceIdByExecutionDirectory({ const workspaceId = resolveWorkspaceIdByDirectory({
workspaces: workspaces?.values(), workspaces: workspaces?.values(),
workspaceDirectory: cwd, workspaceDirectory: cwd,
}); });
@@ -42,6 +42,7 @@ function normalizeCwd(cwd: string): string {
export function buildWorkspaceAttachmentScopeKey(input: WorkspaceAttachmentScopeInput): string { export function buildWorkspaceAttachmentScopeKey(input: WorkspaceAttachmentScopeInput): string {
const workspaceId = input.workspaceId?.trim(); const workspaceId = input.workspaceId?.trim();
// workspaceId is opaque; do not parse this key back into a path.
const workspacePart = workspaceId const workspacePart = workspaceId
? `workspace=${encodeScopePart(workspaceId)}` ? `workspace=${encodeScopePart(workspaceId)}`
: `cwd=${encodeScopePart(normalizeCwd(input.cwd))}`; : `cwd=${encodeScopePart(normalizeCwd(input.cwd))}`;
@@ -10,6 +10,7 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useToast } from "@/contexts/toast-context"; import { useToast } from "@/contexts/toast-context";
import { useBranchSwitcher } from "@/hooks/use-branch-switcher"; import { useBranchSwitcher } from "@/hooks/use-branch-switcher";
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
import { ScreenTitle } from "@/components/headers/screen-title"; import { ScreenTitle } from "@/components/headers/screen-title";
interface BranchSwitcherProps { interface BranchSwitcherProps {
@@ -35,11 +36,13 @@ export function BranchSwitcher({
const isConnected = useHostRuntimeIsConnected(serverId); const isConnected = useHostRuntimeIsConnected(serverId);
const toast = useToast(); const toast = useToast();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const workspaceDirectory = useWorkspaceDirectory(serverId, workspaceId);
const { branchOptions, isOpen, setIsOpen, handleBranchSelect } = useBranchSwitcher({ const { branchOptions, isOpen, setIsOpen, handleBranchSelect } = useBranchSwitcher({
client, client,
normalizedServerId: serverId, normalizedServerId: serverId,
normalizedWorkspaceId: workspaceId, normalizedWorkspaceId: workspaceId,
workspaceDirectory,
currentBranchName, currentBranchName,
isGitCheckout, isGitCheckout,
isConnected, isConnected,
@@ -120,10 +120,7 @@ import type { PrHint } from "@/git/use-pr-status-query";
import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model"; import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect"; import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
import { openExternalUrl } from "@/utils/open-external-url"; import { openExternalUrl } from "@/utils/open-external-url";
import { import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "@/utils/workspace-directory";
requireWorkspaceExecutionDirectory,
resolveWorkspaceExecutionDirectory,
} from "@/utils/workspace-execution";
import { import {
confirmRiskyWorktreeArchive, confirmRiskyWorktreeArchive,
type WorktreeArchiveWarningLabels, type WorktreeArchiveWarningLabels,
@@ -1538,7 +1535,7 @@ function WorkspaceRowWithMenu({
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false); const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
const [isRenameOpen, setIsRenameOpen] = useState(false); const [isRenameOpen, setIsRenameOpen] = useState(false);
const workspaceDirectory = resolveWorkspaceExecutionDirectory({ const workspaceDirectory = resolveWorkspaceDirectory({
workspaceDirectory: workspace.workspaceDirectory, workspaceDirectory: workspace.workspaceDirectory,
}); });
const archiveStatus = useCheckoutGitActionsStore((state) => const archiveStatus = useCheckoutGitActionsStore((state) =>
@@ -1580,7 +1577,7 @@ function WorkspaceRowWithMenu({
} }
let archiveDirectory: string; let archiveDirectory: string;
try { try {
archiveDirectory = requireWorkspaceExecutionDirectory({ archiveDirectory = requireWorkspaceDirectory({
workspaceId: workspace.workspaceId, workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory, workspaceDirectory: workspace.workspaceDirectory,
}); });
@@ -1660,7 +1657,7 @@ function WorkspaceRowWithMenu({
const handleCopyPath = useCallback(() => { const handleCopyPath = useCallback(() => {
let copyTargetDirectory: string; let copyTargetDirectory: string;
try { try {
copyTargetDirectory = requireWorkspaceExecutionDirectory({ copyTargetDirectory = requireWorkspaceDirectory({
workspaceId: workspace.workspaceId, workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory, workspaceDirectory: workspace.workspaceDirectory,
}); });
@@ -1687,7 +1684,7 @@ function WorkspaceRowWithMenu({
if (!client) { if (!client) {
throw new Error(t("sidebar.workspace.toasts.hostDisconnected")); throw new Error(t("sidebar.workspace.toasts.hostDisconnected"));
} }
const targetCwd = requireWorkspaceExecutionDirectory({ const targetCwd = requireWorkspaceDirectory({
workspaceId: workspace.workspaceId, workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory, workspaceDirectory: workspace.workspaceDirectory,
}); });
@@ -40,10 +40,7 @@ import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys"; import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys";
import { slugify, validateBranchSlug, MAX_SLUG_LENGTH } from "@getpaseo/protocol/branch-slug"; import { slugify, validateBranchSlug, MAX_SLUG_LENGTH } from "@getpaseo/protocol/branch-slug";
import { AdaptiveRenameModal } from "@/components/rename-modal"; import { AdaptiveRenameModal } from "@/components/rename-modal";
import { import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "@/utils/workspace-directory";
requireWorkspaceExecutionDirectory,
resolveWorkspaceExecutionDirectory,
} from "@/utils/workspace-execution";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect"; import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
import { archiveWorkspaceOptimistically } from "@/workspace/workspace-archive"; import { archiveWorkspaceOptimistically } from "@/workspace/workspace-archive";
import { useCheckoutGitActionsStore } from "@/git/actions-store"; import { useCheckoutGitActionsStore } from "@/git/actions-store";
@@ -355,7 +352,7 @@ function StatusWorkspaceRowWithMenu({
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false); const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
const [isRenameOpen, setIsRenameOpen] = useState(false); const [isRenameOpen, setIsRenameOpen] = useState(false);
const workspaceDirectory = resolveWorkspaceExecutionDirectory({ const workspaceDirectory = resolveWorkspaceDirectory({
workspaceDirectory: workspace.workspaceDirectory, workspaceDirectory: workspace.workspaceDirectory,
}); });
const archiveStatus = useCheckoutGitActionsStore((state) => const archiveStatus = useCheckoutGitActionsStore((state) =>
@@ -391,7 +388,7 @@ function StatusWorkspaceRowWithMenu({
if (!confirmed) return; if (!confirmed) return;
let archiveDirectory: string; let archiveDirectory: string;
try { try {
archiveDirectory = requireWorkspaceExecutionDirectory({ archiveDirectory = requireWorkspaceDirectory({
workspaceId: workspace.workspaceId, workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory, workspaceDirectory: workspace.workspaceDirectory,
}); });
@@ -441,7 +438,7 @@ function StatusWorkspaceRowWithMenu({
const handleCopyPath = useCallback(() => { const handleCopyPath = useCallback(() => {
let copyTargetDirectory: string; let copyTargetDirectory: string;
try { try {
copyTargetDirectory = requireWorkspaceExecutionDirectory({ copyTargetDirectory = requireWorkspaceDirectory({
workspaceId: workspace.workspaceId, workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory, workspaceDirectory: workspace.workspaceDirectory,
}); });
@@ -462,7 +459,7 @@ function StatusWorkspaceRowWithMenu({
mutationFn: async (branch: string) => { mutationFn: async (branch: string) => {
const client = getHostRuntimeStore().getClient(workspace.serverId); const client = getHostRuntimeStore().getClient(workspace.serverId);
if (!client) throw new Error(t("workspace.terminal.hostDisconnected")); if (!client) throw new Error(t("workspace.terminal.hostDisconnected"));
const targetCwd = requireWorkspaceExecutionDirectory({ const targetCwd = requireWorkspaceDirectory({
workspaceId: workspace.workspaceId, workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory, workspaceDirectory: workspace.workspaceDirectory,
}); });
@@ -22,7 +22,7 @@ import type {
DaemonClient, DaemonClient,
} from "@getpaseo/client/internal/daemon-client"; } from "@getpaseo/client/internal/daemon-client";
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name"; 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 { navigateToAgent } from "@/utils/navigate-to-agent";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation"; import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import type { ImageAttachment, MessagePayload } from "@/composer/types"; import type { ImageAttachment, MessagePayload } from "@/composer/types";
@@ -300,9 +300,10 @@ export function WorkspaceSetupDialog() {
const wirePayload = splitComposerAttachmentsForSubmit(attachments); const wirePayload = splitComposerAttachmentsForSubmit(attachments);
const encodedImages = await encodeImages(wirePayload.images); const encodedImages = await encodeImages(wirePayload.images);
const workspaceDirectory = requireWorkspaceExecutionAuthority({ const workspaceDirectory = requireWorkspaceDirectory({
workspace: ensuredWorkspace, workspaceId: ensuredWorkspace.id,
}).workspaceDirectory; workspaceDirectory: ensuredWorkspace.workspaceDirectory,
});
const agent = await connectedClient.createAgent( const agent = await connectedClient.createAgent(
buildCreateAgentOptions({ buildCreateAgentOptions({
composerState, composerState,
@@ -23,7 +23,7 @@ import { buildDraftStoreKey } from "@/stores/draft-keys";
import { usePanelStore } from "@/stores/panel-store"; import { usePanelStore } from "@/stores/panel-store";
import { useCreateFlowStore } from "@/stores/create-flow-store"; import { useCreateFlowStore } from "@/stores/create-flow-store";
import type { Agent } from "@/stores/session-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 { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
import { encodeImages } from "@/utils/encode-images"; import { encodeImages } from "@/utils/encode-images";
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open"; import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
@@ -121,7 +121,7 @@ async function submitDraftCreateRequest(input: {
attachments?: unknown; attachments?: unknown;
client: DaemonClient | null; client: DaemonClient | null;
workspaceDirectory: string | null; workspaceDirectory: string | null;
workspaceExecutionAuthority: { workspaceId: string } | null; workspaceId: string | null;
autoSubmitConfig: AutoSubmitConfig | null; autoSubmitConfig: AutoSubmitConfig | null;
composerState: { composerState: {
selectedProvider: string | null; selectedProvider: string | null;
@@ -141,13 +141,13 @@ async function submitDraftCreateRequest(input: {
attachments, attachments,
client, client,
workspaceDirectory, workspaceDirectory,
workspaceExecutionAuthority, workspaceId,
autoSubmitConfig, autoSubmitConfig,
composerState, composerState,
} = input; } = input;
invariant(workspaceDirectory, "Workspace directory is required"); invariant(workspaceDirectory, "Workspace directory is required");
invariant(workspaceExecutionAuthority, "Workspace authority is required"); invariant(workspaceId, "Workspace id is required");
if (!client) { if (!client) {
throw new Error(input.hostDisconnectedMessage); throw new Error(input.hostDisconnectedMessage);
} }
@@ -175,7 +175,7 @@ async function submitDraftCreateRequest(input: {
const attachmentsArray = Array.isArray(attachments) ? attachments : undefined; const attachmentsArray = Array.isArray(attachments) ? attachments : undefined;
const result = await client.createAgent({ const result = await client.createAgent({
config, config,
workspaceId: workspaceExecutionAuthority.workspaceId, workspaceId,
...(text ? { initialPrompt: text } : {}), ...(text ? { initialPrompt: text } : {}),
clientMessageId: attempt.clientMessageId, clientMessageId: attempt.clientMessageId,
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}), ...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
@@ -317,9 +317,8 @@ export function WorkspaceDraftAgentTab({
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const client = useHostRuntimeClient(serverId); const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId); const isConnected = useHostRuntimeIsConnected(serverId);
const workspaceAuthority = useWorkspaceExecutionAuthority(serverId, workspaceId); const workspace = useWorkspace(serverId, workspaceId);
const workspaceExecutionAuthority = workspaceAuthority?.ok ? workspaceAuthority.authority : null; const workspaceDirectory = workspace?.workspaceDirectory || null;
const workspaceDirectory = workspaceExecutionAuthority?.workspaceDirectory ?? null;
const draftSetup = initialSetup ?? null; const draftSetup = initialSetup ?? null;
const draftWorkingDirectory = resolveDraftWorkingDirectory({ const draftWorkingDirectory = resolveDraftWorkingDirectory({
workspaceDirectory, workspaceDirectory,
@@ -471,7 +470,7 @@ export function WorkspaceDraftAgentTab({
attachments, attachments,
client, client,
workspaceDirectory: draftWorkingDirectory, workspaceDirectory: draftWorkingDirectory,
workspaceExecutionAuthority, workspaceId: workspace?.id ?? null,
autoSubmitConfig, autoSubmitConfig,
composerState, composerState,
hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"), hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"),
@@ -1,7 +1,7 @@
import equal from "fast-deep-equal"; import equal from "fast-deep-equal";
import type { ScriptStatusUpdateMessage } from "@getpaseo/protocol/messages"; import type { ScriptStatusUpdateMessage } from "@getpaseo/protocol/messages";
import type { WorkspaceDescriptor } from "@/stores/session-store"; import type { WorkspaceDescriptor } from "@/stores/session-store";
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-execution"; import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
export function patchWorkspaceScripts( export function patchWorkspaceScripts(
workspaces: Map<string, WorkspaceDescriptor>, workspaces: Map<string, WorkspaceDescriptor>,
+39 -28
View File
@@ -37,7 +37,7 @@ function workspace(input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescripto
projectId: input.projectId ?? "project-1", projectId: input.projectId ?? "project-1",
projectDisplayName: input.projectDisplayName ?? "Project", projectDisplayName: input.projectDisplayName ?? "Project",
projectRootPath: input.projectRootPath ?? "/tmp/repo", projectRootPath: input.projectRootPath ?? "/tmp/repo",
workspaceDirectory: input.workspaceDirectory ?? input.id, workspaceDirectory: input.workspaceDirectory ?? "/tmp/repo/worktrees/feature",
projectKind: input.projectKind ?? "git", projectKind: input.projectKind ?? "git",
workspaceKind: input.workspaceKind ?? "worktree", workspaceKind: input.workspaceKind ?? "worktree",
name: input.name ?? input.id, name: input.name ?? input.id,
@@ -51,13 +51,13 @@ function workspace(input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescripto
describe("checkout-git-actions-store", () => { describe("checkout-git-actions-store", () => {
const serverId = "server-1"; const serverId = "server-1";
const cwd = "/tmp/repo"; const cwd = "/tmp/repo/worktrees/feature";
const workspaceId = "ws-feature";
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers(); vi.useFakeTimers();
__resetCheckoutGitActionsStoreForTests(); __resetCheckoutGitActionsStoreForTests();
clearWorkspaceArchivePending({ serverId, workspaceId: cwd }); clearWorkspaceArchivePending({ serverId, workspaceId });
clearWorkspaceArchivePending({ serverId, workspaceId: "ws-feature" });
appQueryClient.clear(); appQueryClient.clear();
useSessionStore.setState((state) => ({ ...state, sessions: {} })); useSessionStore.setState((state) => ({ ...state, sessions: {} }));
}); });
@@ -65,8 +65,7 @@ describe("checkout-git-actions-store", () => {
afterEach(() => { afterEach(() => {
vi.useRealTimers(); vi.useRealTimers();
__resetCheckoutGitActionsStoreForTests(); __resetCheckoutGitActionsStoreForTests();
clearWorkspaceArchivePending({ serverId, workspaceId: cwd }); clearWorkspaceArchivePending({ serverId, workspaceId });
clearWorkspaceArchivePending({ serverId, workspaceId: "ws-feature" });
appQueryClient.clear(); appQueryClient.clear();
useSessionStore.setState((state) => ({ ...state, sessions: {} })); useSessionStore.setState((state) => ({ ...state, sessions: {} }));
}); });
@@ -301,9 +300,13 @@ describe("checkout-git-actions-store", () => {
const client = { const client = {
archivePaseoWorktree: vi.fn(() => deferred.promise), 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().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( appQueryClient.setQueryData(
["sidebarPaseoWorktreeList", serverId, "/tmp"], ["sidebarPaseoWorktreeList", serverId, "/tmp"],
[{ worktreePath: cwd }, { worktreePath: "/tmp/other" }], [{ worktreePath: cwd }, { worktreePath: "/tmp/other" }],
@@ -313,6 +316,7 @@ describe("checkout-git-actions-store", () => {
.getState() .getState()
.archiveWorktree({ serverId, cwd, worktreePath: cwd }); .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(useSessionStore.getState().sessions[serverId]?.workspaces.has(cwd)).toBe(false);
expect(appQueryClient.getQueryData(["sidebarPaseoWorktreeList", serverId, "/tmp"])).toEqual([ expect(appQueryClient.getQueryData(["sidebarPaseoWorktreeList", serverId, "/tmp"])).toEqual([
{ worktreePath: "/tmp/other" }, { worktreePath: "/tmp/other" },
@@ -325,49 +329,52 @@ describe("checkout-git-actions-store", () => {
expect( expect(
isWorkspaceArchivePending({ isWorkspaceArchivePending({
serverId, serverId,
workspaceId: cwd, workspaceId,
}), }),
).toBe(true); ).toBe(true);
expect(
isWorkspaceArchivePending({
serverId,
workspaceId: cwd,
}),
).toBe(false);
}); });
it("hides an archived worktree when the workspace map is keyed by opaque id", async () => { it("archives on the server even when its workspace cannot be resolved", async () => {
const deferred = createDeferred<Record<string, never>>();
const client = { 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().initializeSession(serverId, client as unknown as DaemonClient);
useSessionStore.getState().setWorkspaces(serverId, new Map([["ws-feature", featureWorkspace]]));
const archive = useCheckoutGitActionsStore await useCheckoutGitActionsStore
.getState() .getState()
.archiveWorktree({ serverId, cwd, worktreePath: cwd }); .archiveWorktree({ serverId, cwd, worktreePath: cwd });
expect(useSessionStore.getState().sessions[serverId]?.workspaces.has("ws-feature")).toBe(false); // The server archive is keyed by worktreePath and must run regardless.
expect(client.archivePaseoWorktree).toHaveBeenCalledWith({ worktreePath: cwd });
deferred.resolve({}); // The optimistic client-side mark is never keyed by the path.
await archive; expect(isWorkspaceArchivePending({ serverId, workspaceId: cwd })).toBe(false);
}); });
it("restores an optimistically hidden worktree when archive fails", async () => { it("restores an optimistically hidden worktree when archive fails", async () => {
const client = { const client = {
archivePaseoWorktree: vi.fn(async () => ({ error: { message: "archive failed" } })), 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" }]; const listSnapshot = [{ worktreePath: cwd }, { worktreePath: "/tmp/other" }];
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient); 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); appQueryClient.setQueryData(["sidebarPaseoWorktreeList", serverId, "/tmp"], listSnapshot);
await expect( await expect(
useCheckoutGitActionsStore.getState().archiveWorktree({ serverId, cwd, worktreePath: cwd }), useCheckoutGitActionsStore.getState().archiveWorktree({ serverId, cwd, worktreePath: cwd }),
).rejects.toThrow("archive failed"); ).rejects.toThrow("archive failed");
expect(useSessionStore.getState().sessions[serverId]?.workspaces.get(cwd)).toEqual( expect(useSessionStore.getState().sessions[serverId]?.workspaces.get(workspaceId)).toEqual(
featureWorkspace, featureWorkspace,
); );
expect(appQueryClient.getQueryData(["sidebarPaseoWorktreeList", serverId, "/tmp"])).toEqual( expect(appQueryClient.getQueryData(["sidebarPaseoWorktreeList", serverId, "/tmp"])).toEqual(
@@ -380,9 +387,13 @@ describe("checkout-git-actions-store", () => {
const client = { const client = {
archivePaseoWorktree: vi.fn(() => deferred.promise), 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().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 const archive = useCheckoutGitActionsStore
.getState() .getState()
+33 -28
View File
@@ -14,9 +14,9 @@ import {
markWorkspaceArchivePending, markWorkspaceArchivePending,
} from "@/contexts/session-workspace-upserts"; } from "@/contexts/session-workspace-upserts";
import { import {
resolveWorkspaceIdByExecutionDirectory, resolveWorkspaceIdByDirectory,
resolveWorkspaceMapKeyByIdentity, resolveWorkspaceMapKeyByIdentity,
} from "@/utils/workspace-execution"; } from "@/utils/workspace-identity";
import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys"; import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys";
import { i18n } from "@/i18n/i18next"; import { i18n } from "@/i18n/i18next";
@@ -158,12 +158,13 @@ function snapshotWorktreeArchiveState(input: {
worktreePath: string; worktreePath: string;
}): WorktreeArchiveSnapshot { }): WorktreeArchiveSnapshot {
const workspaces = useSessionStore.getState().sessions[input.serverId]?.workspaces; const workspaces = useSessionStore.getState().sessions[input.serverId]?.workspaces;
const workspaceId = const workspaceId = resolveWorkspaceIdByDirectory({
resolveWorkspaceIdByExecutionDirectory({ workspaces: workspaces?.values(),
workspaces: workspaces?.values(), workspaceDirectory: input.worktreePath,
workspaceDirectory: input.worktreePath, });
}) ?? input.worktreePath; const workspaceKey = workspaceId
const workspaceKey = resolveWorkspaceMapKeyByIdentity({ workspaces, workspaceId }); ? resolveWorkspaceMapKeyByIdentity({ workspaces, workspaceId })
: null;
return { return {
workspace: workspaceKey ? (workspaces?.get(workspaceKey) ?? null) : null, workspace: workspaceKey ? (workspaces?.get(workspaceKey) ?? null) : null,
worktreeLists: appQueryClient.getQueriesData({ 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 serverId = input.serverId.trim();
const worktreePath = input.worktreePath.trim(); const workspaceId = input.workspaceId.trim();
if (!serverId || !worktreePath) { if (!serverId || !workspaceId) {
return; return;
} }
useSessionStore.getState().removeWorkspace(serverId, worktreePath); useSessionStore.getState().removeWorkspace(serverId, workspaceId);
} }
function restoreWorktreeArchiveState(input: { 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 serverId = input.serverId.trim();
const workspaceId = input.worktreePath.trim(); const workspaceId = input.workspaceId.trim();
if (!serverId || !workspaceId) { if (!serverId || !workspaceId) {
return; return;
} }
@@ -504,31 +505,35 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
run: async () => { run: async () => {
const client = resolveClient(serverId); const client = resolveClient(serverId);
const snapshot = snapshotWorktreeArchiveState({ serverId, worktreePath }); const snapshot = snapshotWorktreeArchiveState({ serverId, worktreePath });
markWorkspaceArchivePending({ // The server archive is keyed by worktreePath and must always run. The
serverId, // optimistic client-side updates are keyed by workspace id, so they only
workspaceId: snapshot.workspace?.id ?? worktreePath, // apply when the workspace is resolved in the local store.
workspaceDirectory: snapshot.workspace?.workspaceDirectory ?? worktreePath, const workspace = snapshot.workspace;
}); if (workspace) {
markWorkspaceArchivePending({
serverId,
workspaceId: workspace.id,
workspaceDirectory: workspace.workspaceDirectory,
});
removeWorktreeFromSessionStore({ serverId, workspaceId: workspace.id });
}
removeWorktreeFromCachedLists({ serverId, worktreePath }); removeWorktreeFromCachedLists({ serverId, worktreePath });
removeWorktreeFromSessionStore({
serverId,
worktreePath: snapshot.workspace?.id ?? worktreePath,
});
try { try {
const payload = await client.archivePaseoWorktree({ worktreePath }); const payload = await client.archivePaseoWorktree({ worktreePath });
if (payload.error) { if (payload.error) {
throw new Error(payload.error.message); throw new Error(payload.error.message);
} }
} catch (error) { } catch (error) {
clearWorkspaceArchivePending({ if (workspace) {
serverId, clearWorkspaceArchivePending({ serverId, workspaceId: workspace.id });
workspaceId: snapshot.workspace?.id ?? worktreePath, }
});
restoreWorktreeArchiveState({ serverId, snapshot }); restoreWorktreeArchiveState({ serverId, snapshot });
throw error; throw error;
} }
invalidateWorktreeList(); invalidateWorktreeList();
purgeArchivedWorkspaceState({ serverId, worktreePath }); if (workspace) {
purgeArchivedWorkspaceState({ serverId, workspaceId: workspace.id });
}
}, },
}); });
}, },
@@ -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);
});
});
@@ -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<typeof createBranchSwitcherOperations>;
+14 -13
View File
@@ -15,8 +15,9 @@ import type { CheckoutPrMergeMethod } from "@getpaseo/protocol/messages";
import { openExternalUrl } from "@/utils/open-external-url"; import { openExternalUrl } from "@/utils/open-external-url";
import { useToast } from "@/contexts/toast-context"; import { useToast } from "@/contexts/toast-context";
import { useSessionStore } from "@/stores/session-store"; 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 { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation";
import { buildHostRootRoute } from "@/utils/host-routes";
import { import {
confirmRiskyWorktreeArchive, confirmRiskyWorktreeArchive,
type WorktreeArchiveWarningLabels, type WorktreeArchiveWarningLabels,
@@ -495,18 +496,18 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
return; return;
} }
const archivedWorkspaceId = const archivedWorkspaceId = resolveWorkspaceIdByDirectory({
resolveWorkspaceIdByExecutionDirectory({ workspaces: workspaceList,
workspaces: workspaceList, workspaceDirectory: worktreePath,
workspaceDirectory: worktreePath, });
}) ?? worktreePath; const redirectRoute = archivedWorkspaceId
router.replace( ? buildWorkspaceArchiveRedirectRoute({
buildWorkspaceArchiveRedirectRoute({ serverId,
serverId, archivedWorkspaceId,
archivedWorkspaceId, workspaces: workspaceList,
workspaces: workspaceList, })
}) as Href, : buildHostRootRoute(serverId);
); router.replace(redirectRoute as Href);
void runArchiveWorktree({ serverId, cwd, worktreePath }).catch((err) => { void runArchiveWorktree({ serverId, cwd, worktreePath }).catch((err) => {
toastActionError(err, t("workspace.git.actions.toasts.failedArchive")); toastActionError(err, t("workspace.git.actions.toasts.failedArchive"));
}); });
@@ -8,6 +8,12 @@ import type { WorkspaceStructureProject } from "@/projects/workspace-structure";
const EMPTY_PROJECTS: SidebarProjectEntry[] = []; 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 type SidebarStateBucket = WorkspaceDescriptor["status"];
export interface SidebarWorkspaceEntry { export interface SidebarWorkspaceEntry {
@@ -54,7 +60,7 @@ function createStructuralWorkspaceEntry(input: {
workspaceDirectory: undefined, workspaceDirectory: undefined,
projectKind: input.project.projectKind, projectKind: input.project.projectKind,
workspaceKind: "checkout", workspaceKind: "checkout",
name: input.workspaceId, name: workspaceNameFromDirectory(input.project.iconWorkingDir) || input.workspaceId,
statusBucket: "done", statusBucket: "done",
statusEnteredAt: null, statusEnteredAt: null,
archivingAt: null, archivingAt: null,
+30 -22
View File
@@ -5,12 +5,14 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { ComboboxOption } from "@/components/ui/combobox"; import type { ComboboxOption } from "@/components/ui/combobox";
import type { ToastApi } from "@/components/toast-host"; import type { ToastApi } from "@/components/toast-host";
import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys"; import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys";
import { createBranchSwitcherOperations } from "@/git/branch-switcher-operations";
import { confirmDialog } from "@/utils/confirm-dialog"; import { confirmDialog } from "@/utils/confirm-dialog";
interface UseBranchSwitcherInput { interface UseBranchSwitcherInput {
client: DaemonClient | null; client: DaemonClient | null;
normalizedServerId: string; normalizedServerId: string;
normalizedWorkspaceId: string; normalizedWorkspaceId: string;
workspaceDirectory: string | null;
currentBranchName: string | null; currentBranchName: string | null;
isGitCheckout: boolean; isGitCheckout: boolean;
isConnected: boolean; isConnected: boolean;
@@ -30,6 +32,7 @@ export function useBranchSwitcher({
client, client,
normalizedServerId, normalizedServerId,
normalizedWorkspaceId, normalizedWorkspaceId,
workspaceDirectory,
currentBranchName, currentBranchName,
isGitCheckout, isGitCheckout,
isConnected, isConnected,
@@ -39,22 +42,29 @@ export function useBranchSwitcher({
const { t } = useTranslation(); const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false); 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({ const branchSuggestionsQuery = useQuery({
queryKey: ["branchSuggestions", normalizedServerId, normalizedWorkspaceId], queryKey: ["branchSuggestions", normalizedServerId, normalizedWorkspaceId],
queryFn: async () => { queryFn: async () => {
if (!client) { if (!operations) {
throw new Error(t("common.errors.daemonClientUnavailable")); throw new Error(t("common.errors.daemonClientUnavailable"));
} }
const payload = await client.getBranchSuggestions({ const payload = await operations.getBranchSuggestions(200);
cwd: normalizedWorkspaceId,
limit: 200,
});
if (payload.error) { if (payload.error) {
throw new Error(payload.error); throw new Error(payload.error);
} }
return payload.branches ?? []; return payload.branches ?? [];
}, },
enabled: isOpen && isGitCheckout && Boolean(client) && isConnected, enabled: isOpen && isGitCheckout && Boolean(operations) && isConnected,
retry: false, retry: false,
staleTime: 15_000, staleTime: 15_000,
}); });
@@ -70,20 +80,21 @@ export function useBranchSwitcher({
); );
const invalidateStashAndCheckout = useCallback(async () => { const invalidateStashAndCheckout = useCallback(async () => {
if (!workspaceDirectory) return;
await Promise.all([ await Promise.all([
queryClient.invalidateQueries({ queryKey: stashListQueryKey }), queryClient.invalidateQueries({ queryKey: stashListQueryKey }),
invalidateCheckoutGitQueriesForClient(queryClient, { invalidateCheckoutGitQueriesForClient(queryClient, {
serverId: normalizedServerId, serverId: normalizedServerId,
cwd: normalizedWorkspaceId, cwd: workspaceDirectory,
}), }),
]); ]);
}, [queryClient, stashListQueryKey, normalizedServerId, normalizedWorkspaceId]); }, [queryClient, stashListQueryKey, normalizedServerId, workspaceDirectory]);
const maybeRestoreStashForBranch = useCallback( const maybeRestoreStashForBranch = useCallback(
async (branchId: string) => { async (branchId: string) => {
if (!client) return; if (!operations) return;
try { try {
const stashPayload = await client.stashList(normalizedWorkspaceId, { paseoOnly: true }); const stashPayload = await operations.listPaseoStashes();
const targetStash = stashPayload.entries.find((e) => e.branch === branchId); const targetStash = stashPayload.entries.find((e) => e.branch === branchId);
if (!targetStash) return; if (!targetStash) return;
const shouldRestore = await confirmDialog({ const shouldRestore = await confirmDialog({
@@ -93,7 +104,7 @@ export function useBranchSwitcher({
cancelLabel: t("branchSwitcher.later"), cancelLabel: t("branchSwitcher.later"),
}); });
if (!shouldRestore) return; if (!shouldRestore) return;
const popPayload = await client.stashPop(normalizedWorkspaceId, targetStash.index); const popPayload = await operations.popStash(targetStash.index);
if (popPayload.error) { if (popPayload.error) {
toast.error(popPayload.error.message); toast.error(popPayload.error.message);
} else { } else {
@@ -104,12 +115,12 @@ export function useBranchSwitcher({
// Non-critical — user can still restore on next branch switch // Non-critical — user can still restore on next branch switch
} }
}, },
[client, invalidateStashAndCheckout, normalizedWorkspaceId, toast, t], [operations, invalidateStashAndCheckout, toast, t],
); );
const stashAndSwitch = useCallback( const stashAndSwitch = useCallback(
async (branchId: string) => { async (branchId: string) => {
if (!client) return; if (!operations) return;
const shouldStash = await confirmDialog({ const shouldStash = await confirmDialog({
title: t("branchSwitcher.uncommittedTitle"), title: t("branchSwitcher.uncommittedTitle"),
message: t("branchSwitcher.uncommittedMessage"), message: t("branchSwitcher.uncommittedMessage"),
@@ -119,15 +130,13 @@ export function useBranchSwitcher({
if (!shouldStash) return; if (!shouldStash) return;
try { try {
const stashPayload = await client.stashSave(normalizedWorkspaceId, { const stashPayload = await operations.saveStash(currentBranchName ?? undefined);
branch: currentBranchName ?? undefined,
});
if (stashPayload.error) { if (stashPayload.error) {
toast.error(stashPayload.error.message); toast.error(stashPayload.error.message);
return; return;
} }
await invalidateStashAndCheckout(); await invalidateStashAndCheckout();
const switchPayload = await client.checkoutSwitchBranch(normalizedWorkspaceId, branchId); const switchPayload = await operations.switchBranch(branchId);
if (switchPayload.error) { if (switchPayload.error) {
toast.error(switchPayload.error.message); toast.error(switchPayload.error.message);
return; return;
@@ -137,7 +146,7 @@ export function useBranchSwitcher({
toast.error(err instanceof Error ? err.message : t("branchSwitcher.failedToStash")); toast.error(err instanceof Error ? err.message : t("branchSwitcher.failedToStash"));
} }
}, },
[client, currentBranchName, invalidateStashAndCheckout, normalizedWorkspaceId, toast, t], [operations, currentBranchName, invalidateStashAndCheckout, toast, t],
); );
const handleBranchSelect = useCallback( const handleBranchSelect = useCallback(
@@ -145,9 +154,9 @@ export function useBranchSwitcher({
if (branchId === currentBranchName) return; if (branchId === currentBranchName) return;
void (async () => { void (async () => {
if (!client) return; if (!operations) return;
try { try {
const payload = await client.checkoutSwitchBranch(normalizedWorkspaceId, branchId); const payload = await operations.switchBranch(branchId);
if (payload.error) { if (payload.error) {
// If the error is about uncommitted changes, offer the stash dialog // If the error is about uncommitted changes, offer the stash dialog
if (payload.error.message.toLowerCase().includes("uncommitted")) { if (payload.error.message.toLowerCase().includes("uncommitted")) {
@@ -166,11 +175,10 @@ export function useBranchSwitcher({
})(); })();
}, },
[ [
client, operations,
currentBranchName, currentBranchName,
invalidateStashAndCheckout, invalidateStashAndCheckout,
maybeRestoreStashForBranch, maybeRestoreStashForBranch,
normalizedWorkspaceId,
stashAndSwitch, stashAndSwitch,
t, t,
toast, toast,
+1 -3
View File
@@ -230,9 +230,7 @@ describe("translation resources", () => {
expect(en.workspace.browser.unavailable.title).toBe("Browser is desktop-only"); expect(en.workspace.browser.unavailable.title).toBe("Browser is desktop-only");
expect(en.workspace.browser.controls.enterUrl).toBe("Enter URL"); expect(en.workspace.browser.controls.enterUrl).toBe("Enter URL");
expect(en.workspace.terminal.hostDisconnected).toBe("Host is not connected"); expect(en.workspace.terminal.hostDisconnected).toBe("Host is not connected");
expect(en.panels.file.executionDirectoryMissing).toBe( expect(en.panels.file.directoryMissing).toBe("Workspace directory not found.");
"Workspace execution directory not found.",
);
}); });
it("includes workspace Git and review keys for the Batch 4B migration", () => { it("includes workspace Git and review keys for the Batch 4B migration", () => {
+1 -1
View File
@@ -1292,7 +1292,7 @@ export const ar: TranslationResources = {
creatingAgent: "وكيل الخلق", creatingAgent: "وكيل الخلق",
}, },
file: { file: {
executionDirectoryMissing: "لم يتم العثور على دليل تنفيذ Workspace.", directoryMissing: "لم يتم العثور على دليل Workspace.",
loading: "جارٍ تحميل الملف...", loading: "جارٍ تحميل الملف...",
noPreview: "لا تتوفر معاينة", noPreview: "لا تتوفر معاينة",
binaryPreviewUnavailable: "المعاينة الثنائية غير متاحة", binaryPreviewUnavailable: "المعاينة الثنائية غير متاحة",
+1 -1
View File
@@ -1298,7 +1298,7 @@ export const en = {
creatingAgent: "Creating agent", creatingAgent: "Creating agent",
}, },
file: { file: {
executionDirectoryMissing: "Workspace execution directory not found.", directoryMissing: "Workspace directory not found.",
loading: "Loading file...", loading: "Loading file...",
noPreview: "No preview available", noPreview: "No preview available",
binaryPreviewUnavailable: "Binary preview unavailable", binaryPreviewUnavailable: "Binary preview unavailable",
+1 -1
View File
@@ -1327,7 +1327,7 @@ export const es: TranslationResources = {
creatingAgent: "Agente creador", creatingAgent: "Agente creador",
}, },
file: { file: {
executionDirectoryMissing: "No se encontró el directorio de ejecución deWorkspace.", directoryMissing: "No se encontró el directorio de Workspace.",
loading: "Cargando archivo...", loading: "Cargando archivo...",
noPreview: "No hay vista previa disponible", noPreview: "No hay vista previa disponible",
binaryPreviewUnavailable: "Vista previa binaria no disponible", binaryPreviewUnavailable: "Vista previa binaria no disponible",
+1 -1
View File
@@ -1330,7 +1330,7 @@ export const fr: TranslationResources = {
creatingAgent: "Agent créateur", creatingAgent: "Agent créateur",
}, },
file: { file: {
executionDirectoryMissing: "Répertoire d'exécutionWorkspaceintrouvable.", directoryMissing: "Répertoire Workspace introuvable.",
loading: "Chargement du fichier...", loading: "Chargement du fichier...",
noPreview: "Aucun aperçu disponible", noPreview: "Aucun aperçu disponible",
binaryPreviewUnavailable: "Aperçu binaire indisponible", binaryPreviewUnavailable: "Aperçu binaire indisponible",
+1 -1
View File
@@ -1319,7 +1319,7 @@ export const ru: TranslationResources = {
creatingAgent: "Создание агента", creatingAgent: "Создание агента",
}, },
file: { file: {
executionDirectoryMissing: "Каталог выполнения Workspace не найден.", directoryMissing: "Каталог Workspace не найден.",
loading: "Загрузка файла...", loading: "Загрузка файла...",
noPreview: "Предварительный просмотр недоступен", noPreview: "Предварительный просмотр недоступен",
binaryPreviewUnavailable: "Предварительный просмотр двоичного файла недоступен.", binaryPreviewUnavailable: "Предварительный просмотр двоичного файла недоступен.",
+1 -1
View File
@@ -1275,7 +1275,7 @@ export const zhCN: TranslationResources = {
creatingAgent: "正在创建 Agent", creatingAgent: "正在创建 Agent",
}, },
file: { file: {
executionDirectoryMissing: "未找到 workspace 执行目录。", directoryMissing: "未找到 workspace 目录。",
loading: "正在加载文件...", loading: "正在加载文件...",
noPreview: "没有可用预览", noPreview: "没有可用预览",
binaryPreviewUnavailable: "二进制预览不可用", binaryPreviewUnavailable: "二进制预览不可用",
+2 -3
View File
@@ -6,7 +6,7 @@ import { BrowserPane } from "@/components/browser-pane";
import { usePaneContext, usePaneFocus } from "@/panels/pane-context"; import { usePaneContext, usePaneFocus } from "@/panels/pane-context";
import type { PanelDescriptor, PanelIconProps, PanelRegistration } from "@/panels/panel-registry"; import type { PanelDescriptor, PanelIconProps, PanelRegistration } from "@/panels/panel-registry";
import { useBrowserStore } from "@/stores/browser-store"; 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 { function getBrowserLabel(input: { title: string; url: string }): string {
const title = input.title.trim(); const title = input.title.trim();
@@ -55,8 +55,7 @@ function useBrowserPanelDescriptor(target: {
function BrowserPanel() { function BrowserPanel() {
const { serverId, workspaceId, target } = usePaneContext(); const { serverId, workspaceId, target } = usePaneContext();
const { focusPane, isInteractive } = usePaneFocus(); const { focusPane, isInteractive } = usePaneFocus();
const workspaceAuthority = useWorkspaceExecutionAuthority(serverId, workspaceId)!; const cwd = useWorkspaceDirectory(serverId, workspaceId);
const cwd = workspaceAuthority.ok ? workspaceAuthority.authority.workspaceDirectory : null;
invariant(target.kind === "browser", "BrowserPanel requires browser target"); invariant(target.kind === "browser", "BrowserPanel requires browser target");
return ( return (
<BrowserPane <BrowserPane
+3 -6
View File
@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
import { FilePane } from "@/components/file-pane"; import { FilePane } from "@/components/file-pane";
import { usePaneContext } from "@/panels/pane-context"; import { usePaneContext } from "@/panels/pane-context";
import type { PanelRegistration } from "@/panels/panel-registry"; import type { PanelRegistration } from "@/panels/panel-registry";
import { useWorkspaceExecutionAuthority } from "@/stores/session-store-hooks"; import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
const CENTERED_PADDED_STYLE = { const CENTERED_PADDED_STYLE = {
flex: 1, flex: 1,
@@ -28,15 +28,12 @@ function useFilePanelDescriptor(target: { kind: "file"; path: string }) {
function FilePanel() { function FilePanel() {
const { t } = useTranslation(); const { t } = useTranslation();
const { serverId, workspaceId, target } = usePaneContext(); const { serverId, workspaceId, target } = usePaneContext();
const workspaceAuthority = useWorkspaceExecutionAuthority(serverId, workspaceId); const workspaceDirectory = useWorkspaceDirectory(serverId, workspaceId);
const workspaceDirectory = workspaceAuthority?.ok
? workspaceAuthority.authority.workspaceDirectory
: null;
invariant(target.kind === "file", "FilePanel requires file target"); invariant(target.kind === "file", "FilePanel requires file target");
if (!workspaceDirectory) { if (!workspaceDirectory) {
return ( return (
<View style={CENTERED_PADDED_STYLE}> <View style={CENTERED_PADDED_STYLE}>
<Text>{t("panels.file.executionDirectoryMissing")}</Text> <Text>{t("panels.file.directoryMissing")}</Text>
</View> </View>
); );
} }
+8 -22
View File
@@ -11,7 +11,7 @@ import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry
import { queryClient } from "@/query/query-client"; import { queryClient } from "@/query/query-client";
import { usePanelStore } from "@/stores/panel-store"; import { usePanelStore } from "@/stores/panel-store";
import { useSessionStore } from "@/stores/session-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"]; type ListTerminalsPayload = ListTerminalsResponse["payload"];
@@ -37,21 +37,15 @@ function useTerminalPanelDescriptor(
): PanelDescriptor { ): PanelDescriptor {
const { t } = useTranslation(); const { t } = useTranslation();
const client = useSessionStore((state) => state.sessions[context.serverId]?.client ?? null); const client = useSessionStore((state) => state.sessions[context.serverId]?.client ?? null);
const workspaceAuthority = useWorkspaceExecutionAuthority(context.serverId, context.workspaceId)!; const workspace = useWorkspace(context.serverId, context.workspaceId);
const workspaceDirectory = workspaceAuthority.ok const workspaceDirectory = workspace?.workspaceDirectory || null;
? workspaceAuthority.authority.workspaceDirectory
: null;
const terminalsQuery = useQuery( const terminalsQuery = useQuery(
{ {
queryKey: ["terminals", context.serverId, workspaceDirectory] as const, queryKey: ["terminals", context.serverId, workspaceDirectory] as const,
enabled: Boolean(client && workspaceDirectory), enabled: Boolean(client && workspaceDirectory),
queryFn: async (): Promise<ListTerminalsPayload> => { queryFn: async (): Promise<ListTerminalsPayload> => {
if (!client || !workspaceDirectory) { if (!client || !workspaceDirectory) {
throw new Error( throw new Error("Workspace directory not found");
workspaceAuthority.ok
? "Workspace execution directory not found"
: workspaceAuthority.message,
);
} }
return client.listTerminals(workspaceDirectory); return client.listTerminals(workspaceDirectory);
}, },
@@ -76,13 +70,9 @@ function useTerminalPanelDescriptor(
function TerminalPanel() { function TerminalPanel() {
const { serverId, workspaceId, target, openFileInWorkspace } = usePaneContext(); const { serverId, workspaceId, target, openFileInWorkspace } = usePaneContext();
const { isWorkspaceFocused, isPaneFocused } = usePaneFocus(); const { isWorkspaceFocused, isPaneFocused } = usePaneFocus();
const workspaceAuthority = useWorkspaceExecutionAuthority(serverId, workspaceId)!; const workspace = useWorkspace(serverId, workspaceId);
const workspaceDirectory = workspaceAuthority.ok const workspaceDirectory = workspace?.workspaceDirectory || null;
? workspaceAuthority.authority.workspaceDirectory const isGitCheckout = workspace?.projectKind === "git";
: null;
const isGitCheckout = workspaceAuthority.ok
? workspaceAuthority.authority.workspace.projectKind === "git"
: false;
const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout); const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout);
const handleOpenFileExplorer = useCallback(() => { const handleOpenFileExplorer = useCallback(() => {
if (!workspaceDirectory) { if (!workspaceDirectory) {
@@ -102,11 +92,7 @@ function TerminalPanel() {
if (!workspaceDirectory) { if (!workspaceDirectory) {
return ( return (
<View style={CENTERED_PADDED_STYLE}> <View style={CENTERED_PADDED_STYLE}>
<Text> <Text>Workspace directory not found.</Text>
{workspaceAuthority.ok
? "Workspace execution directory not found."
: workspaceAuthority.message}
</Text>
</View> </View>
); );
} }
+1
View File
@@ -96,6 +96,7 @@ function normalizeBaseRef(baseRef: string | null | undefined): string {
function buildReviewDraftScopeParts(input: BuildReviewDraftScopeKeyInput): string[] { function buildReviewDraftScopeParts(input: BuildReviewDraftScopeKeyInput): string[] {
const workspaceId = input.workspaceId?.trim(); const workspaceId = input.workspaceId?.trim();
// workspaceId is opaque; do not parse this key back into a path.
const workspacePart = workspaceId const workspacePart = workspaceId
? `workspace=${encodeKeyPart(workspaceId)}` ? `workspace=${encodeKeyPart(workspaceId)}`
: `cwd=${encodeKeyPart(normalizeCwd(input.cwd))}`; : `cwd=${encodeKeyPart(normalizeCwd(input.cwd))}`;
@@ -38,7 +38,7 @@ interface UseWorkspaceTerminalsInput {
workspaceDirectory: string | null; workspaceDirectory: string | null;
workspaceScripts: WorkspaceDescriptor["scripts"]; workspaceScripts: WorkspaceDescriptor["scripts"];
hasHydratedWorkspaces: boolean; hasHydratedWorkspaces: boolean;
isMissingWorkspaceExecutionAuthority: boolean; isMissingWorkspaceDirectory: boolean;
onTerminalCreated: (input: { terminalId: string; paneId?: string }) => void; onTerminalCreated: (input: { terminalId: string; paneId?: string }) => void;
onScriptTerminalSelected: (terminalId: string) => void; onScriptTerminalSelected: (terminalId: string) => void;
onWorkspacePathUnavailable: () => void; onWorkspacePathUnavailable: () => void;
@@ -56,7 +56,7 @@ export function useWorkspaceTerminals(input: UseWorkspaceTerminalsInput) {
workspaceDirectory, workspaceDirectory,
workspaceScripts, workspaceScripts,
hasHydratedWorkspaces, hasHydratedWorkspaces,
isMissingWorkspaceExecutionAuthority, isMissingWorkspaceDirectory,
onTerminalCreated, onTerminalCreated,
onScriptTerminalSelected, onScriptTerminalSelected,
onWorkspacePathUnavailable, onWorkspacePathUnavailable,
@@ -209,7 +209,7 @@ export function useWorkspaceTerminals(input: UseWorkspaceTerminalsInput) {
return; return;
} }
if (hasHydratedWorkspaces && isMissingWorkspaceExecutionAuthority) { if (hasHydratedWorkspaces && isMissingWorkspaceDirectory) {
setPendingCreateInput(null); setPendingCreateInput(null);
onWorkspacePathUnavailable(); onWorkspacePathUnavailable();
} }
@@ -217,7 +217,7 @@ export function useWorkspaceTerminals(input: UseWorkspaceTerminalsInput) {
canCreateNow, canCreateNow,
createMutation, createMutation,
hasHydratedWorkspaces, hasHydratedWorkspaces,
isMissingWorkspaceExecutionAuthority, isMissingWorkspaceDirectory,
onWorkspacePathUnavailable, onWorkspacePathUnavailable,
pendingCreateInput, pendingCreateInput,
]); ]);
@@ -233,7 +233,7 @@ export function useWorkspaceTerminals(input: UseWorkspaceTerminalsInput) {
return; return;
} }
if (hasHydratedWorkspaces && isMissingWorkspaceExecutionAuthority) { if (hasHydratedWorkspaces && isMissingWorkspaceDirectory) {
onWorkspacePathUnavailable(); onWorkspacePathUnavailable();
return; return;
} }
@@ -245,7 +245,7 @@ export function useWorkspaceTerminals(input: UseWorkspaceTerminalsInput) {
canCreateNow, canCreateNow,
createMutation, createMutation,
hasHydratedWorkspaces, hasHydratedWorkspaces,
isMissingWorkspaceExecutionAuthority, isMissingWorkspaceDirectory,
onTerminalCreateQueued, onTerminalCreateQueued,
onWorkspacePathUnavailable, onWorkspacePathUnavailable,
pendingCreateInput, pendingCreateInput,
@@ -110,11 +110,7 @@ import { createWorkspaceBrowser, useBrowserStore } from "@/stores/browser-store"
import { getDesktopHost } from "@/desktop/host"; import { getDesktopHost } from "@/desktop/host";
import { buildProviderCommand } from "@/utils/provider-command-templates"; import { buildProviderCommand } from "@/utils/provider-command-templates";
import { generateDraftId } from "@/stores/draft-keys"; import { generateDraftId } from "@/stores/draft-keys";
import { import { resolveWorkspaceRouteId } from "@/utils/workspace-identity";
getWorkspaceExecutionAuthority,
resolveWorkspaceRouteId,
type WorkspaceExecutionAuthorityResult,
} from "@/utils/workspace-execution";
import { import {
WorkspaceTabPresentationResolver, WorkspaceTabPresentationResolver,
WorkspaceTabIcon, WorkspaceTabIcon,
@@ -170,7 +166,6 @@ import {
} from "@/screens/workspace/workspace-bulk-close"; } from "@/screens/workspace/workspace-bulk-close";
import { resolveCloseAgentTabPolicy } from "@/subagents"; import { resolveCloseAgentTabPolicy } from "@/subagents";
import { findAdjacentPane } from "@/utils/split-navigation"; import { findAdjacentPane } from "@/utils/split-navigation";
import { isAbsolutePath } from "@/utils/path";
import { useIsCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/layout"; import { useIsCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/layout";
import { getIsElectron, isNative, isWeb } from "@/constants/platform"; import { getIsElectron, isNative, isWeb } from "@/constants/platform";
import { useContainerWidthBelow } from "@/hooks/use-container-width"; import { useContainerWidthBelow } from "@/hooks/use-container-width";
@@ -966,13 +961,13 @@ function useCloseTabs(): UseCloseTabsResult {
interface WorkspaceHeaderMenuProps { interface WorkspaceHeaderMenuProps {
normalizedServerId: string; normalizedServerId: string;
normalizedWorkspaceId: string;
currentBranchName: string | null; currentBranchName: string | null;
showWorkspaceSetup: boolean; showWorkspaceSetup: boolean;
showCreateBrowserTab: boolean; showCreateBrowserTab: boolean;
isMobile: boolean; isMobile: boolean;
createTerminalDisabled: boolean; createTerminalDisabled: boolean;
importAgentDisabled: boolean; importAgentDisabled: boolean;
copyPathDisabled: boolean;
menuNewAgentIcon: ReactElement; menuNewAgentIcon: ReactElement;
menuNewTerminalIcon: ReactElement; menuNewTerminalIcon: ReactElement;
menuNewBrowserIcon: ReactElement; menuNewBrowserIcon: ReactElement;
@@ -1047,13 +1042,13 @@ function WorkspaceHeaderMenuTriggerIcon({
function WorkspaceHeaderMenu({ function WorkspaceHeaderMenu({
normalizedServerId, normalizedServerId,
normalizedWorkspaceId,
currentBranchName, currentBranchName,
showWorkspaceSetup, showWorkspaceSetup,
showCreateBrowserTab, showCreateBrowserTab,
isMobile, isMobile,
createTerminalDisabled, createTerminalDisabled,
importAgentDisabled, importAgentDisabled,
copyPathDisabled,
menuNewAgentIcon, menuNewAgentIcon,
menuNewTerminalIcon, menuNewTerminalIcon,
menuNewBrowserIcon, menuNewBrowserIcon,
@@ -1126,7 +1121,7 @@ function WorkspaceHeaderMenu({
<DropdownMenuItem <DropdownMenuItem
testID="workspace-header-copy-path" testID="workspace-header-copy-path"
leading={menuCopyIcon} leading={menuCopyIcon}
disabled={!isAbsolutePath(normalizedWorkspaceId)} disabled={copyPathDisabled}
onSelect={onCopyWorkspacePath} onSelect={onCopyWorkspacePath}
> >
{t("workspace.header.actions.copyPath")} {t("workspace.header.actions.copyPath")}
@@ -1198,6 +1193,7 @@ interface WorkspaceHeaderTitleBarProps {
isMobile: boolean; isMobile: boolean;
createTerminalDisabled: boolean; createTerminalDisabled: boolean;
importAgentDisabled: boolean; importAgentDisabled: boolean;
copyPathDisabled: boolean;
menuNewAgentIcon: ReactElement; menuNewAgentIcon: ReactElement;
menuNewTerminalIcon: ReactElement; menuNewTerminalIcon: ReactElement;
menuNewBrowserIcon: ReactElement; menuNewBrowserIcon: ReactElement;
@@ -1233,6 +1229,7 @@ function WorkspaceHeaderTitleBar({
isMobile, isMobile,
createTerminalDisabled, createTerminalDisabled,
importAgentDisabled, importAgentDisabled,
copyPathDisabled,
menuNewAgentIcon, menuNewAgentIcon,
menuNewTerminalIcon, menuNewTerminalIcon,
menuNewBrowserIcon, menuNewBrowserIcon,
@@ -1280,13 +1277,13 @@ function WorkspaceHeaderTitleBar({
<View style={styles.compactHeaderMenuCluster}> <View style={styles.compactHeaderMenuCluster}>
<WorkspaceHeaderMenu <WorkspaceHeaderMenu
normalizedServerId={normalizedServerId} normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
currentBranchName={currentBranchName} currentBranchName={currentBranchName}
showWorkspaceSetup={showWorkspaceSetup} showWorkspaceSetup={showWorkspaceSetup}
showCreateBrowserTab={showCreateBrowserTab} showCreateBrowserTab={showCreateBrowserTab}
isMobile={isMobile} isMobile={isMobile}
createTerminalDisabled={createTerminalDisabled} createTerminalDisabled={createTerminalDisabled}
importAgentDisabled={importAgentDisabled} importAgentDisabled={importAgentDisabled}
copyPathDisabled={copyPathDisabled}
menuNewAgentIcon={menuNewAgentIcon} menuNewAgentIcon={menuNewAgentIcon}
menuNewTerminalIcon={menuNewTerminalIcon} menuNewTerminalIcon={menuNewTerminalIcon}
menuNewBrowserIcon={menuNewBrowserIcon} menuNewBrowserIcon={menuNewBrowserIcon}
@@ -1331,7 +1328,7 @@ function parsePaneDirection(actionId: string): PaneDirection | null {
} }
interface RenderWorkspaceContentInput { interface RenderWorkspaceContentInput {
isMissingWorkspaceExecutionAuthority: boolean; isMissingWorkspaceDirectory: boolean;
activeTabDescriptor: WorkspaceTabDescriptor | null; activeTabDescriptor: WorkspaceTabDescriptor | null;
hasHydratedAgents: boolean; hasHydratedAgents: boolean;
mountedFocusedPaneTabIds: string[]; mountedFocusedPaneTabIds: string[];
@@ -1346,7 +1343,7 @@ interface RenderWorkspaceContentInput {
function renderWorkspaceContent(input: RenderWorkspaceContentInput): React.ReactNode { function renderWorkspaceContent(input: RenderWorkspaceContentInput): React.ReactNode {
const { const {
isMissingWorkspaceExecutionAuthority, isMissingWorkspaceDirectory,
activeTabDescriptor, activeTabDescriptor,
hasHydratedAgents, hasHydratedAgents,
mountedFocusedPaneTabIds, mountedFocusedPaneTabIds,
@@ -1356,11 +1353,11 @@ function renderWorkspaceContent(input: RenderWorkspaceContentInput): React.React
buildMobilePaneContentModel, buildMobilePaneContentModel,
} = input; } = input;
if (isMissingWorkspaceExecutionAuthority) { if (isMissingWorkspaceDirectory) {
return ( return (
<View style={styles.emptyState}> <View style={styles.emptyState}>
<Text style={styles.emptyStateText}> <Text style={styles.emptyStateText}>
Workspace execution directory is missing. Reload workspace data before opening tabs. Workspace directory is missing. Reload workspace data before opening tabs.
</Text> </Text>
</View> </View>
); );
@@ -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 { function getHostDisplayName(host: { label?: string | null } | null, fallback: string): string {
const trimmed = host?.label?.trim(); const trimmed = host?.label?.trim();
return trimmed ? trimmed : fallback; return trimmed ? trimmed : fallback;
@@ -1777,15 +1758,8 @@ function WorkspaceScreenContent({
const client = useHostRuntimeClient(normalizedServerId); const client = useHostRuntimeClient(normalizedServerId);
const isConnected = useHostRuntimeIsConnected(normalizedServerId); const isConnected = useHostRuntimeIsConnected(normalizedServerId);
const workspaceAuthority = useMemo( const workspaceDirectory = workspaceDescriptor?.workspaceDirectory || null;
() => const isMissingWorkspaceDirectory = Boolean(workspaceDescriptor) && !workspaceDirectory;
getWorkspaceExecutionAuthority({
workspace: workspaceDescriptor,
}),
[workspaceDescriptor],
);
const { workspaceDirectory, isMissingWorkspaceExecutionAuthority } =
resolveWorkspaceAuthorityState(workspaceAuthority, workspaceDescriptor);
const [isImportSheetVisible, setIsImportSheetVisible] = useState(false); const [isImportSheetVisible, setIsImportSheetVisible] = useState(false);
const canOpenImportSheet = [client, isConnected, workspaceDirectory].every(Boolean); const canOpenImportSheet = [client, isConnected, workspaceDirectory].every(Boolean);
const openImportSheet = useCallback(() => { const openImportSheet = useCallback(() => {
@@ -1870,7 +1844,7 @@ function WorkspaceScreenContent({
workspaceDirectory, workspaceDirectory,
workspaceScripts, workspaceScripts,
hasHydratedWorkspaces, hasHydratedWorkspaces,
isMissingWorkspaceExecutionAuthority, isMissingWorkspaceDirectory,
onTerminalCreated: handleTerminalCreated, onTerminalCreated: handleTerminalCreated,
onScriptTerminalSelected: handleScriptTerminalSelected, onScriptTerminalSelected: handleScriptTerminalSelected,
onWorkspacePathUnavailable: handleWorkspacePathUnavailable, onWorkspacePathUnavailable: handleWorkspacePathUnavailable,
@@ -3260,7 +3234,7 @@ function WorkspaceScreenContent({
[buildPaneContentModel], [buildPaneContentModel],
); );
const content = renderWorkspaceContent({ const content = renderWorkspaceContent({
isMissingWorkspaceExecutionAuthority, isMissingWorkspaceDirectory,
activeTabDescriptor, activeTabDescriptor,
hasHydratedAgents, hasHydratedAgents,
mountedFocusedPaneTabIds, mountedFocusedPaneTabIds,
@@ -3411,11 +3385,13 @@ function WorkspaceScreenContent({
) : null} ) : null}
{!isMobile && isGitCheckout ? ( {!isMobile && isGitCheckout ? (
<> <>
<WorkspaceGitActions {workspaceDirectory ? (
serverId={normalizedServerId} <WorkspaceGitActions
cwd={normalizedWorkspaceId} serverId={normalizedServerId}
hideLabels={showCompactButtonLabels} cwd={workspaceDirectory}
/> hideLabels={showCompactButtonLabels}
/>
) : null}
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}> <Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Pressable <Pressable
@@ -3654,6 +3630,7 @@ function WorkspaceScreenContent({
isMobile={isMobile} isMobile={isMobile}
createTerminalDisabled={createTerminalDisabled} createTerminalDisabled={createTerminalDisabled}
importAgentDisabled={!canOpenImportSheet} importAgentDisabled={!canOpenImportSheet}
copyPathDisabled={!workspaceDirectory}
menuNewAgentIcon={menuNewAgentIcon} menuNewAgentIcon={menuNewAgentIcon}
menuNewTerminalIcon={menuNewTerminalIcon} menuNewTerminalIcon={menuNewTerminalIcon}
menuNewBrowserIcon={MENU_NEW_BROWSER_ICON} menuNewBrowserIcon={MENU_NEW_BROWSER_ICON}
@@ -1,6 +1,6 @@
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import { i18n } from "@/i18n/i18next"; import { i18n } from "@/i18n/i18next";
import { encodeFilePathForPathSegment } from "@/utils/host-routes"; import { encodeFilePathForPathSegment, encodeWorkspaceIdForPathSegment } from "@/utils/host-routes";
import { buildDeterministicWorkspaceTabId } from "@/workspace-tabs/identity"; import { buildDeterministicWorkspaceTabId } from "@/workspace-tabs/identity";
export type WorkspaceTabMenuSurface = "desktop" | "mobile"; export type WorkspaceTabMenuSurface = "desktop" | "mobile";
@@ -136,7 +136,7 @@ function getCloseButtonTestId(tab: WorkspaceTabDescriptor): string {
return `workspace-browser-close-${tab.target.browserId}`; return `workspace-browser-close-${tab.target.browserId}`;
} }
if (tab.target.kind === "setup") { if (tab.target.kind === "setup") {
return `workspace-setup-close-${encodeFilePathForPathSegment(tab.target.workspaceId)}`; return `workspace-setup-close-${encodeWorkspaceIdForPathSegment(tab.target.workspaceId)}`;
} }
return `workspace-file-close-${encodeFilePathForPathSegment(tab.target.path)}`; return `workspace-file-close-${encodeFilePathForPathSegment(tab.target.path)}`;
} }
@@ -59,6 +59,7 @@ export function createLastWorkspaceSelectionStore(storage: LastWorkspaceSelectio
selection = normalized; selection = normalized;
revision += 1; revision += 1;
notifyListeners(); notifyListeners();
// workspaceId is opaque; do not parse this persisted selection back into a path.
void storage.write(JSON.stringify(normalized)).catch(() => {}); void storage.write(JSON.stringify(normalized)).catch(() => {});
} }
@@ -6,9 +6,9 @@ import {
parseHostWorkspaceRouteFromPathname, parseHostWorkspaceRouteFromPathname,
} from "@/utils/host-routes"; } from "@/utils/host-routes";
import { import {
resolveWorkspaceIdByExecutionDirectory, resolveWorkspaceIdByDirectory,
resolveWorkspaceMapKeyByIdentity, resolveWorkspaceMapKeyByIdentity,
} from "@/utils/workspace-execution"; } from "@/utils/workspace-identity";
import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection"; import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection";
export interface RouteSelectionInput { export interface RouteSelectionInput {
@@ -77,7 +77,7 @@ export function navigateToWorkspace(
const workspaceAgents = resolvedWorkspaceId const workspaceAgents = resolvedWorkspaceId
? Array.from(deps.getSessionAgents(serverId)).filter( ? Array.from(deps.getSessionAgents(serverId)).filter(
(agent) => (agent) =>
resolveWorkspaceIdByExecutionDirectory({ resolveWorkspaceIdByDirectory({
workspaces: workspaces?.values(), workspaces: workspaces?.values(),
workspaceDirectory: agent.cwd, workspaceDirectory: agent.cwd,
}) === resolvedWorkspaceId, }) === resolvedWorkspaceId,
@@ -9,8 +9,8 @@ import {
selectRecommendedProjectPaths, selectRecommendedProjectPaths,
selectResolveWorkspaceIdByCwd, selectResolveWorkspaceIdByCwd,
selectWorkspace, selectWorkspace,
selectWorkspaceDirectory,
selectWorkspaceExists, selectWorkspaceExists,
selectWorkspaceExecutionAuthority,
selectWorkspaceFields, selectWorkspaceFields,
selectWorkspaceKeys, selectWorkspaceKeys,
selectWorkspaceOrderByScopeForServer, selectWorkspaceOrderByScopeForServer,
@@ -20,7 +20,6 @@ import {
type WorkspaceStructure, type WorkspaceStructure,
} from "./selectors"; } from "./selectors";
import { useSessionStore, type WorkspaceDescriptor } from "../session-store"; import { useSessionStore, type WorkspaceDescriptor } from "../session-store";
import type { WorkspaceExecutionAuthorityResult } from "@/utils/workspace-execution";
import type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state"; import type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state";
// These are the ONLY supported ways to read workspaces from the session store. // 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, serverId: string | null,
workspaceId: string | null, workspaceId: string | null,
): WorkspaceExecutionAuthorityResult | null { ): string | null {
return useStoreWithEqualityFn( return useStoreWithEqualityFn(
useSessionStore, useSessionStore,
(state) => selectWorkspaceExecutionAuthority(state, serverId, workspaceId), (state) => selectWorkspaceDirectory(state, serverId, workspaceId),
workspaceEqualityFns.deep, workspaceEqualityFns.identity,
); );
} }
@@ -8,7 +8,7 @@ import {
selectRecommendedProjectPaths, selectRecommendedProjectPaths,
selectResolveWorkspaceIdByCwd, selectResolveWorkspaceIdByCwd,
selectWorkspace, selectWorkspace,
selectWorkspaceExecutionAuthority, selectWorkspaceDirectory,
selectWorkspaceFields, selectWorkspaceFields,
selectWorkspaceKeys, selectWorkspaceKeys,
selectWorkspaceOrderByScopeForServer, selectWorkspaceOrderByScopeForServer,
@@ -142,34 +142,30 @@ describe("selectWorkspace", () => {
}); });
}); });
describe("selectWorkspaceExecutionAuthority", () => { describe("selectWorkspaceDirectory", () => {
it("preserves the old missing-workspace message with the requested id", () => { 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([]); initializeWorkspaces([]);
expect( expect(
selectWorkspaceExecutionAuthority(useSessionStore.getState(), SERVER_ID, "missing-id"), selectWorkspaceDirectory(useSessionStore.getState(), SERVER_ID, "missing-id"),
).toMatchObject({ ).toBeNull();
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();
}); });
}); });
@@ -6,11 +6,9 @@ import {
} from "@/projects/workspace-structure"; } from "@/projects/workspace-structure";
import type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state"; import type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state";
import { import {
getWorkspaceExecutionAuthority, resolveWorkspaceIdByDirectory,
resolveWorkspaceIdByExecutionDirectory,
resolveWorkspaceMapKeyByIdentity, resolveWorkspaceMapKeyByIdentity,
type WorkspaceExecutionAuthorityResult, } from "@/utils/workspace-identity";
} from "@/utils/workspace-execution";
import type { WorkspaceDescriptor } from "../session-store"; import type { WorkspaceDescriptor } from "../session-store";
export type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state"; export type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state";
@@ -113,6 +111,14 @@ export function selectWorkspaceFields<T>(
return workspace ? project(workspace) : null; 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( export function selectWorkspaceExists(
state: SessionsSnapshot, state: SessionsSnapshot,
serverId: string | null, serverId: string | null,
@@ -128,20 +134,6 @@ export function selectHasHydratedWorkspaces(
return serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false; 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( export function selectWorkspaceStructureProjects(
state: SessionsSnapshot, state: SessionsSnapshot,
serverId: string | null, serverId: string | null,
@@ -257,7 +249,7 @@ export function selectResolveWorkspaceIdByCwd(
return null; return null;
} }
const workspaces = state.sessions[serverId]?.workspaces; const workspaces = state.sessions[serverId]?.workspaces;
return resolveWorkspaceIdByExecutionDirectory({ return resolveWorkspaceIdByDirectory({
workspaces: workspaces?.values(), workspaces: workspaces?.values(),
workspaceDirectory: cwd, workspaceDirectory: cwd,
}); });
@@ -99,6 +99,44 @@ describe("normalizeWorkspaceDescriptor", () => {
expect(workspace.scripts).not.toBe(scripts); 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", () => { it("defaults missing scripts to an empty array", () => {
const payload = { const payload = {
id: "1", id: "1",
+9 -3
View File
@@ -27,8 +27,11 @@ import type {
ServerCapabilities, ServerCapabilities,
WorkspaceDescriptorPayload, WorkspaceDescriptorPayload,
} from "@getpaseo/protocol/messages"; } from "@getpaseo/protocol/messages";
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity"; import {
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-execution"; normalizeWorkspaceOpaqueId,
normalizeWorkspacePath,
resolveWorkspaceMapKeyByIdentity,
} from "@/utils/workspace-identity";
import { import {
createAgentLastActivityCoalescer, createAgentLastActivityCoalescer,
type AgentLastActivityCommitter, type AgentLastActivityCommitter,
@@ -149,7 +152,10 @@ export function normalizeWorkspaceDescriptor(
projectDisplayName: payload.projectDisplayName, projectDisplayName: payload.projectDisplayName,
projectCustomName: payload.projectCustomName ?? null, projectCustomName: payload.projectCustomName ?? null,
projectRootPath: payload.projectRootPath, 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, projectKind: payload.projectKind,
workspaceKind: payload.workspaceKind, workspaceKind: payload.workspaceKind,
name: payload.name, name: payload.name,
@@ -59,6 +59,7 @@ export function buildWorkspaceTabPersistenceKey(input: {
if (!serverId || !workspaceId) { if (!serverId || !workspaceId) {
return null; return null;
} }
// workspaceId is opaque; do not parse this key back into a path.
return `${serverId}:${workspaceId}`; return `${serverId}:${workspaceId}`;
} }
@@ -31,6 +31,7 @@ describe("workspace route parsing", () => {
it("keeps URL-safe workspace IDs unencoded", () => { it("keeps URL-safe workspace IDs unencoded", () => {
expect(encodeWorkspaceIdForPathSegment("164")).toBe("164"); expect(encodeWorkspaceIdForPathSegment("164")).toBe("164");
expect(decodeWorkspaceIdFromPathSegment("164")).toBe("164"); expect(decodeWorkspaceIdFromPathSegment("164")).toBe("164");
expect(decodeWorkspaceIdFromPathSegment("wks_10b3479c955fcc4c")).toBe("wks_10b3479c955fcc4c");
}); });
it("encodes non-URL-safe workspace IDs as base64url", () => { it("encodes non-URL-safe workspace IDs as base64url", () => {
+16 -2
View File
@@ -100,6 +100,19 @@ function isLegacyPathLikeWorkspaceValue(value: string): boolean {
return value.includes("/") || value.includes("\\") || /^[A-Za-z]:[\\/]/.test(value); 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 = export type WorkspaceOpenIntent =
| { kind: "agent"; agentId: string } | { kind: "agent"; agentId: string }
| { kind: "terminal"; terminalId: string } | { kind: "terminal"; terminalId: string }
@@ -193,13 +206,14 @@ export function decodeWorkspaceIdFromPathSegment(workspaceIdSegment: string): st
return prefixedDecoded ? normalizeWorkspaceId(prefixedDecoded) : null; 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); const base64Decoded = tryDecodeBase64UrlNoPadUtf8(decoded);
if (base64Decoded && isLegacyPathLikeWorkspaceValue(base64Decoded)) { if (base64Decoded && isCleanLegacyPathDecode(base64Decoded)) {
return normalizeWorkspaceId(base64Decoded); return normalizeWorkspaceId(base64Decoded);
} }
const relaxedBase64Decoded = decodeBase64UrlNoPadUtf8(decoded); const relaxedBase64Decoded = decodeBase64UrlNoPadUtf8(decoded);
if (relaxedBase64Decoded && isLegacyPathLikeWorkspaceValue(relaxedBase64Decoded)) { if (relaxedBase64Decoded && isCleanLegacyPathDecode(relaxedBase64Decoded)) {
return normalizeWorkspaceId(relaxedBase64Decoded); return normalizeWorkspaceId(relaxedBase64Decoded);
} }
@@ -1,6 +1,6 @@
import type { WorkspaceDescriptor } from "@/stores/session-store"; import type { WorkspaceDescriptor } from "@/stores/session-store";
import { buildHostAgentDetailRoute } from "@/utils/host-routes"; 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"; import type { NavigateToPreparedWorkspaceTabInput } from "@/utils/prepare-workspace-tab";
export interface NavigateToAgentInput { export interface NavigateToAgentInput {
@@ -29,7 +29,7 @@ export function resolveNavigateToAgent(
serverId: input.serverId, serverId: input.serverId,
agentId: input.agentId, agentId: input.agentId,
}); });
const workspaceId = resolveWorkspaceIdByExecutionDirectory({ const workspaceId = resolveWorkspaceIdByDirectory({
workspaces, workspaces,
workspaceDirectory: agentCwd, workspaceDirectory: agentCwd,
}); });
@@ -1,7 +1,7 @@
import type { Href } from "expo-router"; import type { Href } from "expo-router";
import type { WorkspaceDescriptor } from "@/stores/session-store"; import type { WorkspaceDescriptor } from "@/stores/session-store";
import { buildHostNewWorkspaceRoute, buildHostRootRoute } from "@/utils/host-routes"; import { buildHostNewWorkspaceRoute, buildHostRootRoute } from "@/utils/host-routes";
import { resolveWorkspaceRouteId } from "@/utils/workspace-execution"; import { resolveWorkspaceRouteId } from "@/utils/workspace-identity";
export function buildWorkspaceArchiveRedirectRoute(input: { export function buildWorkspaceArchiveRedirectRoute(input: {
serverId: string; serverId: string;
@@ -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");
});
});
@@ -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;
}
@@ -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<WorkspaceDescriptor> | 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<string, WorkspaceDescriptor> | 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<string, WorkspaceDescriptor> | 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<string, WorkspaceDescriptor> | 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<string, WorkspaceDescriptor> | undefined;
workspaceId: string | null | undefined;
},
): WorkspaceAuthorityResult | null {
const result = getWorkspaceExecutionAuthority(input);
return result.ok ? result.authority : null;
}
@@ -1,12 +1,10 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { WorkspaceDescriptor } from "@/stores/session-store"; import type { WorkspaceDescriptor } from "@/stores/session-store";
import { import {
getWorkspaceExecutionAuthority, resolveWorkspaceIdByDirectory,
requireWorkspaceExecutionAuthority,
resolveWorkspaceMapKeyByIdentity, resolveWorkspaceMapKeyByIdentity,
resolveWorkspaceIdByExecutionDirectory,
resolveWorkspaceRouteId, resolveWorkspaceRouteId,
} from "./workspace-execution"; } from "./workspace-identity";
function createWorkspace( function createWorkspace(
input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">, input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">,
@@ -40,7 +38,7 @@ describe("resolveWorkspaceRouteId", () => {
}); });
}); });
describe("resolveWorkspaceIdByExecutionDirectory", () => { describe("resolveWorkspaceIdByDirectory", () => {
it("matches workspace directories", () => { it("matches workspace directories", () => {
const workspaces = [ const workspaces = [
createWorkspace({ createWorkspace({
@@ -51,7 +49,7 @@ describe("resolveWorkspaceIdByExecutionDirectory", () => {
]; ];
expect( expect(
resolveWorkspaceIdByExecutionDirectory({ resolveWorkspaceIdByDirectory({
workspaces, workspaces,
workspaceDirectory: "/repo/.paseo/worktrees/feature", workspaceDirectory: "/repo/.paseo/worktrees/feature",
}), }),
@@ -68,7 +66,7 @@ describe("resolveWorkspaceIdByExecutionDirectory", () => {
]; ];
expect( expect(
resolveWorkspaceIdByExecutionDirectory({ resolveWorkspaceIdByDirectory({
workspaces, workspaces,
workspaceDirectory: "/repo", workspaceDirectory: "/repo",
}), }),
@@ -115,66 +113,3 @@ describe("resolveWorkspaceMapKeyByIdentity", () => {
).toBeNull(); ).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<string, WorkspaceDescriptor>([
[
"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<string, WorkspaceDescriptor>([
[
"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"),
});
});
});
@@ -1,3 +1,5 @@
import type { WorkspaceDescriptor } from "@/stores/session-store";
function trimNonEmpty(value: string | null | undefined): string | null { function trimNonEmpty(value: string | null | undefined): string | null {
if (typeof value !== "string") { if (typeof value !== "string") {
return null; return null;
@@ -22,3 +24,56 @@ export function normalizeWorkspacePath(value: string | null | undefined): string
const withoutTrailingSlash = withUnixSeparators.replace(/\/+$/, ""); const withoutTrailingSlash = withUnixSeparators.replace(/\/+$/, "");
return withoutTrailingSlash.length > 0 ? withoutTrailingSlash : "/"; 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<WorkspaceDescriptor> | 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<string, WorkspaceDescriptor> | 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;
}
@@ -3,7 +3,7 @@ import type { WorkspaceTabSnapshot } from "@/stores/workspace-layout-actions";
import { shouldAutoOpenAgentTab } from "@/subagents/policies"; import { shouldAutoOpenAgentTab } from "@/subagents/policies";
import { normalizeWorkspacePath } from "@/utils/workspace-identity"; import { normalizeWorkspacePath } from "@/utils/workspace-identity";
function normalizeWorkspaceId(value: string | null | undefined): string { function normalizeWorkspaceDirectory(value: string | null | undefined): string {
return normalizeWorkspacePath(value) ?? ""; return normalizeWorkspacePath(value) ?? "";
} }
@@ -19,7 +19,7 @@ export function deriveWorkspaceAgentVisibility(input: {
workspaceDirectory: string | null | undefined; workspaceDirectory: string | null | undefined;
}): WorkspaceAgentVisibility { }): WorkspaceAgentVisibility {
const { sessionAgents, agentDetails, workspaceDirectory } = input; const { sessionAgents, agentDetails, workspaceDirectory } = input;
const normalizedWorkspaceDirectory = normalizeWorkspaceId(workspaceDirectory); const normalizedWorkspaceDirectory = normalizeWorkspaceDirectory(workspaceDirectory);
if ((!sessionAgents && !agentDetails) || !normalizedWorkspaceDirectory) { if ((!sessionAgents && !agentDetails) || !normalizedWorkspaceDirectory) {
return { return {
activeAgentIds: new Set<string>(), activeAgentIds: new Set<string>(),
@@ -32,7 +32,7 @@ export function deriveWorkspaceAgentVisibility(input: {
const autoOpenAgentIds = new Set<string>(); const autoOpenAgentIds = new Set<string>();
const knownAgentIds = new Set<string>(); const knownAgentIds = new Set<string>();
for (const agent of sessionAgents?.values() ?? []) { for (const agent of sessionAgents?.values() ?? []) {
if (normalizeWorkspaceId(agent.cwd) !== normalizedWorkspaceDirectory) { if (normalizeWorkspaceDirectory(agent.cwd) !== normalizedWorkspaceDirectory) {
continue; continue;
} }
knownAgentIds.add(agent.id); knownAgentIds.add(agent.id);
@@ -44,7 +44,7 @@ export function deriveWorkspaceAgentVisibility(input: {
} }
} }
for (const agent of agentDetails?.values() ?? []) { for (const agent of agentDetails?.values() ?? []) {
if (normalizeWorkspaceId(agent.cwd) !== normalizedWorkspaceDirectory) { if (normalizeWorkspaceDirectory(agent.cwd) !== normalizedWorkspaceDirectory) {
continue; continue;
} }
knownAgentIds.add(agent.id); knownAgentIds.add(agent.id);
@@ -3,7 +3,7 @@ import {
markWorkspaceArchivePending, markWorkspaceArchivePending,
} from "@/contexts/session-workspace-upserts"; } from "@/contexts/session-workspace-upserts";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-execution"; import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
export interface WorkspaceArchiveTarget { export interface WorkspaceArchiveTarget {
serverId: string; serverId: string;
+7 -1
View File
@@ -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 { return {
type: "single", type: "single",
@@ -15,7 +15,6 @@ export async function subscribeToEvents(
await client.connect(); await client.connect();
await client.workspaces.list({ await client.workspaces.list({
filter: { idPrefix: workspaceId },
subscribe: { subscriptionId: `workspace-${workspaceId}` }, subscribe: { subscriptionId: `workspace-${workspaceId}` },
}); });
-1
View File
@@ -241,7 +241,6 @@ test("workspace handles keep identity and refresh snapshots through existing dri
expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({ expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({
type: "fetch_workspaces_request", type: "fetch_workspaces_request",
requestId: "workspace-refetch-request", requestId: "workspace-refetch-request",
filter: { idPrefix: "workspace_sdk" },
page: { limit: 25 }, page: { limit: 25 },
}); });
+3 -1
View File
@@ -414,9 +414,11 @@ function createWorkspaceHandleFactory(daemonClient: DaemonClient): WorkspaceHand
id, id,
latest: () => latest, latest: () => latest,
refetch: async (options) => { 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({ const result = await daemonClient.fetchWorkspaces({
requestId: options?.requestId, requestId: options?.requestId,
filter: { idPrefix: id },
page: { limit: 25 }, page: { limit: 25 },
}); });
latest = result.entries.find((entry) => entry.id === id) ?? null; latest = result.entries.find((entry) => entry.id === id) ?? null;
+2
View File
@@ -948,6 +948,7 @@ export const FetchWorkspacesRequestMessageSchema = z.object({
.object({ .object({
query: z.string().optional(), query: z.string().optional(),
projectId: 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(), idPrefix: z.string().optional(),
}) })
.optional(), .optional(),
@@ -1627,6 +1628,7 @@ export const LegacyOpenInEditorRequestSchema = z.object({
export const OpenProjectRequestSchema = z.object({ export const OpenProjectRequestSchema = z.object({
type: z.literal("open_project_request"), 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(), cwd: z.string(),
requestId: z.string(), requestId: z.string(),
}); });
@@ -849,7 +849,6 @@ export class AgentManager {
agentId?: string, agentId?: string,
options?: { options?: {
labels?: Record<string, string>; labels?: Record<string, string>;
workspaceId?: string;
initialPrompt?: string; initialPrompt?: string;
env?: Record<string, string>; env?: Record<string, string>;
persistSession?: boolean; persistSession?: boolean;
@@ -867,7 +866,6 @@ export class AgentManager {
const session = await client.createSession(launchConfig, launchContext, createOptions); const session = await client.createSession(launchConfig, launchContext, createOptions);
return this.registerSession(session, storedConfig, resolvedAgentId, { return this.registerSession(session, storedConfig, resolvedAgentId, {
labels: options?.labels, labels: options?.labels,
workspaceId: options?.workspaceId,
initialTitle: options?.initialTitle, initialTitle: options?.initialTitle,
}); });
} }
@@ -2321,7 +2319,6 @@ export class AgentManager {
config: AgentSessionConfig, config: AgentSessionConfig,
agentId: string, agentId: string,
options?: { options?: {
workspaceId?: string;
createdAt?: Date; createdAt?: Date;
updatedAt?: Date; updatedAt?: Date;
lastUserMessageAt?: Date | null; lastUserMessageAt?: Date | null;
@@ -2369,7 +2366,6 @@ export class AgentManager {
this.previousStatuses.set(resolvedAgentId, managed.lifecycle); this.previousStatuses.set(resolvedAgentId, managed.lifecycle);
await this.refreshRuntimeInfo(managed, { emit: !options?.publishWhenReady }); await this.refreshRuntimeInfo(managed, { emit: !options?.publishWhenReady });
await this.persistSnapshot(managed, { await this.persistSnapshot(managed, {
workspaceId: options?.workspaceId,
title: initialPersistedTitle, title: initialPersistedTitle,
}); });
if (!options?.publishWhenReady) { if (!options?.publishWhenReady) {
@@ -2378,7 +2374,7 @@ export class AgentManager {
await this.refreshSessionState(managed, { emit: !options?.publishWhenReady }); await this.refreshSessionState(managed, { emit: !options?.publishWhenReady });
managed.lifecycle = "idle"; managed.lifecycle = "idle";
await this.persistSnapshot(managed, { workspaceId: options?.workspaceId }); await this.persistSnapshot(managed);
this.emitState(managed, { persist: false }); this.emitState(managed, { persist: false });
this.subscribeToSession(managed); this.subscribeToSession(managed);
return { ...managed }; return { ...managed };
@@ -2639,7 +2635,7 @@ export class AgentManager {
private async persistSnapshot( private async persistSnapshot(
agent: ManagedAgent, agent: ManagedAgent,
options?: { workspaceId?: string; title?: string | null; internal?: boolean }, options?: { title?: string | null; internal?: boolean },
): Promise<void> { ): Promise<void> {
if (!this.registry) { if (!this.registry) {
return; return;
@@ -2648,10 +2644,6 @@ export class AgentManager {
if (agent.internal) { if (agent.internal) {
return; return;
} }
if (options?.workspaceId !== undefined) {
await this.registry.applySnapshot(agent, options.workspaceId, options);
return;
}
await this.registry.applySnapshot(agent, options); await this.registry.applySnapshot(agent, options);
} }
@@ -191,23 +191,19 @@ export class AgentStorage {
async applySnapshot( async applySnapshot(
agent: ManagedAgent, agent: ManagedAgent,
workspaceIdOrOptions?: string | { title?: string | null; internal?: boolean },
options?: { title?: string | null; internal?: boolean }, options?: { title?: string | null; internal?: boolean },
): Promise<void> { ): Promise<void> {
const nextOptions = typeof workspaceIdOrOptions === "string" ? options : workspaceIdOrOptions;
await this.load(); await this.load();
await this.waitForPendingWrite(agent.id); await this.waitForPendingWrite(agent.id);
const existing = (await this.get(agent.id)) ?? null; const existing = (await this.get(agent.id)) ?? null;
const hasTitleOverride = const hasTitleOverride =
nextOptions !== undefined && Object.prototype.hasOwnProperty.call(nextOptions, "title"); options !== undefined && Object.prototype.hasOwnProperty.call(options, "title");
const hasInternalOverride = const hasInternalOverride =
nextOptions !== undefined && Object.prototype.hasOwnProperty.call(nextOptions, "internal"); options !== undefined && Object.prototype.hasOwnProperty.call(options, "internal");
const record = toStoredAgentRecord(agent, { const record = toStoredAgentRecord(agent, {
title: hasTitleOverride ? (nextOptions?.title ?? null) : (existing?.title ?? null), title: hasTitleOverride ? (options?.title ?? null) : (existing?.title ?? null),
createdAt: existing?.createdAt, createdAt: existing?.createdAt,
internal: hasInternalOverride internal: hasInternalOverride ? options?.internal : (agent.internal ?? existing?.internal),
? nextOptions?.internal
: (agent.internal ?? existing?.internal),
}); });
// Preserve soft-delete/archive status across snapshot flushes. // Preserve soft-delete/archive status across snapshot flushes.
@@ -26,6 +26,7 @@ interface CreateAgentLifecycleDispatchDependencies {
workspaceGitService: WorkspaceGitService; workspaceGitService: WorkspaceGitService;
createPaseoWorktreeWorkflow: CreatePaseoWorktreeWorkflowFn; createPaseoWorktreeWorkflow: CreatePaseoWorktreeWorkflowFn;
archiveAgentForClose: (agentId: string) => Promise<unknown>; archiveAgentForClose: (agentId: string) => Promise<unknown>;
resolveWorkspaceIdForCwd: (cwd: string) => Promise<string | null>;
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>; archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
emit: (message: SessionOutboundMessage) => void; emit: (message: SessionOutboundMessage) => void;
emitAgentRemove: (agentId: string) => void; emitAgentRemove: (agentId: string) => void;
@@ -207,6 +208,7 @@ export class CreateAgentLifecycleDispatch {
workspaceGitService: this.dependencies.workspaceGitService, workspaceGitService: this.dependencies.workspaceGitService,
agentManager: this.dependencies.agentManager, agentManager: this.dependencies.agentManager,
agentStorage: this.dependencies.agentStorage, agentStorage: this.dependencies.agentStorage,
resolveWorkspaceIdForCwd: this.dependencies.resolveWorkspaceIdForCwd,
archiveWorkspaceRecord: this.dependencies.archiveWorkspaceRecord, archiveWorkspaceRecord: this.dependencies.archiveWorkspaceRecord,
emitWorkspaceUpdatesForWorkspaceIds: this.dependencies.emitWorkspaceUpdatesForWorkspaceIds, emitWorkspaceUpdatesForWorkspaceIds: this.dependencies.emitWorkspaceUpdatesForWorkspaceIds,
markWorkspaceArchiving: this.dependencies.markWorkspaceArchiving, markWorkspaceArchiving: this.dependencies.markWorkspaceArchiving,
@@ -38,7 +38,6 @@ test("session create forwards clientMessageId to the initial prompt run options"
explicitTitle: "Explicit title", explicitTitle: "Explicit title",
firstAgentContext: { attachments: [] }, firstAgentContext: { attachments: [] },
buildSessionConfig: async (config) => ({ sessionConfig: config }), buildSessionConfig: async (config) => ({ sessionConfig: config }),
resolveWorkspace: async () => ({ workspaceId: "workspace-1" }),
}); });
expect(streamAgent).toHaveBeenCalledWith("agent-1", "hello from create", { expect(streamAgent).toHaveBeenCalledWith("agent-1", "hello from create", {
@@ -32,10 +32,6 @@ import {
emitLiveTimelineItemIfAgentKnown, emitLiveTimelineItemIfAgentKnown,
} from "../timeline-append.js"; } from "../timeline-append.js";
export interface CreateAgentWorkspace {
workspaceId: string;
}
export interface CreateAgentSessionWorktreeResult { export interface CreateAgentSessionWorktreeResult {
sessionConfig: AgentSessionConfig; sessionConfig: AgentSessionConfig;
setupContinuation?: AgentWorktreeSetupContinuation; setupContinuation?: AgentWorktreeSetupContinuation;
@@ -79,7 +75,6 @@ export interface CreateAgentFromSessionInput {
legacyWorktreeName?: string, legacyWorktreeName?: string,
firstAgentContext?: FirstAgentContext, firstAgentContext?: FirstAgentContext,
) => Promise<CreateAgentSessionWorktreeResult>; ) => Promise<CreateAgentSessionWorktreeResult>;
resolveWorkspace: (input: { cwd: string; workspaceId?: string }) => Promise<CreateAgentWorkspace>;
} }
export interface CreateAgentFromMcpInput { export interface CreateAgentFromMcpInput {
@@ -134,7 +129,6 @@ interface ResolvedCreateAgent {
interface AgentCreateOptions { interface AgentCreateOptions {
labels?: Record<string, string>; labels?: Record<string, string>;
workspaceId?: string;
initialPrompt?: string; initialPrompt?: string;
env?: Record<string, string>; env?: Record<string, string>;
initialTitle?: string | null; initialTitle?: string | null;
@@ -196,10 +190,6 @@ async function resolveSessionCreateAgent(
input.worktreeName, input.worktreeName,
input.firstAgentContext, input.firstAgentContext,
); );
const workspace = await input.resolveWorkspace({
cwd: sessionConfig.cwd,
workspaceId: input.workspaceId,
});
const prompt = buildAgentPrompt(trimmedPrompt ?? "", input.images, input.attachments); const prompt = buildAgentPrompt(trimmedPrompt ?? "", input.images, input.attachments);
const hasPromptContent = Array.isArray(prompt) ? prompt.length > 0 : prompt.length > 0; const hasPromptContent = Array.isArray(prompt) ? prompt.length > 0 : prompt.length > 0;
const clientMessageId = normalizeClientMessageId(input.clientMessageId); const clientMessageId = normalizeClientMessageId(input.clientMessageId);
@@ -215,7 +205,6 @@ async function resolveSessionCreateAgent(
config: sessionConfig, config: sessionConfig,
createOptions: { createOptions: {
labels: input.labels, labels: input.labels,
workspaceId: workspace.workspaceId,
initialPrompt: trimmedPrompt, initialPrompt: trimmedPrompt,
env: input.env, env: input.env,
initialTitle: input.provisionalTitle, initialTitle: input.provisionalTitle,
@@ -96,15 +96,16 @@ export async function archiveAgentCommand(
agentId: string, agentId: string,
): Promise<ArchiveAgentResult> { ): Promise<ArchiveAgentResult> {
const liveAgent = dependencies.agentManager.getAgent(agentId); const liveAgent = dependencies.agentManager.getAgent(agentId);
let record: StoredAgentRecord | null;
if (liveAgent) { if (liveAgent) {
await cancelAgentRunCommand(dependencies, agentId); await cancelAgentRunCommand(dependencies, agentId);
await dependencies.agentManager.clearAgentAttention(agentId).catch(() => undefined); await dependencies.agentManager.clearAgentAttention(agentId).catch(() => undefined);
await dependencies.agentManager.archiveAgent(agentId); await dependencies.agentManager.archiveAgent(agentId);
record = await dependencies.agentStorage.get(agentId);
} else { } else {
await archiveStoredAgent(dependencies, agentId); record = await archiveStoredAgent(dependencies, agentId);
} }
const record = await dependencies.agentStorage.get(agentId);
if (!record) { if (!record) {
throw new Error(`Agent not found in storage after archive: ${agentId}`); throw new Error(`Agent not found in storage after archive: ${agentId}`);
} }
@@ -174,16 +175,16 @@ export async function setAgentModeCommand(
async function archiveStoredAgent( async function archiveStoredAgent(
dependencies: Pick<AgentLifecycleCommandDependencies, "agentManager" | "agentStorage">, dependencies: Pick<AgentLifecycleCommandDependencies, "agentManager" | "agentStorage">,
agentId: string, agentId: string,
): Promise<void> { ): Promise<StoredAgentRecord> {
const existing = await dependencies.agentStorage.get(agentId); const existing = await dependencies.agentStorage.get(agentId);
if (!existing) { if (!existing) {
throw new Error(`Agent not found: ${agentId}`); throw new Error(`Agent not found: ${agentId}`);
} }
if (existing.archivedAt) { if (existing.archivedAt) {
return; return existing;
} }
const archivedAt = new Date().toISOString(); const archivedAt = new Date().toISOString();
await dependencies.agentManager.archiveSnapshot(agentId, archivedAt); return dependencies.agentManager.archiveSnapshot(agentId, archivedAt);
} }
@@ -1258,7 +1258,7 @@ describe("create_agent MCP tool", () => {
baseRefName: "main", baseRefName: "main",
}, },
workspace: { workspace: {
workspaceId: "/tmp/worktrees/pr-123", workspaceId: "ws-pr-123",
projectId: REPO_CWD, projectId: REPO_CWD,
cwd: "/tmp/worktrees/pr-123", cwd: "/tmp/worktrees/pr-123",
kind: "worktree" as const, kind: "worktree" as const,
@@ -1395,7 +1395,7 @@ describe("create_agent MCP tool", () => {
}); });
expect(setupContinuations).toEqual([undefined]); expect(setupContinuations).toEqual([undefined]);
expect(broadcasts).toHaveLength(1); expect(broadcasts).toHaveLength(1);
expect(broadcasts[0]).toContain("tool-worktree"); expect(broadcasts[0]).toMatch(/^wks_[0-9a-f]{16}$/);
} finally { } finally {
await rm(tempDir, { recursive: true, force: true }); await rm(tempDir, { recursive: true, force: true });
} }
@@ -1444,6 +1444,7 @@ describe("create_agent MCP tool", () => {
WorkspaceGitService, WorkspaceGitService,
"getSnapshot" | "listWorktrees" | "resolveRepoRoot" "getSnapshot" | "listWorktrees" | "resolveRepoRoot"
>, >,
resolveWorkspaceIdForCwd: vi.fn(async () => "ws-archive-tool-worktree"),
archiveWorkspaceRecord, archiveWorkspaceRecord,
emitWorkspaceUpdatesForWorkspaceIds, emitWorkspaceUpdatesForWorkspaceIds,
markWorkspaceArchiving, markWorkspaceArchiving,
@@ -1473,16 +1474,14 @@ describe("create_agent MCP tool", () => {
force: true, force: true,
reason: "mcp:archive-worktree", reason: "mcp:archive-worktree",
}); });
expect(archiveWorkspaceRecord).toHaveBeenCalledWith(created.structuredContent.worktreePath); expect(archiveWorkspaceRecord).toHaveBeenCalledWith("ws-archive-tool-worktree");
expect(markWorkspaceArchiving).toHaveBeenCalledWith( expect(markWorkspaceArchiving).toHaveBeenCalledWith(
[created.structuredContent.worktreePath], ["ws-archive-tool-worktree"],
expect.any(String), expect.any(String),
); );
expect(clearWorkspaceArchiving).toHaveBeenCalledWith([ expect(clearWorkspaceArchiving).toHaveBeenCalledWith(["ws-archive-tool-worktree"]);
created.structuredContent.worktreePath,
]);
expect(Array.from(emitWorkspaceUpdatesForWorkspaceIds.mock.calls[0]?.[0] ?? [])).toEqual([ expect(Array.from(emitWorkspaceUpdatesForWorkspaceIds.mock.calls[0]?.[0] ?? [])).toEqual([
created.structuredContent.worktreePath, "ws-archive-tool-worktree",
]); ]);
} finally { } finally {
await rm(tempDir, { recursive: true, force: true }); await rm(tempDir, { recursive: true, force: true });
@@ -1528,6 +1527,7 @@ describe("create_agent MCP tool", () => {
WorkspaceGitService, WorkspaceGitService,
"getSnapshot" | "listWorktrees" | "resolveRepoRoot" "getSnapshot" | "listWorktrees" | "resolveRepoRoot"
>, >,
resolveWorkspaceIdForCwd: vi.fn(async () => "ws-archive-mcp"),
archiveWorkspaceRecord: vi.fn(async () => undefined), archiveWorkspaceRecord: vi.fn(async () => undefined),
emitWorkspaceUpdatesForWorkspaceIds: vi.fn(async () => undefined), emitWorkspaceUpdatesForWorkspaceIds: vi.fn(async () => undefined),
markWorkspaceArchiving: vi.fn(), markWorkspaceArchiving: vi.fn(),
@@ -93,6 +93,7 @@ export interface AgentMcpServerOptions {
WorkspaceGitService, WorkspaceGitService,
"getSnapshot" | "listWorktrees" | "resolveRepoRoot" "getSnapshot" | "listWorktrees" | "resolveRepoRoot"
>; >;
resolveWorkspaceIdForCwd?: ArchivePaseoWorktreeDependencies["resolveWorkspaceIdForCwd"];
archiveWorkspaceRecord?: ArchivePaseoWorktreeDependencies["archiveWorkspaceRecord"]; archiveWorkspaceRecord?: ArchivePaseoWorktreeDependencies["archiveWorkspaceRecord"];
emitWorkspaceUpdatesForWorkspaceIds?: ArchivePaseoWorktreeDependencies["emitWorkspaceUpdatesForWorkspaceIds"]; emitWorkspaceUpdatesForWorkspaceIds?: ArchivePaseoWorktreeDependencies["emitWorkspaceUpdatesForWorkspaceIds"];
markWorkspaceArchiving?: ArchivePaseoWorktreeDependencies["markWorkspaceArchiving"]; markWorkspaceArchiving?: ArchivePaseoWorktreeDependencies["markWorkspaceArchiving"];
@@ -2406,6 +2407,9 @@ function archiveWorktreeDependencies(
if (!options.archiveWorkspaceRecord) { if (!options.archiveWorkspaceRecord) {
throw new Error("Workspace registry archiver is required to archive worktrees"); 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) { if (!options.emitWorkspaceUpdatesForWorkspaceIds) {
throw new Error("Workspace update emitter is required to archive worktrees"); throw new Error("Workspace update emitter is required to archive worktrees");
} }
@@ -2422,6 +2426,7 @@ function archiveWorktreeDependencies(
workspaceGitService: options.workspaceGitService, workspaceGitService: options.workspaceGitService,
agentManager: context.agentManager, agentManager: context.agentManager,
agentStorage: context.agentStorage, agentStorage: context.agentStorage,
resolveWorkspaceIdForCwd: options.resolveWorkspaceIdForCwd,
archiveWorkspaceRecord: options.archiveWorkspaceRecord, archiveWorkspaceRecord: options.archiveWorkspaceRecord,
emitWorkspaceUpdatesForWorkspaceIds: options.emitWorkspaceUpdatesForWorkspaceIds, emitWorkspaceUpdatesForWorkspaceIds: options.emitWorkspaceUpdatesForWorkspaceIds,
markWorkspaceArchiving: options.markWorkspaceArchiving, markWorkspaceArchiving: options.markWorkspaceArchiving,
@@ -93,6 +93,7 @@ function createHarness(overrides?: {
agentManager: {} as AutoArchiveArchiveOptions["agentManager"], agentManager: {} as AutoArchiveArchiveOptions["agentManager"],
agentStorage: {} as AutoArchiveArchiveOptions["agentStorage"], agentStorage: {} as AutoArchiveArchiveOptions["agentStorage"],
terminalManager: {} as AutoArchiveArchiveOptions["terminalManager"], terminalManager: {} as AutoArchiveArchiveOptions["terminalManager"],
resolveWorkspaceIdForCwd: vi.fn(async () => "ws-auto-archive"),
archiveWorkspaceRecord: vi.fn(), archiveWorkspaceRecord: vi.fn(),
markWorkspaceArchiving: vi.fn(), markWorkspaceArchiving: vi.fn(),
clearWorkspaceArchiving: vi.fn(), clearWorkspaceArchiving: vi.fn(),
@@ -22,6 +22,7 @@ export interface AutoArchiveArchiveOptions {
agentManager: AgentManager; agentManager: AgentManager;
agentStorage: AgentStorage; agentStorage: AgentStorage;
terminalManager: TerminalManager; terminalManager: TerminalManager;
resolveWorkspaceIdForCwd: (cwd: string) => Promise<string | null>;
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>; archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
markWorkspaceArchiving: (workspaceIds: Iterable<string>, archivingAt: string) => void; markWorkspaceArchiving: (workspaceIds: Iterable<string>, archivingAt: string) => void;
clearWorkspaceArchiving: (workspaceIds: Iterable<string>) => void; clearWorkspaceArchiving: (workspaceIds: Iterable<string>) => void;
@@ -102,6 +103,7 @@ export async function archiveIfSafe(input: {
workspaceGitService: options.workspaceGitService, workspaceGitService: options.workspaceGitService,
agentManager: options.agentManager, agentManager: options.agentManager,
agentStorage: options.agentStorage, agentStorage: options.agentStorage,
resolveWorkspaceIdForCwd: options.resolveWorkspaceIdForCwd,
archiveWorkspaceRecord: options.archiveWorkspaceRecord, archiveWorkspaceRecord: options.archiveWorkspaceRecord,
emitWorkspaceUpdatesForWorkspaceIds: options.emitWorkspaceUpdatesForWorkspaceIds, emitWorkspaceUpdatesForWorkspaceIds: options.emitWorkspaceUpdatesForWorkspaceIds,
markWorkspaceArchiving: options.markWorkspaceArchiving, markWorkspaceArchiving: options.markWorkspaceArchiving,
+6
View File
@@ -109,6 +109,7 @@ import { LoopService } from "./loop-service.js";
import { ScheduleService } from "./schedule/service.js"; import { ScheduleService } from "./schedule/service.js";
import { DaemonConfigStore } from "./daemon-config-store.js"; import { DaemonConfigStore } from "./daemon-config-store.js";
import { WorkspaceGitServiceImpl } from "./workspace-git-service.js"; import { WorkspaceGitServiceImpl } from "./workspace-git-service.js";
import { resolveRegisteredWorkspaceIdForCwd } from "./workspace-directory.js";
import { archivePersistedWorkspaceRecord } from "./workspace-archive-service.js"; import { archivePersistedWorkspaceRecord } from "./workspace-archive-service.js";
import { setupAutoArchiveOnMerge } from "./auto-archive-on-merge/index.js"; import { setupAutoArchiveOnMerge } from "./auto-archive-on-merge/index.js";
import { wrapSessionMessage, type SessionOutboundMessage } from "./messages.js"; import { wrapSessionMessage, type SessionOutboundMessage } from "./messages.js";
@@ -665,6 +666,9 @@ export async function createPaseoDaemon(
projectRegistry, projectRegistry,
}); });
}; };
const resolveWorkspaceIdForCwdExternal = async (cwd: string): Promise<string | null> => {
return resolveRegisteredWorkspaceIdForCwd(cwd, await workspaceRegistry.list());
};
const markWorkspaceArchivingExternal = (workspaceIds: Iterable<string>, archivingAt: string) => { const markWorkspaceArchivingExternal = (workspaceIds: Iterable<string>, archivingAt: string) => {
const workspaceIdList = Array.from(workspaceIds); const workspaceIdList = Array.from(workspaceIds);
for (const session of wsServer?.listActiveSessions() ?? []) { for (const session of wsServer?.listActiveSessions() ?? []) {
@@ -699,6 +703,7 @@ export async function createPaseoDaemon(
agentStorage, agentStorage,
terminalManager, terminalManager,
logger, logger,
resolveWorkspaceIdForCwd: resolveWorkspaceIdForCwdExternal,
archiveWorkspaceRecord: archiveWorkspaceRecordExternal, archiveWorkspaceRecord: archiveWorkspaceRecordExternal,
markWorkspaceArchiving: markWorkspaceArchivingExternal, markWorkspaceArchiving: markWorkspaceArchivingExternal,
clearWorkspaceArchiving: clearWorkspaceArchivingExternal, clearWorkspaceArchiving: clearWorkspaceArchivingExternal,
@@ -721,6 +726,7 @@ export async function createPaseoDaemon(
providerSnapshotManager, providerSnapshotManager,
github, github,
workspaceGitService, workspaceGitService,
resolveWorkspaceIdForCwd: resolveWorkspaceIdForCwdExternal,
archiveWorkspaceRecord: archiveWorkspaceRecordExternal, archiveWorkspaceRecord: archiveWorkspaceRecordExternal,
emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal, emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal,
markWorkspaceArchiving: markWorkspaceArchivingExternal, markWorkspaceArchiving: markWorkspaceArchivingExternal,
@@ -3,7 +3,6 @@ import type { Logger } from "pino";
import type { AgentManager } from "./agent/agent-manager.js"; import type { AgentManager } from "./agent/agent-manager.js";
import type { AgentStorage, StoredAgentRecord } from "./agent/agent-storage.js"; import type { AgentStorage, StoredAgentRecord } from "./agent/agent-storage.js";
import type { WorkspaceGitService } from "./workspace-git-service.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 type { GitHubService } from "../services/github-service.js";
import { import {
deletePaseoWorktree, deletePaseoWorktree,
@@ -19,6 +18,7 @@ export interface ArchivePaseoWorktreeDependencies {
workspaceGitService: Pick<WorkspaceGitService, "getSnapshot">; workspaceGitService: Pick<WorkspaceGitService, "getSnapshot">;
agentManager: Pick<AgentManager, "listAgents" | "archiveAgent" | "archiveSnapshot">; agentManager: Pick<AgentManager, "listAgents" | "archiveAgent" | "archiveSnapshot">;
agentStorage: Pick<AgentStorage, "list">; agentStorage: Pick<AgentStorage, "list">;
resolveWorkspaceIdForCwd: (cwd: string) => Promise<string | null>;
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>; archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds: Iterable<string>) => Promise<void>; emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds: Iterable<string>) => Promise<void>;
markWorkspaceArchiving: (workspaceIds: Iterable<string>, archivingAt: string) => void; markWorkspaceArchiving: (workspaceIds: Iterable<string>, archivingAt: string) => void;
@@ -57,7 +57,6 @@ export async function archivePaseoWorktree(
const archivedAgents = new Set<string>(); const archivedAgents = new Set<string>();
const affectedWorkspaceCwds = new Set<string>([targetPath]); const affectedWorkspaceCwds = new Set<string>([targetPath]);
const affectedWorkspaceIds = new Set<string>([normalizePersistedWorkspaceId(targetPath)]);
const liveAgents = dependencies.agentManager const liveAgents = dependencies.agentManager
.listAgents() .listAgents()
@@ -65,7 +64,6 @@ export async function archivePaseoWorktree(
for (const agent of liveAgents) { for (const agent of liveAgents) {
archivedAgents.add(agent.id); archivedAgents.add(agent.id);
affectedWorkspaceCwds.add(agent.cwd); affectedWorkspaceCwds.add(agent.cwd);
affectedWorkspaceIds.add(normalizePersistedWorkspaceId(agent.cwd));
} }
let storedRecords: StoredAgentRecord[] = []; let storedRecords: StoredAgentRecord[] = [];
@@ -84,10 +82,12 @@ export async function archivePaseoWorktree(
for (const record of matchingStoredRecords) { for (const record of matchingStoredRecords) {
archivedAgents.add(record.id); archivedAgents.add(record.id);
affectedWorkspaceCwds.add(record.cwd); 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()); dependencies.markWorkspaceArchiving(affectedWorkspaceIdList, new Date().toISOString());
try { try {
@@ -176,6 +176,28 @@ export async function archivePaseoWorktree(
return Array.from(archivedAgents); return Array.from(archivedAgents);
} }
async function resolveAffectedWorkspaceIds(
dependencies: Pick<
ArchivePaseoWorktreeDependencies,
"resolveWorkspaceIdForCwd" | "sessionLogger"
>,
cwds: Iterable<string>,
): Promise<string[]> {
const workspaceIds = new Set<string>();
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( export async function killTerminalsUnderPath(
dependencies: KillTerminalsUnderPathDependencies, dependencies: KillTerminalsUnderPathDependencies,
rootPath: string, rootPath: string,
@@ -34,7 +34,7 @@ test("creates a worktree and registers it in the source workspace project withou
displayName: "acme/repo", displayName: "acme/repo",
}); });
const sourceWorkspace = createPersistedWorkspaceRecordForTest({ const sourceWorkspace = createPersistedWorkspaceRecordForTest({
workspaceId: repoDir, workspaceId: "ws-main-checkout",
projectId: sourceProject.projectId, projectId: sourceProject.projectId,
cwd: repoDir, cwd: repoDir,
kind: "local_checkout", 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.created).toBe(true);
expect(result.workspace.cwd).toBe(result.worktree.worktreePath); expect(result.workspace.cwd).toBe(result.worktree.worktreePath);
expect(result.workspace.kind).toBe("worktree"); 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.projectId).toBe("remote:github.com/acme/repo");
expect(result.workspace.displayName).toBe("feature-one"); expect(result.workspace.displayName).toBe("feature-one");
expect(deps.workspaceGitService.getSnapshot).not.toHaveBeenCalled(); 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", displayName: "acme/repo",
}); });
const existingWorktree = createPersistedWorkspaceRecordForTest({ const existingWorktree = createPersistedWorkspaceRecordForTest({
workspaceId: path.join(tempDir, "existing-worktree"), workspaceId: "ws-existing-worktree",
projectId: sourceProject.projectId, projectId: sourceProject.projectId,
cwd: path.join(tempDir, "existing-worktree"), cwd: path.join(tempDir, "existing-worktree"),
kind: "worktree", kind: "worktree",
@@ -1,4 +1,5 @@
import type { WorkspaceGitService } from "./workspace-git-service.js"; import type { WorkspaceGitService } from "./workspace-git-service.js";
import { resolve } from "node:path";
import { import {
type PersistedWorkspaceRecord, type PersistedWorkspaceRecord,
type ProjectRegistry, type ProjectRegistry,
@@ -6,7 +7,7 @@ import {
createPersistedProjectRecord, createPersistedProjectRecord,
createPersistedWorkspaceRecord, createPersistedWorkspaceRecord,
} from "./workspace-registry.js"; } from "./workspace-registry.js";
import { deriveProjectGroupingName, normalizeWorkspaceId } from "./workspace-registry-model.js"; import { deriveProjectGroupingName, generateWorkspaceId } from "./workspace-registry-model.js";
import { import {
createWorktreeCore, createWorktreeCore,
type CreateWorktreeCoreDeps, type CreateWorktreeCoreDeps,
@@ -195,11 +196,14 @@ async function upsertWorkspaceForWorktree(options: {
projectId?: string; projectId?: string;
repoRoot: string; repoRoot: string;
worktree: WorktreeConfig; worktree: WorktreeConfig;
deps: Pick<CreatePaseoWorktreeDeps, "projectRegistry" | "workspaceRegistry">; deps: Pick<
CreatePaseoWorktreeDeps,
"projectRegistry" | "workspaceRegistry" | "workspaceGitService"
>;
}): Promise<PersistedWorkspaceRecord> { }): Promise<PersistedWorkspaceRecord> {
const normalizedCwd = normalizeWorkspaceId(options.worktree.worktreePath); const normalizedCwd = resolve(options.worktree.worktreePath);
const normalizedInputCwd = normalizeWorkspaceId(options.inputCwd); const normalizedInputCwd = resolve(options.inputCwd);
const normalizedRepoRoot = normalizeWorkspaceId(options.repoRoot); const normalizedRepoRoot = resolve(options.repoRoot);
const existingWorkspace = await findWorkspaceByDirectory( const existingWorkspace = await findWorkspaceByDirectory(
normalizedCwd, normalizedCwd,
options.deps.workspaceRegistry, options.deps.workspaceRegistry,
@@ -211,7 +215,7 @@ async function upsertWorkspaceForWorktree(options: {
existingWorkspace, existingWorkspace,
deps: options.deps, deps: options.deps,
}); });
const workspaceId = normalizedCwd; const workspaceId = existingWorkspace?.workspaceId ?? generateWorkspaceId();
const now = new Date().toISOString(); const now = new Date().toISOString();
await options.deps.projectRegistry.upsert( await options.deps.projectRegistry.upsert(
+91 -64
View File
@@ -3,7 +3,7 @@ import { v4 as uuidv4 } from "uuid";
import { realpathSync } from "node:fs"; import { realpathSync } from "node:fs";
import type { FSWatcher } from "node:fs"; import type { FSWatcher } from "node:fs";
import { stat } from "node:fs/promises"; import { stat } from "node:fs/promises";
import { basename, resolve, sep } from "path"; import { resolve, sep } from "path";
import { homedir } from "node:os"; import { homedir } from "node:os";
import { z } from "zod"; import { z } from "zod";
import type { ToolSet } from "ai"; import type { ToolSet } from "ai";
@@ -154,10 +154,10 @@ import {
} from "./agent/import-sessions.js"; } from "./agent/import-sessions.js";
import { import {
checkoutLiteFromGitSnapshot, checkoutLiteFromGitSnapshot,
normalizeWorkspaceId as normalizePersistedWorkspaceId,
deriveProjectGroupingName,
classifyDirectoryForProjectMembership, classifyDirectoryForProjectMembership,
deriveProjectGroupingName,
deriveWorkspaceDisplayName, deriveWorkspaceDisplayName,
generateWorkspaceId,
} from "./workspace-registry-model.js"; } from "./workspace-registry-model.js";
import { import {
createPersistedProjectRecord, createPersistedProjectRecord,
@@ -962,6 +962,7 @@ export class Session {
createPaseoWorktreeWorkflow: (input, workflowOptions) => createPaseoWorktreeWorkflow: (input, workflowOptions) =>
this.createPaseoWorktreeWorkflow(input, workflowOptions), this.createPaseoWorktreeWorkflow(input, workflowOptions),
archiveAgentForClose: (agentId) => this.archiveAgentForClose(agentId), archiveAgentForClose: (agentId) => this.archiveAgentForClose(agentId),
resolveWorkspaceIdForCwd: (cwd) => this.resolveWorkspaceIdForCwd(cwd),
archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId), archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId),
emit: (message) => this.emit(message), emit: (message) => this.emit(message),
emitAgentRemove: (agentId) => { emitAgentRemove: (agentId) => {
@@ -1607,6 +1608,9 @@ export class Session {
const normalizedCwd = await this.resolveWorkspaceDirectory(cwd, options); const normalizedCwd = await this.resolveWorkspaceDirectory(cwd, options);
const workspaces = await this.workspaceRegistry.list(); const workspaces = await this.workspaceRegistry.list();
const workspaceId = this.resolveRegisteredWorkspaceIdForCwd(normalizedCwd, workspaces); const workspaceId = this.resolveRegisteredWorkspaceIdForCwd(normalizedCwd, workspaces);
if (!workspaceId) {
return null;
}
return workspaces.find((workspace) => workspace.workspaceId === workspaceId) ?? null; return workspaces.find((workspace) => workspace.workspaceId === workspaceId) ?? null;
} }
@@ -1623,14 +1627,14 @@ export class Session {
cwd: string, cwd: string,
options?: { refreshGit?: boolean }, options?: { refreshGit?: boolean },
): Promise<string> { ): Promise<string> {
const normalizedCwd = normalizePersistedWorkspaceId(cwd); const normalizedCwd = resolve(cwd);
if (options?.refreshGit === false) { if (options?.refreshGit === false) {
const snapshot = this.workspaceGitService.peekSnapshot(normalizedCwd); 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); const checkout = await this.workspaceGitService.getCheckout(normalizedCwd);
return normalizePersistedWorkspaceId(checkout.worktreeRoot ?? normalizedCwd); return resolve(checkout.worktreeRoot ?? normalizedCwd);
} }
private async buildProjectPlacementForWorkspace( private async buildProjectPlacementForWorkspace(
@@ -1661,12 +1665,16 @@ export class Session {
return null; 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 { return {
projectKey: normalizedCwd, projectKey: resolvedCwd,
projectName: deriveProjectGroupingName(normalizedCwd), projectName: deriveProjectGroupingName(resolvedCwd),
checkout: { checkout: {
cwd: normalizedCwd, cwd: resolvedCwd,
isGit: false, isGit: false,
currentBranch: null, currentBranch: null,
remoteUrl: null, remoteUrl: null,
@@ -3169,8 +3177,6 @@ export class Session {
firstAgentContext, firstAgentContext,
buildSessionConfig: (sessionConfig, gitOptions, legacyWorktreeName, ctx) => buildSessionConfig: (sessionConfig, gitOptions, legacyWorktreeName, ctx) =>
this.buildAgentSessionConfig(sessionConfig, gitOptions, legacyWorktreeName, ctx), this.buildAgentSessionConfig(sessionConfig, gitOptions, legacyWorktreeName, ctx),
resolveWorkspace: ({ cwd, workspaceId }) =>
this.resolveCreateAgentWorkspace(cwd, workspaceId),
}, },
); );
createdAgentId = snapshot.id; 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( private async handleResumeAgentRequest(
msg: Extract<SessionInboundMessage, { type: "resume_agent_request" }>, msg: Extract<SessionInboundMessage, { type: "resume_agent_request" }>,
): Promise<void> { ): Promise<void> {
@@ -4845,7 +4837,7 @@ export class Session {
} }
private async removeWorkspaceGitWatchTarget(cwd: string): Promise<void> { private async removeWorkspaceGitWatchTarget(cwd: string): Promise<void> {
const normalizedCwd = normalizePersistedWorkspaceId(cwd); const normalizedCwd = resolve(cwd);
const target = this.workspaceGitWatchTargets.get(normalizedCwd); const target = this.workspaceGitWatchTargets.get(normalizedCwd);
if (target) { if (target) {
this.closeWorkspaceGitWatchTarget(target); this.closeWorkspaceGitWatchTarget(target);
@@ -4854,7 +4846,7 @@ export class Session {
} }
private removeWorkspaceGitSubscription(cwd: string): void { private removeWorkspaceGitSubscription(cwd: string): void {
const normalizedCwd = normalizePersistedWorkspaceId(cwd); const normalizedCwd = resolve(cwd);
const target = this.workspaceGitWatchTargets.get(normalizedCwd); const target = this.workspaceGitWatchTargets.get(normalizedCwd);
if (target) { if (target) {
const unsubscribeFetch = this.workspaceGitFetchSubscriptions.get(normalizedCwd); 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( private shouldSkipWorkspaceGitWatchUpdate(
workspaceId: string, workspaceId: string,
workspace: WorkspaceDescriptorPayload | null, workspace: WorkspaceDescriptorPayload | null,
): boolean { ): boolean {
const target = this.workspaceGitWatchTargets.get(workspaceId); const target = this.resolveWorkspaceGitWatchTarget(workspaceId);
if (!target) { if (!target) {
return false; return false;
} }
@@ -4897,7 +4898,7 @@ export class Session {
workspaceId: string, workspaceId: string,
workspace: WorkspaceDescriptorPayload | null, workspace: WorkspaceDescriptorPayload | null,
): void { ): void {
const target = this.workspaceGitWatchTargets.get(workspaceId); const target = this.resolveWorkspaceGitWatchTarget(workspaceId);
if (!target) { if (!target) {
return; return;
} }
@@ -4906,7 +4907,7 @@ export class Session {
} }
private handleWorkspaceGitBranchSnapshot(cwd: string, branchName: string | null): void { private handleWorkspaceGitBranchSnapshot(cwd: string, branchName: string | null): void {
const target = this.workspaceGitWatchTargets.get(normalizePersistedWorkspaceId(cwd)); const target = this.workspaceGitWatchTargets.get(resolve(cwd));
if (!target) { if (!target) {
return; return;
} }
@@ -4934,7 +4935,7 @@ export class Session {
cwd: string, cwd: string,
options: { isGit: boolean; workspaceId: string }, options: { isGit: boolean; workspaceId: string },
): void { ): void {
const normalizedCwd = normalizePersistedWorkspaceId(cwd); const normalizedCwd = resolve(cwd);
if (!options.isGit) { if (!options.isGit) {
this.removeWorkspaceGitSubscription(normalizedCwd); this.removeWorkspaceGitSubscription(normalizedCwd);
return; return;
@@ -5792,6 +5793,7 @@ export class Session {
workspaceGitService: this.workspaceGitService, workspaceGitService: this.workspaceGitService,
agentManager: this.agentManager, agentManager: this.agentManager,
agentStorage: this.agentStorage, agentStorage: this.agentStorage,
resolveWorkspaceIdForCwd: (cwd) => this.resolveWorkspaceIdForCwd(cwd),
archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId), archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId),
emit: (message) => this.emit(message), emit: (message) => this.emit(message),
emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds) => emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds) =>
@@ -6179,7 +6181,7 @@ export class Session {
), ),
); );
for (let i = 0; i < pairs.length; i += 1) { 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; return placementsByCwd;
@@ -6248,17 +6250,14 @@ export class Session {
scope === "active" ? await this.buildActiveProjectPlacementsByWorkspaceCwd() : null; scope === "active" ? await this.buildActiveProjectPlacementsByWorkspaceCwd() : null;
if (activePlacementsByCwd) { if (activePlacementsByCwd) {
agents = agents.filter( agents = agents.filter(
(agent) => (agent) => !agent.archivedAt && activePlacementsByCwd.has(resolve(agent.cwd)),
!agent.archivedAt && activePlacementsByCwd.has(normalizePersistedWorkspaceId(agent.cwd)),
); );
} }
const placementByCwd = new Map<string, Promise<ProjectPlacementPayload | null>>(); const placementByCwd = new Map<string, Promise<ProjectPlacementPayload | null>>();
const getPlacement = (cwd: string): Promise<ProjectPlacementPayload | null> => { const getPlacement = (cwd: string): Promise<ProjectPlacementPayload | null> => {
if (activePlacementsByCwd) { if (activePlacementsByCwd) {
return Promise.resolve( return Promise.resolve(activePlacementsByCwd.get(resolve(cwd)) ?? null);
activePlacementsByCwd.get(normalizePersistedWorkspaceId(cwd)) ?? null,
);
} }
const existing = placementByCwd.get(cwd); const existing = placementByCwd.get(cwd);
if (existing) { if (existing) {
@@ -6509,10 +6508,15 @@ export class Session {
private resolveRegisteredWorkspaceIdForCwd( private resolveRegisteredWorkspaceIdForCwd(
cwd: string, cwd: string,
workspaces: PersistedWorkspaceRecord[], workspaces: PersistedWorkspaceRecord[],
): string { ): string | null {
return this.workspaceDirectory.resolveRegisteredWorkspaceIdForCwd(cwd, workspaces); return this.workspaceDirectory.resolveRegisteredWorkspaceIdForCwd(cwd, workspaces);
} }
private async resolveWorkspaceIdForCwd(cwd: string): Promise<string | null> {
const workspaces = await this.workspaceRegistry.list();
return this.resolveRegisteredWorkspaceIdForCwd(cwd, workspaces);
}
private matchesWorkspaceFilter(input: { private matchesWorkspaceFilter(input: {
workspace: WorkspaceDescriptorPayload; workspace: WorkspaceDescriptorPayload;
filter: FetchWorkspacesRequestFilter | undefined; filter: FetchWorkspacesRequestFilter | undefined;
@@ -6600,7 +6604,7 @@ export class Session {
} }
private async findOrCreateWorkspaceForDirectory(cwd: string): Promise<PersistedWorkspaceRecord> { private async findOrCreateWorkspaceForDirectory(cwd: string): Promise<PersistedWorkspaceRecord> {
const inputCwd = normalizePersistedWorkspaceId(cwd); const inputCwd = resolve(cwd);
const normalizedCwd = await this.resolveWorkspaceDirectory(cwd); const normalizedCwd = await this.resolveWorkspaceDirectory(cwd);
const existingWorkspace = await this.findExactWorkspaceByDirectory(normalizedCwd, { const existingWorkspace = await this.findExactWorkspaceByDirectory(normalizedCwd, {
refreshGit: false, refreshGit: false,
@@ -6608,22 +6612,26 @@ export class Session {
if (existingWorkspace) { if (existingWorkspace) {
if (existingWorkspace.archivedAt && inputCwd !== normalizedCwd) { if (existingWorkspace.archivedAt && inputCwd !== normalizedCwd) {
const timestamp = new Date().toISOString(); const timestamp = new Date().toISOString();
const displayName = basename(inputCwd) || inputCwd; const checkout = checkoutLiteFromGitSnapshot(inputCwd, {
const projectRecord = createPersistedProjectRecord({ isGit: false,
projectId: inputCwd, currentBranch: null,
rootPath: inputCwd, remoteUrl: null,
kind: "non_git", repoRoot: null,
displayName, isPaseoOwnedWorktree: false,
createdAt: timestamp, mainRepoRoot: null,
updatedAt: timestamp, });
const membership = classifyDirectoryForProjectMembership({ cwd: inputCwd, checkout });
const projectRecord = await this.resolveProjectRecordForPlacement({
membership,
timestamp,
}); });
await this.projectRegistry.upsert(projectRecord); await this.projectRegistry.upsert(projectRecord);
const workspaceRecord = createPersistedWorkspaceRecord({ const workspaceRecord = createPersistedWorkspaceRecord({
workspaceId: inputCwd, workspaceId: generateWorkspaceId(),
projectId: projectRecord.projectId, projectId: projectRecord.projectId,
cwd: inputCwd, cwd: inputCwd,
kind: "directory", kind: membership.workspaceKind,
displayName, displayName: membership.workspaceDisplayName,
createdAt: timestamp, createdAt: timestamp,
updatedAt: timestamp, updatedAt: timestamp,
}); });
@@ -6652,7 +6660,7 @@ export class Session {
await this.projectRegistry.upsert(projectRecord); await this.projectRegistry.upsert(projectRecord);
const workspaceRecord = createPersistedWorkspaceRecord({ const workspaceRecord = createPersistedWorkspaceRecord({
workspaceId: membership.workspaceId, workspaceId: generateWorkspaceId(),
projectId: projectRecord.projectId, projectId: projectRecord.projectId,
cwd, cwd,
kind: membership.workspaceKind, kind: membership.workspaceKind,
@@ -6681,7 +6689,6 @@ export class Session {
const displayName = membership.workspaceDisplayName; const displayName = membership.workspaceDisplayName;
if ( if (
input.workspace.workspaceId === membership.workspaceId &&
input.workspace.projectId === projectId && input.workspace.projectId === projectId &&
input.workspace.kind === kind && input.workspace.kind === kind &&
input.workspace.displayName === displayName input.workspace.displayName === displayName
@@ -6693,7 +6700,7 @@ export class Session {
const nextWorkspace = { const nextWorkspace = {
...input.workspace, ...input.workspace,
workspaceId: membership.workspaceId, workspaceId: input.workspace.workspaceId,
projectId, projectId,
cwd: input.cwd, cwd: input.cwd,
kind, kind,
@@ -6797,7 +6804,10 @@ export class Session {
projectRegistry: this.projectRegistry, projectRegistry: this.projectRegistry,
}); });
if (!existingWorkspace) { if (!existingWorkspace) {
this.removeWorkspaceGitSubscription(workspaceId); const watchTarget = this.resolveWorkspaceGitWatchTarget(workspaceId);
if (watchTarget) {
this.removeWorkspaceGitSubscription(watchTarget.cwd);
}
return; return;
} }
@@ -6817,9 +6827,22 @@ export class Session {
); );
} }
await this.removeWorkspaceGitWatchTarget(existingWorkspace.cwd); await this.teardownArchivedWorkspace({
this.scriptRuntimeStore?.removeForWorkspace(existingWorkspace.cwd); workspaceId: existingWorkspace.workspaceId,
this.removeWorkspaceGitSubscription(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<void> {
await this.removeWorkspaceGitWatchTarget(input.cwd);
this.scriptRuntimeStore?.removeForWorkspace(input.workspaceId);
this.removeWorkspaceGitSubscription(input.cwd);
} }
private async reconcileAndEmitWorkspaceUpdates(): Promise<void> { private async reconcileAndEmitWorkspaceUpdates(): Promise<void> {
@@ -6854,9 +6877,10 @@ export class Session {
result.changesApplied.map(async (change) => { result.changesApplied.map(async (change) => {
switch (change.kind) { switch (change.kind) {
case "workspace_archived": case "workspace_archived":
await this.removeWorkspaceGitWatchTarget(change.directory); await this.teardownArchivedWorkspace({
this.scriptRuntimeStore?.removeForWorkspace(change.directory); workspaceId: change.workspaceId,
this.removeWorkspaceGitSubscription(change.workspaceId); cwd: change.directory,
});
changedWorkspaceIds.add(change.workspaceId); changedWorkspaceIds.add(change.workspaceId);
break; break;
case "workspace_updated": case "workspace_updated":
@@ -6912,7 +6936,7 @@ export class Session {
) { ) {
continue; continue;
} }
const watchTarget = this.workspaceGitWatchTargets.get(workspaceId); const watchTarget = this.resolveWorkspaceGitWatchTarget(workspaceId);
if (watchTarget && this.onBranchChanged) { if (watchTarget && this.onBranchChanged) {
const newBranchName = nextWorkspace?.name ?? null; const newBranchName = nextWorkspace?.name ?? null;
if (newBranchName !== watchTarget.lastBranchName) { if (newBranchName !== watchTarget.lastBranchName) {
@@ -6961,6 +6985,9 @@ export class Session {
): Promise<void> { ): Promise<void> {
const workspaces = await this.workspaceRegistry.list(); const workspaces = await this.workspaceRegistry.list();
const workspaceId = this.resolveRegisteredWorkspaceIdForCwd(cwd, workspaces); const workspaceId = this.resolveRegisteredWorkspaceIdForCwd(cwd, workspaces);
if (!workspaceId) {
return;
}
await this.emitWorkspaceUpdatesForWorkspaceIds([workspaceId], options); await this.emitWorkspaceUpdatesForWorkspaceIds([workspaceId], options);
} }
@@ -7583,10 +7610,10 @@ export class Session {
throw new Error(`Workspace not found: ${requestedWorkspaceId}`); throw new Error(`Workspace not found: ${requestedWorkspaceId}`);
} }
const workspaceCwd = normalizePersistedWorkspaceId(workspace.cwd); const workspaceCwd = resolve(workspace.cwd);
const clearableAgentIds = agents const clearableAgentIds = agents
.filter((agent) => !agent.archivedAt) .filter((agent) => !agent.archivedAt)
.filter((agent) => normalizePersistedWorkspaceId(agent.cwd) === workspaceCwd) .filter((agent) => resolve(agent.cwd) === workspaceCwd)
.filter((agent) => agent.requiresAttention === true) .filter((agent) => agent.requiresAttention === true)
.filter((agent) => (agent.pendingPermissions?.length ?? 0) === 0) .filter((agent) => (agent.pendingPermissions?.length ?? 0) === 0)
.filter((agent) => agent.attentionReason !== "permission") .filter((agent) => agent.attentionReason !== "permission")
@@ -495,6 +495,66 @@ describe("workspace git watch targets", () => {
await session.cleanup(); 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<void> }>(
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<void> }
>(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 () => { test("embeds PR status in checkout_status_update for GitHub-inclusive snapshot pushes", async () => {
const { session, emitted, projects, workspaces, subscriptions } = const { session, emitted, projects, workspaces, subscriptions } =
createSessionForWorkspaceGitWatchTests(); createSessionForWorkspaceGitWatchTests();
@@ -204,7 +204,7 @@ const PARENT_CHILD = path.join(PARENT, "child");
function gitWorkspace(rootPath: string, archivedAt: string | null = null) { function gitWorkspace(rootPath: string, archivedAt: string | null = null) {
return createPersistedWorkspaceRecord({ return createPersistedWorkspaceRecord({
workspaceId: rootPath, workspaceId: `ws-${path.basename(rootPath) || "root"}`,
projectId: rootPath, projectId: rootPath,
cwd: rootPath, cwd: rootPath,
kind: "local_checkout", kind: "local_checkout",
@@ -217,7 +217,7 @@ function gitWorkspace(rootPath: string, archivedAt: string | null = null) {
function dirWorkspace(cwd: string, archivedAt: string | null = null) { function dirWorkspace(cwd: string, archivedAt: string | null = null) {
return createPersistedWorkspaceRecord({ return createPersistedWorkspaceRecord({
workspaceId: cwd, workspaceId: `ws-${path.basename(cwd) || "root"}`,
projectId: cwd, projectId: cwd,
cwd, cwd,
kind: "directory", kind: "directory",
@@ -252,6 +252,17 @@ function dirProject(rootPath: string, archivedAt: string | null = null) {
}); });
} }
function workspaceByCwd(
workspaces: Map<string, PersistedWorkspaceRecord>,
cwd: string,
): PersistedWorkspaceRecord | null {
return Array.from(workspaces.values()).find((workspace) => workspace.cwd === cwd) ?? null;
}
function hasWorkspaceCwd(workspaces: Map<string, PersistedWorkspaceRecord>, cwd: string): boolean {
return workspaceByCwd(workspaces, cwd) !== null;
}
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// S1. Open a fresh git repo: creates a workspace at the canonical root. // 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?.error).toBeNull();
expect(resp?.workspace?.workspaceDirectory).toBe(FOO); expect(resp?.workspace?.workspaceDirectory).toBe(FOO);
expect(resp?.workspace?.workspaceKind).toBe("local_checkout"); 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?.error).toBeNull();
expect(resp?.workspace?.workspaceDirectory).toBe(BAR); expect(resp?.workspace?.workspaceDirectory).toBe(BAR);
expect(resp?.workspace?.workspaceKind).toBe("directory"); 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); await openProject(h.session, FOO);
const resp = getOpenResponse(h.emitted, "req-1"); 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.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); await openProject(h.session, FOO_SUB);
const resp = getOpenResponse(h.emitted, "req-1"); 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.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); await openProject(h.session, BAR_BAZ);
const resp = getOpenResponse(h.emitted, "req-1"); const resp = getOpenResponse(h.emitted, "req-1");
expect(resp?.workspace?.workspaceDirectory).toBe(BAR_BAZ); expect(resp?.workspace?.workspaceDirectory).toBe(BAR_BAZ);
expect(h.workspaces.has(BAR)).toBe(true); expect(hasWorkspaceCwd(h.workspaces, BAR)).toBe(true);
expect(h.workspaces.has(BAR_BAZ)).toBe(true); expect(hasWorkspaceCwd(h.workspaces, BAR_BAZ)).toBe(true);
expect(h.workspaces.size).toBe(2); 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], gitRoots: [TOOLBOX],
}); });
await openProject(h.session, 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(); 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); await openProject(h.session, FOO_SUB);
const resp = getOpenResponse(h.emitted, "req-1"); const resp = getOpenResponse(h.emitted, "req-1");
expect(resp?.workspace?.workspaceDirectory).toBe(FOO_SUB); expect(resp?.workspace?.workspaceDirectory).toBe(FOO_SUB);
expect(h.workspaces.has(FOO)).toBe(true); expect(hasWorkspaceCwd(h.workspaces, FOO)).toBe(true);
expect(h.workspaces.has(FOO_SUB)).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)], projects: [dirProject(USERS_DEVELOPER, archivedAt)],
}); });
await openProject(h.session, USERS_PROJECT); await openProject(h.session, USERS_PROJECT);
expect(h.workspaces.get(USERS_DEVELOPER)?.archivedAt).toBe(archivedAt); expect(workspaceByCwd(h.workspaces, USERS_DEVELOPER)?.archivedAt).toBe(archivedAt);
expect(h.workspaces.has(USERS_PROJECT)).toBe(true); 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], gitRoots: [TOOLBOX],
}); });
await openProject(h.session, TOOLBOX_FLOMO); 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?.error).toBeNull();
expect(resp?.workspace?.workspaceDirectory).toBe(SOME_GIT_REPO); expect(resp?.workspace?.workspaceDirectory).toBe(SOME_GIT_REPO);
expect(resp?.workspace?.workspaceKind).toBe("local_checkout"); expect(resp?.workspace?.workspaceKind).toBe("local_checkout");
expect(h.workspaces.has(SOME_GIT_REPO)).toBe(true); expect(hasWorkspaceCwd(h.workspaces, SOME_GIT_REPO)).toBe(true);
expect(h.workspaces.get(PROJECTS)?.archivedAt).toBe(archivedAt); expect(workspaceByCwd(h.workspaces, PROJECTS)?.archivedAt).toBe(archivedAt);
expect(h.projects.get(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); await openProject(h.session, TOOLBOX);
const resp = getOpenResponse(h.emitted, "req-1"); const resp = getOpenResponse(h.emitted, "req-1");
expect(resp?.error).toBeNull(); 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(resp?.workspace?.projectId).toBe(TOOLBOX);
expect(h.workspaces.size).toBe(1); expect(h.workspaces.size).toBe(1);
expect(h.projects.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(); expect(h.projects.get(TOOLBOX)?.archivedAt).toBeNull();
}); });
@@ -490,8 +490,29 @@ function createSessionForWorkspaceTests(
notifyAgentState: () => {}, notifyAgentState: () => {},
}), }),
agentStorage: asAgentStorage({ agentStorage: asAgentStorage({
list: async () => [], list: async () => [
get: async () => null, 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 () => {}, upsert: async () => {},
}), }),
projectRegistry: { projectRegistry: {
@@ -506,8 +527,29 @@ function createSessionForWorkspaceTests(
workspaceRegistry: { workspaceRegistry: {
initialize: async () => {}, initialize: async () => {},
existsOnDisk: async () => true, existsOnDisk: async () => true,
list: async () => [], list: async () => [
get: async () => null, 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 () => {}, upsert: async () => {},
archive: async () => {}, archive: async () => {},
remove: 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 emitted: SessionOutboundMessage[] = [];
const getSnapshot = vi.fn(async () => { 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( const session = asTestSession(
createSessionForWorkspaceTests({ createSessionForWorkspaceTests({
@@ -886,17 +928,15 @@ test("agent_update emits fallback placement when no workspace is registered", as
); );
expect(getSnapshot).not.toHaveBeenCalled(); expect(getSnapshot).not.toHaveBeenCalled();
expect(emitted.find((message) => message.type === "agent_update")?.payload).toMatchObject({ // An agent in a directory with no registered workspace must still emit an
kind: "upsert", // upsert (live model/thinking switches depend on this). It gets a directory-
project: { // scoped placement keyed by the path — a project key, not a workspace id.
projectKey: UNREGISTERED_CWD, const update = emitted.find((message) => message.type === "agent_update");
projectName: "unregistered", if (update?.type !== "agent_update" || update.payload.kind !== "upsert") {
checkout: { throw new Error("expected an agent_update upsert");
cwd: UNREGISTERED_CWD, }
isGit: false, 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 () => { 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 emitted: SessionOutboundMessage[] = [];
const workspaces = [ const workspaces = [
createPersistedWorkspaceRecord({ createPersistedWorkspaceRecord({
workspaceId: "/tmp/repo-a", workspaceId: "ws-repo-a",
projectId: "/tmp/repo-a", projectId: "/tmp/repo-a",
cwd: "/tmp/repo-a", cwd: "/tmp/repo-a",
kind: "directory", 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", updatedAt: "2026-03-30T15:00:00.000Z",
}), }),
createPersistedWorkspaceRecord({ createPersistedWorkspaceRecord({
workspaceId: "/tmp/repo-b", workspaceId: "ws-repo-b",
projectId: "/tmp/repo-b", projectId: "/tmp/repo-b",
cwd: "/tmp/repo-b", cwd: "/tmp/repo-b",
kind: "directory", kind: "directory",
@@ -2570,8 +2610,29 @@ test("workspace update stream keeps persisted workspace visible after agents sto
workspaceRegistry: { workspaceRegistry: {
initialize: async () => {}, initialize: async () => {},
existsOnDisk: async () => true, existsOnDisk: async () => true,
list: async () => [], list: async () => [
get: async () => null, 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 () => {}, upsert: async () => {},
archive: async () => {}, archive: async () => {},
remove: async () => {}, remove: async () => {},
@@ -2619,7 +2680,7 @@ test("workspace update stream keeps persisted workspace visible after agents sto
session.buildWorkspaceDescriptorMap = async () => session.buildWorkspaceDescriptorMap = async () =>
new Map([ new Map([
[ [
REPO_CWD, "ws-repo-running",
{ {
id: "ws-repo-running", id: "ws-repo-running",
projectId: "proj-repo-running", projectId: "proj-repo-running",
@@ -2638,7 +2699,7 @@ test("workspace update stream keeps persisted workspace visible after agents sto
session.buildWorkspaceDescriptorMap = async () => session.buildWorkspaceDescriptorMap = async () =>
new Map([ new Map([
[ [
REPO_CWD, "ws-repo-running",
{ {
id: "ws-repo-running", id: "ws-repo-running",
projectId: "proj-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 workspaces = new Map();
const projects = new Map(); const projects = new Map();
session.paseoHome = paseoHome; session.paseoHome = paseoHome;
session.workspaceRegistry.get = async (workspaceId: string) => session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
workspaces.get(workspaceId) ?? null; workspaces.get(lookupWorkspaceId) ?? null;
session.workspaceRegistry.list = async () => Array.from(workspaces.values()); session.workspaceRegistry.list = async () => Array.from(workspaces.values());
session.workspaceRegistry.upsert = async ( session.workspaceRegistry.upsert = async (
record: ReturnType<typeof createPersistedWorkspaceRecord>, record: ReturnType<typeof createPersistedWorkspaceRecord>,
@@ -2775,7 +2836,8 @@ test("create paseo worktree request returns a registered workspace descriptor",
name: "worktree-123", name: "worktree-123",
status: "done", 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(workspaces.has(response?.payload.workspace?.id ?? "")).toBe(true);
expect(projects.has(response?.payload.workspace?.projectId ?? "")).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); projects.set(record.projectId, record);
}; };
session.workspaceRegistry.get = async (workspaceId: string) => session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
workspaces.get(workspaceId) ?? null; workspaces.get(lookupWorkspaceId) ?? null;
session.workspaceRegistry.upsert = async ( session.workspaceRegistry.upsert = async (
record: ReturnType<typeof createPersistedWorkspaceRecord>, record: ReturnType<typeof createPersistedWorkspaceRecord>,
) => { ) => {
@@ -2904,10 +2966,13 @@ test("open_project_request registers a workspace before any agent exists", async
requestId: "req-open", 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"); const response = findByType(emitted, "open_project_response");
expect(response?.payload.error).toBeNull(); 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 () => { 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); projects.set(record.projectId, record);
}; };
session.workspaceRegistry.get = async (workspaceId: string) => session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
workspaces.get(workspaceId) ?? null; workspaces.get(lookupWorkspaceId) ?? null;
session.workspaceRegistry.upsert = async ( session.workspaceRegistry.upsert = async (
record: ReturnType<typeof createPersistedWorkspaceRecord>, record: ReturnType<typeof createPersistedWorkspaceRecord>,
) => { ) => {
@@ -2971,15 +3036,22 @@ test("import_agent_request registers a workspace for a never-seen cwd", async ()
pendingUpdatesByWorkspaceId: new Map(), pendingUpdatesByWorkspaceId: new Map(),
lastEmittedByWorkspaceId: new Map(), lastEmittedByWorkspaceId: new Map(),
}; };
session.buildWorkspaceDescriptorMap = async () => session.buildWorkspaceDescriptorMap = async () => {
new Map([ const workspace = Array.from(workspaces.values()).find(
(candidate) => candidate.cwd === importedCwd,
);
if (!workspace) {
return new Map();
}
return new Map([
[ [
importedCwd, workspace.workspaceId,
{ {
id: importedCwd, id: workspace.workspaceId,
projectId: importedCwd, projectId: workspace.projectId,
projectDisplayName: "imported-project", projectDisplayName: "imported-project",
projectRootPath: importedCwd, projectRootPath: importedCwd,
workspaceDirectory: importedCwd,
projectKind: "non_git", projectKind: "non_git",
workspaceKind: "directory", workspaceKind: "directory",
name: "imported-project", name: "imported-project",
@@ -2988,6 +3060,7 @@ test("import_agent_request registers a workspace for a never-seen cwd", async ()
}, },
], ],
]); ]);
};
await session.handleMessage({ await session.handleMessage({
type: "import_agent_request", type: "import_agent_request",
@@ -2997,12 +3070,17 @@ test("import_agent_request registers a workspace for a never-seen cwd", async ()
cwd: importedCwd, 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"); const workspaceUpdates = filterByType(emitted, "workspace_update");
expect(workspaceUpdates.length).toBeGreaterThan(0); expect(workspaceUpdates.length).toBeGreaterThan(0);
expect( expect(
workspaceUpdates.some( workspaceUpdates.some(
(update) => update.payload.kind === "upsert" && update.payload.workspace.id === importedCwd, (update) =>
update.payload.kind === "upsert" &&
update.payload.workspace.workspaceDirectory === importedCwd,
), ),
).toBe(true); ).toBe(true);
}); });
@@ -3023,8 +3101,8 @@ test("open_project_response returns immediately even when the GitHub fetch is sl
) => { ) => {
projects.set(record.projectId, record); projects.set(record.projectId, record);
}; };
session.workspaceRegistry.get = async (workspaceId: string) => session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
workspaces.get(workspaceId) ?? null; workspaces.get(lookupWorkspaceId) ?? null;
session.workspaceRegistry.upsert = async ( session.workspaceRegistry.upsert = async (
record: ReturnType<typeof createPersistedWorkspaceRecord>, record: ReturnType<typeof createPersistedWorkspaceRecord>,
) => { ) => {
@@ -3062,7 +3140,8 @@ test("open_project_response returns immediately even when the GitHub fetch is sl
const response = findByType(emitted, "open_project_response"); const response = findByType(emitted, "open_project_response");
expect(response?.payload.error).toBeNull(); 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?.gitRuntime).toBeUndefined();
expect(response?.payload.workspace?.githubRuntime).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); projects.set(record.projectId, record);
}; };
session.workspaceRegistry.get = async (workspaceId: string) => session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
workspaces.get(workspaceId) ?? null; workspaces.get(lookupWorkspaceId) ?? null;
session.workspaceRegistry.upsert = async ( session.workspaceRegistry.upsert = async (
record: ReturnType<typeof createPersistedWorkspaceRecord>, record: ReturnType<typeof createPersistedWorkspaceRecord>,
) => { ) => {
@@ -3143,7 +3222,7 @@ test("open_project_request emits a workspace_update with githubRuntime once the
.map((update) => update.payload) .map((update) => update.payload)
.filter( .filter(
(payload): payload is WorkspaceUpsertPayload => (payload): payload is WorkspaceUpsertPayload =>
payload.kind === "upsert" && payload.workspace.id === cwd, payload.kind === "upsert" && payload.workspace.workspaceDirectory === cwd,
) )
.find((payload) => payload.workspace.githubRuntime?.pullRequest); .find((payload) => payload.workspace.githubRuntime?.pullRequest);
expect(upsertedWithGitHub?.workspace.githubRuntime?.pullRequest).toEqual( expect(upsertedWithGitHub?.workspace.githubRuntime?.pullRequest).toEqual(
@@ -3155,6 +3234,7 @@ interface WorkspaceUpsertPayload {
kind: "upsert"; kind: "upsert";
workspace: { workspace: {
id: string; id: string;
workspaceDirectory?: string;
githubRuntime?: { githubRuntime?: {
pullRequest?: { url?: string } | null; pullRequest?: { url?: string } | null;
} | 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); projects.set(record.projectId, record);
}; };
session.workspaceRegistry.get = async (workspaceId: string) => session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
workspaces.get(workspaceId) ?? null; workspaces.get(lookupWorkspaceId) ?? null;
session.workspaceRegistry.upsert = async ( session.workspaceRegistry.upsert = async (
record: ReturnType<typeof createPersistedWorkspaceRecord>, record: ReturnType<typeof createPersistedWorkspaceRecord>,
) => { ) => {
@@ -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"); const response = findByType(emitted, "open_project_response");
expect(response?.payload.error).toBeNull(); 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(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 () => { 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); projects.set(record.projectId, record);
}; };
session.workspaceRegistry.get = async (workspaceId: string) => session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
workspaces.get(workspaceId) ?? null; workspaces.get(lookupWorkspaceId) ?? null;
session.workspaceRegistry.upsert = async ( session.workspaceRegistry.upsert = async (
record: ReturnType<typeof createPersistedWorkspaceRecord>, record: ReturnType<typeof createPersistedWorkspaceRecord>,
) => { ) => {
@@ -3287,7 +3369,7 @@ test("open_project_request does not unarchive an archived parent workspace for a
const response = findByType(emitted, "open_project_response"); const response = findByType(emitted, "open_project_response");
expect(response?.payload.error).toBeNull(); 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(response?.payload.workspace?.workspaceDirectory).toBe(worktree);
expect(workspaces.get(home)?.archivedAt).toBe(archivedAt); expect(workspaces.get(home)?.archivedAt).toBe(archivedAt);
expect(projects.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 remoteProjectId = "remote:github.com/getpaseo/paseo";
const archivedAt = "2026-04-24T09:48:36.168Z"; const archivedAt = "2026-04-24T09:48:36.168Z";
const workspaceId = "ws-desktop-daemon-settings";
projects.set( projects.set(
cwd, cwd,
@@ -3322,9 +3405,9 @@ test("open_project_request reclassifies an archived directory workspace when git
}), }),
); );
workspaces.set( workspaces.set(
cwd, workspaceId,
createPersistedWorkspaceRecord({ createPersistedWorkspaceRecord({
workspaceId: cwd, workspaceId,
projectId: cwd, projectId: cwd,
cwd, cwd,
kind: "directory", kind: "directory",
@@ -3344,8 +3427,8 @@ test("open_project_request reclassifies an archived directory workspace when git
) => { ) => {
projects.set(record.projectId, record); projects.set(record.projectId, record);
}; };
session.workspaceRegistry.get = async (workspaceId: string) => session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
workspaces.get(workspaceId) ?? null; workspaces.get(lookupWorkspaceId) ?? null;
session.workspaceRegistry.upsert = async ( session.workspaceRegistry.upsert = async (
record: ReturnType<typeof createPersistedWorkspaceRecord>, record: ReturnType<typeof createPersistedWorkspaceRecord>,
) => { ) => {
@@ -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?.projectId).toBe(remoteProjectId);
expect(response?.payload.workspace?.workspaceKind).toBe("worktree"); expect(response?.payload.workspace?.workspaceKind).toBe("worktree");
expect(projects.get(remoteProjectId)?.kind).toBe("git"); expect(projects.get(remoteProjectId)?.kind).toBe("git");
expect(workspaces.get(cwd)?.projectId).toBe(remoteProjectId); expect(workspaces.get(workspaceId)?.projectId).toBe(remoteProjectId);
expect(workspaces.get(cwd)?.kind).toBe("worktree"); expect(workspaces.get(workspaceId)?.kind).toBe("worktree");
expect(workspaces.get(cwd)?.displayName).toBe("feature/desktop-daemon-settings"); 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 () => { 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", updatedAt: "2026-04-24T09:40:00.000Z",
}), }),
); );
const workspaceId = "ws-desktop-daemon-settings-active";
const repoWorkspaceId = "ws-paseo-main";
workspaces.set( workspaces.set(
cwd, workspaceId,
createPersistedWorkspaceRecord({ createPersistedWorkspaceRecord({
workspaceId: cwd, workspaceId,
projectId: cwd, projectId: cwd,
cwd, cwd,
kind: "directory", kind: "directory",
@@ -3440,9 +3525,9 @@ test("open_project_request reclassifies an active directory workspace when git m
}), }),
); );
workspaces.set( workspaces.set(
repoRoot, repoWorkspaceId,
createPersistedWorkspaceRecord({ createPersistedWorkspaceRecord({
workspaceId: repoRoot, workspaceId: repoWorkspaceId,
projectId: repoRoot, projectId: repoRoot,
cwd: repoRoot, cwd: repoRoot,
kind: "local_checkout", kind: "local_checkout",
@@ -3461,8 +3546,8 @@ test("open_project_request reclassifies an active directory workspace when git m
) => { ) => {
projects.set(record.projectId, record); projects.set(record.projectId, record);
}; };
session.workspaceRegistry.get = async (workspaceId: string) => session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
workspaces.get(workspaceId) ?? null; workspaces.get(lookupWorkspaceId) ?? null;
session.workspaceRegistry.upsert = async ( session.workspaceRegistry.upsert = async (
record: ReturnType<typeof createPersistedWorkspaceRecord>, record: ReturnType<typeof createPersistedWorkspaceRecord>,
) => { ) => {
@@ -3502,9 +3587,9 @@ test("open_project_request reclassifies an active directory workspace when git m
expect(response?.payload.error).toBeNull(); expect(response?.payload.error).toBeNull();
expect(response?.payload.workspace?.projectId).toBe(repoRoot); expect(response?.payload.workspace?.projectId).toBe(repoRoot);
expect(response?.payload.workspace?.workspaceKind).toBe("worktree"); expect(response?.payload.workspace?.workspaceKind).toBe("worktree");
expect(workspaces.get(cwd)?.projectId).toBe(repoRoot); expect(workspaces.get(workspaceId)?.projectId).toBe(repoRoot);
expect(workspaces.get(cwd)?.kind).toBe("worktree"); expect(workspaces.get(workspaceId)?.kind).toBe("worktree");
expect(workspaces.get(cwd)?.displayName).toBe("feature/desktop-daemon-settings"); 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 () => { 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); projects.set(record.projectId, record);
}; };
session.workspaceRegistry.get = async (workspaceId: string) => session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
workspaces.get(workspaceId) ?? null; workspaces.get(lookupWorkspaceId) ?? null;
session.workspaceRegistry.upsert = async ( session.workspaceRegistry.upsert = async (
record: ReturnType<typeof createPersistedWorkspaceRecord>, record: ReturnType<typeof createPersistedWorkspaceRecord>,
) => { ) => {
@@ -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.error).toBeNull();
expect(response?.payload.workspace?.projectId).toBe(repoRoot); expect(response?.payload.workspace?.projectId).toBe(repoRoot);
expect(response?.payload.workspace?.workspaceKind).toBe("worktree"); expect(response?.payload.workspace?.workspaceKind).toBe("worktree");
expect(workspaces.get(cwd)?.projectId).toBe(repoRoot); const worktreeWorkspace = Array.from(workspaces.values()).find(
expect(workspaces.get(cwd)?.kind).toBe("worktree"); (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 () => { 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<string, ReturnType<typeof createPersistedWorkspaceRecord>>(); const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
const cwd = REPO_CWD; const cwd = REPO_CWD;
const workspaceId = "ws-repo-archived";
projects.set( projects.set(
cwd, cwd,
createPersistedProjectRecord({ createPersistedProjectRecord({
@@ -3619,9 +3708,9 @@ test("open_project_request unarchives an existing archived workspace and project
}), }),
); );
workspaces.set( workspaces.set(
cwd, workspaceId,
createPersistedWorkspaceRecord({ createPersistedWorkspaceRecord({
workspaceId: cwd, workspaceId,
projectId: cwd, projectId: cwd,
cwd, cwd,
kind: "directory", kind: "directory",
@@ -3641,8 +3730,8 @@ test("open_project_request unarchives an existing archived workspace and project
) => { ) => {
projects.set(record.projectId, record); projects.set(record.projectId, record);
}; };
session.workspaceRegistry.get = async (workspaceId: string) => session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
workspaces.get(workspaceId) ?? null; workspaces.get(lookupWorkspaceId) ?? null;
session.workspaceRegistry.upsert = async ( session.workspaceRegistry.upsert = async (
record: ReturnType<typeof createPersistedWorkspaceRecord>, record: ReturnType<typeof createPersistedWorkspaceRecord>,
) => { ) => {
@@ -3657,11 +3746,11 @@ test("open_project_request unarchives an existing archived workspace and project
requestId: "req-open-unarchive", requestId: "req-open-unarchive",
}); });
expect(workspaces.get(cwd)?.archivedAt).toBeNull(); expect(workspaces.get(workspaceId)?.archivedAt).toBeNull();
expect(projects.get(cwd)?.archivedAt).toBeNull(); expect(projects.get(cwd)?.archivedAt).toBeNull();
const response = findByType(emitted, "open_project_response"); const response = findByType(emitted, "open_project_response");
expect(response?.payload.error).toBeNull(); 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 () => { 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); projects.set(record.projectId, record);
}; };
session.workspaceRegistry.get = async (workspaceId: string) => session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
workspaces.get(workspaceId) ?? null; workspaces.get(lookupWorkspaceId) ?? null;
session.workspaceRegistry.upsert = async ( session.workspaceRegistry.upsert = async (
record: ReturnType<typeof createPersistedWorkspaceRecord>, record: ReturnType<typeof createPersistedWorkspaceRecord>,
) => { ) => {
@@ -3842,8 +3931,8 @@ test.skip("opening a new worktree reconciles older local workspaces into the rem
if (!existing) return; if (!existing) return;
projects.set(projectId, { ...existing, archivedAt, updatedAt: archivedAt }); projects.set(projectId, { ...existing, archivedAt, updatedAt: archivedAt });
}; };
session.workspaceRegistry.get = async (workspaceId: string) => session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
workspaces.get(workspaceId) ?? null; workspaces.get(lookupWorkspaceId) ?? null;
session.workspaceRegistry.list = async () => Array.from(workspaces.values()); session.workspaceRegistry.list = async () => Array.from(workspaces.values());
session.workspaceRegistry.upsert = async ( session.workspaceRegistry.upsert = async (
record: ReturnType<typeof createPersistedWorkspaceRecord>, record: ReturnType<typeof createPersistedWorkspaceRecord>,
@@ -3948,8 +4037,8 @@ test.skip("fetch_workspaces_request reconciles remote URL changes for existing w
if (!existing) return; if (!existing) return;
projects.set(projectId, { ...existing, archivedAt, updatedAt: archivedAt }); projects.set(projectId, { ...existing, archivedAt, updatedAt: archivedAt });
}; };
session.workspaceRegistry.get = async (workspaceId: string) => session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
workspaces.get(workspaceId) ?? null; workspaces.get(lookupWorkspaceId) ?? null;
session.workspaceRegistry.list = async () => Array.from(workspaces.values()); session.workspaceRegistry.list = async () => Array.from(workspaces.values());
session.workspaceRegistry.upsert = async ( session.workspaceRegistry.upsert = async (
record: ReturnType<typeof createPersistedWorkspaceRecord>, record: ReturnType<typeof createPersistedWorkspaceRecord>,
@@ -4044,8 +4133,8 @@ test.skip("reconcile archives stale subdirectory workspace records when collapsi
) => { ) => {
projects.set(record.projectId, record); projects.set(record.projectId, record);
}; };
session.workspaceRegistry.get = async (workspaceId: string) => session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
workspaces.get(workspaceId) ?? null; workspaces.get(lookupWorkspaceId) ?? null;
session.workspaceRegistry.list = async () => Array.from(workspaces.values()); session.workspaceRegistry.list = async () => Array.from(workspaces.values());
session.workspaceRegistry.upsert = async ( session.workspaceRegistry.upsert = async (
record: ReturnType<typeof createPersistedWorkspaceRecord>, record: ReturnType<typeof createPersistedWorkspaceRecord>,
@@ -5040,7 +5129,7 @@ test("resolveRegisteredWorkspaceIdForCwd does not match home directory as a pref
const home = homedir(); const home = homedir();
const childCwd = path.join(home, "projects/new-app"); const childCwd = path.join(home, "projects/new-app");
const homeWorkspace = createPersistedWorkspaceRecord({ const homeWorkspace = createPersistedWorkspaceRecord({
workspaceId: home, workspaceId: "ws-home",
projectId: "proj-home", projectId: "proj-home",
cwd: home, cwd: home,
kind: "directory", kind: "directory",
@@ -5049,6 +5138,6 @@ test("resolveRegisteredWorkspaceIdForCwd does not match home directory as a pref
updatedAt: "2026-03-01T12:00:00.000Z", updatedAt: "2026-03-01T12:00:00.000Z",
}); });
expect(session.resolveRegisteredWorkspaceIdForCwd(childCwd, [homeWorkspace])).toBe(childCwd); expect(session.resolveRegisteredWorkspaceIdForCwd(childCwd, [homeWorkspace])).toBeNull();
expect(session.resolveRegisteredWorkspaceIdForCwd(home, [homeWorkspace])).toBe(home); expect(session.resolveRegisteredWorkspaceIdForCwd(home, [homeWorkspace])).toBe("ws-home");
}); });
@@ -1,5 +1,5 @@
import { homedir } from "node:os"; import { homedir } from "node:os";
import { sep } from "node:path"; import { resolve, sep } from "node:path";
import type pino from "pino"; import type pino from "pino";
import type { import type {
AgentSnapshotPayload, AgentSnapshotPayload,
@@ -15,7 +15,6 @@ import {
import { getParentAgentIdFromLabels, isDelegatedAgent } from "@getpaseo/protocol/agent-labels"; import { getParentAgentIdFromLabels, isDelegatedAgent } from "@getpaseo/protocol/agent-labels";
import { SortablePager } from "./pagination/sortable-pager.js"; import { SortablePager } from "./pagination/sortable-pager.js";
import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "./workspace-registry.js"; import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "./workspace-registry.js";
import { normalizeWorkspaceId } from "./workspace-registry-model.js";
const FETCH_WORKSPACES_SORT_KEYS = [ const FETCH_WORKSPACES_SORT_KEYS = [
"status_priority", "status_priority",
@@ -52,6 +51,33 @@ type FetchWorkspacesResponsePageInfo = FetchWorkspacesResponsePayload["pageInfo"
export type WorkspaceUpdatesFilter = FetchWorkspacesRequestFilter; 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 { export interface WorkspaceDirectoryDeps {
logger: pino.Logger; logger: pino.Logger;
projectRegistry: { projectRegistry: {
@@ -176,9 +202,7 @@ export class WorkspaceDirectory {
const descriptorsByWorkspaceId = new Map<string, WorkspaceDescriptorPayload>(); const descriptorsByWorkspaceId = new Map<string, WorkspaceDescriptorPayload>();
const workspaceIds = options.workspaceIds ? new Set(options.workspaceIds) : null; const workspaceIds = options.workspaceIds ? new Set(options.workspaceIds) : null;
const workspaceIdsByDirectory = new Map( const workspaceIdsByDirectory = new Map(
activeRecords.map( activeRecords.map((workspace) => [resolve(workspace.cwd), workspace.workspaceId] as const),
(workspace) => [normalizeWorkspaceId(workspace.cwd), workspace.workspaceId] as const,
),
); );
const includedWorkspaces = activeRecords.filter( 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) { if (workspaceId === undefined) {
continue; continue;
} }
@@ -252,7 +276,7 @@ export class WorkspaceDirectory {
(agent) => (agent) =>
!agent.archivedAt && !agent.archivedAt &&
this.deps.isProviderVisibleToClient(agent.provider) && this.deps.isProviderVisibleToClient(agent.provider) &&
workspaceIdsByDirectory.get(normalizeWorkspaceId(agent.cwd)) === workspaceId, workspaceIdsByDirectory.get(resolve(agent.cwd)) === workspaceId,
); );
const result = this.resolveStatusEnteredAt({ const result = this.resolveStatusEnteredAt({
workspaceId, workspaceId,
@@ -366,28 +390,11 @@ export class WorkspaceDirectory {
return candidates.at(-1) ?? null; return candidates.at(-1) ?? null;
} }
resolveRegisteredWorkspaceIdForCwd(cwd: string, workspaces: PersistedWorkspaceRecord[]): string { resolveRegisteredWorkspaceIdForCwd(
const normalizedCwd = normalizeWorkspaceId(cwd); cwd: string,
const exact = workspaces.find((workspace) => workspace.cwd === normalizedCwd); workspaces: PersistedWorkspaceRecord[],
if (exact) { ): string | null {
return exact.workspaceId; return resolveRegisteredWorkspaceIdForCwd(cwd, workspaces);
}
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;
} }
async listDescriptors(): Promise<WorkspaceDescriptorPayload[]> { async listDescriptors(): Promise<WorkspaceDescriptorPayload[]> {
@@ -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) { if (filter.query && filter.query.trim().length > 0) {
const query = filter.query.trim().toLocaleLowerCase(); const query = filter.query.trim().toLocaleLowerCase();
const haystacks = [workspace.name, workspace.projectId, workspace.id]; const haystacks = [workspace.name, workspace.projectId, workspace.id];
@@ -1,6 +1,6 @@
import { watch, type FSWatcher } from "node:fs"; import { watch, type FSWatcher } from "node:fs";
import { readFile, readdir } from "node:fs/promises"; 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 { LRUCache } from "lru-cache";
import pLimit from "p-limit"; import pLimit from "p-limit";
import type pino from "pino"; import type pino from "pino";
@@ -38,7 +38,7 @@ import {
buildWorkspaceGitMetadataFromSnapshot, buildWorkspaceGitMetadataFromSnapshot,
type WorkspaceGitMetadata, type WorkspaceGitMetadata,
} from "./workspace-git-metadata.js"; } 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 WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 1_000;
const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000; const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000;
@@ -401,7 +401,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
params: { cwd: string }, params: { cwd: string },
listener: WorkspaceGitListener, listener: WorkspaceGitListener,
): WorkspaceGitSubscription { ): WorkspaceGitSubscription {
const cwd = normalizeWorkspaceId(params.cwd); const cwd = resolve(params.cwd);
const target = this.ensureWorkspaceTarget(cwd); const target = this.ensureWorkspaceTarget(cwd);
target.listeners.add(listener); target.listeners.add(listener);
if (target.listeners.size === 1) { if (target.listeners.size === 1) {
@@ -432,7 +432,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
cwd: string, cwd: string,
options?: WorkspaceGitSnapshotOptions, options?: WorkspaceGitSnapshotOptions,
): Promise<WorkspaceGitRuntimeSnapshot> { ): Promise<WorkspaceGitRuntimeSnapshot> {
cwd = normalizeWorkspaceId(cwd); cwd = resolve(cwd);
const request = this.normalizeRefreshRequest(options, "getSnapshot", true); const request = this.normalizeRefreshRequest(options, "getSnapshot", true);
const target = this.ensureWorkspaceTarget(cwd); const target = this.ensureWorkspaceTarget(cwd);
if (!request.force && target.latestSnapshot) { if (!request.force && target.latestSnapshot) {
@@ -443,7 +443,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
} }
async getCheckout(cwd: string): Promise<ProjectCheckoutLitePayload> { async getCheckout(cwd: string): Promise<ProjectCheckoutLitePayload> {
const normalizedCwd = normalizeWorkspaceId(cwd); const normalizedCwd = resolve(cwd);
try { try {
const status = await this.deps.getCheckoutStatus(normalizedCwd, { const status = await this.deps.getCheckoutStatus(normalizedCwd, {
paseoHome: this.paseoHome, paseoHome: this.paseoHome,
@@ -481,7 +481,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
} }
peekSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot | null { peekSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot | null {
cwd = normalizeWorkspaceId(cwd); cwd = resolve(cwd);
return this.workspaceTargets.get(cwd)?.latestSnapshot ?? null; return this.workspaceTargets.get(cwd)?.latestSnapshot ?? null;
} }
@@ -490,7 +490,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
options: CheckoutDiffCompare, options: CheckoutDiffCompare,
readOptions?: WorkspaceGitReadOptions, readOptions?: WorkspaceGitReadOptions,
): Promise<CheckoutDiffResult> { ): Promise<CheckoutDiffResult> {
const normalizedCwd = normalizeWorkspaceId(cwd); const normalizedCwd = resolve(cwd);
const normalizedOptions = this.normalizeCheckoutDiffOptions(options); const normalizedOptions = this.normalizeCheckoutDiffOptions(options);
const key = this.buildCheckoutDiffCacheKey(normalizedCwd, normalizedOptions); const key = this.buildCheckoutDiffCacheKey(normalizedCwd, normalizedOptions);
return this.readAuxiliaryCache(this.checkoutDiffCache, key, readOptions, () => return this.readAuxiliaryCache(this.checkoutDiffCache, key, readOptions, () =>
@@ -530,7 +530,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
ref: string, ref: string,
options?: WorkspaceGitReadOptions, options?: WorkspaceGitReadOptions,
): Promise<WorkspaceGitBranchValidationResult> { ): Promise<WorkspaceGitBranchValidationResult> {
const normalizedCwd = normalizeWorkspaceId(cwd); const normalizedCwd = resolve(cwd);
const normalizedRef = ref.trim(); const normalizedRef = ref.trim();
const key = JSON.stringify(["branch-validation", normalizedCwd, normalizedRef]); const key = JSON.stringify(["branch-validation", normalizedCwd, normalizedRef]);
return this.readAuxiliaryCache(this.branchValidationCache, key, options, () => return this.readAuxiliaryCache(this.branchValidationCache, key, options, () =>
@@ -539,7 +539,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
} }
hasLocalBranch(cwd: string, branch: string, options?: WorkspaceGitReadOptions): Promise<boolean> { hasLocalBranch(cwd: string, branch: string, options?: WorkspaceGitReadOptions): Promise<boolean> {
const normalizedCwd = normalizeWorkspaceId(cwd); const normalizedCwd = resolve(cwd);
const normalizedBranch = branch.trim(); const normalizedBranch = branch.trim();
const ref = `refs/heads/${normalizedBranch}`; const ref = `refs/heads/${normalizedBranch}`;
const key = JSON.stringify(["local-branch", normalizedCwd, ref]); const key = JSON.stringify(["local-branch", normalizedCwd, ref]);
@@ -558,7 +558,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
options?: WorkspaceGitBranchSuggestionsOptions, options?: WorkspaceGitBranchSuggestionsOptions,
readOptions?: WorkspaceGitReadOptions, readOptions?: WorkspaceGitReadOptions,
): Promise<WorkspaceGitBranchSuggestion[]> { ): Promise<WorkspaceGitBranchSuggestion[]> {
const normalizedCwd = normalizeWorkspaceId(cwd); const normalizedCwd = resolve(cwd);
const query = options?.query ?? ""; const query = options?.query ?? "";
const limit = options?.limit; const limit = options?.limit;
const key = JSON.stringify(["branch-suggestions", normalizedCwd, query, limit ?? null]); const key = JSON.stringify(["branch-suggestions", normalizedCwd, query, limit ?? null]);
@@ -572,7 +572,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
options?: WorkspaceGitStashListOptions, options?: WorkspaceGitStashListOptions,
readOptions?: WorkspaceGitReadOptions, readOptions?: WorkspaceGitReadOptions,
): Promise<WorkspaceGitStashEntry[]> { ): Promise<WorkspaceGitStashEntry[]> {
const normalizedCwd = normalizeWorkspaceId(cwd); const normalizedCwd = resolve(cwd);
const paseoOnly = options?.paseoOnly !== false; const paseoOnly = options?.paseoOnly !== false;
const key = JSON.stringify(["stashes", normalizedCwd, paseoOnly]); const key = JSON.stringify(["stashes", normalizedCwd, paseoOnly]);
return this.readAuxiliaryCache(this.stashListCache, key, readOptions, async () => { return this.readAuxiliaryCache(this.stashListCache, key, readOptions, async () => {
@@ -606,15 +606,15 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
} }
return snapshot.git.isPaseoOwnedWorktree return snapshot.git.isPaseoOwnedWorktree
? (snapshot.git.mainRepoRoot ?? snapshot.git.repoRoot ?? normalizeWorkspaceId(cwd)) ? (snapshot.git.mainRepoRoot ?? snapshot.git.repoRoot ?? resolve(cwd))
: (snapshot.git.repoRoot ?? normalizeWorkspaceId(cwd)); : (snapshot.git.repoRoot ?? resolve(cwd));
} }
async resolveDefaultBranch( async resolveDefaultBranch(
cwdOrRepoRoot: string, cwdOrRepoRoot: string,
options?: WorkspaceGitReadOptions, options?: WorkspaceGitReadOptions,
): Promise<string> { ): Promise<string> {
const cwd = normalizeWorkspaceId(cwdOrRepoRoot); const cwd = resolve(cwdOrRepoRoot);
const key = JSON.stringify(["default-branch", cwd]); const key = JSON.stringify(["default-branch", cwd]);
return this.readAuxiliaryCache(this.defaultBranchCache, key, options, async () => { return this.readAuxiliaryCache(this.defaultBranchCache, key, options, async () => {
const defaultBranch = await this.deps.resolveRepositoryDefaultBranch(cwd); const defaultBranch = await this.deps.resolveRepositoryDefaultBranch(cwd);
@@ -630,10 +630,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
options?: WorkspaceGitReadOptions & { directoryName?: string }, options?: WorkspaceGitReadOptions & { directoryName?: string },
): Promise<WorkspaceGitMetadata> { ): Promise<WorkspaceGitMetadata> {
const snapshot = await this.getSnapshot(cwd, options); const snapshot = await this.getSnapshot(cwd, options);
const directoryName = const directoryName = options?.directoryName ?? basename(cwd) ?? cwd;
options?.directoryName ?? normalizeWorkspaceId(cwd).split(/[\\/]/).findLast(Boolean) ?? cwd;
return buildWorkspaceGitMetadataFromSnapshot({ return buildWorkspaceGitMetadataFromSnapshot({
cwd: normalizeWorkspaceId(cwd), cwd: resolve(cwd),
directoryName, directoryName,
isGit: snapshot.git.isGit, isGit: snapshot.git.isGit,
repoRoot: snapshot.git.repoRoot, repoRoot: snapshot.git.repoRoot,
@@ -652,7 +651,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
} }
async refresh(cwd: string, _options?: { priority?: "normal" | "high" }): Promise<void> { async refresh(cwd: string, _options?: { priority?: "normal" | "high" }): Promise<void> {
cwd = normalizeWorkspaceId(cwd); cwd = resolve(cwd);
const target = this.ensureWorkspaceTarget(cwd); const target = this.ensureWorkspaceTarget(cwd);
await this.refreshWorkspaceTarget(target, { await this.refreshWorkspaceTarget(target, {
force: false, force: false,
@@ -667,7 +666,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
cwd: string, cwd: string,
onChange: () => void, onChange: () => void,
): Promise<{ repoRoot: string | null; unsubscribe: () => void }> { ): Promise<{ repoRoot: string | null; unsubscribe: () => void }> {
cwd = normalizeWorkspaceId(cwd); cwd = resolve(cwd);
const target = await this.ensureWorkingTreeWatchTarget(cwd); const target = await this.ensureWorkingTreeWatchTarget(cwd);
target.listeners.add(onChange); target.listeners.add(onChange);
@@ -680,7 +679,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
} }
scheduleRefreshForCwd(cwd: string): void { scheduleRefreshForCwd(cwd: string): void {
cwd = normalizeWorkspaceId(cwd); cwd = resolve(cwd);
const target = this.workspaceTargets.get(cwd); const target = this.workspaceTargets.get(cwd);
if (target) { if (target) {
this.scheduleWorkspaceRefresh(target); this.scheduleWorkspaceRefresh(target);
@@ -688,7 +687,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
} }
onWorkspaceStateMayHaveChanged(cwd: string): void { onWorkspaceStateMayHaveChanged(cwd: string): void {
const normalizedCwd = normalizeWorkspaceId(cwd); const normalizedCwd = resolve(cwd);
const target = this.workspaceTargets.get(normalizedCwd); const target = this.workspaceTargets.get(normalizedCwd);
if (!target || target.closed) { if (!target || target.closed) {
return; return;
@@ -1120,7 +1119,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
): void { ): void {
const target = const target =
typeof targetOrCwd === "string" typeof targetOrCwd === "string"
? this.workspaceTargets.get(normalizeWorkspaceId(targetOrCwd)) ? this.workspaceTargets.get(resolve(targetOrCwd))
: targetOrCwd; : targetOrCwd;
if (!target || target.closed || this.workspaceTargets.get(target.cwd) !== target) { if (!target || target.closed || this.workspaceTargets.get(target.cwd) !== target) {
return; return;
@@ -1,4 +1,5 @@
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { resolve } from "node:path";
import type pino from "pino"; import type pino from "pino";
import type { import type {
ProjectRegistry, ProjectRegistry,
@@ -7,7 +8,6 @@ import type {
PersistedWorkspaceRecord, PersistedWorkspaceRecord,
} from "./workspace-registry.js"; } from "./workspace-registry.js";
import type { WorkspaceGitService } from "./workspace-git-service.js"; import type { WorkspaceGitService } from "./workspace-git-service.js";
import { normalizeWorkspaceId } from "./workspace-registry-model.js";
const DEFAULT_RECONCILE_INTERVAL_MS = 60_000; const DEFAULT_RECONCILE_INTERVAL_MS = 60_000;
@@ -220,7 +220,7 @@ export class WorkspaceReconciliationService {
if (project.kind !== "git") { if (project.kind !== "git") {
continue; continue;
} }
const rootKey = normalizeWorkspaceId(project.rootPath); const rootKey = resolve(project.rootPath);
const group = projectsByRoot.get(rootKey) ?? []; const group = projectsByRoot.get(rootKey) ?? [];
group.push(project); group.push(project);
projectsByRoot.set(rootKey, group); projectsByRoot.set(rootKey, group);

Some files were not shown because too many files have changed in this diff Show More