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

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
```
`{cwd-with-dashes}` is derived from the agent's filesystem `cwd`. It is not the workspace id; agent storage stays cwd-keyed while workspace identity is the opaque workspace id.
Each agent is a single JSON file. Fields relevant to this doc:
| Field | Type | Meaning |

View File

@@ -87,7 +87,7 @@ code imports from `@getpaseo/client`.
Cross-platform React Native app that connects to one or more daemons.
- Expo Router navigation (`/h/[serverId]/workspace/[workspaceId]`, `/h/[serverId]/agent/[agentId]`, etc.)
- Expo Router navigation (`/h/[serverId]/workspace/[workspaceId]`, `/h/[serverId]/agent/[agentId]`, etc.). The `workspaceId` URL segment is an opaque workspace id (path-shaped today and opaque-encoded for routing), not a directly meaningful filesystem path.
- `HostRuntimeController` manages saved host connections, reconnection, and per-host runtime state
- `SessionContext` wraps the daemon client for the active session
- Composer UI and submit/draft behavior live in `packages/app/src/composer/`; screens and panels should integrate it from there instead of dropping composer internals into `components/`, `hooks/`, or `screens/workspace/`
@@ -232,7 +232,7 @@ initializing → idle ⇄ running
- Timeline is append-only with epochs (each run starts a new epoch). Storage uses sequence numbers for client-side dedup; the default fetch page is 200 items
- Timeline row `timestamp` values are canonical daemon-owned timestamps. Providers may supply original replay timestamps, but clients must not guess timestamp trust or hide time UI based on local clock heuristics.
- Events stream to connected clients in real time; correctness is backed by authoritative timeline fetches and paged-to-completion catch-up.
- Agent state persists to `$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json` (timeline rows live alongside the record)
- Agent state persists to `$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json` (timeline rows live alongside the record). That storage path is derived from `cwd`, not from workspace id.
## Agent providers

View File

@@ -386,16 +386,18 @@ emptied duplicate.
Array of workspace records. A workspace is a specific working directory within a project.
| Field | Type | Description |
| ------------- | ----------------------------------------------- | ------------------------------ |
| `workspaceId` | `string` | Primary key |
| `projectId` | `string` | FK to Project.projectId |
| `cwd` | `string` | Filesystem path |
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | |
| `displayName` | `string` | |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | |
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable |
| Field | Type | Description |
| ------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `workspaceId` | `string` | Opaque stable identifier (`wks_<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 |
| `cwd` | `string` | Filesystem path |
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | |
| `displayName` | `string` | |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | |
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable |
> **Opaque-ID invariant:** `workspaceId` is opaque identity, never a filesystem path. Filesystem and git operations take `cwd`/`workspaceDirectory` only — never the id. Path-derived grouping keys (e.g. `deriveWorkspaceDirectoryKey`, used at bootstrap to group agents into a workspace) are directory keys, not workspace identity, and must not be persisted or compared as ids.
---

View File

