diff --git a/.changeset/close-workspace-terminals-before-delete.md b/.changeset/close-workspace-terminals-before-delete.md new file mode 100644 index 0000000..ed12d70 --- /dev/null +++ b/.changeset/close-workspace-terminals-before-delete.md @@ -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. diff --git a/src/client/src/api/clients.test.ts b/src/client/src/api/clients.test.ts index ef05d09..4de6ef7 100644 --- a/src/client/src/api/clients.test.ts +++ b/src/client/src/api/clients.test.ts @@ -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]), diff --git a/src/client/src/api/clients.ts b/src/client/src/api/clients.ts index 0d24f3a..aab597e 100644 --- a/src/client/src/api/clients.ts +++ b/src/client/src/api/clients.ts @@ -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 ?? {} }) }), diff --git a/src/client/src/api/federatedRouteContract.test.ts b/src/client/src/api/federatedRouteContract.test.ts index c5c69fe..5104513 100644 --- a/src/client/src/api/federatedRouteContract.test.ts +++ b/src/client/src/api/federatedRouteContract.test.ts @@ -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)), diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 158ebb7..09ac643 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -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 { - 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 { + 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); } diff --git a/src/client/src/workspaceDeletion.ts b/src/client/src/workspaceDeletion.ts index 8437993..f57cff4 100644 --- a/src/client/src/workspaceDeletion.ts +++ b/src/client/src/workspaceDeletion.ts @@ -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 { - 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 } { return { diff --git a/src/server/app.test.ts b/src/server/app.test.ts index b7f5b0e..fe37e7c 100644 --- a/src/server/app.test.ts +++ b/src/server/app.test.ts @@ -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 () => { diff --git a/src/server/app.ts b/src/server/app.ts index 53798fb..0c38099 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -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(`${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); diff --git a/src/server/terminals/terminalRoutes.test.ts b/src/server/terminals/terminalRoutes.test.ts index eb2e4d5..e66ac80 100644 --- a/src/server/terminals/terminalRoutes.test.ts +++ b/src/server/terminals/terminalRoutes.test.ts @@ -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}`); } diff --git a/src/server/terminals/terminalRoutes.ts b/src/server/terminals/terminalRoutes.ts index 2f4e77d..e8f74a5 100644 --- a/src/server/terminals/terminalRoutes.ts +++ b/src/server/terminals/terminalRoutes.ts @@ -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); diff --git a/src/server/terminals/terminalService.test.ts b/src/server/terminals/terminalService.test.ts index 759afab..9aabbeb 100644 --- a/src/server/terminals/terminalService.test.ts +++ b/src/server/terminals/terminalService.test.ts @@ -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 { diff --git a/src/server/terminals/terminalService.ts b/src/server/terminals/terminalService.ts index 1a8093a..63204ff 100644 --- a/src/server/terminals/terminalService.ts +++ b/src/server/terminals/terminalService.ts @@ -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: [] }); } diff --git a/src/server/workspaces/workspaceDeletionRoutes.test.ts b/src/server/workspaces/workspaceDeletionRoutes.test.ts new file mode 100644 index 0000000..4ff152d --- /dev/null +++ b/src/server/workspaces/workspaceDeletionRoutes.test.ts @@ -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()).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 { + 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 { + 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"); }, + }; +} diff --git a/src/server/workspaces/workspaceDeletionRoutes.ts b/src/server/workspaces/workspaceDeletionRoutes.ts new file mode 100644 index 0000000..7329f7f --- /dev/null +++ b/src/server/workspaces/workspaceDeletionRoutes.ts @@ -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 { + 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, 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, 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, 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 { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/shared/federatedRoutes.ts b/src/shared/federatedRoutes.ts index 27cb7a5..1796f8e 100644 --- a/src/shared/federatedRoutes.ts +++ b/src/shared/federatedRoutes.ts @@ -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" }, diff --git a/src/shared/workspaceDeletion.ts b/src/shared/workspaceDeletion.ts new file mode 100644 index 0000000..8a188ff --- /dev/null +++ b/src/shared/workspaceDeletion.ts @@ -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 { + return { + [workspaceDeleteOperationMetadataKey]: workspaceDeleteOperation, + [targetWorkspaceIdMetadataKey]: workspace.id, + [targetWorkspacePathMetadataKey]: workspace.path, + }; +}