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:
@@ -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)}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user