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

* Make workspace IDs opaque, independent of the filesystem path

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

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

* Keep attachment scope key stable and align refetch test

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

* Fix live agent updates in directories without a registered workspace

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

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

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

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

* Expect a directory-scoped placement for unregistered agent dirs

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

* Fix opaque workspace routing in app E2E

* Preserve workspace IDs during partial bootstrap

* Fix archive flows for opaque workspace IDs

* Stop treating opaque workspace IDs as filesystem paths

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

Git and filesystem operations now take the workspace directory; the opaque
id is used only for identity and cache keys. Archive/reconcile cleanup
routes through a single teardownArchivedWorkspace helper that keeps the key
split explicit: runtime store by id, git watch and subscription by cwd.
Path-derived grouping keys are renamed to directory keys, and the helpers
are split into workspace-identity and workspace-directory so the id-vs-path
boundary is obvious.
This commit is contained in:
Mohamed Boudra
2026-06-14 22:03:27 +08:00
committed by GitHub
parent f352072dac
commit 21deb08673
105 changed files with 1530 additions and 1045 deletions
+2 -2
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,
});
@@ -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,
});
@@ -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))}`;
@@ -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,
@@ -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,
});
@@ -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,
});
@@ -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,
@@ -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"),
@@ -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>,
+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"));
});
@@ -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,
+30 -22
View File
@@ -5,12 +5,14 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { ComboboxOption } from "@/components/ui/combobox";
import type { 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,
+1 -3
View File
@@ -230,9 +230,7 @@ describe("translation resources", () => {
expect(en.workspace.browser.unavailable.title).toBe("Browser is desktop-only");
expect(en.workspace.browser.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", () => {
+1 -1
View File
@@ -1292,7 +1292,7 @@ export const ar: TranslationResources = {
creatingAgent: "وكيل الخلق",
},
file: {
executionDirectoryMissing: "لم يتم العثور على دليل تنفيذ Workspace.",
directoryMissing: "لم يتم العثور على دليل Workspace.",
loading: "جارٍ تحميل الملف...",
noPreview: "لا تتوفر معاينة",
binaryPreviewUnavailable: "المعاينة الثنائية غير متاحة",
+1 -1
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",
+1 -1
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",
+1 -1
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",
+1 -1
View File
@@ -1319,7 +1319,7 @@ export const ru: TranslationResources = {
creatingAgent: "Создание агента",
},
file: {
executionDirectoryMissing: "Каталог выполнения Workspace не найден.",
directoryMissing: "Каталог Workspace не найден.",
loading: "Загрузка файла...",
noPreview: "Предварительный просмотр недоступен",
binaryPreviewUnavailable: "Предварительный просмотр двоичного файла недоступен.",
+1 -1
View File
@@ -1275,7 +1275,7 @@ export const zhCN: TranslationResources = {
creatingAgent: "正在创建 Agent",
},
file: {
executionDirectoryMissing: "未找到 workspace 执行目录。",
directoryMissing: "未找到 workspace 目录。",
loading: "正在加载文件...",
noPreview: "没有可用预览",
binaryPreviewUnavailable: "二进制预览不可用",
+2 -3
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
+3 -6
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>
);
}
+8 -22
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>
);
}
+1
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))}`;
@@ -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,
@@ -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}
@@ -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)}`;
}
@@ -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(() => {});
}
@@ -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,
@@ -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,
);
}
@@ -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();
});
});
@@ -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,
});
@@ -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",
+9 -3
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,
@@ -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}`;
}
@@ -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", () => {
+16 -2
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);
}
@@ -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,
});
@@ -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;
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "./workspace-directory";
describe("resolveWorkspaceDirectory", () => {
it("canonicalizes a workspace directory and returns null when blank", () => {
expect(resolveWorkspaceDirectory({ workspaceDirectory: "C:\\repo\\app\\" })).toBe(
"C:/repo/app",
);
expect(resolveWorkspaceDirectory({ workspaceDirectory: " " })).toBeNull();
});
});
describe("requireWorkspaceDirectory", () => {
it("returns the canonical directory when present", () => {
expect(requireWorkspaceDirectory({ workspaceDirectory: "/repo/app/" })).toBe("/repo/app");
});
it("throws naming the workspace when the directory is missing", () => {
expect(() =>
requireWorkspaceDirectory({ workspaceId: "wks_1", workspaceDirectory: " " }),
).toThrow("Workspace directory is missing for workspace wks_1");
});
});
@@ -0,0 +1,24 @@
import { normalizeWorkspacePath } from "@/utils/workspace-identity";
export function resolveWorkspaceDirectory(input: {
workspaceDirectory: string | null | undefined;
}): string | null {
return normalizeWorkspacePath(input.workspaceDirectory);
}
export function requireWorkspaceDirectory(input: {
workspaceId?: string;
workspaceDirectory: string | null | undefined;
}): string {
const workspaceDirectory = resolveWorkspaceDirectory({
workspaceDirectory: input.workspaceDirectory,
});
if (!workspaceDirectory) {
throw new Error(
input.workspaceId
? `Workspace directory is missing for workspace ${input.workspaceId}`
: "Workspace directory is missing.",
);
}
return workspaceDirectory;
}
@@ -1,191 +0,0 @@
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { normalizeWorkspaceOpaqueId, normalizeWorkspacePath } from "@/utils/workspace-identity";
export interface WorkspaceAuthorityResult {
workspaceId: string;
workspaceDirectory: string;
workspace: WorkspaceDescriptor;
}
export type WorkspaceExecutionAuthorityFailureReason =
| "workspace_id_missing"
| "workspace_missing"
| "workspace_directory_missing";
export type WorkspaceExecutionAuthorityResult =
| { ok: true; authority: WorkspaceAuthorityResult }
| {
ok: false;
reason: WorkspaceExecutionAuthorityFailureReason;
message: string;
};
export function resolveWorkspaceRouteId(input: {
routeWorkspaceId: string | null | undefined;
}): string | null {
return normalizeWorkspaceOpaqueId(input.routeWorkspaceId);
}
export function resolveWorkspaceIdByExecutionDirectory(input: {
workspaces: Iterable<WorkspaceDescriptor> | null | undefined;
workspaceDirectory: string | null | undefined;
}): string | null {
const normalizedWorkspaceDirectory = normalizeWorkspacePath(input.workspaceDirectory);
if (!normalizedWorkspaceDirectory) {
return null;
}
for (const workspace of input.workspaces ?? []) {
if (normalizeWorkspacePath(workspace.workspaceDirectory) === normalizedWorkspaceDirectory) {
return workspace.id;
}
}
return null;
}
export function resolveWorkspaceMapKeyByIdentity(input: {
workspaces: Map<string, WorkspaceDescriptor> | null | undefined;
workspaceId: string | null | undefined;
}): string | null {
const normalizedWorkspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
if (!normalizedWorkspaceId) {
return null;
}
const workspaces = input.workspaces;
if (!workspaces) {
return null;
}
if (workspaces.has(normalizedWorkspaceId)) {
return normalizedWorkspaceId;
}
for (const [workspaceKey, workspace] of workspaces) {
if (normalizeWorkspaceOpaqueId(workspace.id) === normalizedWorkspaceId) {
return workspaceKey;
}
}
return null;
}
export function getWorkspaceExecutionAuthority(
input:
| {
workspace: WorkspaceDescriptor | null | undefined;
}
| {
workspaces: Map<string, WorkspaceDescriptor> | undefined;
workspaceId: string | null | undefined;
},
): WorkspaceExecutionAuthorityResult {
const workspace =
"workspace" in input
? input.workspace
: (() => {
const workspaceKey = resolveWorkspaceMapKeyByIdentity({
workspaces: input.workspaces,
workspaceId: input.workspaceId,
});
if (!workspaceKey) {
return null;
}
return input.workspaces?.get(workspaceKey) ?? null;
})();
if ("workspaces" in input) {
const normalizedWorkspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
if (!normalizedWorkspaceId) {
return {
ok: false,
reason: "workspace_id_missing",
message: "Workspace id is required.",
};
}
}
if (!workspace) {
return {
ok: false,
reason: "workspace_missing",
message:
"workspaces" in input
? `Workspace not found: ${input.workspaceId ?? ""}`
: "Workspace not found.",
};
}
const workspaceDirectory = normalizeWorkspacePath(workspace.workspaceDirectory);
if (!workspaceDirectory) {
return {
ok: false,
reason: "workspace_directory_missing",
message: `Workspace directory is missing for workspace ${workspace.id}`,
};
}
return {
ok: true,
authority: {
workspaceId: workspace.id,
workspaceDirectory,
workspace,
},
};
}
export function requireWorkspaceExecutionAuthority(
input:
| {
workspace: WorkspaceDescriptor | null | undefined;
}
| {
workspaces: Map<string, WorkspaceDescriptor> | undefined;
workspaceId: string | null | undefined;
},
): WorkspaceAuthorityResult {
const result = getWorkspaceExecutionAuthority(input);
if (!result.ok) {
throw new Error(result.message);
}
return result.authority;
}
export function resolveWorkspaceExecutionDirectory(input: {
workspaceDirectory: string | null | undefined;
}): string | null {
return normalizeWorkspacePath(input.workspaceDirectory);
}
export function requireWorkspaceExecutionDirectory(input: {
workspaceId?: string;
workspaceDirectory: string | null | undefined;
}): string {
const workspaceDirectory = resolveWorkspaceExecutionDirectory({
workspaceDirectory: input.workspaceDirectory,
});
if (!workspaceDirectory) {
throw new Error(
input.workspaceId
? `Workspace directory is missing for workspace ${input.workspaceId}`
: "Workspace directory is missing.",
);
}
return workspaceDirectory;
}
export function resolveWorkspaceExecutionAuthority(
input:
| {
workspace: WorkspaceDescriptor | null | undefined;
}
| {
workspaces: Map<string, WorkspaceDescriptor> | undefined;
workspaceId: string | null | undefined;
},
): WorkspaceAuthorityResult | null {
const result = getWorkspaceExecutionAuthority(input);
return result.ok ? result.authority : null;
}
@@ -1,12 +1,10 @@
import { describe, expect, it } from "vitest";
import 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"),
});
});
});
@@ -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;
}
@@ -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);
@@ -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;