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