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:
Mohamed Boudra
2026-06-19 00:46:40 +07:00
parent f876b80f7a
commit 9a09ccb3d6
20 changed files with 736 additions and 29 deletions
+149 -16
View File
@@ -197,7 +197,10 @@ import {
type ProjectConfigRpcError,
} from "../utils/paseo-config-file.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 type { ServiceProxySubsystem } from "./service-proxy.js";
import {
@@ -1703,6 +1706,17 @@ export class Session {
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> {
try {
const subscription = this.agentUpdatesSubscription;
@@ -2184,6 +2198,8 @@ export class Session {
return this.handleOpenProjectRequest(msg);
case "archive_workspace_request":
return this.handleArchiveWorkspaceRequest(msg);
case "project.remove.request":
return this.handleProjectRemoveRequest(msg);
case "workspace.create.request":
return this.handleWorkspaceCreateRequest(msg);
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(
workspaceId: string,
title: string | null,
@@ -6569,7 +6676,10 @@ export class Session {
if (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);
return placementPromise;
};
@@ -7023,6 +7133,9 @@ export class Session {
input.workspace.kind === kind &&
input.workspace.displayName === displayName
) {
if (!input.project) {
await this.projectRegistry.upsert(projectRecord);
}
return this.ensureWorkspaceRecordUnarchived(input.workspace);
}
@@ -7327,7 +7440,7 @@ export class Session {
private async emitWorkspaceUpdatesForWorkspaceIds(
workspaceIds: Iterable<string>,
options?: { skipReconcile?: boolean; dedupeGitState?: boolean },
options?: { skipReconcile?: boolean; dedupeGitState?: boolean; removedProjectId?: string },
): Promise<void> {
const subscription = this.workspaceUpdatesSubscription;
if (!subscription) {
@@ -7356,22 +7469,14 @@ export class Session {
) {
continue;
}
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);
this.recordWorkspaceGitDescriptorState(workspaceId, nextWorkspace);
if (!nextWorkspace) {
subscription.lastEmittedByWorkspaceId.delete(workspaceId);
this.bufferOrEmitWorkspaceUpdate(subscription, {
kind: "remove",
id: workspaceId,
...(await this.resolveEmptyProjectForArchivedWorkspace(workspaceId)),
});
this.bufferOrEmitWorkspaceUpdate(
subscription,
await this.buildWorkspaceRemoveUpdatePayload(workspaceId, options?.removedProjectId),
);
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
// 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.
@@ -84,6 +84,7 @@ interface SessionTestAccess {
archive(projectId: string, archivedAt: string): Promise<void>;
get(id: string): Promise<unknown>;
upsert(record: unknown): Promise<unknown>;
remove(projectId: string): Promise<void>;
};
agentStorage: {
list(...args: unknown[]): Promise<unknown[]>;
@@ -2391,6 +2392,94 @@ test("fetch_agent_history_request pages archived historical rows separately", as
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 () => {
const emitted: Array<{ type: string; payload: unknown }> = [];
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 () => {
const emitted: SessionOutboundMessage[] = [];
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);
});
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 () => {
const emitted: SessionOutboundMessage[] = [];
const session = createSessionForWorkspaceTests({
@@ -1157,6 +1157,8 @@ export class VoiceAssistantWebSocketServer {
checkoutRefresh: true,
// COMPAT(workspaceMultiplicity): added in v0.1.97, drop the gate when floor >= v0.1.97
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
worktreeRestore: true,
},