Archived
fix: harden machine federation boundaries
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { TerminalCommandRun, Workspace } from "../../../shared/apiTypes";
|
||||
import { terminalsApi } from "./clients";
|
||||
|
||||
const workspace: Workspace = {
|
||||
id: "w/1",
|
||||
projectId: "p 1",
|
||||
path: "/repo",
|
||||
label: "repo",
|
||||
isMain: true,
|
||||
isGitRepo: true,
|
||||
isGitWorktree: true,
|
||||
};
|
||||
|
||||
const commandRun: TerminalCommandRun = {
|
||||
id: "run1",
|
||||
origin: "core",
|
||||
projectId: workspace.projectId,
|
||||
workspaceId: workspace.id,
|
||||
terminalId: "t1",
|
||||
title: "Build",
|
||||
command: "npm test",
|
||||
status: "running",
|
||||
createdAt: "2026-05-25T00:00:00.000Z",
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("machine-scoped terminal command-run API", () => {
|
||||
it("creates command runs through the selected machine scope", async () => {
|
||||
const fetchMock = stubJsonFetch(commandRun);
|
||||
|
||||
await terminalsApi.runTerminalCommand("core", { workspace, title: "Build", command: "npm test", open: true }, "remote a");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
const [url, init] = fetchCall(fetchMock, 0);
|
||||
expect(url).toBe("/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/terminal-command-runs");
|
||||
expect(init?.method).toBe("POST");
|
||||
expect(JSON.parse(requestBody(init))).toEqual({ origin: "core", title: "Build", command: "npm test", metadata: {} });
|
||||
});
|
||||
|
||||
it("lists, reads, and cancels command runs through the selected machine scope", async () => {
|
||||
const fetchMock = stubSequenceFetch([
|
||||
jsonResponse([commandRun]),
|
||||
jsonResponse(commandRun),
|
||||
jsonResponse(commandRun),
|
||||
]);
|
||||
|
||||
await terminalsApi.listCommandRuns({ projectId: "p 1", workspaceId: "w/1", statuses: ["running"], metadata: { "pi.operation": "workspace.delete" } }, "remote a");
|
||||
await terminalsApi.getCommandRun("run 1", "remote a");
|
||||
await terminalsApi.cancelCommandRun("run 1", "remote a");
|
||||
|
||||
expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([
|
||||
"/api/machines/remote%20a/terminal-command-runs?projectId=p+1&workspaceId=w%2F1&statuses=running&metadata=%7B%22pi.operation%22%3A%22workspace.delete%22%7D",
|
||||
"/api/machines/remote%20a/terminal-command-runs/run%201",
|
||||
"/api/machines/remote%20a/terminal-command-runs/run%201/cancel",
|
||||
]);
|
||||
expect(fetchCall(fetchMock, 2)[1]?.method).toBe("POST");
|
||||
});
|
||||
|
||||
it("returns undefined for missing command runs in the selected machine scope", async () => {
|
||||
const fetchMock = stubResponseFetch(new Response("{}", { status: 404 }));
|
||||
|
||||
await expect(terminalsApi.getCommandRun("missing", "remote-a")).resolves.toBeUndefined();
|
||||
|
||||
expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote-a/terminal-command-runs/missing");
|
||||
});
|
||||
});
|
||||
|
||||
type FetchLike = (url: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
||||
type FetchMock = ReturnType<typeof vi.fn<FetchLike>>;
|
||||
|
||||
function stubJsonFetch(value: unknown): FetchMock {
|
||||
return stubResponseFetch(jsonResponse(value));
|
||||
}
|
||||
|
||||
function stubSequenceFetch(responses: Response[]): FetchMock {
|
||||
const fetchMock = vi.fn<FetchLike>(() => {
|
||||
const response = responses.shift();
|
||||
if (response === undefined) throw new Error("No fetch response queued");
|
||||
return Promise.resolve(response);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
function stubResponseFetch(response: Response): FetchMock {
|
||||
const fetchMock = vi.fn<FetchLike>(() => Promise.resolve(response));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
function fetchCall(fetchMock: FetchMock, index: number): Parameters<FetchLike> {
|
||||
const call = fetchMock.mock.calls[index];
|
||||
if (call === undefined) throw new Error(`Missing fetch call ${String(index)}`);
|
||||
return call;
|
||||
}
|
||||
|
||||
function requestBody(init: RequestInit | undefined): string {
|
||||
if (typeof init?.body !== "string") throw new Error("Expected string request body");
|
||||
return init.body;
|
||||
}
|
||||
|
||||
function jsonResponse(value: unknown): Response {
|
||||
return new Response(JSON.stringify(value), { status: 200, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
@@ -107,14 +107,14 @@ export const terminalsApi = {
|
||||
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 ?? {}) }),
|
||||
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) => request(`/api/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 ?? {} }) }),
|
||||
listCommandRuns: (filter?: TerminalCommandRunFilter) => request(`/api/terminal-command-runs${terminalCommandRunFilterQuery(filter)}`, arrayOf(parseTerminalCommandRun)),
|
||||
getCommandRun: (runId: string) => getOptionalTerminalCommandRun(runId),
|
||||
cancelCommandRun: (runId: string) => request(`/api/terminal-command-runs/${encodeURIComponent(runId)}/cancel`, parseTerminalCommandRun, { 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 ?? {} }) }),
|
||||
listCommandRuns: (filter?: TerminalCommandRunFilter, machineId = "local") => request(`${machinePrefix(machineId)}/terminal-command-runs${terminalCommandRunFilterQuery(filter)}`, arrayOf(parseTerminalCommandRun)),
|
||||
getCommandRun: (runId: string, machineId = "local") => getOptionalTerminalCommandRun(runId, machineId),
|
||||
cancelCommandRun: (runId: string, machineId = "local") => request(`${machinePrefix(machineId)}/terminal-command-runs/${encodeURIComponent(runId)}/cancel`, parseTerminalCommandRun, { method: "POST" }),
|
||||
};
|
||||
|
||||
async function getOptionalTerminalCommandRun(runId: string): Promise<TerminalCommandRun | undefined> {
|
||||
const response = await fetch(`/api/terminal-command-runs/${encodeURIComponent(runId)}`);
|
||||
async function getOptionalTerminalCommandRun(runId: string, machineId: string): Promise<TerminalCommandRun | undefined> {
|
||||
const response = await fetch(`${machinePrefix(machineId)}/terminal-command-runs/${encodeURIComponent(runId)}`);
|
||||
if (response.status === 404) return undefined;
|
||||
if (!response.ok) {
|
||||
const body: unknown = await response.json().catch((): unknown => ({}));
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Workspace } from "../../../shared/apiTypes";
|
||||
import { FEDERATED_HTTP_ROUTES, FEDERATED_WEBSOCKET_ROUTES, type FederatedHttpRouteSpec } from "../../../shared/federatedRoutes";
|
||||
import { activityApi, filesApi, gitApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./clients";
|
||||
import { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./sockets";
|
||||
import { workspaceImagePreviewUrl } from "./urls";
|
||||
|
||||
const machineId = "remote-a";
|
||||
const workspace: Workspace = {
|
||||
id: "w 1",
|
||||
projectId: "p 1",
|
||||
path: "/repo",
|
||||
label: "repo",
|
||||
isMain: true,
|
||||
isGitRepo: true,
|
||||
isGitWorktree: true,
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("federated route contract", () => {
|
||||
it("covers machine-scoped client HTTP calls with remote proxy routes", async () => {
|
||||
const fetchMock = vi.fn<FetchLike>(() => Promise.resolve(jsonResponse({})));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await Promise.all([
|
||||
ignoreParseFailure(activityApi.workspaceActivity(machineId)),
|
||||
ignoreParseFailure(projectsApi.projects(machineId)),
|
||||
ignoreParseFailure(projectsApi.addProject("/repo", "Repo", false, machineId)),
|
||||
ignoreParseFailure(projectsApi.closeProject("p 1", machineId)),
|
||||
ignoreParseFailure(projectsApi.projectDirectories("/r", machineId)),
|
||||
ignoreParseFailure(workspacesApi.workspaces("p 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", "tracked", "file", machineId)),
|
||||
ignoreParseFailure(gitApi.gitStatus("p 1", "w 1", machineId)),
|
||||
ignoreParseFailure(gitApi.gitDiff("p 1", "w 1", { path: "README.md", staged: true }, machineId)),
|
||||
ignoreParseFailure(sessionsApi.sessions("/repo", machineId)),
|
||||
ignoreParseFailure(sessionsApi.startSession("/repo", machineId)),
|
||||
ignoreParseFailure(sessionsApi.messages("s 1", { limit: 20, before: 10 }, machineId)),
|
||||
ignoreParseFailure(sessionsApi.status("s 1", machineId)),
|
||||
ignoreParseFailure(sessionsApi.models("s 1", machineId)),
|
||||
ignoreParseFailure(sessionsApi.setModel("s 1", "openai", "gpt", machineId)),
|
||||
ignoreParseFailure(sessionsApi.cycleModel("s 1", "forward", machineId)),
|
||||
ignoreParseFailure(sessionsApi.thinkingLevels("s 1", machineId)),
|
||||
ignoreParseFailure(sessionsApi.setThinkingLevel("s 1", "medium", machineId)),
|
||||
ignoreParseFailure(sessionsApi.cycleThinkingLevel("s 1", machineId)),
|
||||
ignoreParseFailure(sessionsApi.commands("s 1", machineId)),
|
||||
ignoreParseFailure(sessionsApi.prompt("s 1", "hello", "followUp", machineId)),
|
||||
ignoreParseFailure(sessionsApi.shell("s 1", "ls", machineId)),
|
||||
ignoreParseFailure(sessionsApi.runCommand("s 1", "/help", machineId)),
|
||||
ignoreParseFailure(sessionsApi.respondToCommand("s 1", "req 1", "yes", machineId)),
|
||||
ignoreParseFailure(sessionsApi.abort("s 1", machineId)),
|
||||
ignoreParseFailure(sessionsApi.stop("s 1", machineId)),
|
||||
ignoreParseFailure(sessionsApi.archive("s 1", machineId)),
|
||||
ignoreParseFailure(sessionsApi.archiveWithDescendants("s 1", machineId)),
|
||||
ignoreParseFailure(sessionsApi.restore("s 1", machineId)),
|
||||
ignoreParseFailure(sessionsApi.detachParent("s 1", machineId)),
|
||||
ignoreParseFailure(sessionsApi.authProviders({ mode: "login", authType: "oauth", machineId })),
|
||||
ignoreParseFailure(sessionsApi.saveApiKey("openai", "key", machineId)),
|
||||
ignoreParseFailure(sessionsApi.logoutProvider("openai", machineId)),
|
||||
ignoreParseFailure(sessionsApi.startOAuthLogin("openai", machineId)),
|
||||
ignoreParseFailure(sessionsApi.oauthFlow("flow 1", machineId)),
|
||||
ignoreParseFailure(sessionsApi.respondOAuthFlow("flow 1", "req 1", "code", machineId)),
|
||||
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.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)),
|
||||
ignoreParseFailure(terminalsApi.listCommandRuns({ projectId: "p 1", workspaceId: "w 1", statuses: ["running"], metadata: { "pi.operation": "test" } }, machineId)),
|
||||
ignoreParseFailure(terminalsApi.getCommandRun("run 1", machineId)),
|
||||
ignoreParseFailure(terminalsApi.cancelCommandRun("run 1", machineId)),
|
||||
]);
|
||||
|
||||
const observedRoutes = uniqueHttpRoutes([
|
||||
...fetchMock.mock.calls.map((call) => fetchCallToRoute(call, machineId)),
|
||||
routeFromMachineUrl("GET", workspaceImagePreviewUrl("p 1", "w 1", "diagram.svg", { machineId, modifiedAt: "2026-05-25T00:00:00.000Z" }), machineId),
|
||||
]);
|
||||
const unmatched = observedRoutes.filter((route) => !matchesHttpRoute(route, FEDERATED_HTTP_ROUTES));
|
||||
|
||||
expect(unmatched).toEqual([]);
|
||||
});
|
||||
|
||||
it("covers machine-scoped client WebSocket calls with remote proxy routes", () => {
|
||||
const webSocketUrls: string[] = [];
|
||||
function FakeWebSocket(url: string): void {
|
||||
webSocketUrls.push(url);
|
||||
}
|
||||
vi.stubGlobal("WebSocket", FakeWebSocket);
|
||||
vi.stubGlobal("location", { protocol: "https:", host: "pi.example.test" });
|
||||
|
||||
sessionEvents("s 1", machineId);
|
||||
globalSessionEvents(machineId);
|
||||
realtimeEvents(machineId);
|
||||
terminalSocket("p 1", "w 1", "t 1", { cols: 120, rows: 40 }, machineId);
|
||||
|
||||
const observedPaths = uniqueStrings(webSocketUrls.map((url) => routeFromMachineUrl("GET", url, machineId).path));
|
||||
const unmatched = observedPaths.filter((path) => !FEDERATED_WEBSOCKET_ROUTES.some((route) => pathMatchesPattern(path, route)));
|
||||
|
||||
expect(unmatched).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
type FetchLike = (url: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
interface ObservedHttpRoute {
|
||||
method: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
async function ignoreParseFailure(promise: Promise<unknown>): Promise<void> {
|
||||
await promise.catch(() => undefined);
|
||||
}
|
||||
|
||||
function fetchCallToRoute(call: Parameters<FetchLike>, scopedMachineId: string): ObservedHttpRoute {
|
||||
const [url, init] = call;
|
||||
return routeFromMachineUrl((init?.method ?? "GET").toUpperCase(), url, scopedMachineId);
|
||||
}
|
||||
|
||||
function routeFromMachineUrl(method: string, input: string | URL | Request, scopedMachineId: string): ObservedHttpRoute {
|
||||
const url = toUrl(input);
|
||||
const prefix = `/api/machines/${encodeURIComponent(scopedMachineId)}`;
|
||||
if (!url.pathname.startsWith(prefix)) throw new Error(`Expected machine-scoped URL, got ${url.pathname}`);
|
||||
return { method, path: url.pathname.slice(prefix.length) || "/" };
|
||||
}
|
||||
|
||||
function toUrl(input: string | URL | Request): URL {
|
||||
if (input instanceof URL) return input;
|
||||
if (input instanceof Request) return new URL(input.url);
|
||||
return new URL(input, "https://pi.example.test");
|
||||
}
|
||||
|
||||
function matchesHttpRoute(route: ObservedHttpRoute, specs: readonly FederatedHttpRouteSpec[]): boolean {
|
||||
return specs.some((spec) => spec.method === route.method && pathMatchesPattern(route.path, spec.path));
|
||||
}
|
||||
|
||||
function pathMatchesPattern(path: string, pattern: string): boolean {
|
||||
const pathSegments = path.split("/").filter((segment) => segment !== "");
|
||||
const patternSegments = pattern.split("/").filter((segment) => segment !== "");
|
||||
return pathSegments.length === patternSegments.length
|
||||
&& patternSegments.every((segment, index) => segment.startsWith(":") || segment === pathSegments[index]);
|
||||
}
|
||||
|
||||
function uniqueHttpRoutes(routes: ObservedHttpRoute[]): ObservedHttpRoute[] {
|
||||
const seen = new Set<string>();
|
||||
return routes.filter((route) => {
|
||||
const key = `${route.method} ${route.path}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function uniqueStrings(values: string[]): string[] {
|
||||
return [...new Set(values)];
|
||||
}
|
||||
|
||||
function jsonResponse(value: unknown): Response {
|
||||
return new Response(JSON.stringify(value), { status: 200, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
@@ -430,18 +430,36 @@ export class PiWebApp extends LitElement {
|
||||
this.openWorkspaceTool("core:workspace.terminal");
|
||||
}
|
||||
|
||||
private terminalCommandRunsForOrigin(origin: string): TerminalCommandRunsInternalRuntime {
|
||||
const existing = this.terminalCommandRunRuntimes.get(origin);
|
||||
private terminalCommandRunsForOrigin(origin: string, machineId = selectedMachineId(this.state)): TerminalCommandRunsInternalRuntime {
|
||||
const key = machineScopedKey(machineId, origin);
|
||||
const existing = this.terminalCommandRunRuntimes.get(key);
|
||||
if (existing !== undefined) return existing;
|
||||
const runtime = createTerminalCommandRunsRuntime(origin, {
|
||||
openTerminal: (workspace, options) => { void this.openRuntimeTerminal(workspace, options); },
|
||||
api: {
|
||||
runTerminalCommand: (runtimeOrigin, input) => terminalsApi.runTerminalCommand(runtimeOrigin, input, machineId),
|
||||
listCommandRuns: (filter) => terminalsApi.listCommandRuns(filter, machineId),
|
||||
getCommandRun: (runId) => terminalsApi.getCommandRun(runId, machineId),
|
||||
},
|
||||
openTerminal: (workspace, options) => { void this.openRuntimeTerminal(machineId, workspace, options); },
|
||||
});
|
||||
this.terminalCommandRunRuntimes.set(origin, runtime);
|
||||
this.terminalCommandRunRuntimes.set(key, runtime);
|
||||
return runtime;
|
||||
}
|
||||
|
||||
private async openRuntimeTerminal(workspace: Workspace | undefined, options?: { terminalId?: string | undefined }): Promise<void> {
|
||||
if (workspace !== undefined && this.state.selectedWorkspace?.id !== workspace.id) await this.workspaces.selectWorkspace(workspace);
|
||||
private async openRuntimeTerminal(machineId: string, workspace: Workspace | undefined, options?: { terminalId?: string | undefined }): Promise<void> {
|
||||
if (selectedMachineId(this.state) !== machineId) {
|
||||
const machine = this.state.machines.find((candidate) => candidate.id === machineId);
|
||||
if (machine === undefined) {
|
||||
this.setState({ error: "Machine not found for terminal command run" });
|
||||
return;
|
||||
}
|
||||
await this.machines.selectMachine(machine);
|
||||
}
|
||||
if (workspace !== undefined && (this.state.selectedWorkspace?.id !== workspace.id || this.state.selectedProject?.id !== workspace.projectId)) {
|
||||
const project = this.state.projects.find((candidate) => candidate.id === workspace.projectId);
|
||||
if (project !== undefined && this.state.selectedProject?.id !== project.id) await this.workspaces.selectProject(project, { workspaceId: workspace.id });
|
||||
else await this.workspaces.selectWorkspace(workspace);
|
||||
}
|
||||
this.openTerminal(options);
|
||||
}
|
||||
|
||||
@@ -525,9 +543,10 @@ export class PiWebApp extends LitElement {
|
||||
}
|
||||
|
||||
private async refreshActiveTerminals(workspace: Workspace): Promise<void> {
|
||||
const machineId = selectedMachineId(this.state);
|
||||
try {
|
||||
const terminals = await terminalsApi.terminals(workspace.projectId, workspace.id, selectedMachineId(this.state));
|
||||
if (this.state.selectedWorkspace?.id !== workspace.id) return;
|
||||
const terminals = await terminalsApi.terminals(workspace.projectId, workspace.id, machineId);
|
||||
if (selectedMachineId(this.state) !== machineId || this.state.selectedWorkspace?.id !== workspace.id) return;
|
||||
this.activeTerminalIds.clear();
|
||||
for (const terminal of terminals) {
|
||||
if (!terminal.exited) this.activeTerminalIds.add(terminal.id);
|
||||
@@ -824,25 +843,27 @@ export class PiWebApp extends LitElement {
|
||||
const confirmed = confirm(`Delete workspace ${label}?\n\nThis will run git worktree remove and delete:\n${workspace.path}\n\nThe Git branch will not be deleted.`);
|
||||
if (!confirmed) return;
|
||||
|
||||
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 handle = await this.terminalCommandRunsForOrigin("core").runCommand({
|
||||
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);
|
||||
void handle.completed.then((run) => this.handleCompletedWorkspaceDeletionRun(run)).catch((error: unknown) => {
|
||||
this.setState({ error: `Workspace deletion failed. See terminal output. ${errorMessage(error)}` });
|
||||
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)}` });
|
||||
});
|
||||
} catch (error) {
|
||||
this.setState({ error: `Failed to start workspace deletion: ${errorMessage(error)}` });
|
||||
if (selectedMachineId(this.state) === machineId) this.setState({ error: `Failed to start workspace deletion: ${errorMessage(error)}` });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -852,7 +873,8 @@ export class PiWebApp extends LitElement {
|
||||
return workspaces.find((workspace) => workspace.isMain) ?? workspaces[0];
|
||||
}
|
||||
|
||||
private recordWorkspaceDeletionRun(run: TerminalCommandRun): void {
|
||||
private recordWorkspaceDeletionRun(run: TerminalCommandRun, machineId: string): void {
|
||||
if (selectedMachineId(this.state) !== machineId) return;
|
||||
const workspaceId = targetWorkspaceIdForRun(run);
|
||||
if (workspaceId === undefined) return;
|
||||
this.setState({ workspaceDeletionRuns: { ...this.state.workspaceDeletionRuns, [workspaceId]: run } });
|
||||
@@ -861,6 +883,7 @@ export class PiWebApp extends LitElement {
|
||||
|
||||
private async refreshWorkspaceDeletionRuns(): Promise<void> {
|
||||
if (this.refreshingWorkspaceDeletionRuns) return;
|
||||
const machineId = selectedMachineId(this.state);
|
||||
const project = this.state.selectedProject;
|
||||
if (project === undefined) {
|
||||
this.setState({ workspaceDeletionRuns: {} });
|
||||
@@ -870,11 +893,12 @@ export class PiWebApp extends LitElement {
|
||||
|
||||
this.refreshingWorkspaceDeletionRuns = true;
|
||||
try {
|
||||
const runs = await this.terminalCommandRunsForOrigin("core").listCommandRuns(workspaceDeletionRunFilter(project.id));
|
||||
const runs = await this.terminalCommandRunsForOrigin("core", machineId).listCommandRuns(workspaceDeletionRunFilter(project.id));
|
||||
if (selectedMachineId(this.state) !== machineId) return;
|
||||
const latestRuns = latestWorkspaceDeletionRuns(runs);
|
||||
this.setState({ workspaceDeletionRuns: latestRuns });
|
||||
for (const run of Object.values(latestRuns)) {
|
||||
if (!isWorkspaceDeletionRunPending(run)) await this.handleCompletedWorkspaceDeletionRun(run);
|
||||
if (!isWorkspaceDeletionRunPending(run)) await this.handleCompletedWorkspaceDeletionRun(run, machineId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Failed to refresh workspace deletion runs", error);
|
||||
@@ -896,14 +920,17 @@ export class PiWebApp extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private async handleCompletedWorkspaceDeletionRun(run: TerminalCommandRun): Promise<void> {
|
||||
if (this.handledWorkspaceDeletionRunIds.has(run.id)) return;
|
||||
private async handleCompletedWorkspaceDeletionRun(run: TerminalCommandRun, machineId = selectedMachineId(this.state)): Promise<void> {
|
||||
if (selectedMachineId(this.state) !== machineId) return;
|
||||
const runKey = machineScopedKey(machineId, run.id);
|
||||
if (this.handledWorkspaceDeletionRunIds.has(runKey)) return;
|
||||
const workspaceId = targetWorkspaceIdForRun(run);
|
||||
if (workspaceId === undefined) return;
|
||||
this.handledWorkspaceDeletionRunIds.add(run.id);
|
||||
this.handledWorkspaceDeletionRunIds.add(runKey);
|
||||
|
||||
if (run.status === "succeeded") {
|
||||
await this.workspaces.refreshAfterWorkspaceDeleted(run.projectId, workspaceId);
|
||||
if (selectedMachineId(this.state) !== machineId) return;
|
||||
this.setState({ workspaceDeletionRuns: omitWorkspaceDeletionRun(this.state.workspaceDeletionRuns, workspaceId) });
|
||||
this.updateWorkspaceDeletionPolling();
|
||||
return;
|
||||
@@ -1383,6 +1410,10 @@ function isTerminalEvent(event: RealtimeEvent): event is TerminalUiEvent {
|
||||
return event.type === "terminal.created" || event.type === "terminal.exited" || event.type === "terminal.closed";
|
||||
}
|
||||
|
||||
function machineScopedKey(machineId: string, value: string): string {
|
||||
return JSON.stringify([machineId, value]);
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replaceAll("'", "'\\''")}'`;
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ export class TerminalPanel extends LitElement {
|
||||
private intersectionObserver: IntersectionObserver | undefined;
|
||||
private themeObserver: MutationObserver | undefined;
|
||||
private suppressTerminalInput = false;
|
||||
private observedCwd: string | undefined;
|
||||
private observedWorkspaceScope: string | undefined;
|
||||
private loadedCwd: string | undefined;
|
||||
private autoStartConsumedCwd: string | undefined;
|
||||
private commandRunPollTimer: number | undefined;
|
||||
@@ -69,9 +69,9 @@ export class TerminalPanel extends LitElement {
|
||||
}
|
||||
|
||||
override willUpdate(changed: PropertyValues<this>): void {
|
||||
const cwd = this.workspace?.path;
|
||||
if (cwd !== this.observedCwd) {
|
||||
this.observedCwd = cwd;
|
||||
const workspaceScope = this.workspace === undefined ? undefined : JSON.stringify([this.machineId, this.workspace.path]);
|
||||
if (workspaceScope !== this.observedWorkspaceScope) {
|
||||
this.observedWorkspaceScope = workspaceScope;
|
||||
this.loadedCwd = undefined;
|
||||
this.autoStartConsumedCwd = undefined;
|
||||
this.terminals = [];
|
||||
@@ -118,7 +118,7 @@ export class TerminalPanel extends LitElement {
|
||||
const shouldAutoStart = this.consumeAutoStart();
|
||||
const [terminals, commandRuns] = await Promise.all([
|
||||
terminalsApi.terminals(workspace.projectId, workspace.id, this.machineId),
|
||||
terminalsApi.listCommandRuns({ projectId: workspace.projectId, workspaceId: workspace.id }),
|
||||
terminalsApi.listCommandRuns({ projectId: workspace.projectId, workspaceId: workspace.id }, this.machineId),
|
||||
]);
|
||||
this.terminals = terminals;
|
||||
this.commandRuns = commandRuns;
|
||||
@@ -218,7 +218,7 @@ export class TerminalPanel extends LitElement {
|
||||
const workspace = this.workspace;
|
||||
if (workspace === undefined) return;
|
||||
try {
|
||||
const commandRuns = await terminalsApi.listCommandRuns({ projectId: workspace.projectId, workspaceId: workspace.id });
|
||||
const commandRuns = await terminalsApi.listCommandRuns({ projectId: workspace.projectId, workspaceId: workspace.id }, this.machineId);
|
||||
this.commandRuns = commandRuns;
|
||||
this.cancellingRunIds = this.cancellingRunIds.filter((runId) => commandRuns.some((run) => run.id === runId && isCommandRunPending(run)));
|
||||
this.updateCommandRunPolling(this.hasPendingCommandRuns(commandRuns));
|
||||
@@ -247,7 +247,7 @@ export class TerminalPanel extends LitElement {
|
||||
this.error = undefined;
|
||||
this.cancellingRunIds = [...this.cancellingRunIds, run.id];
|
||||
try {
|
||||
await terminalsApi.cancelCommandRun(run.id);
|
||||
await terminalsApi.cancelCommandRun(run.id, this.machineId);
|
||||
await this.loadCommandRuns();
|
||||
} catch (error) {
|
||||
this.error = error instanceof Error ? error.message : String(error);
|
||||
@@ -261,7 +261,7 @@ export class TerminalPanel extends LitElement {
|
||||
this.error = undefined;
|
||||
this.continuingTerminalIds = [...this.continuingTerminalIds, id];
|
||||
try {
|
||||
const terminal = await terminalsApi.continueTerminal(this.workspace.projectId, this.workspace.id, id);
|
||||
const terminal = await terminalsApi.continueTerminal(this.workspace.projectId, this.workspace.id, id, this.machineId);
|
||||
this.terminals = this.terminals.map((item) => item.id === id ? terminal : item);
|
||||
if (this.socket === undefined) this.disposeTerminalView();
|
||||
this.fitAndNotify();
|
||||
|
||||
@@ -38,6 +38,8 @@ export class MachineController {
|
||||
sessionActivities: {},
|
||||
workspaceActivities: {},
|
||||
workspacesByProjectId: {},
|
||||
workspaceDeletionRuns: {},
|
||||
activeTerminalCount: 0,
|
||||
...resetWorkspaceScopedState(),
|
||||
});
|
||||
if (options.updateUrl !== false) this.updateUrl();
|
||||
|
||||
Reference in New Issue
Block a user