Archived
fix: close workspace terminals before deletion
This commit is contained in:
@@ -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.
|
||||
@@ -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]),
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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" },
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user