refactor: transition to opaque workspace identifiers

This commit is contained in:
Mohamed Boudra
2026-04-15 17:59:09 +07:00
parent beeb315c8d
commit e6044264e2
34 changed files with 462 additions and 382 deletions
+17 -8
View File
@@ -24,13 +24,13 @@ describe("parseHostAgentRouteFromPathname", () => {
});
describe("workspace route parsing", () => {
it("encodes numeric workspace IDs without base64", () => {
it("keeps URL-safe workspace IDs unencoded", () => {
expect(encodeWorkspaceIdForPathSegment("164")).toBe("164");
expect(decodeWorkspaceIdFromPathSegment("164")).toBe("164");
});
it("encodes path-based workspace IDs as base64url (legacy)", () => {
expect(encodeWorkspaceIdForPathSegment("/tmp/repo")).toBe("L3RtcC9yZXBv");
it("encodes non-URL-safe workspace IDs as base64url", () => {
expect(encodeWorkspaceIdForPathSegment("/tmp/repo")).toBe("b64_L3RtcC9yZXBv");
expect(decodeWorkspaceIdFromPathSegment("L3RtcC9yZXBv")).toBe("/tmp/repo");
});
@@ -46,7 +46,7 @@ describe("workspace route parsing", () => {
expect(decodeFilePathFromPathSegment(encoded)).toBe("src/index.ts");
});
it("parses workspace route with numeric ID", () => {
it("parses workspace route with a plain workspace id", () => {
expect(parseHostWorkspaceRouteFromPathname("/h/local/workspace/164")).toEqual({
serverId: "local",
workspaceId: "164",
@@ -66,12 +66,14 @@ describe("workspace route parsing", () => {
).toBeNull();
});
it("builds numeric workspace routes without base64", () => {
it("builds plain workspace routes for URL-safe ids", () => {
expect(buildHostWorkspaceRoute("local", "164")).toBe("/h/local/workspace/164");
});
it("builds base64url workspace routes for legacy paths", () => {
expect(buildHostWorkspaceRoute("local", "/tmp/repo")).toBe("/h/local/workspace/L3RtcC9yZXBv");
expect(buildHostWorkspaceRoute("local", "/tmp/repo")).toBe(
"/h/local/workspace/b64_L3RtcC9yZXBv",
);
});
it("builds host root routes", () => {
@@ -115,12 +117,19 @@ describe("workspace route parsing", () => {
);
});
it("round-trips numeric IDs through encode/decode", () => {
const ids = ["1", "40", "164", "9999"];
it("round-trips URL-safe IDs through encode/decode", () => {
const ids = ["1", "40", "164", "9999", "workspace-1", "opaque_id.v2~test"];
for (const id of ids) {
const encoded = encodeWorkspaceIdForPathSegment(id);
const decoded = decodeWorkspaceIdFromPathSegment(encoded);
expect(decoded).toBe(id);
}
});
it("round-trips opaque IDs with reserved characters through base64 encoding", () => {
const id = " team/setup:id#1 ";
const encoded = encodeWorkspaceIdForPathSegment(id);
expect(encoded).toBe("b64_dGVhbS9zZXR1cDppZCMx");
expect(decodeWorkspaceIdFromPathSegment(encoded)).toBe("team/setup:id#1");
});
});
+19 -23
View File
@@ -1,6 +1,7 @@
import { Buffer } from "buffer";
type NullableString = string | null | undefined;
const BASE64_WORKSPACE_ID_PREFIX = "b64_";
function stripSearchAndHash(pathname: string): string {
const hashIndex = pathname.indexOf("#");
@@ -87,12 +88,16 @@ function tryDecodeBase64UrlNoPadUtf8(input: string): string | null {
return decoded;
}
function isPathLikeWorkspaceIdentity(value: string): boolean {
return value.includes("/") || value.includes("\\") || /^[A-Za-z]:[\\/]/.test(value);
function normalizeWorkspaceId(value: string): string {
return value.trim();
}
function normalizeWorkspaceId(value: string): string {
return value.trim().replace(/\\/g, "/").replace(/\/+$/, "");
function isUrlSafeWorkspaceId(value: string): boolean {
return /^[A-Za-z0-9._~-]+$/.test(value);
}
function isLegacyPathLikeWorkspaceValue(value: string): boolean {
return value.includes("/") || value.includes("\\") || /^[A-Za-z]:[\\/]/.test(value);
}
export type WorkspaceOpenIntent =
@@ -163,13 +168,11 @@ export function encodeWorkspaceIdForPathSegment(workspaceId: string): string {
if (!normalized) {
return "";
}
// Numeric string IDs are URL-safe and don't need encoding.
// Legacy path-based IDs still get base64-encoded for safety.
const id = normalizeWorkspaceId(normalized);
if (isPathLikeWorkspaceIdentity(id)) {
return toBase64UrlNoPad(id);
if (isUrlSafeWorkspaceId(id)) {
return id;
}
return encodeURIComponent(id);
return `${BASE64_WORKSPACE_ID_PREFIX}${toBase64UrlNoPad(id)}`;
}
export function decodeWorkspaceIdFromPathSegment(workspaceIdSegment: string): string | null {
@@ -178,32 +181,25 @@ export function decodeWorkspaceIdFromPathSegment(workspaceIdSegment: string): st
return null;
}
// Decode %2F etc first (legacy scheme), but keep the raw segment to decide if base64 applies.
const decoded = trimNonEmpty(decodeSegment(normalizedSegment));
if (!decoded) {
return null;
}
// Legacy: if it already looks like a path after decoding, keep it.
if (decoded.includes("/") || decoded.includes("\\")) {
return normalizeWorkspaceId(decoded);
}
// If the segment looks like a plain numeric ID, return it directly.
// Do NOT attempt base64 decode on short alphanumeric strings.
if (/^\d+$/.test(decoded)) {
return decoded;
if (decoded.startsWith(BASE64_WORKSPACE_ID_PREFIX)) {
const encodedPayload = decoded.slice(BASE64_WORKSPACE_ID_PREFIX.length);
const prefixedDecoded =
tryDecodeBase64UrlNoPadUtf8(encodedPayload) ?? decodeBase64UrlNoPadUtf8(encodedPayload);
return prefixedDecoded ? normalizeWorkspaceId(prefixedDecoded) : null;
}
const base64Decoded = tryDecodeBase64UrlNoPadUtf8(decoded);
if (base64Decoded) {
if (base64Decoded && isLegacyPathLikeWorkspaceValue(base64Decoded)) {
return normalizeWorkspaceId(base64Decoded);
}
// Some older links use non-canonical base64url (non-zero pad bits). Accept
// decoded values only when they clearly represent filesystem paths.
const relaxedBase64Decoded = decodeBase64UrlNoPadUtf8(decoded);
if (relaxedBase64Decoded && isPathLikeWorkspaceIdentity(relaxedBase64Decoded)) {
if (relaxedBase64Decoded && isLegacyPathLikeWorkspaceValue(relaxedBase64Decoded)) {
return normalizeWorkspaceId(relaxedBase64Decoded);
}
@@ -50,9 +50,9 @@ describe("buildNotificationRoute", () => {
buildNotificationRoute({
serverId: "srv-1",
agentId: "agent-1",
workspaceId: "/tmp/repo",
workspaceId: "ws-main",
}),
).toBe("/h/srv-1/workspace/L3RtcC9yZXBv?open=agent%3Aagent-1");
).toBe("/h/srv-1/workspace/ws-main?open=agent%3Aagent-1");
});
it("routes directly to server-scoped agent path when both ids are present", () => {
@@ -10,9 +10,10 @@ import type {
function workspace(overrides: Partial<SidebarWorkspaceEntry> = {}): SidebarWorkspaceEntry {
return {
workspaceKey: "srv:/repo",
workspaceKey: "srv:ws-root",
serverId: "srv",
workspaceId: "/repo",
workspaceId: "ws-root",
workspaceDirectory: "/repo",
projectKind: "git",
workspaceKind: "checkout",
name: "paseo",
@@ -41,7 +42,7 @@ function project(overrides: Partial<SidebarProjectEntry> = {}): SidebarProjectEn
describe("buildSidebarProjectRowModel", () => {
it("flattens non-git projects with one workspace into a direct workspace row model", () => {
const flattenedWorkspace = workspace({
workspaceId: "/repo/non-git",
workspaceId: "ws-non-git",
workspaceKind: "checkout",
statusBucket: "running",
});
@@ -66,7 +67,7 @@ describe("buildSidebarProjectRowModel", () => {
it("marks flattened non-git project rows as selected when their workspace is active", () => {
const flattenedWorkspace = workspace({
serverId: "srv-2",
workspaceId: "/repo/non-git",
workspaceId: "ws-non-git",
});
const result = buildSidebarProjectRowModel({
@@ -78,7 +79,7 @@ describe("buildSidebarProjectRowModel", () => {
serverId: "srv-2",
activeWorkspaceSelection: {
serverId: "srv-2",
workspaceId: "/repo/non-git",
workspaceId: "ws-non-git",
},
});
@@ -90,7 +91,7 @@ describe("buildSidebarProjectRowModel", () => {
it("keeps single-workspace git projects as sections with the new worktree action", () => {
const onlyWorkspace = workspace({
workspaceId: "/repo/main",
workspaceId: "ws-main",
workspaceKind: "checkout",
});
@@ -114,8 +115,8 @@ describe("buildSidebarProjectRowModel", () => {
project: project({
projectKind: "git",
workspaces: [
workspace({ workspaceId: "/repo/main", workspaceKind: "checkout" }),
workspace({ workspaceId: "/repo/feature", workspaceKind: "worktree" }),
workspace({ workspaceId: "ws-main", workspaceKind: "checkout" }),
workspace({ workspaceId: "ws-feature", workspaceKind: "worktree" }),
],
}),
collapsed: true,
@@ -144,8 +145,8 @@ describe("isSidebarProjectFlattened", () => {
isSidebarProjectFlattened(
project({
workspaces: [
workspace({ workspaceId: "/repo/main" }),
workspace({ workspaceId: "/repo/feat" }),
workspace({ workspaceId: "ws-main" }),
workspace({ workspaceId: "ws-feat" }),
],
}),
),
@@ -6,14 +6,20 @@ import type {
import { buildSidebarShortcutModel } from "./sidebar-shortcuts";
function workspace(serverId: string, cwd: string): SidebarWorkspaceEntry {
function workspace(input: {
serverId: string;
workspaceId: string;
workspaceDirectory: string;
name: string;
}): SidebarWorkspaceEntry {
return {
workspaceKey: `${serverId}:${cwd}`,
serverId,
workspaceId: cwd,
workspaceKey: `${input.serverId}:${input.workspaceId}`,
serverId: input.serverId,
workspaceId: input.workspaceId,
workspaceDirectory: input.workspaceDirectory,
projectKind: "git",
workspaceKind: "checkout",
name: cwd,
name: input.name,
statusBucket: "done",
diffStat: null,
scripts: [],
@@ -26,7 +32,7 @@ function project(projectKey: string, workspaces: SidebarWorkspaceEntry[]): Sideb
projectKey,
projectName: projectKey,
projectKind: "git",
iconWorkingDir: workspaces[0]?.workspaceId ?? "",
iconWorkingDir: workspaces[0]?.workspaceDirectory ?? "",
statusBucket: "done",
activeCount: 0,
totalWorkspaces: workspaces.length,
@@ -37,8 +43,34 @@ function project(projectKey: string, workspaces: SidebarWorkspaceEntry[]): Sideb
describe("buildSidebarShortcutModel", () => {
it("builds shortcut targets in visual order and excludes collapsed projects", () => {
const projects = [
project("p1", [workspace("s1", "/repo/main"), workspace("s1", "/repo/feat-a")]),
project("p2", [workspace("s1", "/repo2/main"), workspace("s1", "/repo2/feat-a")]),
project("p1", [
workspace({
serverId: "s1",
workspaceId: "ws-main",
workspaceDirectory: "/repo/main",
name: "main",
}),
workspace({
serverId: "s1",
workspaceId: "ws-feat-a",
workspaceDirectory: "/repo/feat-a",
name: "feat-a",
}),
]),
project("p2", [
workspace({
serverId: "s1",
workspaceId: "ws-repo2-main",
workspaceDirectory: "/repo2/main",
name: "main",
}),
workspace({
serverId: "s1",
workspaceId: "ws-repo2-feat-a",
workspaceDirectory: "/repo2/feat-a",
name: "feat-a",
}),
]),
];
const model = buildSidebarShortcutModel({
@@ -47,21 +79,26 @@ describe("buildSidebarShortcutModel", () => {
});
expect(model.visibleTargets).toEqual([
{ serverId: "s1", workspaceId: "/repo/main" },
{ serverId: "s1", workspaceId: "/repo/feat-a" },
{ serverId: "s1", workspaceId: "ws-main" },
{ serverId: "s1", workspaceId: "ws-feat-a" },
]);
expect(model.shortcutTargets).toEqual([
{ serverId: "s1", workspaceId: "/repo/main" },
{ serverId: "s1", workspaceId: "/repo/feat-a" },
{ serverId: "s1", workspaceId: "ws-main" },
{ serverId: "s1", workspaceId: "ws-feat-a" },
]);
expect(model.shortcutIndexByWorkspaceKey.get("s1:/repo/main")).toBe(1);
expect(model.shortcutIndexByWorkspaceKey.get("s1:/repo/feat-a")).toBe(2);
expect(model.shortcutIndexByWorkspaceKey.get("s1:/repo2/main")).toBeUndefined();
expect(model.shortcutIndexByWorkspaceKey.get("s1:ws-main")).toBe(1);
expect(model.shortcutIndexByWorkspaceKey.get("s1:ws-feat-a")).toBe(2);
expect(model.shortcutIndexByWorkspaceKey.get("s1:ws-repo2-main")).toBeUndefined();
});
it("limits shortcuts to 9", () => {
const workspaces = Array.from({ length: 20 }, (_, index) =>
workspace("s", `/repo/w${index + 1}`),
workspace({
serverId: "s",
workspaceId: `ws-${index + 1}`,
workspaceDirectory: `/repo/w${index + 1}`,
name: `w${index + 1}`,
}),
);
const projects = [project("p", workspaces)];
@@ -71,14 +108,23 @@ describe("buildSidebarShortcutModel", () => {
});
expect(model.visibleTargets).toHaveLength(20);
expect(model.visibleTargets[19]).toEqual({ serverId: "s", workspaceId: "/repo/w20" });
expect(model.visibleTargets[19]).toEqual({ serverId: "s", workspaceId: "ws-20" });
expect(model.shortcutTargets).toHaveLength(9);
expect(model.shortcutTargets[0]).toEqual({ serverId: "s", workspaceId: "/repo/w1" });
expect(model.shortcutTargets[8]).toEqual({ serverId: "s", workspaceId: "/repo/w9" });
expect(model.shortcutTargets[0]).toEqual({ serverId: "s", workspaceId: "ws-1" });
expect(model.shortcutTargets[8]).toEqual({ serverId: "s", workspaceId: "ws-9" });
});
it("still excludes collapsed single-workspace git projects because they are not flattened", () => {
const projects = [project("p1", [workspace("s1", "/repo/main")])];
const projects = [
project("p1", [
workspace({
serverId: "s1",
workspaceId: "ws-main",
workspaceDirectory: "/repo/main",
name: "main",
}),
]),
];
const model = buildSidebarShortcutModel({
projects,
@@ -27,8 +27,10 @@ function createWorkspace(
}
describe("resolveWorkspaceRouteId", () => {
it("normalizes route workspace ids", () => {
expect(resolveWorkspaceRouteId({ routeWorkspaceId: " /tmp/repo/ " })).toBe("/tmp/repo");
it("trims route workspace ids without path normalization", () => {
expect(resolveWorkspaceRouteId({ routeWorkspaceId: " C:\\tmp\\repo\\ " })).toBe(
"C:\\tmp\\repo\\",
);
});
it("returns null for empty values", () => {
@@ -87,12 +89,12 @@ describe("resolveWorkspaceMapKeyByIdentity", () => {
expect(
resolveWorkspaceMapKeyByIdentity({
workspaces,
workspaceIdentity: "workspace-1",
workspaceId: "workspace-1",
}),
).toBe("workspace-1");
});
it("resolves a workspace directory identity to the canonical map key", () => {
it("does not resolve workspace directories when an id is required", () => {
const workspaces = new Map<string, WorkspaceDescriptor>([
[
"workspace-1",
@@ -106,9 +108,9 @@ describe("resolveWorkspaceMapKeyByIdentity", () => {
expect(
resolveWorkspaceMapKeyByIdentity({
workspaces,
workspaceIdentity: "C:/repo/feature",
workspaceId: "C:/repo/feature",
}),
).toBe("workspace-1");
).toBeNull();
});
});
+14 -33
View File
@@ -1,5 +1,5 @@
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { normalizeWorkspaceIdentity } from "@/utils/workspace-identity";
import { normalizeWorkspaceOpaqueId, normalizeWorkspacePath } from "@/utils/workspace-identity";
export type WorkspaceAuthorityResult = {
workspaceId: string;
@@ -23,20 +23,20 @@ export type WorkspaceExecutionAuthorityResult =
export function resolveWorkspaceRouteId(input: {
routeWorkspaceId: string | null | undefined;
}): string | null {
return normalizeWorkspaceIdentity(input.routeWorkspaceId);
return normalizeWorkspaceOpaqueId(input.routeWorkspaceId);
}
export function resolveWorkspaceIdByExecutionDirectory(input: {
workspaces: Iterable<WorkspaceDescriptor> | null | undefined;
workspaceDirectory: string | null | undefined;
}): string | null {
const normalizedWorkspaceDirectory = normalizeWorkspaceIdentity(input.workspaceDirectory);
const normalizedWorkspaceDirectory = normalizeWorkspacePath(input.workspaceDirectory);
if (!normalizedWorkspaceDirectory) {
return null;
}
for (const workspace of input.workspaces ?? []) {
if (normalizeWorkspaceIdentity(workspace.workspaceDirectory) === normalizedWorkspaceDirectory) {
if (normalizeWorkspacePath(workspace.workspaceDirectory) === normalizedWorkspaceDirectory) {
return workspace.id;
}
}
@@ -46,10 +46,10 @@ export function resolveWorkspaceIdByExecutionDirectory(input: {
export function resolveWorkspaceMapKeyByIdentity(input: {
workspaces: Map<string, WorkspaceDescriptor> | null | undefined;
workspaceIdentity: string | null | undefined;
workspaceId: string | null | undefined;
}): string | null {
const normalizedWorkspaceIdentity = normalizeWorkspaceIdentity(input.workspaceIdentity);
if (!normalizedWorkspaceIdentity) {
const normalizedWorkspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
if (!normalizedWorkspaceId) {
return null;
}
@@ -58,15 +58,12 @@ export function resolveWorkspaceMapKeyByIdentity(input: {
return null;
}
if (workspaces.has(normalizedWorkspaceIdentity)) {
return normalizedWorkspaceIdentity;
if (workspaces.has(normalizedWorkspaceId)) {
return normalizedWorkspaceId;
}
for (const [workspaceKey, workspace] of workspaces) {
if (
normalizeWorkspaceIdentity(workspace.id) === normalizedWorkspaceIdentity ||
normalizeWorkspaceIdentity(workspace.workspaceDirectory) === normalizedWorkspaceIdentity
) {
if (normalizeWorkspaceOpaqueId(workspace.id) === normalizedWorkspaceId) {
return workspaceKey;
}
}
@@ -90,7 +87,7 @@ export function getWorkspaceExecutionAuthority(
: (() => {
const workspaceKey = resolveWorkspaceMapKeyByIdentity({
workspaces: input.workspaces,
workspaceIdentity: input.workspaceId,
workspaceId: input.workspaceId,
});
if (!workspaceKey) {
return null;
@@ -99,7 +96,7 @@ export function getWorkspaceExecutionAuthority(
})();
if ("workspaces" in input) {
const normalizedWorkspaceId = normalizeWorkspaceIdentity(input.workspaceId);
const normalizedWorkspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
if (!normalizedWorkspaceId) {
return {
ok: false,
@@ -120,7 +117,7 @@ export function getWorkspaceExecutionAuthority(
};
}
const workspaceDirectory = normalizeWorkspaceIdentity(workspace.workspaceDirectory);
const workspaceDirectory = normalizeWorkspacePath(workspace.workspaceDirectory);
if (!workspaceDirectory) {
return {
ok: false,
@@ -156,24 +153,10 @@ export function requireWorkspaceExecutionAuthority(
return result.authority;
}
export function requireWorkspaceRecordId(workspaceId: string): number {
const normalizedWorkspaceId = normalizeWorkspaceIdentity(workspaceId);
if (!normalizedWorkspaceId) {
throw new Error("Workspace ID is required");
}
const parsedWorkspaceId = Number(normalizedWorkspaceId);
if (!Number.isInteger(parsedWorkspaceId)) {
throw new Error(`Workspace ID is not a persisted record ID: ${workspaceId}`);
}
return parsedWorkspaceId;
}
export function resolveWorkspaceExecutionDirectory(input: {
workspaceDirectory: string | null | undefined;
}): string | null {
return normalizeWorkspaceIdentity(input.workspaceDirectory);
return normalizeWorkspacePath(input.workspaceDirectory);
}
export function requireWorkspaceExecutionDirectory(input: {
@@ -206,5 +189,3 @@ export function resolveWorkspaceExecutionAuthority(
const result = getWorkspaceExecutionAuthority(input);
return result.ok ? result.authority : null;
}
export const parseWorkspaceRecordId = requireWorkspaceRecordId;
+5 -1
View File
@@ -6,7 +6,11 @@ function trimNonEmpty(value: string | null | undefined): string | null {
return trimmed.length > 0 ? trimmed : null;
}
export function normalizeWorkspaceIdentity(value: string | null | undefined): string | null {
export function normalizeWorkspaceOpaqueId(value: string | null | undefined): string | null {
return trimNonEmpty(value);
}
export function normalizeWorkspacePath(value: string | null | undefined): string | null {
const trimmed = trimNonEmpty(value);
if (!trimmed) {
return null;
@@ -24,7 +24,7 @@ export function normalizeWorkspaceTabTarget(
}
if (value.kind === "setup") {
const workspaceId = trimNonEmpty(value.workspaceId);
return workspaceId ? { kind: "setup", workspaceId: workspaceId.replace(/\\/g, "/") } : null;
return workspaceId ? { kind: "setup", workspaceId } : null;
}
return null;
}