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:
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user