Archived
fix: close workspace terminals before deletion
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user