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