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,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);
}