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
@@ -1280,7 +1280,7 @@ function WorkspaceRowWithMenu({
void (async () => { void (async () => {
try { try {
const payload = await client.archiveWorkspace(Number(workspace.workspaceId)); const payload = await client.archiveWorkspace(workspace.workspaceId);
if (payload.error) { if (payload.error) {
throw new Error(payload.error); throw new Error(payload.error);
} }
@@ -1438,7 +1438,7 @@ function NonGitProjectRowWithMenuContent({
void (async () => { void (async () => {
try { try {
const payload = await client.archiveWorkspace(Number(workspace.workspaceId)); const payload = await client.archiveWorkspace(workspace.workspaceId);
if (payload.error) { if (payload.error) {
throw new Error(payload.error); throw new Error(payload.error);
} }
@@ -1792,7 +1792,7 @@ function ProjectBlock({
void Promise.allSettled( void Promise.allSettled(
project.workspaces.map(async (ws) => { project.workspaces.map(async (ws) => {
const payload = await client.archiveWorkspace(Number(ws.workspaceId)); const payload = await client.archiveWorkspace(ws.workspaceId);
if (payload.error) { if (payload.error) {
throw new Error(payload.error); throw new Error(payload.error);
} }
@@ -5,6 +5,7 @@ import { patchWorkspaceScripts } from "./session-workspace-scripts";
function workspace(input: { function workspace(input: {
id: string; id: string;
workspaceDirectory?: string;
scripts?: WorkspaceDescriptor["scripts"]; scripts?: WorkspaceDescriptor["scripts"];
}): WorkspaceDescriptor { }): WorkspaceDescriptor {
return { return {
@@ -12,7 +13,7 @@ function workspace(input: {
projectId: "project-1", projectId: "project-1",
projectDisplayName: "Project 1", projectDisplayName: "Project 1",
projectRootPath: "/repo", projectRootPath: "/repo",
workspaceDirectory: input.id, workspaceDirectory: input.workspaceDirectory ?? "/repo/main",
projectKind: "git", projectKind: "git",
workspaceKind: "checkout", workspaceKind: "checkout",
name: "main", name: "main",
@@ -35,58 +36,54 @@ const runningScript: WorkspaceScriptPayload = {
describe("patchWorkspaceScripts", () => { describe("patchWorkspaceScripts", () => {
it("patches only the matching workspace scripts", () => { 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<string, WorkspaceDescriptor>([ const current = new Map<string, WorkspaceDescriptor>([
["/repo/main", workspace({ id: "/repo/main", scripts: [] })], ["ws-main", workspace({ id: "ws-main", workspaceDirectory: "/repo/main", scripts: [] })],
[other.id, other], [other.id, other],
]); ]);
const next = patchWorkspaceScripts(current, { const next = patchWorkspaceScripts(current, {
workspaceId: "/repo/main", workspaceId: "ws-main",
scripts: [runningScript], scripts: [runningScript],
}); });
expect(next).not.toBe(current); expect(next).not.toBe(current);
expect(next.get("/repo/main")?.scripts).toEqual([runningScript]); expect(next.get("ws-main")?.scripts).toEqual([runningScript]);
expect(next.get("/repo/other")).toBe(other); 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<string, WorkspaceDescriptor>([ const current = new Map<string, WorkspaceDescriptor>([
[ [
"42", "workspace-record-42",
workspace({ workspace({
id: "42", id: "ws-main",
workspaceDirectory: "C:\\repo\\main\\",
scripts: [], scripts: [],
}), }),
], ],
]); ]);
current.set("42", {
...current.get("42")!,
workspaceDirectory: "C:\\repo\\main\\",
});
const next = patchWorkspaceScripts(current, { const next = patchWorkspaceScripts(current, {
workspaceId: "C:/repo/main", workspaceId: "ws-main",
scripts: [runningScript], scripts: [runningScript],
}); });
expect(next).not.toBe(current); 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", () => { it("ignores updates for unknown workspaces", () => {
const current = new Map<string, WorkspaceDescriptor>([ const current = new Map<string, WorkspaceDescriptor>([
["/repo/main", workspace({ id: "/repo/main", scripts: [] })], ["ws-main", workspace({ id: "ws-main", workspaceDirectory: "/repo/main", scripts: [] })],
]); ]);
const next = patchWorkspaceScripts(current, { const next = patchWorkspaceScripts(current, {
workspaceId: "/repo/missing", workspaceId: "ws-missing",
scripts: [runningScript], scripts: [runningScript],
}); });
expect(next).toBe(current); expect(next).toBe(current);
expect(next.get("/repo/main")?.scripts).toEqual([]); expect(next.get("ws-main")?.scripts).toEqual([]);
}); });
}); });
@@ -8,7 +8,7 @@ export function patchWorkspaceScripts(
): Map<string, WorkspaceDescriptor> { ): Map<string, WorkspaceDescriptor> {
const workspaceKey = resolveWorkspaceMapKeyByIdentity({ const workspaceKey = resolveWorkspaceMapKeyByIdentity({
workspaces, workspaces,
workspaceIdentity: update.workspaceId, workspaceId: update.workspaceId,
}); });
if (!workspaceKey) { if (!workspaceKey) {
return workspaces; return workspaces;
@@ -80,7 +80,6 @@ function compareWorkspaceBaseline(
} }
return left.workspaceId.localeCompare(right.workspaceId, undefined, { return left.workspaceId.localeCompare(right.workspaceId, undefined, {
numeric: true,
sensitivity: "base", sensitivity: "base",
}); });
} }
@@ -54,15 +54,15 @@ function makeAgent(input: {
describe("workspace agent visibility", () => { describe("workspace agent visibility", () => {
it("keeps archived agents out of activeAgentIds but present in knownAgentIds", () => { it("keeps archived agents out of activeAgentIds but present in knownAgentIds", () => {
const workspaceId = "/repo/worktree"; const workspaceDirectory = "/repo/worktree";
const visible = makeAgent({ const visible = makeAgent({
id: "visible-agent", id: "visible-agent",
cwd: workspaceId, cwd: workspaceDirectory,
createdAt: new Date("2026-03-04T00:00:00.000Z"), createdAt: new Date("2026-03-04T00:00:00.000Z"),
}); });
const archived = makeAgent({ const archived = makeAgent({
id: "archived-agent", id: "archived-agent",
cwd: workspaceId, cwd: workspaceDirectory,
archivedAt: new Date("2026-03-04T00:01:00.000Z"), archivedAt: new Date("2026-03-04T00:01:00.000Z"),
createdAt: 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({ const result = deriveWorkspaceAgentVisibility({
sessionAgents, sessionAgents,
workspaceDirectory: workspaceId, workspaceDirectory,
}); });
expect(result.activeAgentIds).toEqual(new Set(["visible-agent"])); expect(result.activeAgentIds).toEqual(new Set(["visible-agent"]));
@@ -1,8 +1,8 @@
import type { Agent } from "@/stores/session-store"; 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 { function normalizeWorkspaceId(value: string | null | undefined): string {
return normalizeWorkspaceIdentity(value) ?? ""; return normalizeWorkspacePath(value) ?? "";
} }
export interface WorkspaceAgentVisibility { export interface WorkspaceAgentVisibility {
+2 -2
View File
@@ -27,7 +27,7 @@ import type {
AgentSnapshotPayload, AgentSnapshotPayload,
WorkspaceDescriptorPayload, WorkspaceDescriptorPayload,
} from "@server/shared/messages"; } from "@server/shared/messages";
import { normalizeWorkspaceIdentity } from "@/utils/workspace-identity"; import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
import { import {
createAgentLastActivityCoalescer, createAgentLastActivityCoalescer,
type AgentLastActivityCommitter, type AgentLastActivityCommitter,
@@ -130,7 +130,7 @@ export function normalizeWorkspaceDescriptor(
payload: WorkspaceDescriptorPayload, payload: WorkspaceDescriptorPayload,
): WorkspaceDescriptor { ): WorkspaceDescriptor {
return { return {
id: normalizeWorkspaceIdentity(String(payload.id)) ?? String(payload.id), id: normalizeWorkspaceOpaqueId(String(payload.id)) ?? String(payload.id),
projectId: String(payload.projectId), projectId: String(payload.projectId),
projectDisplayName: payload.projectDisplayName, projectDisplayName: payload.projectDisplayName,
projectRootPath: payload.projectRootPath, projectRootPath: payload.projectRootPath,
@@ -32,7 +32,7 @@ import {
} from "@/stores/workspace-layout-store"; } from "@/stores/workspace-layout-store";
const SERVER_ID = "server-1"; const SERVER_ID = "server-1";
const WORKSPACE_ID = "/repo/worktree"; const WORKSPACE_ID = "ws-main";
function createTab(tabId: string): WorkspaceTab { function createTab(tabId: string): WorkspaceTab {
return { return {
@@ -813,7 +813,7 @@ describe("workspace-layout-store actions", () => {
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const otherWorkspaceKey = buildWorkspaceTabPersistenceKey({ const otherWorkspaceKey = buildWorkspaceTabPersistenceKey({
serverId: SERVER_ID, serverId: SERVER_ID,
workspaceId: "/repo/other-worktree", workspaceId: "ws-other-worktree",
}); });
expect(otherWorkspaceKey).toBeTruthy(); expect(otherWorkspaceKey).toBeTruthy();
@@ -849,7 +849,7 @@ describe("workspace-layout-store actions", () => {
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const otherWorkspaceKey = buildWorkspaceTabPersistenceKey({ const otherWorkspaceKey = buildWorkspaceTabPersistenceKey({
serverId: SERVER_ID, serverId: SERVER_ID,
workspaceId: "/repo/other-worktree", workspaceId: "ws-other-worktree",
}); });
expect(otherWorkspaceKey).toBeTruthy(); expect(otherWorkspaceKey).toBeTruthy();
@@ -23,6 +23,17 @@ import {
const SERVER_ID = "server-1"; const SERVER_ID = "server-1";
const WORKSPACE_ID = "/repo/worktree"; 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", () => { describe("workspace-tabs-store retargetTab", () => {
beforeEach(() => { beforeEach(() => {
useWorkspaceTabsStore.setState({ useWorkspaceTabsStore.setState({
@@ -28,10 +28,6 @@ function trimNonEmpty(value: string | null | undefined): string | null {
return trimmed.length > 0 ? trimmed : null; return trimmed.length > 0 ? trimmed : null;
} }
function normalizeWorkspaceId(value: string): string {
return value.trim().replace(/\\/g, "/").replace(/\/+$/, "");
}
export function buildWorkspaceTabPersistenceKey(input: { export function buildWorkspaceTabPersistenceKey(input: {
serverId: string; serverId: string;
workspaceId: string; workspaceId: string;
@@ -41,7 +37,7 @@ export function buildWorkspaceTabPersistenceKey(input: {
if (!serverId || !workspaceId) { if (!serverId || !workspaceId) {
return null; return null;
} }
return `${serverId}:${normalizeWorkspaceId(workspaceId)}`; return `${serverId}:${workspaceId}`;
} }
function normalizeTabOrder(list: unknown): string[] { function normalizeTabOrder(list: unknown): string[] {
+17 -8
View File
@@ -24,13 +24,13 @@ describe("parseHostAgentRouteFromPathname", () => {
}); });
describe("workspace route parsing", () => { describe("workspace route parsing", () => {
it("encodes numeric workspace IDs without base64", () => { it("keeps URL-safe workspace IDs unencoded", () => {
expect(encodeWorkspaceIdForPathSegment("164")).toBe("164"); expect(encodeWorkspaceIdForPathSegment("164")).toBe("164");
expect(decodeWorkspaceIdFromPathSegment("164")).toBe("164"); expect(decodeWorkspaceIdFromPathSegment("164")).toBe("164");
}); });
it("encodes path-based workspace IDs as base64url (legacy)", () => { it("encodes non-URL-safe workspace IDs as base64url", () => {
expect(encodeWorkspaceIdForPathSegment("/tmp/repo")).toBe("L3RtcC9yZXBv"); expect(encodeWorkspaceIdForPathSegment("/tmp/repo")).toBe("b64_L3RtcC9yZXBv");
expect(decodeWorkspaceIdFromPathSegment("L3RtcC9yZXBv")).toBe("/tmp/repo"); expect(decodeWorkspaceIdFromPathSegment("L3RtcC9yZXBv")).toBe("/tmp/repo");
}); });
@@ -46,7 +46,7 @@ describe("workspace route parsing", () => {
expect(decodeFilePathFromPathSegment(encoded)).toBe("src/index.ts"); 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({ expect(parseHostWorkspaceRouteFromPathname("/h/local/workspace/164")).toEqual({
serverId: "local", serverId: "local",
workspaceId: "164", workspaceId: "164",
@@ -66,12 +66,14 @@ describe("workspace route parsing", () => {
).toBeNull(); ).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"); expect(buildHostWorkspaceRoute("local", "164")).toBe("/h/local/workspace/164");
}); });
it("builds base64url workspace routes for legacy paths", () => { 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", () => { it("builds host root routes", () => {
@@ -115,12 +117,19 @@ describe("workspace route parsing", () => {
); );
}); });
it("round-trips numeric IDs through encode/decode", () => { it("round-trips URL-safe IDs through encode/decode", () => {
const ids = ["1", "40", "164", "9999"]; const ids = ["1", "40", "164", "9999", "workspace-1", "opaque_id.v2~test"];
for (const id of ids) { for (const id of ids) {
const encoded = encodeWorkspaceIdForPathSegment(id); const encoded = encodeWorkspaceIdForPathSegment(id);
const decoded = decodeWorkspaceIdFromPathSegment(encoded); const decoded = decodeWorkspaceIdFromPathSegment(encoded);
expect(decoded).toBe(id); 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"; import { Buffer } from "buffer";
type NullableString = string | null | undefined; type NullableString = string | null | undefined;
const BASE64_WORKSPACE_ID_PREFIX = "b64_";
function stripSearchAndHash(pathname: string): string { function stripSearchAndHash(pathname: string): string {
const hashIndex = pathname.indexOf("#"); const hashIndex = pathname.indexOf("#");
@@ -87,12 +88,16 @@ function tryDecodeBase64UrlNoPadUtf8(input: string): string | null {
return decoded; return decoded;
} }
function isPathLikeWorkspaceIdentity(value: string): boolean { function normalizeWorkspaceId(value: string): string {
return value.includes("/") || value.includes("\\") || /^[A-Za-z]:[\\/]/.test(value); return value.trim();
} }
function normalizeWorkspaceId(value: string): string { function isUrlSafeWorkspaceId(value: string): boolean {
return value.trim().replace(/\\/g, "/").replace(/\/+$/, ""); 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 = export type WorkspaceOpenIntent =
@@ -163,13 +168,11 @@ export function encodeWorkspaceIdForPathSegment(workspaceId: string): string {
if (!normalized) { if (!normalized) {
return ""; 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); const id = normalizeWorkspaceId(normalized);
if (isPathLikeWorkspaceIdentity(id)) { if (isUrlSafeWorkspaceId(id)) {
return toBase64UrlNoPad(id); return id;
} }
return encodeURIComponent(id); return `${BASE64_WORKSPACE_ID_PREFIX}${toBase64UrlNoPad(id)}`;
} }
export function decodeWorkspaceIdFromPathSegment(workspaceIdSegment: string): string | null { export function decodeWorkspaceIdFromPathSegment(workspaceIdSegment: string): string | null {
@@ -178,32 +181,25 @@ export function decodeWorkspaceIdFromPathSegment(workspaceIdSegment: string): st
return null; return null;
} }
// Decode %2F etc first (legacy scheme), but keep the raw segment to decide if base64 applies.
const decoded = trimNonEmpty(decodeSegment(normalizedSegment)); const decoded = trimNonEmpty(decodeSegment(normalizedSegment));
if (!decoded) { if (!decoded) {
return null; return null;
} }
// Legacy: if it already looks like a path after decoding, keep it. if (decoded.startsWith(BASE64_WORKSPACE_ID_PREFIX)) {
if (decoded.includes("/") || decoded.includes("\\")) { const encodedPayload = decoded.slice(BASE64_WORKSPACE_ID_PREFIX.length);
return normalizeWorkspaceId(decoded); const prefixedDecoded =
} tryDecodeBase64UrlNoPadUtf8(encodedPayload) ?? decodeBase64UrlNoPadUtf8(encodedPayload);
return prefixedDecoded ? normalizeWorkspaceId(prefixedDecoded) : null;
// 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;
} }
const base64Decoded = tryDecodeBase64UrlNoPadUtf8(decoded); const base64Decoded = tryDecodeBase64UrlNoPadUtf8(decoded);
if (base64Decoded) { if (base64Decoded && isLegacyPathLikeWorkspaceValue(base64Decoded)) {
return normalizeWorkspaceId(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); const relaxedBase64Decoded = decodeBase64UrlNoPadUtf8(decoded);
if (relaxedBase64Decoded && isPathLikeWorkspaceIdentity(relaxedBase64Decoded)) { if (relaxedBase64Decoded && isLegacyPathLikeWorkspaceValue(relaxedBase64Decoded)) {
return normalizeWorkspaceId(relaxedBase64Decoded); return normalizeWorkspaceId(relaxedBase64Decoded);
} }
@@ -50,9 +50,9 @@ describe("buildNotificationRoute", () => {
buildNotificationRoute({ buildNotificationRoute({
serverId: "srv-1", serverId: "srv-1",
agentId: "agent-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", () => { 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 { function workspace(overrides: Partial<SidebarWorkspaceEntry> = {}): SidebarWorkspaceEntry {
return { return {
workspaceKey: "srv:/repo", workspaceKey: "srv:ws-root",
serverId: "srv", serverId: "srv",
workspaceId: "/repo", workspaceId: "ws-root",
workspaceDirectory: "/repo",
projectKind: "git", projectKind: "git",
workspaceKind: "checkout", workspaceKind: "checkout",
name: "paseo", name: "paseo",
@@ -41,7 +42,7 @@ function project(overrides: Partial<SidebarProjectEntry> = {}): SidebarProjectEn
describe("buildSidebarProjectRowModel", () => { describe("buildSidebarProjectRowModel", () => {
it("flattens non-git projects with one workspace into a direct workspace row model", () => { it("flattens non-git projects with one workspace into a direct workspace row model", () => {
const flattenedWorkspace = workspace({ const flattenedWorkspace = workspace({
workspaceId: "/repo/non-git", workspaceId: "ws-non-git",
workspaceKind: "checkout", workspaceKind: "checkout",
statusBucket: "running", statusBucket: "running",
}); });
@@ -66,7 +67,7 @@ describe("buildSidebarProjectRowModel", () => {
it("marks flattened non-git project rows as selected when their workspace is active", () => { it("marks flattened non-git project rows as selected when their workspace is active", () => {
const flattenedWorkspace = workspace({ const flattenedWorkspace = workspace({
serverId: "srv-2", serverId: "srv-2",
workspaceId: "/repo/non-git", workspaceId: "ws-non-git",
}); });
const result = buildSidebarProjectRowModel({ const result = buildSidebarProjectRowModel({
@@ -78,7 +79,7 @@ describe("buildSidebarProjectRowModel", () => {
serverId: "srv-2", serverId: "srv-2",
activeWorkspaceSelection: { activeWorkspaceSelection: {
serverId: "srv-2", 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", () => { it("keeps single-workspace git projects as sections with the new worktree action", () => {
const onlyWorkspace = workspace({ const onlyWorkspace = workspace({
workspaceId: "/repo/main", workspaceId: "ws-main",
workspaceKind: "checkout", workspaceKind: "checkout",
}); });
@@ -114,8 +115,8 @@ describe("buildSidebarProjectRowModel", () => {
project: project({ project: project({
projectKind: "git", projectKind: "git",
workspaces: [ workspaces: [
workspace({ workspaceId: "/repo/main", workspaceKind: "checkout" }), workspace({ workspaceId: "ws-main", workspaceKind: "checkout" }),
workspace({ workspaceId: "/repo/feature", workspaceKind: "worktree" }), workspace({ workspaceId: "ws-feature", workspaceKind: "worktree" }),
], ],
}), }),
collapsed: true, collapsed: true,
@@ -144,8 +145,8 @@ describe("isSidebarProjectFlattened", () => {
isSidebarProjectFlattened( isSidebarProjectFlattened(
project({ project({
workspaces: [ workspaces: [
workspace({ workspaceId: "/repo/main" }), workspace({ workspaceId: "ws-main" }),
workspace({ workspaceId: "/repo/feat" }), workspace({ workspaceId: "ws-feat" }),
], ],
}), }),
), ),
@@ -6,14 +6,20 @@ import type {
import { buildSidebarShortcutModel } from "./sidebar-shortcuts"; 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 { return {
workspaceKey: `${serverId}:${cwd}`, workspaceKey: `${input.serverId}:${input.workspaceId}`,
serverId, serverId: input.serverId,
workspaceId: cwd, workspaceId: input.workspaceId,
workspaceDirectory: input.workspaceDirectory,
projectKind: "git", projectKind: "git",
workspaceKind: "checkout", workspaceKind: "checkout",
name: cwd, name: input.name,
statusBucket: "done", statusBucket: "done",
diffStat: null, diffStat: null,
scripts: [], scripts: [],
@@ -26,7 +32,7 @@ function project(projectKey: string, workspaces: SidebarWorkspaceEntry[]): Sideb
projectKey, projectKey,
projectName: projectKey, projectName: projectKey,
projectKind: "git", projectKind: "git",
iconWorkingDir: workspaces[0]?.workspaceId ?? "", iconWorkingDir: workspaces[0]?.workspaceDirectory ?? "",
statusBucket: "done", statusBucket: "done",
activeCount: 0, activeCount: 0,
totalWorkspaces: workspaces.length, totalWorkspaces: workspaces.length,
@@ -37,8 +43,34 @@ function project(projectKey: string, workspaces: SidebarWorkspaceEntry[]): Sideb
describe("buildSidebarShortcutModel", () => { describe("buildSidebarShortcutModel", () => {
it("builds shortcut targets in visual order and excludes collapsed projects", () => { it("builds shortcut targets in visual order and excludes collapsed projects", () => {
const projects = [ const projects = [
project("p1", [workspace("s1", "/repo/main"), workspace("s1", "/repo/feat-a")]), project("p1", [
project("p2", [workspace("s1", "/repo2/main"), workspace("s1", "/repo2/feat-a")]), 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({ const model = buildSidebarShortcutModel({
@@ -47,21 +79,26 @@ describe("buildSidebarShortcutModel", () => {
}); });
expect(model.visibleTargets).toEqual([ expect(model.visibleTargets).toEqual([
{ serverId: "s1", workspaceId: "/repo/main" }, { serverId: "s1", workspaceId: "ws-main" },
{ serverId: "s1", workspaceId: "/repo/feat-a" }, { serverId: "s1", workspaceId: "ws-feat-a" },
]); ]);
expect(model.shortcutTargets).toEqual([ expect(model.shortcutTargets).toEqual([
{ serverId: "s1", workspaceId: "/repo/main" }, { serverId: "s1", workspaceId: "ws-main" },
{ serverId: "s1", workspaceId: "/repo/feat-a" }, { serverId: "s1", workspaceId: "ws-feat-a" },
]); ]);
expect(model.shortcutIndexByWorkspaceKey.get("s1:/repo/main")).toBe(1); expect(model.shortcutIndexByWorkspaceKey.get("s1:ws-main")).toBe(1);
expect(model.shortcutIndexByWorkspaceKey.get("s1:/repo/feat-a")).toBe(2); expect(model.shortcutIndexByWorkspaceKey.get("s1:ws-feat-a")).toBe(2);
expect(model.shortcutIndexByWorkspaceKey.get("s1:/repo2/main")).toBeUndefined(); expect(model.shortcutIndexByWorkspaceKey.get("s1:ws-repo2-main")).toBeUndefined();
}); });
it("limits shortcuts to 9", () => { it("limits shortcuts to 9", () => {
const workspaces = Array.from({ length: 20 }, (_, index) => 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)]; const projects = [project("p", workspaces)];
@@ -71,14 +108,23 @@ describe("buildSidebarShortcutModel", () => {
}); });
expect(model.visibleTargets).toHaveLength(20); 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).toHaveLength(9);
expect(model.shortcutTargets[0]).toEqual({ serverId: "s", workspaceId: "/repo/w1" }); expect(model.shortcutTargets[0]).toEqual({ serverId: "s", workspaceId: "ws-1" });
expect(model.shortcutTargets[8]).toEqual({ serverId: "s", workspaceId: "/repo/w9" }); expect(model.shortcutTargets[8]).toEqual({ serverId: "s", workspaceId: "ws-9" });
}); });
it("still excludes collapsed single-workspace git projects because they are not flattened", () => { 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({ const model = buildSidebarShortcutModel({
projects, projects,
@@ -27,8 +27,10 @@ function createWorkspace(
} }
describe("resolveWorkspaceRouteId", () => { describe("resolveWorkspaceRouteId", () => {
it("normalizes route workspace ids", () => { it("trims route workspace ids without path normalization", () => {
expect(resolveWorkspaceRouteId({ routeWorkspaceId: " /tmp/repo/ " })).toBe("/tmp/repo"); expect(resolveWorkspaceRouteId({ routeWorkspaceId: " C:\\tmp\\repo\\ " })).toBe(
"C:\\tmp\\repo\\",
);
}); });
it("returns null for empty values", () => { it("returns null for empty values", () => {
@@ -87,12 +89,12 @@ describe("resolveWorkspaceMapKeyByIdentity", () => {
expect( expect(
resolveWorkspaceMapKeyByIdentity({ resolveWorkspaceMapKeyByIdentity({
workspaces, workspaces,
workspaceIdentity: "workspace-1", workspaceId: "workspace-1",
}), }),
).toBe("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>([ const workspaces = new Map<string, WorkspaceDescriptor>([
[ [
"workspace-1", "workspace-1",
@@ -106,9 +108,9 @@ describe("resolveWorkspaceMapKeyByIdentity", () => {
expect( expect(
resolveWorkspaceMapKeyByIdentity({ resolveWorkspaceMapKeyByIdentity({
workspaces, 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 type { WorkspaceDescriptor } from "@/stores/session-store";
import { normalizeWorkspaceIdentity } from "@/utils/workspace-identity"; import { normalizeWorkspaceOpaqueId, normalizeWorkspacePath } from "@/utils/workspace-identity";
export type WorkspaceAuthorityResult = { export type WorkspaceAuthorityResult = {
workspaceId: string; workspaceId: string;
@@ -23,20 +23,20 @@ export type WorkspaceExecutionAuthorityResult =
export function resolveWorkspaceRouteId(input: { export function resolveWorkspaceRouteId(input: {
routeWorkspaceId: string | null | undefined; routeWorkspaceId: string | null | undefined;
}): string | null { }): string | null {
return normalizeWorkspaceIdentity(input.routeWorkspaceId); return normalizeWorkspaceOpaqueId(input.routeWorkspaceId);
} }
export function resolveWorkspaceIdByExecutionDirectory(input: { export function resolveWorkspaceIdByExecutionDirectory(input: {
workspaces: Iterable<WorkspaceDescriptor> | null | undefined; workspaces: Iterable<WorkspaceDescriptor> | null | undefined;
workspaceDirectory: string | null | undefined; workspaceDirectory: string | null | undefined;
}): string | null { }): string | null {
const normalizedWorkspaceDirectory = normalizeWorkspaceIdentity(input.workspaceDirectory); const normalizedWorkspaceDirectory = normalizeWorkspacePath(input.workspaceDirectory);
if (!normalizedWorkspaceDirectory) { if (!normalizedWorkspaceDirectory) {
return null; return null;
} }
for (const workspace of input.workspaces ?? []) { for (const workspace of input.workspaces ?? []) {
if (normalizeWorkspaceIdentity(workspace.workspaceDirectory) === normalizedWorkspaceDirectory) { if (normalizeWorkspacePath(workspace.workspaceDirectory) === normalizedWorkspaceDirectory) {
return workspace.id; return workspace.id;
} }
} }
@@ -46,10 +46,10 @@ export function resolveWorkspaceIdByExecutionDirectory(input: {
export function resolveWorkspaceMapKeyByIdentity(input: { export function resolveWorkspaceMapKeyByIdentity(input: {
workspaces: Map<string, WorkspaceDescriptor> | null | undefined; workspaces: Map<string, WorkspaceDescriptor> | null | undefined;
workspaceIdentity: string | null | undefined; workspaceId: string | null | undefined;
}): string | null { }): string | null {
const normalizedWorkspaceIdentity = normalizeWorkspaceIdentity(input.workspaceIdentity); const normalizedWorkspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
if (!normalizedWorkspaceIdentity) { if (!normalizedWorkspaceId) {
return null; return null;
} }
@@ -58,15 +58,12 @@ export function resolveWorkspaceMapKeyByIdentity(input: {
return null; return null;
} }
if (workspaces.has(normalizedWorkspaceIdentity)) { if (workspaces.has(normalizedWorkspaceId)) {
return normalizedWorkspaceIdentity; return normalizedWorkspaceId;
} }
for (const [workspaceKey, workspace] of workspaces) { for (const [workspaceKey, workspace] of workspaces) {
if ( if (normalizeWorkspaceOpaqueId(workspace.id) === normalizedWorkspaceId) {
normalizeWorkspaceIdentity(workspace.id) === normalizedWorkspaceIdentity ||
normalizeWorkspaceIdentity(workspace.workspaceDirectory) === normalizedWorkspaceIdentity
) {
return workspaceKey; return workspaceKey;
} }
} }
@@ -90,7 +87,7 @@ export function getWorkspaceExecutionAuthority(
: (() => { : (() => {
const workspaceKey = resolveWorkspaceMapKeyByIdentity({ const workspaceKey = resolveWorkspaceMapKeyByIdentity({
workspaces: input.workspaces, workspaces: input.workspaces,
workspaceIdentity: input.workspaceId, workspaceId: input.workspaceId,
}); });
if (!workspaceKey) { if (!workspaceKey) {
return null; return null;
@@ -99,7 +96,7 @@ export function getWorkspaceExecutionAuthority(
})(); })();
if ("workspaces" in input) { if ("workspaces" in input) {
const normalizedWorkspaceId = normalizeWorkspaceIdentity(input.workspaceId); const normalizedWorkspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
if (!normalizedWorkspaceId) { if (!normalizedWorkspaceId) {
return { return {
ok: false, ok: false,
@@ -120,7 +117,7 @@ export function getWorkspaceExecutionAuthority(
}; };
} }
const workspaceDirectory = normalizeWorkspaceIdentity(workspace.workspaceDirectory); const workspaceDirectory = normalizeWorkspacePath(workspace.workspaceDirectory);
if (!workspaceDirectory) { if (!workspaceDirectory) {
return { return {
ok: false, ok: false,
@@ -156,24 +153,10 @@ export function requireWorkspaceExecutionAuthority(
return result.authority; 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: { export function resolveWorkspaceExecutionDirectory(input: {
workspaceDirectory: string | null | undefined; workspaceDirectory: string | null | undefined;
}): string | null { }): string | null {
return normalizeWorkspaceIdentity(input.workspaceDirectory); return normalizeWorkspacePath(input.workspaceDirectory);
} }
export function requireWorkspaceExecutionDirectory(input: { export function requireWorkspaceExecutionDirectory(input: {
@@ -206,5 +189,3 @@ export function resolveWorkspaceExecutionAuthority(
const result = getWorkspaceExecutionAuthority(input); const result = getWorkspaceExecutionAuthority(input);
return result.ok ? result.authority : null; 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; 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); const trimmed = trimNonEmpty(value);
if (!trimmed) { if (!trimmed) {
return null; return null;
@@ -24,7 +24,7 @@ export function normalizeWorkspaceTabTarget(
} }
if (value.kind === "setup") { if (value.kind === "setup") {
const workspaceId = trimNonEmpty(value.workspaceId); const workspaceId = trimNonEmpty(value.workspaceId);
return workspaceId ? { kind: "setup", workspaceId: workspaceId.replace(/\\/g, "/") } : null; return workspaceId ? { kind: "setup", workspaceId } : null;
} }
return null; return null;
} }
@@ -239,7 +239,7 @@ describe("DaemonClient", () => {
wrapSessionMessage({ wrapSessionMessage({
type: "workspace_setup_progress", type: "workspace_setup_progress",
payload: { payload: {
workspaceId: "/tmp/project/.paseo/worktrees/feature-a", workspaceId: "ws-feature-a",
status: "running", status: "running",
detail: { detail: {
type: "worktree_setup", type: "worktree_setup",
@@ -264,9 +264,9 @@ describe("DaemonClient", () => {
expect(events).toContainEqual({ expect(events).toContainEqual({
type: "workspace_setup_progress", type: "workspace_setup_progress",
workspaceId: "/tmp/project/.paseo/worktrees/feature-a", workspaceId: "ws-feature-a",
payload: { payload: {
workspaceId: "/tmp/project/.paseo/worktrees/feature-a", workspaceId: "ws-feature-a",
status: "running", status: "running",
detail: { detail: {
type: "worktree_setup", type: "worktree_setup",
@@ -309,7 +309,7 @@ describe("DaemonClient", () => {
const createPromise = client.createAgent({ const createPromise = client.createAgent({
provider: "codex", provider: "codex",
cwd: "/tmp/project/.paseo/worktrees/feature-a", cwd: "/tmp/project/.paseo/worktrees/feature-a",
workspaceId: "/tmp/project/.paseo/worktrees/feature-a", workspaceId: "ws-feature-a",
title: "Compat agent", title: "Compat agent",
modeId: "default", modeId: "default",
}); });
@@ -326,7 +326,7 @@ describe("DaemonClient", () => {
expect(request.message).toEqual( expect(request.message).toEqual(
expect.objectContaining({ expect.objectContaining({
type: "create_agent_request", type: "create_agent_request",
workspaceId: "/tmp/project/.paseo/worktrees/feature-a", workspaceId: "ws-feature-a",
}), }),
); );
+2 -2
View File
@@ -210,7 +210,7 @@ export type CreateAgentRequestOptions = {
config?: AgentSessionConfig; config?: AgentSessionConfig;
provider?: AgentProvider; provider?: AgentProvider;
cwd?: string; cwd?: string;
workspaceId?: string | number; workspaceId?: string;
initialPrompt?: string; initialPrompt?: string;
clientMessageId?: string; clientMessageId?: string;
outputSchema?: Record<string, unknown>; outputSchema?: Record<string, unknown>;
@@ -1400,7 +1400,7 @@ export class DaemonClient {
} }
async archiveWorkspace( async archiveWorkspace(
workspaceId: string | number, workspaceId: string,
requestId?: string, requestId?: string,
): Promise<ArchiveWorkspacePayload> { ): Promise<ArchiveWorkspacePayload> {
return this.sendCorrelatedSessionRequest({ return this.sendCorrelatedSessionRequest({
+4 -1
View File
@@ -237,6 +237,7 @@ export async function createPaseoDaemon(
const app = express(); const app = express();
let boundListenTarget: ListenTarget | null = null; let boundListenTarget: ListenTarget | null = null;
let workspaceRegistry: FileBackedWorkspaceRegistry | null = null;
const scriptRouteStore = new ScriptRouteStore(); const scriptRouteStore = new ScriptRouteStore();
const scriptRuntimeStore = new WorkspaceScriptRuntimeStore(); const scriptRuntimeStore = new WorkspaceScriptRuntimeStore();
@@ -252,6 +253,8 @@ export async function createPaseoDaemon(
routeStore: scriptRouteStore, routeStore: scriptRouteStore,
runtimeStore: scriptRuntimeStore, runtimeStore: scriptRuntimeStore,
daemonPort: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null), daemonPort: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null),
resolveWorkspaceDirectory: async (workspaceId) =>
(await workspaceRegistry?.get(workspaceId))?.cwd ?? null,
}), }),
}); });
const handleBranchChange = createBranchChangeRouteHandler({ const handleBranchChange = createBranchChangeRouteHandler({
@@ -396,7 +399,7 @@ export async function createPaseoDaemon(
path.join(config.paseoHome, "projects", "projects.json"), path.join(config.paseoHome, "projects", "projects.json"),
logger, logger,
); );
const workspaceRegistry = new FileBackedWorkspaceRegistry( workspaceRegistry = new FileBackedWorkspaceRegistry(
path.join(config.paseoHome, "projects", "workspaces.json"), path.join(config.paseoHome, "projects", "workspaces.json"),
logger, logger,
); );
@@ -38,6 +38,7 @@ function createWorkspaceRepo(options?: {
} }
function buildPayloads(input: { function buildPayloads(input: {
workspaceId: string;
workspaceDirectory: string; workspaceDirectory: string;
routeStore: ScriptRouteStore; routeStore: ScriptRouteStore;
runtimeStore: WorkspaceScriptRuntimeStore; runtimeStore: WorkspaceScriptRuntimeStore;
@@ -49,6 +50,7 @@ function buildPayloads(input: {
describe("script-status-projection", () => { describe("script-status-projection", () => {
it("projects plain scripts and services differently", () => { it("projects plain scripts and services differently", () => {
const workspaceId = "workspace-plain-and-service";
const workspace = createWorkspaceRepo({ const workspace = createWorkspaceRepo({
paseoConfig: { paseoConfig: {
scripts: { scripts: {
@@ -60,7 +62,7 @@ describe("script-status-projection", () => {
const routeStore = new ScriptRouteStore(); const routeStore = new ScriptRouteStore();
const runtimeStore = new WorkspaceScriptRuntimeStore(); const runtimeStore = new WorkspaceScriptRuntimeStore();
runtimeStore.set({ runtimeStore.set({
workspaceId: workspace.repoDir, workspaceId,
scriptName: "typecheck", scriptName: "typecheck",
type: "script", type: "script",
lifecycle: "stopped", lifecycle: "stopped",
@@ -71,6 +73,7 @@ describe("script-status-projection", () => {
try { try {
expect( expect(
buildPayloads({ buildPayloads({
workspaceId,
workspaceDirectory: workspace.repoDir, workspaceDirectory: workspace.repoDir,
routeStore, routeStore,
runtimeStore, runtimeStore,
@@ -104,6 +107,7 @@ describe("script-status-projection", () => {
}); });
it("overlays runtime, route, and health state for running services", () => { it("overlays runtime, route, and health state for running services", () => {
const workspaceId = "workspace-running-service";
const workspace = createWorkspaceRepo({ const workspace = createWorkspaceRepo({
branchName: "feature/card", branchName: "feature/card",
paseoConfig: { paseoConfig: {
@@ -116,12 +120,12 @@ describe("script-status-projection", () => {
routeStore.registerRoute({ routeStore.registerRoute({
hostname: "feature-card.web.localhost", hostname: "feature-card.web.localhost",
port: 4321, port: 4321,
workspaceId: workspace.repoDir, workspaceId,
scriptName: "web", scriptName: "web",
}); });
const runtimeStore = new WorkspaceScriptRuntimeStore(); const runtimeStore = new WorkspaceScriptRuntimeStore();
runtimeStore.set({ runtimeStore.set({
workspaceId: workspace.repoDir, workspaceId,
scriptName: "web", scriptName: "web",
type: "service", type: "service",
lifecycle: "running", lifecycle: "running",
@@ -132,6 +136,7 @@ describe("script-status-projection", () => {
try { try {
expect( expect(
buildPayloads({ buildPayloads({
workspaceId,
workspaceDirectory: workspace.repoDir, workspaceDirectory: workspace.repoDir,
routeStore, routeStore,
runtimeStore, runtimeStore,
@@ -156,6 +161,7 @@ describe("script-status-projection", () => {
}); });
it("maps internal pending health to null on the wire", () => { it("maps internal pending health to null on the wire", () => {
const workspaceId = "workspace-pending-health";
const workspace = createWorkspaceRepo({ const workspace = createWorkspaceRepo({
paseoConfig: { paseoConfig: {
scripts: { scripts: {
@@ -167,12 +173,12 @@ describe("script-status-projection", () => {
routeStore.registerRoute({ routeStore.registerRoute({
hostname: "web.localhost", hostname: "web.localhost",
port: 4321, port: 4321,
workspaceId: workspace.repoDir, workspaceId,
scriptName: "web", scriptName: "web",
}); });
const runtimeStore = new WorkspaceScriptRuntimeStore(); const runtimeStore = new WorkspaceScriptRuntimeStore();
runtimeStore.set({ runtimeStore.set({
workspaceId: workspace.repoDir, workspaceId,
scriptName: "web", scriptName: "web",
type: "service", type: "service",
lifecycle: "running", lifecycle: "running",
@@ -183,6 +189,7 @@ describe("script-status-projection", () => {
try { try {
expect( expect(
buildPayloads({ buildPayloads({
workspaceId,
workspaceDirectory: workspace.repoDir, workspaceDirectory: workspace.repoDir,
routeStore, routeStore,
runtimeStore, runtimeStore,
@@ -207,17 +214,18 @@ describe("script-status-projection", () => {
}); });
it("includes orphaned running runtime entries even after config removal", () => { it("includes orphaned running runtime entries even after config removal", () => {
const workspaceId = "workspace-orphaned-service";
const workspace = createWorkspaceRepo(); const workspace = createWorkspaceRepo();
const routeStore = new ScriptRouteStore(); const routeStore = new ScriptRouteStore();
routeStore.registerRoute({ routeStore.registerRoute({
hostname: "docs.localhost", hostname: "docs.localhost",
port: 3002, port: 3002,
workspaceId: workspace.repoDir, workspaceId,
scriptName: "docs", scriptName: "docs",
}); });
const runtimeStore = new WorkspaceScriptRuntimeStore(); const runtimeStore = new WorkspaceScriptRuntimeStore();
runtimeStore.set({ runtimeStore.set({
workspaceId: workspace.repoDir, workspaceId,
scriptName: "docs", scriptName: "docs",
type: "service", type: "service",
lifecycle: "running", lifecycle: "running",
@@ -228,6 +236,7 @@ describe("script-status-projection", () => {
try { try {
expect( expect(
buildPayloads({ buildPayloads({
workspaceId,
workspaceDirectory: workspace.repoDir, workspaceDirectory: workspace.repoDir,
routeStore, routeStore,
runtimeStore, runtimeStore,
@@ -251,11 +260,12 @@ describe("script-status-projection", () => {
}); });
it("projects orphaned plain scripts as scripts instead of services", () => { it("projects orphaned plain scripts as scripts instead of services", () => {
const workspaceId = "workspace-orphaned-script";
const workspace = createWorkspaceRepo(); const workspace = createWorkspaceRepo();
const routeStore = new ScriptRouteStore(); const routeStore = new ScriptRouteStore();
const runtimeStore = new WorkspaceScriptRuntimeStore(); const runtimeStore = new WorkspaceScriptRuntimeStore();
runtimeStore.set({ runtimeStore.set({
workspaceId: workspace.repoDir, workspaceId,
scriptName: "typecheck", scriptName: "typecheck",
type: "script", type: "script",
lifecycle: "running", lifecycle: "running",
@@ -266,6 +276,7 @@ describe("script-status-projection", () => {
try { try {
expect( expect(
buildPayloads({ buildPayloads({
workspaceId,
workspaceDirectory: workspace.repoDir, workspaceDirectory: workspace.repoDir,
routeStore, routeStore,
runtimeStore, 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({ const workspace = createWorkspaceRepo({
paseoConfig: { paseoConfig: {
scripts: { scripts: {
@@ -301,12 +313,12 @@ describe("script-status-projection", () => {
routeStore.registerRoute({ routeStore.registerRoute({
hostname: "api.localhost", hostname: "api.localhost",
port: 3001, port: 3001,
workspaceId: workspace.repoDir, workspaceId,
scriptName: "api", scriptName: "api",
}); });
const runtimeStore = new WorkspaceScriptRuntimeStore(); const runtimeStore = new WorkspaceScriptRuntimeStore();
runtimeStore.set({ runtimeStore.set({
workspaceId: workspace.repoDir, workspaceId,
scriptName: "api", scriptName: "api",
type: "service", type: "service",
lifecycle: "running", lifecycle: "running",
@@ -320,10 +332,12 @@ describe("script-status-projection", () => {
routeStore, routeStore,
runtimeStore, runtimeStore,
daemonPort: 6767, daemonPort: 6767,
resolveWorkspaceDirectory: async (workspaceId) =>
workspaceId === "workspace-emitter" ? workspace.repoDir : null,
}); });
try { try {
emitUpdate(workspace.repoDir, [ emitUpdate(workspaceId, [
{ {
scriptName: "api", scriptName: "api",
hostname: "api.localhost", hostname: "api.localhost",
@@ -331,11 +345,12 @@ describe("script-status-projection", () => {
health: "healthy", health: "healthy",
}, },
]); ]);
await Promise.resolve();
expect(session.emit).toHaveBeenCalledWith({ expect(session.emit).toHaveBeenCalledWith({
type: "script_status_update", type: "script_status_update",
payload: { payload: {
workspaceId: workspace.repoDir, workspaceId,
scripts: [ scripts: [
{ {
scriptName: "api", scriptName: "api",
@@ -15,6 +15,7 @@ type SessionEmitter = {
}; };
type BuildWorkspaceScriptPayloadsOptions = { type BuildWorkspaceScriptPayloadsOptions = {
workspaceId: string;
workspaceDirectory: string; workspaceDirectory: string;
routeStore: ScriptRouteStore; routeStore: ScriptRouteStore;
runtimeStore: WorkspaceScriptRuntimeStore; runtimeStore: WorkspaceScriptRuntimeStore;
@@ -83,17 +84,18 @@ function sortPayloads(payloads: WorkspaceScriptPayload[]): WorkspaceScriptPayloa
export function buildWorkspaceScriptPayloads( export function buildWorkspaceScriptPayloads(
options: BuildWorkspaceScriptPayloadsOptions, options: BuildWorkspaceScriptPayloadsOptions,
): WorkspaceScriptPayload[] { ): WorkspaceScriptPayload[] {
const workspaceId = options.workspaceId;
const workspaceDirectory = options.workspaceDirectory; const workspaceDirectory = options.workspaceDirectory;
const branchName = resolveWorkspaceBranchName(workspaceDirectory); const branchName = resolveWorkspaceBranchName(workspaceDirectory);
const scriptConfigs = getScriptConfigs(workspaceDirectory); const scriptConfigs = getScriptConfigs(workspaceDirectory);
const runtimeEntries = new Map( const runtimeEntries = new Map(
options.runtimeStore options.runtimeStore
.listForWorkspace(workspaceDirectory) .listForWorkspace(workspaceId)
.map((entry) => [entry.scriptName, entry] as const), .map((entry) => [entry.scriptName, entry] as const),
); );
const routesByScriptName = new Map( const routesByScriptName = new Map(
options.routeStore options.routeStore
.listRoutesForWorkspace(workspaceDirectory) .listRoutesForWorkspace(workspaceId)
.map((entry) => [entry.scriptName, entry] as const), .map((entry) => [entry.scriptName, entry] as const),
); );
@@ -177,33 +179,43 @@ export function createScriptStatusEmitter({
routeStore, routeStore,
runtimeStore, runtimeStore,
daemonPort, daemonPort,
resolveWorkspaceDirectory,
}: { }: {
sessions: () => SessionEmitter[]; sessions: () => SessionEmitter[];
routeStore: ScriptRouteStore; routeStore: ScriptRouteStore;
runtimeStore: WorkspaceScriptRuntimeStore; runtimeStore: WorkspaceScriptRuntimeStore;
daemonPort: number | null | (() => number | null); daemonPort: number | null | (() => number | null);
resolveWorkspaceDirectory: (workspaceId: string) => string | null | Promise<string | null>;
}): (workspaceId: string, scripts: ScriptHealthEntry[]) => void { }): (workspaceId: string, scripts: ScriptHealthEntry[]) => void {
return (workspaceId, scripts) => { return (workspaceId, scripts) => {
const resolvedDaemonPort = resolveDaemonPort(daemonPort); void (async () => {
const scriptHealthByHostname = new Map( const workspaceDirectory = await resolveWorkspaceDirectory(workspaceId);
scripts.map((script) => [script.hostname, script.health] as const), if (!workspaceDirectory) {
); return;
}
const projected = buildWorkspaceScriptPayloads({ const resolvedDaemonPort = resolveDaemonPort(daemonPort);
workspaceDirectory: workspaceId, const scriptHealthByHostname = new Map(
routeStore, scripts.map((script) => [script.hostname, script.health] as const),
runtimeStore, );
daemonPort: resolvedDaemonPort,
resolveHealth: (hostname) => scriptHealthByHostname.get(hostname) ?? null,
});
const message = buildScriptStatusUpdateMessage({ const projected = buildWorkspaceScriptPayloads({
workspaceId, workspaceId,
scripts: projected, workspaceDirectory,
}); routeStore,
runtimeStore,
daemonPort: resolvedDaemonPort,
resolveHealth: (hostname) => scriptHealthByHostname.get(hostname) ?? null,
});
for (const session of sessions()) { const message = buildScriptStatusUpdateMessage({
session.emit(message); workspaceId,
} scripts: projected,
});
for (const session of sessions()) {
session.emit(message);
}
})();
}; };
} }
+54 -68
View File
@@ -1474,6 +1474,7 @@ export class Session {
private buildPersistedProjectRecord(input: { private buildPersistedProjectRecord(input: {
workspaceId: string; workspaceId: string;
cwd: string;
placement: ProjectPlacementPayload; placement: ProjectPlacementPayload;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
@@ -1481,7 +1482,7 @@ export class Session {
return createPersistedProjectRecord({ return createPersistedProjectRecord({
projectId: input.placement.projectKey, projectId: input.placement.projectKey,
rootPath: deriveProjectRootPath({ rootPath: deriveProjectRootPath({
cwd: input.workspaceId, cwd: input.cwd,
checkout: input.placement.checkout, checkout: input.placement.checkout,
}), }),
kind: deriveProjectKind(input.placement.checkout), kind: deriveProjectKind(input.placement.checkout),
@@ -1494,6 +1495,7 @@ export class Session {
private buildPersistedWorkspaceRecord(input: { private buildPersistedWorkspaceRecord(input: {
workspaceId: string; workspaceId: string;
cwd: string;
placement: ProjectPlacementPayload; placement: ProjectPlacementPayload;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
@@ -1501,10 +1503,10 @@ export class Session {
return createPersistedWorkspaceRecord({ return createPersistedWorkspaceRecord({
workspaceId: input.workspaceId, workspaceId: input.workspaceId,
projectId: input.placement.projectKey, projectId: input.placement.projectKey,
cwd: input.workspaceId, cwd: input.cwd,
kind: deriveWorkspaceKind(input.placement.checkout), kind: deriveWorkspaceKind(input.placement.checkout),
displayName: deriveWorkspaceDisplayName({ displayName: deriveWorkspaceDisplayName({
cwd: input.workspaceId, cwd: input.cwd,
checkout: input.placement.checkout, checkout: input.placement.checkout,
}), }),
createdAt: input.createdAt, createdAt: input.createdAt,
@@ -1522,15 +1524,6 @@ export class Session {
} }
} }
private async resolveWorkspaceByIdOrDirectory(
workspaceId: string,
): Promise<PersistedWorkspaceRecord | null> {
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<string> { private async resolveWorkspaceDirectory(cwd: string): Promise<string> {
const normalizedCwd = normalizePersistedWorkspaceId(cwd); const normalizedCwd = normalizePersistedWorkspaceId(cwd);
try { try {
@@ -2980,7 +2973,7 @@ export class Session {
attachments, attachments,
); );
const resolvedWorkspace = msg.workspaceId const resolvedWorkspace = msg.workspaceId
? await this.resolveWorkspaceByIdOrDirectory(msg.workspaceId) ? await this.workspaceRegistry.get(msg.workspaceId)
: ((await this.findWorkspaceByDirectory(sessionConfig.cwd)) ?? : ((await this.findWorkspaceByDirectory(sessionConfig.cwd)) ??
(await this.findOrCreateWorkspaceForDirectory(sessionConfig.cwd))); (await this.findOrCreateWorkspaceForDirectory(sessionConfig.cwd)));
if (!resolvedWorkspace) { if (!resolvedWorkspace) {
@@ -4378,26 +4371,26 @@ export class Session {
} }
private async removeWorkspaceGitWatchTarget(cwd: string): Promise<void> { private async removeWorkspaceGitWatchTarget(cwd: string): Promise<void> {
const workspaceId = normalizePersistedWorkspaceId(cwd); const normalizedCwd = normalizePersistedWorkspaceId(cwd);
const target = this.workspaceGitWatchTargets.get(workspaceId); const target = this.workspaceGitWatchTargets.get(normalizedCwd);
if (target) { if (target) {
this.closeWorkspaceGitWatchTarget(target); this.closeWorkspaceGitWatchTarget(target);
this.workspaceGitWatchTargets.delete(workspaceId); this.workspaceGitWatchTargets.delete(normalizedCwd);
} }
} }
private removeWorkspaceGitSubscription(cwd: string): void { private removeWorkspaceGitSubscription(cwd: string): void {
const workspaceId = normalizePersistedWorkspaceId(cwd); const normalizedCwd = normalizePersistedWorkspaceId(cwd);
const target = this.workspaceGitWatchTargets.get(workspaceId); const target = this.workspaceGitWatchTargets.get(normalizedCwd);
if (target) { if (target) {
const unsubscribeFetch = this.workspaceGitFetchSubscriptions.get(workspaceId); const unsubscribeFetch = this.workspaceGitFetchSubscriptions.get(normalizedCwd);
unsubscribeFetch?.(); unsubscribeFetch?.();
this.workspaceGitFetchSubscriptions.delete(workspaceId); this.workspaceGitFetchSubscriptions.delete(normalizedCwd);
this.closeWorkspaceGitWatchTarget(target); this.closeWorkspaceGitWatchTarget(target);
this.workspaceGitWatchTargets.delete(workspaceId); this.workspaceGitWatchTargets.delete(normalizedCwd);
} }
this.workspaceGitSubscriptions.get(workspaceId)?.(); this.workspaceGitSubscriptions.get(normalizedCwd)?.();
this.workspaceGitSubscriptions.delete(workspaceId); this.workspaceGitSubscriptions.delete(normalizedCwd);
} }
private workspaceGitDescriptorFingerprint(workspace: WorkspaceDescriptorPayload | null): string { private workspaceGitDescriptorFingerprint(workspace: WorkspaceDescriptorPayload | null): string {
@@ -4442,7 +4435,7 @@ export class Session {
workspaces: Iterable<WorkspaceDescriptorPayload>, workspaces: Iterable<WorkspaceDescriptorPayload>,
): Promise<void> { ): Promise<void> {
for (const workspace of workspaces) { for (const workspace of workspaces) {
const persistedWorkspace = await this.findWorkspaceByDirectory(workspace.id); const persistedWorkspace = await this.workspaceRegistry.get(workspace.id);
if (!persistedWorkspace) { if (!persistedWorkspace) {
continue; continue;
} }
@@ -4457,20 +4450,20 @@ export class Session {
cwd: string, cwd: string,
options: { isGit: boolean }, options: { isGit: boolean },
): Promise<void> { ): Promise<void> {
const workspaceId = normalizePersistedWorkspaceId(cwd); const normalizedCwd = normalizePersistedWorkspaceId(cwd);
if (!options.isGit) { if (!options.isGit) {
this.removeWorkspaceGitSubscription(workspaceId); this.removeWorkspaceGitSubscription(normalizedCwd);
return; return;
} }
if (this.workspaceGitSubscriptions.has(workspaceId)) { if (this.workspaceGitSubscriptions.has(normalizedCwd)) {
return; return;
} }
const subscription = await this.workspaceGitService.subscribe({ cwd: workspaceId }, () => { const subscription = await this.workspaceGitService.subscribe({ cwd: normalizedCwd }, () => {
void this.emitWorkspaceUpdateForCwd(workspaceId); void this.emitWorkspaceUpdateForCwd(normalizedCwd);
}); });
this.workspaceGitSubscriptions.set(workspaceId, subscription.unsubscribe); this.workspaceGitSubscriptions.set(normalizedCwd, subscription.unsubscribe);
} }
private async handleSubscribeCheckoutDiffRequest( private async handleSubscribeCheckoutDiffRequest(
@@ -5611,8 +5604,8 @@ export class Session {
} }
return { return {
id: workspace.cwd, id: workspace.workspaceId,
projectId: resolvedProjectRecord?.rootPath ?? workspace.cwd, projectId: workspace.projectId,
projectDisplayName: resolvedProjectRecord?.displayName ?? String(workspace.projectId), projectDisplayName: resolvedProjectRecord?.displayName ?? String(workspace.projectId),
projectRootPath: resolvedProjectRecord?.rootPath ?? workspace.cwd, projectRootPath: resolvedProjectRecord?.rootPath ?? workspace.cwd,
workspaceDirectory: workspace.cwd, workspaceDirectory: workspace.cwd,
@@ -5625,6 +5618,7 @@ export class Session {
scripts: scripts:
this.scriptRouteStore && this.scriptRuntimeStore this.scriptRouteStore && this.scriptRuntimeStore
? buildWorkspaceScriptPayloads({ ? buildWorkspaceScriptPayloads({
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.cwd, workspaceDirectory: workspace.cwd,
routeStore: this.scriptRouteStore, routeStore: this.scriptRouteStore,
runtimeStore: this.scriptRuntimeStore, runtimeStore: this.scriptRuntimeStore,
@@ -5726,24 +5720,18 @@ export class Session {
.map((project) => [project.projectId, project] as const), .map((project) => [project.projectId, project] as const),
); );
const descriptorsByWorkspaceId = new Map<string, WorkspaceDescriptorPayload>(); const descriptorsByWorkspaceId = new Map<string, WorkspaceDescriptorPayload>();
const workspaceIds = options.workspaceIds const workspaceIds = options.workspaceIds ? new Set(options.workspaceIds) : null;
? new Set(
Array.from(options.workspaceIds, (workspaceId) =>
normalizePersistedWorkspaceId(workspaceId),
),
)
: null;
const workspaceIdsByDirectory = new Map( 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) { for (const workspace of activeRecords) {
if (workspaceIds && !workspaceIds.has(workspace.cwd)) { if (workspaceIds && !workspaceIds.has(workspace.workspaceId)) {
continue; continue;
} }
const projectRecord = activeProjects.get(workspace.projectId) ?? null; const projectRecord = activeProjects.get(workspace.projectId) ?? null;
descriptorsByWorkspaceId.set( descriptorsByWorkspaceId.set(
workspace.cwd, workspace.workspaceId,
await this.buildWorkspaceDescriptor({ await this.buildWorkspaceDescriptor({
workspace, workspace,
projectRecord, projectRecord,
@@ -5785,7 +5773,7 @@ export class Session {
const normalizedCwd = normalizePersistedWorkspaceId(cwd); const normalizedCwd = normalizePersistedWorkspaceId(cwd);
const exact = workspaces.find((workspace) => workspace.cwd === normalizedCwd); const exact = workspaces.find((workspace) => workspace.cwd === normalizedCwd);
if (exact) { if (exact) {
return exact.cwd; return exact.workspaceId;
} }
let bestMatch: PersistedWorkspaceRecord | null = null; let bestMatch: PersistedWorkspaceRecord | null = null;
@@ -5799,7 +5787,7 @@ export class Session {
} }
} }
return bestMatch?.cwd ?? normalizedCwd; return bestMatch?.workspaceId ?? normalizedCwd;
} }
private async listWorkspaceDescriptors(): Promise<WorkspaceDescriptorPayload[]> { private async listWorkspaceDescriptors(): Promise<WorkspaceDescriptorPayload[]> {
@@ -6140,9 +6128,12 @@ export class Session {
const workspaceRecord = createPersistedWorkspaceRecord({ const workspaceRecord = createPersistedWorkspaceRecord({
workspaceId, workspaceId,
projectId: placement.projectKey, projectId: placement.projectKey,
cwd: workspaceId, cwd: normalizedCwd,
kind: deriveWorkspaceKind(placement.checkout), kind: deriveWorkspaceKind(placement.checkout),
displayName: deriveWorkspaceDisplayName({ cwd: workspaceId, checkout: placement.checkout }), displayName: deriveWorkspaceDisplayName({
cwd: normalizedCwd,
checkout: placement.checkout,
}),
createdAt: timestamp, createdAt: timestamp,
updatedAt: timestamp, updatedAt: timestamp,
}); });
@@ -6224,9 +6215,7 @@ export class Session {
return; return;
} }
const uniqueWorkspaceIds = new Set( const uniqueWorkspaceIds = new Set(Array.from(workspaceIds));
Array.from(workspaceIds, (workspaceId) => normalizePersistedWorkspaceId(workspaceId)),
);
if (uniqueWorkspaceIds.size === 0) { if (uniqueWorkspaceIds.size === 0) {
return; return;
} }
@@ -6292,20 +6281,16 @@ export class Session {
cwd: string, cwd: string,
options?: { skipReconcile?: boolean; dedupeGitState?: boolean }, options?: { skipReconcile?: boolean; dedupeGitState?: boolean },
): Promise<void> { ): Promise<void> {
const activeWorkspaces = (await this.workspaceRegistry.list()).filter( const workspaces = await this.workspaceRegistry.list();
(workspace) => !workspace.archivedAt, const workspaceId = this.resolveRegisteredWorkspaceIdForCwd(cwd, workspaces);
);
const workspaceId = this.resolveRegisteredWorkspaceIdForCwd(cwd, activeWorkspaces);
await this.emitWorkspaceUpdatesForWorkspaceIds([workspaceId], options); await this.emitWorkspaceUpdatesForWorkspaceIds([workspaceId], options);
} }
private async emitWorkspaceUpdatesForCwds(cwds: Iterable<string>): Promise<void> { private async emitWorkspaceUpdatesForCwds(cwds: Iterable<string>): Promise<void> {
const activeWorkspaces = (await this.workspaceRegistry.list()).filter( const workspaces = await this.workspaceRegistry.list();
(workspace) => !workspace.archivedAt,
);
const uniqueWorkspaceIds = new Set<string>(); const uniqueWorkspaceIds = new Set<string>();
for (const cwd of cwds) { for (const cwd of cwds) {
uniqueWorkspaceIds.add(this.resolveRegisteredWorkspaceIdForCwd(cwd, activeWorkspaces)); uniqueWorkspaceIds.add(this.resolveRegisteredWorkspaceIdForCwd(cwd, workspaces));
} }
await this.emitWorkspaceUpdatesForWorkspaceIds(uniqueWorkspaceIds); await this.emitWorkspaceUpdatesForWorkspaceIds(uniqueWorkspaceIds);
} }
@@ -6484,12 +6469,14 @@ export class Session {
} }
private buildWorkspaceScriptPayloadSnapshot( private buildWorkspaceScriptPayloadSnapshot(
workspaceId: string,
workspaceDirectory: string, workspaceDirectory: string,
): WorkspaceDescriptorPayload["scripts"] { ): WorkspaceDescriptorPayload["scripts"] {
if (!this.scriptRouteStore || !this.scriptRuntimeStore) { if (!this.scriptRouteStore || !this.scriptRuntimeStore) {
return []; return [];
} }
return buildWorkspaceScriptPayloads({ return buildWorkspaceScriptPayloads({
workspaceId,
workspaceDirectory, workspaceDirectory,
routeStore: this.scriptRouteStore, routeStore: this.scriptRouteStore,
runtimeStore: this.scriptRuntimeStore, runtimeStore: this.scriptRuntimeStore,
@@ -6498,12 +6485,12 @@ export class Session {
}); });
} }
private emitWorkspaceScriptStatusUpdate(workspaceDirectory: string): void { private emitWorkspaceScriptStatusUpdate(workspaceId: string, workspaceDirectory: string): void {
this.emit({ this.emit({
type: "script_status_update", type: "script_status_update",
payload: { payload: {
workspaceId: workspaceDirectory, workspaceId,
scripts: this.buildWorkspaceScriptPayloadSnapshot(workspaceDirectory), scripts: this.buildWorkspaceScriptPayloadSnapshot(workspaceId, workspaceDirectory),
}, },
}); });
} }
@@ -6524,14 +6511,14 @@ export class Session {
throw new Error("Workspace scripts are not available on this daemon"); 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) { if (!workspace) {
throw new Error(`Workspace not found: ${request.workspaceId}`); throw new Error(`Workspace not found: ${request.workspaceId}`);
} }
const serviceResult = await spawnWorkspaceScript({ const serviceResult = await spawnWorkspaceScript({
repoRoot: workspace.cwd, repoRoot: workspace.cwd,
workspaceId: workspace.cwd, workspaceId: workspace.workspaceId,
branchName: readGitCommand(workspace.cwd, "git symbolic-ref --short HEAD"), branchName: readGitCommand(workspace.cwd, "git symbolic-ref --short HEAD"),
scriptName: request.scriptName, scriptName: request.scriptName,
daemonPort: this.getDaemonTcpPort?.() ?? null, daemonPort: this.getDaemonTcpPort?.() ?? null,
@@ -6541,11 +6528,11 @@ export class Session {
terminalManager: this.terminalManager, terminalManager: this.terminalManager,
logger: this.sessionLogger, logger: this.sessionLogger,
onLifecycleChanged: () => { onLifecycleChanged: () => {
this.emitWorkspaceScriptStatusUpdate(workspace.cwd); this.emitWorkspaceScriptStatusUpdate(workspace.workspaceId, workspace.cwd);
}, },
}); });
this.emitWorkspaceScriptStatusUpdate(workspace.cwd); this.emitWorkspaceScriptStatusUpdate(workspace.workspaceId, workspace.cwd);
this.emit({ this.emit({
type: "start_workspace_script_response", type: "start_workspace_script_response",
payload: { payload: {
@@ -6687,8 +6674,8 @@ export class Session {
scriptRuntimeStore: this.scriptRuntimeStore, scriptRuntimeStore: this.scriptRuntimeStore,
getDaemonTcpPort: this.getDaemonTcpPort, getDaemonTcpPort: this.getDaemonTcpPort,
getDaemonTcpHost: this.getDaemonTcpHost, getDaemonTcpHost: this.getDaemonTcpHost,
onScriptsChanged: (workspaceDirectory) => { onScriptsChanged: (workspaceId, workspaceDirectory) => {
this.emitWorkspaceScriptStatusUpdate(workspaceDirectory); this.emitWorkspaceScriptStatusUpdate(workspaceId, workspaceDirectory);
}, },
}, },
options, options,
@@ -6702,7 +6689,6 @@ export class Session {
{ {
emit: (message) => this.emit(message), emit: (message) => this.emit(message),
workspaceSetupSnapshots: this.workspaceSetupSnapshots, workspaceSetupSnapshots: this.workspaceSetupSnapshots,
workspaceRegistry: this.workspaceRegistry,
}, },
request, request,
); );
@@ -6712,7 +6698,7 @@ export class Session {
request: Extract<SessionInboundMessage, { type: "archive_workspace_request" }>, request: Extract<SessionInboundMessage, { type: "archive_workspace_request" }>,
): Promise<void> { ): Promise<void> {
try { try {
const existing = await this.resolveWorkspaceByIdOrDirectory(request.workspaceId); const existing = await this.workspaceRegistry.get(request.workspaceId);
if (!existing) { if (!existing) {
throw new Error(`Workspace not found: ${request.workspaceId}`); throw new Error(`Workspace not found: ${request.workspaceId}`);
} }
@@ -259,8 +259,8 @@ describe("workspace git watch targets", () => {
}; };
let descriptor = { let descriptor = {
id: "/tmp/repo", id: "ws-10",
projectId: "/tmp/repo", projectId: "proj-1",
projectDisplayName: "repo", projectDisplayName: "repo",
projectRootPath: "/tmp/repo", projectRootPath: "/tmp/repo",
projectKind: "git", projectKind: "git",
@@ -304,7 +304,7 @@ describe("workspace git watch targets", () => {
expect(workspaceUpdates[0]?.payload).toMatchObject({ expect(workspaceUpdates[0]?.payload).toMatchObject({
kind: "upsert", kind: "upsert",
workspace: { workspace: {
id: "/tmp/repo", id: "ws-10",
name: "renamed-branch", name: "renamed-branch",
diffStat: { additions: 1, deletions: 0 }, diffStat: { additions: 1, deletions: 0 },
}, },
@@ -899,8 +899,8 @@ describe("workspace aggregation", () => {
const session = createSessionForWorkspaceTests() as any; const session = createSessionForWorkspaceTests() as any;
session.workspaceRegistry.list = async () => [ session.workspaceRegistry.list = async () => [
createPersistedWorkspaceRecord({ createPersistedWorkspaceRecord({
workspaceId: "/tmp/non-git", workspaceId: "ws-non-git",
projectId: "/tmp/non-git", projectId: "proj-non-git",
cwd: "/tmp/non-git", cwd: "/tmp/non-git",
kind: "directory", kind: "directory",
displayName: "non-git", displayName: "non-git",
@@ -930,8 +930,8 @@ describe("workspace aggregation", () => {
const session = createSessionForWorkspaceTests() as any; const session = createSessionForWorkspaceTests() as any;
session.workspaceRegistry.list = async () => [ session.workspaceRegistry.list = async () => [
createPersistedWorkspaceRecord({ createPersistedWorkspaceRecord({
workspaceId: "/tmp/repo-branch", workspaceId: "ws-repo-branch",
projectId: "/tmp/repo-branch", projectId: "proj-repo-branch",
cwd: "/tmp/repo-branch", cwd: "/tmp/repo-branch",
kind: "local_checkout", kind: "local_checkout",
displayName: "feature/name-from-server", displayName: "feature/name-from-server",
@@ -973,8 +973,8 @@ describe("workspace aggregation", () => {
const session = createSessionForWorkspaceTests() as any; const session = createSessionForWorkspaceTests() as any;
session.workspaceRegistry.list = async () => [ session.workspaceRegistry.list = async () => [
createPersistedWorkspaceRecord({ createPersistedWorkspaceRecord({
workspaceId: "/tmp/repo", workspaceId: "ws-repo-status",
projectId: "/tmp/repo", projectId: "proj-repo-status",
cwd: "/tmp/repo", cwd: "/tmp/repo",
kind: "local_checkout", kind: "local_checkout",
displayName: "repo", displayName: "repo",
@@ -1017,8 +1017,8 @@ describe("workspace aggregation", () => {
const session = createSessionForWorkspaceTests() as any; const session = createSessionForWorkspaceTests() as any;
session.workspaceRegistry.list = async () => [ session.workspaceRegistry.list = async () => [
createPersistedWorkspaceRecord({ createPersistedWorkspaceRecord({
workspaceId: "/tmp/repo", workspaceId: "ws-repo-subdir",
projectId: "/tmp/repo", projectId: "proj-repo-subdir",
cwd: "/tmp/repo", cwd: "/tmp/repo",
kind: "local_checkout", kind: "local_checkout",
displayName: "main", displayName: "main",
@@ -1042,7 +1042,7 @@ describe("workspace aggregation", () => {
expect(result.entries).toHaveLength(1); expect(result.entries).toHaveLength(1);
expect(result.entries[0]).toMatchObject({ expect(result.entries[0]).toMatchObject({
id: "/tmp/repo", id: "ws-repo-subdir",
status: "done", status: "done",
activityAt: null, activityAt: null,
}); });
@@ -1128,8 +1128,8 @@ describe("workspace aggregation", () => {
[ [
"/tmp/repo", "/tmp/repo",
{ {
id: "/tmp/repo", id: "ws-repo-running",
projectId: "/tmp/repo", projectId: "proj-repo-running",
projectDisplayName: "repo", projectDisplayName: "repo",
projectRootPath: "/tmp/repo", projectRootPath: "/tmp/repo",
projectKind: "non_git", projectKind: "non_git",
@@ -1147,8 +1147,8 @@ describe("workspace aggregation", () => {
[ [
"/tmp/repo", "/tmp/repo",
{ {
id: "/tmp/repo", id: "ws-repo-running",
projectId: "/tmp/repo", projectId: "proj-repo-running",
projectDisplayName: "repo", projectDisplayName: "repo",
projectRootPath: "/tmp/repo", projectRootPath: "/tmp/repo",
projectKind: "non_git", projectKind: "non_git",
@@ -1167,8 +1167,8 @@ describe("workspace aggregation", () => {
expect((workspaceUpdates[1] as any).payload).toEqual({ expect((workspaceUpdates[1] as any).payload).toEqual({
kind: "upsert", kind: "upsert",
workspace: { workspace: {
id: "/tmp/repo", id: "ws-repo-running",
projectId: "/tmp/repo", projectId: "proj-repo-running",
projectDisplayName: "repo", projectDisplayName: "repo",
projectRootPath: "/tmp/repo", projectRootPath: "/tmp/repo",
projectKind: "non_git", projectKind: "non_git",
@@ -1280,6 +1280,26 @@ describe("workspace aggregation", () => {
test("workspace update fanout for multiple cwd values is deduplicated", async () => { test("workspace update fanout for multiple cwd values is deduplicated", async () => {
const emitted: Array<{ type: string; payload: unknown }> = []; const emitted: Array<{ type: string; payload: unknown }> = [];
const session = createSessionForWorkspaceTests() as any; 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 = { session.workspaceUpdatesSubscription = {
subscriptionId: "sub-dedup", subscriptionId: "sub-dedup",
filter: undefined, filter: undefined,
@@ -1288,14 +1308,14 @@ describe("workspace aggregation", () => {
lastEmittedByWorkspaceId: new Map(), lastEmittedByWorkspaceId: new Map(),
}; };
session.reconcileActiveWorkspaceRecords = async () => session.reconcileActiveWorkspaceRecords = async () =>
new Set(["/tmp/repo", "/tmp/repo/worktree"]); new Set(["ws-repo-main", "ws-repo-feature"]);
session.buildWorkspaceDescriptorMap = async () => session.buildWorkspaceDescriptorMap = async () =>
new Map([ new Map([
[ [
"/tmp/repo", "ws-repo-main",
{ {
id: "/tmp/repo", id: "ws-repo-main",
projectId: "/tmp/repo", projectId: "proj-repo-main",
projectDisplayName: "repo", projectDisplayName: "repo",
projectRootPath: "/tmp/repo", projectRootPath: "/tmp/repo",
projectKind: "git", projectKind: "git",
@@ -1306,10 +1326,10 @@ describe("workspace aggregation", () => {
}, },
], ],
[ [
"/tmp/repo/worktree", "ws-repo-feature",
{ {
id: "/tmp/repo/worktree", id: "ws-repo-feature",
projectId: "/tmp/repo", projectId: "proj-repo-main",
projectDisplayName: "repo", projectDisplayName: "repo",
projectRootPath: "/tmp/repo", projectRootPath: "/tmp/repo",
projectKind: "git", projectKind: "git",
@@ -1333,8 +1353,8 @@ describe("workspace aggregation", () => {
expect(workspaceUpdates).toHaveLength(2); expect(workspaceUpdates).toHaveLength(2);
expect(workspaceUpdates.map((entry) => entry.payload.kind)).toEqual(["upsert", "upsert"]); expect(workspaceUpdates.map((entry) => entry.payload.kind)).toEqual(["upsert", "upsert"]);
expect(workspaceUpdates.map((entry) => entry.payload.workspace.id).sort()).toEqual([ expect(workspaceUpdates.map((entry) => entry.payload.workspace.id).sort()).toEqual([
"/tmp/repo", "ws-repo-feature",
"/tmp/repo/worktree", "ws-repo-main",
]); ]);
}); });
@@ -1521,8 +1541,8 @@ describe("workspace aggregation", () => {
const emitted: Array<{ type: string; payload: unknown }> = []; const emitted: Array<{ type: string; payload: unknown }> = [];
const session = createSessionForWorkspaceTests() as any; const session = createSessionForWorkspaceTests() as any;
const workspace = createPersistedWorkspaceRecord({ const workspace = createPersistedWorkspaceRecord({
workspaceId: "/tmp/repo", workspaceId: "ws-repo-archive",
projectId: "/tmp/repo", projectId: "proj-repo-archive",
cwd: "/tmp/repo", cwd: "/tmp/repo",
kind: "directory", kind: "directory",
displayName: "repo", displayName: "repo",
@@ -1540,7 +1560,7 @@ describe("workspace aggregation", () => {
await session.handleMessage({ await session.handleMessage({
type: "archive_workspace_request", type: "archive_workspace_request",
workspaceId: "/tmp/repo", workspaceId: "ws-repo-archive",
requestId: "req-archive", requestId: "req-archive",
}); });
@@ -1855,7 +1875,7 @@ describe("workspace aggregation", () => {
test("listWorkspaceDescriptorsSnapshot keeps git workspaces on the baseline descriptor path", async () => { test("listWorkspaceDescriptorsSnapshot keeps git workspaces on the baseline descriptor path", async () => {
const session = createSessionForWorkspaceTests() as any; const session = createSessionForWorkspaceTests() as any;
const project = createPersistedProjectRecord({ const project = createPersistedProjectRecord({
projectId: "/tmp/repo", projectId: "proj-baseline",
rootPath: "/tmp/repo", rootPath: "/tmp/repo",
kind: "git", kind: "git",
displayName: "repo", displayName: "repo",
@@ -1863,7 +1883,7 @@ describe("workspace aggregation", () => {
updatedAt: "2026-03-01T12:00:00.000Z", updatedAt: "2026-03-01T12:00:00.000Z",
}); });
const workspace = createPersistedWorkspaceRecord({ const workspace = createPersistedWorkspaceRecord({
workspaceId: "/tmp/repo", workspaceId: "ws-baseline",
projectId: project.projectId, projectId: project.projectId,
cwd: "/tmp/repo", cwd: "/tmp/repo",
kind: "local_checkout", kind: "local_checkout",
@@ -1942,7 +1962,7 @@ describe("workspace aggregation", () => {
workspaceGitService, workspaceGitService,
}) as any; }) as any;
const project = createPersistedProjectRecord({ const project = createPersistedProjectRecord({
projectId: "/tmp/repo", projectId: "proj-runtime-fetch",
rootPath: "/tmp/repo", rootPath: "/tmp/repo",
kind: "git", kind: "git",
displayName: "repo", displayName: "repo",
@@ -1950,7 +1970,7 @@ describe("workspace aggregation", () => {
updatedAt: "2026-03-01T12:00:00.000Z", updatedAt: "2026-03-01T12:00:00.000Z",
}); });
const workspace = createPersistedWorkspaceRecord({ const workspace = createPersistedWorkspaceRecord({
workspaceId: "/tmp/repo", workspaceId: "ws-runtime-fetch",
projectId: project.projectId, projectId: project.projectId,
cwd: "/tmp/repo", cwd: "/tmp/repo",
kind: "local_checkout", kind: "local_checkout",
@@ -1989,7 +2009,7 @@ describe("workspace aggregation", () => {
expect(workspaceGitService.peekSnapshot).toHaveBeenCalledWith("/tmp/repo"); expect(workspaceGitService.peekSnapshot).toHaveBeenCalledWith("/tmp/repo");
expect(response?.payload.entries).toEqual([ expect(response?.payload.entries).toEqual([
expect.objectContaining({ expect.objectContaining({
id: "/tmp/repo", id: "ws-runtime-fetch",
gitRuntime: { gitRuntime: {
currentBranch: "runtime-branch", currentBranch: "runtime-branch",
remoteUrl: "https://github.com/acme/repo.git", remoteUrl: "https://github.com/acme/repo.git",
@@ -2042,7 +2062,7 @@ describe("workspace aggregation", () => {
workspaceGitService, workspaceGitService,
}) as any; }) as any;
const project = createPersistedProjectRecord({ const project = createPersistedProjectRecord({
projectId: "/tmp/repo", projectId: "proj-runtime-update",
rootPath: "/tmp/repo", rootPath: "/tmp/repo",
kind: "git", kind: "git",
displayName: "repo", displayName: "repo",
@@ -2050,7 +2070,7 @@ describe("workspace aggregation", () => {
updatedAt: "2026-03-01T12:00:00.000Z", updatedAt: "2026-03-01T12:00:00.000Z",
}); });
const workspace = createPersistedWorkspaceRecord({ const workspace = createPersistedWorkspaceRecord({
workspaceId: "/tmp/repo", workspaceId: "ws-runtime-update",
projectId: project.projectId, projectId: project.projectId,
cwd: "/tmp/repo", cwd: "/tmp/repo",
kind: "local_checkout", kind: "local_checkout",
@@ -2095,7 +2115,7 @@ describe("workspace aggregation", () => {
payload: { payload: {
kind: "upsert", kind: "upsert",
workspace: expect.objectContaining({ workspace: expect.objectContaining({
id: "/tmp/repo", id: "ws-runtime-update",
gitRuntime: expect.objectContaining({ gitRuntime: expect.objectContaining({
currentBranch: "feature/runtime-payloads", currentBranch: "feature/runtime-payloads",
isDirty: true, isDirty: true,
@@ -2117,7 +2137,7 @@ describe("workspace aggregation", () => {
const emitted: Array<{ type: string; payload: any }> = []; const emitted: Array<{ type: string; payload: any }> = [];
const session = createSessionForWorkspaceTests() as any; const session = createSessionForWorkspaceTests() as any;
const gitProject = createPersistedProjectRecord({ const gitProject = createPersistedProjectRecord({
projectId: "/tmp/repo", projectId: "proj-git-subscribe",
rootPath: "/tmp/repo", rootPath: "/tmp/repo",
kind: "git", kind: "git",
displayName: "repo", displayName: "repo",
@@ -2125,7 +2145,7 @@ describe("workspace aggregation", () => {
updatedAt: "2026-03-01T12:00:00.000Z", updatedAt: "2026-03-01T12:00:00.000Z",
}); });
const directoryProject = createPersistedProjectRecord({ const directoryProject = createPersistedProjectRecord({
projectId: "/tmp/docs", projectId: "proj-docs-subscribe",
rootPath: "/tmp/docs", rootPath: "/tmp/docs",
kind: "non_git", kind: "non_git",
displayName: "docs", displayName: "docs",
@@ -2133,7 +2153,7 @@ describe("workspace aggregation", () => {
updatedAt: "2026-03-01T12:00:00.000Z", updatedAt: "2026-03-01T12:00:00.000Z",
}); });
const gitWorkspace = createPersistedWorkspaceRecord({ const gitWorkspace = createPersistedWorkspaceRecord({
workspaceId: "/tmp/repo", workspaceId: "ws-git-subscribe",
projectId: gitProject.projectId, projectId: gitProject.projectId,
cwd: "/tmp/repo", cwd: "/tmp/repo",
kind: "local_checkout", kind: "local_checkout",
@@ -2142,7 +2162,7 @@ describe("workspace aggregation", () => {
updatedAt: "2026-03-01T12:00:00.000Z", updatedAt: "2026-03-01T12:00:00.000Z",
}); });
const directoryWorkspace = createPersistedWorkspaceRecord({ const directoryWorkspace = createPersistedWorkspaceRecord({
workspaceId: "/tmp/docs", workspaceId: "ws-docs-subscribe",
projectId: directoryProject.projectId, projectId: directoryProject.projectId,
cwd: "/tmp/docs", cwd: "/tmp/docs",
kind: "directory", kind: "directory",
@@ -93,6 +93,7 @@ export async function bootstrapWorkspaceRegistries(options: {
for (const [workspaceId, entry] of recordsByWorkspaceId.entries()) { for (const [workspaceId, entry] of recordsByWorkspaceId.entries()) {
const { placement, records: workspaceRecords } = entry; const { placement, records: workspaceRecords } = entry;
const workspaceCwd = placement.checkout.cwd;
let workspaceCreatedAt: string | null = null; let workspaceCreatedAt: string | null = null;
let workspaceUpdatedAt: string | null = null; let workspaceUpdatedAt: string | null = null;
for (const record of workspaceRecords) { for (const record of workspaceRecords) {
@@ -106,10 +107,10 @@ export async function bootstrapWorkspaceRegistries(options: {
createPersistedWorkspaceRecord({ createPersistedWorkspaceRecord({
workspaceId, workspaceId,
projectId: placement.projectKey, projectId: placement.projectKey,
cwd: workspaceId, cwd: workspaceCwd,
kind: deriveWorkspaceKind(placement.checkout), kind: deriveWorkspaceKind(placement.checkout),
displayName: deriveWorkspaceDisplayName({ displayName: deriveWorkspaceDisplayName({
cwd: workspaceId, cwd: workspaceCwd,
checkout: placement.checkout, checkout: placement.checkout,
}), }),
createdAt, createdAt,
@@ -129,7 +130,7 @@ export async function bootstrapWorkspaceRegistries(options: {
createPersistedProjectRecord({ createPersistedProjectRecord({
projectId: placement.projectKey, projectId: placement.projectKey,
rootPath: deriveProjectRootPath({ rootPath: deriveProjectRootPath({
cwd: workspaceId, cwd: workspaceCwd,
checkout: placement.checkout, checkout: placement.checkout,
}), }),
kind: deriveProjectKind(placement.checkout), kind: deriveProjectKind(placement.checkout),
@@ -6,7 +6,7 @@ import {
function createEntry(overrides: Partial<ScriptRuntimeEntry> = {}): ScriptRuntimeEntry { function createEntry(overrides: Partial<ScriptRuntimeEntry> = {}): ScriptRuntimeEntry {
return { return {
workspaceId: 101, workspaceId: "workspace-101",
scriptName: "web", scriptName: "web",
type: "service", type: "service",
lifecycle: "running", lifecycle: "running",
@@ -23,8 +23,8 @@ describe("WorkspaceScriptRuntimeStore", () => {
store.set(entry); store.set(entry);
expect(store.get({ workspaceId: 101, scriptName: "web" })).toEqual(entry); expect(store.get({ workspaceId: "workspace-101", scriptName: "web" })).toEqual(entry);
expect(store.listForWorkspace(101)).toEqual([entry]); expect(store.listForWorkspace("workspace-101")).toEqual([entry]);
}); });
it("preserves whether the runtime entry is a plain script or service", () => { it("preserves whether the runtime entry is a plain script or service", () => {
@@ -36,7 +36,7 @@ describe("WorkspaceScriptRuntimeStore", () => {
store.set(entry); 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", () => { it("reports whether a script is currently running", () => {
@@ -44,7 +44,7 @@ describe("WorkspaceScriptRuntimeStore", () => {
store.set(createEntry()); store.set(createEntry());
store.set( store.set(
createEntry({ createEntry({
workspaceId: 101, workspaceId: "workspace-101",
scriptName: "typecheck", scriptName: "typecheck",
lifecycle: "stopped", lifecycle: "stopped",
terminalId: "terminal-2", terminalId: "terminal-2",
@@ -52,19 +52,19 @@ describe("WorkspaceScriptRuntimeStore", () => {
}), }),
); );
expect(store.isRunning({ workspaceId: 101, scriptName: "web" })).toBe(true); expect(store.isRunning({ workspaceId: "workspace-101", scriptName: "web" })).toBe(true);
expect(store.isRunning({ workspaceId: 101, scriptName: "typecheck" })).toBe(false); expect(store.isRunning({ workspaceId: "workspace-101", scriptName: "typecheck" })).toBe(false);
expect(store.isRunning({ workspaceId: 101, scriptName: "missing" })).toBe(false); expect(store.isRunning({ workspaceId: "workspace-101", scriptName: "missing" })).toBe(false);
}); });
it("removes individual entries", () => { it("removes individual entries", () => {
const store = new WorkspaceScriptRuntimeStore(); const store = new WorkspaceScriptRuntimeStore();
store.set(createEntry()); 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.get({ workspaceId: "workspace-101", scriptName: "web" })).toBeNull();
expect(store.listForWorkspace(101)).toEqual([]); expect(store.listForWorkspace("workspace-101")).toEqual([]);
}); });
it("removes all entries for a workspace without touching others", () => { it("removes all entries for a workspace without touching others", () => {
@@ -72,25 +72,25 @@ describe("WorkspaceScriptRuntimeStore", () => {
store.set(createEntry()); store.set(createEntry());
store.set( store.set(
createEntry({ createEntry({
workspaceId: 101, workspaceId: "workspace-101",
scriptName: "api", scriptName: "api",
terminalId: "terminal-2", terminalId: "terminal-2",
}), }),
); );
store.set( store.set(
createEntry({ createEntry({
workspaceId: 202, workspaceId: "workspace-202",
scriptName: "docs", scriptName: "docs",
terminalId: "terminal-3", terminalId: "terminal-3",
}), }),
); );
store.removeForWorkspace(101); store.removeForWorkspace("workspace-101");
expect(store.listForWorkspace(101)).toEqual([]); expect(store.listForWorkspace("workspace-101")).toEqual([]);
expect(store.listForWorkspace(202)).toEqual([ expect(store.listForWorkspace("workspace-202")).toEqual([
createEntry({ createEntry({
workspaceId: 202, workspaceId: "workspace-202",
scriptName: "docs", scriptName: "docs",
terminalId: "terminal-3", terminalId: "terminal-3",
}), }),
@@ -1,5 +1,5 @@
export interface ScriptRuntimeEntry { export interface ScriptRuntimeEntry {
workspaceId: number | string; workspaceId: string;
scriptName: string; scriptName: string;
type: "script" | "service"; type: "script" | "service";
lifecycle: "running" | "stopped"; lifecycle: "running" | "stopped";
@@ -8,7 +8,7 @@ export interface ScriptRuntimeEntry {
} }
type RuntimeEntryKey = { type RuntimeEntryKey = {
workspaceId: number | string; workspaceId: string;
scriptName: string; scriptName: string;
}; };
@@ -44,7 +44,7 @@ export class WorkspaceScriptRuntimeStore {
this.removeScriptFromWorkspaceIndex(existing.workspaceId, existing.scriptName); this.removeScriptFromWorkspaceIndex(existing.workspaceId, existing.scriptName);
} }
listForWorkspace(workspaceId: number | string): ScriptRuntimeEntry[] { listForWorkspace(workspaceId: string): ScriptRuntimeEntry[] {
const scriptNames = this.scriptsByWorkspace.get(this.toWorkspaceKey(workspaceId)); const scriptNames = this.scriptsByWorkspace.get(this.toWorkspaceKey(workspaceId));
if (!scriptNames) { if (!scriptNames) {
return []; return [];
@@ -65,7 +65,7 @@ export class WorkspaceScriptRuntimeStore {
return entries; return entries;
} }
removeForWorkspace(workspaceId: number | string): void { removeForWorkspace(workspaceId: string): void {
for (const entry of this.listForWorkspace(workspaceId)) { for (const entry of this.listForWorkspace(workspaceId)) {
this.entries.delete(this.toEntryKey(entry)); this.entries.delete(this.toEntryKey(entry));
} }
@@ -82,7 +82,7 @@ export class WorkspaceScriptRuntimeStore {
this.scriptsByWorkspace.set(workspaceKey, scripts); 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 workspaceKey = this.toWorkspaceKey(workspaceId);
const scripts = this.scriptsByWorkspace.get(workspaceKey); const scripts = this.scriptsByWorkspace.get(workspaceKey);
if (!scripts) { if (!scripts) {
@@ -99,7 +99,7 @@ export class WorkspaceScriptRuntimeStore {
return `${this.toWorkspaceKey(key.workspaceId)}::${key.scriptName}`; return `${this.toWorkspaceKey(key.workspaceId)}::${key.scriptName}`;
} }
private toWorkspaceKey(workspaceId: number | string): string { private toWorkspaceKey(workspaceId: string): string {
return String(workspaceId); return workspaceId;
} }
} }
@@ -266,7 +266,7 @@ describe("runWorktreeSetupInBackground", () => {
const logger = createLogger(); const logger = createLogger();
const emitWorkspaceUpdateForCwd = vi.fn(async () => {}); const emitWorkspaceUpdateForCwd = vi.fn(async () => {});
const archiveWorkspaceRecord = vi.fn(async () => {}); const archiveWorkspaceRecord = vi.fn(async () => {});
const workspaceId = 101; const workspaceId = "ws-broken-feature";
await runWorktreeSetupInBackground( await runWorktreeSetupInBackground(
{ {
@@ -282,7 +282,7 @@ describe("runWorktreeSetupInBackground", () => {
{ {
requestCwd: repoDir, requestCwd: repoDir,
repoRoot: repoDir, repoRoot: repoDir,
workspaceId: String(workspaceId), workspaceId,
worktree: { worktree: {
branchName: "broken-feature", branchName: "broken-feature",
worktreePath, worktreePath,
@@ -303,11 +303,11 @@ describe("runWorktreeSetupInBackground", () => {
expect(progressMessages[1]?.payload.status).toBe("failed"); expect(progressMessages[1]?.payload.status).toBe("failed");
expect(progressMessages[1]?.payload.error).toContain("Failed to parse paseo.json"); expect(progressMessages[1]?.payload.error).toContain("Failed to parse paseo.json");
expect(progressMessages[1]?.payload.detail.commands).toEqual([]); expect(progressMessages[1]?.payload.detail.commands).toEqual([]);
expect(snapshots.get("101")).toMatchObject({ expect(snapshots.get(workspaceId)).toMatchObject({
status: "failed", status: "failed",
error: expect.stringContaining("Failed to parse paseo.json"), error: expect.stringContaining("Failed to parse paseo.json"),
}); });
expect(archiveWorkspaceRecord).toHaveBeenCalledWith(String(workspaceId)); expect(archiveWorkspaceRecord).toHaveBeenCalledWith(workspaceId);
expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(worktreePath); expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(worktreePath);
}); });
@@ -671,7 +671,7 @@ describe("runWorktreeSetupInBackground", () => {
const emitted: SessionOutboundMessage[] = []; const emitted: SessionOutboundMessage[] = [];
const snapshots = new Map([ const snapshots = new Map([
[ [
"/repo/.paseo/worktrees/feature-a", "ws-feature-a",
{ {
status: "completed", status: "completed",
detail: { detail: {
@@ -694,7 +694,7 @@ describe("runWorktreeSetupInBackground", () => {
}, },
{ {
type: "workspace_setup_status_request", type: "workspace_setup_status_request",
workspaceId: "/repo/.paseo/worktrees/feature-a", workspaceId: "ws-feature-a",
requestId: "req-status", requestId: "req-status",
}, },
); );
@@ -703,7 +703,7 @@ describe("runWorktreeSetupInBackground", () => {
type: "workspace_setup_status_response", type: "workspace_setup_status_response",
payload: { payload: {
requestId: "req-status", requestId: "req-status",
workspaceId: "/repo/.paseo/worktrees/feature-a", workspaceId: "ws-feature-a",
snapshot: { snapshot: {
status: "completed", status: "completed",
detail: { detail: {
@@ -730,7 +730,7 @@ describe("runWorktreeSetupInBackground", () => {
}, },
{ {
type: "workspace_setup_status_request", type: "workspace_setup_status_request",
workspaceId: "/repo/.paseo/worktrees/missing", workspaceId: "ws-missing",
requestId: "req-missing", requestId: "req-missing",
}, },
); );
@@ -739,7 +739,7 @@ describe("runWorktreeSetupInBackground", () => {
type: "workspace_setup_status_response", type: "workspace_setup_status_response",
payload: { payload: {
requestId: "req-missing", requestId: "req-missing",
workspaceId: "/repo/.paseo/worktrees/missing", workspaceId: "ws-missing",
snapshot: null, snapshot: null,
}, },
}); });
@@ -784,8 +784,8 @@ describe("handleCreatePaseoWorktreeRequest", () => {
emit: (message) => emitted.push(message), emit: (message) => emitted.push(message),
registerPendingWorktreeWorkspace: async ({ worktreePath, branchName }) => registerPendingWorktreeWorkspace: async ({ worktreePath, branchName }) =>
({ ({
id: 1, id: "ws-pr-worktree",
projectId: 1, projectId: "proj-pr-worktree",
directory: worktreePath, directory: worktreePath,
displayName: branchName, displayName: branchName,
kind: "worktree", kind: "worktree",
@@ -904,8 +904,8 @@ describe("handleCreatePaseoWorktreeRequest", () => {
sessionLogger: createLogger(), sessionLogger: createLogger(),
emit: (message) => emitted.push(message), emit: (message) => emitted.push(message),
registerPendingWorktreeWorkspace: vi.fn(async (options) => ({ registerPendingWorktreeWorkspace: vi.fn(async (options) => ({
workspaceId: options.worktreePath, workspaceId: "ws-single-call",
projectId: options.repoRoot, projectId: "proj-single-call",
})), })),
describeWorkspaceRecord: vi.fn(async (workspace) => ({ describeWorkspaceRecord: vi.fn(async (workspace) => ({
id: workspace.workspaceId, id: workspace.workspaceId,
@@ -914,7 +914,7 @@ describe("handleCreatePaseoWorktreeRequest", () => {
projectRootPath: repoDir, projectRootPath: repoDir,
projectKind: "git", projectKind: "git",
workspaceKind: "worktree", workspaceKind: "worktree",
name: path.basename(workspace.workspaceId), name: "single-call",
status: "done", status: "done",
activityAt: null, activityAt: null,
})), })),
@@ -947,6 +947,7 @@ describe("handleCreatePaseoWorktreeRequest", () => {
const paseoHome = path.join(tempDir, ".paseo"); const paseoHome = path.join(tempDir, ".paseo");
const emitted: SessionOutboundMessage[] = []; const emitted: SessionOutboundMessage[] = [];
const backgroundWork = vi.fn(async () => {}); const backgroundWork = vi.fn(async () => {});
let registeredWorktreePath: string | null = null;
try { try {
await handleCreatePaseoWorktreeRequest( await handleCreatePaseoWorktreeRequest(
@@ -956,9 +957,10 @@ describe("handleCreatePaseoWorktreeRequest", () => {
emit: (message) => emitted.push(message), emit: (message) => emitted.push(message),
registerPendingWorktreeWorkspace: vi.fn(async (options) => { registerPendingWorktreeWorkspace: vi.fn(async (options) => {
expect(existsSync(options.worktreePath)).toBe(true); expect(existsSync(options.worktreePath)).toBe(true);
registeredWorktreePath = options.worktreePath;
return { return {
workspaceId: options.worktreePath, workspaceId: "ws-response-after-create",
projectId: options.repoRoot, projectId: "proj-response-after-create",
} as any; } as any;
}), }),
describeWorkspaceRecord: vi.fn(async (workspace) => ({ describeWorkspaceRecord: vi.fn(async (workspace) => ({
@@ -968,7 +970,7 @@ describe("handleCreatePaseoWorktreeRequest", () => {
projectRootPath: repoDir, projectRootPath: repoDir,
projectKind: "git", projectKind: "git",
workspaceKind: "worktree", workspaceKind: "worktree",
name: path.basename(workspace.workspaceId), name: "response-after-create",
status: "done", status: "done",
activityAt: null, activityAt: null,
})), })),
@@ -990,14 +992,15 @@ describe("handleCreatePaseoWorktreeRequest", () => {
); );
expect(response?.payload.error).toBeNull(); expect(response?.payload.error).toBeNull();
expect(response?.payload.workspace?.id).toBeTruthy(); 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(backgroundWork).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
requestCwd: repoDir, requestCwd: repoDir,
repoRoot: repoDir, repoRoot: repoDir,
worktree: { worktree: {
branchName: "response-after-create", branchName: "response-after-create",
worktreePath: response!.payload.workspace!.id, worktreePath: registeredWorktreePath,
}, },
shouldBootstrap: true, shouldBootstrap: true,
}), }),
+19 -21
View File
@@ -23,7 +23,10 @@ import type {
WorkspaceRegistry, WorkspaceRegistry,
} from "./workspace-registry.js"; } from "./workspace-registry.js";
import type { WorkspaceGitService } from "./workspace-git-service.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 { import {
applyWorktreeSetupProgressEvent, applyWorktreeSetupProgressEvent,
buildWorktreeSetupDetail, buildWorktreeSetupDetail,
@@ -98,12 +101,14 @@ type ArchivePaseoWorktreeDependencies = {
type RegisterPendingWorktreeWorkspaceDependencies = { type RegisterPendingWorktreeWorkspaceDependencies = {
buildPersistedProjectRecord: (input: { buildPersistedProjectRecord: (input: {
workspaceId: string; workspaceId: string;
cwd: string;
placement: ProjectPlacementPayload; placement: ProjectPlacementPayload;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
}) => PersistedProjectRecord; }) => PersistedProjectRecord;
buildPersistedWorkspaceRecord: (input: { buildPersistedWorkspaceRecord: (input: {
workspaceId: string; workspaceId: string;
cwd: string;
placement: ProjectPlacementPayload; placement: ProjectPlacementPayload;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
@@ -128,13 +133,12 @@ type CreatePaseoWorktreeInBackgroundDependencies = {
scriptRuntimeStore: WorkspaceScriptRuntimeStore | null; scriptRuntimeStore: WorkspaceScriptRuntimeStore | null;
getDaemonTcpPort: (() => number | null) | null; getDaemonTcpPort: (() => number | null) | null;
getDaemonTcpHost: (() => string | null) | null; getDaemonTcpHost: (() => string | null) | null;
onScriptsChanged: ((workspaceDirectory: string) => void) | null; onScriptsChanged: ((workspaceId: string, workspaceDirectory: string) => void) | null;
}; };
type HandleWorkspaceSetupStatusRequestDependencies = { type HandleWorkspaceSetupStatusRequestDependencies = {
emit: EmitSessionMessage; emit: EmitSessionMessage;
workspaceSetupSnapshots: ReadonlyMap<string, WorkspaceSetupSnapshot>; workspaceSetupSnapshots: ReadonlyMap<string, WorkspaceSetupSnapshot>;
workspaceRegistry: WorkspaceRegistry;
}; };
type HandleCreatePaseoWorktreeRequestDependencies = { type HandleCreatePaseoWorktreeRequestDependencies = {
@@ -649,12 +653,12 @@ export async function registerPendingWorktreeWorkspace(
branchName: string; branchName: string;
}, },
): Promise<PersistedWorkspaceRecord> { ): Promise<PersistedWorkspaceRecord> {
const workspaceDirectory = normalizePersistedWorkspaceId(options.worktreePath); const normalizedWorktreePath = normalizePersistedWorkspaceId(options.worktreePath);
const basePlacement = await dependencies.buildProjectPlacement(options.repoRoot); const basePlacement = await dependencies.buildProjectPlacement(options.repoRoot);
const placement: ProjectPlacementPayload = { const placement: ProjectPlacementPayload = {
...basePlacement, ...basePlacement,
checkout: { checkout: {
cwd: workspaceDirectory, cwd: normalizedWorktreePath,
isGit: true, isGit: true,
currentBranch: options.branchName, currentBranch: options.branchName,
remoteUrl: basePlacement.checkout.remoteUrl, remoteUrl: basePlacement.checkout.remoteUrl,
@@ -663,17 +667,20 @@ export async function registerPendingWorktreeWorkspace(
mainRepoRoot: options.repoRoot, mainRepoRoot: options.repoRoot,
}, },
}; };
const workspaceId = deriveWorkspaceId(normalizedWorktreePath, placement.checkout);
const now = new Date().toISOString(); 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 existingProject = await dependencies.projectRegistry.get(placement.projectKey);
const nextProjectRecord = dependencies.buildPersistedProjectRecord({ const nextProjectRecord = dependencies.buildPersistedProjectRecord({
workspaceId: workspaceDirectory, workspaceId,
cwd: normalizedWorktreePath,
placement, placement,
createdAt: existingProject?.createdAt ?? now, createdAt: existingProject?.createdAt ?? now,
updatedAt: now, updatedAt: now,
}); });
const nextWorkspaceRecord = dependencies.buildPersistedWorkspaceRecord({ const nextWorkspaceRecord = dependencies.buildPersistedWorkspaceRecord({
workspaceId: workspaceDirectory, workspaceId,
cwd: normalizedWorktreePath,
placement, placement,
createdAt: existingWorkspace?.createdAt ?? now, createdAt: existingWorkspace?.createdAt ?? now,
updatedAt: now, updatedAt: now,
@@ -681,7 +688,7 @@ export async function registerPendingWorktreeWorkspace(
await dependencies.projectRegistry.upsert(nextProjectRecord); await dependencies.projectRegistry.upsert(nextProjectRecord);
await dependencies.workspaceRegistry.upsert(nextWorkspaceRecord); await dependencies.workspaceRegistry.upsert(nextWorkspaceRecord);
await dependencies.syncWorkspaceGitWatchTarget(workspaceDirectory, { isGit: true }); await dependencies.syncWorkspaceGitWatchTarget(normalizedWorktreePath, { isGit: true });
if ( if (
existingWorkspace && existingWorkspace &&
@@ -802,16 +809,7 @@ export async function handleWorkspaceSetupStatusRequest(
request: Extract<SessionInboundMessage, { type: "workspace_setup_status_request" }>, request: Extract<SessionInboundMessage, { type: "workspace_setup_status_request" }>,
): Promise<void> { ): Promise<void> {
const workspaceId = request.workspaceId; const workspaceId = request.workspaceId;
let snapshot = dependencies.workspaceSetupSnapshots.get(workspaceId) ?? null; const 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;
}
}
dependencies.emit({ dependencies.emit({
type: "workspace_setup_status_response", type: "workspace_setup_status_response",
@@ -909,7 +907,7 @@ export async function runWorktreeSetupInBackground(
) { ) {
await spawnWorktreeScripts({ await spawnWorktreeScripts({
repoRoot: worktree.worktreePath, repoRoot: worktree.worktreePath,
workspaceId: worktree.worktreePath, workspaceId: options.workspaceId,
branchName: worktree.branchName, branchName: worktree.branchName,
daemonPort: dependencies.getDaemonTcpPort?.() ?? null, daemonPort: dependencies.getDaemonTcpPort?.() ?? null,
daemonListenHost: dependencies.getDaemonTcpHost?.() ?? null, daemonListenHost: dependencies.getDaemonTcpHost?.() ?? null,
@@ -918,7 +916,7 @@ export async function runWorktreeSetupInBackground(
terminalManager: dependencies.terminalManager, terminalManager: dependencies.terminalManager,
logger: dependencies.sessionLogger, logger: dependencies.sessionLogger,
onLifecycleChanged: () => { onLifecycleChanged: () => {
dependencies.onScriptsChanged?.(worktree.worktreePath); dependencies.onScriptsChanged?.(options.workspaceId, worktree.worktreePath);
}, },
}); });
} }
+7 -7
View File
@@ -739,7 +739,7 @@ export const FetchWorkspacesRequestMessageSchema = z.object({
filter: z filter: z
.object({ .object({
query: z.string().optional(), query: z.string().optional(),
projectId: z.union([z.string(), z.number()]).transform(String).optional(), projectId: z.string().optional(),
idPrefix: z.string().optional(), idPrefix: z.string().optional(),
}) })
.optional(), .optional(),
@@ -843,7 +843,7 @@ export type GitSetupOptions = z.infer<typeof GitSetupOptionsSchema>;
export const CreateAgentRequestMessageSchema = z.object({ export const CreateAgentRequestMessageSchema = z.object({
type: z.literal("create_agent_request"), type: z.literal("create_agent_request"),
config: AgentSessionConfigSchema, config: AgentSessionConfigSchema,
workspaceId: z.union([z.string(), z.number()]).transform(String).optional(), workspaceId: z.string().optional(),
worktreeName: z.string().optional(), worktreeName: z.string().optional(),
initialPrompt: z.string().optional(), initialPrompt: z.string().optional(),
clientMessageId: z.string().optional(), clientMessageId: z.string().optional(),
@@ -1285,7 +1285,7 @@ export const OpenProjectRequestSchema = z.object({
export const ArchiveWorkspaceRequestSchema = z.object({ export const ArchiveWorkspaceRequestSchema = z.object({
type: z.literal("archive_workspace_request"), type: z.literal("archive_workspace_request"),
workspaceId: z.union([z.string(), z.number()]).transform(String), workspaceId: z.string(),
requestId: z.string(), requestId: z.string(),
}); });
@@ -1993,8 +1993,8 @@ const WorkspaceGitHubRuntimePayloadSchema = z
.nullable(); .nullable();
export const WorkspaceDescriptorPayloadSchema = z.object({ export const WorkspaceDescriptorPayloadSchema = z.object({
id: z.union([z.string(), z.number()]).transform(String), id: z.string(),
projectId: z.union([z.string(), z.number()]).transform(String), projectId: z.string(),
projectDisplayName: z.string(), projectDisplayName: z.string(),
projectRootPath: z.string(), projectRootPath: z.string(),
workspaceDirectory: z.string(), workspaceDirectory: z.string(),
@@ -2101,7 +2101,7 @@ export const WorkspaceUpdateMessageSchema = z.object({
}), }),
z.object({ z.object({
kind: z.literal("remove"), 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"), type: z.literal("archive_workspace_response"),
payload: z.object({ payload: z.object({
requestId: z.string(), requestId: z.string(),
workspaceId: z.union([z.string(), z.number()]).transform(String), workspaceId: z.string(),
archivedAt: z.string().nullable(), archivedAt: z.string().nullable(),
error: z.string().nullable(), error: z.string().nullable(),
}), }),
@@ -9,7 +9,7 @@ describe("workspace message schemas", () => {
requestId: "req-1", requestId: "req-1",
filter: { filter: {
query: "repo", query: "repo",
projectId: 12, projectId: "proj-12",
idPrefix: "/Users/me", idPrefix: "/Users/me",
}, },
sort: [{ key: "activity_at", direction: "desc" }], sort: [{ key: "activity_at", direction: "desc" }],
@@ -102,8 +102,8 @@ describe("workspace message schemas", () => {
payload: { payload: {
kind: "upsert", kind: "upsert",
workspace: { workspace: {
id: 1, id: "ws-invalid",
projectId: 1, projectId: "proj-invalid",
projectDisplayName: "repo", projectDisplayName: "repo",
projectRootPath: "/repo", projectRootPath: "/repo",
projectKind: "directory", projectKind: "directory",
@@ -125,8 +125,8 @@ describe("workspace message schemas", () => {
payload: { payload: {
kind: "upsert", kind: "upsert",
workspace: { workspace: {
id: 1, id: "ws-1",
projectId: 1, projectId: "proj-1",
projectDisplayName: "repo", projectDisplayName: "repo",
projectRootPath: "/repo", projectRootPath: "/repo",
workspaceDirectory: "/repo", workspaceDirectory: "/repo",
@@ -200,7 +200,7 @@ describe("workspace message schemas", () => {
const parsed = SessionOutboundMessageSchema.parse({ const parsed = SessionOutboundMessageSchema.parse({
type: "script_status_update", type: "script_status_update",
payload: { payload: {
workspaceId: "/repo", workspaceId: "ws-repo",
scripts: [ scripts: [
{ {
scriptName: "web", scriptName: "web",
@@ -215,7 +215,7 @@ describe("workspace message schemas", () => {
}); });
expect(parsed.type).toBe("script_status_update"); 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({ expect(parsed.payload.scripts[0]).toMatchObject({
type: "service", type: "service",
exitCode: null, exitCode: null,
@@ -226,7 +226,7 @@ describe("workspace message schemas", () => {
const parsed = SessionOutboundMessageSchema.parse({ const parsed = SessionOutboundMessageSchema.parse({
type: "workspace_setup_progress", type: "workspace_setup_progress",
payload: { payload: {
workspaceId: "/repo/.paseo/worktrees/feature-a", workspaceId: "ws-feature-a",
status: "completed", status: "completed",
detail: { detail: {
type: "worktree_setup", type: "worktree_setup",
@@ -255,7 +255,7 @@ describe("workspace message schemas", () => {
test("parses workspace_setup_status_request", () => { test("parses workspace_setup_status_request", () => {
const parsed = SessionInboundMessageSchema.parse({ const parsed = SessionInboundMessageSchema.parse({
type: "workspace_setup_status_request", type: "workspace_setup_status_request",
workspaceId: "/repo/.paseo/worktrees/feature-a", workspaceId: "ws-feature-a",
requestId: "req-status", requestId: "req-status",
}); });
@@ -267,7 +267,7 @@ describe("workspace message schemas", () => {
type: "workspace_setup_status_response", type: "workspace_setup_status_response",
payload: { payload: {
requestId: "req-status", requestId: "req-status",
workspaceId: "/repo/.paseo/worktrees/feature-a", workspaceId: "ws-feature-a",
snapshot: { snapshot: {
status: "completed", status: "completed",
detail: { detail: {
@@ -292,7 +292,7 @@ describe("workspace message schemas", () => {
requestId: "req-workspaces", requestId: "req-workspaces",
entries: [ entries: [
{ {
id: "/tmp/repo", id: "ws-main",
projectId: "remote:github.com/acme/repo", projectId: "remote:github.com/acme/repo",
projectDisplayName: "acme/repo", projectDisplayName: "acme/repo",
projectRootPath: "/tmp/repo", projectRootPath: "/tmp/repo",
@@ -357,7 +357,7 @@ describe("workspace message schemas", () => {
requestId: "req-workspaces", requestId: "req-workspaces",
entries: [ entries: [
{ {
id: "/tmp/repo", id: "ws-main",
projectId: "remote:github.com/acme/repo", projectId: "remote:github.com/acme/repo",
projectDisplayName: "acme/repo", projectDisplayName: "acme/repo",
projectRootPath: "/tmp/repo", projectRootPath: "/tmp/repo",
@@ -429,7 +429,7 @@ describe("workspace message schemas", () => {
const parsed = legacyMessageSchema.parse(message); const parsed = legacyMessageSchema.parse(message);
expect(parsed.payload.entries[0]).toEqual({ expect(parsed.payload.entries[0]).toEqual({
id: "/tmp/repo", id: "ws-main",
projectId: "remote:github.com/acme/repo", projectId: "remote:github.com/acme/repo",
projectDisplayName: "acme/repo", projectDisplayName: "acme/repo",
projectRootPath: "/tmp/repo", projectRootPath: "/tmp/repo",