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
+39 -28
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()
+33 -28
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 });
}
},
});
},
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { createBranchSwitcherOperations } from "./branch-switcher-operations";
function createRecordingClient() {
const cwds: string[] = [];
const client = {
getBranchSuggestions: async (options: { cwd: string; limit?: number }) => {
cwds.push(options.cwd);
return { branches: [], error: null };
},
stashList: async (cwd: string) => {
cwds.push(cwd);
return { entries: [] };
},
stashSave: async (cwd: string) => {
cwds.push(cwd);
return { error: null };
},
stashPop: async (cwd: string) => {
cwds.push(cwd);
return { error: null };
},
checkoutSwitchBranch: async (cwd: string) => {
cwds.push(cwd);
return { error: null };
},
} as unknown as DaemonClient;
return { client, cwds };
}
describe("createBranchSwitcherOperations", () => {
it("sends the workspace directory as cwd to every git operation, never the workspace id", async () => {
const workspaceDirectory = "/Users/dev/project";
const workspaceId = "wks_3f9a2b1c";
const { client, cwds } = createRecordingClient();
const operations = createBranchSwitcherOperations(client, workspaceDirectory);
await operations.getBranchSuggestions(200);
await operations.listPaseoStashes();
await operations.saveStash("main");
await operations.popStash(0);
await operations.switchBranch("feature");
expect(cwds).toEqual([
workspaceDirectory,
workspaceDirectory,
workspaceDirectory,
workspaceDirectory,
workspaceDirectory,
]);
expect(cwds).not.toContain(workspaceId);
});
});
@@ -0,0 +1,16 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
// Binds the branch switcher's git operations to a single workspace directory, so a
// workspace id can never be passed where a cwd is expected. `cwd` is set once here;
// callers choose the operation, never the directory.
export function createBranchSwitcherOperations(client: DaemonClient, cwd: string) {
return {
getBranchSuggestions: (limit: number) => client.getBranchSuggestions({ cwd, limit }),
listPaseoStashes: () => client.stashList(cwd, { paseoOnly: true }),
saveStash: (branch: string | undefined) => client.stashSave(cwd, { branch }),
popStash: (stashIndex: number) => client.stashPop(cwd, stashIndex),
switchBranch: (branch: string) => client.checkoutSwitchBranch(cwd, branch),
};
}
export type BranchSwitcherOperations = ReturnType<typeof createBranchSwitcherOperations>;
+14 -13
View File
@@ -15,8 +15,9 @@ import type { CheckoutPrMergeMethod } from "@getpaseo/protocol/messages";
import { openExternalUrl } from "@/utils/open-external-url";
import { 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"));
});