Fix sidebar project removal
Remove projects through a daemon RPC so the sidebar can delete the project record after archiving its active workspaces. Reopening a removed directory recreates the missing project record so local projects keep their derived basename, and History skips orphaned archived workspace rows. Fixes #1583.
This commit is contained in:
@@ -410,6 +410,11 @@ Array of workspace records. A workspace is a specific working directory within a
|
|||||||
|
|
||||||
> **Opaque-ID invariant:** `workspaceId` is opaque identity, never a filesystem path. Filesystem and git operations take `cwd`/`workspaceDirectory` only — never the id. Path-derived grouping keys (e.g. `deriveWorkspaceDirectoryKey`, used at bootstrap to group agents into a workspace) are directory keys, not workspace identity, and must not be persisted or compared as ids.
|
> **Opaque-ID invariant:** `workspaceId` is opaque identity, never a filesystem path. Filesystem and git operations take `cwd`/`workspaceDirectory` only — never the id. Path-derived grouping keys (e.g. `deriveWorkspaceDirectoryKey`, used at bootstrap to group agents into a workspace) are directory keys, not workspace identity, and must not be persisted or compared as ids.
|
||||||
|
|
||||||
|
`projectId` is still a real FK: workspace records should have a matching project record. Read-only
|
||||||
|
history surfaces tolerate transient orphaned workspaces by omitting those rows so one bad FK cannot
|
||||||
|
blank the whole History screen, but mutation paths should repair or remove the orphaned state rather
|
||||||
|
than treating it as valid.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. Push Token Store
|
## 8. Push Token Store
|
||||||
|
|||||||
@@ -27,6 +27,24 @@ async function hideWorkspaceFromSidebar(page: Page, workspaceId: string): Promis
|
|||||||
await archiveItem.click();
|
await archiveItem.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function removeProjectFromSidebar(page: Page, projectId: string): Promise<void> {
|
||||||
|
const projectRow = page.getByTestId(`sidebar-project-row-${projectId}`);
|
||||||
|
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||||
|
await projectRow.hover();
|
||||||
|
|
||||||
|
const kebab = page.getByTestId(`sidebar-project-kebab-${projectId}`);
|
||||||
|
await expect(kebab).toBeVisible({ timeout: 10_000 });
|
||||||
|
await kebab.click();
|
||||||
|
|
||||||
|
// Removing a project raises a browser confirm; accept it so the
|
||||||
|
// user-confirmed removal proceeds deterministically.
|
||||||
|
page.once("dialog", (dialog) => void dialog.accept());
|
||||||
|
|
||||||
|
const removeItem = page.getByTestId(`sidebar-project-menu-remove-${projectId}`);
|
||||||
|
await expect(removeItem).toBeVisible({ timeout: 10_000 });
|
||||||
|
await removeItem.click();
|
||||||
|
}
|
||||||
|
|
||||||
// Model B makes the project a first-class parent: archiving its last workspace
|
// Model B makes the project a first-class parent: archiving its last workspace
|
||||||
// must not delete the project. The per-project "+ New workspace" row is gone;
|
// must not delete the project. The per-project "+ New workspace" row is gone;
|
||||||
// the empty project keeps its parent row, and creation stays reachable from the
|
// the empty project keeps its parent row, and creation stays reachable from the
|
||||||
@@ -76,3 +94,43 @@ test.describe("Empty project persists", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.describe("Project remove", () => {
|
||||||
|
test("removing a project from project actions removes it from the sidebar", async ({ page }) => {
|
||||||
|
const workspace = await seedWorkspace({ repoPrefix: "project-remove-sidebar-" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const projectRow = page.getByTestId(`sidebar-project-row-${workspace.projectId}`);
|
||||||
|
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await waitForSidebarHydration(page);
|
||||||
|
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await removeProjectFromSidebar(page, workspace.projectId);
|
||||||
|
|
||||||
|
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toHaveCount(0, {
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await expect(projectRow).toHaveCount(0, { timeout: 30_000 });
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
await waitForSidebarHydration(page);
|
||||||
|
await expect(projectRow).toHaveCount(0, { timeout: 30_000 });
|
||||||
|
|
||||||
|
const reopened = await workspace.client.openProject(workspace.repoPath);
|
||||||
|
expect(reopened.error).toBeNull();
|
||||||
|
expect(reopened.workspace?.projectDisplayName).toBe(workspace.projectDisplayName);
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
await waitForSidebarHydration(page);
|
||||||
|
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(projectRow).toContainText(workspace.projectDisplayName);
|
||||||
|
await expect(projectRow).not.toContainText(workspace.repoPath);
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ import {
|
|||||||
type SidebarWorkspaceEntry,
|
type SidebarWorkspaceEntry,
|
||||||
} from "@/hooks/use-sidebar-workspaces-list";
|
} from "@/hooks/use-sidebar-workspaces-list";
|
||||||
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||||
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { useShowShortcutBadges } from "@/hooks/use-show-shortcut-badges";
|
import { useShowShortcutBadges } from "@/hooks/use-show-shortcut-badges";
|
||||||
import { ContextMenuTrigger, useContextMenu } from "@/components/ui/context-menu";
|
import { ContextMenuTrigger, useContextMenu } from "@/components/ui/context-menu";
|
||||||
import {
|
import {
|
||||||
@@ -113,8 +114,6 @@ import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model";
|
|||||||
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
||||||
import { openExternalUrl } from "@/utils/open-external-url";
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "@/utils/workspace-directory";
|
import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "@/utils/workspace-directory";
|
||||||
import { archiveWorkspacesOptimistically } from "@/workspace/workspace-archive";
|
|
||||||
import { selectProjectWorkspacesToArchive } from "@/workspace/project-workspace-archive";
|
|
||||||
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
|
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
|
||||||
import {
|
import {
|
||||||
isWeb as platformIsWeb,
|
isWeb as platformIsWeb,
|
||||||
@@ -1785,6 +1784,7 @@ function ProjectBlock({
|
|||||||
displayName,
|
displayName,
|
||||||
iconDataUri,
|
iconDataUri,
|
||||||
serverId,
|
serverId,
|
||||||
|
canRemoveProject,
|
||||||
selectionEnabled,
|
selectionEnabled,
|
||||||
showShortcutBadges,
|
showShortcutBadges,
|
||||||
shortcutIndexByWorkspaceKey,
|
shortcutIndexByWorkspaceKey,
|
||||||
@@ -1805,6 +1805,7 @@ function ProjectBlock({
|
|||||||
displayName: string;
|
displayName: string;
|
||||||
iconDataUri: string | null;
|
iconDataUri: string | null;
|
||||||
serverId: string | null;
|
serverId: string | null;
|
||||||
|
canRemoveProject: boolean;
|
||||||
selectionEnabled: boolean;
|
selectionEnabled: boolean;
|
||||||
showShortcutBadges: boolean;
|
showShortcutBadges: boolean;
|
||||||
shortcutIndexByWorkspaceKey: Map<string, number>;
|
shortcutIndexByWorkspaceKey: Map<string, number>;
|
||||||
@@ -1923,26 +1924,24 @@ function ProjectBlock({
|
|||||||
toast.error(t("sidebar.project.toasts.hostDisconnected"));
|
toast.error(t("sidebar.project.toasts.hostDisconnected"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!canRemoveProject) {
|
||||||
|
toast.error(t("sidebar.project.toasts.updateHostToRemove"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsRemovingProject(true);
|
setIsRemovingProject(true);
|
||||||
void selectProjectWorkspacesToArchive(project.workspaces)
|
void client
|
||||||
.then((workspaces) => archiveWorkspacesOptimistically({ client, workspaces }))
|
.removeProject(project.projectKey)
|
||||||
.then((failures) => {
|
|
||||||
if (failures.length > 0) {
|
|
||||||
toast.error(t("sidebar.project.toasts.removeFailed"));
|
|
||||||
}
|
|
||||||
setIsRemovingProject(false);
|
|
||||||
return;
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
toast.error(
|
toast.error(
|
||||||
error instanceof Error ? error.message : t("sidebar.project.toasts.removeFailed"),
|
error instanceof Error ? error.message : t("sidebar.project.toasts.removeFailed"),
|
||||||
);
|
);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
setIsRemovingProject(false);
|
setIsRemovingProject(false);
|
||||||
return;
|
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
}, [isRemovingProject, serverId, displayName, t, toast, project.workspaces]);
|
}, [isRemovingProject, serverId, displayName, t, toast, project.projectKey, canRemoveProject]);
|
||||||
|
|
||||||
const handleToggleCollapsed = useCallback(() => {
|
const handleToggleCollapsed = useCallback(() => {
|
||||||
onToggleCollapsed(project.projectKey);
|
onToggleCollapsed(project.projectKey);
|
||||||
@@ -2000,6 +1999,7 @@ function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlo
|
|||||||
previous.displayName === next.displayName &&
|
previous.displayName === next.displayName &&
|
||||||
previous.iconDataUri === next.iconDataUri &&
|
previous.iconDataUri === next.iconDataUri &&
|
||||||
previous.serverId === next.serverId &&
|
previous.serverId === next.serverId &&
|
||||||
|
previous.canRemoveProject === next.canRemoveProject &&
|
||||||
previous.selectionEnabled === next.selectionEnabled &&
|
previous.selectionEnabled === next.selectionEnabled &&
|
||||||
previous.showShortcutBadges === next.showShortcutBadges &&
|
previous.showShortcutBadges === next.showShortcutBadges &&
|
||||||
previous.shortcutIndexByWorkspaceKey === next.shortcutIndexByWorkspaceKey &&
|
previous.shortcutIndexByWorkspaceKey === next.shortcutIndexByWorkspaceKey &&
|
||||||
@@ -2145,6 +2145,9 @@ function ProjectModeList({
|
|||||||
const setProjectOrder = useSidebarOrderStore((state) => state.setProjectOrder);
|
const setProjectOrder = useSidebarOrderStore((state) => state.setProjectOrder);
|
||||||
const getWorkspaceOrder = useSidebarOrderStore((state) => state.getWorkspaceOrder);
|
const getWorkspaceOrder = useSidebarOrderStore((state) => state.getWorkspaceOrder);
|
||||||
const setWorkspaceOrder = useSidebarOrderStore((state) => state.setWorkspaceOrder);
|
const setWorkspaceOrder = useSidebarOrderStore((state) => state.setWorkspaceOrder);
|
||||||
|
const canRemoveProject = useSessionStore((state) =>
|
||||||
|
serverId ? state.sessions[serverId]?.serverInfo?.features?.projectRemove === true : false,
|
||||||
|
);
|
||||||
|
|
||||||
const isWorkspaceRoute = useMemo(
|
const isWorkspaceRoute = useMemo(
|
||||||
() => Boolean(pathname && parseHostWorkspaceRouteFromPathname(pathname)),
|
() => Boolean(pathname && parseHostWorkspaceRouteFromPathname(pathname)),
|
||||||
@@ -2309,6 +2312,7 @@ function ProjectModeList({
|
|||||||
displayName={item.projectName}
|
displayName={item.projectName}
|
||||||
iconDataUri={projectIconByProjectKey.get(item.projectKey) ?? null}
|
iconDataUri={projectIconByProjectKey.get(item.projectKey) ?? null}
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
|
canRemoveProject={canRemoveProject}
|
||||||
selectionEnabled={selectionEnabled}
|
selectionEnabled={selectionEnabled}
|
||||||
showShortcutBadges={showShortcutBadges}
|
showShortcutBadges={showShortcutBadges}
|
||||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||||
@@ -2335,6 +2339,7 @@ function ProjectModeList({
|
|||||||
onToggleProjectCollapsed,
|
onToggleProjectCollapsed,
|
||||||
parentGestureRef,
|
parentGestureRef,
|
||||||
projectIconByProjectKey,
|
projectIconByProjectKey,
|
||||||
|
canRemoveProject,
|
||||||
selectionEnabled,
|
selectionEnabled,
|
||||||
serverId,
|
serverId,
|
||||||
shortcutIndexByWorkspaceKey,
|
shortcutIndexByWorkspaceKey,
|
||||||
|
|||||||
@@ -531,6 +531,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
const setWorkspaces = useSessionStore((state) => state.setWorkspaces);
|
const setWorkspaces = useSessionStore((state) => state.setWorkspaces);
|
||||||
const setEmptyProjects = useSessionStore((state) => state.setEmptyProjects);
|
const setEmptyProjects = useSessionStore((state) => state.setEmptyProjects);
|
||||||
const addEmptyProject = useSessionStore((state) => state.addEmptyProject);
|
const addEmptyProject = useSessionStore((state) => state.addEmptyProject);
|
||||||
|
const removeEmptyProject = useSessionStore((state) => state.removeEmptyProject);
|
||||||
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
|
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
|
||||||
const removeWorkspace = useSessionStore((state) => state.removeWorkspace);
|
const removeWorkspace = useSessionStore((state) => state.removeWorkspace);
|
||||||
const setAgentLastActivity = useSessionStore((state) => state.setAgentLastActivity);
|
const setAgentLastActivity = useSessionStore((state) => state.setAgentLastActivity);
|
||||||
@@ -1348,6 +1349,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
if (message.payload.emptyProject) {
|
if (message.payload.emptyProject) {
|
||||||
addEmptyProject(serverId, normalizeEmptyProjectDescriptor(message.payload.emptyProject));
|
addEmptyProject(serverId, normalizeEmptyProjectDescriptor(message.payload.emptyProject));
|
||||||
}
|
}
|
||||||
|
if (message.payload.removedProjectId) {
|
||||||
|
removeEmptyProject(serverId, message.payload.removedProjectId);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const workspace = normalizeWorkspaceDescriptor(message.payload.workspace);
|
const workspace = normalizeWorkspaceDescriptor(message.payload.workspace);
|
||||||
@@ -1771,6 +1775,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
removeWorkspace,
|
removeWorkspace,
|
||||||
removeWorkspaceSetup,
|
removeWorkspaceSetup,
|
||||||
addEmptyProject,
|
addEmptyProject,
|
||||||
|
removeEmptyProject,
|
||||||
setAgentLastActivity,
|
setAgentLastActivity,
|
||||||
setPendingPermissions,
|
setPendingPermissions,
|
||||||
setHasHydratedAgents,
|
setHasHydratedAgents,
|
||||||
|
|||||||
@@ -249,6 +249,9 @@ describe("translation resources", () => {
|
|||||||
it("includes sidebar and workspace creation keys for the Batch 4C migration", () => {
|
it("includes sidebar and workspace creation keys for the Batch 4C migration", () => {
|
||||||
expect(en.sidebar.workspace.actions.copyPath).toBe("Copy path");
|
expect(en.sidebar.workspace.actions.copyPath).toBe("Copy path");
|
||||||
expect(en.sidebar.project.confirmations.removeTitle).toBe("Remove project?");
|
expect(en.sidebar.project.confirmations.removeTitle).toBe("Remove project?");
|
||||||
|
expect(en.sidebar.project.toasts.updateHostToRemove).toBe(
|
||||||
|
"Update the host to remove projects.",
|
||||||
|
);
|
||||||
expect(en.newWorkspace.title).toBe("New workspace");
|
expect(en.newWorkspace.title).toBe("New workspace");
|
||||||
expect(en.newWorkspace.refPicker.searchPlaceholder).toBe("Search branches and PRs");
|
expect(en.newWorkspace.refPicker.searchPlaceholder).toBe("Search branches and PRs");
|
||||||
expect(en.openProject.tiles.addProject.title).toBe("Add a project");
|
expect(en.openProject.tiles.addProject.title).toBe("Add a project");
|
||||||
|
|||||||
@@ -787,6 +787,7 @@ export const ar: TranslationResources = {
|
|||||||
toasts: {
|
toasts: {
|
||||||
hostDisconnected: "Host غير متصل",
|
hostDisconnected: "Host غير متصل",
|
||||||
removeFailed: "فشل في إزالة بعض مساحات العمل",
|
removeFailed: "فشل في إزالة بعض مساحات العمل",
|
||||||
|
updateHostToRemove: "قم بتحديث Host لإزالة المشاريع.",
|
||||||
},
|
},
|
||||||
empty: {
|
empty: {
|
||||||
title: "لا توجد مشاريع حتى الآن",
|
title: "لا توجد مشاريع حتى الآن",
|
||||||
|
|||||||
@@ -794,6 +794,7 @@ export const en = {
|
|||||||
toasts: {
|
toasts: {
|
||||||
hostDisconnected: "Host is not connected",
|
hostDisconnected: "Host is not connected",
|
||||||
removeFailed: "Failed to remove some workspaces",
|
removeFailed: "Failed to remove some workspaces",
|
||||||
|
updateHostToRemove: "Update the host to remove projects.",
|
||||||
},
|
},
|
||||||
empty: {
|
empty: {
|
||||||
title: "No projects yet",
|
title: "No projects yet",
|
||||||
|
|||||||
@@ -814,6 +814,7 @@ export const es: TranslationResources = {
|
|||||||
toasts: {
|
toasts: {
|
||||||
hostDisconnected: "Hostno está conectado",
|
hostDisconnected: "Hostno está conectado",
|
||||||
removeFailed: "No se pudieron eliminar algunos espacios de trabajo",
|
removeFailed: "No se pudieron eliminar algunos espacios de trabajo",
|
||||||
|
updateHostToRemove: "Actualiza el host para eliminar proyectos.",
|
||||||
},
|
},
|
||||||
empty: {
|
empty: {
|
||||||
title: "Aún no hay proyectos",
|
title: "Aún no hay proyectos",
|
||||||
|
|||||||
@@ -813,6 +813,7 @@ export const fr: TranslationResources = {
|
|||||||
toasts: {
|
toasts: {
|
||||||
hostDisconnected: "Hostn'est pas connecté",
|
hostDisconnected: "Hostn'est pas connecté",
|
||||||
removeFailed: "Échec de la suppression de certains espaces de travail",
|
removeFailed: "Échec de la suppression de certains espaces de travail",
|
||||||
|
updateHostToRemove: "Mettez à jour le host pour supprimer des projets.",
|
||||||
},
|
},
|
||||||
empty: {
|
empty: {
|
||||||
title: "Aucun projet pour l'instant",
|
title: "Aucun projet pour l'instant",
|
||||||
|
|||||||
@@ -806,6 +806,7 @@ export const ru: TranslationResources = {
|
|||||||
toasts: {
|
toasts: {
|
||||||
hostDisconnected: "Host не подключен",
|
hostDisconnected: "Host не подключен",
|
||||||
removeFailed: "Не удалось удалить некоторые рабочие области.",
|
removeFailed: "Не удалось удалить некоторые рабочие области.",
|
||||||
|
updateHostToRemove: "Обновите host, чтобы удалять проекты.",
|
||||||
},
|
},
|
||||||
empty: {
|
empty: {
|
||||||
title: "Пока нет проектов",
|
title: "Пока нет проектов",
|
||||||
|
|||||||
@@ -779,6 +779,7 @@ export const zhCN: TranslationResources = {
|
|||||||
toasts: {
|
toasts: {
|
||||||
hostDisconnected: "Host 未连接",
|
hostDisconnected: "Host 未连接",
|
||||||
removeFailed: "部分 workspace 移除失败",
|
removeFailed: "部分 workspace 移除失败",
|
||||||
|
updateHostToRemove: "更新 host 以移除 projects。",
|
||||||
},
|
},
|
||||||
empty: {
|
empty: {
|
||||||
title: "还没有 projects",
|
title: "还没有 projects",
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ function getTestSessionReferences() {
|
|||||||
sessions: state.sessions,
|
sessions: state.sessions,
|
||||||
session,
|
session,
|
||||||
workspaces: session.workspaces,
|
workspaces: session.workspaces,
|
||||||
|
emptyProjects: session.emptyProjects,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -448,6 +449,48 @@ describe("removeWorkspace", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("removeEmptyProject", () => {
|
||||||
|
it("removes an empty project by project id", () => {
|
||||||
|
const store = useSessionStore.getState();
|
||||||
|
initializeTestSession();
|
||||||
|
store.setEmptyProjects("test-server", [
|
||||||
|
{
|
||||||
|
projectId: "project-empty",
|
||||||
|
projectDisplayName: "Empty",
|
||||||
|
projectCustomName: null,
|
||||||
|
projectRootPath: "/repo/empty",
|
||||||
|
projectKind: "git",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
store.removeEmptyProject("test-server", "project-empty");
|
||||||
|
|
||||||
|
expect(getTestSessionReferences().emptyProjects.has("project-empty")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves identity when removing a missing empty project", () => {
|
||||||
|
const store = useSessionStore.getState();
|
||||||
|
initializeTestSession();
|
||||||
|
store.setEmptyProjects("test-server", [
|
||||||
|
{
|
||||||
|
projectId: "project-empty",
|
||||||
|
projectDisplayName: "Empty",
|
||||||
|
projectCustomName: null,
|
||||||
|
projectRootPath: "/repo/empty",
|
||||||
|
projectKind: "git",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const before = getTestSessionReferences();
|
||||||
|
|
||||||
|
store.removeEmptyProject("test-server", "project-missing");
|
||||||
|
const after = getTestSessionReferences();
|
||||||
|
|
||||||
|
expect(after.sessions).toBe(before.sessions);
|
||||||
|
expect(after.session).toBe(before.session);
|
||||||
|
expect(after.emptyProjects).toBe(before.emptyProjects);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("patchWorkspaceScripts", () => {
|
describe("patchWorkspaceScripts", () => {
|
||||||
it("preserves workspace entry identity when scripts are content-equal", () => {
|
it("preserves workspace entry identity when scripts are content-equal", () => {
|
||||||
const script = {
|
const script = {
|
||||||
|
|||||||
@@ -460,6 +460,7 @@ interface SessionStoreActions {
|
|||||||
removeWorkspace: (serverId: string, workspaceId: string) => void;
|
removeWorkspace: (serverId: string, workspaceId: string) => void;
|
||||||
setEmptyProjects: (serverId: string, emptyProjects: Iterable<EmptyProjectDescriptor>) => void;
|
setEmptyProjects: (serverId: string, emptyProjects: Iterable<EmptyProjectDescriptor>) => void;
|
||||||
addEmptyProject: (serverId: string, emptyProject: EmptyProjectDescriptor) => void;
|
addEmptyProject: (serverId: string, emptyProject: EmptyProjectDescriptor) => void;
|
||||||
|
removeEmptyProject: (serverId: string, projectId: string) => void;
|
||||||
setWorkspaceRestoreStatus: (
|
setWorkspaceRestoreStatus: (
|
||||||
serverId: string,
|
serverId: string,
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
@@ -1265,6 +1266,24 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
removeEmptyProject: (serverId, projectId) => {
|
||||||
|
set((prev) => {
|
||||||
|
const session = prev.sessions[serverId];
|
||||||
|
if (!session?.emptyProjects.has(projectId)) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
const next = new Map(session.emptyProjects);
|
||||||
|
next.delete(projectId);
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
sessions: {
|
||||||
|
...prev.sessions,
|
||||||
|
[serverId]: { ...session, emptyProjects: next },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
setWorkspaceRestoreStatus: (serverId, workspaceId, status) => {
|
setWorkspaceRestoreStatus: (serverId, workspaceId, status) => {
|
||||||
set((prev) => {
|
set((prev) => {
|
||||||
const session = prev.sessions[serverId];
|
const session = prev.sessions[serverId];
|
||||||
|
|||||||
@@ -1521,6 +1521,47 @@ test("sends first-agent prompt context with workspace.create.request", async ()
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("sends project.remove.request", async () => {
|
||||||
|
const logger = createMockLogger();
|
||||||
|
const mock = createMockTransport();
|
||||||
|
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: "ws://test",
|
||||||
|
clientId: "clsk_unit_test",
|
||||||
|
logger,
|
||||||
|
reconnect: { enabled: false },
|
||||||
|
transportFactory: () => mock.transport,
|
||||||
|
});
|
||||||
|
clients.push(client);
|
||||||
|
|
||||||
|
const connectPromise = client.connect();
|
||||||
|
mock.triggerOpen();
|
||||||
|
await connectPromise;
|
||||||
|
|
||||||
|
const removePromise = client.removeProject("remote:github.com/acme/app", "req-remove-project");
|
||||||
|
|
||||||
|
expect(parseSentFrame(mock.sent[0])).toEqual({
|
||||||
|
type: "project.remove.request",
|
||||||
|
requestId: "req-remove-project",
|
||||||
|
projectId: "remote:github.com/acme/app",
|
||||||
|
});
|
||||||
|
|
||||||
|
mock.triggerMessage(
|
||||||
|
wrapSessionMessage({
|
||||||
|
type: "project.remove.response",
|
||||||
|
payload: {
|
||||||
|
requestId: "req-remove-project",
|
||||||
|
projectId: "remote:github.com/acme/app",
|
||||||
|
accepted: true,
|
||||||
|
removedWorkspaceIds: ["ws-main"],
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(removePromise).resolves.toEqual({ removedWorkspaceIds: ["ws-main"] });
|
||||||
|
});
|
||||||
|
|
||||||
test("sends worktree base-ref fields in create_paseo_worktree_request", async () => {
|
test("sends worktree base-ref fields in create_paseo_worktree_request", async () => {
|
||||||
const logger = createMockLogger();
|
const logger = createMockLogger();
|
||||||
const mock = createMockTransport();
|
const mock = createMockTransport();
|
||||||
|
|||||||
@@ -2153,6 +2153,24 @@ export class DaemonClient {
|
|||||||
return { customName: payload.customName };
|
return { customName: payload.customName };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async removeProject(
|
||||||
|
projectId: string,
|
||||||
|
requestId?: string,
|
||||||
|
): Promise<{ removedWorkspaceIds: string[] }> {
|
||||||
|
const payload = await this.sendNamespacedCorrelatedSessionRequest<"project.remove.response">({
|
||||||
|
requestId,
|
||||||
|
message: {
|
||||||
|
type: "project.remove.request",
|
||||||
|
projectId,
|
||||||
|
},
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
if (!payload.accepted) {
|
||||||
|
throw new Error(payload.error ?? "removeProject rejected");
|
||||||
|
}
|
||||||
|
return { removedWorkspaceIds: payload.removedWorkspaceIds };
|
||||||
|
}
|
||||||
|
|
||||||
async setWorkspaceTitle(
|
async setWorkspaceTitle(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
title: string | null,
|
title: string | null,
|
||||||
|
|||||||
@@ -340,4 +340,18 @@ describe("checkout PR schemas", () => {
|
|||||||
githubCheckDetails: true,
|
githubCheckDetails: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("accepts the project removal server_info feature flag", () => {
|
||||||
|
expect(
|
||||||
|
ServerInfoStatusPayloadSchema.parse({
|
||||||
|
status: "server_info",
|
||||||
|
serverId: "srv_test",
|
||||||
|
features: {
|
||||||
|
projectRemove: true,
|
||||||
|
},
|
||||||
|
}).features,
|
||||||
|
).toEqual({
|
||||||
|
projectRemove: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -795,6 +795,12 @@ export const ProjectRenameRequestSchema = z.object({
|
|||||||
requestId: z.string(),
|
requestId: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const ProjectRemoveRequestSchema = z.object({
|
||||||
|
type: z.literal("project.remove.request"),
|
||||||
|
projectId: z.string(),
|
||||||
|
requestId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
export const WorkspaceTitleSetRequestSchema = z.object({
|
export const WorkspaceTitleSetRequestSchema = z.object({
|
||||||
type: z.literal("workspace.title.set.request"),
|
type: z.literal("workspace.title.set.request"),
|
||||||
workspaceId: z.string(),
|
workspaceId: z.string(),
|
||||||
@@ -1340,6 +1346,19 @@ export const ProjectRenameResponseSchema = z.object({
|
|||||||
payload: ProjectRenameResponsePayloadSchema,
|
payload: ProjectRenameResponsePayloadSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const ProjectRemoveResponsePayloadSchema = z.object({
|
||||||
|
requestId: z.string(),
|
||||||
|
projectId: z.string(),
|
||||||
|
accepted: z.boolean(),
|
||||||
|
removedWorkspaceIds: z.array(z.string()).default([]),
|
||||||
|
error: z.string().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ProjectRemoveResponseSchema = z.object({
|
||||||
|
type: z.literal("project.remove.response"),
|
||||||
|
payload: ProjectRemoveResponsePayloadSchema,
|
||||||
|
});
|
||||||
|
|
||||||
export const WorkspaceTitleSetResponsePayloadSchema = z.object({
|
export const WorkspaceTitleSetResponsePayloadSchema = z.object({
|
||||||
requestId: z.string(),
|
requestId: z.string(),
|
||||||
workspaceId: z.string(),
|
workspaceId: z.string(),
|
||||||
@@ -1968,6 +1987,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
CloseItemsRequestMessageSchema,
|
CloseItemsRequestMessageSchema,
|
||||||
UpdateAgentRequestMessageSchema,
|
UpdateAgentRequestMessageSchema,
|
||||||
ProjectRenameRequestSchema,
|
ProjectRenameRequestSchema,
|
||||||
|
ProjectRemoveRequestSchema,
|
||||||
WorkspaceTitleSetRequestSchema,
|
WorkspaceTitleSetRequestSchema,
|
||||||
SetVoiceModeMessageSchema,
|
SetVoiceModeMessageSchema,
|
||||||
SendAgentMessageRequestSchema,
|
SendAgentMessageRequestSchema,
|
||||||
@@ -2256,6 +2276,8 @@ export const ServerInfoStatusPayloadSchema = z
|
|||||||
checkoutRefresh: z.boolean().optional(),
|
checkoutRefresh: z.boolean().optional(),
|
||||||
// COMPAT(workspaceMultiplicity): added in v0.1.97, drop the gate when floor >= v0.1.97
|
// COMPAT(workspaceMultiplicity): added in v0.1.97, drop the gate when floor >= v0.1.97
|
||||||
workspaceMultiplicity: z.boolean().optional(),
|
workspaceMultiplicity: z.boolean().optional(),
|
||||||
|
// COMPAT(projectRemove): added in v0.1.97, drop the gate when floor >= v0.1.97.
|
||||||
|
projectRemove: z.boolean().optional(),
|
||||||
// COMPAT(worktreeRestore): added in v0.1.98, drop the gate when floor >= v0.1.98
|
// COMPAT(worktreeRestore): added in v0.1.98, drop the gate when floor >= v0.1.98
|
||||||
worktreeRestore: z.boolean().optional(),
|
worktreeRestore: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
@@ -2692,6 +2714,9 @@ export const WorkspaceUpdateMessageSchema = z.object({
|
|||||||
// daemons omit it; old clients ignore it and surface the empty project on
|
// daemons omit it; old clients ignore it and surface the empty project on
|
||||||
// their next workspace fetch instead.
|
// their next workspace fetch instead.
|
||||||
emptyProject: WorkspaceProjectDescriptorPayloadSchema.optional(),
|
emptyProject: WorkspaceProjectDescriptorPayloadSchema.optional(),
|
||||||
|
// Project removal is represented on the existing workspace update channel
|
||||||
|
// so old clients can still parse the message and ignore the extra field.
|
||||||
|
removedProjectId: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
});
|
});
|
||||||
@@ -3997,6 +4022,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
AgentRewindResponseMessageSchema,
|
AgentRewindResponseMessageSchema,
|
||||||
UpdateAgentResponseMessageSchema,
|
UpdateAgentResponseMessageSchema,
|
||||||
ProjectRenameResponseSchema,
|
ProjectRenameResponseSchema,
|
||||||
|
ProjectRemoveResponseSchema,
|
||||||
WorkspaceTitleSetResponseSchema,
|
WorkspaceTitleSetResponseSchema,
|
||||||
WaitForFinishResponseMessageSchema,
|
WaitForFinishResponseMessageSchema,
|
||||||
AgentPermissionRequestMessageSchema,
|
AgentPermissionRequestMessageSchema,
|
||||||
@@ -4143,6 +4169,7 @@ export type SetAgentFeatureResponseMessage = z.infer<typeof SetAgentFeatureRespo
|
|||||||
export type AgentRewindResponseMessage = z.infer<typeof AgentRewindResponseMessageSchema>;
|
export type AgentRewindResponseMessage = z.infer<typeof AgentRewindResponseMessageSchema>;
|
||||||
export type UpdateAgentResponseMessage = z.infer<typeof UpdateAgentResponseMessageSchema>;
|
export type UpdateAgentResponseMessage = z.infer<typeof UpdateAgentResponseMessageSchema>;
|
||||||
export type ProjectRenameResponse = z.infer<typeof ProjectRenameResponseSchema>;
|
export type ProjectRenameResponse = z.infer<typeof ProjectRenameResponseSchema>;
|
||||||
|
export type ProjectRemoveResponse = z.infer<typeof ProjectRemoveResponseSchema>;
|
||||||
export type WorkspaceTitleSetResponse = z.infer<typeof WorkspaceTitleSetResponseSchema>;
|
export type WorkspaceTitleSetResponse = z.infer<typeof WorkspaceTitleSetResponseSchema>;
|
||||||
export type WorkspaceTitleSetResponsePayload = z.infer<
|
export type WorkspaceTitleSetResponsePayload = z.infer<
|
||||||
typeof WorkspaceTitleSetResponsePayloadSchema
|
typeof WorkspaceTitleSetResponsePayloadSchema
|
||||||
@@ -4150,6 +4177,7 @@ export type WorkspaceTitleSetResponsePayload = z.infer<
|
|||||||
export type WorkspaceCreateRequest = z.infer<typeof WorkspaceCreateRequestSchema>;
|
export type WorkspaceCreateRequest = z.infer<typeof WorkspaceCreateRequestSchema>;
|
||||||
export type WorkspaceCreateResponse = z.infer<typeof WorkspaceCreateResponseSchema>;
|
export type WorkspaceCreateResponse = z.infer<typeof WorkspaceCreateResponseSchema>;
|
||||||
export type ProjectRenameResponsePayload = z.infer<typeof ProjectRenameResponsePayloadSchema>;
|
export type ProjectRenameResponsePayload = z.infer<typeof ProjectRenameResponsePayloadSchema>;
|
||||||
|
export type ProjectRemoveResponsePayload = z.infer<typeof ProjectRemoveResponsePayloadSchema>;
|
||||||
export type WaitForFinishResponseMessage = z.infer<typeof WaitForFinishResponseMessageSchema>;
|
export type WaitForFinishResponseMessage = z.infer<typeof WaitForFinishResponseMessageSchema>;
|
||||||
export type AgentPermissionRequestMessage = z.infer<typeof AgentPermissionRequestMessageSchema>;
|
export type AgentPermissionRequestMessage = z.infer<typeof AgentPermissionRequestMessageSchema>;
|
||||||
export type AgentPermissionResolvedMessage = z.infer<typeof AgentPermissionResolvedMessageSchema>;
|
export type AgentPermissionResolvedMessage = z.infer<typeof AgentPermissionResolvedMessageSchema>;
|
||||||
@@ -4265,6 +4293,7 @@ export type ResumeAgentRequestMessage = z.infer<typeof ResumeAgentRequestMessage
|
|||||||
export type DeleteAgentRequestMessage = z.infer<typeof DeleteAgentRequestMessageSchema>;
|
export type DeleteAgentRequestMessage = z.infer<typeof DeleteAgentRequestMessageSchema>;
|
||||||
export type UpdateAgentRequestMessage = z.infer<typeof UpdateAgentRequestMessageSchema>;
|
export type UpdateAgentRequestMessage = z.infer<typeof UpdateAgentRequestMessageSchema>;
|
||||||
export type ProjectRenameRequest = z.infer<typeof ProjectRenameRequestSchema>;
|
export type ProjectRenameRequest = z.infer<typeof ProjectRenameRequestSchema>;
|
||||||
|
export type ProjectRemoveRequest = z.infer<typeof ProjectRemoveRequestSchema>;
|
||||||
export type WorkspaceTitleSetRequest = z.infer<typeof WorkspaceTitleSetRequestSchema>;
|
export type WorkspaceTitleSetRequest = z.infer<typeof WorkspaceTitleSetRequestSchema>;
|
||||||
export type SetAgentModeRequestMessage = z.infer<typeof SetAgentModeRequestMessageSchema>;
|
export type SetAgentModeRequestMessage = z.infer<typeof SetAgentModeRequestMessageSchema>;
|
||||||
export type SetAgentModelRequestMessage = z.infer<typeof SetAgentModelRequestMessageSchema>;
|
export type SetAgentModelRequestMessage = z.infer<typeof SetAgentModelRequestMessageSchema>;
|
||||||
|
|||||||
@@ -197,7 +197,10 @@ import {
|
|||||||
type ProjectConfigRpcError,
|
type ProjectConfigRpcError,
|
||||||
} from "../utils/paseo-config-file.js";
|
} from "../utils/paseo-config-file.js";
|
||||||
import { buildMetadataPrompt } from "../utils/build-metadata-prompt.js";
|
import { buildMetadataPrompt } from "../utils/build-metadata-prompt.js";
|
||||||
import { archivePersistedWorkspaceRecord } from "./workspace-archive-service.js";
|
import {
|
||||||
|
archivePersistedWorkspaceRecord,
|
||||||
|
archiveWorkspaceContents,
|
||||||
|
} from "./workspace-archive-service.js";
|
||||||
import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js";
|
import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js";
|
||||||
import type { ServiceProxySubsystem } from "./service-proxy.js";
|
import type { ServiceProxySubsystem } from "./service-proxy.js";
|
||||||
import {
|
import {
|
||||||
@@ -1703,6 +1706,17 @@ export class Session {
|
|||||||
return this.buildProjectPlacementForWorkspace(workspace);
|
return this.buildProjectPlacementForWorkspace(workspace);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async buildProjectPlacementForExistingWorkspaceProject(
|
||||||
|
workspaceId: string,
|
||||||
|
): Promise<ProjectPlacementPayload | null> {
|
||||||
|
const workspace = await this.workspaceRegistry.get(workspaceId);
|
||||||
|
if (!workspace) return null;
|
||||||
|
|
||||||
|
const project = await this.projectRegistry.get(workspace.projectId);
|
||||||
|
if (!project) return null;
|
||||||
|
return this.buildProjectPlacementForWorkspace(workspace, project);
|
||||||
|
}
|
||||||
|
|
||||||
private async forwardAgentUpdate(agent: ManagedAgent): Promise<void> {
|
private async forwardAgentUpdate(agent: ManagedAgent): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const subscription = this.agentUpdatesSubscription;
|
const subscription = this.agentUpdatesSubscription;
|
||||||
@@ -2184,6 +2198,8 @@ export class Session {
|
|||||||
return this.handleOpenProjectRequest(msg);
|
return this.handleOpenProjectRequest(msg);
|
||||||
case "archive_workspace_request":
|
case "archive_workspace_request":
|
||||||
return this.handleArchiveWorkspaceRequest(msg);
|
return this.handleArchiveWorkspaceRequest(msg);
|
||||||
|
case "project.remove.request":
|
||||||
|
return this.handleProjectRemoveRequest(msg);
|
||||||
case "workspace.create.request":
|
case "workspace.create.request":
|
||||||
return this.handleWorkspaceCreateRequest(msg);
|
return this.handleWorkspaceCreateRequest(msg);
|
||||||
case "workspace.clear_attention.request":
|
case "workspace.clear_attention.request":
|
||||||
@@ -2685,6 +2701,97 @@ export class Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async handleProjectRemoveRequest(
|
||||||
|
request: Extract<SessionInboundMessage, { type: "project.remove.request" }>,
|
||||||
|
): Promise<void> {
|
||||||
|
const { projectId, requestId } = request;
|
||||||
|
this.sessionLogger.info({ projectId, requestId }, "session: project.remove.request");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const projectWorkspaces = (await this.workspaceRegistry.list()).filter(
|
||||||
|
(workspace) => workspace.projectId === projectId,
|
||||||
|
);
|
||||||
|
const activeWorkspaceIds = projectWorkspaces
|
||||||
|
.filter((workspace) => !workspace.archivedAt)
|
||||||
|
.map((workspace) => workspace.workspaceId);
|
||||||
|
|
||||||
|
if (activeWorkspaceIds.length > 0) {
|
||||||
|
this.markWorkspaceArchiving(activeWorkspaceIds, new Date().toISOString());
|
||||||
|
await this.emitWorkspaceUpdatesForWorkspaceIds(activeWorkspaceIds, {
|
||||||
|
skipReconcile: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const removedWorkspaceIds: string[] = [];
|
||||||
|
try {
|
||||||
|
for (const workspaceId of activeWorkspaceIds) {
|
||||||
|
await archiveWorkspaceContents(
|
||||||
|
{
|
||||||
|
agentManager: this.agentManager,
|
||||||
|
agentStorage: this.agentStorage,
|
||||||
|
killTerminalsForWorkspace: (id) =>
|
||||||
|
this.terminalController.killTerminalsForWorkspace(id),
|
||||||
|
sessionLogger: this.sessionLogger,
|
||||||
|
},
|
||||||
|
workspaceId,
|
||||||
|
);
|
||||||
|
await this.archiveWorkspaceRecord(workspaceId);
|
||||||
|
removedWorkspaceIds.push(workspaceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.projectRegistry.remove(projectId);
|
||||||
|
} finally {
|
||||||
|
if (activeWorkspaceIds.length > 0) {
|
||||||
|
this.clearWorkspaceArchiving(activeWorkspaceIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateIds =
|
||||||
|
removedWorkspaceIds.length > 0
|
||||||
|
? removedWorkspaceIds
|
||||||
|
: [projectWorkspaces[0]?.workspaceId ?? projectId];
|
||||||
|
await this.emitWorkspaceUpdatesForWorkspaceIds(updateIds, {
|
||||||
|
skipReconcile: true,
|
||||||
|
removedProjectId: projectId,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.emit({
|
||||||
|
type: "project.remove.response",
|
||||||
|
payload: {
|
||||||
|
requestId,
|
||||||
|
projectId,
|
||||||
|
accepted: true,
|
||||||
|
removedWorkspaceIds,
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.sessionLogger.error(
|
||||||
|
{ err: error, projectId, requestId },
|
||||||
|
"session: project.remove.request error",
|
||||||
|
);
|
||||||
|
this.emit({
|
||||||
|
type: "activity_log",
|
||||||
|
payload: {
|
||||||
|
id: uuidv4(),
|
||||||
|
timestamp: new Date(),
|
||||||
|
type: "error",
|
||||||
|
content: `Failed to remove project: ${getErrorMessage(error)}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.emit({
|
||||||
|
type: "project.remove.response",
|
||||||
|
payload: {
|
||||||
|
requestId,
|
||||||
|
projectId,
|
||||||
|
accepted: false,
|
||||||
|
removedWorkspaceIds: [],
|
||||||
|
error: getErrorMessageOr(error, "Failed to remove project"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async handleWorkspaceTitleSetRequest(
|
private async handleWorkspaceTitleSetRequest(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
title: string | null,
|
title: string | null,
|
||||||
@@ -6569,7 +6676,10 @@ export class Session {
|
|||||||
if (existing) {
|
if (existing) {
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
const placementPromise = this.buildProjectPlacementForWorkspaceId(workspaceId);
|
const placementPromise =
|
||||||
|
request.type === "fetch_agent_history_request"
|
||||||
|
? this.buildProjectPlacementForExistingWorkspaceProject(workspaceId)
|
||||||
|
: this.buildProjectPlacementForWorkspaceId(workspaceId);
|
||||||
placementByWorkspaceId.set(workspaceId, placementPromise);
|
placementByWorkspaceId.set(workspaceId, placementPromise);
|
||||||
return placementPromise;
|
return placementPromise;
|
||||||
};
|
};
|
||||||
@@ -7023,6 +7133,9 @@ export class Session {
|
|||||||
input.workspace.kind === kind &&
|
input.workspace.kind === kind &&
|
||||||
input.workspace.displayName === displayName
|
input.workspace.displayName === displayName
|
||||||
) {
|
) {
|
||||||
|
if (!input.project) {
|
||||||
|
await this.projectRegistry.upsert(projectRecord);
|
||||||
|
}
|
||||||
return this.ensureWorkspaceRecordUnarchived(input.workspace);
|
return this.ensureWorkspaceRecordUnarchived(input.workspace);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7327,7 +7440,7 @@ export class Session {
|
|||||||
|
|
||||||
private async emitWorkspaceUpdatesForWorkspaceIds(
|
private async emitWorkspaceUpdatesForWorkspaceIds(
|
||||||
workspaceIds: Iterable<string>,
|
workspaceIds: Iterable<string>,
|
||||||
options?: { skipReconcile?: boolean; dedupeGitState?: boolean },
|
options?: { skipReconcile?: boolean; dedupeGitState?: boolean; removedProjectId?: string },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const subscription = this.workspaceUpdatesSubscription;
|
const subscription = this.workspaceUpdatesSubscription;
|
||||||
if (!subscription) {
|
if (!subscription) {
|
||||||
@@ -7356,22 +7469,14 @@ export class Session {
|
|||||||
) {
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const watchTarget = this.resolveWorkspaceGitWatchTarget(workspaceId);
|
this.recordWorkspaceGitDescriptorState(workspaceId, nextWorkspace);
|
||||||
if (watchTarget && this.onBranchChanged) {
|
|
||||||
const newBranchName = nextWorkspace?.name ?? null;
|
|
||||||
if (newBranchName !== watchTarget.lastBranchName) {
|
|
||||||
this.onBranchChanged(workspaceId, watchTarget.lastBranchName, newBranchName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.rememberWorkspaceGitDescriptorState(workspaceId, nextWorkspace);
|
|
||||||
|
|
||||||
if (!nextWorkspace) {
|
if (!nextWorkspace) {
|
||||||
subscription.lastEmittedByWorkspaceId.delete(workspaceId);
|
subscription.lastEmittedByWorkspaceId.delete(workspaceId);
|
||||||
this.bufferOrEmitWorkspaceUpdate(subscription, {
|
this.bufferOrEmitWorkspaceUpdate(
|
||||||
kind: "remove",
|
subscription,
|
||||||
id: workspaceId,
|
await this.buildWorkspaceRemoveUpdatePayload(workspaceId, options?.removedProjectId),
|
||||||
...(await this.resolveEmptyProjectForArchivedWorkspace(workspaceId)),
|
);
|
||||||
});
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7397,6 +7502,34 @@ export class Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private recordWorkspaceGitDescriptorState(
|
||||||
|
workspaceId: string,
|
||||||
|
nextWorkspace: WorkspaceDescriptorPayload | null,
|
||||||
|
): void {
|
||||||
|
const watchTarget = this.resolveWorkspaceGitWatchTarget(workspaceId);
|
||||||
|
if (watchTarget && this.onBranchChanged) {
|
||||||
|
const newBranchName = nextWorkspace?.name ?? null;
|
||||||
|
if (newBranchName !== watchTarget.lastBranchName) {
|
||||||
|
this.onBranchChanged(workspaceId, watchTarget.lastBranchName, newBranchName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.rememberWorkspaceGitDescriptorState(workspaceId, nextWorkspace);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildWorkspaceRemoveUpdatePayload(
|
||||||
|
workspaceId: string,
|
||||||
|
removedProjectId?: string,
|
||||||
|
): Promise<WorkspaceUpdatePayload> {
|
||||||
|
if (removedProjectId) {
|
||||||
|
return { kind: "remove", id: workspaceId, removedProjectId };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: "remove",
|
||||||
|
id: workspaceId,
|
||||||
|
...(await this.resolveEmptyProjectForArchivedWorkspace(workspaceId)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// When a workspace is archived its project may become empty. Resolve the
|
// When a workspace is archived its project may become empty. Resolve the
|
||||||
// now-empty project parent so the `remove` update can carry it, keeping the
|
// now-empty project parent so the `remove` update can carry it, keeping the
|
||||||
// sidebar's empty project row in sync without a full re-hydration.
|
// sidebar's empty project row in sync without a full re-hydration.
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ interface SessionTestAccess {
|
|||||||
archive(projectId: string, archivedAt: string): Promise<void>;
|
archive(projectId: string, archivedAt: string): Promise<void>;
|
||||||
get(id: string): Promise<unknown>;
|
get(id: string): Promise<unknown>;
|
||||||
upsert(record: unknown): Promise<unknown>;
|
upsert(record: unknown): Promise<unknown>;
|
||||||
|
remove(projectId: string): Promise<void>;
|
||||||
};
|
};
|
||||||
agentStorage: {
|
agentStorage: {
|
||||||
list(...args: unknown[]): Promise<unknown[]>;
|
list(...args: unknown[]): Promise<unknown[]>;
|
||||||
@@ -2391,6 +2392,94 @@ test("fetch_agent_history_request pages archived historical rows separately", as
|
|||||||
expect(session.agentUpdatesSubscription).toBeNull();
|
expect(session.agentUpdatesSubscription).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("fetch_agent_history_request skips rows whose workspace project record is missing", async () => {
|
||||||
|
const emitted: SessionOutboundMessage[] = [];
|
||||||
|
const session = createSessionForWorkspaceTests();
|
||||||
|
const stableCwd = path.resolve("/tmp/stable-history");
|
||||||
|
const orphanCwd = path.resolve("/tmp/orphan-history");
|
||||||
|
const stableProject = createPersistedProjectRecord({
|
||||||
|
projectId: "proj-stable-history",
|
||||||
|
rootPath: stableCwd,
|
||||||
|
kind: "non_git",
|
||||||
|
displayName: "stable history",
|
||||||
|
createdAt: "2026-03-01T12:00:00.000Z",
|
||||||
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||||
|
});
|
||||||
|
const stableWorkspace = createPersistedWorkspaceRecord({
|
||||||
|
workspaceId: "ws-stable-history",
|
||||||
|
projectId: stableProject.projectId,
|
||||||
|
cwd: stableCwd,
|
||||||
|
kind: "directory",
|
||||||
|
displayName: "stable history",
|
||||||
|
createdAt: "2026-03-01T12:00:00.000Z",
|
||||||
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||||
|
});
|
||||||
|
const orphanWorkspace = createPersistedWorkspaceRecord({
|
||||||
|
workspaceId: "ws-orphan-history",
|
||||||
|
projectId: orphanCwd,
|
||||||
|
cwd: orphanCwd,
|
||||||
|
kind: "local_checkout",
|
||||||
|
displayName: "main",
|
||||||
|
createdAt: "2026-03-01T12:00:00.000Z",
|
||||||
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
session.emit = (message) => {
|
||||||
|
if (isSessionOutboundMessage(message)) emitted.push(message);
|
||||||
|
};
|
||||||
|
session.projectRegistry.get = async (projectId: string) =>
|
||||||
|
projectId === stableProject.projectId ? stableProject : null;
|
||||||
|
session.workspaceRegistry.get = async (workspaceId: string) =>
|
||||||
|
[stableWorkspace, orphanWorkspace].find((workspace) => workspace.workspaceId === workspaceId) ??
|
||||||
|
null;
|
||||||
|
session.listAgentPayloads = async () => [
|
||||||
|
makeAgent({
|
||||||
|
id: "history-orphan",
|
||||||
|
cwd: orphanCwd,
|
||||||
|
workspaceId: orphanWorkspace.workspaceId,
|
||||||
|
status: "idle",
|
||||||
|
updatedAt: "2026-03-01T12:01:00.000Z",
|
||||||
|
}),
|
||||||
|
makeAgent({
|
||||||
|
id: "history-stable",
|
||||||
|
cwd: stableCwd,
|
||||||
|
workspaceId: stableWorkspace.workspaceId,
|
||||||
|
status: "closed",
|
||||||
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
await session.handleMessage({
|
||||||
|
type: "fetch_agent_history_request",
|
||||||
|
requestId: "req-history-orphan",
|
||||||
|
page: { limit: 25 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(emitted).toEqual([
|
||||||
|
{
|
||||||
|
type: "fetch_agent_history_response",
|
||||||
|
payload: expect.objectContaining({
|
||||||
|
requestId: "req-history-orphan",
|
||||||
|
entries: [
|
||||||
|
expect.objectContaining({
|
||||||
|
agent: expect.objectContaining({ id: "history-stable" }),
|
||||||
|
project: expect.objectContaining({
|
||||||
|
projectKey: stableProject.projectId,
|
||||||
|
projectName: "stable history",
|
||||||
|
workspaceName: "stable history",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
pageInfo: {
|
||||||
|
nextCursor: null,
|
||||||
|
prevCursor: null,
|
||||||
|
hasMore: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
test("fetch_recent_provider_sessions_request lists importable provider sessions by handle", async () => {
|
test("fetch_recent_provider_sessions_request lists importable provider sessions by handle", async () => {
|
||||||
const emitted: Array<{ type: string; payload: unknown }> = [];
|
const emitted: Array<{ type: string; payload: unknown }> = [];
|
||||||
const session = createSessionForWorkspaceTests();
|
const session = createSessionForWorkspaceTests();
|
||||||
@@ -3035,6 +3124,182 @@ test("archiving the last workspace emits a remove carrying the now-empty project
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("project.remove.request archives active workspaces and removes the project record", async () => {
|
||||||
|
const emitted: SessionOutboundMessage[] = [];
|
||||||
|
const session = createSessionForWorkspaceTests({
|
||||||
|
onMessage: (message) => emitted.push(message),
|
||||||
|
});
|
||||||
|
const project = createPersistedProjectRecord({
|
||||||
|
projectId: "proj-remove-with-workspace",
|
||||||
|
rootPath: REPO_CWD,
|
||||||
|
kind: "git",
|
||||||
|
displayName: "repo",
|
||||||
|
createdAt: "2026-03-01T12:00:00.000Z",
|
||||||
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||||
|
});
|
||||||
|
const workspace = createPersistedWorkspaceRecord({
|
||||||
|
workspaceId: "ws-project-remove",
|
||||||
|
projectId: project.projectId,
|
||||||
|
cwd: REPO_CWD,
|
||||||
|
kind: "local_checkout",
|
||||||
|
displayName: "main",
|
||||||
|
createdAt: "2026-03-01T12:00:00.000Z",
|
||||||
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||||
|
});
|
||||||
|
const projects = new Map<string, PersistedProjectRecord>([[project.projectId, project]]);
|
||||||
|
const workspaces = new Map<string, PersistedWorkspaceRecord>([
|
||||||
|
[workspace.workspaceId, workspace],
|
||||||
|
]);
|
||||||
|
|
||||||
|
session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null;
|
||||||
|
session.projectRegistry.list = async () => Array.from(projects.values());
|
||||||
|
session.projectRegistry.remove = async (projectId: string) => {
|
||||||
|
projects.delete(projectId);
|
||||||
|
};
|
||||||
|
session.workspaceRegistry.get = async (workspaceId: string) =>
|
||||||
|
workspaces.get(workspaceId) ?? null;
|
||||||
|
session.workspaceRegistry.list = async () => Array.from(workspaces.values());
|
||||||
|
session.workspaceRegistry.archive = async (workspaceId: string, archivedAt: string) => {
|
||||||
|
const existing = workspaces.get(workspaceId);
|
||||||
|
if (!existing) return;
|
||||||
|
workspaces.set(workspaceId, { ...existing, updatedAt: archivedAt, archivedAt });
|
||||||
|
};
|
||||||
|
session.workspaceUpdatesSubscription = {
|
||||||
|
subscriptionId: "sub-project-remove",
|
||||||
|
filter: undefined,
|
||||||
|
isBootstrapping: false,
|
||||||
|
pendingUpdatesByWorkspaceId: new Map(),
|
||||||
|
lastEmittedByWorkspaceId: new Map(),
|
||||||
|
};
|
||||||
|
session.reconcileActiveWorkspaceRecords = async () => new Set();
|
||||||
|
session.listAgentPayloads = async () => [];
|
||||||
|
session.buildWorkspaceDescriptorMap = async (options: { workspaceIds?: Iterable<string> }) => {
|
||||||
|
const workspaceIds = Array.from(options.workspaceIds ?? workspaces.keys());
|
||||||
|
const descriptors = new Map<string, unknown>();
|
||||||
|
for (const workspaceId of workspaceIds) {
|
||||||
|
const record = workspaces.get(workspaceId);
|
||||||
|
if (!record || record.archivedAt) continue;
|
||||||
|
descriptors.set(workspaceId, {
|
||||||
|
id: record.workspaceId,
|
||||||
|
projectId: record.projectId,
|
||||||
|
projectDisplayName: "repo",
|
||||||
|
projectRootPath: REPO_CWD,
|
||||||
|
projectKind: "git",
|
||||||
|
workspaceKind: record.kind,
|
||||||
|
name: record.displayName,
|
||||||
|
status: "idle",
|
||||||
|
activityAt: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return descriptors;
|
||||||
|
};
|
||||||
|
|
||||||
|
await session.handleMessage({
|
||||||
|
type: "project.remove.request",
|
||||||
|
projectId: project.projectId,
|
||||||
|
requestId: "req-remove-project",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(projects.has(project.projectId)).toBe(false);
|
||||||
|
expect(workspaces.get(workspace.workspaceId)).toEqual({
|
||||||
|
...workspace,
|
||||||
|
updatedAt: expect.any(String),
|
||||||
|
archivedAt: expect.any(String),
|
||||||
|
});
|
||||||
|
expect(findByType(emitted, "project.remove.response")?.payload).toEqual({
|
||||||
|
requestId: "req-remove-project",
|
||||||
|
projectId: project.projectId,
|
||||||
|
accepted: true,
|
||||||
|
removedWorkspaceIds: [workspace.workspaceId],
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
const workspaceUpdates = filterByType(emitted, "workspace_update");
|
||||||
|
expect(workspaceUpdates.at(-1)?.payload).toEqual({
|
||||||
|
kind: "remove",
|
||||||
|
id: workspace.workspaceId,
|
||||||
|
removedProjectId: project.projectId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("project.remove.request removes an already-empty project", async () => {
|
||||||
|
const emitted: SessionOutboundMessage[] = [];
|
||||||
|
const session = createSessionForWorkspaceTests({
|
||||||
|
onMessage: (message) => emitted.push(message),
|
||||||
|
});
|
||||||
|
const project = createPersistedProjectRecord({
|
||||||
|
projectId: "proj-remove-empty",
|
||||||
|
rootPath: REPO_CWD,
|
||||||
|
kind: "git",
|
||||||
|
displayName: "repo",
|
||||||
|
createdAt: "2026-03-01T12:00:00.000Z",
|
||||||
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||||
|
});
|
||||||
|
const archivedWorkspace = createPersistedWorkspaceRecord({
|
||||||
|
workspaceId: "ws-project-remove-empty",
|
||||||
|
projectId: project.projectId,
|
||||||
|
cwd: REPO_CWD,
|
||||||
|
kind: "local_checkout",
|
||||||
|
displayName: "main",
|
||||||
|
createdAt: "2026-03-01T12:00:00.000Z",
|
||||||
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||||
|
archivedAt: "2026-03-02T12:00:00.000Z",
|
||||||
|
});
|
||||||
|
const projects = new Map<string, PersistedProjectRecord>([[project.projectId, project]]);
|
||||||
|
const workspaces = new Map<string, PersistedWorkspaceRecord>([
|
||||||
|
[archivedWorkspace.workspaceId, archivedWorkspace],
|
||||||
|
]);
|
||||||
|
|
||||||
|
session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null;
|
||||||
|
session.projectRegistry.list = async () => Array.from(projects.values());
|
||||||
|
session.projectRegistry.remove = async (projectId: string) => {
|
||||||
|
projects.delete(projectId);
|
||||||
|
};
|
||||||
|
session.workspaceRegistry.get = async (workspaceId: string) =>
|
||||||
|
workspaces.get(workspaceId) ?? null;
|
||||||
|
session.workspaceRegistry.list = async () => Array.from(workspaces.values());
|
||||||
|
session.workspaceRegistry.archive = async (workspaceId: string, archivedAt: string) => {
|
||||||
|
const existing = workspaces.get(workspaceId);
|
||||||
|
if (!existing) return;
|
||||||
|
workspaces.set(workspaceId, { ...existing, updatedAt: archivedAt, archivedAt });
|
||||||
|
};
|
||||||
|
session.workspaceUpdatesSubscription = {
|
||||||
|
subscriptionId: "sub-empty-project-remove",
|
||||||
|
filter: undefined,
|
||||||
|
isBootstrapping: false,
|
||||||
|
pendingUpdatesByWorkspaceId: new Map(),
|
||||||
|
lastEmittedByWorkspaceId: new Map(),
|
||||||
|
};
|
||||||
|
session.reconcileActiveWorkspaceRecords = async () => new Set();
|
||||||
|
session.listAgentPayloads = async () => [];
|
||||||
|
session.buildWorkspaceDescriptorMap = async () => new Map();
|
||||||
|
|
||||||
|
await session.handleMessage({
|
||||||
|
type: "project.remove.request",
|
||||||
|
projectId: project.projectId,
|
||||||
|
requestId: "req-remove-empty-project",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(projects.has(project.projectId)).toBe(false);
|
||||||
|
expect(workspaces.get(archivedWorkspace.workspaceId)).toEqual(archivedWorkspace);
|
||||||
|
expect(findByType(emitted, "project.remove.response")?.payload).toEqual({
|
||||||
|
requestId: "req-remove-empty-project",
|
||||||
|
projectId: project.projectId,
|
||||||
|
accepted: true,
|
||||||
|
removedWorkspaceIds: [],
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
expect(filterByType(emitted, "workspace_update")).toEqual([
|
||||||
|
{
|
||||||
|
type: "workspace_update",
|
||||||
|
payload: {
|
||||||
|
kind: "remove",
|
||||||
|
id: archivedWorkspace.workspaceId,
|
||||||
|
removedProjectId: project.projectId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
test("create paseo worktree request returns a registered workspace descriptor", async () => {
|
test("create paseo worktree request returns a registered workspace descriptor", async () => {
|
||||||
const emitted: SessionOutboundMessage[] = [];
|
const emitted: SessionOutboundMessage[] = [];
|
||||||
const createdAt = "2026-05-12T12:00:00.000Z";
|
const createdAt = "2026-05-12T12:00:00.000Z";
|
||||||
@@ -4058,6 +4323,67 @@ test("open_project_request unarchives an existing archived workspace and project
|
|||||||
expect(response?.payload.workspace?.id).toBe(workspaceId);
|
expect(response?.payload.workspace?.id).toBe(workspaceId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("open_project_request recreates a missing project record when unarchiving its workspace", async () => {
|
||||||
|
const emitted: SessionOutboundMessage[] = [];
|
||||||
|
const session = createSessionForWorkspaceTests();
|
||||||
|
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
|
||||||
|
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
|
||||||
|
|
||||||
|
const cwd = REPO_CWD;
|
||||||
|
const workspaceId = "ws-repo-project-removed";
|
||||||
|
workspaces.set(
|
||||||
|
workspaceId,
|
||||||
|
createPersistedWorkspaceRecord({
|
||||||
|
workspaceId,
|
||||||
|
projectId: cwd,
|
||||||
|
cwd,
|
||||||
|
kind: "directory",
|
||||||
|
displayName: "repo",
|
||||||
|
createdAt: "2026-03-01T12:00:00.000Z",
|
||||||
|
updatedAt: "2026-03-10T00:00:00.000Z",
|
||||||
|
archivedAt: "2026-03-10T00:00:00.000Z",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
session.emit = (message) => {
|
||||||
|
if (isSessionOutboundMessage(message)) emitted.push(message);
|
||||||
|
};
|
||||||
|
session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null;
|
||||||
|
session.projectRegistry.upsert = async (
|
||||||
|
record: ReturnType<typeof createPersistedProjectRecord>,
|
||||||
|
) => {
|
||||||
|
projects.set(record.projectId, record);
|
||||||
|
};
|
||||||
|
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
|
||||||
|
workspaces.get(lookupWorkspaceId) ?? null;
|
||||||
|
session.workspaceRegistry.upsert = async (
|
||||||
|
record: ReturnType<typeof createPersistedWorkspaceRecord>,
|
||||||
|
) => {
|
||||||
|
workspaces.set(record.workspaceId, record);
|
||||||
|
};
|
||||||
|
session.projectRegistry.list = async () => Array.from(projects.values());
|
||||||
|
session.workspaceRegistry.list = async () => Array.from(workspaces.values());
|
||||||
|
|
||||||
|
await session.handleMessage({
|
||||||
|
type: "open_project_request",
|
||||||
|
cwd,
|
||||||
|
requestId: "req-open-removed-project",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(projects.get(cwd)).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
projectId: cwd,
|
||||||
|
displayName: "repo",
|
||||||
|
archivedAt: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(workspaces.get(workspaceId)?.archivedAt).toBeNull();
|
||||||
|
const response = findByType(emitted, "open_project_response");
|
||||||
|
expect(response?.payload.error).toBeNull();
|
||||||
|
expect(response?.payload.workspace?.id).toBe(workspaceId);
|
||||||
|
expect(response?.payload.workspace?.projectDisplayName).toBe("repo");
|
||||||
|
});
|
||||||
|
|
||||||
test("refresh_agent_request unarchives the owning workspace when its directory exists", async () => {
|
test("refresh_agent_request unarchives the owning workspace when its directory exists", async () => {
|
||||||
const emitted: SessionOutboundMessage[] = [];
|
const emitted: SessionOutboundMessage[] = [];
|
||||||
const session = createSessionForWorkspaceTests({
|
const session = createSessionForWorkspaceTests({
|
||||||
|
|||||||
@@ -1157,6 +1157,8 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
checkoutRefresh: true,
|
checkoutRefresh: true,
|
||||||
// COMPAT(workspaceMultiplicity): added in v0.1.97, drop the gate when floor >= v0.1.97
|
// COMPAT(workspaceMultiplicity): added in v0.1.97, drop the gate when floor >= v0.1.97
|
||||||
workspaceMultiplicity: true,
|
workspaceMultiplicity: true,
|
||||||
|
// COMPAT(projectRemove): added in v0.1.97, drop the gate when floor >= v0.1.97.
|
||||||
|
projectRemove: true,
|
||||||
// COMPAT(worktreeRestore): added in v0.1.98, drop the gate when floor >= v0.1.98
|
// COMPAT(worktreeRestore): added in v0.1.98, drop the gate when floor >= v0.1.98
|
||||||
worktreeRestore: true,
|
worktreeRestore: true,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user