@@ -3,7 +3,7 @@
Authoritative terminology. UI label wins. Don't invent synonyms; use what's here.
- **Project** — Logical grouping of workspaces sharing a git remote (or main repo root). UI: "Project" / "Add project". Code: `ProjectSummary` (`packages/app/src/utils/projects.ts:22`), `projectKey` (`packages/server/src/server/workspace-registry-model.ts:16`). Forbidden: "Repo", "Repository" as UI label.
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/protocol/src/messages.ts:2178`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. Its `id` is opaque workspace identity; its `cwd` is the filesystem directory. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/protocol/src/messages.ts:2178`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. Code: `PersistedWorkspaceKind` (`packages/server/src/server/workspace-registry-model.ts:8`).
- **Agent** — See **Agent session**. UI still says "Agent" / "New Agent" in places, but moving toward **Agent session** as the canonical term. Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`). Forbidden: "Task", "Job", "Run".
- **Daemon** — Local Paseo server process; identified by `serverId`. UI: "Daemon" (system contexts only). Code: `serverId` in `ServerInfoStatusPayloadSchema` (`packages/protocol/src/messages.ts:1936`), `DaemonClient` (`packages/client/src/daemon-client.ts`).

View File

@@ -68,7 +68,7 @@ test.describe("Archive tab reconciliation", () => {
archivedAgentId: archived.id,
survivingAgentId: surviving.id,
});
await reloadWorkspace(passivePage, tempRepo.path);
await reloadWorkspace(passivePage, surviving.workspaceId);
await expectWorkspaceTabHidden(passivePage, archived.id);
} finally {
await passivePage.close();
@@ -94,7 +94,7 @@ test.describe("Archive tab reconciliation", () => {
await openWorkspaceWithAgents(passivePage, [archived, surviving]);
await openSessions(page);
await archiveAgentFromSessions(page, { agentId: archived.id, title: archived.title });
await reloadWorkspace(page, tempRepo.path);
await reloadWorkspace(page, surviving.workspaceId);
await expectWorkspaceTabHidden(page, archived.id);
await expectWorkspaceArchiveOutcome(passivePage, {
archivedAgentId: archived.id,

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

View File

@@ -244,7 +244,7 @@ test.describe("Composer attachments", () => {
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: workspace.workspaceId,
workspaceId: workspace.workspaceId,
});
await openNewWorkspaceComposer(page, {

View File

@@ -14,6 +14,7 @@ export interface ArchiveTabAgent {
id: string;
title: string;
cwd: string;
workspaceId: string;
}
function buildSeededStoragePayload() {
@@ -34,6 +35,10 @@ function buildSeededStoragePayload() {
* idle agent from the same client it uses for everything else.
*/
export interface IdleAgentSeedClient {
openProject(cwd: string): Promise<{
workspace: { id: string } | null;
error: string | null;
}>;
createAgent(options: {
provider: string;
model: string;
@@ -52,6 +57,10 @@ export async function createIdleAgent(
client: IdleAgentSeedClient,
input: { cwd: string; title: string },
): Promise<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({
provider: "opencode",
model: "opencode/gpt-5-nano",
@@ -71,6 +80,7 @@ export async function createIdleAgent(
id: created.id,
title: input.title,
cwd: input.cwd,
workspaceId: opened.workspace.id,
};
}
@@ -133,7 +143,7 @@ export async function openWorkspaceWithAgents(
): Promise<void> {
const serverId = getServerId();
for (const agent of agents) {
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.cwd));
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.workspaceId));
// The workspace layout consumes `?open=agent:xxx`, returns null during the effect,
// then replaces the URL with the clean workspace route after preparing the tab.

View File

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

View File

@@ -166,7 +166,7 @@ export async function startRunningMockAgent(
cwd: repo.path,
model: opts.model,
});
const agentUrl = `${buildHostWorkspaceRoute(serverId, repo.path)}?open=${encodeURIComponent(`agent:${agent.id}`)}`;
const agentUrl = `${buildHostWorkspaceRoute(serverId, opened.workspace.id)}?open=${encodeURIComponent(`agent:${agent.id}`)}`;
await page.goto(agentUrl);
await expectComposerVisible(page);
await client.sendAgentMessage(agent.id, opts.prompt);

View File

@@ -6,8 +6,8 @@ import { getServerId } from "./server-id";
// ─── Navigation ────────────────────────────────────────────────────────────
/** Navigate to a workspace and wait for the tab bar to appear. */
export async function gotoWorkspace(page: Page, cwd: string): Promise<void> {
const route = buildHostWorkspaceRoute(getServerId(), cwd);
export async function gotoWorkspace(page: Page, workspaceId: string): Promise<void> {
const route = buildHostWorkspaceRoute(getServerId(), workspaceId);
await page.goto(route);
await waitForTabBar(page);
}

View File

@@ -5,6 +5,7 @@ import { getServerId } from "./server-id";
export interface MockAgentWorkspace {
agentId: string;
workspaceId: string;
cwd: string;
client: SeedDaemonClient;
cleanup(): Promise<void>;
@@ -40,6 +41,7 @@ export async function seedMockAgentWorkspace(
});
return {
agentId: agent.id,
workspaceId: workspace.workspaceId,
cwd: workspace.repoPath,
client: workspace.client,
cleanup: workspace.cleanup,
@@ -50,8 +52,8 @@ export async function seedMockAgentWorkspace(
}
}
export function buildAgentRoute(cwd: string, agentId: string): string {
return `${buildHostWorkspaceRoute(getServerId(), cwd)}?open=${encodeURIComponent(
export function buildAgentRoute(workspaceId: string, agentId: string): string {
return `${buildHostWorkspaceRoute(getServerId(), workspaceId)}?open=${encodeURIComponent(
`agent:${agentId}`,
)}`;
}
@@ -59,9 +61,9 @@ export function buildAgentRoute(cwd: string, agentId: string): string {
/** Boots the app directly at the agent's workspace route and waits for the open intent to settle. */
export async function openAgentRoute(
page: Page,
input: { cwd: string; agentId: string },
input: { workspaceId: string; agentId: string },
): Promise<void> {
await page.goto(buildAgentRoute(input.cwd, input.agentId));
await page.goto(buildAgentRoute(input.workspaceId, input.agentId));
await page.waitForURL(
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
{ timeout: 60_000 },

View File

@@ -3,7 +3,7 @@ import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/inte
import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes";
import { connectDaemonClient } from "./daemon-client-loader";
import { daemonWsRoutePattern } from "./daemon-port";
import { expectWorkspaceHeader, workspaceLabelFromPath } from "./workspace-ui";
import { expectWorkspaceHeader } from "./workspace-ui";
type NewWorkspaceDaemonClient = Pick<
InternalDaemonClient,
@@ -12,17 +12,20 @@ type NewWorkspaceDaemonClient = Pick<
| "close"
| "connect"
| "createPaseoWorktree"
| "fetchWorkspaces"
| "openProject"
>;
type OpenProjectPayload = Awaited<ReturnType<NewWorkspaceDaemonClient["openProject"]>>;
type WorkspacePayload = Pick<OpenProjectPayload, "error" | "workspace">;
type WorkspaceDescriptor = NonNullable<OpenProjectPayload["workspace"]>;
export interface OpenedProject {
workspaceId: string;
projectKey: string;
projectDisplayName: string;
workspaceName: string;
workspaceDirectory: string;
}
function requireWorkspace(payload: WorkspacePayload) {
@@ -35,6 +38,39 @@ function requireWorkspace(payload: WorkspacePayload) {
return payload.workspace;
}
function openedProjectFromWorkspace(workspace: WorkspaceDescriptor): OpenedProject {
return {
workspaceId: workspace.id,
projectKey: workspace.projectId,
projectDisplayName: workspace.projectDisplayName,
workspaceName: workspace.name,
workspaceDirectory: workspace.workspaceDirectory,
};
}
async function fetchWorkspaceById(
client: NewWorkspaceDaemonClient,
workspaceId: string,
): Promise<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 {
const pathname = new URL(page.url()).pathname;
const match = pathname.match(
@@ -57,24 +93,19 @@ export async function openProjectViaDaemon(
repoPath: string,
): Promise<OpenedProject> {
const workspace = requireWorkspace(await client.openProject(repoPath));
return {
workspaceId: workspace.id,
projectKey: workspace.projectId,
projectDisplayName: workspace.projectDisplayName,
workspaceName: workspace.name,
};
return openedProjectFromWorkspace(workspace);
}
export async function archiveWorkspaceFromDaemon(
client: NewWorkspaceDaemonClient,
workspaceId: string,
workspaceDirectory: string,
): Promise<void> {
const payload = await client.archivePaseoWorktree({ worktreePath: workspaceId });
const payload = await client.archivePaseoWorktree({ worktreePath: workspaceDirectory });
if (payload.error) {
throw new Error(payload.error.message);
}
if (!payload.success) {
throw new Error(`Failed to archive workspace: ${workspaceId}`);
throw new Error(`Failed to archive workspace: ${workspaceDirectory}`);
}
}
@@ -100,12 +131,7 @@ export async function createWorktreeViaDaemon(
worktreeSlug: input.slug,
});
const workspace = requireWorkspace(payload);
return {
workspaceId: workspace.id,
projectKey: workspace.projectId,
projectDisplayName: workspace.projectDisplayName,
workspaceName: workspace.name,
};
return openedProjectFromWorkspace(workspace);
}
export async function openNewWorkspaceComposer(
@@ -241,12 +267,13 @@ export async function assertNewWorkspaceSidebarAndHeader(
page: Page,
input: {
serverId: string;
client: NewWorkspaceDaemonClient;
previousWorkspaceId: string;
projectDisplayName: string;
assertSidebarRow?: boolean;
assertHeader?: boolean;
},
): Promise<{ workspaceId: string }> {
): Promise<{ workspaceId: string; workspaceName: string; workspaceDirectory: string }> {
// Wait for URL to redirect to the newly created workspace.
// Uses URL as source of truth to avoid picking up sidebar rows from concurrent tests.
let workspaceId: string | null = null;
@@ -263,21 +290,27 @@ export async function assertNewWorkspaceSidebarAndHeader(
throw new Error(`Expected URL to redirect to a new workspace.\nCurrent URL: ${page.url()}`);
}
const workspace = await waitForWorkspaceDescriptor(input.client, workspaceId);
if (input.assertSidebarRow !== false) {
const createdWorkspaceRow = page.getByTestId(
`sidebar-workspace-row-${input.serverId}:${workspaceId}`,
`sidebar-workspace-row-${input.serverId}:${workspace.id}`,
);
await expect(createdWorkspaceRow.first()).toBeVisible({ timeout: 30_000 });
}
if (input.assertHeader !== false) {
await expectWorkspaceHeader(page, {
title: workspaceLabelFromPath(workspaceId),
title: workspace.name,
subtitle: input.projectDisplayName,
});
}
return { workspaceId };
return {
workspaceId: workspace.id,
workspaceName: workspace.name,
workspaceDirectory: workspace.workspaceDirectory,
};
}
type WebSocketMessage = string | Buffer;

View File

@@ -14,6 +14,7 @@ export interface AgentHandle {
page: Page;
client: SeedDaemonClient;
agentId: string;
workspaceId: string;
cwd: string;
provider: RewindFlowProvider;
}
@@ -61,14 +62,17 @@ function fullAccessConfig(provider: RewindFlowProvider): ProviderLaunchConfig {
}
}
function agentRoute(cwd: string, agentId: string): string {
return `${buildHostWorkspaceRoute(getServerId(), cwd)}?open=${encodeURIComponent(
function agentRoute(workspaceId: string, agentId: string): string {
return `${buildHostWorkspaceRoute(getServerId(), workspaceId)}?open=${encodeURIComponent(
`agent:${agentId}`,
)}`;
}
async function openAgent(page: Page, input: { cwd: string; agentId: string }): Promise<void> {
await page.goto(agentRoute(input.cwd, input.agentId));
async function openAgent(
page: Page,
input: { workspaceId: string; agentId: string },
): Promise<void> {
await page.goto(agentRoute(input.workspaceId, input.agentId));
await page.waitForURL(
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
{ timeout: 60_000 },
@@ -180,10 +184,11 @@ export async function launchAgent(input: {
page: input.page,
client,
agentId: agent.id,
workspaceId: opened.workspace.id,
cwd: input.cwd,
provider: input.provider,
};
await openAgent(input.page, { cwd: input.cwd, agentId: agent.id });
await openAgent(input.page, { workspaceId: opened.workspace.id, agentId: agent.id });
return handle;
}

View File

@@ -13,6 +13,38 @@ export async function expectWorkspaceListed(page: Page, name: string): Promise<v
).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> {
await page.getByRole("button", { name: "Open menu" }).click();
}

View File

@@ -38,7 +38,7 @@ export async function seedLongMockAgentTimeline(
}
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(
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
{ timeout: 60_000 },

View File

@@ -261,7 +261,7 @@ export async function navigateToWorkspaceViaSidebar(
await switchWorkspaceViaSidebar({
page,
serverId: getServerId(),
targetWorkspacePath: workspaceId,
workspaceId,
});
}

View File

@@ -18,29 +18,8 @@ export async function waitForSidebarHydration(page: Page, timeout = 60_000): Pro
.waitFor({ state: "visible", timeout });
}
export function workspaceLabelFromPath(value: string): string {
const normalized = value.replace(/\\/g, "/").replace(/\/+$/, "");
const parts = normalized.split("/").filter(Boolean);
return parts[parts.length - 1] ?? normalized;
}
function candidateWorkspaceIds(inputPath: string): string[] {
const trimmed = inputPath.replace(/\/+$/, "");
const candidates = new Set<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();
function workspaceRowLocator(page: Page, serverId: string, workspaceId: string) {
return page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`).first();
}
export async function expectSidebarWorkspaceSelected(input: {
@@ -69,13 +48,13 @@ export async function expectSidebarWorkspaceSelected(input: {
export async function switchWorkspaceViaSidebar(input: {
page: Page;
serverId: string;
targetWorkspacePath: string;
workspaceId: string;
}): Promise<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 row.click();
const targetWorkspaceRoute = buildHostWorkspaceRoute(input.serverId, input.targetWorkspacePath);
const targetWorkspaceRoute = buildHostWorkspaceRoute(input.serverId, input.workspaceId);
await expect(input.page).toHaveURL(new RegExp(escapeRegex(targetWorkspaceRoute)), {
timeout: 30_000,
});
@@ -89,11 +68,10 @@ export async function waitForWorkspaceInSidebar(
page: Page,
input: { serverId: string; workspaceId: string },
): Promise<void> {
const candidates = candidateWorkspaceIds(input.workspaceId);
const selector = candidates
.map((id) => `[data-testid="sidebar-workspace-row-${input.serverId}:${id}"]`)
.join(",");
await page.locator(selector).first().waitFor({ state: "visible", timeout: 60_000 });
await workspaceRowLocator(page, input.serverId, input.workspaceId).waitFor({
state: "visible",
timeout: 60_000,
});
}
export async function expectWorkspaceHeader(

View File

@@ -36,7 +36,6 @@ import {
switchWorkspaceViaSidebar,
waitForSidebarHydration,
waitForWorkspaceInSidebar,
workspaceLabelFromPath,
} from "./helpers/workspace-ui";
interface WorkspaceStatusGroupEvent {
@@ -183,7 +182,7 @@ async function submitNewWorkspaceWithoutPrompt(page: import("@playwright/test").
test.describe("New workspace flow", () => {
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
const localWorkspaceIds = new Set<string>();
const createdWorktreeIds = new Set<string>();
const createdWorktreeDirectories = new Set<string>();
test.describe.configure({ timeout: 240_000 });
@@ -193,14 +192,14 @@ test.describe("New workspace flow", () => {
test.afterEach(async () => {
if (client) {
for (const workspaceId of createdWorktreeIds) {
await archiveWorkspaceFromDaemon(client, workspaceId).catch(() => undefined);
for (const workspaceDirectory of createdWorktreeDirectories) {
await archiveWorkspaceFromDaemon(client, workspaceDirectory).catch(() => undefined);
}
for (const workspaceId of localWorkspaceIds) {
await archiveLocalWorkspaceFromDaemon(client, workspaceId).catch(() => undefined);
}
}
createdWorktreeIds.clear();
createdWorktreeDirectories.clear();
localWorkspaceIds.clear();
await client?.close().catch(() => undefined);
});
@@ -223,7 +222,7 @@ test.describe("New workspace flow", () => {
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: firstWorkspace.workspaceId,
workspaceId: firstWorkspace.workspaceId,
});
await expectWorkspaceHeader(page, {
title: firstWorkspace.workspaceName,
@@ -233,7 +232,7 @@ test.describe("New workspace flow", () => {
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: secondWorkspace.workspaceId,
workspaceId: secondWorkspace.workspaceId,
});
await waitForWorkspaceInSidebar(page, {
serverId,
@@ -247,7 +246,7 @@ test.describe("New workspace flow", () => {
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: firstWorkspace.workspaceId,
workspaceId: firstWorkspace.workspaceId,
});
await expectWorkspaceHeader(page, {
title: firstWorkspace.workspaceName,
@@ -271,7 +270,7 @@ test.describe("New workspace flow", () => {
slug: `nav-${Date.now()}`,
});
localWorkspaceIds.add(rootWorkspace.workspaceId);
createdWorktreeIds.add(worktreeWorkspace.workspaceId);
createdWorktreeDirectories.add(worktreeWorkspace.workspaceDirectory);
await gotoAppShell(page);
await waitForSidebarHydration(page);
@@ -279,7 +278,7 @@ test.describe("New workspace flow", () => {
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: rootWorkspace.workspaceId,
workspaceId: rootWorkspace.workspaceId,
});
await expectWorkspaceHeader(page, {
title: rootWorkspace.workspaceName,
@@ -294,7 +293,7 @@ test.describe("New workspace flow", () => {
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: worktreeWorkspace.workspaceId,
workspaceId: worktreeWorkspace.workspaceId,
});
await expectWorkspaceHeader(page, {
title: worktreeWorkspace.workspaceName,
@@ -315,7 +314,7 @@ test.describe("New workspace flow", () => {
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: rootWorkspace.workspaceId,
workspaceId: rootWorkspace.workspaceId,
});
await expectWorkspaceHeader(page, {
title: rootWorkspace.workspaceName,
@@ -354,7 +353,7 @@ test.describe("New workspace flow", () => {
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: openedProject.workspaceId,
workspaceId: openedProject.workspaceId,
});
await expectWorkspaceHeader(page, {
title: openedProject.workspaceName,
@@ -367,12 +366,13 @@ test.describe("New workspace flow", () => {
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
serverId,
client,
previousWorkspaceId: openedProject.workspaceId,
projectDisplayName: openedProject.projectDisplayName,
assertSidebarRow: false,
assertHeader: false,
});
createdWorktreeIds.add(createdWorkspace.workspaceId);
createdWorktreeDirectories.add(createdWorkspace.workspaceDirectory);
expect(createdWorkspace.workspaceId).not.toBe(openedProject.workspaceId);
await expect(page).toHaveURL(
@@ -388,7 +388,7 @@ test.describe("New workspace flow", () => {
await expect(createdWorkspaceRow).toBeVisible({ timeout: 30_000 });
await expectWorkspaceHeader(page, {
title: workspaceLabelFromPath(createdWorkspace.workspaceId),
title: createdWorkspace.workspaceName,
subtitle: openedProject.projectDisplayName,
});
@@ -428,7 +428,7 @@ test.describe("New workspace flow", () => {
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: openedProject.workspaceId,
workspaceId: openedProject.workspaceId,
});
await expectWorkspaceHeader(page, {
title: openedProject.workspaceName,
@@ -455,12 +455,13 @@ test.describe("New workspace flow", () => {
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
serverId,
client,
previousWorkspaceId: openedProject.workspaceId,
projectDisplayName: openedProject.projectDisplayName,
assertSidebarRow: false,
assertHeader: false,
});
createdWorktreeIds.add(createdWorkspace.workspaceId);
createdWorktreeDirectories.add(createdWorkspace.workspaceDirectory);
await expect(page).toHaveURL(
buildHostWorkspaceRoute(serverId, createdWorkspace.workspaceId),
@@ -507,7 +508,7 @@ test.describe("New workspace flow", () => {
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: openedProject.workspaceId,
workspaceId: openedProject.workspaceId,
});
await expectWorkspaceHeader(page, {
title: openedProject.workspaceName,
@@ -523,12 +524,13 @@ test.describe("New workspace flow", () => {
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
serverId,
client,
previousWorkspaceId: openedProject.workspaceId,
projectDisplayName: openedProject.projectDisplayName,
assertSidebarRow: false,
assertHeader: false,
});
createdWorktreeIds.add(createdWorkspace.workspaceId);
createdWorktreeDirectories.add(createdWorkspace.workspaceDirectory);
const rowTestId = `sidebar-workspace-row-${serverId}:${createdWorkspace.workspaceId}`;
await expectWorkspaceStatusGroupEvents({
@@ -560,7 +562,7 @@ test.describe("New workspace flow", () => {
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: openedProject.workspaceId,
workspaceId: openedProject.workspaceId,
});
await expectWorkspaceHeader(page, {
title: openedProject.workspaceName,
@@ -576,10 +578,11 @@ test.describe("New workspace flow", () => {
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
serverId,
client,
previousWorkspaceId: openedProject.workspaceId,
projectDisplayName: openedProject.projectDisplayName,
});
createdWorktreeIds.add(createdWorkspace.workspaceId);
createdWorktreeDirectories.add(createdWorkspace.workspaceDirectory);
const rowTestId = `sidebar-workspace-row-${serverId}:${createdWorkspace.workspaceId}`;
await expectWorkspaceStatusGroupEvents({
@@ -618,7 +621,7 @@ test.describe("New workspace flow", () => {
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: openedProject.workspaceId,
workspaceId: openedProject.workspaceId,
});
await expectWorkspaceHeader(page, {
title: openedProject.workspaceName,
@@ -640,17 +643,18 @@ test.describe("New workspace flow", () => {
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
serverId,
client,
previousWorkspaceId: openedProject.workspaceId,
projectDisplayName: openedProject.projectDisplayName,
});
createdWorktreeIds.add(createdWorkspace.workspaceId);
createdWorktreeDirectories.add(createdWorkspace.workspaceDirectory);
expect(existsSync(createdWorkspace.workspaceId)).toBe(true);
expect(existsSync(createdWorkspace.workspaceDirectory)).toBe(true);
const branchInfo = await readWorktreeBranchInfo({
worktreePath: createdWorkspace.workspaceId,
worktreePath: createdWorkspace.workspaceDirectory,
});
expect(branchInfo.currentBranch).toBe(path.basename(createdWorkspace.workspaceId));
expect(branchInfo.currentBranch).toBe(path.basename(createdWorkspace.workspaceDirectory));
expect(branchInfo.hasAncestor(tempRepo.branchHeads.main)).toBe(true);
expect(branchInfo.hasAncestor(tempRepo.branchHeads.dev)).toBe(true);
} finally {

View File

@@ -9,7 +9,6 @@ async function openMockAgentAtMobileBreakpoint(page: Page) {
const session = await seedMockAgentWorkspace({
repoPrefix: "provider-sheet-stack-",
title: "Provider sheet stack e2e",
initialPrompt: "Prepare provider sheet stack test agent.",
});
await openAgentRoute(page, session);
await expectComposerVisible(page);
@@ -58,6 +57,12 @@ async function closeTopSheet(page: Page) {
await page.mouse.up();
}
async function closeSheetByHeaderButton(page: Page, testId: string) {
const sheet = page.getByTestId(testId);
await sheet.getByLabel("Close", { exact: true }).click({ force: true });
await expect(sheet).not.toBeVisible({ timeout: 10_000 });
}
async function expectProviderSettingsVisible(page: Page) {
await expect(page.getByTestId("provider-settings-sheet")).toBeVisible({ timeout: 10_000 });
await expect(page.getByRole("button", { name: "Add model" })).toBeVisible();
@@ -69,14 +74,15 @@ async function exerciseProviderSettingsStack(page: Page) {
await page.getByRole("button", { name: "Add model" }).click();
await expect(page.getByTestId("add-custom-model-sheet")).toBeVisible({ timeout: 10_000 });
await closeTopSheet(page);
await closeSheetByHeaderButton(page, "add-custom-model-sheet");
await expect(page.getByPlaceholder("e.g. openai/gpt-5")).not.toBeVisible({ timeout: 10_000 });
await expectProviderSettingsVisible(page);
await page.getByRole("button", { name: "Diagnostic", exact: true }).click();
await expect(page.getByTestId("provider-diagnostic-sheet")).toBeVisible({ timeout: 10_000 });
await page.getByRole("button", { name: /Refresh diagnostic/ }).click();
await expect(page.getByTestId("provider-diagnostic-sheet")).toBeVisible({ timeout: 10_000 });
await closeTopSheet(page);
await closeSheetByHeaderButton(page, "provider-diagnostic-sheet");
await expectProviderSettingsVisible(page);
await page.getByRole("button", { name: "Refresh", exact: true }).click();
@@ -87,18 +93,20 @@ test.describe("provider settings bottom-sheet stack", () => {
test("provider settings and children close back through the model selector stack", async ({
page,
}) => {
test.setTimeout(180_000);
const session = await openMockAgentAtMobileBreakpoint(page);
try {
await openProviderSettingsFromModelSelector(page);
await exerciseProviderSettingsStack(page);
await closeTopSheet(page);
await closeSheetByHeaderButton(page, "provider-settings-sheet");
await expectModelSelectorVisible(page);
await page.getByRole("button", { name: /Open .* settings/ }).click();
await expect(page.getByTestId("provider-settings-sheet")).toBeVisible({ timeout: 10_000 });
await exerciseProviderSettingsStack(page);
await closeTopSheet(page);
await closeSheetByHeaderButton(page, "provider-settings-sheet");
await expectModelSelectorVisible(page);
await closeTopSheet(page);

View File

@@ -74,7 +74,7 @@ test.describe("Settings toggle tab regression", () => {
await expectSendBehavior(page, "interrupt");
await pressSettingsToggleShortcut(page);
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, workspace.repoPath));
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, workspace.workspaceId));
await waitForTabBar(page);
await expectAgentTabActive(page, secondAgent.id);
@@ -105,13 +105,13 @@ test.describe("Settings toggle tab regression", () => {
await openAgentRouteAndExpectFocused({
page,
serverId,
workspaceId: workspace.repoPath,
workspaceId: workspace.workspaceId,
agentId: firstAgent.id,
});
await openAgentRouteAndExpectFocused({
page,
serverId,
workspaceId: workspace.repoPath,
workspaceId: workspace.workspaceId,
agentId: secondAgent.id,
});

View File

@@ -7,8 +7,8 @@ import { buildHostAgentDetailRoute } from "@/utils/host-routes";
import { captureWsSessionFrames, renameModalInput, renameModalSubmit } from "./helpers/rename";
import { getServerId } from "./helpers/server-id";
async function openAgentInWorkspace(page: Page, agent: { id: string; cwd: string }) {
await page.goto(buildHostAgentDetailRoute(getServerId(), agent.id, agent.cwd));
async function openAgentInWorkspace(page: Page, agent: { id: string; workspaceId: string }) {
await page.goto(buildHostAgentDetailRoute(getServerId(), agent.id, agent.workspaceId));
await page.waitForURL(
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
{ timeout: 60_000 },

View File

@@ -92,7 +92,7 @@ async function openWorkspaceThroughApp(
await switchWorkspaceViaSidebar({
page,
serverId: input.serverId,
targetWorkspacePath: input.workspace.workspaceId,
workspaceId: input.workspace.workspaceId,
});
await waitForWorkspaceTabsVisible(page);
await expectWorkspaceLocation(page, input);
@@ -209,7 +209,7 @@ test.describe("Workspace navigation regression", () => {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.cwd));
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.workspaceId));
await page.waitForURL(
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
{ timeout: 60_000 },
@@ -365,7 +365,7 @@ test.describe("Workspace navigation regression", () => {
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: firstWorkspace.workspaceId,
workspaceId: firstWorkspace.workspaceId,
});
await waitForWorkspaceTabsVisible(page);
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId), {
@@ -397,7 +397,7 @@ test.describe("Workspace navigation regression", () => {
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: secondWorkspace.workspaceId,
workspaceId: secondWorkspace.workspaceId,
});
await waitForWorkspaceTabsVisible(page);
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, secondWorkspace.workspaceId), {
@@ -461,7 +461,7 @@ test.describe("Workspace navigation regression", () => {
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: firstWorkspace.workspaceId,
workspaceId: firstWorkspace.workspaceId,
});
await waitForWorkspaceTabsVisible(page);
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId), {

View File

@@ -21,7 +21,7 @@ test.describe("Workspace pane mounting", () => {
title: `pane-remount-${Date.now()}`,
});
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.cwd));
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.workspaceId));
await page.waitForURL(
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
{ timeout: 60_000 },

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

View File

@@ -75,7 +75,7 @@ import {
type OpenFileDisposition,
type WorkspaceFileOpenRequest,
} from "@/workspace/file-open";
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
import { resolveWorkspaceIdByDirectory } from "@/utils/workspace-identity";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import { useStableEvent } from "@/hooks/use-stable-event";
import { isWeb } from "@/constants/platform";
@@ -276,7 +276,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
);
const workspaceRoot = agent.cwd?.trim() || "";
const workspaceId = resolveWorkspaceIdByExecutionDirectory({
const workspaceId = resolveWorkspaceIdByDirectory({
workspaces: useSessionStore.getState().sessions[resolvedServerId]?.workspaces?.values(),
workspaceDirectory: workspaceRoot,
});

View File

@@ -5,7 +5,7 @@ import { useSessionStore } from "@/stores/session-store";
import { useResolveWorkspaceIdByCwd } from "@/stores/session-store-hooks";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { buildHostRootRoute } from "@/utils/host-routes";
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
import { resolveWorkspaceIdByDirectory } from "@/utils/workspace-identity";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
export default function HostAgentReadyRoute() {
@@ -93,7 +93,7 @@ function HostAgentReadyRouteContent() {
}
const cwd = result?.agent?.cwd?.trim();
const workspaces = useSessionStore.getState().sessions[serverId]?.workspaces;
const workspaceId = resolveWorkspaceIdByExecutionDirectory({
const workspaceId = resolveWorkspaceIdByDirectory({
workspaces: workspaces?.values(),
workspaceDirectory: cwd,
});

View File

@@ -42,6 +42,7 @@ function normalizeCwd(cwd: string): string {
export function buildWorkspaceAttachmentScopeKey(input: WorkspaceAttachmentScopeInput): string {
const workspaceId = input.workspaceId?.trim();
// workspaceId is opaque; do not parse this key back into a path.
const workspacePart = workspaceId
? `workspace=${encodeScopePart(workspaceId)}`
: `cwd=${encodeScopePart(normalizeCwd(input.cwd))}`;

View File

@@ -10,6 +10,7 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useToast } from "@/contexts/toast-context";
import { useBranchSwitcher } from "@/hooks/use-branch-switcher";
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
import { ScreenTitle } from "@/components/headers/screen-title";
interface BranchSwitcherProps {
@@ -35,11 +36,13 @@ export function BranchSwitcher({
const isConnected = useHostRuntimeIsConnected(serverId);
const toast = useToast();
const queryClient = useQueryClient();
const workspaceDirectory = useWorkspaceDirectory(serverId, workspaceId);
const { branchOptions, isOpen, setIsOpen, handleBranchSelect } = useBranchSwitcher({
client,
normalizedServerId: serverId,
normalizedWorkspaceId: workspaceId,
workspaceDirectory,
currentBranchName,
isGitCheckout,
isConnected,

View File

@@ -120,10 +120,7 @@ import type { PrHint } from "@/git/use-pr-status-query";
import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
import { openExternalUrl } from "@/utils/open-external-url";
import {
requireWorkspaceExecutionDirectory,
resolveWorkspaceExecutionDirectory,
} from "@/utils/workspace-execution";
import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "@/utils/workspace-directory";
import {
confirmRiskyWorktreeArchive,
type WorktreeArchiveWarningLabels,
@@ -1538,7 +1535,7 @@ function WorkspaceRowWithMenu({
const queryClient = useQueryClient();
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
const [isRenameOpen, setIsRenameOpen] = useState(false);
const workspaceDirectory = resolveWorkspaceExecutionDirectory({
const workspaceDirectory = resolveWorkspaceDirectory({
workspaceDirectory: workspace.workspaceDirectory,
});
const archiveStatus = useCheckoutGitActionsStore((state) =>
@@ -1580,7 +1577,7 @@ function WorkspaceRowWithMenu({
}
let archiveDirectory: string;
try {
archiveDirectory = requireWorkspaceExecutionDirectory({
archiveDirectory = requireWorkspaceDirectory({
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
});
@@ -1660,7 +1657,7 @@ function WorkspaceRowWithMenu({
const handleCopyPath = useCallback(() => {
let copyTargetDirectory: string;
try {
copyTargetDirectory = requireWorkspaceExecutionDirectory({
copyTargetDirectory = requireWorkspaceDirectory({
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
});
@@ -1687,7 +1684,7 @@ function WorkspaceRowWithMenu({
if (!client) {
throw new Error(t("sidebar.workspace.toasts.hostDisconnected"));
}
const targetCwd = requireWorkspaceExecutionDirectory({
const targetCwd = requireWorkspaceDirectory({
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
});

View File

@@ -40,10 +40,7 @@ import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys";
import { slugify, validateBranchSlug, MAX_SLUG_LENGTH } from "@getpaseo/protocol/branch-slug";
import { AdaptiveRenameModal } from "@/components/rename-modal";
import {
requireWorkspaceExecutionDirectory,
resolveWorkspaceExecutionDirectory,
} from "@/utils/workspace-execution";
import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "@/utils/workspace-directory";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
import { archiveWorkspaceOptimistically } from "@/workspace/workspace-archive";
import { useCheckoutGitActionsStore } from "@/git/actions-store";
@@ -355,7 +352,7 @@ function StatusWorkspaceRowWithMenu({
const queryClient = useQueryClient();
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
const [isRenameOpen, setIsRenameOpen] = useState(false);
const workspaceDirectory = resolveWorkspaceExecutionDirectory({
const workspaceDirectory = resolveWorkspaceDirectory({
workspaceDirectory: workspace.workspaceDirectory,
});
const archiveStatus = useCheckoutGitActionsStore((state) =>
@@ -391,7 +388,7 @@ function StatusWorkspaceRowWithMenu({
if (!confirmed) return;
let archiveDirectory: string;
try {
archiveDirectory = requireWorkspaceExecutionDirectory({
archiveDirectory = requireWorkspaceDirectory({
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
});
@@ -441,7 +438,7 @@ function StatusWorkspaceRowWithMenu({
const handleCopyPath = useCallback(() => {
let copyTargetDirectory: string;
try {
copyTargetDirectory = requireWorkspaceExecutionDirectory({
copyTargetDirectory = requireWorkspaceDirectory({
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
});
@@ -462,7 +459,7 @@ function StatusWorkspaceRowWithMenu({
mutationFn: async (branch: string) => {
const client = getHostRuntimeStore().getClient(workspace.serverId);
if (!client) throw new Error(t("workspace.terminal.hostDisconnected"));
const targetCwd = requireWorkspaceExecutionDirectory({
const targetCwd = requireWorkspaceDirectory({
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
});

View File

@@ -22,7 +22,7 @@ import type {
DaemonClient,
} from "@getpaseo/client/internal/daemon-client";
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
import { requireWorkspaceExecutionAuthority } from "@/utils/workspace-execution";
import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import type { ImageAttachment, MessagePayload } from "@/composer/types";
@@ -300,9 +300,10 @@ export function WorkspaceSetupDialog() {
const wirePayload = splitComposerAttachmentsForSubmit(attachments);
const encodedImages = await encodeImages(wirePayload.images);
const workspaceDirectory = requireWorkspaceExecutionAuthority({
workspace: ensuredWorkspace,
}).workspaceDirectory;
const workspaceDirectory = requireWorkspaceDirectory({
workspaceId: ensuredWorkspace.id,
workspaceDirectory: ensuredWorkspace.workspaceDirectory,
});
const agent = await connectedClient.createAgent(
buildCreateAgentOptions({
composerState,

View File

@@ -23,7 +23,7 @@ import { buildDraftStoreKey } from "@/stores/draft-keys";
import { usePanelStore } from "@/stores/panel-store";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import type { Agent } from "@/stores/session-store";
import { useWorkspaceExecutionAuthority } from "@/stores/session-store-hooks";
import { useWorkspace } from "@/stores/session-store-hooks";
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
import { encodeImages } from "@/utils/encode-images";
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
@@ -121,7 +121,7 @@ async function submitDraftCreateRequest(input: {
attachments?: unknown;
client: DaemonClient | null;
workspaceDirectory: string | null;
workspaceExecutionAuthority: { workspaceId: string } | null;
workspaceId: string | null;
autoSubmitConfig: AutoSubmitConfig | null;
composerState: {
selectedProvider: string | null;
@@ -141,13 +141,13 @@ async function submitDraftCreateRequest(input: {
attachments,
client,
workspaceDirectory,
workspaceExecutionAuthority,
workspaceId,
autoSubmitConfig,
composerState,
} = input;
invariant(workspaceDirectory, "Workspace directory is required");
invariant(workspaceExecutionAuthority, "Workspace authority is required");
invariant(workspaceId, "Workspace id is required");
if (!client) {
throw new Error(input.hostDisconnectedMessage);
}
@@ -175,7 +175,7 @@ async function submitDraftCreateRequest(input: {
const attachmentsArray = Array.isArray(attachments) ? attachments : undefined;
const result = await client.createAgent({
config,
workspaceId: workspaceExecutionAuthority.workspaceId,
workspaceId,
...(text ? { initialPrompt: text } : {}),
clientMessageId: attempt.clientMessageId,
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
@@ -317,9 +317,8 @@ export function WorkspaceDraftAgentTab({
const insets = useSafeAreaInsets();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const workspaceAuthority = useWorkspaceExecutionAuthority(serverId, workspaceId);
const workspaceExecutionAuthority = workspaceAuthority?.ok ? workspaceAuthority.authority : null;
const workspaceDirectory = workspaceExecutionAuthority?.workspaceDirectory ?? null;
const workspace = useWorkspace(serverId, workspaceId);
const workspaceDirectory = workspace?.workspaceDirectory || null;
const draftSetup = initialSetup ?? null;
const draftWorkingDirectory = resolveDraftWorkingDirectory({
workspaceDirectory,
@@ -471,7 +470,7 @@ export function WorkspaceDraftAgentTab({
attachments,
client,
workspaceDirectory: draftWorkingDirectory,
workspaceExecutionAuthority,
workspaceId: workspace?.id ?? null,
autoSubmitConfig,
composerState,
hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"),

View File

@@ -1,7 +1,7 @@
import equal from "fast-deep-equal";
import type { ScriptStatusUpdateMessage } from "@getpaseo/protocol/messages";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-execution";
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
export function patchWorkspaceScripts(
workspaces: Map<string, WorkspaceDescriptor>,

View File

@@ -37,7 +37,7 @@ function workspace(input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescripto
projectId: input.projectId ?? "project-1",
projectDisplayName: input.projectDisplayName ?? "Project",
projectRootPath: input.projectRootPath ?? "/tmp/repo",
workspaceDirectory: input.workspaceDirectory ?? input.id,
workspaceDirectory: input.workspaceDirectory ?? "/tmp/repo/worktrees/feature",
projectKind: input.projectKind ?? "git",
workspaceKind: input.workspaceKind ?? "worktree",
name: input.name ?? input.id,
@@ -51,13 +51,13 @@ function workspace(input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescripto
describe("checkout-git-actions-store", () => {
const serverId = "server-1";
const cwd = "/tmp/repo";
const cwd = "/tmp/repo/worktrees/feature";
const workspaceId = "ws-feature";
beforeEach(() => {
vi.useFakeTimers();
__resetCheckoutGitActionsStoreForTests();
clearWorkspaceArchivePending({ serverId, workspaceId: cwd });
clearWorkspaceArchivePending({ serverId, workspaceId: "ws-feature" });
clearWorkspaceArchivePending({ serverId, workspaceId });
appQueryClient.clear();
useSessionStore.setState((state) => ({ ...state, sessions: {} }));
});
@@ -65,8 +65,7 @@ describe("checkout-git-actions-store", () => {
afterEach(() => {
vi.useRealTimers();
__resetCheckoutGitActionsStoreForTests();
clearWorkspaceArchivePending({ serverId, workspaceId: cwd });
clearWorkspaceArchivePending({ serverId, workspaceId: "ws-feature" });
clearWorkspaceArchivePending({ serverId, workspaceId });
appQueryClient.clear();
useSessionStore.setState((state) => ({ ...state, sessions: {} }));
});
@@ -301,9 +300,13 @@ describe("checkout-git-actions-store", () => {
const client = {
archivePaseoWorktree: vi.fn(() => deferred.promise),
};
const featureWorkspace = workspace({ id: cwd, name: "feature" });
const featureWorkspace = workspace({
id: workspaceId,
name: "feature",
workspaceDirectory: cwd,
});
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
useSessionStore.getState().setWorkspaces(serverId, new Map([[cwd, featureWorkspace]]));
useSessionStore.getState().setWorkspaces(serverId, new Map([[workspaceId, featureWorkspace]]));
appQueryClient.setQueryData(
["sidebarPaseoWorktreeList", serverId, "/tmp"],
[{ worktreePath: cwd }, { worktreePath: "/tmp/other" }],
@@ -313,6 +316,7 @@ describe("checkout-git-actions-store", () => {
.getState()
.archiveWorktree({ serverId, cwd, worktreePath: cwd });
expect(useSessionStore.getState().sessions[serverId]?.workspaces.has(workspaceId)).toBe(false);
expect(useSessionStore.getState().sessions[serverId]?.workspaces.has(cwd)).toBe(false);
expect(appQueryClient.getQueryData(["sidebarPaseoWorktreeList", serverId, "/tmp"])).toEqual([
{ worktreePath: "/tmp/other" },
@@ -325,49 +329,52 @@ describe("checkout-git-actions-store", () => {
expect(
isWorkspaceArchivePending({
serverId,
workspaceId: cwd,
workspaceId,
}),
).toBe(true);
expect(
isWorkspaceArchivePending({
serverId,
workspaceId: cwd,
}),
).toBe(false);
});
it("hides an archived worktree when the workspace map is keyed by opaque id", async () => {
const deferred = createDeferred<Record<string, never>>();
it("archives on the server even when its workspace cannot be resolved", async () => {
const client = {
archivePaseoWorktree: vi.fn(() => deferred.promise),
archivePaseoWorktree: vi.fn(async () => ({})),
};
const featureWorkspace = workspace({
id: "ws-feature",
name: "feature",
workspaceDirectory: cwd,
});
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
useSessionStore.getState().setWorkspaces(serverId, new Map([["ws-feature", featureWorkspace]]));
const archive = useCheckoutGitActionsStore
await useCheckoutGitActionsStore
.getState()
.archiveWorktree({ serverId, cwd, worktreePath: cwd });
expect(useSessionStore.getState().sessions[serverId]?.workspaces.has("ws-feature")).toBe(false);
deferred.resolve({});
await archive;
// The server archive is keyed by worktreePath and must run regardless.
expect(client.archivePaseoWorktree).toHaveBeenCalledWith({ worktreePath: cwd });
// The optimistic client-side mark is never keyed by the path.
expect(isWorkspaceArchivePending({ serverId, workspaceId: cwd })).toBe(false);
});
it("restores an optimistically hidden worktree when archive fails", async () => {
const client = {
archivePaseoWorktree: vi.fn(async () => ({ error: { message: "archive failed" } })),
};
const featureWorkspace = workspace({ id: cwd, name: "feature" });
const featureWorkspace = workspace({
id: workspaceId,
name: "feature",
workspaceDirectory: cwd,
});
const listSnapshot = [{ worktreePath: cwd }, { worktreePath: "/tmp/other" }];
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
useSessionStore.getState().setWorkspaces(serverId, new Map([[cwd, featureWorkspace]]));
useSessionStore.getState().setWorkspaces(serverId, new Map([[workspaceId, featureWorkspace]]));
appQueryClient.setQueryData(["sidebarPaseoWorktreeList", serverId, "/tmp"], listSnapshot);
await expect(
useCheckoutGitActionsStore.getState().archiveWorktree({ serverId, cwd, worktreePath: cwd }),
).rejects.toThrow("archive failed");
expect(useSessionStore.getState().sessions[serverId]?.workspaces.get(cwd)).toEqual(
expect(useSessionStore.getState().sessions[serverId]?.workspaces.get(workspaceId)).toEqual(
featureWorkspace,
);
expect(appQueryClient.getQueryData(["sidebarPaseoWorktreeList", serverId, "/tmp"])).toEqual(
@@ -380,9 +387,13 @@ describe("checkout-git-actions-store", () => {
const client = {
archivePaseoWorktree: vi.fn(() => deferred.promise),
};
const featureWorkspace = workspace({ id: cwd, name: "feature" });
const featureWorkspace = workspace({
id: workspaceId,
name: "feature",
workspaceDirectory: cwd,
});
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
useSessionStore.getState().setWorkspaces(serverId, new Map([[cwd, featureWorkspace]]));
useSessionStore.getState().setWorkspaces(serverId, new Map([[workspaceId, featureWorkspace]]));
const archive = useCheckoutGitActionsStore
.getState()

View File

@@ -14,9 +14,9 @@ import {
markWorkspaceArchivePending,
} from "@/contexts/session-workspace-upserts";
import {
resolveWorkspaceIdByExecutionDirectory,
resolveWorkspaceIdByDirectory,
resolveWorkspaceMapKeyByIdentity,
} from "@/utils/workspace-execution";
} from "@/utils/workspace-identity";
import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys";
import { i18n } from "@/i18n/i18next";
@@ -158,12 +158,13 @@ function snapshotWorktreeArchiveState(input: {
worktreePath: string;
}): WorktreeArchiveSnapshot {
const workspaces = useSessionStore.getState().sessions[input.serverId]?.workspaces;
const workspaceId =
resolveWorkspaceIdByExecutionDirectory({
workspaces: workspaces?.values(),
workspaceDirectory: input.worktreePath,
}) ?? input.worktreePath;
const workspaceKey = resolveWorkspaceMapKeyByIdentity({ workspaces, workspaceId });
const workspaceId = resolveWorkspaceIdByDirectory({
workspaces: workspaces?.values(),
workspaceDirectory: input.worktreePath,
});
const workspaceKey = workspaceId
? resolveWorkspaceMapKeyByIdentity({ workspaces, workspaceId })
: null;
return {
workspace: workspaceKey ? (workspaces?.get(workspaceKey) ?? null) : null,
worktreeLists: appQueryClient.getQueriesData({
@@ -173,13 +174,13 @@ function snapshotWorktreeArchiveState(input: {
};
}
function removeWorktreeFromSessionStore(input: { serverId: string; worktreePath: string }): void {
function removeWorktreeFromSessionStore(input: { serverId: string; workspaceId: string }): void {
const serverId = input.serverId.trim();
const worktreePath = input.worktreePath.trim();
if (!serverId || !worktreePath) {
const workspaceId = input.workspaceId.trim();
if (!serverId || !workspaceId) {
return;
}
useSessionStore.getState().removeWorkspace(serverId, worktreePath);
useSessionStore.getState().removeWorkspace(serverId, workspaceId);
}
function restoreWorktreeArchiveState(input: {
@@ -195,9 +196,9 @@ function restoreWorktreeArchiveState(input: {
}
}
function purgeArchivedWorkspaceState(input: { serverId: string; worktreePath: string }): void {
function purgeArchivedWorkspaceState(input: { serverId: string; workspaceId: string }): void {
const serverId = input.serverId.trim();
const workspaceId = input.worktreePath.trim();
const workspaceId = input.workspaceId.trim();
if (!serverId || !workspaceId) {
return;
}
@@ -504,31 +505,35 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
run: async () => {
const client = resolveClient(serverId);
const snapshot = snapshotWorktreeArchiveState({ serverId, worktreePath });
markWorkspaceArchivePending({
serverId,
workspaceId: snapshot.workspace?.id ?? worktreePath,
workspaceDirectory: snapshot.workspace?.workspaceDirectory ?? worktreePath,
});
// The server archive is keyed by worktreePath and must always run. The
// optimistic client-side updates are keyed by workspace id, so they only
// apply when the workspace is resolved in the local store.
const workspace = snapshot.workspace;
if (workspace) {
markWorkspaceArchivePending({
serverId,
workspaceId: workspace.id,
workspaceDirectory: workspace.workspaceDirectory,
});
removeWorktreeFromSessionStore({ serverId, workspaceId: workspace.id });
}
removeWorktreeFromCachedLists({ serverId, worktreePath });
removeWorktreeFromSessionStore({
serverId,
worktreePath: snapshot.workspace?.id ?? worktreePath,
});
try {
const payload = await client.archivePaseoWorktree({ worktreePath });
if (payload.error) {
throw new Error(payload.error.message);
}
} catch (error) {
clearWorkspaceArchivePending({
serverId,
workspaceId: snapshot.workspace?.id ?? worktreePath,
});
if (workspace) {
clearWorkspaceArchivePending({ serverId, workspaceId: workspace.id });
}
restoreWorktreeArchiveState({ serverId, snapshot });
throw error;
}
invalidateWorktreeList();
purgeArchivedWorkspaceState({ serverId, worktreePath });
if (workspace) {
purgeArchivedWorkspaceState({ serverId, workspaceId: workspace.id });
}
},
});
},

View File

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

View File

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

View File

@@ -15,8 +15,9 @@ import type { CheckoutPrMergeMethod } from "@getpaseo/protocol/messages";
import { openExternalUrl } from "@/utils/open-external-url";
import { useToast } from "@/contexts/toast-context";
import { useSessionStore } from "@/stores/session-store";
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
import { resolveWorkspaceIdByDirectory } from "@/utils/workspace-identity";
import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation";
import { buildHostRootRoute } from "@/utils/host-routes";
import {
confirmRiskyWorktreeArchive,
type WorktreeArchiveWarningLabels,
@@ -495,18 +496,18 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
return;
}
const archivedWorkspaceId =
resolveWorkspaceIdByExecutionDirectory({
workspaces: workspaceList,
workspaceDirectory: worktreePath,
}) ?? worktreePath;
router.replace(
buildWorkspaceArchiveRedirectRoute({
serverId,
archivedWorkspaceId,
workspaces: workspaceList,
}) as Href,
);
const archivedWorkspaceId = resolveWorkspaceIdByDirectory({
workspaces: workspaceList,
workspaceDirectory: worktreePath,
});
const redirectRoute = archivedWorkspaceId
? buildWorkspaceArchiveRedirectRoute({
serverId,
archivedWorkspaceId,
workspaces: workspaceList,
})
: buildHostRootRoute(serverId);
router.replace(redirectRoute as Href);
void runArchiveWorktree({ serverId, cwd, worktreePath }).catch((err) => {
toastActionError(err, t("workspace.git.actions.toasts.failedArchive"));
});

View File

@@ -8,6 +8,12 @@ import type { WorkspaceStructureProject } from "@/projects/workspace-structure";
const EMPTY_PROJECTS: SidebarProjectEntry[] = [];
function workspaceNameFromDirectory(directory: string): string {
const trimmed = directory.trim().replace(/[\\/]+$/g, "");
const separator = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\"));
return separator >= 0 ? trimmed.slice(separator + 1) : trimmed;
}
export type SidebarStateBucket = WorkspaceDescriptor["status"];
export interface SidebarWorkspaceEntry {
@@ -54,7 +60,7 @@ function createStructuralWorkspaceEntry(input: {
workspaceDirectory: undefined,
projectKind: input.project.projectKind,
workspaceKind: "checkout",
name: input.workspaceId,
name: workspaceNameFromDirectory(input.project.iconWorkingDir) || input.workspaceId,
statusBucket: "done",
statusEnteredAt: null,
archivingAt: null,

View File

@@ -5,12 +5,14 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { ComboboxOption } from "@/components/ui/combobox";
import type { ToastApi } from "@/components/toast-host";
import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys";
import { createBranchSwitcherOperations } from "@/git/branch-switcher-operations";
import { confirmDialog } from "@/utils/confirm-dialog";
interface UseBranchSwitcherInput {
client: DaemonClient | null;
normalizedServerId: string;
normalizedWorkspaceId: string;
workspaceDirectory: string | null;
currentBranchName: string | null;
isGitCheckout: boolean;
isConnected: boolean;
@@ -30,6 +32,7 @@ export function useBranchSwitcher({
client,
normalizedServerId,
normalizedWorkspaceId,
workspaceDirectory,
currentBranchName,
isGitCheckout,
isConnected,
@@ -39,22 +42,29 @@ export function useBranchSwitcher({
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
// Git operations are bound to the workspace directory; the opaque workspace id is
// used only for query cache identity below, never as a cwd.
const operations = useMemo(
() =>
client && workspaceDirectory
? createBranchSwitcherOperations(client, workspaceDirectory)
: null,
[client, workspaceDirectory],
);
const branchSuggestionsQuery = useQuery({
queryKey: ["branchSuggestions", normalizedServerId, normalizedWorkspaceId],
queryFn: async () => {
if (!client) {
if (!operations) {
throw new Error(t("common.errors.daemonClientUnavailable"));
}
const payload = await client.getBranchSuggestions({
cwd: normalizedWorkspaceId,
limit: 200,
});
const payload = await operations.getBranchSuggestions(200);
if (payload.error) {
throw new Error(payload.error);
}
return payload.branches ?? [];
},
enabled: isOpen && isGitCheckout && Boolean(client) && isConnected,
enabled: isOpen && isGitCheckout && Boolean(operations) && isConnected,
retry: false,
staleTime: 15_000,
});
@@ -70,20 +80,21 @@ export function useBranchSwitcher({
);
const invalidateStashAndCheckout = useCallback(async () => {
if (!workspaceDirectory) return;
await Promise.all([
queryClient.invalidateQueries({ queryKey: stashListQueryKey }),
invalidateCheckoutGitQueriesForClient(queryClient, {
serverId: normalizedServerId,
cwd: normalizedWorkspaceId,
cwd: workspaceDirectory,
}),
]);
}, [queryClient, stashListQueryKey, normalizedServerId, normalizedWorkspaceId]);
}, [queryClient, stashListQueryKey, normalizedServerId, workspaceDirectory]);
const maybeRestoreStashForBranch = useCallback(
async (branchId: string) => {
if (!client) return;
if (!operations) return;
try {
const stashPayload = await client.stashList(normalizedWorkspaceId, { paseoOnly: true });
const stashPayload = await operations.listPaseoStashes();
const targetStash = stashPayload.entries.find((e) => e.branch === branchId);
if (!targetStash) return;
const shouldRestore = await confirmDialog({
@@ -93,7 +104,7 @@ export function useBranchSwitcher({
cancelLabel: t("branchSwitcher.later"),
});
if (!shouldRestore) return;
const popPayload = await client.stashPop(normalizedWorkspaceId, targetStash.index);
const popPayload = await operations.popStash(targetStash.index);
if (popPayload.error) {
toast.error(popPayload.error.message);
} else {
@@ -104,12 +115,12 @@ export function useBranchSwitcher({
// Non-critical — user can still restore on next branch switch
}
},
[client, invalidateStashAndCheckout, normalizedWorkspaceId, toast, t],
[operations, invalidateStashAndCheckout, toast, t],
);
const stashAndSwitch = useCallback(
async (branchId: string) => {
if (!client) return;
if (!operations) return;
const shouldStash = await confirmDialog({
title: t("branchSwitcher.uncommittedTitle"),
message: t("branchSwitcher.uncommittedMessage"),
@@ -119,15 +130,13 @@ export function useBranchSwitcher({
if (!shouldStash) return;
try {
const stashPayload = await client.stashSave(normalizedWorkspaceId, {
branch: currentBranchName ?? undefined,
});
const stashPayload = await operations.saveStash(currentBranchName ?? undefined);
if (stashPayload.error) {
toast.error(stashPayload.error.message);
return;
}
await invalidateStashAndCheckout();
const switchPayload = await client.checkoutSwitchBranch(normalizedWorkspaceId, branchId);
const switchPayload = await operations.switchBranch(branchId);
if (switchPayload.error) {
toast.error(switchPayload.error.message);
return;
@@ -137,7 +146,7 @@ export function useBranchSwitcher({
toast.error(err instanceof Error ? err.message : t("branchSwitcher.failedToStash"));
}
},
[client, currentBranchName, invalidateStashAndCheckout, normalizedWorkspaceId, toast, t],
[operations, currentBranchName, invalidateStashAndCheckout, toast, t],
);
const handleBranchSelect = useCallback(
@@ -145,9 +154,9 @@ export function useBranchSwitcher({
if (branchId === currentBranchName) return;
void (async () => {
if (!client) return;
if (!operations) return;
try {
const payload = await client.checkoutSwitchBranch(normalizedWorkspaceId, branchId);
const payload = await operations.switchBranch(branchId);
if (payload.error) {
// If the error is about uncommitted changes, offer the stash dialog
if (payload.error.message.toLowerCase().includes("uncommitted")) {
@@ -166,11 +175,10 @@ export function useBranchSwitcher({
})();
},
[
client,
operations,
currentBranchName,
invalidateStashAndCheckout,
maybeRestoreStashForBranch,
normalizedWorkspaceId,
stashAndSwitch,
t,
toast,

View File

@@ -230,9 +230,7 @@ describe("translation resources", () => {
expect(en.workspace.browser.unavailable.title).toBe("Browser is desktop-only");
expect(en.workspace.browser.controls.enterUrl).toBe("Enter URL");
expect(en.workspace.terminal.hostDisconnected).toBe("Host is not connected");
expect(en.panels.file.executionDirectoryMissing).toBe(
"Workspace execution directory not found.",
);
expect(en.panels.file.directoryMissing).toBe("Workspace directory not found.");
});
it("includes workspace Git and review keys for the Batch 4B migration", () => {

View File

@@ -1292,7 +1292,7 @@ export const ar: TranslationResources = {
creatingAgent: "وكيل الخلق",
},
file: {
executionDirectoryMissing: "لم يتم العثور على دليل تنفيذ Workspace.",
directoryMissing: "لم يتم العثور على دليل Workspace.",
loading: "جارٍ تحميل الملف...",
noPreview: "لا تتوفر معاينة",
binaryPreviewUnavailable: "المعاينة الثنائية غير متاحة",

View File

@@ -1298,7 +1298,7 @@ export const en = {
creatingAgent: "Creating agent",
},
file: {
executionDirectoryMissing: "Workspace execution directory not found.",
directoryMissing: "Workspace directory not found.",
loading: "Loading file...",
noPreview: "No preview available",
binaryPreviewUnavailable: "Binary preview unavailable",

View File

@@ -1327,7 +1327,7 @@ export const es: TranslationResources = {
creatingAgent: "Agente creador",
},
file: {
executionDirectoryMissing: "No se encontró el directorio de ejecución deWorkspace.",
directoryMissing: "No se encontró el directorio de Workspace.",
loading: "Cargando archivo...",
noPreview: "No hay vista previa disponible",
binaryPreviewUnavailable: "Vista previa binaria no disponible",

View File

@@ -1330,7 +1330,7 @@ export const fr: TranslationResources = {
creatingAgent: "Agent créateur",
},
file: {
executionDirectoryMissing: "Répertoire d'exécutionWorkspaceintrouvable.",
directoryMissing: "Répertoire Workspace introuvable.",
loading: "Chargement du fichier...",
noPreview: "Aucun aperçu disponible",
binaryPreviewUnavailable: "Aperçu binaire indisponible",

View File

@@ -1319,7 +1319,7 @@ export const ru: TranslationResources = {
creatingAgent: "Создание агента",
},
file: {
executionDirectoryMissing: "Каталог выполнения Workspace не найден.",
directoryMissing: "Каталог Workspace не найден.",
loading: "Загрузка файла...",
noPreview: "Предварительный просмотр недоступен",
binaryPreviewUnavailable: "Предварительный просмотр двоичного файла недоступен.",

View File

@@ -1275,7 +1275,7 @@ export const zhCN: TranslationResources = {
creatingAgent: "正在创建 Agent",
},
file: {
executionDirectoryMissing: "未找到 workspace 执行目录。",
directoryMissing: "未找到 workspace 目录。",
loading: "正在加载文件...",
noPreview: "没有可用预览",
binaryPreviewUnavailable: "二进制预览不可用",

View File

@@ -6,7 +6,7 @@ import { BrowserPane } from "@/components/browser-pane";
import { usePaneContext, usePaneFocus } from "@/panels/pane-context";
import type { PanelDescriptor, PanelIconProps, PanelRegistration } from "@/panels/panel-registry";
import { useBrowserStore } from "@/stores/browser-store";
import { useWorkspaceExecutionAuthority } from "@/stores/session-store-hooks";
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
function getBrowserLabel(input: { title: string; url: string }): string {
const title = input.title.trim();
@@ -55,8 +55,7 @@ function useBrowserPanelDescriptor(target: {
function BrowserPanel() {
const { serverId, workspaceId, target } = usePaneContext();
const { focusPane, isInteractive } = usePaneFocus();
const workspaceAuthority = useWorkspaceExecutionAuthority(serverId, workspaceId)!;
const cwd = workspaceAuthority.ok ? workspaceAuthority.authority.workspaceDirectory : null;
const cwd = useWorkspaceDirectory(serverId, workspaceId);
invariant(target.kind === "browser", "BrowserPanel requires browser target");
return (
<BrowserPane

View File

@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
import { FilePane } from "@/components/file-pane";
import { usePaneContext } from "@/panels/pane-context";
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 = {
flex: 1,
@@ -28,15 +28,12 @@ function useFilePanelDescriptor(target: { kind: "file"; path: string }) {
function FilePanel() {
const { t } = useTranslation();
const { serverId, workspaceId, target } = usePaneContext();
const workspaceAuthority = useWorkspaceExecutionAuthority(serverId, workspaceId);
const workspaceDirectory = workspaceAuthority?.ok
? workspaceAuthority.authority.workspaceDirectory
: null;
const workspaceDirectory = useWorkspaceDirectory(serverId, workspaceId);
invariant(target.kind === "file", "FilePanel requires file target");
if (!workspaceDirectory) {
return (
<View style={CENTERED_PADDED_STYLE}>
<Text>{t("panels.file.executionDirectoryMissing")}</Text>
<Text>{t("panels.file.directoryMissing")}</Text>
</View>
);
}

View File

@@ -11,7 +11,7 @@ import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry
import { queryClient } from "@/query/query-client";
import { usePanelStore } from "@/stores/panel-store";
import { useSessionStore } from "@/stores/session-store";
import { useWorkspaceExecutionAuthority } from "@/stores/session-store-hooks";
import { useWorkspace } from "@/stores/session-store-hooks";
type ListTerminalsPayload = ListTerminalsResponse["payload"];
@@ -37,21 +37,15 @@ function useTerminalPanelDescriptor(
): PanelDescriptor {
const { t } = useTranslation();
const client = useSessionStore((state) => state.sessions[context.serverId]?.client ?? null);
const workspaceAuthority = useWorkspaceExecutionAuthority(context.serverId, context.workspaceId)!;
const workspaceDirectory = workspaceAuthority.ok
? workspaceAuthority.authority.workspaceDirectory
: null;
const workspace = useWorkspace(context.serverId, context.workspaceId);
const workspaceDirectory = workspace?.workspaceDirectory || null;
const terminalsQuery = useQuery(
{
queryKey: ["terminals", context.serverId, workspaceDirectory] as const,
enabled: Boolean(client && workspaceDirectory),
queryFn: async (): Promise<ListTerminalsPayload> => {
if (!client || !workspaceDirectory) {
throw new Error(
workspaceAuthority.ok
? "Workspace execution directory not found"
: workspaceAuthority.message,
);
throw new Error("Workspace directory not found");
}
return client.listTerminals(workspaceDirectory);
},
@@ -76,13 +70,9 @@ function useTerminalPanelDescriptor(
function TerminalPanel() {
const { serverId, workspaceId, target, openFileInWorkspace } = usePaneContext();
const { isWorkspaceFocused, isPaneFocused } = usePaneFocus();
const workspaceAuthority = useWorkspaceExecutionAuthority(serverId, workspaceId)!;
const workspaceDirectory = workspaceAuthority.ok
? workspaceAuthority.authority.workspaceDirectory
: null;
const isGitCheckout = workspaceAuthority.ok
? workspaceAuthority.authority.workspace.projectKind === "git"
: false;
const workspace = useWorkspace(serverId, workspaceId);
const workspaceDirectory = workspace?.workspaceDirectory || null;
const isGitCheckout = workspace?.projectKind === "git";
const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout);
const handleOpenFileExplorer = useCallback(() => {
if (!workspaceDirectory) {
@@ -102,11 +92,7 @@ function TerminalPanel() {
if (!workspaceDirectory) {
return (
<View style={CENTERED_PADDED_STYLE}>
<Text>
{workspaceAuthority.ok
? "Workspace execution directory not found."
: workspaceAuthority.message}
</Text>
<Text>Workspace directory not found.</Text>
</View>
);
}

View File

@@ -96,6 +96,7 @@ function normalizeBaseRef(baseRef: string | null | undefined): string {
function buildReviewDraftScopeParts(input: BuildReviewDraftScopeKeyInput): string[] {
const workspaceId = input.workspaceId?.trim();
// workspaceId is opaque; do not parse this key back into a path.
const workspacePart = workspaceId
? `workspace=${encodeKeyPart(workspaceId)}`
: `cwd=${encodeKeyPart(normalizeCwd(input.cwd))}`;

View File

@@ -38,7 +38,7 @@ interface UseWorkspaceTerminalsInput {
workspaceDirectory: string | null;
workspaceScripts: WorkspaceDescriptor["scripts"];
hasHydratedWorkspaces: boolean;
isMissingWorkspaceExecutionAuthority: boolean;
isMissingWorkspaceDirectory: boolean;
onTerminalCreated: (input: { terminalId: string; paneId?: string }) => void;
onScriptTerminalSelected: (terminalId: string) => void;
onWorkspacePathUnavailable: () => void;
@@ -56,7 +56,7 @@ export function useWorkspaceTerminals(input: UseWorkspaceTerminalsInput) {
workspaceDirectory,
workspaceScripts,
hasHydratedWorkspaces,
isMissingWorkspaceExecutionAuthority,
isMissingWorkspaceDirectory,
onTerminalCreated,
onScriptTerminalSelected,
onWorkspacePathUnavailable,
@@ -209,7 +209,7 @@ export function useWorkspaceTerminals(input: UseWorkspaceTerminalsInput) {
return;
}
if (hasHydratedWorkspaces && isMissingWorkspaceExecutionAuthority) {
if (hasHydratedWorkspaces && isMissingWorkspaceDirectory) {
setPendingCreateInput(null);
onWorkspacePathUnavailable();
}
@@ -217,7 +217,7 @@ export function useWorkspaceTerminals(input: UseWorkspaceTerminalsInput) {
canCreateNow,
createMutation,
hasHydratedWorkspaces,
isMissingWorkspaceExecutionAuthority,
isMissingWorkspaceDirectory,
onWorkspacePathUnavailable,
pendingCreateInput,
]);
@@ -233,7 +233,7 @@ export function useWorkspaceTerminals(input: UseWorkspaceTerminalsInput) {
return;
}
if (hasHydratedWorkspaces && isMissingWorkspaceExecutionAuthority) {
if (hasHydratedWorkspaces && isMissingWorkspaceDirectory) {
onWorkspacePathUnavailable();
return;
}
@@ -245,7 +245,7 @@ export function useWorkspaceTerminals(input: UseWorkspaceTerminalsInput) {
canCreateNow,
createMutation,
hasHydratedWorkspaces,
isMissingWorkspaceExecutionAuthority,
isMissingWorkspaceDirectory,
onTerminalCreateQueued,
onWorkspacePathUnavailable,
pendingCreateInput,

View File

@@ -110,11 +110,7 @@ import { createWorkspaceBrowser, useBrowserStore } from "@/stores/browser-store"
import { getDesktopHost } from "@/desktop/host";
import { buildProviderCommand } from "@/utils/provider-command-templates";
import { generateDraftId } from "@/stores/draft-keys";
import {
getWorkspaceExecutionAuthority,
resolveWorkspaceRouteId,
type WorkspaceExecutionAuthorityResult,
} from "@/utils/workspace-execution";
import { resolveWorkspaceRouteId } from "@/utils/workspace-identity";
import {
WorkspaceTabPresentationResolver,
WorkspaceTabIcon,
@@ -170,7 +166,6 @@ import {
} from "@/screens/workspace/workspace-bulk-close";
import { resolveCloseAgentTabPolicy } from "@/subagents";
import { findAdjacentPane } from "@/utils/split-navigation";
import { isAbsolutePath } from "@/utils/path";
import { useIsCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/layout";
import { getIsElectron, isNative, isWeb } from "@/constants/platform";
import { useContainerWidthBelow } from "@/hooks/use-container-width";
@@ -966,13 +961,13 @@ function useCloseTabs(): UseCloseTabsResult {
interface WorkspaceHeaderMenuProps {
normalizedServerId: string;
normalizedWorkspaceId: string;
currentBranchName: string | null;
showWorkspaceSetup: boolean;
showCreateBrowserTab: boolean;
isMobile: boolean;
createTerminalDisabled: boolean;
importAgentDisabled: boolean;
copyPathDisabled: boolean;
menuNewAgentIcon: ReactElement;
menuNewTerminalIcon: ReactElement;
menuNewBrowserIcon: ReactElement;
@@ -1047,13 +1042,13 @@ function WorkspaceHeaderMenuTriggerIcon({
function WorkspaceHeaderMenu({
normalizedServerId,
normalizedWorkspaceId,
currentBranchName,
showWorkspaceSetup,
showCreateBrowserTab,
isMobile,
createTerminalDisabled,
importAgentDisabled,
copyPathDisabled,
menuNewAgentIcon,
menuNewTerminalIcon,
menuNewBrowserIcon,
@@ -1126,7 +1121,7 @@ function WorkspaceHeaderMenu({
<DropdownMenuItem
testID="workspace-header-copy-path"
leading={menuCopyIcon}
disabled={!isAbsolutePath(normalizedWorkspaceId)}
disabled={copyPathDisabled}
onSelect={onCopyWorkspacePath}
>
{t("workspace.header.actions.copyPath")}
@@ -1198,6 +1193,7 @@ interface WorkspaceHeaderTitleBarProps {
isMobile: boolean;
createTerminalDisabled: boolean;
importAgentDisabled: boolean;
copyPathDisabled: boolean;
menuNewAgentIcon: ReactElement;
menuNewTerminalIcon: ReactElement;
menuNewBrowserIcon: ReactElement;
@@ -1233,6 +1229,7 @@ function WorkspaceHeaderTitleBar({
isMobile,
createTerminalDisabled,
importAgentDisabled,
copyPathDisabled,
menuNewAgentIcon,
menuNewTerminalIcon,
menuNewBrowserIcon,
@@ -1280,13 +1277,13 @@ function WorkspaceHeaderTitleBar({
<View style={styles.compactHeaderMenuCluster}>
<WorkspaceHeaderMenu
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
currentBranchName={currentBranchName}
showWorkspaceSetup={showWorkspaceSetup}
showCreateBrowserTab={showCreateBrowserTab}
isMobile={isMobile}
createTerminalDisabled={createTerminalDisabled}
importAgentDisabled={importAgentDisabled}
copyPathDisabled={copyPathDisabled}
menuNewAgentIcon={menuNewAgentIcon}
menuNewTerminalIcon={menuNewTerminalIcon}
menuNewBrowserIcon={menuNewBrowserIcon}
@@ -1331,7 +1328,7 @@ function parsePaneDirection(actionId: string): PaneDirection | null {
}
interface RenderWorkspaceContentInput {
isMissingWorkspaceExecutionAuthority: boolean;
isMissingWorkspaceDirectory: boolean;
activeTabDescriptor: WorkspaceTabDescriptor | null;
hasHydratedAgents: boolean;
mountedFocusedPaneTabIds: string[];
@@ -1346,7 +1343,7 @@ interface RenderWorkspaceContentInput {
function renderWorkspaceContent(input: RenderWorkspaceContentInput): React.ReactNode {
const {
isMissingWorkspaceExecutionAuthority,
isMissingWorkspaceDirectory,
activeTabDescriptor,
hasHydratedAgents,
mountedFocusedPaneTabIds,
@@ -1356,11 +1353,11 @@ function renderWorkspaceContent(input: RenderWorkspaceContentInput): React.React
buildMobilePaneContentModel,
} = input;
if (isMissingWorkspaceExecutionAuthority) {
if (isMissingWorkspaceDirectory) {
return (
<View style={styles.emptyState}>
<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>
</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 {
const trimmed = host?.label?.trim();
return trimmed ? trimmed : fallback;
@@ -1777,15 +1758,8 @@ function WorkspaceScreenContent({
const client = useHostRuntimeClient(normalizedServerId);
const isConnected = useHostRuntimeIsConnected(normalizedServerId);
const workspaceAuthority = useMemo(
() =>
getWorkspaceExecutionAuthority({
workspace: workspaceDescriptor,
}),
[workspaceDescriptor],
);
const { workspaceDirectory, isMissingWorkspaceExecutionAuthority } =
resolveWorkspaceAuthorityState(workspaceAuthority, workspaceDescriptor);
const workspaceDirectory = workspaceDescriptor?.workspaceDirectory || null;
const isMissingWorkspaceDirectory = Boolean(workspaceDescriptor) && !workspaceDirectory;
const [isImportSheetVisible, setIsImportSheetVisible] = useState(false);
const canOpenImportSheet = [client, isConnected, workspaceDirectory].every(Boolean);
const openImportSheet = useCallback(() => {
@@ -1870,7 +1844,7 @@ function WorkspaceScreenContent({
workspaceDirectory,
workspaceScripts,
hasHydratedWorkspaces,
isMissingWorkspaceExecutionAuthority,
isMissingWorkspaceDirectory,
onTerminalCreated: handleTerminalCreated,
onScriptTerminalSelected: handleScriptTerminalSelected,
onWorkspacePathUnavailable: handleWorkspacePathUnavailable,
@@ -3260,7 +3234,7 @@ function WorkspaceScreenContent({
[buildPaneContentModel],
);
const content = renderWorkspaceContent({
isMissingWorkspaceExecutionAuthority,
isMissingWorkspaceDirectory,
activeTabDescriptor,
hasHydratedAgents,
mountedFocusedPaneTabIds,
@@ -3411,11 +3385,13 @@ function WorkspaceScreenContent({
) : null}
{!isMobile && isGitCheckout ? (
<>
<WorkspaceGitActions
serverId={normalizedServerId}
cwd={normalizedWorkspaceId}
hideLabels={showCompactButtonLabels}
/>
{workspaceDirectory ? (
<WorkspaceGitActions
serverId={normalizedServerId}
cwd={workspaceDirectory}
hideLabels={showCompactButtonLabels}
/>
) : null}
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild>
<Pressable
@@ -3654,6 +3630,7 @@ function WorkspaceScreenContent({
isMobile={isMobile}
createTerminalDisabled={createTerminalDisabled}
importAgentDisabled={!canOpenImportSheet}
copyPathDisabled={!workspaceDirectory}
menuNewAgentIcon={menuNewAgentIcon}
menuNewTerminalIcon={menuNewTerminalIcon}
menuNewBrowserIcon={MENU_NEW_BROWSER_ICON}

View File

@@ -1,6 +1,6 @@
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import { i18n } from "@/i18n/i18next";
import { encodeFilePathForPathSegment } from "@/utils/host-routes";
import { encodeFilePathForPathSegment, encodeWorkspaceIdForPathSegment } from "@/utils/host-routes";
import { buildDeterministicWorkspaceTabId } from "@/workspace-tabs/identity";
export type WorkspaceTabMenuSurface = "desktop" | "mobile";
@@ -136,7 +136,7 @@ function getCloseButtonTestId(tab: WorkspaceTabDescriptor): string {
return `workspace-browser-close-${tab.target.browserId}`;
}
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)}`;
}

View File

@@ -59,6 +59,7 @@ export function createLastWorkspaceSelectionStore(storage: LastWorkspaceSelectio
selection = normalized;
revision += 1;
notifyListeners();
// workspaceId is opaque; do not parse this persisted selection back into a path.
void storage.write(JSON.stringify(normalized)).catch(() => {});
}

View File

@@ -6,9 +6,9 @@ import {
parseHostWorkspaceRouteFromPathname,
} from "@/utils/host-routes";
import {
resolveWorkspaceIdByExecutionDirectory,
resolveWorkspaceIdByDirectory,
resolveWorkspaceMapKeyByIdentity,
} from "@/utils/workspace-execution";
} from "@/utils/workspace-identity";
import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection";
export interface RouteSelectionInput {
@@ -77,7 +77,7 @@ export function navigateToWorkspace(
const workspaceAgents = resolvedWorkspaceId
? Array.from(deps.getSessionAgents(serverId)).filter(
(agent) =>
resolveWorkspaceIdByExecutionDirectory({
resolveWorkspaceIdByDirectory({
workspaces: workspaces?.values(),
workspaceDirectory: agent.cwd,
}) === resolvedWorkspaceId,

View File

@@ -9,8 +9,8 @@ import {
selectRecommendedProjectPaths,
selectResolveWorkspaceIdByCwd,
selectWorkspace,
selectWorkspaceDirectory,
selectWorkspaceExists,
selectWorkspaceExecutionAuthority,
selectWorkspaceFields,
selectWorkspaceKeys,
selectWorkspaceOrderByScopeForServer,
@@ -20,7 +20,6 @@ import {
type WorkspaceStructure,
} from "./selectors";
import { useSessionStore, type WorkspaceDescriptor } from "../session-store";
import type { WorkspaceExecutionAuthorityResult } from "@/utils/workspace-execution";
import type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state";
// These are the ONLY supported ways to read workspaces from the session store.
@@ -72,14 +71,14 @@ export function useHasHydratedWorkspaces(serverId: string | null): boolean {
);
}
export function useWorkspaceExecutionAuthority(
export function useWorkspaceDirectory(
serverId: string | null,
workspaceId: string | null,
): WorkspaceExecutionAuthorityResult | null {
): string | null {
return useStoreWithEqualityFn(
useSessionStore,
(state) => selectWorkspaceExecutionAuthority(state, serverId, workspaceId),
workspaceEqualityFns.deep,
(state) => selectWorkspaceDirectory(state, serverId, workspaceId),
workspaceEqualityFns.identity,
);
}

View File

@@ -8,7 +8,7 @@ import {
selectRecommendedProjectPaths,
selectResolveWorkspaceIdByCwd,
selectWorkspace,
selectWorkspaceExecutionAuthority,
selectWorkspaceDirectory,
selectWorkspaceFields,
selectWorkspaceKeys,
selectWorkspaceOrderByScopeForServer,
@@ -142,34 +142,30 @@ describe("selectWorkspace", () => {
});
});
describe("selectWorkspaceExecutionAuthority", () => {
it("preserves the old missing-workspace message with the requested id", () => {
describe("selectWorkspaceDirectory", () => {
it("returns the workspace directory, never the opaque workspace id", () => {
const workspace = createWorkspace({
id: "wks_3f9a2b1c",
workspaceDirectory: "/Users/dev/project",
});
initializeWorkspaces([workspace]);
const directory = selectWorkspaceDirectory(
useSessionStore.getState(),
SERVER_ID,
"wks_3f9a2b1c",
);
expect(directory).toBe("/Users/dev/project");
expect(directory).not.toBe("wks_3f9a2b1c");
});
it("returns null when the workspace is missing", () => {
initializeWorkspaces([]);
expect(
selectWorkspaceExecutionAuthority(useSessionStore.getState(), SERVER_ID, "missing-id"),
).toMatchObject({
ok: false,
message: "Workspace not found: missing-id",
});
});
it("keeps deep-equal authority references under unrelated workspace updates", () => {
const workspaceA = createWorkspace({ id: "workspace-a", name: "A" });
const workspaceB = createWorkspace({ id: "workspace-b", name: "B" });
initializeWorkspaces([workspaceA, workspaceB]);
const tracked = trackSelector(
useSessionStore,
(state) => selectWorkspaceExecutionAuthority(state, SERVER_ID, workspaceA.id),
workspaceEqualityFns.deep,
);
const before = tracked.current;
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceB, status: "running" }]);
expect(tracked.current).toBe(before);
tracked.stop();
selectWorkspaceDirectory(useSessionStore.getState(), SERVER_ID, "missing-id"),
).toBeNull();
});
});

View File

@@ -6,11 +6,9 @@ import {
} from "@/projects/workspace-structure";
import type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state";
import {
getWorkspaceExecutionAuthority,
resolveWorkspaceIdByExecutionDirectory,
resolveWorkspaceIdByDirectory,
resolveWorkspaceMapKeyByIdentity,
type WorkspaceExecutionAuthorityResult,
} from "@/utils/workspace-execution";
} from "@/utils/workspace-identity";
import type { WorkspaceDescriptor } from "../session-store";
export type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state";
@@ -113,6 +111,14 @@ export function selectWorkspaceFields<T>(
return workspace ? project(workspace) : null;
}
export function selectWorkspaceDirectory(
state: SessionsSnapshot,
serverId: string | null,
workspaceId: string | null,
): string | null {
return selectWorkspace(state, serverId, workspaceId)?.workspaceDirectory || null;
}
export function selectWorkspaceExists(
state: SessionsSnapshot,
serverId: string | null,
@@ -128,20 +134,6 @@ export function selectHasHydratedWorkspaces(
return serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false;
}
export function selectWorkspaceExecutionAuthority(
state: SessionsSnapshot,
serverId: string | null,
workspaceId: string | null,
): WorkspaceExecutionAuthorityResult | null {
if (serverId === null || workspaceId === null) {
return null;
}
return getWorkspaceExecutionAuthority({
workspaces: state.sessions[serverId]?.workspaces,
workspaceId,
});
}
export function selectWorkspaceStructureProjects(
state: SessionsSnapshot,
serverId: string | null,
@@ -257,7 +249,7 @@ export function selectResolveWorkspaceIdByCwd(
return null;
}
const workspaces = state.sessions[serverId]?.workspaces;
return resolveWorkspaceIdByExecutionDirectory({
return resolveWorkspaceIdByDirectory({
workspaces: workspaces?.values(),
workspaceDirectory: cwd,
});

View File

@@ -99,6 +99,44 @@ describe("normalizeWorkspaceDescriptor", () => {
expect(workspace.scripts).not.toBe(scripts);
});
it("canonicalizes the workspace directory and treats a blank one as empty", () => {
const canonical = normalizeWorkspaceDescriptor({
id: "1",
projectId: "1",
projectDisplayName: "Project 1",
projectRootPath: "/repo",
workspaceDirectory: "/repo/app/",
projectKind: "git",
workspaceKind: "checkout",
name: "main",
archivingAt: null,
status: "done",
statusEnteredAt: null,
activityAt: null,
diffStat: null,
scripts: [],
});
expect(canonical.workspaceDirectory).toBe("/repo/app");
const blank = normalizeWorkspaceDescriptor({
id: "1",
projectId: "1",
projectDisplayName: "Project 1",
projectRootPath: "/repo",
workspaceDirectory: " ",
projectKind: "git",
workspaceKind: "checkout",
name: "main",
archivingAt: null,
status: "done",
statusEnteredAt: null,
activityAt: null,
diffStat: null,
scripts: [],
});
expect(blank.workspaceDirectory).toBe("");
});
it("defaults missing scripts to an empty array", () => {
const payload = {
id: "1",

View File

@@ -27,8 +27,11 @@ import type {
ServerCapabilities,
WorkspaceDescriptorPayload,
} from "@getpaseo/protocol/messages";
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-execution";
import {
normalizeWorkspaceOpaqueId,
normalizeWorkspacePath,
resolveWorkspaceMapKeyByIdentity,
} from "@/utils/workspace-identity";
import {
createAgentLastActivityCoalescer,
type AgentLastActivityCommitter,
@@ -149,7 +152,10 @@ export function normalizeWorkspaceDescriptor(
projectDisplayName: payload.projectDisplayName,
projectCustomName: payload.projectCustomName ?? null,
projectRootPath: payload.projectRootPath,
workspaceDirectory: payload.workspaceDirectory,
// Canonicalize the workspace directory once, at the store boundary, so every
// consumer can read workspace.workspaceDirectory directly. Empty means "no
// usable directory" (older daemons may omit it; the wire field is optional).
workspaceDirectory: normalizeWorkspacePath(payload.workspaceDirectory) ?? "",
projectKind: payload.projectKind,
workspaceKind: payload.workspaceKind,
name: payload.name,

View File

@@ -59,6 +59,7 @@ export function buildWorkspaceTabPersistenceKey(input: {
if (!serverId || !workspaceId) {
return null;
}
// workspaceId is opaque; do not parse this key back into a path.
return `${serverId}:${workspaceId}`;
}

View File

@@ -31,6 +31,7 @@ describe("workspace route parsing", () => {
it("keeps URL-safe workspace IDs unencoded", () => {
expect(encodeWorkspaceIdForPathSegment("164")).toBe("164");
expect(decodeWorkspaceIdFromPathSegment("164")).toBe("164");
expect(decodeWorkspaceIdFromPathSegment("wks_10b3479c955fcc4c")).toBe("wks_10b3479c955fcc4c");
});
it("encodes non-URL-safe workspace IDs as base64url", () => {

View File

@@ -100,6 +100,19 @@ function isLegacyPathLikeWorkspaceValue(value: string): boolean {
return value.includes("/") || value.includes("\\") || /^[A-Za-z]:[\\/]/.test(value);
}
function hasLegacyDecodeNoise(value: string): boolean {
for (const character of value) {
const codePoint = character.codePointAt(0);
if (codePoint == null) continue;
if (codePoint < 0x20 || codePoint === 0x7f || codePoint === 0xfffd) return true;
}
return false;
}
function isCleanLegacyPathDecode(value: string): boolean {
return isLegacyPathLikeWorkspaceValue(value) && !hasLegacyDecodeNoise(value);
}
export type WorkspaceOpenIntent =
| { kind: "agent"; agentId: string }
| { kind: "terminal"; terminalId: string }
@@ -193,13 +206,14 @@ export function decodeWorkspaceIdFromPathSegment(workspaceIdSegment: string): st
return prefixedDecoded ? normalizeWorkspaceId(prefixedDecoded) : null;
}
// COMPAT(legacyPathWorkspaceId): IDs were path-shaped before v0.1.95. Remove when deep-link floor >= v0.2.0.
const base64Decoded = tryDecodeBase64UrlNoPadUtf8(decoded);
if (base64Decoded && isLegacyPathLikeWorkspaceValue(base64Decoded)) {
if (base64Decoded && isCleanLegacyPathDecode(base64Decoded)) {
return normalizeWorkspaceId(base64Decoded);
}
const relaxedBase64Decoded = decodeBase64UrlNoPadUtf8(decoded);
if (relaxedBase64Decoded && isLegacyPathLikeWorkspaceValue(relaxedBase64Decoded)) {
if (relaxedBase64Decoded && isCleanLegacyPathDecode(relaxedBase64Decoded)) {
return normalizeWorkspaceId(relaxedBase64Decoded);
}

View File

@@ -1,6 +1,6 @@
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
import { resolveWorkspaceIdByDirectory } from "@/utils/workspace-identity";
import type { NavigateToPreparedWorkspaceTabInput } from "@/utils/prepare-workspace-tab";
export interface NavigateToAgentInput {
@@ -29,7 +29,7 @@ export function resolveNavigateToAgent(
serverId: input.serverId,
agentId: input.agentId,
});
const workspaceId = resolveWorkspaceIdByExecutionDirectory({
const workspaceId = resolveWorkspaceIdByDirectory({
workspaces,
workspaceDirectory: agentCwd,
});

View File

@@ -1,7 +1,7 @@
import type { Href } from "expo-router";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { buildHostNewWorkspaceRoute, buildHostRootRoute } from "@/utils/host-routes";
import { resolveWorkspaceRouteId } from "@/utils/workspace-execution";
import { resolveWorkspaceRouteId } from "@/utils/workspace-identity";
export function buildWorkspaceArchiveRedirectRoute(input: {
serverId: string;

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,10 @@
import { describe, expect, it } from "vitest";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import {
getWorkspaceExecutionAuthority,
requireWorkspaceExecutionAuthority,
resolveWorkspaceIdByDirectory,
resolveWorkspaceMapKeyByIdentity,
resolveWorkspaceIdByExecutionDirectory,
resolveWorkspaceRouteId,
} from "./workspace-execution";
} from "./workspace-identity";
function createWorkspace(
input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">,
@@ -40,7 +38,7 @@ describe("resolveWorkspaceRouteId", () => {
});
});
describe("resolveWorkspaceIdByExecutionDirectory", () => {
describe("resolveWorkspaceIdByDirectory", () => {
it("matches workspace directories", () => {
const workspaces = [
createWorkspace({
@@ -51,7 +49,7 @@ describe("resolveWorkspaceIdByExecutionDirectory", () => {
];
expect(
resolveWorkspaceIdByExecutionDirectory({
resolveWorkspaceIdByDirectory({
workspaces,
workspaceDirectory: "/repo/.paseo/worktrees/feature",
}),
@@ -68,7 +66,7 @@ describe("resolveWorkspaceIdByExecutionDirectory", () => {
];
expect(
resolveWorkspaceIdByExecutionDirectory({
resolveWorkspaceIdByDirectory({
workspaces,
workspaceDirectory: "/repo",
}),
@@ -115,66 +113,3 @@ describe("resolveWorkspaceMapKeyByIdentity", () => {
).toBeNull();
});
});
describe("workspace execution authority", () => {
it("returns an explicit failure when workspace id is missing", () => {
expect(
getWorkspaceExecutionAuthority({
workspaces: new Map(),
workspaceId: null,
}),
).toEqual({
ok: false,
reason: "workspace_id_missing",
message: "Workspace id is required.",
});
});
it("returns an explicit failure when workspace directory is missing", () => {
const workspaces = new Map<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"),
});
});
});

View File

@@ -1,3 +1,5 @@
import type { WorkspaceDescriptor } from "@/stores/session-store";
function trimNonEmpty(value: string | null | undefined): string | null {
if (typeof value !== "string") {
return null;
@@ -22,3 +24,56 @@ export function normalizeWorkspacePath(value: string | null | undefined): string
const withoutTrailingSlash = withUnixSeparators.replace(/\/+$/, "");
return withoutTrailingSlash.length > 0 ? withoutTrailingSlash : "/";
}
export function resolveWorkspaceRouteId(input: {
routeWorkspaceId: string | null | undefined;
}): string | null {
return normalizeWorkspaceOpaqueId(input.routeWorkspaceId);
}
// Single approved cwd→workspaceId inference site.
// Do not add cwd-to-id inference elsewhere; prefer agent.workspaceId when the daemon provides it.
export function resolveWorkspaceIdByDirectory(input: {
workspaces: Iterable<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;
}

View File

@@ -3,7 +3,7 @@ import type { WorkspaceTabSnapshot } from "@/stores/workspace-layout-actions";
import { shouldAutoOpenAgentTab } from "@/subagents/policies";
import { normalizeWorkspacePath } from "@/utils/workspace-identity";
function normalizeWorkspaceId(value: string | null | undefined): string {
function normalizeWorkspaceDirectory(value: string | null | undefined): string {
return normalizeWorkspacePath(value) ?? "";
}
@@ -19,7 +19,7 @@ export function deriveWorkspaceAgentVisibility(input: {
workspaceDirectory: string | null | undefined;
}): WorkspaceAgentVisibility {
const { sessionAgents, agentDetails, workspaceDirectory } = input;
const normalizedWorkspaceDirectory = normalizeWorkspaceId(workspaceDirectory);
const normalizedWorkspaceDirectory = normalizeWorkspaceDirectory(workspaceDirectory);
if ((!sessionAgents && !agentDetails) || !normalizedWorkspaceDirectory) {
return {
activeAgentIds: new Set<string>(),
@@ -32,7 +32,7 @@ export function deriveWorkspaceAgentVisibility(input: {
const autoOpenAgentIds = new Set<string>();
const knownAgentIds = new Set<string>();
for (const agent of sessionAgents?.values() ?? []) {
if (normalizeWorkspaceId(agent.cwd) !== normalizedWorkspaceDirectory) {
if (normalizeWorkspaceDirectory(agent.cwd) !== normalizedWorkspaceDirectory) {
continue;
}
knownAgentIds.add(agent.id);
@@ -44,7 +44,7 @@ export function deriveWorkspaceAgentVisibility(input: {
}
}
for (const agent of agentDetails?.values() ?? []) {
if (normalizeWorkspaceId(agent.cwd) !== normalizedWorkspaceDirectory) {
if (normalizeWorkspaceDirectory(agent.cwd) !== normalizedWorkspaceDirectory) {
continue;
}
knownAgentIds.add(agent.id);

View File

@@ -3,7 +3,7 @@ import {
markWorkspaceArchivePending,
} from "@/contexts/session-workspace-upserts";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-execution";
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
export interface WorkspaceArchiveTarget {
serverId: string;

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 {
type: "single",

View File

@@ -15,7 +15,6 @@ export async function subscribeToEvents(
await client.connect();
await client.workspaces.list({
filter: { idPrefix: workspaceId },
subscribe: { subscriptionId: `workspace-${workspaceId}` },
});

View File

@@ -241,7 +241,6 @@ test("workspace handles keep identity and refresh snapshots through existing dri
expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({
type: "fetch_workspaces_request",
requestId: "workspace-refetch-request",
filter: { idPrefix: "workspace_sdk" },
page: { limit: 25 },
});

View File

@@ -414,9 +414,11 @@ function createWorkspaceHandleFactory(daemonClient: DaemonClient): WorkspaceHand
id,
latest: () => latest,
refetch: async (options) => {
// Best-effort: fetches one page and matches by id client-side, so a workspace beyond
// the first page won't be found. TODO: add a "get workspace by id" lookup and resolve
// by exact id instead of paging.
const result = await daemonClient.fetchWorkspaces({
requestId: options?.requestId,
filter: { idPrefix: id },
page: { limit: 25 },
});
latest = result.entries.find((entry) => entry.id === id) ?? null;

View File

@@ -948,6 +948,7 @@ export const FetchWorkspacesRequestMessageSchema = z.object({
.object({
query: z.string().optional(),
projectId: z.string().optional(),
// Unused: accepted so older clients still parse, but the server does not filter on it.
idPrefix: z.string().optional(),
})
.optional(),
@@ -1627,6 +1628,7 @@ export const LegacyOpenInEditorRequestSchema = z.object({
export const OpenProjectRequestSchema = z.object({
type: z.literal("open_project_request"),
// Path used only for workspace lookup/creation. Use the returned workspace.id for all subsequent references.
cwd: z.string(),
requestId: z.string(),
});

View File

@@ -849,7 +849,6 @@ export class AgentManager {
agentId?: string,
options?: {
labels?: Record<string, string>;
workspaceId?: string;
initialPrompt?: string;
env?: Record<string, string>;
persistSession?: boolean;
@@ -867,7 +866,6 @@ export class AgentManager {
const session = await client.createSession(launchConfig, launchContext, createOptions);
return this.registerSession(session, storedConfig, resolvedAgentId, {
labels: options?.labels,
workspaceId: options?.workspaceId,
initialTitle: options?.initialTitle,
});
}
@@ -2321,7 +2319,6 @@ export class AgentManager {
config: AgentSessionConfig,
agentId: string,
options?: {
workspaceId?: string;
createdAt?: Date;
updatedAt?: Date;
lastUserMessageAt?: Date | null;
@@ -2369,7 +2366,6 @@ export class AgentManager {
this.previousStatuses.set(resolvedAgentId, managed.lifecycle);
await this.refreshRuntimeInfo(managed, { emit: !options?.publishWhenReady });
await this.persistSnapshot(managed, {
workspaceId: options?.workspaceId,
title: initialPersistedTitle,
});
if (!options?.publishWhenReady) {
@@ -2378,7 +2374,7 @@ export class AgentManager {
await this.refreshSessionState(managed, { emit: !options?.publishWhenReady });
managed.lifecycle = "idle";
await this.persistSnapshot(managed, { workspaceId: options?.workspaceId });
await this.persistSnapshot(managed);
this.emitState(managed, { persist: false });
this.subscribeToSession(managed);
return { ...managed };
@@ -2639,7 +2635,7 @@ export class AgentManager {
private async persistSnapshot(
agent: ManagedAgent,
options?: { workspaceId?: string; title?: string | null; internal?: boolean },
options?: { title?: string | null; internal?: boolean },
): Promise<void> {
if (!this.registry) {
return;
@@ -2648,10 +2644,6 @@ export class AgentManager {
if (agent.internal) {
return;
}
if (options?.workspaceId !== undefined) {
await this.registry.applySnapshot(agent, options.workspaceId, options);
return;
}
await this.registry.applySnapshot(agent, options);
}

View File

@@ -191,23 +191,19 @@ export class AgentStorage {
async applySnapshot(
agent: ManagedAgent,
workspaceIdOrOptions?: string | { title?: string | null; internal?: boolean },
options?: { title?: string | null; internal?: boolean },
): Promise<void> {
const nextOptions = typeof workspaceIdOrOptions === "string" ? options : workspaceIdOrOptions;
await this.load();
await this.waitForPendingWrite(agent.id);
const existing = (await this.get(agent.id)) ?? null;
const hasTitleOverride =
nextOptions !== undefined && Object.prototype.hasOwnProperty.call(nextOptions, "title");
options !== undefined && Object.prototype.hasOwnProperty.call(options, "title");
const hasInternalOverride =
nextOptions !== undefined && Object.prototype.hasOwnProperty.call(nextOptions, "internal");
options !== undefined && Object.prototype.hasOwnProperty.call(options, "internal");
const record = toStoredAgentRecord(agent, {
title: hasTitleOverride ? (nextOptions?.title ?? null) : (existing?.title ?? null),
title: hasTitleOverride ? (options?.title ?? null) : (existing?.title ?? null),
createdAt: existing?.createdAt,
internal: hasInternalOverride
? nextOptions?.internal
: (agent.internal ?? existing?.internal),
internal: hasInternalOverride ? options?.internal : (agent.internal ?? existing?.internal),
});
// Preserve soft-delete/archive status across snapshot flushes.

View File

@@ -26,6 +26,7 @@ interface CreateAgentLifecycleDispatchDependencies {
workspaceGitService: WorkspaceGitService;
createPaseoWorktreeWorkflow: CreatePaseoWorktreeWorkflowFn;
archiveAgentForClose: (agentId: string) => Promise<unknown>;
resolveWorkspaceIdForCwd: (cwd: string) => Promise<string | null>;
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
emit: (message: SessionOutboundMessage) => void;
emitAgentRemove: (agentId: string) => void;
@@ -207,6 +208,7 @@ export class CreateAgentLifecycleDispatch {
workspaceGitService: this.dependencies.workspaceGitService,
agentManager: this.dependencies.agentManager,
agentStorage: this.dependencies.agentStorage,
resolveWorkspaceIdForCwd: this.dependencies.resolveWorkspaceIdForCwd,
archiveWorkspaceRecord: this.dependencies.archiveWorkspaceRecord,
emitWorkspaceUpdatesForWorkspaceIds: this.dependencies.emitWorkspaceUpdatesForWorkspaceIds,
markWorkspaceArchiving: this.dependencies.markWorkspaceArchiving,

View File

@@ -38,7 +38,6 @@ test("session create forwards clientMessageId to the initial prompt run options"
explicitTitle: "Explicit title",
firstAgentContext: { attachments: [] },
buildSessionConfig: async (config) => ({ sessionConfig: config }),
resolveWorkspace: async () => ({ workspaceId: "workspace-1" }),
});
expect(streamAgent).toHaveBeenCalledWith("agent-1", "hello from create", {

View File

@@ -32,10 +32,6 @@ import {
emitLiveTimelineItemIfAgentKnown,
} from "../timeline-append.js";
export interface CreateAgentWorkspace {
workspaceId: string;
}
export interface CreateAgentSessionWorktreeResult {
sessionConfig: AgentSessionConfig;
setupContinuation?: AgentWorktreeSetupContinuation;
@@ -79,7 +75,6 @@ export interface CreateAgentFromSessionInput {
legacyWorktreeName?: string,
firstAgentContext?: FirstAgentContext,
) => Promise<CreateAgentSessionWorktreeResult>;
resolveWorkspace: (input: { cwd: string; workspaceId?: string }) => Promise<CreateAgentWorkspace>;
}
export interface CreateAgentFromMcpInput {
@@ -134,7 +129,6 @@ interface ResolvedCreateAgent {
interface AgentCreateOptions {
labels?: Record<string, string>;
workspaceId?: string;
initialPrompt?: string;
env?: Record<string, string>;
initialTitle?: string | null;
@@ -196,10 +190,6 @@ async function resolveSessionCreateAgent(
input.worktreeName,
input.firstAgentContext,
);
const workspace = await input.resolveWorkspace({
cwd: sessionConfig.cwd,
workspaceId: input.workspaceId,
});
const prompt = buildAgentPrompt(trimmedPrompt ?? "", input.images, input.attachments);
const hasPromptContent = Array.isArray(prompt) ? prompt.length > 0 : prompt.length > 0;
const clientMessageId = normalizeClientMessageId(input.clientMessageId);
@@ -215,7 +205,6 @@ async function resolveSessionCreateAgent(
config: sessionConfig,
createOptions: {
labels: input.labels,
workspaceId: workspace.workspaceId,
initialPrompt: trimmedPrompt,
env: input.env,
initialTitle: input.provisionalTitle,

View File

@@ -96,15 +96,16 @@ export async function archiveAgentCommand(
agentId: string,
): Promise<ArchiveAgentResult> {
const liveAgent = dependencies.agentManager.getAgent(agentId);
let record: StoredAgentRecord | null;
if (liveAgent) {
await cancelAgentRunCommand(dependencies, agentId);
await dependencies.agentManager.clearAgentAttention(agentId).catch(() => undefined);
await dependencies.agentManager.archiveAgent(agentId);
record = await dependencies.agentStorage.get(agentId);
} else {
await archiveStoredAgent(dependencies, agentId);
record = await archiveStoredAgent(dependencies, agentId);
}
const record = await dependencies.agentStorage.get(agentId);
if (!record) {
throw new Error(`Agent not found in storage after archive: ${agentId}`);
}
@@ -174,16 +175,16 @@ export async function setAgentModeCommand(
async function archiveStoredAgent(
dependencies: Pick<AgentLifecycleCommandDependencies, "agentManager" | "agentStorage">,
agentId: string,
): Promise<void> {
): Promise<StoredAgentRecord> {
const existing = await dependencies.agentStorage.get(agentId);
if (!existing) {
throw new Error(`Agent not found: ${agentId}`);
}
if (existing.archivedAt) {
return;
return existing;
}
const archivedAt = new Date().toISOString();
await dependencies.agentManager.archiveSnapshot(agentId, archivedAt);
return dependencies.agentManager.archiveSnapshot(agentId, archivedAt);
}

View File

@@ -1258,7 +1258,7 @@ describe("create_agent MCP tool", () => {
baseRefName: "main",
},
workspace: {
workspaceId: "/tmp/worktrees/pr-123",
workspaceId: "ws-pr-123",
projectId: REPO_CWD,
cwd: "/tmp/worktrees/pr-123",
kind: "worktree" as const,
@@ -1395,7 +1395,7 @@ describe("create_agent MCP tool", () => {
});
expect(setupContinuations).toEqual([undefined]);
expect(broadcasts).toHaveLength(1);
expect(broadcasts[0]).toContain("tool-worktree");
expect(broadcasts[0]).toMatch(/^wks_[0-9a-f]{16}$/);
} finally {
await rm(tempDir, { recursive: true, force: true });
}
@@ -1444,6 +1444,7 @@ describe("create_agent MCP tool", () => {
WorkspaceGitService,
"getSnapshot" | "listWorktrees" | "resolveRepoRoot"
>,
resolveWorkspaceIdForCwd: vi.fn(async () => "ws-archive-tool-worktree"),
archiveWorkspaceRecord,
emitWorkspaceUpdatesForWorkspaceIds,
markWorkspaceArchiving,
@@ -1473,16 +1474,14 @@ describe("create_agent MCP tool", () => {
force: true,
reason: "mcp:archive-worktree",
});
expect(archiveWorkspaceRecord).toHaveBeenCalledWith(created.structuredContent.worktreePath);
expect(archiveWorkspaceRecord).toHaveBeenCalledWith("ws-archive-tool-worktree");
expect(markWorkspaceArchiving).toHaveBeenCalledWith(
[created.structuredContent.worktreePath],
["ws-archive-tool-worktree"],
expect.any(String),
);
expect(clearWorkspaceArchiving).toHaveBeenCalledWith([
created.structuredContent.worktreePath,
]);
expect(clearWorkspaceArchiving).toHaveBeenCalledWith(["ws-archive-tool-worktree"]);
expect(Array.from(emitWorkspaceUpdatesForWorkspaceIds.mock.calls[0]?.[0] ?? [])).toEqual([
created.structuredContent.worktreePath,
"ws-archive-tool-worktree",
]);
} finally {
await rm(tempDir, { recursive: true, force: true });
@@ -1528,6 +1527,7 @@ describe("create_agent MCP tool", () => {
WorkspaceGitService,
"getSnapshot" | "listWorktrees" | "resolveRepoRoot"
>,
resolveWorkspaceIdForCwd: vi.fn(async () => "ws-archive-mcp"),
archiveWorkspaceRecord: vi.fn(async () => undefined),
emitWorkspaceUpdatesForWorkspaceIds: vi.fn(async () => undefined),
markWorkspaceArchiving: vi.fn(),

View File

@@ -93,6 +93,7 @@ export interface AgentMcpServerOptions {
WorkspaceGitService,
"getSnapshot" | "listWorktrees" | "resolveRepoRoot"
>;
resolveWorkspaceIdForCwd?: ArchivePaseoWorktreeDependencies["resolveWorkspaceIdForCwd"];
archiveWorkspaceRecord?: ArchivePaseoWorktreeDependencies["archiveWorkspaceRecord"];
emitWorkspaceUpdatesForWorkspaceIds?: ArchivePaseoWorktreeDependencies["emitWorkspaceUpdatesForWorkspaceIds"];
markWorkspaceArchiving?: ArchivePaseoWorktreeDependencies["markWorkspaceArchiving"];
@@ -2406,6 +2407,9 @@ function archiveWorktreeDependencies(
if (!options.archiveWorkspaceRecord) {
throw new Error("Workspace registry archiver is required to archive worktrees");
}
if (!options.resolveWorkspaceIdForCwd) {
throw new Error("Workspace resolver is required to archive worktrees");
}
if (!options.emitWorkspaceUpdatesForWorkspaceIds) {
throw new Error("Workspace update emitter is required to archive worktrees");
}
@@ -2422,6 +2426,7 @@ function archiveWorktreeDependencies(
workspaceGitService: options.workspaceGitService,
agentManager: context.agentManager,
agentStorage: context.agentStorage,
resolveWorkspaceIdForCwd: options.resolveWorkspaceIdForCwd,
archiveWorkspaceRecord: options.archiveWorkspaceRecord,
emitWorkspaceUpdatesForWorkspaceIds: options.emitWorkspaceUpdatesForWorkspaceIds,
markWorkspaceArchiving: options.markWorkspaceArchiving,

View File

@@ -93,6 +93,7 @@ function createHarness(overrides?: {
agentManager: {} as AutoArchiveArchiveOptions["agentManager"],
agentStorage: {} as AutoArchiveArchiveOptions["agentStorage"],
terminalManager: {} as AutoArchiveArchiveOptions["terminalManager"],
resolveWorkspaceIdForCwd: vi.fn(async () => "ws-auto-archive"),
archiveWorkspaceRecord: vi.fn(),
markWorkspaceArchiving: vi.fn(),
clearWorkspaceArchiving: vi.fn(),

View File

@@ -22,6 +22,7 @@ export interface AutoArchiveArchiveOptions {
agentManager: AgentManager;
agentStorage: AgentStorage;
terminalManager: TerminalManager;
resolveWorkspaceIdForCwd: (cwd: string) => Promise<string | null>;
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
markWorkspaceArchiving: (workspaceIds: Iterable<string>, archivingAt: string) => void;
clearWorkspaceArchiving: (workspaceIds: Iterable<string>) => void;
@@ -102,6 +103,7 @@ export async function archiveIfSafe(input: {
workspaceGitService: options.workspaceGitService,
agentManager: options.agentManager,
agentStorage: options.agentStorage,
resolveWorkspaceIdForCwd: options.resolveWorkspaceIdForCwd,
archiveWorkspaceRecord: options.archiveWorkspaceRecord,
emitWorkspaceUpdatesForWorkspaceIds: options.emitWorkspaceUpdatesForWorkspaceIds,
markWorkspaceArchiving: options.markWorkspaceArchiving,

View File

@@ -109,6 +109,7 @@ import { LoopService } from "./loop-service.js";
import { ScheduleService } from "./schedule/service.js";
import { DaemonConfigStore } from "./daemon-config-store.js";
import { WorkspaceGitServiceImpl } from "./workspace-git-service.js";
import { resolveRegisteredWorkspaceIdForCwd } from "./workspace-directory.js";
import { archivePersistedWorkspaceRecord } from "./workspace-archive-service.js";
import { setupAutoArchiveOnMerge } from "./auto-archive-on-merge/index.js";
import { wrapSessionMessage, type SessionOutboundMessage } from "./messages.js";
@@ -665,6 +666,9 @@ export async function createPaseoDaemon(
projectRegistry,
});
};
const resolveWorkspaceIdForCwdExternal = async (cwd: string): Promise<string | null> => {
return resolveRegisteredWorkspaceIdForCwd(cwd, await workspaceRegistry.list());
};
const markWorkspaceArchivingExternal = (workspaceIds: Iterable<string>, archivingAt: string) => {
const workspaceIdList = Array.from(workspaceIds);
for (const session of wsServer?.listActiveSessions() ?? []) {
@@ -699,6 +703,7 @@ export async function createPaseoDaemon(
agentStorage,
terminalManager,
logger,
resolveWorkspaceIdForCwd: resolveWorkspaceIdForCwdExternal,
archiveWorkspaceRecord: archiveWorkspaceRecordExternal,
markWorkspaceArchiving: markWorkspaceArchivingExternal,
clearWorkspaceArchiving: clearWorkspaceArchivingExternal,
@@ -721,6 +726,7 @@ export async function createPaseoDaemon(
providerSnapshotManager,
github,
workspaceGitService,
resolveWorkspaceIdForCwd: resolveWorkspaceIdForCwdExternal,
archiveWorkspaceRecord: archiveWorkspaceRecordExternal,
emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal,
markWorkspaceArchiving: markWorkspaceArchivingExternal,

View File

@@ -3,7 +3,6 @@ import type { Logger } from "pino";
import type { AgentManager } from "./agent/agent-manager.js";
import type { AgentStorage, StoredAgentRecord } from "./agent/agent-storage.js";
import type { WorkspaceGitService } from "./workspace-git-service.js";
import { normalizeWorkspaceId as normalizePersistedWorkspaceId } from "./workspace-registry-model.js";
import type { GitHubService } from "../services/github-service.js";
import {
deletePaseoWorktree,
@@ -19,6 +18,7 @@ export interface ArchivePaseoWorktreeDependencies {
workspaceGitService: Pick<WorkspaceGitService, "getSnapshot">;
agentManager: Pick<AgentManager, "listAgents" | "archiveAgent" | "archiveSnapshot">;
agentStorage: Pick<AgentStorage, "list">;
resolveWorkspaceIdForCwd: (cwd: string) => Promise<string | null>;
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds: Iterable<string>) => Promise<void>;
markWorkspaceArchiving: (workspaceIds: Iterable<string>, archivingAt: string) => void;
@@ -57,7 +57,6 @@ export async function archivePaseoWorktree(
const archivedAgents = new Set<string>();
const affectedWorkspaceCwds = new Set<string>([targetPath]);
const affectedWorkspaceIds = new Set<string>([normalizePersistedWorkspaceId(targetPath)]);
const liveAgents = dependencies.agentManager
.listAgents()
@@ -65,7 +64,6 @@ export async function archivePaseoWorktree(
for (const agent of liveAgents) {
archivedAgents.add(agent.id);
affectedWorkspaceCwds.add(agent.cwd);
affectedWorkspaceIds.add(normalizePersistedWorkspaceId(agent.cwd));
}
let storedRecords: StoredAgentRecord[] = [];
@@ -84,10 +82,12 @@ export async function archivePaseoWorktree(
for (const record of matchingStoredRecords) {
archivedAgents.add(record.id);
affectedWorkspaceCwds.add(record.cwd);
affectedWorkspaceIds.add(normalizePersistedWorkspaceId(record.cwd));
}
const affectedWorkspaceIdList = Array.from(affectedWorkspaceIds);
const affectedWorkspaceIdList = await resolveAffectedWorkspaceIds(
dependencies,
affectedWorkspaceCwds,
);
dependencies.markWorkspaceArchiving(affectedWorkspaceIdList, new Date().toISOString());
try {
@@ -176,6 +176,28 @@ export async function archivePaseoWorktree(
return Array.from(archivedAgents);
}
async function resolveAffectedWorkspaceIds(
dependencies: Pick<
ArchivePaseoWorktreeDependencies,
"resolveWorkspaceIdForCwd" | "sessionLogger"
>,
cwds: Iterable<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(
dependencies: KillTerminalsUnderPathDependencies,
rootPath: string,

View File

@@ -34,7 +34,7 @@ test("creates a worktree and registers it in the source workspace project withou
displayName: "acme/repo",
});
const sourceWorkspace = createPersistedWorkspaceRecordForTest({
workspaceId: repoDir,
workspaceId: "ws-main-checkout",
projectId: sourceProject.projectId,
cwd: repoDir,
kind: "local_checkout",
@@ -57,6 +57,7 @@ test("creates a worktree and registers it in the source workspace project withou
expect(result.created).toBe(true);
expect(result.workspace.cwd).toBe(result.worktree.worktreePath);
expect(result.workspace.kind).toBe("worktree");
expect(result.workspace.workspaceId).toMatch(/^wks_[0-9a-f]{16}$/);
expect(result.workspace.projectId).toBe("remote:github.com/acme/repo");
expect(result.workspace.displayName).toBe("feature-one");
expect(deps.workspaceGitService.getSnapshot).not.toHaveBeenCalled();
@@ -76,7 +77,7 @@ test("registers a new worktree in the existing root project after the main check
displayName: "acme/repo",
});
const existingWorktree = createPersistedWorkspaceRecordForTest({
workspaceId: path.join(tempDir, "existing-worktree"),
workspaceId: "ws-existing-worktree",
projectId: sourceProject.projectId,
cwd: path.join(tempDir, "existing-worktree"),
kind: "worktree",

View File

@@ -1,4 +1,5 @@
import type { WorkspaceGitService } from "./workspace-git-service.js";
import { resolve } from "node:path";
import {
type PersistedWorkspaceRecord,
type ProjectRegistry,
@@ -6,7 +7,7 @@ import {
createPersistedProjectRecord,
createPersistedWorkspaceRecord,
} from "./workspace-registry.js";
import { deriveProjectGroupingName, normalizeWorkspaceId } from "./workspace-registry-model.js";
import { deriveProjectGroupingName, generateWorkspaceId } from "./workspace-registry-model.js";
import {
createWorktreeCore,
type CreateWorktreeCoreDeps,
@@ -195,11 +196,14 @@ async function upsertWorkspaceForWorktree(options: {
projectId?: string;
repoRoot: string;
worktree: WorktreeConfig;
deps: Pick<CreatePaseoWorktreeDeps, "projectRegistry" | "workspaceRegistry">;
deps: Pick<
CreatePaseoWorktreeDeps,
"projectRegistry" | "workspaceRegistry" | "workspaceGitService"
>;
}): Promise<PersistedWorkspaceRecord> {
const normalizedCwd = normalizeWorkspaceId(options.worktree.worktreePath);
const normalizedInputCwd = normalizeWorkspaceId(options.inputCwd);
const normalizedRepoRoot = normalizeWorkspaceId(options.repoRoot);
const normalizedCwd = resolve(options.worktree.worktreePath);
const normalizedInputCwd = resolve(options.inputCwd);
const normalizedRepoRoot = resolve(options.repoRoot);
const existingWorkspace = await findWorkspaceByDirectory(
normalizedCwd,
options.deps.workspaceRegistry,
@@ -211,7 +215,7 @@ async function upsertWorkspaceForWorktree(options: {
existingWorkspace,
deps: options.deps,
});
const workspaceId = normalizedCwd;
const workspaceId = existingWorkspace?.workspaceId ?? generateWorkspaceId();
const now = new Date().toISOString();
await options.deps.projectRegistry.upsert(

View File

@@ -3,7 +3,7 @@ import { v4 as uuidv4 } from "uuid";
import { realpathSync } from "node:fs";
import type { FSWatcher } from "node:fs";
import { stat } from "node:fs/promises";
import { basename, resolve, sep } from "path";
import { resolve, sep } from "path";
import { homedir } from "node:os";
import { z } from "zod";
import type { ToolSet } from "ai";
@@ -154,10 +154,10 @@ import {
} from "./agent/import-sessions.js";
import {
checkoutLiteFromGitSnapshot,
normalizeWorkspaceId as normalizePersistedWorkspaceId,
deriveProjectGroupingName,
classifyDirectoryForProjectMembership,
deriveProjectGroupingName,
deriveWorkspaceDisplayName,
generateWorkspaceId,
} from "./workspace-registry-model.js";
import {
createPersistedProjectRecord,
@@ -962,6 +962,7 @@ export class Session {
createPaseoWorktreeWorkflow: (input, workflowOptions) =>
this.createPaseoWorktreeWorkflow(input, workflowOptions),
archiveAgentForClose: (agentId) => this.archiveAgentForClose(agentId),
resolveWorkspaceIdForCwd: (cwd) => this.resolveWorkspaceIdForCwd(cwd),
archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId),
emit: (message) => this.emit(message),
emitAgentRemove: (agentId) => {
@@ -1607,6 +1608,9 @@ export class Session {
const normalizedCwd = await this.resolveWorkspaceDirectory(cwd, options);
const workspaces = await this.workspaceRegistry.list();
const workspaceId = this.resolveRegisteredWorkspaceIdForCwd(normalizedCwd, workspaces);
if (!workspaceId) {
return null;
}
return workspaces.find((workspace) => workspace.workspaceId === workspaceId) ?? null;
}
@@ -1623,14 +1627,14 @@ export class Session {
cwd: string,
options?: { refreshGit?: boolean },
): Promise<string> {
const normalizedCwd = normalizePersistedWorkspaceId(cwd);
const normalizedCwd = resolve(cwd);
if (options?.refreshGit === false) {
const snapshot = this.workspaceGitService.peekSnapshot(normalizedCwd);
return normalizePersistedWorkspaceId(snapshot?.git.repoRoot ?? normalizedCwd);
return resolve(snapshot?.git.repoRoot ?? normalizedCwd);
}
const checkout = await this.workspaceGitService.getCheckout(normalizedCwd);
return normalizePersistedWorkspaceId(checkout.worktreeRoot ?? normalizedCwd);
return resolve(checkout.worktreeRoot ?? normalizedCwd);
}
private async buildProjectPlacementForWorkspace(
@@ -1661,12 +1665,16 @@ export class Session {
return null;
}
const normalizedCwd = normalizePersistedWorkspaceId(cwd);
// An agent can run in a directory that has no registered workspace yet
// (e.g. a fresh non-git folder). Synthesize a directory-scoped placement so
// the agent still emits updates. projectKey/cwd are paths here — a project
// grouping key, not a workspace id — so this stays opaque-id-safe.
const resolvedCwd = resolve(cwd);
return {
projectKey: normalizedCwd,
projectName: deriveProjectGroupingName(normalizedCwd),
projectKey: resolvedCwd,
projectName: deriveProjectGroupingName(resolvedCwd),
checkout: {
cwd: normalizedCwd,
cwd: resolvedCwd,
isGit: false,
currentBranch: null,
remoteUrl: null,
@@ -3169,8 +3177,6 @@ export class Session {
firstAgentContext,
buildSessionConfig: (sessionConfig, gitOptions, legacyWorktreeName, ctx) =>
this.buildAgentSessionConfig(sessionConfig, gitOptions, legacyWorktreeName, ctx),
resolveWorkspace: ({ cwd, workspaceId }) =>
this.resolveCreateAgentWorkspace(cwd, workspaceId),
},
);
createdAgentId = snapshot.id;
@@ -3228,20 +3234,6 @@ export class Session {
}
}
private async resolveCreateAgentWorkspace(
cwd: string,
workspaceId?: string,
): Promise<{ workspaceId: string }> {
const resolvedWorkspace = workspaceId
? await this.workspaceRegistry.get(workspaceId)
: ((await this.findWorkspaceByDirectory(cwd)) ??
(await this.findOrCreateWorkspaceForDirectory(cwd)));
if (!resolvedWorkspace) {
throw new Error(`Workspace not found: ${workspaceId}`);
}
return { workspaceId: resolvedWorkspace.workspaceId };
}
private async handleResumeAgentRequest(
msg: Extract<SessionInboundMessage, { type: "resume_agent_request" }>,
): Promise<void> {
@@ -4845,7 +4837,7 @@ export class Session {
}
private async removeWorkspaceGitWatchTarget(cwd: string): Promise<void> {
const normalizedCwd = normalizePersistedWorkspaceId(cwd);
const normalizedCwd = resolve(cwd);
const target = this.workspaceGitWatchTargets.get(normalizedCwd);
if (target) {
this.closeWorkspaceGitWatchTarget(target);
@@ -4854,7 +4846,7 @@ export class Session {
}
private removeWorkspaceGitSubscription(cwd: string): void {
const normalizedCwd = normalizePersistedWorkspaceId(cwd);
const normalizedCwd = resolve(cwd);
const target = this.workspaceGitWatchTargets.get(normalizedCwd);
if (target) {
const unsubscribeFetch = this.workspaceGitFetchSubscriptions.get(normalizedCwd);
@@ -4877,11 +4869,20 @@ export class Session {
]);
}
private resolveWorkspaceGitWatchTarget(workspaceId: string): WorkspaceGitWatchTarget | null {
for (const target of this.workspaceGitWatchTargets.values()) {
if (target.workspaceId === workspaceId) {
return target;
}
}
return null;
}
private shouldSkipWorkspaceGitWatchUpdate(
workspaceId: string,
workspace: WorkspaceDescriptorPayload | null,
): boolean {
const target = this.workspaceGitWatchTargets.get(workspaceId);
const target = this.resolveWorkspaceGitWatchTarget(workspaceId);
if (!target) {
return false;
}
@@ -4897,7 +4898,7 @@ export class Session {
workspaceId: string,
workspace: WorkspaceDescriptorPayload | null,
): void {
const target = this.workspaceGitWatchTargets.get(workspaceId);
const target = this.resolveWorkspaceGitWatchTarget(workspaceId);
if (!target) {
return;
}
@@ -4906,7 +4907,7 @@ export class Session {
}
private handleWorkspaceGitBranchSnapshot(cwd: string, branchName: string | null): void {
const target = this.workspaceGitWatchTargets.get(normalizePersistedWorkspaceId(cwd));
const target = this.workspaceGitWatchTargets.get(resolve(cwd));
if (!target) {
return;
}
@@ -4934,7 +4935,7 @@ export class Session {
cwd: string,
options: { isGit: boolean; workspaceId: string },
): void {
const normalizedCwd = normalizePersistedWorkspaceId(cwd);
const normalizedCwd = resolve(cwd);
if (!options.isGit) {
this.removeWorkspaceGitSubscription(normalizedCwd);
return;
@@ -5792,6 +5793,7 @@ export class Session {
workspaceGitService: this.workspaceGitService,
agentManager: this.agentManager,
agentStorage: this.agentStorage,
resolveWorkspaceIdForCwd: (cwd) => this.resolveWorkspaceIdForCwd(cwd),
archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId),
emit: (message) => this.emit(message),
emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds) =>
@@ -6179,7 +6181,7 @@ export class Session {
),
);
for (let i = 0; i < pairs.length; i += 1) {
placementsByCwd.set(normalizePersistedWorkspaceId(pairs[i].workspace.cwd), placements[i]);
placementsByCwd.set(resolve(pairs[i].workspace.cwd), placements[i]);
}
return placementsByCwd;
@@ -6248,17 +6250,14 @@ export class Session {
scope === "active" ? await this.buildActiveProjectPlacementsByWorkspaceCwd() : null;
if (activePlacementsByCwd) {
agents = agents.filter(
(agent) =>
!agent.archivedAt && activePlacementsByCwd.has(normalizePersistedWorkspaceId(agent.cwd)),
(agent) => !agent.archivedAt && activePlacementsByCwd.has(resolve(agent.cwd)),
);
}
const placementByCwd = new Map<string, Promise<ProjectPlacementPayload | null>>();
const getPlacement = (cwd: string): Promise<ProjectPlacementPayload | null> => {
if (activePlacementsByCwd) {
return Promise.resolve(
activePlacementsByCwd.get(normalizePersistedWorkspaceId(cwd)) ?? null,
);
return Promise.resolve(activePlacementsByCwd.get(resolve(cwd)) ?? null);
}
const existing = placementByCwd.get(cwd);
if (existing) {
@@ -6509,10 +6508,15 @@ export class Session {
private resolveRegisteredWorkspaceIdForCwd(
cwd: string,
workspaces: PersistedWorkspaceRecord[],
): string {
): string | null {
return this.workspaceDirectory.resolveRegisteredWorkspaceIdForCwd(cwd, workspaces);
}
private async resolveWorkspaceIdForCwd(cwd: string): Promise<string | null> {
const workspaces = await this.workspaceRegistry.list();
return this.resolveRegisteredWorkspaceIdForCwd(cwd, workspaces);
}
private matchesWorkspaceFilter(input: {
workspace: WorkspaceDescriptorPayload;
filter: FetchWorkspacesRequestFilter | undefined;
@@ -6600,7 +6604,7 @@ export class Session {
}
private async findOrCreateWorkspaceForDirectory(cwd: string): Promise<PersistedWorkspaceRecord> {
const inputCwd = normalizePersistedWorkspaceId(cwd);
const inputCwd = resolve(cwd);
const normalizedCwd = await this.resolveWorkspaceDirectory(cwd);
const existingWorkspace = await this.findExactWorkspaceByDirectory(normalizedCwd, {
refreshGit: false,
@@ -6608,22 +6612,26 @@ export class Session {
if (existingWorkspace) {
if (existingWorkspace.archivedAt && inputCwd !== normalizedCwd) {
const timestamp = new Date().toISOString();
const displayName = basename(inputCwd) || inputCwd;
const projectRecord = createPersistedProjectRecord({
projectId: inputCwd,
rootPath: inputCwd,
kind: "non_git",
displayName,
createdAt: timestamp,
updatedAt: timestamp,
const checkout = checkoutLiteFromGitSnapshot(inputCwd, {
isGit: false,
currentBranch: null,
remoteUrl: null,
repoRoot: null,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
});
const membership = classifyDirectoryForProjectMembership({ cwd: inputCwd, checkout });
const projectRecord = await this.resolveProjectRecordForPlacement({
membership,
timestamp,
});
await this.projectRegistry.upsert(projectRecord);
const workspaceRecord = createPersistedWorkspaceRecord({
workspaceId: inputCwd,
workspaceId: generateWorkspaceId(),
projectId: projectRecord.projectId,
cwd: inputCwd,
kind: "directory",
displayName,
kind: membership.workspaceKind,
displayName: membership.workspaceDisplayName,
createdAt: timestamp,
updatedAt: timestamp,
});
@@ -6652,7 +6660,7 @@ export class Session {
await this.projectRegistry.upsert(projectRecord);
const workspaceRecord = createPersistedWorkspaceRecord({
workspaceId: membership.workspaceId,
workspaceId: generateWorkspaceId(),
projectId: projectRecord.projectId,
cwd,
kind: membership.workspaceKind,
@@ -6681,7 +6689,6 @@ export class Session {
const displayName = membership.workspaceDisplayName;
if (
input.workspace.workspaceId === membership.workspaceId &&
input.workspace.projectId === projectId &&
input.workspace.kind === kind &&
input.workspace.displayName === displayName
@@ -6693,7 +6700,7 @@ export class Session {
const nextWorkspace = {
...input.workspace,
workspaceId: membership.workspaceId,
workspaceId: input.workspace.workspaceId,
projectId,
cwd: input.cwd,
kind,
@@ -6797,7 +6804,10 @@ export class Session {
projectRegistry: this.projectRegistry,
});
if (!existingWorkspace) {
this.removeWorkspaceGitSubscription(workspaceId);
const watchTarget = this.resolveWorkspaceGitWatchTarget(workspaceId);
if (watchTarget) {
this.removeWorkspaceGitSubscription(watchTarget.cwd);
}
return;
}
@@ -6817,9 +6827,22 @@ export class Session {
);
}
await this.removeWorkspaceGitWatchTarget(existingWorkspace.cwd);
this.scriptRuntimeStore?.removeForWorkspace(existingWorkspace.cwd);
this.removeWorkspaceGitSubscription(workspaceId);
await this.teardownArchivedWorkspace({
workspaceId: existingWorkspace.workspaceId,
cwd: existingWorkspace.cwd,
});
}
// Git watch and subscription state is keyed by directory; the script runtime
// store is keyed by the opaque workspace id. Each cleanup uses its own key so an
// opaque id is never resolved as a filesystem path.
private async teardownArchivedWorkspace(input: {
workspaceId: string;
cwd: string;
}): Promise<void> {
await this.removeWorkspaceGitWatchTarget(input.cwd);
this.scriptRuntimeStore?.removeForWorkspace(input.workspaceId);
this.removeWorkspaceGitSubscription(input.cwd);
}
private async reconcileAndEmitWorkspaceUpdates(): Promise<void> {
@@ -6854,9 +6877,10 @@ export class Session {
result.changesApplied.map(async (change) => {
switch (change.kind) {
case "workspace_archived":
await this.removeWorkspaceGitWatchTarget(change.directory);
this.scriptRuntimeStore?.removeForWorkspace(change.directory);
this.removeWorkspaceGitSubscription(change.workspaceId);
await this.teardownArchivedWorkspace({
workspaceId: change.workspaceId,
cwd: change.directory,
});
changedWorkspaceIds.add(change.workspaceId);
break;
case "workspace_updated":
@@ -6912,7 +6936,7 @@ export class Session {
) {
continue;
}
const watchTarget = this.workspaceGitWatchTargets.get(workspaceId);
const watchTarget = this.resolveWorkspaceGitWatchTarget(workspaceId);
if (watchTarget && this.onBranchChanged) {
const newBranchName = nextWorkspace?.name ?? null;
if (newBranchName !== watchTarget.lastBranchName) {
@@ -6961,6 +6985,9 @@ export class Session {
): Promise<void> {
const workspaces = await this.workspaceRegistry.list();
const workspaceId = this.resolveRegisteredWorkspaceIdForCwd(cwd, workspaces);
if (!workspaceId) {
return;
}
await this.emitWorkspaceUpdatesForWorkspaceIds([workspaceId], options);
}
@@ -7583,10 +7610,10 @@ export class Session {
throw new Error(`Workspace not found: ${requestedWorkspaceId}`);
}
const workspaceCwd = normalizePersistedWorkspaceId(workspace.cwd);
const workspaceCwd = resolve(workspace.cwd);
const clearableAgentIds = agents
.filter((agent) => !agent.archivedAt)
.filter((agent) => normalizePersistedWorkspaceId(agent.cwd) === workspaceCwd)
.filter((agent) => resolve(agent.cwd) === workspaceCwd)
.filter((agent) => agent.requiresAttention === true)
.filter((agent) => (agent.pendingPermissions?.length ?? 0) === 0)
.filter((agent) => agent.attentionReason !== "permission")

View File

@@ -495,6 +495,66 @@ describe("workspace git watch targets", () => {
await session.cleanup();
});
test("archiving a workspace clears its script runtime entries by opaque workspace id", async () => {
const runtimeStore = new WorkspaceScriptRuntimeStore();
runtimeStore.set({
workspaceId: "ws-10",
scriptName: "app",
type: "service",
lifecycle: "running",
terminalId: "term-app",
exitCode: null,
});
const { session, projects, workspaces } = createSessionForWorkspaceGitWatchTests({
scriptRuntimeStore: runtimeStore,
});
seedGitWorkspace({
projects,
workspaces,
projectId: "proj-1",
workspaceId: "ws-10",
cwd: "/tmp/repo",
name: "main",
});
await asInternals<{ archiveWorkspaceRecord: (workspaceId: string) => Promise<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 () => {
const { session, emitted, projects, workspaces, subscriptions } =
createSessionForWorkspaceGitWatchTests();

View File

@@ -204,7 +204,7 @@ const PARENT_CHILD = path.join(PARENT, "child");
function gitWorkspace(rootPath: string, archivedAt: string | null = null) {
return createPersistedWorkspaceRecord({
workspaceId: rootPath,
workspaceId: `ws-${path.basename(rootPath) || "root"}`,
projectId: rootPath,
cwd: rootPath,
kind: "local_checkout",
@@ -217,7 +217,7 @@ function gitWorkspace(rootPath: string, archivedAt: string | null = null) {
function dirWorkspace(cwd: string, archivedAt: string | null = null) {
return createPersistedWorkspaceRecord({
workspaceId: cwd,
workspaceId: `ws-${path.basename(cwd) || "root"}`,
projectId: cwd,
cwd,
kind: "directory",
@@ -252,6 +252,17 @@ function dirProject(rootPath: string, archivedAt: string | null = null) {
});
}
function workspaceByCwd(
workspaces: Map<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.
// ─────────────────────────────────────────────────────────────────────────────
@@ -262,7 +273,7 @@ test("S1: open fresh git repo creates workspace at canonical root", async () =>
expect(resp?.error).toBeNull();
expect(resp?.workspace?.workspaceDirectory).toBe(FOO);
expect(resp?.workspace?.workspaceKind).toBe("local_checkout");
expect(h.workspaces.has(FOO)).toBe(true);
expect(hasWorkspaceCwd(h.workspaces, FOO)).toBe(true);
});
// ─────────────────────────────────────────────────────────────────────────────
@@ -276,7 +287,7 @@ test("S2: open fresh non-git directory creates a directory workspace at exact pa
expect(resp?.error).toBeNull();
expect(resp?.workspace?.workspaceDirectory).toBe(BAR);
expect(resp?.workspace?.workspaceKind).toBe("directory");
expect(h.workspaces.has(BAR)).toBe(true);
expect(hasWorkspaceCwd(h.workspaces, BAR)).toBe(true);
});
// ─────────────────────────────────────────────────────────────────────────────
@@ -291,9 +302,9 @@ test("S3: re-open active workspace by exact path returns the same record", async
});
await openProject(h.session, FOO);
const resp = getOpenResponse(h.emitted, "req-1");
expect(resp?.workspace?.id).toBe(FOO);
expect(resp?.workspace?.id).toBe(workspaceByCwd(h.workspaces, FOO)?.workspaceId);
expect(h.workspaces.size).toBe(1);
expect(h.workspaces.get(FOO)?.archivedAt).toBeNull();
expect(workspaceByCwd(h.workspaces, FOO)?.archivedAt).toBeNull();
});
// ─────────────────────────────────────────────────────────────────────────────
@@ -308,7 +319,7 @@ test("S4: open subdir of active git workspace returns the repo-root workspace",
});
await openProject(h.session, FOO_SUB);
const resp = getOpenResponse(h.emitted, "req-1");
expect(resp?.workspace?.id).toBe(FOO);
expect(resp?.workspace?.id).toBe(workspaceByCwd(h.workspaces, FOO)?.workspaceId);
expect(h.workspaces.size).toBe(1);
});
@@ -324,8 +335,8 @@ test("S5: open subdir of active non-git directory creates a SEPARATE workspace",
await openProject(h.session, BAR_BAZ);
const resp = getOpenResponse(h.emitted, "req-1");
expect(resp?.workspace?.workspaceDirectory).toBe(BAR_BAZ);
expect(h.workspaces.has(BAR)).toBe(true);
expect(h.workspaces.has(BAR_BAZ)).toBe(true);
expect(hasWorkspaceCwd(h.workspaces, BAR)).toBe(true);
expect(hasWorkspaceCwd(h.workspaces, BAR_BAZ)).toBe(true);
expect(h.workspaces.size).toBe(2);
});
@@ -341,7 +352,7 @@ test("S6: re-opening an archived git workspace by exact path UNARCHIVES it", asy
gitRoots: [TOOLBOX],
});
await openProject(h.session, TOOLBOX);
expect(h.workspaces.get(TOOLBOX)?.archivedAt).toBeNull();
expect(workspaceByCwd(h.workspaces, TOOLBOX)?.archivedAt).toBeNull();
expect(h.projects.get(TOOLBOX)?.archivedAt).toBeNull();
});
@@ -357,8 +368,8 @@ test("S7: open nested git repo (own .git) creates a SEPARATE workspace at the in
await openProject(h.session, FOO_SUB);
const resp = getOpenResponse(h.emitted, "req-1");
expect(resp?.workspace?.workspaceDirectory).toBe(FOO_SUB);
expect(h.workspaces.has(FOO)).toBe(true);
expect(h.workspaces.has(FOO_SUB)).toBe(true);
expect(hasWorkspaceCwd(h.workspaces, FOO)).toBe(true);
expect(hasWorkspaceCwd(h.workspaces, FOO_SUB)).toBe(true);
});
// ─────────────────────────────────────────────────────────────────────────────
@@ -373,8 +384,8 @@ test("S8: open child of archived non-git ancestor creates fresh workspace; ances
projects: [dirProject(USERS_DEVELOPER, archivedAt)],
});
await openProject(h.session, USERS_PROJECT);
expect(h.workspaces.get(USERS_DEVELOPER)?.archivedAt).toBe(archivedAt);
expect(h.workspaces.has(USERS_PROJECT)).toBe(true);
expect(workspaceByCwd(h.workspaces, USERS_DEVELOPER)?.archivedAt).toBe(archivedAt);
expect(hasWorkspaceCwd(h.workspaces, USERS_PROJECT)).toBe(true);
});
// ─────────────────────────────────────────────────────────────────────────────
@@ -390,7 +401,7 @@ test("S9: opening child of archived git workspace does NOT auto-unarchive the pa
gitRoots: [TOOLBOX],
});
await openProject(h.session, TOOLBOX_FLOMO);
expect(h.workspaces.get(TOOLBOX)?.archivedAt).toBe(archivedAt);
expect(workspaceByCwd(h.workspaces, TOOLBOX)?.archivedAt).toBe(archivedAt);
});
// ─────────────────────────────────────────────────────────────────────────────
@@ -414,8 +425,8 @@ test("S10: opening a git repo nested inside an archived non-git directory create
expect(resp?.error).toBeNull();
expect(resp?.workspace?.workspaceDirectory).toBe(SOME_GIT_REPO);
expect(resp?.workspace?.workspaceKind).toBe("local_checkout");
expect(h.workspaces.has(SOME_GIT_REPO)).toBe(true);
expect(h.workspaces.get(PROJECTS)?.archivedAt).toBe(archivedAt);
expect(hasWorkspaceCwd(h.workspaces, SOME_GIT_REPO)).toBe(true);
expect(workspaceByCwd(h.workspaces, PROJECTS)?.archivedAt).toBe(archivedAt);
expect(h.projects.get(PROJECTS)?.archivedAt).toBe(archivedAt);
});
@@ -434,11 +445,11 @@ test("S11: re-opening an archived project by exact path unarchives project + wor
await openProject(h.session, TOOLBOX);
const resp = getOpenResponse(h.emitted, "req-1");
expect(resp?.error).toBeNull();
expect(resp?.workspace?.id).toBe(TOOLBOX);
expect(resp?.workspace?.id).toBe(workspaceByCwd(h.workspaces, TOOLBOX)?.workspaceId);
expect(resp?.workspace?.projectId).toBe(TOOLBOX);
expect(h.workspaces.size).toBe(1);
expect(h.projects.size).toBe(1);
expect(h.workspaces.get(TOOLBOX)?.archivedAt).toBeNull();
expect(workspaceByCwd(h.workspaces, TOOLBOX)?.archivedAt).toBeNull();
expect(h.projects.get(TOOLBOX)?.archivedAt).toBeNull();
});

View File

@@ -490,8 +490,29 @@ function createSessionForWorkspaceTests(
notifyAgentState: () => {},
}),
agentStorage: asAgentStorage({
list: async () => [],
get: async () => null,
list: async () => [
createPersistedWorkspaceRecord({
workspaceId: "ws-repo-running",
projectId: "proj-repo-running",
cwd: REPO_CWD,
kind: "directory",
displayName: "repo",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
}),
],
get: async (workspaceId: string) =>
workspaceId === "ws-repo-running"
? createPersistedWorkspaceRecord({
workspaceId: "ws-repo-running",
projectId: "proj-repo-running",
cwd: REPO_CWD,
kind: "directory",
displayName: "repo",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
})
: null,
upsert: async () => {},
}),
projectRegistry: {
@@ -506,8 +527,29 @@ function createSessionForWorkspaceTests(
workspaceRegistry: {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => [],
get: async () => null,
list: async () => [
createPersistedWorkspaceRecord({
workspaceId: "ws-repo-running",
projectId: "proj-repo-running",
cwd: REPO_CWD,
kind: "directory",
displayName: "repo",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
}),
],
get: async (workspaceId: string) =>
workspaceId === "ws-repo-running"
? createPersistedWorkspaceRecord({
workspaceId: "ws-repo-running",
projectId: "proj-repo-running",
cwd: REPO_CWD,
kind: "directory",
displayName: "repo",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
})
: null,
upsert: async () => {},
archive: async () => {},
remove: async () => {},
@@ -853,10 +895,10 @@ test("agent_update placement does not refresh git snapshots", async () => {
});
});
test("agent_update emits fallback placement when no workspace is registered", async () => {
test("agent_update emits a directory-scoped placement when no workspace is registered", async () => {
const emitted: SessionOutboundMessage[] = [];
const getSnapshot = vi.fn(async () => {
throw new Error("getSnapshot should not be called for fallback agent_update placement");
throw new Error("getSnapshot should not be called for unregistered agent_update placement");
});
const session = asTestSession(
createSessionForWorkspaceTests({
@@ -886,17 +928,15 @@ test("agent_update emits fallback placement when no workspace is registered", as
);
expect(getSnapshot).not.toHaveBeenCalled();
expect(emitted.find((message) => message.type === "agent_update")?.payload).toMatchObject({
kind: "upsert",
project: {
projectKey: UNREGISTERED_CWD,
projectName: "unregistered",
checkout: {
cwd: UNREGISTERED_CWD,
isGit: false,
},
},
});
// An agent in a directory with no registered workspace must still emit an
// upsert (live model/thinking switches depend on this). It gets a directory-
// scoped placement keyed by the path — a project key, not a workspace id.
const update = emitted.find((message) => message.type === "agent_update");
if (update?.type !== "agent_update" || update.payload.kind !== "upsert") {
throw new Error("expected an agent_update upsert");
}
expect(update.payload.agent.id).toBe("agent-1");
expect(update.payload.project?.checkout.isGit).toBe(false);
});
test("archive emits an authoritative agent_update upsert for subscribed clients", async () => {
@@ -1158,7 +1198,7 @@ test("workspace clear attention can clear multiple workspaces in one request", a
const emitted: SessionOutboundMessage[] = [];
const workspaces = [
createPersistedWorkspaceRecord({
workspaceId: "/tmp/repo-a",
workspaceId: "ws-repo-a",
projectId: "/tmp/repo-a",
cwd: "/tmp/repo-a",
kind: "directory",
@@ -1167,7 +1207,7 @@ test("workspace clear attention can clear multiple workspaces in one request", a
updatedAt: "2026-03-30T15:00:00.000Z",
}),
createPersistedWorkspaceRecord({
workspaceId: "/tmp/repo-b",
workspaceId: "ws-repo-b",
projectId: "/tmp/repo-b",
cwd: "/tmp/repo-b",
kind: "directory",
@@ -2570,8 +2610,29 @@ test("workspace update stream keeps persisted workspace visible after agents sto
workspaceRegistry: {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => [],
get: async () => null,
list: async () => [
createPersistedWorkspaceRecord({
workspaceId: "ws-repo-running",
projectId: "proj-repo-running",
cwd: REPO_CWD,
kind: "directory",
displayName: "repo",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
}),
],
get: async (workspaceId: string) =>
workspaceId === "ws-repo-running"
? createPersistedWorkspaceRecord({
workspaceId: "ws-repo-running",
projectId: "proj-repo-running",
cwd: REPO_CWD,
kind: "directory",
displayName: "repo",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
})
: null,
upsert: async () => {},
archive: async () => {},
remove: async () => {},
@@ -2619,7 +2680,7 @@ test("workspace update stream keeps persisted workspace visible after agents sto
session.buildWorkspaceDescriptorMap = async () =>
new Map([
[
REPO_CWD,
"ws-repo-running",
{
id: "ws-repo-running",
projectId: "proj-repo-running",
@@ -2638,7 +2699,7 @@ test("workspace update stream keeps persisted workspace visible after agents sto
session.buildWorkspaceDescriptorMap = async () =>
new Map([
[
REPO_CWD,
"ws-repo-running",
{
id: "ws-repo-running",
projectId: "proj-repo-running",
@@ -2736,8 +2797,8 @@ test("create paseo worktree request returns a registered workspace descriptor",
const workspaces = new Map();
const projects = new Map();
session.paseoHome = paseoHome;
session.workspaceRegistry.get = async (workspaceId: string) =>
workspaces.get(workspaceId) ?? null;
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
workspaces.get(lookupWorkspaceId) ?? null;
session.workspaceRegistry.list = async () => Array.from(workspaces.values());
session.workspaceRegistry.upsert = async (
record: ReturnType<typeof createPersistedWorkspaceRecord>,
@@ -2775,7 +2836,8 @@ test("create paseo worktree request returns a registered workspace descriptor",
name: "worktree-123",
status: "done",
});
expect(response?.payload.workspace?.id).toContain(path.join("worktree-123"));
expect(response?.payload.workspace?.id).toMatch(/^wks_[0-9a-f]{16}$/);
expect(response?.payload.workspace?.workspaceDirectory).toContain(path.join("worktree-123"));
expect(workspaces.has(response?.payload.workspace?.id ?? "")).toBe(true);
expect(projects.has(response?.payload.workspace?.projectId ?? "")).toBe(true);
});
@@ -2875,8 +2937,8 @@ test("open_project_request registers a workspace before any agent exists", async
) => {
projects.set(record.projectId, record);
};
session.workspaceRegistry.get = async (workspaceId: string) =>
workspaces.get(workspaceId) ?? null;
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
workspaces.get(lookupWorkspaceId) ?? null;
session.workspaceRegistry.upsert = async (
record: ReturnType<typeof createPersistedWorkspaceRecord>,
) => {
@@ -2904,10 +2966,13 @@ test("open_project_request registers a workspace before any agent exists", async
requestId: "req-open",
});
expect(workspaces.get(REPO_CWD)).toBeTruthy();
const registeredWorkspace = Array.from(workspaces.values()).find(
(workspace) => workspace.cwd === REPO_CWD,
);
expect(registeredWorkspace).toBeTruthy();
const response = findByType(emitted, "open_project_response");
expect(response?.payload.error).toBeNull();
expect(response?.payload.workspace?.id).toBe(REPO_CWD);
expect(response?.payload.workspace?.id).toBe(registeredWorkspace?.workspaceId);
});
test("import_agent_request registers a workspace for a never-seen cwd", async () => {
@@ -2927,8 +2992,8 @@ test("import_agent_request registers a workspace for a never-seen cwd", async ()
) => {
projects.set(record.projectId, record);
};
session.workspaceRegistry.get = async (workspaceId: string) =>
workspaces.get(workspaceId) ?? null;
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
workspaces.get(lookupWorkspaceId) ?? null;
session.workspaceRegistry.upsert = async (
record: ReturnType<typeof createPersistedWorkspaceRecord>,
) => {
@@ -2971,15 +3036,22 @@ test("import_agent_request registers a workspace for a never-seen cwd", async ()
pendingUpdatesByWorkspaceId: new Map(),
lastEmittedByWorkspaceId: new Map(),
};
session.buildWorkspaceDescriptorMap = async () =>
new Map([
session.buildWorkspaceDescriptorMap = async () => {
const workspace = Array.from(workspaces.values()).find(
(candidate) => candidate.cwd === importedCwd,
);
if (!workspace) {
return new Map();
}
return new Map([
[
importedCwd,
workspace.workspaceId,
{
id: importedCwd,
projectId: importedCwd,
id: workspace.workspaceId,
projectId: workspace.projectId,
projectDisplayName: "imported-project",
projectRootPath: importedCwd,
workspaceDirectory: importedCwd,
projectKind: "non_git",
workspaceKind: "directory",
name: "imported-project",
@@ -2988,6 +3060,7 @@ test("import_agent_request registers a workspace for a never-seen cwd", async ()
},
],
]);
};
await session.handleMessage({
type: "import_agent_request",
@@ -2997,12 +3070,17 @@ test("import_agent_request registers a workspace for a never-seen cwd", async ()
cwd: importedCwd,
});
expect(workspaces.get(importedCwd)).toBeTruthy();
const importedWorkspace = Array.from(workspaces.values()).find(
(workspace) => workspace.cwd === importedCwd,
);
expect(importedWorkspace).toBeTruthy();
const workspaceUpdates = filterByType(emitted, "workspace_update");
expect(workspaceUpdates.length).toBeGreaterThan(0);
expect(
workspaceUpdates.some(
(update) => update.payload.kind === "upsert" && update.payload.workspace.id === importedCwd,
(update) =>
update.payload.kind === "upsert" &&
update.payload.workspace.workspaceDirectory === importedCwd,
),
).toBe(true);
});
@@ -3023,8 +3101,8 @@ test("open_project_response returns immediately even when the GitHub fetch is sl
) => {
projects.set(record.projectId, record);
};
session.workspaceRegistry.get = async (workspaceId: string) =>
workspaces.get(workspaceId) ?? null;
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
workspaces.get(lookupWorkspaceId) ?? null;
session.workspaceRegistry.upsert = async (
record: ReturnType<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");
expect(response?.payload.error).toBeNull();
expect(response?.payload.workspace?.id).toBe(cwd);
expect(response?.payload.workspace?.id).toMatch(/^wks_[0-9a-f]{16}$/);
expect(response?.payload.workspace?.workspaceDirectory).toBe(cwd);
expect(response?.payload.workspace?.gitRuntime).toBeUndefined();
expect(response?.payload.workspace?.githubRuntime).toBeUndefined();
@@ -3089,8 +3168,8 @@ test("open_project_request emits a workspace_update with githubRuntime once the
) => {
projects.set(record.projectId, record);
};
session.workspaceRegistry.get = async (workspaceId: string) =>
workspaces.get(workspaceId) ?? null;
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
workspaces.get(lookupWorkspaceId) ?? null;
session.workspaceRegistry.upsert = async (
record: ReturnType<typeof createPersistedWorkspaceRecord>,
) => {
@@ -3143,7 +3222,7 @@ test("open_project_request emits a workspace_update with githubRuntime once the
.map((update) => update.payload)
.filter(
(payload): payload is WorkspaceUpsertPayload =>
payload.kind === "upsert" && payload.workspace.id === cwd,
payload.kind === "upsert" && payload.workspace.workspaceDirectory === cwd,
)
.find((payload) => payload.workspace.githubRuntime?.pullRequest);
expect(upsertedWithGitHub?.workspace.githubRuntime?.pullRequest).toEqual(
@@ -3155,6 +3234,7 @@ interface WorkspaceUpsertPayload {
kind: "upsert";
workspace: {
id: string;
workspaceDirectory?: string;
githubRuntime?: {
pullRequest?: { url?: string } | null;
} | null;
@@ -3202,8 +3282,8 @@ test("open_project_request does not match a new child directory to an existing p
) => {
projects.set(record.projectId, record);
};
session.workspaceRegistry.get = async (workspaceId: string) =>
workspaces.get(workspaceId) ?? null;
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
workspaces.get(lookupWorkspaceId) ?? null;
session.workspaceRegistry.upsert = async (
record: ReturnType<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");
expect(response?.payload.error).toBeNull();
expect(response?.payload.workspace?.id).toBe(worktree);
expect(response?.payload.workspace?.id).toMatch(/^wks_[0-9a-f]{16}$/);
expect(response?.payload.workspace?.workspaceDirectory).toBe(worktree);
expect(workspaces.get(worktree)).toBeTruthy();
expect(Array.from(workspaces.values()).some((workspace) => workspace.cwd === worktree)).toBe(
true,
);
});
test("open_project_request does not unarchive an archived parent workspace for a new child directory", async () => {
@@ -3269,8 +3351,8 @@ test("open_project_request does not unarchive an archived parent workspace for a
) => {
projects.set(record.projectId, record);
};
session.workspaceRegistry.get = async (workspaceId: string) =>
workspaces.get(workspaceId) ?? null;
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
workspaces.get(lookupWorkspaceId) ?? null;
session.workspaceRegistry.upsert = async (
record: ReturnType<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");
expect(response?.payload.error).toBeNull();
expect(response?.payload.workspace?.id).toBe(worktree);
expect(response?.payload.workspace?.id).toMatch(/^wks_[0-9a-f]{16}$/);
expect(response?.payload.workspace?.workspaceDirectory).toBe(worktree);
expect(workspaces.get(home)?.archivedAt).toBe(archivedAt);
expect(projects.get(home)?.archivedAt).toBe(archivedAt);
@@ -3308,6 +3390,7 @@ test("open_project_request reclassifies an archived directory workspace when git
);
const remoteProjectId = "remote:github.com/getpaseo/paseo";
const archivedAt = "2026-04-24T09:48:36.168Z";
const workspaceId = "ws-desktop-daemon-settings";
projects.set(
cwd,
@@ -3322,9 +3405,9 @@ test("open_project_request reclassifies an archived directory workspace when git
}),
);
workspaces.set(
cwd,
workspaceId,
createPersistedWorkspaceRecord({
workspaceId: cwd,
workspaceId,
projectId: cwd,
cwd,
kind: "directory",
@@ -3344,8 +3427,8 @@ test("open_project_request reclassifies an archived directory workspace when git
) => {
projects.set(record.projectId, record);
};
session.workspaceRegistry.get = async (workspaceId: string) =>
workspaces.get(workspaceId) ?? null;
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
workspaces.get(lookupWorkspaceId) ?? null;
session.workspaceRegistry.upsert = async (
record: ReturnType<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?.workspaceKind).toBe("worktree");
expect(projects.get(remoteProjectId)?.kind).toBe("git");
expect(workspaces.get(cwd)?.projectId).toBe(remoteProjectId);
expect(workspaces.get(cwd)?.kind).toBe("worktree");
expect(workspaces.get(cwd)?.displayName).toBe("feature/desktop-daemon-settings");
expect(workspaces.get(workspaceId)?.projectId).toBe(remoteProjectId);
expect(workspaces.get(workspaceId)?.kind).toBe("worktree");
expect(workspaces.get(workspaceId)?.displayName).toBe("feature/desktop-daemon-settings");
});
test("open_project_request reclassifies an active directory workspace when git metadata becomes available", async () => {
@@ -3427,10 +3510,12 @@ test("open_project_request reclassifies an active directory workspace when git m
updatedAt: "2026-04-24T09:40:00.000Z",
}),
);
const workspaceId = "ws-desktop-daemon-settings-active";
const repoWorkspaceId = "ws-paseo-main";
workspaces.set(
cwd,
workspaceId,
createPersistedWorkspaceRecord({
workspaceId: cwd,
workspaceId,
projectId: cwd,
cwd,
kind: "directory",
@@ -3440,9 +3525,9 @@ test("open_project_request reclassifies an active directory workspace when git m
}),
);
workspaces.set(
repoRoot,
repoWorkspaceId,
createPersistedWorkspaceRecord({
workspaceId: repoRoot,
workspaceId: repoWorkspaceId,
projectId: repoRoot,
cwd: repoRoot,
kind: "local_checkout",
@@ -3461,8 +3546,8 @@ test("open_project_request reclassifies an active directory workspace when git m
) => {
projects.set(record.projectId, record);
};
session.workspaceRegistry.get = async (workspaceId: string) =>
workspaces.get(workspaceId) ?? null;
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
workspaces.get(lookupWorkspaceId) ?? null;
session.workspaceRegistry.upsert = async (
record: ReturnType<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.workspace?.projectId).toBe(repoRoot);
expect(response?.payload.workspace?.workspaceKind).toBe("worktree");
expect(workspaces.get(cwd)?.projectId).toBe(repoRoot);
expect(workspaces.get(cwd)?.kind).toBe("worktree");
expect(workspaces.get(cwd)?.displayName).toBe("feature/desktop-daemon-settings");
expect(workspaces.get(workspaceId)?.projectId).toBe(repoRoot);
expect(workspaces.get(workspaceId)?.kind).toBe("worktree");
expect(workspaces.get(workspaceId)?.displayName).toBe("feature/desktop-daemon-settings");
});
test("open_project_request groups a plain git worktree under an existing repo project", async () => {
@@ -3554,8 +3639,8 @@ test("open_project_request groups a plain git worktree under an existing repo pr
) => {
projects.set(record.projectId, record);
};
session.workspaceRegistry.get = async (workspaceId: string) =>
workspaces.get(workspaceId) ?? null;
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
workspaces.get(lookupWorkspaceId) ?? null;
session.workspaceRegistry.upsert = async (
record: ReturnType<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.workspace?.projectId).toBe(repoRoot);
expect(response?.payload.workspace?.workspaceKind).toBe("worktree");
expect(workspaces.get(cwd)?.projectId).toBe(repoRoot);
expect(workspaces.get(cwd)?.kind).toBe("worktree");
const worktreeWorkspace = Array.from(workspaces.values()).find(
(workspace) => workspace.cwd === cwd,
);
expect(worktreeWorkspace?.projectId).toBe(repoRoot);
expect(worktreeWorkspace?.kind).toBe("worktree");
});
test("open_project_request unarchives an existing archived workspace and project", async () => {
@@ -3606,6 +3694,7 @@ test("open_project_request unarchives an existing archived workspace and project
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
const cwd = REPO_CWD;
const workspaceId = "ws-repo-archived";
projects.set(
cwd,
createPersistedProjectRecord({
@@ -3619,9 +3708,9 @@ test("open_project_request unarchives an existing archived workspace and project
}),
);
workspaces.set(
cwd,
workspaceId,
createPersistedWorkspaceRecord({
workspaceId: cwd,
workspaceId,
projectId: cwd,
cwd,
kind: "directory",
@@ -3641,8 +3730,8 @@ test("open_project_request unarchives an existing archived workspace and project
) => {
projects.set(record.projectId, record);
};
session.workspaceRegistry.get = async (workspaceId: string) =>
workspaces.get(workspaceId) ?? null;
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
workspaces.get(lookupWorkspaceId) ?? null;
session.workspaceRegistry.upsert = async (
record: ReturnType<typeof createPersistedWorkspaceRecord>,
) => {
@@ -3657,11 +3746,11 @@ test("open_project_request unarchives an existing archived workspace and project
requestId: "req-open-unarchive",
});
expect(workspaces.get(cwd)?.archivedAt).toBeNull();
expect(workspaces.get(workspaceId)?.archivedAt).toBeNull();
expect(projects.get(cwd)?.archivedAt).toBeNull();
const response = findByType(emitted, "open_project_response");
expect(response?.payload.error).toBeNull();
expect(response?.payload.workspace?.id).toBe(cwd);
expect(response?.payload.workspace?.id).toBe(workspaceId);
});
test.skip("open_project_request collapses a git subdirectory onto the repo root workspace", async () => {
@@ -3681,8 +3770,8 @@ test.skip("open_project_request collapses a git subdirectory onto the repo root
) => {
projects.set(record.projectId, record);
};
session.workspaceRegistry.get = async (workspaceId: string) =>
workspaces.get(workspaceId) ?? null;
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
workspaces.get(lookupWorkspaceId) ?? null;
session.workspaceRegistry.upsert = async (
record: ReturnType<typeof createPersistedWorkspaceRecord>,
) => {
@@ -3842,8 +3931,8 @@ test.skip("opening a new worktree reconciles older local workspaces into the rem
if (!existing) return;
projects.set(projectId, { ...existing, archivedAt, updatedAt: archivedAt });
};
session.workspaceRegistry.get = async (workspaceId: string) =>
workspaces.get(workspaceId) ?? null;
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
workspaces.get(lookupWorkspaceId) ?? null;
session.workspaceRegistry.list = async () => Array.from(workspaces.values());
session.workspaceRegistry.upsert = async (
record: ReturnType<typeof createPersistedWorkspaceRecord>,
@@ -3948,8 +4037,8 @@ test.skip("fetch_workspaces_request reconciles remote URL changes for existing w
if (!existing) return;
projects.set(projectId, { ...existing, archivedAt, updatedAt: archivedAt });
};
session.workspaceRegistry.get = async (workspaceId: string) =>
workspaces.get(workspaceId) ?? null;
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
workspaces.get(lookupWorkspaceId) ?? null;
session.workspaceRegistry.list = async () => Array.from(workspaces.values());
session.workspaceRegistry.upsert = async (
record: ReturnType<typeof createPersistedWorkspaceRecord>,
@@ -4044,8 +4133,8 @@ test.skip("reconcile archives stale subdirectory workspace records when collapsi
) => {
projects.set(record.projectId, record);
};
session.workspaceRegistry.get = async (workspaceId: string) =>
workspaces.get(workspaceId) ?? null;
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
workspaces.get(lookupWorkspaceId) ?? null;
session.workspaceRegistry.list = async () => Array.from(workspaces.values());
session.workspaceRegistry.upsert = async (
record: ReturnType<typeof createPersistedWorkspaceRecord>,
@@ -5040,7 +5129,7 @@ test("resolveRegisteredWorkspaceIdForCwd does not match home directory as a pref
const home = homedir();
const childCwd = path.join(home, "projects/new-app");
const homeWorkspace = createPersistedWorkspaceRecord({
workspaceId: home,
workspaceId: "ws-home",
projectId: "proj-home",
cwd: home,
kind: "directory",
@@ -5049,6 +5138,6 @@ test("resolveRegisteredWorkspaceIdForCwd does not match home directory as a pref
updatedAt: "2026-03-01T12:00:00.000Z",
});
expect(session.resolveRegisteredWorkspaceIdForCwd(childCwd, [homeWorkspace])).toBe(childCwd);
expect(session.resolveRegisteredWorkspaceIdForCwd(home, [homeWorkspace])).toBe(home);
expect(session.resolveRegisteredWorkspaceIdForCwd(childCwd, [homeWorkspace])).toBeNull();
expect(session.resolveRegisteredWorkspaceIdForCwd(home, [homeWorkspace])).toBe("ws-home");
});

View File

@@ -1,5 +1,5 @@
import { homedir } from "node:os";
import { sep } from "node:path";
import { resolve, sep } from "node:path";
import type pino from "pino";
import type {
AgentSnapshotPayload,
@@ -15,7 +15,6 @@ import {
import { getParentAgentIdFromLabels, isDelegatedAgent } from "@getpaseo/protocol/agent-labels";
import { SortablePager } from "./pagination/sortable-pager.js";
import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "./workspace-registry.js";
import { normalizeWorkspaceId } from "./workspace-registry-model.js";
const FETCH_WORKSPACES_SORT_KEYS = [
"status_priority",
@@ -52,6 +51,33 @@ type FetchWorkspacesResponsePageInfo = FetchWorkspacesResponsePayload["pageInfo"
export type WorkspaceUpdatesFilter = FetchWorkspacesRequestFilter;
export function resolveRegisteredWorkspaceIdForCwd(
cwd: string,
workspaces: PersistedWorkspaceRecord[],
): string | null {
const resolvedCwd = resolve(cwd);
const exact = workspaces.find((workspace) => workspace.cwd === resolvedCwd);
if (exact) {
return exact.workspaceId;
}
const userHome = homedir();
let bestMatch: PersistedWorkspaceRecord | null = null;
for (const workspace of workspaces) {
if (workspace.cwd === userHome) continue;
if (workspace.archivedAt) continue;
const prefix = workspace.cwd.endsWith(sep) ? workspace.cwd : `${workspace.cwd}${sep}`;
if (!resolvedCwd.startsWith(prefix)) {
continue;
}
if (!bestMatch || workspace.cwd.length > bestMatch.cwd.length) {
bestMatch = workspace;
}
}
return bestMatch?.workspaceId ?? null;
}
export interface WorkspaceDirectoryDeps {
logger: pino.Logger;
projectRegistry: {
@@ -176,9 +202,7 @@ export class WorkspaceDirectory {
const descriptorsByWorkspaceId = new Map<string, WorkspaceDescriptorPayload>();
const workspaceIds = options.workspaceIds ? new Set(options.workspaceIds) : null;
const workspaceIdsByDirectory = new Map(
activeRecords.map(
(workspace) => [normalizeWorkspaceId(workspace.cwd), workspace.workspaceId] as const,
),
activeRecords.map((workspace) => [resolve(workspace.cwd), workspace.workspaceId] as const),
);
const includedWorkspaces = activeRecords.filter(
@@ -228,7 +252,7 @@ export class WorkspaceDirectory {
});
}
const workspaceId = workspaceIdsByDirectory.get(normalizeWorkspaceId(workspaceAgent.cwd));
const workspaceId = workspaceIdsByDirectory.get(resolve(workspaceAgent.cwd));
if (workspaceId === undefined) {
continue;
}
@@ -252,7 +276,7 @@ export class WorkspaceDirectory {
(agent) =>
!agent.archivedAt &&
this.deps.isProviderVisibleToClient(agent.provider) &&
workspaceIdsByDirectory.get(normalizeWorkspaceId(agent.cwd)) === workspaceId,
workspaceIdsByDirectory.get(resolve(agent.cwd)) === workspaceId,
);
const result = this.resolveStatusEnteredAt({
workspaceId,
@@ -366,28 +390,11 @@ export class WorkspaceDirectory {
return candidates.at(-1) ?? null;
}
resolveRegisteredWorkspaceIdForCwd(cwd: string, workspaces: PersistedWorkspaceRecord[]): string {
const normalizedCwd = normalizeWorkspaceId(cwd);
const exact = workspaces.find((workspace) => workspace.cwd === normalizedCwd);
if (exact) {
return exact.workspaceId;
}
const userHome = homedir();
let bestMatch: PersistedWorkspaceRecord | null = null;
for (const workspace of workspaces) {
if (workspace.cwd === userHome) continue;
if (workspace.archivedAt) continue;
const prefix = workspace.cwd.endsWith(sep) ? workspace.cwd : `${workspace.cwd}${sep}`;
if (!normalizedCwd.startsWith(prefix)) {
continue;
}
if (!bestMatch || workspace.cwd.length > bestMatch.cwd.length) {
bestMatch = workspace;
}
}
return bestMatch?.workspaceId ?? normalizedCwd;
resolveRegisteredWorkspaceIdForCwd(
cwd: string,
workspaces: PersistedWorkspaceRecord[],
): string | null {
return resolveRegisteredWorkspaceIdForCwd(cwd, workspaces);
}
async listDescriptors(): Promise<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) {
const query = filter.query.trim().toLocaleLowerCase();
const haystacks = [workspace.name, workspace.projectId, workspace.id];

View File

@@ -1,6 +1,6 @@
import { watch, type FSWatcher } from "node:fs";
import { readFile, readdir } from "node:fs/promises";
import { join, resolve } from "node:path";
import { basename, join, resolve } from "node:path";
import { LRUCache } from "lru-cache";
import pLimit from "p-limit";
import type pino from "pino";
@@ -38,7 +38,7 @@ import {
buildWorkspaceGitMetadataFromSnapshot,
type WorkspaceGitMetadata,
} from "./workspace-git-metadata.js";
import { checkoutLiteFromGitSnapshot, normalizeWorkspaceId } from "./workspace-registry-model.js";
import { checkoutLiteFromGitSnapshot } from "./workspace-registry-model.js";
const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 1_000;
const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000;
@@ -401,7 +401,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
params: { cwd: string },
listener: WorkspaceGitListener,
): WorkspaceGitSubscription {
const cwd = normalizeWorkspaceId(params.cwd);
const cwd = resolve(params.cwd);
const target = this.ensureWorkspaceTarget(cwd);
target.listeners.add(listener);
if (target.listeners.size === 1) {
@@ -432,7 +432,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
cwd: string,
options?: WorkspaceGitSnapshotOptions,
): Promise<WorkspaceGitRuntimeSnapshot> {
cwd = normalizeWorkspaceId(cwd);
cwd = resolve(cwd);
const request = this.normalizeRefreshRequest(options, "getSnapshot", true);
const target = this.ensureWorkspaceTarget(cwd);
if (!request.force && target.latestSnapshot) {
@@ -443,7 +443,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
}
async getCheckout(cwd: string): Promise<ProjectCheckoutLitePayload> {
const normalizedCwd = normalizeWorkspaceId(cwd);
const normalizedCwd = resolve(cwd);
try {
const status = await this.deps.getCheckoutStatus(normalizedCwd, {
paseoHome: this.paseoHome,
@@ -481,7 +481,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
}
peekSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot | null {
cwd = normalizeWorkspaceId(cwd);
cwd = resolve(cwd);
return this.workspaceTargets.get(cwd)?.latestSnapshot ?? null;
}
@@ -490,7 +490,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
options: CheckoutDiffCompare,
readOptions?: WorkspaceGitReadOptions,
): Promise<CheckoutDiffResult> {
const normalizedCwd = normalizeWorkspaceId(cwd);
const normalizedCwd = resolve(cwd);
const normalizedOptions = this.normalizeCheckoutDiffOptions(options);
const key = this.buildCheckoutDiffCacheKey(normalizedCwd, normalizedOptions);
return this.readAuxiliaryCache(this.checkoutDiffCache, key, readOptions, () =>
@@ -530,7 +530,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
ref: string,
options?: WorkspaceGitReadOptions,
): Promise<WorkspaceGitBranchValidationResult> {
const normalizedCwd = normalizeWorkspaceId(cwd);
const normalizedCwd = resolve(cwd);
const normalizedRef = ref.trim();
const key = JSON.stringify(["branch-validation", normalizedCwd, normalizedRef]);
return this.readAuxiliaryCache(this.branchValidationCache, key, options, () =>
@@ -539,7 +539,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
}
hasLocalBranch(cwd: string, branch: string, options?: WorkspaceGitReadOptions): Promise<boolean> {
const normalizedCwd = normalizeWorkspaceId(cwd);
const normalizedCwd = resolve(cwd);
const normalizedBranch = branch.trim();
const ref = `refs/heads/${normalizedBranch}`;
const key = JSON.stringify(["local-branch", normalizedCwd, ref]);
@@ -558,7 +558,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
options?: WorkspaceGitBranchSuggestionsOptions,
readOptions?: WorkspaceGitReadOptions,
): Promise<WorkspaceGitBranchSuggestion[]> {
const normalizedCwd = normalizeWorkspaceId(cwd);
const normalizedCwd = resolve(cwd);
const query = options?.query ?? "";
const limit = options?.limit;
const key = JSON.stringify(["branch-suggestions", normalizedCwd, query, limit ?? null]);
@@ -572,7 +572,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
options?: WorkspaceGitStashListOptions,
readOptions?: WorkspaceGitReadOptions,
): Promise<WorkspaceGitStashEntry[]> {
const normalizedCwd = normalizeWorkspaceId(cwd);
const normalizedCwd = resolve(cwd);
const paseoOnly = options?.paseoOnly !== false;
const key = JSON.stringify(["stashes", normalizedCwd, paseoOnly]);
return this.readAuxiliaryCache(this.stashListCache, key, readOptions, async () => {
@@ -606,15 +606,15 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
}
return snapshot.git.isPaseoOwnedWorktree
? (snapshot.git.mainRepoRoot ?? snapshot.git.repoRoot ?? normalizeWorkspaceId(cwd))
: (snapshot.git.repoRoot ?? normalizeWorkspaceId(cwd));
? (snapshot.git.mainRepoRoot ?? snapshot.git.repoRoot ?? resolve(cwd))
: (snapshot.git.repoRoot ?? resolve(cwd));
}
async resolveDefaultBranch(
cwdOrRepoRoot: string,
options?: WorkspaceGitReadOptions,
): Promise<string> {
const cwd = normalizeWorkspaceId(cwdOrRepoRoot);
const cwd = resolve(cwdOrRepoRoot);
const key = JSON.stringify(["default-branch", cwd]);
return this.readAuxiliaryCache(this.defaultBranchCache, key, options, async () => {
const defaultBranch = await this.deps.resolveRepositoryDefaultBranch(cwd);
@@ -630,10 +630,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
options?: WorkspaceGitReadOptions & { directoryName?: string },
): Promise<WorkspaceGitMetadata> {
const snapshot = await this.getSnapshot(cwd, options);
const directoryName =
options?.directoryName ?? normalizeWorkspaceId(cwd).split(/[\\/]/).findLast(Boolean) ?? cwd;
const directoryName = options?.directoryName ?? basename(cwd) ?? cwd;
return buildWorkspaceGitMetadataFromSnapshot({
cwd: normalizeWorkspaceId(cwd),
cwd: resolve(cwd),
directoryName,
isGit: snapshot.git.isGit,
repoRoot: snapshot.git.repoRoot,
@@ -652,7 +651,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
}
async refresh(cwd: string, _options?: { priority?: "normal" | "high" }): Promise<void> {
cwd = normalizeWorkspaceId(cwd);
cwd = resolve(cwd);
const target = this.ensureWorkspaceTarget(cwd);
await this.refreshWorkspaceTarget(target, {
force: false,
@@ -667,7 +666,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
cwd: string,
onChange: () => void,
): Promise<{ repoRoot: string | null; unsubscribe: () => void }> {
cwd = normalizeWorkspaceId(cwd);
cwd = resolve(cwd);
const target = await this.ensureWorkingTreeWatchTarget(cwd);
target.listeners.add(onChange);
@@ -680,7 +679,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
}
scheduleRefreshForCwd(cwd: string): void {
cwd = normalizeWorkspaceId(cwd);
cwd = resolve(cwd);
const target = this.workspaceTargets.get(cwd);
if (target) {
this.scheduleWorkspaceRefresh(target);
@@ -688,7 +687,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
}
onWorkspaceStateMayHaveChanged(cwd: string): void {
const normalizedCwd = normalizeWorkspaceId(cwd);
const normalizedCwd = resolve(cwd);
const target = this.workspaceTargets.get(normalizedCwd);
if (!target || target.closed) {
return;
@@ -1120,7 +1119,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
): void {
const target =
typeof targetOrCwd === "string"
? this.workspaceTargets.get(normalizeWorkspaceId(targetOrCwd))
? this.workspaceTargets.get(resolve(targetOrCwd))
: targetOrCwd;
if (!target || target.closed || this.workspaceTargets.get(target.cwd) !== target) {
return;

View File

@@ -1,4 +1,5 @@
import { existsSync } from "node:fs";
import { resolve } from "node:path";
import type pino from "pino";
import type {
ProjectRegistry,
@@ -7,7 +8,6 @@ import type {
PersistedWorkspaceRecord,
} from "./workspace-registry.js";
import type { WorkspaceGitService } from "./workspace-git-service.js";
import { normalizeWorkspaceId } from "./workspace-registry-model.js";
const DEFAULT_RECONCILE_INTERVAL_MS = 60_000;
@@ -220,7 +220,7 @@ export class WorkspaceReconciliationService {
if (project.kind !== "git") {
continue;
}
const rootKey = normalizeWorkspaceId(project.rootPath);
const rootKey = resolve(project.rootPath);
const group = projectsByRoot.get(rootKey) ?? [];
group.push(project);
projectsByRoot.set(rootKey, group);

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