Archived
feat: run workspace operations in terminals
This commit is contained in:
@@ -2,7 +2,8 @@ import Fastify, { type FastifyInstance } from "fastify";
|
||||
import fastifyWebsocket from "@fastify/websocket";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { WebSocket, type RawData } from "ws";
|
||||
import type { TerminalInfo } from "./terminalService.js";
|
||||
import type { TerminalCommandRun, TerminalCommandRunFilter } from "../../shared/apiTypes.js";
|
||||
import type { RunTerminalCommandOptions, TerminalInfo } from "./terminalService.js";
|
||||
import { registerTerminalRoutes, type TerminalRouteService } from "./terminalRoutes.js";
|
||||
|
||||
let app: FastifyInstance;
|
||||
@@ -20,7 +21,7 @@ afterEach(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe("terminal socket routes", () => {
|
||||
describe("terminal routes", () => {
|
||||
it("applies the initial socket size before attaching and replaying output", async () => {
|
||||
const socket = new WebSocket(`${serverUrl(app)}/terminals/t1/socket?cols=120.9&rows=40.2`);
|
||||
|
||||
@@ -29,10 +30,37 @@ describe("terminal socket routes", () => {
|
||||
|
||||
socket.close();
|
||||
});
|
||||
|
||||
it("creates and lists terminal command runs with filters", async () => {
|
||||
const createResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/terminal-command-runs",
|
||||
payload: { origin: "core", projectId: "p1", workspaceId: "w1", cwd: "/repo", title: "Build", command: "npm test", metadata: { "pi.operation": "test" } },
|
||||
});
|
||||
|
||||
expect(createResponse.statusCode).toBe(200);
|
||||
expect(createResponse.json<TerminalCommandRun>()).toMatchObject({ id: "run1", terminalId: "t-run", status: "running" });
|
||||
|
||||
const listResponse = await app.inject({ method: "GET", url: `/terminal-command-runs?projectId=p1&statuses=running&metadata=${encodeURIComponent(JSON.stringify({ "pi.operation": "test" }))}` });
|
||||
|
||||
expect(listResponse.statusCode).toBe(200);
|
||||
expect(listResponse.json<TerminalCommandRun[]>()).toHaveLength(1);
|
||||
expect(terminals.filters).toEqual([{ projectId: "p1", statuses: ["running"], metadata: { "pi.operation": "test" } }]);
|
||||
|
||||
const cancelResponse = await app.inject({ method: "POST", url: "/terminal-command-runs/run1/cancel" });
|
||||
expect(cancelResponse.statusCode).toBe(200);
|
||||
expect(terminals.events).toContain("cancel:run1");
|
||||
|
||||
const continueResponse = await app.inject({ method: "POST", url: "/terminals/t-run/continue" });
|
||||
expect(continueResponse.statusCode).toBe(200);
|
||||
expect(terminals.events).toContain("continue:t-run");
|
||||
});
|
||||
});
|
||||
|
||||
class FakeTerminals implements TerminalRouteService {
|
||||
readonly events: string[] = [];
|
||||
readonly filters: TerminalCommandRunFilter[] = [];
|
||||
private readonly commandRuns = new Map<string, TerminalCommandRun>();
|
||||
|
||||
list(cwd: string): TerminalInfo[] {
|
||||
void cwd;
|
||||
@@ -68,6 +96,49 @@ class FakeTerminals implements TerminalRouteService {
|
||||
resize(id: string, cols: number, rows: number): void {
|
||||
this.events.push(`resize:${id}:${String(cols)}x${String(rows)}`);
|
||||
}
|
||||
|
||||
continue(id: string): TerminalInfo {
|
||||
this.events.push(`continue:${id}`);
|
||||
return { id, cwd: "/repo", name: "Shell 1", createdAt: "2026-05-13T00:00:00.000Z", exited: false };
|
||||
}
|
||||
|
||||
runCommand(options: RunTerminalCommandOptions): TerminalCommandRun {
|
||||
const run: TerminalCommandRun = {
|
||||
id: "run1",
|
||||
origin: options.origin,
|
||||
projectId: options.projectId,
|
||||
workspaceId: options.workspaceId,
|
||||
terminalId: "t-run",
|
||||
title: options.title,
|
||||
command: options.command,
|
||||
status: "running",
|
||||
createdAt: "2026-05-13T00:00:00.000Z",
|
||||
metadata: routeMetadata(options.metadata),
|
||||
};
|
||||
this.commandRuns.set(run.id, run);
|
||||
return run;
|
||||
}
|
||||
|
||||
listCommandRuns(filter: TerminalCommandRunFilter = {}): TerminalCommandRun[] {
|
||||
this.filters.push(filter);
|
||||
return [...this.commandRuns.values()];
|
||||
}
|
||||
|
||||
getCommandRun(runId: string): TerminalCommandRun | undefined {
|
||||
return this.commandRuns.get(runId);
|
||||
}
|
||||
|
||||
cancelCommandRun(runId: string): TerminalCommandRun {
|
||||
const run = this.commandRuns.get(runId);
|
||||
if (run === undefined) throw new Error("Terminal command run not found");
|
||||
this.events.push(`cancel:${runId}`);
|
||||
return run;
|
||||
}
|
||||
}
|
||||
|
||||
function routeMetadata(value: unknown): Record<string, string> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return {};
|
||||
return Object.fromEntries(Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === "string"));
|
||||
}
|
||||
|
||||
function serverUrl(instance: FastifyInstance): string {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import type { RawData } from "ws";
|
||||
import type { TerminalInfo } from "./terminalService.js";
|
||||
import type { TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunStatus } from "../../shared/apiTypes.js";
|
||||
import type { RunTerminalCommandOptions, TerminalInfo } from "./terminalService.js";
|
||||
import { parseTerminalSize } from "./terminalSize.js";
|
||||
|
||||
export interface TerminalRouteService {
|
||||
@@ -10,6 +11,11 @@ export interface TerminalRouteService {
|
||||
attach(id: string, handlers: { output: (data: string, replay: boolean) => void; exit: (exitCode: number | undefined) => void }): () => void;
|
||||
write(id: string, data: string): void;
|
||||
resize(id: string, cols: number, rows: number): void;
|
||||
continue(id: string): TerminalInfo;
|
||||
runCommand(options: RunTerminalCommandOptions): TerminalCommandRun;
|
||||
listCommandRuns(filter?: TerminalCommandRunFilter): TerminalCommandRun[];
|
||||
getCommandRun(runId: string): TerminalCommandRun | undefined;
|
||||
cancelCommandRun(runId: string): TerminalCommandRun;
|
||||
}
|
||||
|
||||
export function registerTerminalRoutes(app: FastifyInstance, terminals: TerminalRouteService, prefix = ""): void {
|
||||
@@ -26,13 +32,51 @@ export function registerTerminalRoutes(app: FastifyInstance, terminals: Terminal
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Body: RunTerminalCommandOptions }>(`${prefix}/terminal-command-runs`, (request, reply) => {
|
||||
try {
|
||||
return terminals.runCommand(request.body);
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.get<{ Querystring: TerminalCommandRunQuery }>(`${prefix}/terminal-command-runs`, (request, reply) => {
|
||||
try {
|
||||
return terminals.listCommandRuns(parseCommandRunFilter(request.query));
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Params: { runId: string } }>(`${prefix}/terminal-command-runs/:runId/cancel`, (request, reply) => {
|
||||
try {
|
||||
return terminals.cancelCommandRun(request.params.runId);
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.get<{ Params: { runId: string } }>(`${prefix}/terminal-command-runs/:runId`, (request, reply) => {
|
||||
const run = terminals.getCommandRun(request.params.runId);
|
||||
if (run === undefined) return reply.code(404).send({ error: "Terminal command run not found" });
|
||||
return run;
|
||||
});
|
||||
|
||||
app.post<{ Params: { terminalId: string } }>(`${prefix}/terminals/:terminalId/continue`, (request, reply) => {
|
||||
try {
|
||||
return terminals.continue(request.params.terminalId);
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete<{ Params: { terminalId: string } }>(`${prefix}/terminals/:terminalId`, (request) => {
|
||||
terminals.close(request.params.terminalId);
|
||||
return { closed: true };
|
||||
});
|
||||
|
||||
app.get<{ Params: { terminalId: string }; Querystring: { cols?: string; rows?: string } }>(`${prefix}/terminals/:terminalId/socket`, { websocket: true }, (socket, request) => {
|
||||
let detach: (() => void) | undefined;
|
||||
let detach: () => void = () => undefined;
|
||||
try {
|
||||
const initialSize = parseTerminalSize(request.query.cols, request.query.rows);
|
||||
if (initialSize !== undefined) terminals.resize(request.params.terminalId, initialSize.cols, initialSize.rows);
|
||||
@@ -64,6 +108,40 @@ type ClientTerminalMessage =
|
||||
| { type: "input"; data: string }
|
||||
| { type: "resize"; cols: number; rows: number };
|
||||
|
||||
interface TerminalCommandRunQuery {
|
||||
projectId?: string;
|
||||
workspaceId?: string;
|
||||
terminalId?: string;
|
||||
statuses?: string;
|
||||
metadata?: string;
|
||||
}
|
||||
|
||||
function parseCommandRunFilter(query: TerminalCommandRunQuery): TerminalCommandRunFilter {
|
||||
const metadata = query.metadata === undefined || query.metadata === "" ? undefined : parseMetadataFilter(query.metadata);
|
||||
const statuses = query.statuses === undefined || query.statuses === "" ? undefined : query.statuses.split(",").filter((status) => status !== "").map(parseCommandRunStatus);
|
||||
return {
|
||||
...(query.projectId === undefined ? {} : { projectId: query.projectId }),
|
||||
...(query.workspaceId === undefined ? {} : { workspaceId: query.workspaceId }),
|
||||
...(query.terminalId === undefined ? {} : { terminalId: query.terminalId }),
|
||||
...(statuses === undefined ? {} : { statuses }),
|
||||
...(metadata === undefined ? {} : { metadata }),
|
||||
};
|
||||
}
|
||||
|
||||
function parseCommandRunStatus(value: string): TerminalCommandRunStatus {
|
||||
if (value !== "queued" && value !== "running" && value !== "succeeded" && value !== "failed") throw new Error(`Invalid command run status: ${value}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseMetadataFilter(value: string): Record<string, string> {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
if (!isRecord(parsed) || Array.isArray(parsed)) throw new Error("metadata filter must be an object");
|
||||
return Object.fromEntries(Object.entries(parsed).map(([key, metadataValue]) => {
|
||||
if (typeof metadataValue !== "string") throw new Error(`metadata filter value must be a string: ${key}`);
|
||||
return [key, metadataValue];
|
||||
}));
|
||||
}
|
||||
|
||||
function parseClientMessage(data: RawData): ClientTerminalMessage {
|
||||
const value: unknown = JSON.parse(rawDataToString(data));
|
||||
if (!isRecord(value) || typeof value["type"] !== "string") throw new Error("Invalid terminal message");
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { TerminalService } from "./terminalService";
|
||||
|
||||
describe("TerminalService command runs", () => {
|
||||
it("tracks dedicated terminal command runs through completion", async () => {
|
||||
const service = new TerminalService();
|
||||
try {
|
||||
const run = service.runCommand({
|
||||
origin: "core",
|
||||
projectId: "p1",
|
||||
workspaceId: "w1",
|
||||
cwd: process.cwd(),
|
||||
title: "Test command",
|
||||
command: "printf 'hello'",
|
||||
metadata: { "pi.operation": "test" },
|
||||
});
|
||||
|
||||
expect(run).toMatchObject({ status: "running", origin: "core", projectId: "p1", workspaceId: "w1", metadata: { "pi.operation": "test" } });
|
||||
expect(service.get(run.terminalId)).toMatchObject({ commandRunId: run.id });
|
||||
expect(service.listCommandRuns({ metadata: { "pi.operation": "test" } })).toHaveLength(1);
|
||||
|
||||
const output = await terminalExit(service, run.terminalId);
|
||||
|
||||
expect(output).toContain("$ printf 'hello'");
|
||||
expect(output).toContain("hello");
|
||||
expect(service.getCommandRun(run.id)).toMatchObject({ status: "succeeded", exitCode: 0, terminalId: run.terminalId });
|
||||
expect(service.listCommandRuns({ statuses: ["succeeded"] }).map((candidate) => candidate.id)).toEqual([run.id]);
|
||||
} finally {
|
||||
service.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("continues an exited command-run terminal as an interactive shell", async () => {
|
||||
const service = new TerminalService();
|
||||
try {
|
||||
const run = service.runCommand({
|
||||
origin: "core",
|
||||
projectId: "p1",
|
||||
workspaceId: "w1",
|
||||
cwd: process.cwd(),
|
||||
title: "Done command",
|
||||
command: "true",
|
||||
});
|
||||
await terminalExit(service, run.terminalId);
|
||||
|
||||
const continued = service.continue(run.terminalId);
|
||||
|
||||
expect(continued).toMatchObject({ id: run.terminalId, exited: false });
|
||||
expect(continued.commandRunId).toBeUndefined();
|
||||
expect(service.get(run.terminalId)?.commandRunId).toBeUndefined();
|
||||
expect(await terminalReplay(service, run.terminalId)).toContain("[continued in interactive shell]");
|
||||
} finally {
|
||||
service.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("marks failed command runs when the command exits non-zero", async () => {
|
||||
const service = new TerminalService();
|
||||
try {
|
||||
const run = service.runCommand({
|
||||
origin: "core",
|
||||
projectId: "p1",
|
||||
workspaceId: "w1",
|
||||
cwd: process.cwd(),
|
||||
title: "Failing command",
|
||||
command: "exit 7",
|
||||
});
|
||||
|
||||
await terminalExit(service, run.terminalId);
|
||||
|
||||
expect(service.getCommandRun(run.id)).toMatchObject({ status: "failed", exitCode: 7 });
|
||||
} finally {
|
||||
service.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function terminalReplay(service: TerminalService, terminalId: string): Promise<string> {
|
||||
let output = "";
|
||||
const detach = service.attach(terminalId, {
|
||||
output: (data) => { output += data; },
|
||||
exit: () => undefined,
|
||||
});
|
||||
detach();
|
||||
return Promise.resolve(output);
|
||||
}
|
||||
|
||||
function terminalExit(service: TerminalService, terminalId: string): Promise<string> {
|
||||
const output: string[] = [];
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
service.attach(terminalId, {
|
||||
output: (data) => { output.push(data); },
|
||||
exit: () => { resolve(output.join("")); },
|
||||
});
|
||||
} catch (error) {
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import * as pty from "node-pty";
|
||||
import type { TerminalUiEvent } from "../../shared/apiTypes.js";
|
||||
import type { TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunStatus, TerminalUiEvent } from "../../shared/apiTypes.js";
|
||||
import type { SessionEventHub } from "../realtime/sessionEventHub.js";
|
||||
import type { WorkspaceActivityService } from "../activity/workspaceActivityService.js";
|
||||
|
||||
@@ -14,16 +14,31 @@ export interface TerminalInfo {
|
||||
createdAt: string;
|
||||
exited: boolean;
|
||||
exitCode?: number;
|
||||
commandRunId?: string;
|
||||
}
|
||||
|
||||
export interface RunTerminalCommandOptions {
|
||||
origin: string;
|
||||
projectId: string;
|
||||
workspaceId: string;
|
||||
cwd: string;
|
||||
title: string;
|
||||
command: string;
|
||||
metadata?: unknown;
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
}
|
||||
|
||||
interface TerminalRecord extends TerminalInfo {
|
||||
pty: pty.IPty;
|
||||
buffer: string;
|
||||
events: EventEmitter;
|
||||
commandRunId?: string;
|
||||
}
|
||||
|
||||
export class TerminalService {
|
||||
private readonly terminals = new Map<string, TerminalRecord>();
|
||||
private readonly commandRuns = new Map<string, TerminalCommandRun>();
|
||||
|
||||
constructor(private readonly events?: SessionEventHub, private readonly workspaceActivity?: Pick<WorkspaceActivityService, "updateTerminal" | "removeTerminal">) {}
|
||||
|
||||
@@ -34,45 +49,67 @@ export class TerminalService {
|
||||
}
|
||||
|
||||
create(options: { cwd: string; name?: string; cols?: number; rows?: number }): TerminalInfo {
|
||||
if (options.cwd === "") throw new Error("cwd is required");
|
||||
const id = randomUUID();
|
||||
return this.createTerminal({ ...options, shellArgs: [] });
|
||||
}
|
||||
|
||||
runCommand(options: RunTerminalCommandOptions): TerminalCommandRun {
|
||||
validateCommandRunOptions(options);
|
||||
const commandRunId = randomUUID();
|
||||
const terminalId = randomUUID();
|
||||
const createdAt = new Date().toISOString();
|
||||
const shell = process.env["SHELL"] ?? "/bin/bash";
|
||||
const terminal = pty.spawn(shell, [], {
|
||||
name: "xterm-256color",
|
||||
cwd: options.cwd,
|
||||
cols: options.cols ?? 100,
|
||||
rows: options.rows ?? 30,
|
||||
env: { ...process.env, TERM: "xterm-256color" },
|
||||
});
|
||||
const requestedName = options.name?.trim();
|
||||
const record: TerminalRecord = {
|
||||
id,
|
||||
cwd: options.cwd,
|
||||
name: requestedName !== undefined && requestedName !== "" ? requestedName : `Shell ${String(this.list(options.cwd).length + 1)}`,
|
||||
const metadata = parseMetadata(options.metadata);
|
||||
const queued: TerminalCommandRun = {
|
||||
id: commandRunId,
|
||||
origin: options.origin,
|
||||
projectId: options.projectId,
|
||||
workspaceId: options.workspaceId,
|
||||
terminalId,
|
||||
title: options.title,
|
||||
command: options.command,
|
||||
status: "queued",
|
||||
createdAt,
|
||||
exited: false,
|
||||
pty: terminal,
|
||||
buffer: "",
|
||||
events: new EventEmitter(),
|
||||
metadata,
|
||||
};
|
||||
terminal.onData((data) => {
|
||||
record.buffer = trimReplayBuffer(record.buffer + data);
|
||||
record.events.emit("output", data);
|
||||
});
|
||||
terminal.onExit(({ exitCode }) => {
|
||||
record.exited = true;
|
||||
record.exitCode = exitCode;
|
||||
record.events.emit("exit", exitCode);
|
||||
const info = toInfo(record);
|
||||
this.workspaceActivity?.updateTerminal(info);
|
||||
this.publish({ type: "terminal.exited", terminal: info });
|
||||
});
|
||||
this.terminals.set(id, record);
|
||||
const info = toInfo(record);
|
||||
this.workspaceActivity?.updateTerminal(info);
|
||||
this.publish({ type: "terminal.created", terminal: info });
|
||||
return info;
|
||||
const running: TerminalCommandRun = { ...queued, status: "running", startedAt: new Date().toISOString() };
|
||||
this.commandRuns.set(commandRunId, running);
|
||||
|
||||
try {
|
||||
this.createTerminal({
|
||||
id: terminalId,
|
||||
cwd: options.cwd,
|
||||
name: options.title,
|
||||
...(options.cols === undefined ? {} : { cols: options.cols }),
|
||||
...(options.rows === undefined ? {} : { rows: options.rows }),
|
||||
shellArgs: ["-lc", commandRunShellScript(options.command)],
|
||||
commandRunId,
|
||||
});
|
||||
} catch (error) {
|
||||
this.commandRuns.delete(commandRunId);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return copyCommandRun(this.commandRuns.get(commandRunId) ?? running);
|
||||
}
|
||||
|
||||
listCommandRuns(filter: TerminalCommandRunFilter = {}): TerminalCommandRun[] {
|
||||
return [...this.commandRuns.values()]
|
||||
.filter((run) => matchesCommandRunFilter(run, filter))
|
||||
.map(copyCommandRun);
|
||||
}
|
||||
|
||||
getCommandRun(runId: string): TerminalCommandRun | undefined {
|
||||
const run = this.commandRuns.get(runId);
|
||||
return run === undefined ? undefined : copyCommandRun(run);
|
||||
}
|
||||
|
||||
cancelCommandRun(runId: string): TerminalCommandRun {
|
||||
const run = this.commandRuns.get(runId);
|
||||
if (run === undefined) throw new Error("Terminal command run not found");
|
||||
if (isTerminalCommandRunFinal(run.status)) return copyCommandRun(run);
|
||||
const terminal = this.terminals.get(run.terminalId);
|
||||
if (terminal === undefined) throw new Error("Terminal not found");
|
||||
if (!terminal.exited) terminal.pty.write("\x03");
|
||||
return copyCommandRun(run);
|
||||
}
|
||||
|
||||
get(id: string): TerminalInfo | undefined {
|
||||
@@ -106,6 +143,30 @@ export class TerminalService {
|
||||
}
|
||||
}
|
||||
|
||||
continue(id: string): TerminalInfo {
|
||||
const record = this.require(id);
|
||||
if (!record.exited) return toInfo(record);
|
||||
delete record.exitCode;
|
||||
delete record.commandRunId;
|
||||
record.exited = false;
|
||||
const marker = "\r\n[continued in interactive shell]\r\n";
|
||||
record.buffer = trimReplayBuffer(record.buffer + marker);
|
||||
record.events.emit("output", marker);
|
||||
const shell = process.env["SHELL"] ?? "/bin/bash";
|
||||
record.pty = pty.spawn(shell, [], {
|
||||
name: "xterm-256color",
|
||||
cwd: record.cwd,
|
||||
cols: 100,
|
||||
rows: 30,
|
||||
env: { ...process.env, TERM: "xterm-256color" },
|
||||
});
|
||||
this.attachPtyEvents(record);
|
||||
const info = toInfo(record);
|
||||
this.workspaceActivity?.updateTerminal(info);
|
||||
this.publish({ type: "terminal.created", terminal: info });
|
||||
return info;
|
||||
}
|
||||
|
||||
close(id: string): void {
|
||||
const terminal = this.terminals.get(id);
|
||||
if (terminal === undefined) return;
|
||||
@@ -120,6 +181,67 @@ export class TerminalService {
|
||||
for (const id of [...this.terminals.keys()]) this.close(id);
|
||||
}
|
||||
|
||||
private createTerminal(options: { id?: string; cwd: string; name?: string; cols?: number; rows?: number; shellArgs: string[]; commandRunId?: string }): TerminalInfo {
|
||||
if (options.cwd === "") throw new Error("cwd is required");
|
||||
const id = options.id ?? randomUUID();
|
||||
const createdAt = new Date().toISOString();
|
||||
const shell = process.env["SHELL"] ?? "/bin/bash";
|
||||
const terminal = pty.spawn(shell, options.shellArgs, {
|
||||
name: "xterm-256color",
|
||||
cwd: options.cwd,
|
||||
cols: options.cols ?? 100,
|
||||
rows: options.rows ?? 30,
|
||||
env: { ...process.env, TERM: "xterm-256color" },
|
||||
});
|
||||
const requestedName = options.name?.trim();
|
||||
const record: TerminalRecord = {
|
||||
id,
|
||||
cwd: options.cwd,
|
||||
name: requestedName !== undefined && requestedName !== "" ? requestedName : `Shell ${String(this.list(options.cwd).length + 1)}`,
|
||||
createdAt,
|
||||
exited: false,
|
||||
pty: terminal,
|
||||
buffer: "",
|
||||
events: new EventEmitter(),
|
||||
...(options.commandRunId === undefined ? {} : { commandRunId: options.commandRunId }),
|
||||
};
|
||||
this.attachPtyEvents(record);
|
||||
this.terminals.set(id, record);
|
||||
const info = toInfo(record);
|
||||
this.workspaceActivity?.updateTerminal(info);
|
||||
this.publish({ type: "terminal.created", terminal: info });
|
||||
return info;
|
||||
}
|
||||
|
||||
private attachPtyEvents(record: TerminalRecord): void {
|
||||
record.pty.onData((data) => {
|
||||
record.buffer = trimReplayBuffer(record.buffer + data);
|
||||
record.events.emit("output", data);
|
||||
});
|
||||
record.pty.onExit(({ exitCode }) => {
|
||||
record.exited = true;
|
||||
record.exitCode = exitCode;
|
||||
this.completeCommandRun(record.commandRunId, exitCode);
|
||||
record.events.emit("exit", exitCode);
|
||||
const info = toInfo(record);
|
||||
this.workspaceActivity?.updateTerminal(info);
|
||||
this.publish({ type: "terminal.exited", terminal: info });
|
||||
});
|
||||
}
|
||||
|
||||
private completeCommandRun(runId: string | undefined, exitCode: number | undefined): void {
|
||||
if (runId === undefined) return;
|
||||
const run = this.commandRuns.get(runId);
|
||||
if (run === undefined || isTerminalCommandRunFinal(run.status)) return;
|
||||
const completed: TerminalCommandRun = {
|
||||
...run,
|
||||
status: exitCode === 0 ? "succeeded" : "failed",
|
||||
...(exitCode === undefined ? {} : { exitCode }),
|
||||
completedAt: new Date().toISOString(),
|
||||
};
|
||||
this.commandRuns.set(runId, completed);
|
||||
}
|
||||
|
||||
private require(id: string): TerminalRecord {
|
||||
const terminal = this.terminals.get(id);
|
||||
if (terminal === undefined) throw new Error("Terminal not found");
|
||||
@@ -139,6 +261,7 @@ function toInfo(record: TerminalRecord): TerminalInfo {
|
||||
createdAt: record.createdAt,
|
||||
exited: record.exited,
|
||||
...(record.exitCode === undefined ? {} : { exitCode: record.exitCode }),
|
||||
...(record.commandRunId === undefined ? {} : { commandRunId: record.commandRunId }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -146,3 +269,54 @@ function trimReplayBuffer(buffer: string): string {
|
||||
if (buffer.length <= MAX_REPLAY_BUFFER) return buffer;
|
||||
return buffer.slice(buffer.length - MAX_REPLAY_BUFFER);
|
||||
}
|
||||
|
||||
function commandRunShellScript(command: string): string {
|
||||
return `printf '%s\\n' ${shellQuote(`$ ${command}`)}\n${command}`;
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replaceAll("'", "'\\''")}'`;
|
||||
}
|
||||
|
||||
function validateCommandRunOptions(options: RunTerminalCommandOptions): void {
|
||||
if (options.origin.trim() === "") throw new Error("origin is required");
|
||||
if (options.projectId.trim() === "") throw new Error("projectId is required");
|
||||
if (options.workspaceId.trim() === "") throw new Error("workspaceId is required");
|
||||
if (options.cwd.trim() === "") throw new Error("cwd is required");
|
||||
if (options.title.trim() === "") throw new Error("title is required");
|
||||
if (options.command.trim() === "") throw new Error("command is required");
|
||||
parseMetadata(options.metadata);
|
||||
}
|
||||
|
||||
function parseMetadata(value: unknown): Record<string, string> {
|
||||
if (value === undefined || value === null) return {};
|
||||
if (!isRecord(value) || Array.isArray(value)) throw new Error("metadata must be an object");
|
||||
return Object.fromEntries(Object.entries(value).map(([key, metadataValue]) => {
|
||||
if (key.trim() === "") throw new Error("metadata keys must not be empty");
|
||||
if (typeof metadataValue !== "string") throw new Error("metadata values must be strings");
|
||||
return [key, metadataValue];
|
||||
}));
|
||||
}
|
||||
|
||||
function matchesCommandRunFilter(run: TerminalCommandRun, filter: TerminalCommandRunFilter): boolean {
|
||||
if (filter.projectId !== undefined && run.projectId !== filter.projectId) return false;
|
||||
if (filter.workspaceId !== undefined && run.workspaceId !== filter.workspaceId) return false;
|
||||
if (filter.terminalId !== undefined && run.terminalId !== filter.terminalId) return false;
|
||||
if (filter.statuses !== undefined && filter.statuses.length > 0 && !filter.statuses.includes(run.status)) return false;
|
||||
for (const [key, value] of Object.entries(filter.metadata ?? {})) {
|
||||
if (run.metadata[key] !== value) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isTerminalCommandRunFinal(status: TerminalCommandRunStatus): boolean {
|
||||
return status === "succeeded" || status === "failed";
|
||||
}
|
||||
|
||||
function copyCommandRun(run: TerminalCommandRun): TerminalCommandRun {
|
||||
return { ...run, metadata: { ...run.metadata } };
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user