feat: run workspace operations in terminals

This commit is contained in:
Federico Jaramillo Martinez
2026-05-25 15:01:56 +02:00
parent 57a6a4a69f
commit 711c4f3d98
34 changed files with 1631 additions and 300 deletions
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, it, vi } from "vitest";
import type { Workspace } from "@jmfederico/pi-web/plugin-api";
import { runWorkspaceActionInTerminal } from "./actionRunner";
import type { WorkspaceAction } from "./config";
import type { InternalTerminalCommandRun, InternalTerminalCommandRunsRuntime } from "./piWebInternal";
const workspace: Workspace = {
id: "workspace 1",
projectId: "project/1",
path: "/repo",
label: "repo",
isMain: false,
isGitRepo: true,
isGitWorktree: true,
};
const run: InternalTerminalCommandRun = {
id: "run1",
origin: "actions",
projectId: workspace.projectId,
workspaceId: workspace.id,
terminalId: "term1",
title: "Build",
command: "npm run build",
status: "running",
createdAt: "2026-05-25T00:00:00.000Z",
metadata: { "pi.plugin": "actions", "action.id": "build" },
};
describe("action runner", () => {
it("starts workspace actions through the internal terminal command-run helper", async () => {
const action: WorkspaceAction = { id: "build", title: "Build", command: "npm run build", confirm: false };
const runCommand = vi.fn<InternalTerminalCommandRunsRuntime["runCommand"]>(() => Promise.resolve({ run, completed: Promise.resolve(run) }));
const terminal: InternalTerminalCommandRunsRuntime = {
runCommand,
open: vi.fn(),
};
const handle = await runWorkspaceActionInTerminal(terminal, workspace, action);
expect(handle.run).toEqual(run);
await expect(handle.completed).resolves.toEqual(run);
expect(runCommand).toHaveBeenCalledWith({
workspace,
title: "Build",
command: "npm run build",
open: true,
metadata: { "pi.plugin": "actions", "action.id": "build" },
});
});
});
+16
View File
@@ -0,0 +1,16 @@
import type { Workspace } from "@jmfederico/pi-web/plugin-api";
import type { WorkspaceAction } from "./config.js";
import type { InternalTerminalCommandRunsRuntime } from "./piWebInternal.js";
export function runWorkspaceActionInTerminal(terminal: InternalTerminalCommandRunsRuntime, workspace: Workspace, action: WorkspaceAction): ReturnType<InternalTerminalCommandRunsRuntime["runCommand"]> {
return terminal.runCommand({
workspace,
title: action.title,
command: action.command,
open: true,
metadata: {
"pi.plugin": "actions",
"action.id": action.id,
},
});
}
+22 -10
View File
@@ -1,7 +1,8 @@
import type { Workspace } from "@jmfederico/pi-web/plugin-api";
import { ACTIONS_CONFIG_PATH, type WorkspaceAction } from "./config.js";
import { createWorkspaceTerminal, sendTerminalCommand } from "./terminalDispatcher.js";
import { runWorkspaceActionInTerminal } from "./actionRunner.js";
import { requestPiWebRender } from "./piWebPrivateUi.js";
import type { InternalTerminalCommandRunsRuntime } from "./piWebInternal.js";
import { actionsConfigRefreshHint, actionsConfigUnavailableMessage, loadWorkspaceActionsConfig, type WorkspaceActionsConfigLoadResult } from "./workspaceActionsClient.js";
export const actionsPanelTagName = "pi-web-actions-panel";
@@ -30,6 +31,7 @@ export function actionsPanelBadge(workspace: Workspace): string | number | undef
class PiWebActionsPanel extends HTMLElement {
private workspaceValue: Workspace | undefined;
private openTerminalValue: OpenTerminal | undefined;
private terminalCommandRunsValue: InternalTerminalCommandRunsRuntime | undefined;
private runningActionId: string | undefined;
private status: { kind: "info" | "success" | "error"; message: string; detail?: string } | undefined;
private readonly root: ShadowRoot;
@@ -51,6 +53,10 @@ class PiWebActionsPanel extends HTMLElement {
this.openTerminalValue = value;
}
set terminalCommandRuns(value: InternalTerminalCommandRunsRuntime | undefined) {
this.terminalCommandRunsValue = value;
}
connectedCallback(): void {
window.addEventListener(configChangedEvent, this.onConfigChanged);
this.render();
@@ -104,7 +110,7 @@ class PiWebActionsPanel extends HTMLElement {
if (state.kind === "unavailable") return `${renderUnavailableState(state)}${this.renderStatus()}`;
if (state.config.actions.length === 0) return `<p class="muted">No actions are defined in ${escapeHtml(ACTIONS_CONFIG_PATH)}. Add actions to the file, then click Refresh.</p>${this.renderStatus()}`;
return `
<p class="muted">Actions create a new workspace terminal, send the command, then switch to that terminal. Edit ${escapeHtml(ACTIONS_CONFIG_PATH)} and click Refresh to reload.</p>
<p class="muted">Actions run in a dedicated workspace terminal, then switch to that terminal. Edit ${escapeHtml(ACTIONS_CONFIG_PATH)} and click Refresh to reload.</p>
${renderActionGroups(state.config.actions, this.runningActionId)}
${this.renderStatus()}
`;
@@ -132,24 +138,26 @@ class PiWebActionsPanel extends HTMLElement {
if (this.runningActionId !== undefined) return;
if (action.confirm && !window.confirm(`Run ${action.title}?\n\n${action.command}`)) return;
const terminal = this.terminalCommandRunsValue;
if (terminal === undefined) {
this.status = { kind: "error", message: "This PI WEB version does not provide terminal command helpers to plugins." };
this.render();
return;
}
this.runningActionId = action.id;
this.status = { kind: "info", message: `Creating terminal for ${action.title}` };
this.status = { kind: "info", message: `Starting ${action.title}` };
this.render();
try {
const terminal = await createWorkspaceTerminal(workspace, action.title);
this.status = { kind: "info", message: `Dispatching command to ${terminal.name}` };
this.render();
await sendTerminalCommand(workspace, terminal.id, action.command);
const handle = await runWorkspaceActionInTerminal(terminal, workspace, action);
this.status = {
kind: "success",
message: `Dispatched to terminal “${terminal.name}”.`,
message: `Started terminal command ${handle.run.title}”.`,
detail: action.command,
};
this.runningActionId = undefined;
this.render();
this.openWorkspaceTerminal(terminal.id);
} catch (error) {
this.runningActionId = undefined;
this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) };
@@ -158,6 +166,10 @@ class PiWebActionsPanel extends HTMLElement {
}
private openWorkspaceTerminal(terminalId?: string): void {
if (this.terminalCommandRunsValue !== undefined) {
this.terminalCommandRunsValue.open(terminalId === undefined ? undefined : { terminalId });
return;
}
if (this.openTerminalValue === undefined) {
this.status = { kind: "error", message: "This PI WEB version does not provide terminal navigation to plugins." };
this.render();
+2 -1
View File
@@ -1,6 +1,7 @@
import type { PiWebPlugin } from "@jmfederico/pi-web/plugin-api";
import { ACTIONS_CONFIG_PATH } from "./config.js";
import { actionsPanelBadge, defineActionsPanelElement } from "./actionsPanelElement.js";
import { terminalCommandRunsFromContext } from "./piWebInternal.js";
const plugin: PiWebPlugin = {
apiVersion: 1,
@@ -29,7 +30,7 @@ const plugin: PiWebPlugin = {
title: "Actions",
order: 40,
badge: ({ workspace }) => actionsPanelBadge(workspace),
render: ({ workspace, openTerminal }) => html`<pi-web-actions-panel .workspace=${workspace} .openTerminal=${openTerminal}></pi-web-actions-panel>`,
render: (context) => html`<pi-web-actions-panel .workspace=${context.workspace} .terminalCommandRuns=${terminalCommandRunsFromContext(context)} .openTerminal=${context.openTerminal}></pi-web-actions-panel>`,
},
],
},
+62
View File
@@ -0,0 +1,62 @@
import type { Workspace } from "@jmfederico/pi-web/plugin-api";
export interface InternalRunTerminalCommandInput {
workspace: Workspace;
title: string;
command: string;
metadata?: Record<string, string>;
open?: boolean;
}
export interface InternalTerminalCommandRun {
id: string;
origin: string;
projectId: string;
workspaceId: string;
terminalId: string;
title: string;
command: string;
status: "queued" | "running" | "succeeded" | "failed";
exitCode?: number;
createdAt: string;
startedAt?: string;
completedAt?: string;
metadata: Record<string, string>;
}
export interface InternalTerminalCommandRunHandle {
run: InternalTerminalCommandRun;
completed: Promise<InternalTerminalCommandRun>;
}
export interface InternalTerminalCommandRunsRuntime {
runCommand(input: InternalRunTerminalCommandInput): Promise<InternalTerminalCommandRunHandle>;
open(options?: { terminalId?: string | undefined }): void;
}
export function terminalCommandRunsFromContext(context: unknown): InternalTerminalCommandRunsRuntime | undefined {
if (!isRecord(context)) return undefined;
const internal = context["piWebInternal"];
if (!isRecord(internal)) return undefined;
const terminalCommandRuns = internal["terminalCommandRuns"];
if (!isRecord(terminalCommandRuns)) return undefined;
const runCommand = terminalCommandRuns["runCommand"];
const open = terminalCommandRuns["open"];
if (!isRunCommand(runCommand) || !isOpen(open)) return undefined;
return {
runCommand: (input) => runCommand(input),
open: (options) => { open(options); },
};
}
function isRunCommand(value: unknown): value is InternalTerminalCommandRunsRuntime["runCommand"] {
return typeof value === "function";
}
function isOpen(value: unknown): value is InternalTerminalCommandRunsRuntime["open"] {
return typeof value === "function";
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
@@ -1,52 +0,0 @@
import { describe, expect, it } from "vitest";
import type { Workspace } from "@jmfederico/pi-web/plugin-api";
import { actionTerminalCols, actionTerminalRows, createWorkspaceTerminal, normalizeTerminalCommand, parseTerminalInfo, parseTerminalSocketMessage, terminalSocketUrl, type FetchLike } from "./terminalDispatcher";
const workspace: Workspace = {
id: "workspace 1",
projectId: "project/1",
path: "/repo",
label: "repo",
isMain: false,
isGitRepo: true,
isGitWorktree: true,
};
describe("terminal dispatcher", () => {
it("normalizes commands for terminal input", () => {
expect(normalizeTerminalCommand("npm test")).toBe("npm test\r");
expect(normalizeTerminalCommand("npm test\n")).toBe("npm test\r");
expect(normalizeTerminalCommand("npm test\r\n")).toBe("npm test\r");
});
it("builds terminal socket URLs from the current host", () => {
expect(terminalSocketUrl(workspace, "term/1", { protocol: "https:", host: "example.test" })).toBe(
`wss://example.test/api/projects/project%2F1/workspaces/workspace%201/terminals/term%2F1/socket?cols=${String(actionTerminalCols)}&rows=${String(actionTerminalRows)}`,
);
});
it("parses terminal socket messages", () => {
expect(parseTerminalSocketMessage(JSON.stringify({ type: "error", message: "boom" }))).toEqual({ type: "error", message: "boom" });
expect(parseTerminalSocketMessage(JSON.stringify({ type: "output", data: "hello" }))).toEqual({ type: "output" });
expect(parseTerminalSocketMessage("not json")).toBeUndefined();
});
it("creates terminals through the private workspace terminal endpoint", async () => {
let capturedRequest: { input: string; init: RequestInit | undefined } | undefined;
const fetcher: FetchLike = (input, init) => {
capturedRequest = { input, init };
return Promise.resolve(new Response(JSON.stringify({ id: "t1", name: "Action: Build" }), { status: 200 }));
};
await expect(createWorkspaceTerminal(workspace, "Build", fetcher)).resolves.toEqual({ id: "t1", name: "Action: Build" });
if (capturedRequest === undefined) throw new Error("Expected terminal request");
expect(capturedRequest.input).toBe("/api/projects/project%2F1/workspaces/workspace%201/terminals");
expect(capturedRequest.init?.method).toBe("POST");
expect(capturedRequest.init?.body).toBe(JSON.stringify({ name: "Action: Build", cols: actionTerminalCols, rows: actionTerminalRows }));
});
it("falls back to a generated terminal name when the response omits one", () => {
expect(parseTerminalInfo({ id: "t1" }, "Build")).toEqual({ id: "t1", name: "Action: Build" });
});
});
-161
View File
@@ -1,161 +0,0 @@
import type { Workspace } from "@jmfederico/pi-web/plugin-api";
export const actionTerminalCols = 120;
export const actionTerminalRows = 32;
export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
export interface TerminalInfo {
id: string;
name: string;
}
interface ServerTerminalMessage {
type: string;
message?: string;
}
interface TerminalCommandDeps {
createWebSocket: (url: string) => WebSocket;
setTimeout: typeof window.setTimeout;
clearTimeout: typeof window.clearTimeout;
}
interface TerminalLocation {
protocol: string;
host: string;
}
export async function createWorkspaceTerminal(
workspace: Workspace,
actionTitle: string,
fetcher: FetchLike = window.fetch.bind(window),
): Promise<TerminalInfo> {
const response = await fetcher(`/api/projects/${encodeURIComponent(workspace.projectId)}/workspaces/${encodeURIComponent(workspace.id)}/terminals`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ name: `Action: ${actionTitle}`, cols: actionTerminalCols, rows: actionTerminalRows }),
});
if (!response.ok) throw new Error(`Failed to create terminal: ${String(response.status)}`);
return parseTerminalInfo(await response.json(), actionTitle);
}
export function parseTerminalInfo(value: unknown, actionTitle: string): TerminalInfo {
if (!isRecord(value) || typeof value["id"] !== "string") throw new Error("Failed to create terminal: invalid response");
const name = value["name"];
return { id: value["id"], name: typeof name === "string" && name !== "" ? name : `Action: ${actionTitle}` };
}
export function sendTerminalCommand(workspace: Workspace, terminalId: string, command: string, deps = defaultTerminalCommandDeps()): Promise<void> {
return new Promise((resolve, reject) => {
const socket = deps.createWebSocket(terminalSocketUrl(workspace, terminalId));
const input = normalizeTerminalCommand(command);
let settled = false;
let sent = false;
let fallbackTimer: number | undefined;
let completionTimer: number | undefined;
const timeout = deps.setTimeout(() => {
finish(new Error("Timed out while dispatching command to terminal"));
}, 15000);
const finish = (error?: Error) => {
if (settled) return;
settled = true;
deps.clearTimeout(timeout);
if (fallbackTimer !== undefined) deps.clearTimeout(fallbackTimer);
if (completionTimer !== undefined) deps.clearTimeout(completionTimer);
try {
socket.close();
} catch {
// Ignore close failures.
}
if (error === undefined) resolve();
else reject(error);
};
const scheduleFinishAfterOutput = () => {
completionTimer = deps.setTimeout(() => { finish(); }, 300);
};
const send = () => {
if (settled || sent || socket.readyState !== WebSocket.OPEN) return;
sent = true;
socket.send(JSON.stringify({ type: "input", data: input }));
completionTimer = deps.setTimeout(() => { finish(); }, 5000);
};
socket.addEventListener("open", () => {
fallbackTimer = deps.setTimeout(send, 3000);
});
socket.addEventListener("message", (event: MessageEvent<unknown>) => {
void socketDataToText(event.data).then((text) => {
const message = parseTerminalSocketMessage(text);
if (message?.type === "error") {
finish(new Error(message.message ?? "Terminal socket error"));
return;
}
if (sent) {
scheduleFinishAfterOutput();
return;
}
if (fallbackTimer !== undefined) deps.clearTimeout(fallbackTimer);
deps.setTimeout(send, 100);
}).catch(() => {
if (sent) {
scheduleFinishAfterOutput();
return;
}
if (fallbackTimer !== undefined) deps.clearTimeout(fallbackTimer);
deps.setTimeout(send, 100);
});
});
socket.addEventListener("close", () => {
if (!sent) finish(new Error("Terminal socket closed before the command was dispatched"));
else finish();
});
socket.addEventListener("error", () => { finish(new Error("Failed to connect to terminal socket")); });
});
}
export function normalizeTerminalCommand(command: string): string {
return `${command.replace(/\r?\n$/u, "")}\r`;
}
export async function socketDataToText(data: unknown): Promise<string> {
if (typeof data === "string") return data;
if (data instanceof ArrayBuffer) return new TextDecoder().decode(data);
if (data instanceof Blob) return await data.text();
return String(data);
}
export function parseTerminalSocketMessage(text: string): ServerTerminalMessage | undefined {
try {
const message: unknown = JSON.parse(text);
if (!isRecord(message) || typeof message["type"] !== "string") return undefined;
const rawMessage = message["message"];
return {
type: message["type"],
...(typeof rawMessage === "string" ? { message: rawMessage } : {}),
};
} catch {
return undefined;
}
}
export function terminalSocketUrl(workspace: Workspace, terminalId: string, location: TerminalLocation = window.location): string {
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
const query = `cols=${String(actionTerminalCols)}&rows=${String(actionTerminalRows)}`;
return `${protocol}//${location.host}/api/projects/${encodeURIComponent(workspace.projectId)}/workspaces/${encodeURIComponent(workspace.id)}/terminals/${encodeURIComponent(terminalId)}/socket?${query}`;
}
function defaultTerminalCommandDeps(): TerminalCommandDeps {
return {
createWebSocket: (url) => new WebSocket(url),
setTimeout: window.setTimeout.bind(window),
clearTimeout: window.clearTimeout.bind(window),
};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}