fix: close workspace terminals before deletion

This commit is contained in:
Federico Jaramillo Martinez
2026-06-04 21:31:39 +02:00
parent 30fb9602d6
commit 9c3dafc4d4
17 changed files with 415 additions and 38 deletions
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Delete workspaces through a server-side operation that closes target workspace terminals before running the worktree removal command, preventing stale machine activity indicators.
+23 -1
View File
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { TerminalCommandRun, Workspace } from "../../../shared/apiTypes";
import { terminalsApi } from "./clients";
import { terminalsApi, workspacesApi } from "./clients";
const workspace: Workspace = {
id: "w/1",
@@ -30,6 +30,17 @@ afterEach(() => {
});
describe("machine-scoped terminal command-run API", () => {
it("deletes workspaces through the selected machine scope", async () => {
const fetchMock = stubJsonFetch(commandRun);
await workspacesApi.deleteWorkspace("p 1", "w/1", "remote a");
expect(fetchMock).toHaveBeenCalledOnce();
const [url, init] = fetchCall(fetchMock, 0);
expect(url).toBe("/api/machines/remote%20a/projects/p%201/workspaces/w%2F1");
expect(init?.method).toBe("DELETE");
});
it("creates command runs through the selected machine scope", async () => {
const fetchMock = stubJsonFetch(commandRun);
@@ -42,6 +53,17 @@ describe("machine-scoped terminal command-run API", () => {
expect(JSON.parse(requestBody(init))).toEqual({ origin: "core", title: "Build", command: "npm test", metadata: {} });
});
it("closes all workspace terminals through the selected machine scope", async () => {
const fetchMock = stubJsonFetch({ closed: true });
await terminalsApi.closeWorkspaceTerminals("p 1", "w/1", "remote a");
expect(fetchMock).toHaveBeenCalledOnce();
const [url, init] = fetchCall(fetchMock, 0);
expect(url).toBe("/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/terminals");
expect(init?.method).toBe("DELETE");
});
it("lists, reads, and cancels command runs through the selected machine scope", async () => {
const fetchMock = stubSequenceFetch([
jsonResponse([commandRun]),
+2
View File
@@ -72,6 +72,7 @@ export const projectsApi = {
export const workspacesApi = {
workspaces: (projectId: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${projectId}/workspaces`, arrayOf(parseWorkspace)),
deleteWorkspace: (projectId: string, workspaceId: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}`, parseTerminalCommandRun, { method: "DELETE" }),
workspaceTree: (projectId: string, workspaceId: string, path = "", machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/tree?path=${encodeURIComponent(path)}`, parseFileTreeResponse),
workspaceFile: (projectId: string, workspaceId: string, path: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file?path=${encodeURIComponent(path)}`, parseFileContentResponse),
};
@@ -116,6 +117,7 @@ export const sessionsApi = {
export const terminalsApi = {
terminals: (projectId: string, workspaceId: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals`, arrayOf(parseTerminalInfo)),
startTerminal: (projectId: string, workspaceId: string, options?: { name?: string; cols?: number; rows?: number }, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals`, parseTerminalInfo, { method: "POST", body: JSON.stringify(options ?? {}) }),
closeWorkspaceTerminals: (projectId: string, workspaceId: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals`, parseClosed, { method: "DELETE" }),
closeTerminal: (projectId: string, workspaceId: string, terminalId: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals/${encodeURIComponent(terminalId)}`, parseClosed, { method: "DELETE" }),
continueTerminal: (projectId: string, workspaceId: string, terminalId: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals/${encodeURIComponent(terminalId)}/continue`, parseTerminalInfo, { method: "POST" }),
runTerminalCommand: (origin: string, input: RunTerminalCommandInput, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(input.workspace.projectId)}/workspaces/${encodeURIComponent(input.workspace.id)}/terminal-command-runs`, parseTerminalCommandRun, { method: "POST", body: JSON.stringify({ origin, title: input.title, command: input.command, metadata: input.metadata ?? {} }) }),
@@ -32,6 +32,7 @@ describe("federated route contract", () => {
ignoreParseFailure(projectsApi.closeProject("p 1", machineId)),
ignoreParseFailure(projectsApi.projectDirectories("/r", machineId)),
ignoreParseFailure(workspacesApi.workspaces("p 1", machineId)),
ignoreParseFailure(workspacesApi.deleteWorkspace("p 1", "w 1", machineId)),
ignoreParseFailure(workspacesApi.workspaceTree("p 1", "w 1", "src", machineId)),
ignoreParseFailure(workspacesApi.workspaceFile("p 1", "w 1", "README.md", machineId)),
ignoreParseFailure(filesApi.files("/repo", "README", { kind: "tracked", mode: "file", machineId })),
@@ -67,6 +68,7 @@ describe("federated route contract", () => {
ignoreParseFailure(sessionsApi.cancelOAuthFlow("flow 1", machineId)),
ignoreParseFailure(terminalsApi.terminals("p 1", "w 1", machineId)),
ignoreParseFailure(terminalsApi.startTerminal("p 1", "w 1", { cols: 120, rows: 40 }, machineId)),
ignoreParseFailure(terminalsApi.closeWorkspaceTerminals("p 1", "w 1", machineId)),
ignoreParseFailure(terminalsApi.closeTerminal("p 1", "w 1", "t 1", machineId)),
ignoreParseFailure(terminalsApi.continueTerminal("p 1", "w 1", "t 1", machineId)),
ignoreParseFailure(terminalsApi.runTerminalCommand("core", { workspace, title: "Build", command: "npm test" }, machineId)),
+10 -25
View File
@@ -33,7 +33,7 @@ import { readRoute, writeRoute, type AppRoute } from "../route";
import { readSettingsSection, writeSettingsSection, type SettingsSection } from "../settingsRoute";
import { applyShortcutPreferences } from "../shortcutPreferences";
import { createTerminalCommandRunsRuntime } from "../runtime/terminalRuntime";
import { isWorkspaceDeletionPending, isWorkspaceDeletionRunPending, latestWorkspaceDeletionRuns, pendingWorkspaceDeletionIds, targetWorkspaceIdForRun, workspaceDeletionMetadata, workspaceDeletionRunFilter } from "../workspaceDeletion";
import { isWorkspaceDeletionPending, isWorkspaceDeletionRunPending, latestWorkspaceDeletionRuns, pendingWorkspaceDeletionIds, targetWorkspaceIdForRun, workspaceDeletionRunFilter } from "../workspaceDeletion";
import { machineActivityIndicator } from "../workspaceActivity";
import "./MachineList";
import "./ProjectList";
@@ -1007,32 +1007,21 @@ export class PiWebApp extends LitElement {
const machineId = selectedMachineId(this.state);
try {
const mainWorkspace = await this.mainWorkspaceForProject(workspace.projectId);
if (mainWorkspace === undefined) {
this.setState({ error: "Project main workspace not found" });
return;
}
const run = await workspacesApi.deleteWorkspace(workspace.projectId, workspace.id, machineId);
if (selectedMachineId(this.state) !== machineId) return;
const handle = await this.terminalCommandRunsForOrigin("core", machineId).runCommand({
workspace: mainWorkspace,
title: `Delete workspace: ${label}`,
command: `git worktree remove ${shellQuote(workspace.path)}`,
open: true,
metadata: workspaceDeletionMetadata(workspace),
});
this.recordWorkspaceDeletionRun(handle.run, machineId);
void handle.completed.then((run) => this.handleCompletedWorkspaceDeletionRun(run, machineId)).catch((error: unknown) => {
if (selectedMachineId(this.state) === machineId) this.setState({ error: `Workspace deletion failed. See terminal output. ${errorMessage(error)}` });
});
this.recordWorkspaceDeletionRun(run, machineId);
const commandWorkspace = await this.workspaceForCommandRun(run);
if (selectedMachineId(this.state) !== machineId) return;
if (commandWorkspace !== undefined) void this.openRuntimeTerminal(machineId, commandWorkspace, { terminalId: run.terminalId });
} catch (error) {
if (selectedMachineId(this.state) === machineId) this.setState({ error: `Failed to start workspace deletion: ${errorMessage(error)}` });
}
}
private async mainWorkspaceForProject(projectId: string): Promise<Workspace | undefined> {
let workspaces = this.state.selectedProject?.id === projectId ? this.state.workspaces : this.state.workspacesByProjectId[projectId];
if (workspaces === undefined || workspaces.length === 0) workspaces = await this.workspaces.refreshProjectWorkspaces(projectId);
return workspaces.find((workspace) => workspace.isMain) ?? workspaces[0];
private async workspaceForCommandRun(run: TerminalCommandRun): Promise<Workspace | undefined> {
let workspaces = this.state.selectedProject?.id === run.projectId ? this.state.workspaces : this.state.workspacesByProjectId[run.projectId];
if (workspaces === undefined || workspaces.length === 0) workspaces = await this.workspaces.refreshProjectWorkspaces(run.projectId);
return workspaces.find((workspace) => workspace.id === run.workspaceId);
}
private recordWorkspaceDeletionRun(run: TerminalCommandRun, machineId: string): void {
@@ -1413,10 +1402,6 @@ function machineScopedKey(machineId: string, value: string): string {
return JSON.stringify([machineId, value]);
}
function shellQuote(value: string): string {
return `'${value.replaceAll("'", "'\\''")}'`;
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
+2 -12
View File
@@ -1,18 +1,8 @@
import { workspaceDeleteOperation, workspaceDeleteOperationMetadataKey, targetWorkspaceIdMetadataKey, targetWorkspacePathMetadataKey } from "../../shared/workspaceDeletion";
import type { AppState } from "./appState";
import type { TerminalCommandRun, Workspace } from "./api";
export const workspaceDeleteOperation = "workspace.delete";
export const workspaceDeleteOperationMetadataKey = "pi.operation";
export const targetWorkspaceIdMetadataKey = "target.workspaceId";
export const targetWorkspacePathMetadataKey = "target.workspacePath";
export function workspaceDeletionMetadata(workspace: Workspace): Record<string, string> {
return {
[workspaceDeleteOperationMetadataKey]: workspaceDeleteOperation,
[targetWorkspaceIdMetadataKey]: workspace.id,
[targetWorkspacePathMetadataKey]: workspace.path,
};
}
export { targetWorkspaceIdMetadataKey, targetWorkspacePathMetadataKey, workspaceDeleteOperation, workspaceDeleteOperationMetadataKey, workspaceDeletionMetadata } from "../../shared/workspaceDeletion";
export function workspaceDeletionRunFilter(projectId?: string): { projectId?: string; metadata: Record<string, string> } {
return {
+9
View File
@@ -163,16 +163,20 @@ describe("buildApp", () => {
remoteClient = fakeRemoteClient({ request });
const createBody = { origin: "core", title: "Build", command: "npm test", metadata: { "pi.operation": "test" } };
const deleteWorkspaceResponse = await app.inject({ method: "DELETE", url: `/api/machines/${remote.id}/projects/p1/workspaces/w1` });
const createResponse = await app.inject({ method: "POST", url: `/api/machines/${remote.id}/projects/p1/workspaces/w1/terminal-command-runs`, payload: createBody });
const listResponse = await app.inject({ method: "GET", url: `/api/machines/${remote.id}/terminal-command-runs?projectId=p1&statuses=running` });
const getResponse = await app.inject({ method: "GET", url: `/api/machines/${remote.id}/terminal-command-runs/run1` });
const cancelResponse = await app.inject({ method: "POST", url: `/api/machines/${remote.id}/terminal-command-runs/run1/cancel` });
const closeWorkspaceTerminalsResponse = await app.inject({ method: "DELETE", url: `/api/machines/${remote.id}/projects/p1/workspaces/w1/terminals` });
const continueResponse = await app.inject({ method: "POST", url: `/api/machines/${remote.id}/projects/p1/workspaces/w1/terminals/t1/continue` });
expect(deleteWorkspaceResponse.json()).toEqual({ method: "DELETE", path: "/api/projects/p1/workspaces/w1" });
expect(createResponse.json()).toEqual({ method: "POST", path: "/api/projects/p1/workspaces/w1/terminal-command-runs" });
expect(listResponse.json()).toEqual({ method: "GET", path: "/api/terminal-command-runs?projectId=p1&statuses=running" });
expect(getResponse.json()).toEqual({ method: "GET", path: "/api/terminal-command-runs/run1" });
expect(cancelResponse.json()).toEqual({ method: "POST", path: "/api/terminal-command-runs/run1/cancel" });
expect(closeWorkspaceTerminalsResponse.json()).toEqual({ method: "DELETE", path: "/api/projects/p1/workspaces/w1/terminals" });
expect(continueResponse.json()).toEqual({ method: "POST", path: "/api/projects/p1/workspaces/w1/terminals/t1/continue" });
expect(request).toHaveBeenCalledWith("POST", "/api/projects/p1/workspaces/w1/terminal-command-runs", createBody);
});
@@ -237,6 +241,8 @@ describe("buildApp", () => {
payload: { origin: "core", title: "Build", command: "npm test", metadata: { "pi.operation": "test" } },
});
const closeTerminalsResponse = await app.inject({ method: "DELETE", url: `/api/machines/local/projects/${project.id}/workspaces/${workspace.id}/terminals` });
expect(terminalResponse.statusCode).toBe(200);
expect(terminalResponse.json()).toEqual({
method: "POST",
@@ -251,6 +257,8 @@ describe("buildApp", () => {
metadata: { "pi.operation": "test" },
},
});
expect(closeTerminalsResponse.statusCode).toBe(200);
expect(closeTerminalsResponse.json()).toEqual({ method: "DELETE", path: `/terminals?cwd=${encodeURIComponent(projectDir)}` });
expect(sessionDaemonRequests[1]).toEqual({
method: "POST",
path: "/terminal-command-runs",
@@ -264,6 +272,7 @@ describe("buildApp", () => {
metadata: { "pi.operation": "test" },
},
});
expect(sessionDaemonRequests[2]).toEqual({ method: "DELETE", path: `/terminals?cwd=${encodeURIComponent(projectDir)}` });
});
it("serves local projects and workspaces through machine-scoped aliases", async () => {
+3
View File
@@ -14,6 +14,7 @@ import { registerSessionProxyRoutes, type SessionProxyDaemon } from "./sessiond/
import { registerWorkspaceExplorerRoutes } from "./workspaceExplorerRoutes.js";
import { registerGitRoutes } from "./gitRoutes.js";
import { registerTerminalProxyRoutes } from "./terminalProxyRoutes.js";
import { registerWorkspaceDeletionRoutes } from "./workspaces/workspaceDeletionRoutes.js";
import { registerConfigRoutes, type PiWebConfigService } from "./configRoutes.js";
import { PiWebPluginService } from "./piWebPluginService.js";
import { getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js";
@@ -118,6 +119,8 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
registerGitRoutes(app, projects, workspaces, "/api/machines/local");
registerTerminalProxyRoutes(app, projects, workspaces, sessionDaemon);
registerTerminalProxyRoutes(app, projects, workspaces, sessionDaemon, "/api/machines/local");
registerWorkspaceDeletionRoutes(app, projects, workspaces, sessionDaemon);
registerWorkspaceDeletionRoutes(app, projects, workspaces, sessionDaemon, "/api/machines/local");
registerLocalFileSuggestionRoutes(app, "/api");
registerLocalFileSuggestionRoutes(app, "/api/machines/local");
+10
View File
@@ -18,6 +18,16 @@ export function registerTerminalProxyRoutes(app: FastifyInstance, projects: Proj
}
});
app.delete<{ Params: { projectId: string; workspaceId: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/terminals`, async (request, reply) => {
try {
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
return await proxyJson(daemon, "DELETE", `/terminals?cwd=${encodeURIComponent(context.root)}`, undefined, reply);
} catch (error) {
requestFailed(reply, error);
return undefined;
}
});
app.post<{ Params: { projectId: string; workspaceId: string }; Body: { name?: string; cols?: number; rows?: number } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/terminals`, async (request, reply) => {
try {
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
@@ -31,6 +31,14 @@ describe("terminal routes", () => {
socket.close();
});
it("closes all terminals for a cwd", async () => {
const response = await app.inject({ method: "DELETE", url: `/terminals?cwd=${encodeURIComponent("/repo/worktree")}` });
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ closed: true });
expect(terminals.events).toEqual(["close-cwd:/repo/worktree"]);
});
it("creates and lists terminal command runs with filters", async () => {
const createResponse = await app.inject({
method: "POST",
@@ -77,6 +85,10 @@ class FakeTerminals implements TerminalRouteService {
};
}
closeForCwd(cwd: string): void {
this.events.push(`close-cwd:${cwd}`);
}
close(id: string): void {
this.events.push(`close:${id}`);
}
+11
View File
@@ -7,6 +7,7 @@ import { parseTerminalSize } from "./terminalSize.js";
export interface TerminalRouteService {
list(cwd: string): TerminalInfo[];
create(options: { cwd: string; name?: string; cols?: number; rows?: number }): TerminalInfo;
closeForCwd(cwd: string): void;
close(id: string): void;
attach(id: string, handlers: { output: (data: string, replay: boolean) => void; exit: (exitCode: number | undefined) => void }): () => void;
write(id: string, data: string): void;
@@ -32,6 +33,16 @@ export function registerTerminalRoutes(app: FastifyInstance, terminals: Terminal
}
});
app.delete<{ Querystring: { cwd?: string } }>(`${prefix}/terminals`, (request, reply) => {
if (request.query.cwd === undefined || request.query.cwd === "") return reply.code(400).send({ error: "cwd query parameter is required" });
try {
terminals.closeForCwd(request.query.cwd);
return { closed: true };
} catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
}
});
app.post<{ Body: RunTerminalCommandOptions }>(`${prefix}/terminal-command-runs`, (request, reply) => {
try {
return terminals.runCommand(request.body);
@@ -2,6 +2,20 @@ import { describe, expect, it } from "vitest";
import { TerminalService } from "./terminalService";
describe("TerminalService command runs", () => {
it("closes all terminal records for a cwd", () => {
const service = new TerminalService();
try {
const terminal = service.create({ cwd: process.cwd() });
service.closeForCwd(process.cwd());
expect(service.get(terminal.id)).toBeUndefined();
expect(service.list(process.cwd())).toEqual([]);
} finally {
service.dispose();
}
});
it("tracks dedicated terminal command runs through completion", async () => {
const service = new TerminalService();
try {
+5
View File
@@ -48,6 +48,11 @@ export class TerminalService {
.map(toInfo);
}
closeForCwd(cwd: string): void {
if (cwd === "") throw new Error("cwd is required");
for (const terminal of [...this.terminals.values()].filter((candidate) => candidate.cwd === cwd)) this.close(terminal.id);
}
create(options: { cwd: string; name?: string; cols?: number; rows?: number }): TerminalInfo {
return this.createTerminal({ ...options, shellArgs: [] });
}
@@ -0,0 +1,170 @@
import Fastify, { type FastifyInstance } from "fastify";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { TerminalCommandRun } from "../../shared/apiTypes.js";
import { ProjectService } from "../projects/projectService.js";
import type { SessionProxyDaemon } from "../sessiond/sessionProxyRoutes.js";
import { ProjectStore } from "../storage/projectStore.js";
import type { Project, Workspace } from "../types.js";
import { registerWorkspaceDeletionRoutes } from "./workspaceDeletionRoutes.js";
import { WorkspaceService } from "./workspaceService.js";
let app: FastifyInstance;
let daemonRequests: DaemonRequest[];
let closeStatusCode: number;
const project: Project = {
id: "p1",
name: "Project",
path: "/repo",
createdAt: "2026-05-25T00:00:00.000Z",
};
const mainWorkspace: Workspace = {
id: "main",
projectId: project.id,
path: "/repo",
label: "main",
branch: "main",
isMain: true,
isGitRepo: true,
isGitWorktree: true,
};
const targetWorkspace: Workspace = {
id: "feature",
projectId: project.id,
path: "/repo/feature path",
label: "feature",
branch: "feature/branch",
isMain: false,
isGitRepo: true,
isGitWorktree: true,
};
beforeEach(() => {
app = Fastify({ logger: false });
daemonRequests = [];
closeStatusCode = 200;
registerWorkspaceDeletionRoutes(app, fakeProjects(), fakeWorkspaces([mainWorkspace, targetWorkspace]), fakeDaemon(), "/api");
});
afterEach(async () => {
await app.close();
});
describe("workspace deletion routes", () => {
it("closes target workspace terminals before starting the deletion terminal command", async () => {
const response = await app.inject({ method: "DELETE", url: "/api/projects/p1/workspaces/feature" });
expect(response.statusCode).toBe(200);
expect(response.json<TerminalCommandRun>()).toMatchObject({ id: "run1", workspaceId: "main", terminalId: "terminal1", status: "running" });
expect(daemonRequests).toEqual([
{ method: "DELETE", path: `/terminals?cwd=${encodeURIComponent(targetWorkspace.path)}` },
{
method: "POST",
path: "/terminal-command-runs",
body: {
origin: "core",
projectId: "p1",
workspaceId: "main",
cwd: "/repo",
title: "Delete workspace: feature/branch",
command: "git worktree remove '/repo/feature path'",
metadata: {
"pi.operation": "workspace.delete",
"target.workspaceId": "feature",
"target.workspacePath": "/repo/feature path",
},
},
},
]);
});
it("does not start deletion when terminal cleanup fails", async () => {
closeStatusCode = 500;
const response = await app.inject({ method: "DELETE", url: "/api/projects/p1/workspaces/feature" });
expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({ error: "Failed to close workspace terminals: cleanup failed" });
expect(daemonRequests).toEqual([{ method: "DELETE", path: `/terminals?cwd=${encodeURIComponent(targetWorkspace.path)}` }]);
});
it("rejects main workspace deletion before touching terminals", async () => {
const response = await app.inject({ method: "DELETE", url: "/api/projects/p1/workspaces/main" });
expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({ error: "Only secondary Git worktrees can be deleted" });
expect(daemonRequests).toEqual([]);
});
});
interface DaemonRequest {
method: string;
path: string;
body?: unknown;
}
function fakeProjects(): ProjectService {
return new FakeProjectService();
}
function fakeWorkspaces(workspaces: Workspace[]): WorkspaceService {
return new FakeWorkspaceService(workspaces);
}
class FakeProjectService extends ProjectService {
constructor() {
super(new ProjectStore("/dev/null"));
}
override requireProject(projectId: string): Promise<Project> {
return projectId === project.id ? Promise.resolve(project) : Promise.reject(new Error("Project not found"));
}
}
class FakeWorkspaceService extends WorkspaceService {
constructor(private readonly workspaces: Workspace[]) {
super();
}
override list(): Promise<Workspace[]> {
return Promise.resolve(this.workspaces);
}
}
function fakeDaemon(): SessionProxyDaemon {
return {
request: (method, path, body) => {
daemonRequests.push({ method, path, ...(body === undefined ? {} : { body }) });
if (method === "DELETE") {
return Promise.resolve({
statusCode: closeStatusCode,
headers: { "content-type": "application/json" },
body: JSON.stringify(closeStatusCode === 200 ? { closed: true } : { error: "cleanup failed" }),
});
}
return Promise.resolve({
statusCode: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
id: "run1",
origin: "core",
projectId: project.id,
workspaceId: mainWorkspace.id,
terminalId: "terminal1",
title: "Delete workspace: feature/branch",
command: "git worktree remove '/repo/feature path'",
status: "running",
createdAt: "2026-05-25T00:00:00.000Z",
metadata: {
"pi.operation": "workspace.delete",
"target.workspaceId": targetWorkspace.id,
"target.workspacePath": targetWorkspace.path,
},
} satisfies TerminalCommandRun),
});
},
connectWebSocket: () => { throw new Error("WebSocket not configured for test"); },
};
}
@@ -0,0 +1,118 @@
import type { FastifyInstance } from "fastify";
import type { TerminalCommandRun, Workspace } from "../../shared/apiTypes.js";
import { workspaceDeletionMetadata } from "../../shared/workspaceDeletion.js";
import { SessionDaemonClient } from "../../sessiond/sessionDaemonClient.js";
import type { ProjectService } from "../projects/projectService.js";
import type { SessionProxyDaemon } from "../sessiond/sessionProxyRoutes.js";
import type { WorkspaceService } from "./workspaceService.js";
export function registerWorkspaceDeletionRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService, daemon: SessionProxyDaemon = new SessionDaemonClient(), prefix = "/api"): void {
app.delete<{ Params: { projectId: string; workspaceId: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId`, async (request, reply) => {
try {
return await deleteWorkspace(projects, workspaces, daemon, request.params.projectId, request.params.workspaceId);
} catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
}
});
}
async function deleteWorkspace(projects: ProjectService, workspaces: WorkspaceService, daemon: SessionProxyDaemon, projectId: string, workspaceId: string): Promise<TerminalCommandRun> {
const project = await projects.requireProject(projectId);
const projectWorkspaces = await workspaces.list(project);
const targetWorkspace = projectWorkspaces.find((workspace) => workspace.id === workspaceId);
if (targetWorkspace === undefined) throw new Error("Workspace not found");
if (!canDeleteWorkspace(targetWorkspace)) throw new Error("Only secondary Git worktrees can be deleted");
const commandWorkspace = projectWorkspaces.find((workspace) => workspace.isMain) ?? projectWorkspaces.find((workspace) => workspace.id !== targetWorkspace.id);
if (commandWorkspace === undefined) throw new Error("Project main workspace not found");
const closeResponse = await requestJson(daemon, "DELETE", `/terminals?cwd=${encodeURIComponent(targetWorkspace.path)}`);
if (closeResponse.statusCode < 200 || closeResponse.statusCode >= 300) throw new Error(`Failed to close workspace terminals: ${responseError(closeResponse.body, closeResponse.statusCode)}`);
const deleteResponse = await requestJson(daemon, "POST", "/terminal-command-runs", {
origin: "core",
projectId: project.id,
workspaceId: commandWorkspace.id,
cwd: commandWorkspace.path,
title: `Delete workspace: ${workspaceLabel(targetWorkspace)}`,
command: `git worktree remove ${shellQuote(targetWorkspace.path)}`,
metadata: workspaceDeletionMetadata(targetWorkspace),
});
if (deleteResponse.statusCode < 200 || deleteResponse.statusCode >= 300) throw new Error(`Failed to start workspace deletion: ${responseError(deleteResponse.body, deleteResponse.statusCode)}`);
return parseTerminalCommandRun(deleteResponse.body);
}
function canDeleteWorkspace(workspace: Workspace): boolean {
return workspace.isGitWorktree && !workspace.isMain;
}
function workspaceLabel(workspace: Workspace): string {
return workspace.branch ?? workspace.label;
}
async function requestJson(daemon: SessionProxyDaemon, method: string, path: string, body?: unknown): Promise<{ statusCode: number; body: unknown }> {
const response = await daemon.request(method, path, body);
return { statusCode: response.statusCode, body: response.body === "" ? undefined : JSON.parse(response.body) };
}
function responseError(body: unknown, statusCode: number): string {
if (isRecord(body) && typeof body["error"] === "string") return body["error"];
return `HTTP ${String(statusCode)}`;
}
function parseTerminalCommandRun(value: unknown): TerminalCommandRun {
if (!isRecord(value)) throw new Error("Invalid terminal command run response");
const metadata = value["metadata"];
if (!isRecord(metadata)) throw new Error("Invalid terminal command run response");
const startedAt = optionalString(value, "startedAt");
const exitCode = optionalNumber(value, "exitCode");
const completedAt = optionalString(value, "completedAt");
return {
id: requireString(value, "id"),
origin: requireString(value, "origin"),
projectId: requireString(value, "projectId"),
workspaceId: requireString(value, "workspaceId"),
terminalId: requireString(value, "terminalId"),
title: requireString(value, "title"),
command: requireString(value, "command"),
status: parseStatus(value["status"]),
createdAt: requireString(value, "createdAt"),
metadata: Object.fromEntries(Object.entries(metadata).filter((entry): entry is [string, string] => typeof entry[1] === "string")),
...(startedAt === undefined ? {} : { startedAt }),
...(exitCode === undefined ? {} : { exitCode }),
...(completedAt === undefined ? {} : { completedAt }),
};
}
function parseStatus(value: unknown): TerminalCommandRun["status"] {
if (value === "queued" || value === "running" || value === "succeeded" || value === "failed") return value;
throw new Error("Invalid terminal command run response");
}
function requireString(record: Record<string, unknown>, field: string): string {
const value = record[field];
if (typeof value !== "string") throw new Error("Invalid terminal command run response");
return value;
}
function optionalString(record: Record<string, unknown>, field: string): string | undefined {
const value = record[field];
if (value === undefined) return undefined;
if (typeof value !== "string") throw new Error("Invalid terminal command run response");
return value;
}
function optionalNumber(record: Record<string, unknown>, field: string): number | undefined {
const value = record[field];
if (value === undefined) return undefined;
if (typeof value !== "number") throw new Error("Invalid terminal command run response");
return value;
}
function shellQuote(value: string): string {
return `'${value.replaceAll("'", "'\\''")}'`;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
+2
View File
@@ -11,6 +11,7 @@ export const FEDERATED_HTTP_ROUTES = [
{ method: "DELETE", path: "/projects/:projectId" },
{ method: "GET", path: "/project-directories" },
{ method: "GET", path: "/projects/:projectId/workspaces" },
{ method: "DELETE", path: "/projects/:projectId/workspaces/:workspaceId" },
{ method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/tree" },
{ method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/file" },
{ method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/file/preview" },
@@ -18,6 +19,7 @@ export const FEDERATED_HTTP_ROUTES = [
{ method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/git/diff" },
{ method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/terminals" },
{ method: "POST", path: "/projects/:projectId/workspaces/:workspaceId/terminals" },
{ method: "DELETE", path: "/projects/:projectId/workspaces/:workspaceId/terminals" },
{ method: "POST", path: "/projects/:projectId/workspaces/:workspaceId/terminals/:terminalId/continue" },
{ method: "DELETE", path: "/projects/:projectId/workspaces/:workspaceId/terminals/:terminalId" },
{ method: "POST", path: "/projects/:projectId/workspaces/:workspaceId/terminal-command-runs" },
+17
View File
@@ -0,0 +1,17 @@
export const workspaceDeleteOperation = "workspace.delete";
export const workspaceDeleteOperationMetadataKey = "pi.operation";
export const targetWorkspaceIdMetadataKey = "target.workspaceId";
export const targetWorkspacePathMetadataKey = "target.workspacePath";
export interface WorkspaceDeletionTarget {
id: string;
path: string;
}
export function workspaceDeletionMetadata(workspace: WorkspaceDeletionTarget): Record<string, string> {
return {
[workspaceDeleteOperationMetadataKey]: workspaceDeleteOperation,
[targetWorkspaceIdMetadataKey]: workspace.id,
[targetWorkspacePathMetadataKey]: workspace.path,
};
}