diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 5cb365f8d..f25f6783c 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -1280,7 +1280,7 @@ function WorkspaceRowWithMenu({ void (async () => { try { - const payload = await client.archiveWorkspace(Number(workspace.workspaceId)); + const payload = await client.archiveWorkspace(workspace.workspaceId); if (payload.error) { throw new Error(payload.error); } @@ -1438,7 +1438,7 @@ function NonGitProjectRowWithMenuContent({ void (async () => { try { - const payload = await client.archiveWorkspace(Number(workspace.workspaceId)); + const payload = await client.archiveWorkspace(workspace.workspaceId); if (payload.error) { throw new Error(payload.error); } @@ -1792,7 +1792,7 @@ function ProjectBlock({ void Promise.allSettled( project.workspaces.map(async (ws) => { - const payload = await client.archiveWorkspace(Number(ws.workspaceId)); + const payload = await client.archiveWorkspace(ws.workspaceId); if (payload.error) { throw new Error(payload.error); } diff --git a/packages/app/src/contexts/session-context.service-status.test.ts b/packages/app/src/contexts/session-context.service-status.test.ts index f849656fa..920f7b20b 100644 --- a/packages/app/src/contexts/session-context.service-status.test.ts +++ b/packages/app/src/contexts/session-context.service-status.test.ts @@ -5,6 +5,7 @@ import { patchWorkspaceScripts } from "./session-workspace-scripts"; function workspace(input: { id: string; + workspaceDirectory?: string; scripts?: WorkspaceDescriptor["scripts"]; }): WorkspaceDescriptor { return { @@ -12,7 +13,7 @@ function workspace(input: { projectId: "project-1", projectDisplayName: "Project 1", projectRootPath: "/repo", - workspaceDirectory: input.id, + workspaceDirectory: input.workspaceDirectory ?? "/repo/main", projectKind: "git", workspaceKind: "checkout", name: "main", @@ -35,58 +36,54 @@ const runningScript: WorkspaceScriptPayload = { describe("patchWorkspaceScripts", () => { it("patches only the matching workspace scripts", () => { - const other = workspace({ id: "/repo/other", scripts: [] }); + const other = workspace({ id: "ws-other", workspaceDirectory: "/repo/other", scripts: [] }); const current = new Map([ - ["/repo/main", workspace({ id: "/repo/main", scripts: [] })], + ["ws-main", workspace({ id: "ws-main", workspaceDirectory: "/repo/main", scripts: [] })], [other.id, other], ]); const next = patchWorkspaceScripts(current, { - workspaceId: "/repo/main", + workspaceId: "ws-main", scripts: [runningScript], }); expect(next).not.toBe(current); - expect(next.get("/repo/main")?.scripts).toEqual([runningScript]); - expect(next.get("/repo/other")).toBe(other); + expect(next.get("ws-main")?.scripts).toEqual([runningScript]); + expect(next.get("ws-other")).toBe(other); }); - it("patches the matching workspace when the update uses workspace directory identity", () => { + it("patches the matching workspace when the map key differs from the workspace id", () => { const current = new Map([ [ - "42", + "workspace-record-42", workspace({ - id: "42", + id: "ws-main", + workspaceDirectory: "C:\\repo\\main\\", scripts: [], }), ], ]); - current.set("42", { - ...current.get("42")!, - workspaceDirectory: "C:\\repo\\main\\", - }); - const next = patchWorkspaceScripts(current, { - workspaceId: "C:/repo/main", + workspaceId: "ws-main", scripts: [runningScript], }); expect(next).not.toBe(current); - expect(next.get("42")?.scripts).toEqual([runningScript]); + expect(next.get("workspace-record-42")?.scripts).toEqual([runningScript]); }); it("ignores updates for unknown workspaces", () => { const current = new Map([ - ["/repo/main", workspace({ id: "/repo/main", scripts: [] })], + ["ws-main", workspace({ id: "ws-main", workspaceDirectory: "/repo/main", scripts: [] })], ]); const next = patchWorkspaceScripts(current, { - workspaceId: "/repo/missing", + workspaceId: "ws-missing", scripts: [runningScript], }); expect(next).toBe(current); - expect(next.get("/repo/main")?.scripts).toEqual([]); + expect(next.get("ws-main")?.scripts).toEqual([]); }); }); diff --git a/packages/app/src/contexts/session-workspace-scripts.ts b/packages/app/src/contexts/session-workspace-scripts.ts index c2ccf9b2a..7a8ed4557 100644 --- a/packages/app/src/contexts/session-workspace-scripts.ts +++ b/packages/app/src/contexts/session-workspace-scripts.ts @@ -8,7 +8,7 @@ export function patchWorkspaceScripts( ): Map { const workspaceKey = resolveWorkspaceMapKeyByIdentity({ workspaces, - workspaceIdentity: update.workspaceId, + workspaceId: update.workspaceId, }); if (!workspaceKey) { return workspaces; diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.ts index 9bf1ed285..d8e847e34 100644 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.ts +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.ts @@ -80,7 +80,6 @@ function compareWorkspaceBaseline( } return left.workspaceId.localeCompare(right.workspaceId, undefined, { - numeric: true, sensitivity: "base", }); } diff --git a/packages/app/src/screens/workspace/workspace-agent-visibility.test.ts b/packages/app/src/screens/workspace/workspace-agent-visibility.test.ts index 7d03db8ac..4cc0ad96e 100644 --- a/packages/app/src/screens/workspace/workspace-agent-visibility.test.ts +++ b/packages/app/src/screens/workspace/workspace-agent-visibility.test.ts @@ -54,15 +54,15 @@ function makeAgent(input: { describe("workspace agent visibility", () => { it("keeps archived agents out of activeAgentIds but present in knownAgentIds", () => { - const workspaceId = "/repo/worktree"; + const workspaceDirectory = "/repo/worktree"; const visible = makeAgent({ id: "visible-agent", - cwd: workspaceId, + cwd: workspaceDirectory, createdAt: new Date("2026-03-04T00:00:00.000Z"), }); const archived = makeAgent({ id: "archived-agent", - cwd: workspaceId, + cwd: workspaceDirectory, archivedAt: new Date("2026-03-04T00:01:00.000Z"), createdAt: new Date("2026-03-04T00:01:00.000Z"), }); @@ -79,7 +79,7 @@ describe("workspace agent visibility", () => { const result = deriveWorkspaceAgentVisibility({ sessionAgents, - workspaceDirectory: workspaceId, + workspaceDirectory, }); expect(result.activeAgentIds).toEqual(new Set(["visible-agent"])); diff --git a/packages/app/src/screens/workspace/workspace-agent-visibility.ts b/packages/app/src/screens/workspace/workspace-agent-visibility.ts index a15a899b3..949b54bda 100644 --- a/packages/app/src/screens/workspace/workspace-agent-visibility.ts +++ b/packages/app/src/screens/workspace/workspace-agent-visibility.ts @@ -1,8 +1,8 @@ import type { Agent } from "@/stores/session-store"; -import { normalizeWorkspaceIdentity } from "@/utils/workspace-identity"; +import { normalizeWorkspacePath } from "@/utils/workspace-identity"; function normalizeWorkspaceId(value: string | null | undefined): string { - return normalizeWorkspaceIdentity(value) ?? ""; + return normalizeWorkspacePath(value) ?? ""; } export interface WorkspaceAgentVisibility { diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index 74bec9bf5..e4242cb31 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -27,7 +27,7 @@ import type { AgentSnapshotPayload, WorkspaceDescriptorPayload, } from "@server/shared/messages"; -import { normalizeWorkspaceIdentity } from "@/utils/workspace-identity"; +import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity"; import { createAgentLastActivityCoalescer, type AgentLastActivityCommitter, @@ -130,7 +130,7 @@ export function normalizeWorkspaceDescriptor( payload: WorkspaceDescriptorPayload, ): WorkspaceDescriptor { return { - id: normalizeWorkspaceIdentity(String(payload.id)) ?? String(payload.id), + id: normalizeWorkspaceOpaqueId(String(payload.id)) ?? String(payload.id), projectId: String(payload.projectId), projectDisplayName: payload.projectDisplayName, projectRootPath: payload.projectRootPath, diff --git a/packages/app/src/stores/workspace-layout-store.test.ts b/packages/app/src/stores/workspace-layout-store.test.ts index 07ecbdc45..32a6a4f69 100644 --- a/packages/app/src/stores/workspace-layout-store.test.ts +++ b/packages/app/src/stores/workspace-layout-store.test.ts @@ -32,7 +32,7 @@ import { } from "@/stores/workspace-layout-store"; const SERVER_ID = "server-1"; -const WORKSPACE_ID = "/repo/worktree"; +const WORKSPACE_ID = "ws-main"; function createTab(tabId: string): WorkspaceTab { return { @@ -813,7 +813,7 @@ describe("workspace-layout-store actions", () => { const workspaceKey = createWorkspaceKey(); const otherWorkspaceKey = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, - workspaceId: "/repo/other-worktree", + workspaceId: "ws-other-worktree", }); expect(otherWorkspaceKey).toBeTruthy(); @@ -849,7 +849,7 @@ describe("workspace-layout-store actions", () => { const workspaceKey = createWorkspaceKey(); const otherWorkspaceKey = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, - workspaceId: "/repo/other-worktree", + workspaceId: "ws-other-worktree", }); expect(otherWorkspaceKey).toBeTruthy(); diff --git a/packages/app/src/stores/workspace-tabs-store.test.ts b/packages/app/src/stores/workspace-tabs-store.test.ts index 81a215484..225361a1b 100644 --- a/packages/app/src/stores/workspace-tabs-store.test.ts +++ b/packages/app/src/stores/workspace-tabs-store.test.ts @@ -23,6 +23,17 @@ import { const SERVER_ID = "server-1"; const WORKSPACE_ID = "/repo/worktree"; +describe("buildWorkspaceTabPersistenceKey", () => { + it("preserves opaque workspace ids instead of normalizing them like paths", () => { + expect( + buildWorkspaceTabPersistenceKey({ + serverId: SERVER_ID, + workspaceId: " setup\\workspace\\ ", + }), + ).toBe("server-1:setup\\workspace\\"); + }); +}); + describe("workspace-tabs-store retargetTab", () => { beforeEach(() => { useWorkspaceTabsStore.setState({ diff --git a/packages/app/src/stores/workspace-tabs-store.ts b/packages/app/src/stores/workspace-tabs-store.ts index f1821ab32..96582500b 100644 --- a/packages/app/src/stores/workspace-tabs-store.ts +++ b/packages/app/src/stores/workspace-tabs-store.ts @@ -28,10 +28,6 @@ function trimNonEmpty(value: string | null | undefined): string | null { return trimmed.length > 0 ? trimmed : null; } -function normalizeWorkspaceId(value: string): string { - return value.trim().replace(/\\/g, "/").replace(/\/+$/, ""); -} - export function buildWorkspaceTabPersistenceKey(input: { serverId: string; workspaceId: string; @@ -41,7 +37,7 @@ export function buildWorkspaceTabPersistenceKey(input: { if (!serverId || !workspaceId) { return null; } - return `${serverId}:${normalizeWorkspaceId(workspaceId)}`; + return `${serverId}:${workspaceId}`; } function normalizeTabOrder(list: unknown): string[] { diff --git a/packages/app/src/utils/host-routes.test.ts b/packages/app/src/utils/host-routes.test.ts index ffad6176b..3b925badc 100644 --- a/packages/app/src/utils/host-routes.test.ts +++ b/packages/app/src/utils/host-routes.test.ts @@ -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"); + }); }); diff --git a/packages/app/src/utils/host-routes.ts b/packages/app/src/utils/host-routes.ts index b8326da9b..ca4b06c42 100644 --- a/packages/app/src/utils/host-routes.ts +++ b/packages/app/src/utils/host-routes.ts @@ -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); } diff --git a/packages/app/src/utils/notification-routing.test.ts b/packages/app/src/utils/notification-routing.test.ts index 099b20ef1..33859b5fc 100644 --- a/packages/app/src/utils/notification-routing.test.ts +++ b/packages/app/src/utils/notification-routing.test.ts @@ -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", () => { diff --git a/packages/app/src/utils/sidebar-project-row-model.test.ts b/packages/app/src/utils/sidebar-project-row-model.test.ts index 13662c3dd..e3640f525 100644 --- a/packages/app/src/utils/sidebar-project-row-model.test.ts +++ b/packages/app/src/utils/sidebar-project-row-model.test.ts @@ -10,9 +10,10 @@ import type { function workspace(overrides: Partial = {}): 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 = {}): 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" }), ], }), ), diff --git a/packages/app/src/utils/sidebar-shortcuts.test.ts b/packages/app/src/utils/sidebar-shortcuts.test.ts index 72e7435a5..ef7b31701 100644 --- a/packages/app/src/utils/sidebar-shortcuts.test.ts +++ b/packages/app/src/utils/sidebar-shortcuts.test.ts @@ -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, diff --git a/packages/app/src/utils/workspace-execution.test.ts b/packages/app/src/utils/workspace-execution.test.ts index f9a48a4d6..335b16399 100644 --- a/packages/app/src/utils/workspace-execution.test.ts +++ b/packages/app/src/utils/workspace-execution.test.ts @@ -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([ [ "workspace-1", @@ -106,9 +108,9 @@ describe("resolveWorkspaceMapKeyByIdentity", () => { expect( resolveWorkspaceMapKeyByIdentity({ workspaces, - workspaceIdentity: "C:/repo/feature", + workspaceId: "C:/repo/feature", }), - ).toBe("workspace-1"); + ).toBeNull(); }); }); diff --git a/packages/app/src/utils/workspace-execution.ts b/packages/app/src/utils/workspace-execution.ts index d1a2ca80c..0595539eb 100644 --- a/packages/app/src/utils/workspace-execution.ts +++ b/packages/app/src/utils/workspace-execution.ts @@ -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 | 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 | 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; diff --git a/packages/app/src/utils/workspace-identity.ts b/packages/app/src/utils/workspace-identity.ts index 150df4885..bb982c23b 100644 --- a/packages/app/src/utils/workspace-identity.ts +++ b/packages/app/src/utils/workspace-identity.ts @@ -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; diff --git a/packages/app/src/utils/workspace-tab-identity.ts b/packages/app/src/utils/workspace-tab-identity.ts index 5de7dcff2..87d6085f8 100644 --- a/packages/app/src/utils/workspace-tab-identity.ts +++ b/packages/app/src/utils/workspace-tab-identity.ts @@ -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; } diff --git a/packages/server/src/client/daemon-client.test.ts b/packages/server/src/client/daemon-client.test.ts index e51419cc4..c9b7a4bcd 100644 --- a/packages/server/src/client/daemon-client.test.ts +++ b/packages/server/src/client/daemon-client.test.ts @@ -239,7 +239,7 @@ describe("DaemonClient", () => { wrapSessionMessage({ type: "workspace_setup_progress", payload: { - workspaceId: "/tmp/project/.paseo/worktrees/feature-a", + workspaceId: "ws-feature-a", status: "running", detail: { type: "worktree_setup", @@ -264,9 +264,9 @@ describe("DaemonClient", () => { expect(events).toContainEqual({ type: "workspace_setup_progress", - workspaceId: "/tmp/project/.paseo/worktrees/feature-a", + workspaceId: "ws-feature-a", payload: { - workspaceId: "/tmp/project/.paseo/worktrees/feature-a", + workspaceId: "ws-feature-a", status: "running", detail: { type: "worktree_setup", @@ -309,7 +309,7 @@ describe("DaemonClient", () => { const createPromise = client.createAgent({ provider: "codex", cwd: "/tmp/project/.paseo/worktrees/feature-a", - workspaceId: "/tmp/project/.paseo/worktrees/feature-a", + workspaceId: "ws-feature-a", title: "Compat agent", modeId: "default", }); @@ -326,7 +326,7 @@ describe("DaemonClient", () => { expect(request.message).toEqual( expect.objectContaining({ type: "create_agent_request", - workspaceId: "/tmp/project/.paseo/worktrees/feature-a", + workspaceId: "ws-feature-a", }), ); diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index 6a3f00f04..34eaffc3d 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -210,7 +210,7 @@ export type CreateAgentRequestOptions = { config?: AgentSessionConfig; provider?: AgentProvider; cwd?: string; - workspaceId?: string | number; + workspaceId?: string; initialPrompt?: string; clientMessageId?: string; outputSchema?: Record; @@ -1400,7 +1400,7 @@ export class DaemonClient { } async archiveWorkspace( - workspaceId: string | number, + workspaceId: string, requestId?: string, ): Promise { return this.sendCorrelatedSessionRequest({ diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 970cf1cb8..d8367104d 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -237,6 +237,7 @@ export async function createPaseoDaemon( const app = express(); let boundListenTarget: ListenTarget | null = null; + let workspaceRegistry: FileBackedWorkspaceRegistry | null = null; const scriptRouteStore = new ScriptRouteStore(); const scriptRuntimeStore = new WorkspaceScriptRuntimeStore(); @@ -252,6 +253,8 @@ export async function createPaseoDaemon( routeStore: scriptRouteStore, runtimeStore: scriptRuntimeStore, daemonPort: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null), + resolveWorkspaceDirectory: async (workspaceId) => + (await workspaceRegistry?.get(workspaceId))?.cwd ?? null, }), }); const handleBranchChange = createBranchChangeRouteHandler({ @@ -396,7 +399,7 @@ export async function createPaseoDaemon( path.join(config.paseoHome, "projects", "projects.json"), logger, ); - const workspaceRegistry = new FileBackedWorkspaceRegistry( + workspaceRegistry = new FileBackedWorkspaceRegistry( path.join(config.paseoHome, "projects", "workspaces.json"), logger, ); diff --git a/packages/server/src/server/script-status-projection.test.ts b/packages/server/src/server/script-status-projection.test.ts index ad13a369c..da13785c2 100644 --- a/packages/server/src/server/script-status-projection.test.ts +++ b/packages/server/src/server/script-status-projection.test.ts @@ -38,6 +38,7 @@ function createWorkspaceRepo(options?: { } function buildPayloads(input: { + workspaceId: string; workspaceDirectory: string; routeStore: ScriptRouteStore; runtimeStore: WorkspaceScriptRuntimeStore; @@ -49,6 +50,7 @@ function buildPayloads(input: { describe("script-status-projection", () => { it("projects plain scripts and services differently", () => { + const workspaceId = "workspace-plain-and-service"; const workspace = createWorkspaceRepo({ paseoConfig: { scripts: { @@ -60,7 +62,7 @@ describe("script-status-projection", () => { const routeStore = new ScriptRouteStore(); const runtimeStore = new WorkspaceScriptRuntimeStore(); runtimeStore.set({ - workspaceId: workspace.repoDir, + workspaceId, scriptName: "typecheck", type: "script", lifecycle: "stopped", @@ -71,6 +73,7 @@ describe("script-status-projection", () => { try { expect( buildPayloads({ + workspaceId, workspaceDirectory: workspace.repoDir, routeStore, runtimeStore, @@ -104,6 +107,7 @@ describe("script-status-projection", () => { }); it("overlays runtime, route, and health state for running services", () => { + const workspaceId = "workspace-running-service"; const workspace = createWorkspaceRepo({ branchName: "feature/card", paseoConfig: { @@ -116,12 +120,12 @@ describe("script-status-projection", () => { routeStore.registerRoute({ hostname: "feature-card.web.localhost", port: 4321, - workspaceId: workspace.repoDir, + workspaceId, scriptName: "web", }); const runtimeStore = new WorkspaceScriptRuntimeStore(); runtimeStore.set({ - workspaceId: workspace.repoDir, + workspaceId, scriptName: "web", type: "service", lifecycle: "running", @@ -132,6 +136,7 @@ describe("script-status-projection", () => { try { expect( buildPayloads({ + workspaceId, workspaceDirectory: workspace.repoDir, routeStore, runtimeStore, @@ -156,6 +161,7 @@ describe("script-status-projection", () => { }); it("maps internal pending health to null on the wire", () => { + const workspaceId = "workspace-pending-health"; const workspace = createWorkspaceRepo({ paseoConfig: { scripts: { @@ -167,12 +173,12 @@ describe("script-status-projection", () => { routeStore.registerRoute({ hostname: "web.localhost", port: 4321, - workspaceId: workspace.repoDir, + workspaceId, scriptName: "web", }); const runtimeStore = new WorkspaceScriptRuntimeStore(); runtimeStore.set({ - workspaceId: workspace.repoDir, + workspaceId, scriptName: "web", type: "service", lifecycle: "running", @@ -183,6 +189,7 @@ describe("script-status-projection", () => { try { expect( buildPayloads({ + workspaceId, workspaceDirectory: workspace.repoDir, routeStore, runtimeStore, @@ -207,17 +214,18 @@ describe("script-status-projection", () => { }); it("includes orphaned running runtime entries even after config removal", () => { + const workspaceId = "workspace-orphaned-service"; const workspace = createWorkspaceRepo(); const routeStore = new ScriptRouteStore(); routeStore.registerRoute({ hostname: "docs.localhost", port: 3002, - workspaceId: workspace.repoDir, + workspaceId, scriptName: "docs", }); const runtimeStore = new WorkspaceScriptRuntimeStore(); runtimeStore.set({ - workspaceId: workspace.repoDir, + workspaceId, scriptName: "docs", type: "service", lifecycle: "running", @@ -228,6 +236,7 @@ describe("script-status-projection", () => { try { expect( buildPayloads({ + workspaceId, workspaceDirectory: workspace.repoDir, routeStore, runtimeStore, @@ -251,11 +260,12 @@ describe("script-status-projection", () => { }); it("projects orphaned plain scripts as scripts instead of services", () => { + const workspaceId = "workspace-orphaned-script"; const workspace = createWorkspaceRepo(); const routeStore = new ScriptRouteStore(); const runtimeStore = new WorkspaceScriptRuntimeStore(); runtimeStore.set({ - workspaceId: workspace.repoDir, + workspaceId, scriptName: "typecheck", type: "script", lifecycle: "running", @@ -266,6 +276,7 @@ describe("script-status-projection", () => { try { expect( buildPayloads({ + workspaceId, workspaceDirectory: workspace.repoDir, routeStore, runtimeStore, @@ -288,7 +299,8 @@ describe("script-status-projection", () => { } }); - it("createScriptStatusEmitter overlays health onto the projected workspace script list", () => { + it("createScriptStatusEmitter overlays health onto the projected workspace script list", async () => { + const workspaceId = "workspace-emitter"; const workspace = createWorkspaceRepo({ paseoConfig: { scripts: { @@ -301,12 +313,12 @@ describe("script-status-projection", () => { routeStore.registerRoute({ hostname: "api.localhost", port: 3001, - workspaceId: workspace.repoDir, + workspaceId, scriptName: "api", }); const runtimeStore = new WorkspaceScriptRuntimeStore(); runtimeStore.set({ - workspaceId: workspace.repoDir, + workspaceId, scriptName: "api", type: "service", lifecycle: "running", @@ -320,10 +332,12 @@ describe("script-status-projection", () => { routeStore, runtimeStore, daemonPort: 6767, + resolveWorkspaceDirectory: async (workspaceId) => + workspaceId === "workspace-emitter" ? workspace.repoDir : null, }); try { - emitUpdate(workspace.repoDir, [ + emitUpdate(workspaceId, [ { scriptName: "api", hostname: "api.localhost", @@ -331,11 +345,12 @@ describe("script-status-projection", () => { health: "healthy", }, ]); + await Promise.resolve(); expect(session.emit).toHaveBeenCalledWith({ type: "script_status_update", payload: { - workspaceId: workspace.repoDir, + workspaceId, scripts: [ { scriptName: "api", diff --git a/packages/server/src/server/script-status-projection.ts b/packages/server/src/server/script-status-projection.ts index c828a5c0e..8b0e865b0 100644 --- a/packages/server/src/server/script-status-projection.ts +++ b/packages/server/src/server/script-status-projection.ts @@ -15,6 +15,7 @@ type SessionEmitter = { }; type BuildWorkspaceScriptPayloadsOptions = { + workspaceId: string; workspaceDirectory: string; routeStore: ScriptRouteStore; runtimeStore: WorkspaceScriptRuntimeStore; @@ -83,17 +84,18 @@ function sortPayloads(payloads: WorkspaceScriptPayload[]): WorkspaceScriptPayloa export function buildWorkspaceScriptPayloads( options: BuildWorkspaceScriptPayloadsOptions, ): WorkspaceScriptPayload[] { + const workspaceId = options.workspaceId; const workspaceDirectory = options.workspaceDirectory; const branchName = resolveWorkspaceBranchName(workspaceDirectory); const scriptConfigs = getScriptConfigs(workspaceDirectory); const runtimeEntries = new Map( options.runtimeStore - .listForWorkspace(workspaceDirectory) + .listForWorkspace(workspaceId) .map((entry) => [entry.scriptName, entry] as const), ); const routesByScriptName = new Map( options.routeStore - .listRoutesForWorkspace(workspaceDirectory) + .listRoutesForWorkspace(workspaceId) .map((entry) => [entry.scriptName, entry] as const), ); @@ -177,33 +179,43 @@ export function createScriptStatusEmitter({ routeStore, runtimeStore, daemonPort, + resolveWorkspaceDirectory, }: { sessions: () => SessionEmitter[]; routeStore: ScriptRouteStore; runtimeStore: WorkspaceScriptRuntimeStore; daemonPort: number | null | (() => number | null); + resolveWorkspaceDirectory: (workspaceId: string) => string | null | Promise; }): (workspaceId: string, scripts: ScriptHealthEntry[]) => void { return (workspaceId, scripts) => { - const resolvedDaemonPort = resolveDaemonPort(daemonPort); - const scriptHealthByHostname = new Map( - scripts.map((script) => [script.hostname, script.health] as const), - ); + void (async () => { + const workspaceDirectory = await resolveWorkspaceDirectory(workspaceId); + if (!workspaceDirectory) { + return; + } - const projected = buildWorkspaceScriptPayloads({ - workspaceDirectory: workspaceId, - routeStore, - runtimeStore, - daemonPort: resolvedDaemonPort, - resolveHealth: (hostname) => scriptHealthByHostname.get(hostname) ?? null, - }); + const resolvedDaemonPort = resolveDaemonPort(daemonPort); + const scriptHealthByHostname = new Map( + scripts.map((script) => [script.hostname, script.health] as const), + ); - const message = buildScriptStatusUpdateMessage({ - workspaceId, - scripts: projected, - }); + const projected = buildWorkspaceScriptPayloads({ + workspaceId, + workspaceDirectory, + routeStore, + runtimeStore, + daemonPort: resolvedDaemonPort, + resolveHealth: (hostname) => scriptHealthByHostname.get(hostname) ?? null, + }); - for (const session of sessions()) { - session.emit(message); - } + const message = buildScriptStatusUpdateMessage({ + workspaceId, + scripts: projected, + }); + + for (const session of sessions()) { + session.emit(message); + } + })(); }; } diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 62ccfb0a4..cd6a09fda 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1474,6 +1474,7 @@ export class Session { private buildPersistedProjectRecord(input: { workspaceId: string; + cwd: string; placement: ProjectPlacementPayload; createdAt: string; updatedAt: string; @@ -1481,7 +1482,7 @@ export class Session { return createPersistedProjectRecord({ projectId: input.placement.projectKey, rootPath: deriveProjectRootPath({ - cwd: input.workspaceId, + cwd: input.cwd, checkout: input.placement.checkout, }), kind: deriveProjectKind(input.placement.checkout), @@ -1494,6 +1495,7 @@ export class Session { private buildPersistedWorkspaceRecord(input: { workspaceId: string; + cwd: string; placement: ProjectPlacementPayload; createdAt: string; updatedAt: string; @@ -1501,10 +1503,10 @@ export class Session { return createPersistedWorkspaceRecord({ workspaceId: input.workspaceId, projectId: input.placement.projectKey, - cwd: input.workspaceId, + cwd: input.cwd, kind: deriveWorkspaceKind(input.placement.checkout), displayName: deriveWorkspaceDisplayName({ - cwd: input.workspaceId, + cwd: input.cwd, checkout: input.placement.checkout, }), createdAt: input.createdAt, @@ -1522,15 +1524,6 @@ export class Session { } } - private async resolveWorkspaceByIdOrDirectory( - workspaceId: string, - ): Promise { - const record = await this.workspaceRegistry.get(workspaceId); - if (record) return record; - // Fallback: treat as directory path - return this.findWorkspaceByDirectory(workspaceId); - } - private async resolveWorkspaceDirectory(cwd: string): Promise { const normalizedCwd = normalizePersistedWorkspaceId(cwd); try { @@ -2980,7 +2973,7 @@ export class Session { attachments, ); const resolvedWorkspace = msg.workspaceId - ? await this.resolveWorkspaceByIdOrDirectory(msg.workspaceId) + ? await this.workspaceRegistry.get(msg.workspaceId) : ((await this.findWorkspaceByDirectory(sessionConfig.cwd)) ?? (await this.findOrCreateWorkspaceForDirectory(sessionConfig.cwd))); if (!resolvedWorkspace) { @@ -4378,26 +4371,26 @@ export class Session { } private async removeWorkspaceGitWatchTarget(cwd: string): Promise { - const workspaceId = normalizePersistedWorkspaceId(cwd); - const target = this.workspaceGitWatchTargets.get(workspaceId); + const normalizedCwd = normalizePersistedWorkspaceId(cwd); + const target = this.workspaceGitWatchTargets.get(normalizedCwd); if (target) { this.closeWorkspaceGitWatchTarget(target); - this.workspaceGitWatchTargets.delete(workspaceId); + this.workspaceGitWatchTargets.delete(normalizedCwd); } } private removeWorkspaceGitSubscription(cwd: string): void { - const workspaceId = normalizePersistedWorkspaceId(cwd); - const target = this.workspaceGitWatchTargets.get(workspaceId); + const normalizedCwd = normalizePersistedWorkspaceId(cwd); + const target = this.workspaceGitWatchTargets.get(normalizedCwd); if (target) { - const unsubscribeFetch = this.workspaceGitFetchSubscriptions.get(workspaceId); + const unsubscribeFetch = this.workspaceGitFetchSubscriptions.get(normalizedCwd); unsubscribeFetch?.(); - this.workspaceGitFetchSubscriptions.delete(workspaceId); + this.workspaceGitFetchSubscriptions.delete(normalizedCwd); this.closeWorkspaceGitWatchTarget(target); - this.workspaceGitWatchTargets.delete(workspaceId); + this.workspaceGitWatchTargets.delete(normalizedCwd); } - this.workspaceGitSubscriptions.get(workspaceId)?.(); - this.workspaceGitSubscriptions.delete(workspaceId); + this.workspaceGitSubscriptions.get(normalizedCwd)?.(); + this.workspaceGitSubscriptions.delete(normalizedCwd); } private workspaceGitDescriptorFingerprint(workspace: WorkspaceDescriptorPayload | null): string { @@ -4442,7 +4435,7 @@ export class Session { workspaces: Iterable, ): Promise { for (const workspace of workspaces) { - const persistedWorkspace = await this.findWorkspaceByDirectory(workspace.id); + const persistedWorkspace = await this.workspaceRegistry.get(workspace.id); if (!persistedWorkspace) { continue; } @@ -4457,20 +4450,20 @@ export class Session { cwd: string, options: { isGit: boolean }, ): Promise { - const workspaceId = normalizePersistedWorkspaceId(cwd); + const normalizedCwd = normalizePersistedWorkspaceId(cwd); if (!options.isGit) { - this.removeWorkspaceGitSubscription(workspaceId); + this.removeWorkspaceGitSubscription(normalizedCwd); return; } - if (this.workspaceGitSubscriptions.has(workspaceId)) { + if (this.workspaceGitSubscriptions.has(normalizedCwd)) { return; } - const subscription = await this.workspaceGitService.subscribe({ cwd: workspaceId }, () => { - void this.emitWorkspaceUpdateForCwd(workspaceId); + const subscription = await this.workspaceGitService.subscribe({ cwd: normalizedCwd }, () => { + void this.emitWorkspaceUpdateForCwd(normalizedCwd); }); - this.workspaceGitSubscriptions.set(workspaceId, subscription.unsubscribe); + this.workspaceGitSubscriptions.set(normalizedCwd, subscription.unsubscribe); } private async handleSubscribeCheckoutDiffRequest( @@ -5611,8 +5604,8 @@ export class Session { } return { - id: workspace.cwd, - projectId: resolvedProjectRecord?.rootPath ?? workspace.cwd, + id: workspace.workspaceId, + projectId: workspace.projectId, projectDisplayName: resolvedProjectRecord?.displayName ?? String(workspace.projectId), projectRootPath: resolvedProjectRecord?.rootPath ?? workspace.cwd, workspaceDirectory: workspace.cwd, @@ -5625,6 +5618,7 @@ export class Session { scripts: this.scriptRouteStore && this.scriptRuntimeStore ? buildWorkspaceScriptPayloads({ + workspaceId: workspace.workspaceId, workspaceDirectory: workspace.cwd, routeStore: this.scriptRouteStore, runtimeStore: this.scriptRuntimeStore, @@ -5726,24 +5720,18 @@ export class Session { .map((project) => [project.projectId, project] as const), ); const descriptorsByWorkspaceId = new Map(); - const workspaceIds = options.workspaceIds - ? new Set( - Array.from(options.workspaceIds, (workspaceId) => - normalizePersistedWorkspaceId(workspaceId), - ), - ) - : null; + const workspaceIds = options.workspaceIds ? new Set(options.workspaceIds) : null; const workspaceIdsByDirectory = new Map( - activeRecords.map((workspace) => [workspace.cwd, workspace.cwd] as const), + activeRecords.map((workspace) => [workspace.cwd, workspace.workspaceId] as const), ); for (const workspace of activeRecords) { - if (workspaceIds && !workspaceIds.has(workspace.cwd)) { + if (workspaceIds && !workspaceIds.has(workspace.workspaceId)) { continue; } const projectRecord = activeProjects.get(workspace.projectId) ?? null; descriptorsByWorkspaceId.set( - workspace.cwd, + workspace.workspaceId, await this.buildWorkspaceDescriptor({ workspace, projectRecord, @@ -5785,7 +5773,7 @@ export class Session { const normalizedCwd = normalizePersistedWorkspaceId(cwd); const exact = workspaces.find((workspace) => workspace.cwd === normalizedCwd); if (exact) { - return exact.cwd; + return exact.workspaceId; } let bestMatch: PersistedWorkspaceRecord | null = null; @@ -5799,7 +5787,7 @@ export class Session { } } - return bestMatch?.cwd ?? normalizedCwd; + return bestMatch?.workspaceId ?? normalizedCwd; } private async listWorkspaceDescriptors(): Promise { @@ -6140,9 +6128,12 @@ export class Session { const workspaceRecord = createPersistedWorkspaceRecord({ workspaceId, projectId: placement.projectKey, - cwd: workspaceId, + cwd: normalizedCwd, kind: deriveWorkspaceKind(placement.checkout), - displayName: deriveWorkspaceDisplayName({ cwd: workspaceId, checkout: placement.checkout }), + displayName: deriveWorkspaceDisplayName({ + cwd: normalizedCwd, + checkout: placement.checkout, + }), createdAt: timestamp, updatedAt: timestamp, }); @@ -6224,9 +6215,7 @@ export class Session { return; } - const uniqueWorkspaceIds = new Set( - Array.from(workspaceIds, (workspaceId) => normalizePersistedWorkspaceId(workspaceId)), - ); + const uniqueWorkspaceIds = new Set(Array.from(workspaceIds)); if (uniqueWorkspaceIds.size === 0) { return; } @@ -6292,20 +6281,16 @@ export class Session { cwd: string, options?: { skipReconcile?: boolean; dedupeGitState?: boolean }, ): Promise { - const activeWorkspaces = (await this.workspaceRegistry.list()).filter( - (workspace) => !workspace.archivedAt, - ); - const workspaceId = this.resolveRegisteredWorkspaceIdForCwd(cwd, activeWorkspaces); + const workspaces = await this.workspaceRegistry.list(); + const workspaceId = this.resolveRegisteredWorkspaceIdForCwd(cwd, workspaces); await this.emitWorkspaceUpdatesForWorkspaceIds([workspaceId], options); } private async emitWorkspaceUpdatesForCwds(cwds: Iterable): Promise { - const activeWorkspaces = (await this.workspaceRegistry.list()).filter( - (workspace) => !workspace.archivedAt, - ); + const workspaces = await this.workspaceRegistry.list(); const uniqueWorkspaceIds = new Set(); for (const cwd of cwds) { - uniqueWorkspaceIds.add(this.resolveRegisteredWorkspaceIdForCwd(cwd, activeWorkspaces)); + uniqueWorkspaceIds.add(this.resolveRegisteredWorkspaceIdForCwd(cwd, workspaces)); } await this.emitWorkspaceUpdatesForWorkspaceIds(uniqueWorkspaceIds); } @@ -6484,12 +6469,14 @@ export class Session { } private buildWorkspaceScriptPayloadSnapshot( + workspaceId: string, workspaceDirectory: string, ): WorkspaceDescriptorPayload["scripts"] { if (!this.scriptRouteStore || !this.scriptRuntimeStore) { return []; } return buildWorkspaceScriptPayloads({ + workspaceId, workspaceDirectory, routeStore: this.scriptRouteStore, runtimeStore: this.scriptRuntimeStore, @@ -6498,12 +6485,12 @@ export class Session { }); } - private emitWorkspaceScriptStatusUpdate(workspaceDirectory: string): void { + private emitWorkspaceScriptStatusUpdate(workspaceId: string, workspaceDirectory: string): void { this.emit({ type: "script_status_update", payload: { - workspaceId: workspaceDirectory, - scripts: this.buildWorkspaceScriptPayloadSnapshot(workspaceDirectory), + workspaceId, + scripts: this.buildWorkspaceScriptPayloadSnapshot(workspaceId, workspaceDirectory), }, }); } @@ -6524,14 +6511,14 @@ export class Session { throw new Error("Workspace scripts are not available on this daemon"); } - const workspace = await this.resolveWorkspaceByIdOrDirectory(request.workspaceId); + const workspace = await this.workspaceRegistry.get(request.workspaceId); if (!workspace) { throw new Error(`Workspace not found: ${request.workspaceId}`); } const serviceResult = await spawnWorkspaceScript({ repoRoot: workspace.cwd, - workspaceId: workspace.cwd, + workspaceId: workspace.workspaceId, branchName: readGitCommand(workspace.cwd, "git symbolic-ref --short HEAD"), scriptName: request.scriptName, daemonPort: this.getDaemonTcpPort?.() ?? null, @@ -6541,11 +6528,11 @@ export class Session { terminalManager: this.terminalManager, logger: this.sessionLogger, onLifecycleChanged: () => { - this.emitWorkspaceScriptStatusUpdate(workspace.cwd); + this.emitWorkspaceScriptStatusUpdate(workspace.workspaceId, workspace.cwd); }, }); - this.emitWorkspaceScriptStatusUpdate(workspace.cwd); + this.emitWorkspaceScriptStatusUpdate(workspace.workspaceId, workspace.cwd); this.emit({ type: "start_workspace_script_response", payload: { @@ -6687,8 +6674,8 @@ export class Session { scriptRuntimeStore: this.scriptRuntimeStore, getDaemonTcpPort: this.getDaemonTcpPort, getDaemonTcpHost: this.getDaemonTcpHost, - onScriptsChanged: (workspaceDirectory) => { - this.emitWorkspaceScriptStatusUpdate(workspaceDirectory); + onScriptsChanged: (workspaceId, workspaceDirectory) => { + this.emitWorkspaceScriptStatusUpdate(workspaceId, workspaceDirectory); }, }, options, @@ -6702,7 +6689,6 @@ export class Session { { emit: (message) => this.emit(message), workspaceSetupSnapshots: this.workspaceSetupSnapshots, - workspaceRegistry: this.workspaceRegistry, }, request, ); @@ -6712,7 +6698,7 @@ export class Session { request: Extract, ): Promise { try { - const existing = await this.resolveWorkspaceByIdOrDirectory(request.workspaceId); + const existing = await this.workspaceRegistry.get(request.workspaceId); if (!existing) { throw new Error(`Workspace not found: ${request.workspaceId}`); } diff --git a/packages/server/src/server/session.workspace-git-watch.test.ts b/packages/server/src/server/session.workspace-git-watch.test.ts index 352292b0d..66a6f190a 100644 --- a/packages/server/src/server/session.workspace-git-watch.test.ts +++ b/packages/server/src/server/session.workspace-git-watch.test.ts @@ -259,8 +259,8 @@ describe("workspace git watch targets", () => { }; let descriptor = { - id: "/tmp/repo", - projectId: "/tmp/repo", + id: "ws-10", + projectId: "proj-1", projectDisplayName: "repo", projectRootPath: "/tmp/repo", projectKind: "git", @@ -304,7 +304,7 @@ describe("workspace git watch targets", () => { expect(workspaceUpdates[0]?.payload).toMatchObject({ kind: "upsert", workspace: { - id: "/tmp/repo", + id: "ws-10", name: "renamed-branch", diffStat: { additions: 1, deletions: 0 }, }, diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index eca80beea..a67e04ba9 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -899,8 +899,8 @@ describe("workspace aggregation", () => { const session = createSessionForWorkspaceTests() as any; session.workspaceRegistry.list = async () => [ createPersistedWorkspaceRecord({ - workspaceId: "/tmp/non-git", - projectId: "/tmp/non-git", + workspaceId: "ws-non-git", + projectId: "proj-non-git", cwd: "/tmp/non-git", kind: "directory", displayName: "non-git", @@ -930,8 +930,8 @@ describe("workspace aggregation", () => { const session = createSessionForWorkspaceTests() as any; session.workspaceRegistry.list = async () => [ createPersistedWorkspaceRecord({ - workspaceId: "/tmp/repo-branch", - projectId: "/tmp/repo-branch", + workspaceId: "ws-repo-branch", + projectId: "proj-repo-branch", cwd: "/tmp/repo-branch", kind: "local_checkout", displayName: "feature/name-from-server", @@ -973,8 +973,8 @@ describe("workspace aggregation", () => { const session = createSessionForWorkspaceTests() as any; session.workspaceRegistry.list = async () => [ createPersistedWorkspaceRecord({ - workspaceId: "/tmp/repo", - projectId: "/tmp/repo", + workspaceId: "ws-repo-status", + projectId: "proj-repo-status", cwd: "/tmp/repo", kind: "local_checkout", displayName: "repo", @@ -1017,8 +1017,8 @@ describe("workspace aggregation", () => { const session = createSessionForWorkspaceTests() as any; session.workspaceRegistry.list = async () => [ createPersistedWorkspaceRecord({ - workspaceId: "/tmp/repo", - projectId: "/tmp/repo", + workspaceId: "ws-repo-subdir", + projectId: "proj-repo-subdir", cwd: "/tmp/repo", kind: "local_checkout", displayName: "main", @@ -1042,7 +1042,7 @@ describe("workspace aggregation", () => { expect(result.entries).toHaveLength(1); expect(result.entries[0]).toMatchObject({ - id: "/tmp/repo", + id: "ws-repo-subdir", status: "done", activityAt: null, }); @@ -1128,8 +1128,8 @@ describe("workspace aggregation", () => { [ "/tmp/repo", { - id: "/tmp/repo", - projectId: "/tmp/repo", + id: "ws-repo-running", + projectId: "proj-repo-running", projectDisplayName: "repo", projectRootPath: "/tmp/repo", projectKind: "non_git", @@ -1147,8 +1147,8 @@ describe("workspace aggregation", () => { [ "/tmp/repo", { - id: "/tmp/repo", - projectId: "/tmp/repo", + id: "ws-repo-running", + projectId: "proj-repo-running", projectDisplayName: "repo", projectRootPath: "/tmp/repo", projectKind: "non_git", @@ -1167,8 +1167,8 @@ describe("workspace aggregation", () => { expect((workspaceUpdates[1] as any).payload).toEqual({ kind: "upsert", workspace: { - id: "/tmp/repo", - projectId: "/tmp/repo", + id: "ws-repo-running", + projectId: "proj-repo-running", projectDisplayName: "repo", projectRootPath: "/tmp/repo", projectKind: "non_git", @@ -1280,6 +1280,26 @@ describe("workspace aggregation", () => { test("workspace update fanout for multiple cwd values is deduplicated", async () => { const emitted: Array<{ type: string; payload: unknown }> = []; const session = createSessionForWorkspaceTests() as any; + session.workspaceRegistry.list = async () => [ + createPersistedWorkspaceRecord({ + workspaceId: "ws-repo-main", + projectId: "proj-repo-main", + cwd: "/tmp/repo", + kind: "local_checkout", + displayName: "main", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }), + createPersistedWorkspaceRecord({ + workspaceId: "ws-repo-feature", + projectId: "proj-repo-main", + cwd: "/tmp/repo/worktree", + kind: "worktree", + displayName: "feature", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }), + ]; session.workspaceUpdatesSubscription = { subscriptionId: "sub-dedup", filter: undefined, @@ -1288,14 +1308,14 @@ describe("workspace aggregation", () => { lastEmittedByWorkspaceId: new Map(), }; session.reconcileActiveWorkspaceRecords = async () => - new Set(["/tmp/repo", "/tmp/repo/worktree"]); + new Set(["ws-repo-main", "ws-repo-feature"]); session.buildWorkspaceDescriptorMap = async () => new Map([ [ - "/tmp/repo", + "ws-repo-main", { - id: "/tmp/repo", - projectId: "/tmp/repo", + id: "ws-repo-main", + projectId: "proj-repo-main", projectDisplayName: "repo", projectRootPath: "/tmp/repo", projectKind: "git", @@ -1306,10 +1326,10 @@ describe("workspace aggregation", () => { }, ], [ - "/tmp/repo/worktree", + "ws-repo-feature", { - id: "/tmp/repo/worktree", - projectId: "/tmp/repo", + id: "ws-repo-feature", + projectId: "proj-repo-main", projectDisplayName: "repo", projectRootPath: "/tmp/repo", projectKind: "git", @@ -1333,8 +1353,8 @@ describe("workspace aggregation", () => { expect(workspaceUpdates).toHaveLength(2); expect(workspaceUpdates.map((entry) => entry.payload.kind)).toEqual(["upsert", "upsert"]); expect(workspaceUpdates.map((entry) => entry.payload.workspace.id).sort()).toEqual([ - "/tmp/repo", - "/tmp/repo/worktree", + "ws-repo-feature", + "ws-repo-main", ]); }); @@ -1521,8 +1541,8 @@ describe("workspace aggregation", () => { const emitted: Array<{ type: string; payload: unknown }> = []; const session = createSessionForWorkspaceTests() as any; const workspace = createPersistedWorkspaceRecord({ - workspaceId: "/tmp/repo", - projectId: "/tmp/repo", + workspaceId: "ws-repo-archive", + projectId: "proj-repo-archive", cwd: "/tmp/repo", kind: "directory", displayName: "repo", @@ -1540,7 +1560,7 @@ describe("workspace aggregation", () => { await session.handleMessage({ type: "archive_workspace_request", - workspaceId: "/tmp/repo", + workspaceId: "ws-repo-archive", requestId: "req-archive", }); @@ -1855,7 +1875,7 @@ describe("workspace aggregation", () => { test("listWorkspaceDescriptorsSnapshot keeps git workspaces on the baseline descriptor path", async () => { const session = createSessionForWorkspaceTests() as any; const project = createPersistedProjectRecord({ - projectId: "/tmp/repo", + projectId: "proj-baseline", rootPath: "/tmp/repo", kind: "git", displayName: "repo", @@ -1863,7 +1883,7 @@ describe("workspace aggregation", () => { updatedAt: "2026-03-01T12:00:00.000Z", }); const workspace = createPersistedWorkspaceRecord({ - workspaceId: "/tmp/repo", + workspaceId: "ws-baseline", projectId: project.projectId, cwd: "/tmp/repo", kind: "local_checkout", @@ -1942,7 +1962,7 @@ describe("workspace aggregation", () => { workspaceGitService, }) as any; const project = createPersistedProjectRecord({ - projectId: "/tmp/repo", + projectId: "proj-runtime-fetch", rootPath: "/tmp/repo", kind: "git", displayName: "repo", @@ -1950,7 +1970,7 @@ describe("workspace aggregation", () => { updatedAt: "2026-03-01T12:00:00.000Z", }); const workspace = createPersistedWorkspaceRecord({ - workspaceId: "/tmp/repo", + workspaceId: "ws-runtime-fetch", projectId: project.projectId, cwd: "/tmp/repo", kind: "local_checkout", @@ -1989,7 +2009,7 @@ describe("workspace aggregation", () => { expect(workspaceGitService.peekSnapshot).toHaveBeenCalledWith("/tmp/repo"); expect(response?.payload.entries).toEqual([ expect.objectContaining({ - id: "/tmp/repo", + id: "ws-runtime-fetch", gitRuntime: { currentBranch: "runtime-branch", remoteUrl: "https://github.com/acme/repo.git", @@ -2042,7 +2062,7 @@ describe("workspace aggregation", () => { workspaceGitService, }) as any; const project = createPersistedProjectRecord({ - projectId: "/tmp/repo", + projectId: "proj-runtime-update", rootPath: "/tmp/repo", kind: "git", displayName: "repo", @@ -2050,7 +2070,7 @@ describe("workspace aggregation", () => { updatedAt: "2026-03-01T12:00:00.000Z", }); const workspace = createPersistedWorkspaceRecord({ - workspaceId: "/tmp/repo", + workspaceId: "ws-runtime-update", projectId: project.projectId, cwd: "/tmp/repo", kind: "local_checkout", @@ -2095,7 +2115,7 @@ describe("workspace aggregation", () => { payload: { kind: "upsert", workspace: expect.objectContaining({ - id: "/tmp/repo", + id: "ws-runtime-update", gitRuntime: expect.objectContaining({ currentBranch: "feature/runtime-payloads", isDirty: true, @@ -2117,7 +2137,7 @@ describe("workspace aggregation", () => { const emitted: Array<{ type: string; payload: any }> = []; const session = createSessionForWorkspaceTests() as any; const gitProject = createPersistedProjectRecord({ - projectId: "/tmp/repo", + projectId: "proj-git-subscribe", rootPath: "/tmp/repo", kind: "git", displayName: "repo", @@ -2125,7 +2145,7 @@ describe("workspace aggregation", () => { updatedAt: "2026-03-01T12:00:00.000Z", }); const directoryProject = createPersistedProjectRecord({ - projectId: "/tmp/docs", + projectId: "proj-docs-subscribe", rootPath: "/tmp/docs", kind: "non_git", displayName: "docs", @@ -2133,7 +2153,7 @@ describe("workspace aggregation", () => { updatedAt: "2026-03-01T12:00:00.000Z", }); const gitWorkspace = createPersistedWorkspaceRecord({ - workspaceId: "/tmp/repo", + workspaceId: "ws-git-subscribe", projectId: gitProject.projectId, cwd: "/tmp/repo", kind: "local_checkout", @@ -2142,7 +2162,7 @@ describe("workspace aggregation", () => { updatedAt: "2026-03-01T12:00:00.000Z", }); const directoryWorkspace = createPersistedWorkspaceRecord({ - workspaceId: "/tmp/docs", + workspaceId: "ws-docs-subscribe", projectId: directoryProject.projectId, cwd: "/tmp/docs", kind: "directory", diff --git a/packages/server/src/server/workspace-registry-bootstrap.ts b/packages/server/src/server/workspace-registry-bootstrap.ts index 625334ffe..9ed0345ef 100644 --- a/packages/server/src/server/workspace-registry-bootstrap.ts +++ b/packages/server/src/server/workspace-registry-bootstrap.ts @@ -93,6 +93,7 @@ export async function bootstrapWorkspaceRegistries(options: { for (const [workspaceId, entry] of recordsByWorkspaceId.entries()) { const { placement, records: workspaceRecords } = entry; + const workspaceCwd = placement.checkout.cwd; let workspaceCreatedAt: string | null = null; let workspaceUpdatedAt: string | null = null; for (const record of workspaceRecords) { @@ -106,10 +107,10 @@ export async function bootstrapWorkspaceRegistries(options: { createPersistedWorkspaceRecord({ workspaceId, projectId: placement.projectKey, - cwd: workspaceId, + cwd: workspaceCwd, kind: deriveWorkspaceKind(placement.checkout), displayName: deriveWorkspaceDisplayName({ - cwd: workspaceId, + cwd: workspaceCwd, checkout: placement.checkout, }), createdAt, @@ -129,7 +130,7 @@ export async function bootstrapWorkspaceRegistries(options: { createPersistedProjectRecord({ projectId: placement.projectKey, rootPath: deriveProjectRootPath({ - cwd: workspaceId, + cwd: workspaceCwd, checkout: placement.checkout, }), kind: deriveProjectKind(placement.checkout), diff --git a/packages/server/src/server/workspace-script-runtime-store.test.ts b/packages/server/src/server/workspace-script-runtime-store.test.ts index 41ce11797..625d1f2fe 100644 --- a/packages/server/src/server/workspace-script-runtime-store.test.ts +++ b/packages/server/src/server/workspace-script-runtime-store.test.ts @@ -6,7 +6,7 @@ import { function createEntry(overrides: Partial = {}): ScriptRuntimeEntry { return { - workspaceId: 101, + workspaceId: "workspace-101", scriptName: "web", type: "service", lifecycle: "running", @@ -23,8 +23,8 @@ describe("WorkspaceScriptRuntimeStore", () => { store.set(entry); - expect(store.get({ workspaceId: 101, scriptName: "web" })).toEqual(entry); - expect(store.listForWorkspace(101)).toEqual([entry]); + expect(store.get({ workspaceId: "workspace-101", scriptName: "web" })).toEqual(entry); + expect(store.listForWorkspace("workspace-101")).toEqual([entry]); }); it("preserves whether the runtime entry is a plain script or service", () => { @@ -36,7 +36,7 @@ describe("WorkspaceScriptRuntimeStore", () => { store.set(entry); - expect(store.get({ workspaceId: 101, scriptName: "typecheck" })).toEqual(entry); + expect(store.get({ workspaceId: "workspace-101", scriptName: "typecheck" })).toEqual(entry); }); it("reports whether a script is currently running", () => { @@ -44,7 +44,7 @@ describe("WorkspaceScriptRuntimeStore", () => { store.set(createEntry()); store.set( createEntry({ - workspaceId: 101, + workspaceId: "workspace-101", scriptName: "typecheck", lifecycle: "stopped", terminalId: "terminal-2", @@ -52,19 +52,19 @@ describe("WorkspaceScriptRuntimeStore", () => { }), ); - expect(store.isRunning({ workspaceId: 101, scriptName: "web" })).toBe(true); - expect(store.isRunning({ workspaceId: 101, scriptName: "typecheck" })).toBe(false); - expect(store.isRunning({ workspaceId: 101, scriptName: "missing" })).toBe(false); + expect(store.isRunning({ workspaceId: "workspace-101", scriptName: "web" })).toBe(true); + expect(store.isRunning({ workspaceId: "workspace-101", scriptName: "typecheck" })).toBe(false); + expect(store.isRunning({ workspaceId: "workspace-101", scriptName: "missing" })).toBe(false); }); it("removes individual entries", () => { const store = new WorkspaceScriptRuntimeStore(); store.set(createEntry()); - store.remove({ workspaceId: 101, scriptName: "web" }); + store.remove({ workspaceId: "workspace-101", scriptName: "web" }); - expect(store.get({ workspaceId: 101, scriptName: "web" })).toBeNull(); - expect(store.listForWorkspace(101)).toEqual([]); + expect(store.get({ workspaceId: "workspace-101", scriptName: "web" })).toBeNull(); + expect(store.listForWorkspace("workspace-101")).toEqual([]); }); it("removes all entries for a workspace without touching others", () => { @@ -72,25 +72,25 @@ describe("WorkspaceScriptRuntimeStore", () => { store.set(createEntry()); store.set( createEntry({ - workspaceId: 101, + workspaceId: "workspace-101", scriptName: "api", terminalId: "terminal-2", }), ); store.set( createEntry({ - workspaceId: 202, + workspaceId: "workspace-202", scriptName: "docs", terminalId: "terminal-3", }), ); - store.removeForWorkspace(101); + store.removeForWorkspace("workspace-101"); - expect(store.listForWorkspace(101)).toEqual([]); - expect(store.listForWorkspace(202)).toEqual([ + expect(store.listForWorkspace("workspace-101")).toEqual([]); + expect(store.listForWorkspace("workspace-202")).toEqual([ createEntry({ - workspaceId: 202, + workspaceId: "workspace-202", scriptName: "docs", terminalId: "terminal-3", }), diff --git a/packages/server/src/server/workspace-script-runtime-store.ts b/packages/server/src/server/workspace-script-runtime-store.ts index 3e7218f08..8f46980f0 100644 --- a/packages/server/src/server/workspace-script-runtime-store.ts +++ b/packages/server/src/server/workspace-script-runtime-store.ts @@ -1,5 +1,5 @@ export interface ScriptRuntimeEntry { - workspaceId: number | string; + workspaceId: string; scriptName: string; type: "script" | "service"; lifecycle: "running" | "stopped"; @@ -8,7 +8,7 @@ export interface ScriptRuntimeEntry { } type RuntimeEntryKey = { - workspaceId: number | string; + workspaceId: string; scriptName: string; }; @@ -44,7 +44,7 @@ export class WorkspaceScriptRuntimeStore { this.removeScriptFromWorkspaceIndex(existing.workspaceId, existing.scriptName); } - listForWorkspace(workspaceId: number | string): ScriptRuntimeEntry[] { + listForWorkspace(workspaceId: string): ScriptRuntimeEntry[] { const scriptNames = this.scriptsByWorkspace.get(this.toWorkspaceKey(workspaceId)); if (!scriptNames) { return []; @@ -65,7 +65,7 @@ export class WorkspaceScriptRuntimeStore { return entries; } - removeForWorkspace(workspaceId: number | string): void { + removeForWorkspace(workspaceId: string): void { for (const entry of this.listForWorkspace(workspaceId)) { this.entries.delete(this.toEntryKey(entry)); } @@ -82,7 +82,7 @@ export class WorkspaceScriptRuntimeStore { this.scriptsByWorkspace.set(workspaceKey, scripts); } - private removeScriptFromWorkspaceIndex(workspaceId: number | string, scriptName: string): void { + private removeScriptFromWorkspaceIndex(workspaceId: string, scriptName: string): void { const workspaceKey = this.toWorkspaceKey(workspaceId); const scripts = this.scriptsByWorkspace.get(workspaceKey); if (!scripts) { @@ -99,7 +99,7 @@ export class WorkspaceScriptRuntimeStore { return `${this.toWorkspaceKey(key.workspaceId)}::${key.scriptName}`; } - private toWorkspaceKey(workspaceId: number | string): string { - return String(workspaceId); + private toWorkspaceKey(workspaceId: string): string { + return workspaceId; } } diff --git a/packages/server/src/server/worktree-session.test.ts b/packages/server/src/server/worktree-session.test.ts index 153bacf7a..e9aa9f1ff 100644 --- a/packages/server/src/server/worktree-session.test.ts +++ b/packages/server/src/server/worktree-session.test.ts @@ -266,7 +266,7 @@ describe("runWorktreeSetupInBackground", () => { const logger = createLogger(); const emitWorkspaceUpdateForCwd = vi.fn(async () => {}); const archiveWorkspaceRecord = vi.fn(async () => {}); - const workspaceId = 101; + const workspaceId = "ws-broken-feature"; await runWorktreeSetupInBackground( { @@ -282,7 +282,7 @@ describe("runWorktreeSetupInBackground", () => { { requestCwd: repoDir, repoRoot: repoDir, - workspaceId: String(workspaceId), + workspaceId, worktree: { branchName: "broken-feature", worktreePath, @@ -303,11 +303,11 @@ describe("runWorktreeSetupInBackground", () => { expect(progressMessages[1]?.payload.status).toBe("failed"); expect(progressMessages[1]?.payload.error).toContain("Failed to parse paseo.json"); expect(progressMessages[1]?.payload.detail.commands).toEqual([]); - expect(snapshots.get("101")).toMatchObject({ + expect(snapshots.get(workspaceId)).toMatchObject({ status: "failed", error: expect.stringContaining("Failed to parse paseo.json"), }); - expect(archiveWorkspaceRecord).toHaveBeenCalledWith(String(workspaceId)); + expect(archiveWorkspaceRecord).toHaveBeenCalledWith(workspaceId); expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(worktreePath); }); @@ -671,7 +671,7 @@ describe("runWorktreeSetupInBackground", () => { const emitted: SessionOutboundMessage[] = []; const snapshots = new Map([ [ - "/repo/.paseo/worktrees/feature-a", + "ws-feature-a", { status: "completed", detail: { @@ -694,7 +694,7 @@ describe("runWorktreeSetupInBackground", () => { }, { type: "workspace_setup_status_request", - workspaceId: "/repo/.paseo/worktrees/feature-a", + workspaceId: "ws-feature-a", requestId: "req-status", }, ); @@ -703,7 +703,7 @@ describe("runWorktreeSetupInBackground", () => { type: "workspace_setup_status_response", payload: { requestId: "req-status", - workspaceId: "/repo/.paseo/worktrees/feature-a", + workspaceId: "ws-feature-a", snapshot: { status: "completed", detail: { @@ -730,7 +730,7 @@ describe("runWorktreeSetupInBackground", () => { }, { type: "workspace_setup_status_request", - workspaceId: "/repo/.paseo/worktrees/missing", + workspaceId: "ws-missing", requestId: "req-missing", }, ); @@ -739,7 +739,7 @@ describe("runWorktreeSetupInBackground", () => { type: "workspace_setup_status_response", payload: { requestId: "req-missing", - workspaceId: "/repo/.paseo/worktrees/missing", + workspaceId: "ws-missing", snapshot: null, }, }); @@ -784,8 +784,8 @@ describe("handleCreatePaseoWorktreeRequest", () => { emit: (message) => emitted.push(message), registerPendingWorktreeWorkspace: async ({ worktreePath, branchName }) => ({ - id: 1, - projectId: 1, + id: "ws-pr-worktree", + projectId: "proj-pr-worktree", directory: worktreePath, displayName: branchName, kind: "worktree", @@ -904,8 +904,8 @@ describe("handleCreatePaseoWorktreeRequest", () => { sessionLogger: createLogger(), emit: (message) => emitted.push(message), registerPendingWorktreeWorkspace: vi.fn(async (options) => ({ - workspaceId: options.worktreePath, - projectId: options.repoRoot, + workspaceId: "ws-single-call", + projectId: "proj-single-call", })), describeWorkspaceRecord: vi.fn(async (workspace) => ({ id: workspace.workspaceId, @@ -914,7 +914,7 @@ describe("handleCreatePaseoWorktreeRequest", () => { projectRootPath: repoDir, projectKind: "git", workspaceKind: "worktree", - name: path.basename(workspace.workspaceId), + name: "single-call", status: "done", activityAt: null, })), @@ -947,6 +947,7 @@ describe("handleCreatePaseoWorktreeRequest", () => { const paseoHome = path.join(tempDir, ".paseo"); const emitted: SessionOutboundMessage[] = []; const backgroundWork = vi.fn(async () => {}); + let registeredWorktreePath: string | null = null; try { await handleCreatePaseoWorktreeRequest( @@ -956,9 +957,10 @@ describe("handleCreatePaseoWorktreeRequest", () => { emit: (message) => emitted.push(message), registerPendingWorktreeWorkspace: vi.fn(async (options) => { expect(existsSync(options.worktreePath)).toBe(true); + registeredWorktreePath = options.worktreePath; return { - workspaceId: options.worktreePath, - projectId: options.repoRoot, + workspaceId: "ws-response-after-create", + projectId: "proj-response-after-create", } as any; }), describeWorkspaceRecord: vi.fn(async (workspace) => ({ @@ -968,7 +970,7 @@ describe("handleCreatePaseoWorktreeRequest", () => { projectRootPath: repoDir, projectKind: "git", workspaceKind: "worktree", - name: path.basename(workspace.workspaceId), + name: "response-after-create", status: "done", activityAt: null, })), @@ -990,14 +992,15 @@ describe("handleCreatePaseoWorktreeRequest", () => { ); expect(response?.payload.error).toBeNull(); expect(response?.payload.workspace?.id).toBeTruthy(); - expect(existsSync(response!.payload.workspace!.id)).toBe(true); + expect(registeredWorktreePath).toBeTruthy(); + expect(existsSync(registeredWorktreePath!)).toBe(true); expect(backgroundWork).toHaveBeenCalledWith( expect.objectContaining({ requestCwd: repoDir, repoRoot: repoDir, worktree: { branchName: "response-after-create", - worktreePath: response!.payload.workspace!.id, + worktreePath: registeredWorktreePath, }, shouldBootstrap: true, }), diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts index 2c91c2e1b..6edc12bc8 100644 --- a/packages/server/src/server/worktree-session.ts +++ b/packages/server/src/server/worktree-session.ts @@ -23,7 +23,10 @@ import type { WorkspaceRegistry, } from "./workspace-registry.js"; import type { WorkspaceGitService } from "./workspace-git-service.js"; -import { normalizeWorkspaceId as normalizePersistedWorkspaceId } from "./workspace-registry-model.js"; +import { + deriveWorkspaceId, + normalizeWorkspaceId as normalizePersistedWorkspaceId, +} from "./workspace-registry-model.js"; import { applyWorktreeSetupProgressEvent, buildWorktreeSetupDetail, @@ -98,12 +101,14 @@ type ArchivePaseoWorktreeDependencies = { type RegisterPendingWorktreeWorkspaceDependencies = { buildPersistedProjectRecord: (input: { workspaceId: string; + cwd: string; placement: ProjectPlacementPayload; createdAt: string; updatedAt: string; }) => PersistedProjectRecord; buildPersistedWorkspaceRecord: (input: { workspaceId: string; + cwd: string; placement: ProjectPlacementPayload; createdAt: string; updatedAt: string; @@ -128,13 +133,12 @@ type CreatePaseoWorktreeInBackgroundDependencies = { scriptRuntimeStore: WorkspaceScriptRuntimeStore | null; getDaemonTcpPort: (() => number | null) | null; getDaemonTcpHost: (() => string | null) | null; - onScriptsChanged: ((workspaceDirectory: string) => void) | null; + onScriptsChanged: ((workspaceId: string, workspaceDirectory: string) => void) | null; }; type HandleWorkspaceSetupStatusRequestDependencies = { emit: EmitSessionMessage; workspaceSetupSnapshots: ReadonlyMap; - workspaceRegistry: WorkspaceRegistry; }; type HandleCreatePaseoWorktreeRequestDependencies = { @@ -649,12 +653,12 @@ export async function registerPendingWorktreeWorkspace( branchName: string; }, ): Promise { - const workspaceDirectory = normalizePersistedWorkspaceId(options.worktreePath); + const normalizedWorktreePath = normalizePersistedWorkspaceId(options.worktreePath); const basePlacement = await dependencies.buildProjectPlacement(options.repoRoot); const placement: ProjectPlacementPayload = { ...basePlacement, checkout: { - cwd: workspaceDirectory, + cwd: normalizedWorktreePath, isGit: true, currentBranch: options.branchName, remoteUrl: basePlacement.checkout.remoteUrl, @@ -663,17 +667,20 @@ export async function registerPendingWorktreeWorkspace( mainRepoRoot: options.repoRoot, }, }; + const workspaceId = deriveWorkspaceId(normalizedWorktreePath, placement.checkout); const now = new Date().toISOString(); - const existingWorkspace = await dependencies.findWorkspaceByDirectory(workspaceDirectory); + const existingWorkspace = await dependencies.findWorkspaceByDirectory(normalizedWorktreePath); const existingProject = await dependencies.projectRegistry.get(placement.projectKey); const nextProjectRecord = dependencies.buildPersistedProjectRecord({ - workspaceId: workspaceDirectory, + workspaceId, + cwd: normalizedWorktreePath, placement, createdAt: existingProject?.createdAt ?? now, updatedAt: now, }); const nextWorkspaceRecord = dependencies.buildPersistedWorkspaceRecord({ - workspaceId: workspaceDirectory, + workspaceId, + cwd: normalizedWorktreePath, placement, createdAt: existingWorkspace?.createdAt ?? now, updatedAt: now, @@ -681,7 +688,7 @@ export async function registerPendingWorktreeWorkspace( await dependencies.projectRegistry.upsert(nextProjectRecord); await dependencies.workspaceRegistry.upsert(nextWorkspaceRecord); - await dependencies.syncWorkspaceGitWatchTarget(workspaceDirectory, { isGit: true }); + await dependencies.syncWorkspaceGitWatchTarget(normalizedWorktreePath, { isGit: true }); if ( existingWorkspace && @@ -802,16 +809,7 @@ export async function handleWorkspaceSetupStatusRequest( request: Extract, ): Promise { const workspaceId = request.workspaceId; - let snapshot = dependencies.workspaceSetupSnapshots.get(workspaceId) ?? null; - - // Fallback: if workspaceId is a directory path, resolve to numeric ID and retry lookup - if (!snapshot && Number.isNaN(Number(workspaceId))) { - const workspaces = await dependencies.workspaceRegistry.list(); - const match = workspaces.find((w) => w.cwd === workspaceId && !w.archivedAt); - if (match) { - snapshot = dependencies.workspaceSetupSnapshots.get(match.workspaceId) ?? null; - } - } + const snapshot = dependencies.workspaceSetupSnapshots.get(workspaceId) ?? null; dependencies.emit({ type: "workspace_setup_status_response", @@ -909,7 +907,7 @@ export async function runWorktreeSetupInBackground( ) { await spawnWorktreeScripts({ repoRoot: worktree.worktreePath, - workspaceId: worktree.worktreePath, + workspaceId: options.workspaceId, branchName: worktree.branchName, daemonPort: dependencies.getDaemonTcpPort?.() ?? null, daemonListenHost: dependencies.getDaemonTcpHost?.() ?? null, @@ -918,7 +916,7 @@ export async function runWorktreeSetupInBackground( terminalManager: dependencies.terminalManager, logger: dependencies.sessionLogger, onLifecycleChanged: () => { - dependencies.onScriptsChanged?.(worktree.worktreePath); + dependencies.onScriptsChanged?.(options.workspaceId, worktree.worktreePath); }, }); } diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index c61832748..0c5559d53 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -739,7 +739,7 @@ export const FetchWorkspacesRequestMessageSchema = z.object({ filter: z .object({ query: z.string().optional(), - projectId: z.union([z.string(), z.number()]).transform(String).optional(), + projectId: z.string().optional(), idPrefix: z.string().optional(), }) .optional(), @@ -843,7 +843,7 @@ export type GitSetupOptions = z.infer; export const CreateAgentRequestMessageSchema = z.object({ type: z.literal("create_agent_request"), config: AgentSessionConfigSchema, - workspaceId: z.union([z.string(), z.number()]).transform(String).optional(), + workspaceId: z.string().optional(), worktreeName: z.string().optional(), initialPrompt: z.string().optional(), clientMessageId: z.string().optional(), @@ -1285,7 +1285,7 @@ export const OpenProjectRequestSchema = z.object({ export const ArchiveWorkspaceRequestSchema = z.object({ type: z.literal("archive_workspace_request"), - workspaceId: z.union([z.string(), z.number()]).transform(String), + workspaceId: z.string(), requestId: z.string(), }); @@ -1993,8 +1993,8 @@ const WorkspaceGitHubRuntimePayloadSchema = z .nullable(); export const WorkspaceDescriptorPayloadSchema = z.object({ - id: z.union([z.string(), z.number()]).transform(String), - projectId: z.union([z.string(), z.number()]).transform(String), + id: z.string(), + projectId: z.string(), projectDisplayName: z.string(), projectRootPath: z.string(), workspaceDirectory: z.string(), @@ -2101,7 +2101,7 @@ export const WorkspaceUpdateMessageSchema = z.object({ }), z.object({ kind: z.literal("remove"), - id: z.union([z.string(), z.number()]).transform(String), + id: z.string(), }), ]), }); @@ -2180,7 +2180,7 @@ export const ArchiveWorkspaceResponseMessageSchema = z.object({ type: z.literal("archive_workspace_response"), payload: z.object({ requestId: z.string(), - workspaceId: z.union([z.string(), z.number()]).transform(String), + workspaceId: z.string(), archivedAt: z.string().nullable(), error: z.string().nullable(), }), diff --git a/packages/server/src/shared/messages.workspaces.test.ts b/packages/server/src/shared/messages.workspaces.test.ts index 13e556529..848be6a4f 100644 --- a/packages/server/src/shared/messages.workspaces.test.ts +++ b/packages/server/src/shared/messages.workspaces.test.ts @@ -9,7 +9,7 @@ describe("workspace message schemas", () => { requestId: "req-1", filter: { query: "repo", - projectId: 12, + projectId: "proj-12", idPrefix: "/Users/me", }, sort: [{ key: "activity_at", direction: "desc" }], @@ -102,8 +102,8 @@ describe("workspace message schemas", () => { payload: { kind: "upsert", workspace: { - id: 1, - projectId: 1, + id: "ws-invalid", + projectId: "proj-invalid", projectDisplayName: "repo", projectRootPath: "/repo", projectKind: "directory", @@ -125,8 +125,8 @@ describe("workspace message schemas", () => { payload: { kind: "upsert", workspace: { - id: 1, - projectId: 1, + id: "ws-1", + projectId: "proj-1", projectDisplayName: "repo", projectRootPath: "/repo", workspaceDirectory: "/repo", @@ -200,7 +200,7 @@ describe("workspace message schemas", () => { const parsed = SessionOutboundMessageSchema.parse({ type: "script_status_update", payload: { - workspaceId: "/repo", + workspaceId: "ws-repo", scripts: [ { scriptName: "web", @@ -215,7 +215,7 @@ describe("workspace message schemas", () => { }); expect(parsed.type).toBe("script_status_update"); - expect(parsed.payload.workspaceId).toBe("/repo"); + expect(parsed.payload.workspaceId).toBe("ws-repo"); expect(parsed.payload.scripts[0]).toMatchObject({ type: "service", exitCode: null, @@ -226,7 +226,7 @@ describe("workspace message schemas", () => { const parsed = SessionOutboundMessageSchema.parse({ type: "workspace_setup_progress", payload: { - workspaceId: "/repo/.paseo/worktrees/feature-a", + workspaceId: "ws-feature-a", status: "completed", detail: { type: "worktree_setup", @@ -255,7 +255,7 @@ describe("workspace message schemas", () => { test("parses workspace_setup_status_request", () => { const parsed = SessionInboundMessageSchema.parse({ type: "workspace_setup_status_request", - workspaceId: "/repo/.paseo/worktrees/feature-a", + workspaceId: "ws-feature-a", requestId: "req-status", }); @@ -267,7 +267,7 @@ describe("workspace message schemas", () => { type: "workspace_setup_status_response", payload: { requestId: "req-status", - workspaceId: "/repo/.paseo/worktrees/feature-a", + workspaceId: "ws-feature-a", snapshot: { status: "completed", detail: { @@ -292,7 +292,7 @@ describe("workspace message schemas", () => { requestId: "req-workspaces", entries: [ { - id: "/tmp/repo", + id: "ws-main", projectId: "remote:github.com/acme/repo", projectDisplayName: "acme/repo", projectRootPath: "/tmp/repo", @@ -357,7 +357,7 @@ describe("workspace message schemas", () => { requestId: "req-workspaces", entries: [ { - id: "/tmp/repo", + id: "ws-main", projectId: "remote:github.com/acme/repo", projectDisplayName: "acme/repo", projectRootPath: "/tmp/repo", @@ -429,7 +429,7 @@ describe("workspace message schemas", () => { const parsed = legacyMessageSchema.parse(message); expect(parsed.payload.entries[0]).toEqual({ - id: "/tmp/repo", + id: "ws-main", projectId: "remote:github.com/acme/repo", projectDisplayName: "acme/repo", projectRootPath: "/tmp/repo",