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