feat(KB-641): improve terminal session handling and multi-project UX
- Update terminal session result flow for better async handling - Enhance QuickEntryBox and InlineCreateCard with improved UX - Refactor CLI project commands with better multi-project support - Update terminal service tests for new result flow patterns - Add changeset documentation for tracking changes - Clean up legacy multi-project migration components - Improve project detection and settings management
This commit is contained in:
@@ -4748,9 +4748,13 @@ describe("Terminal session routes", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("POST /api/terminal/sessions", () => {
|
describe("POST /api/terminal/sessions", () => {
|
||||||
it("returns 503 when max sessions reached (session is null)", async () => {
|
it("returns 503 when max sessions reached", async () => {
|
||||||
const mockService = {
|
const mockService = {
|
||||||
createSession: vi.fn().mockResolvedValue(null),
|
createSession: vi.fn().mockResolvedValue({
|
||||||
|
success: false,
|
||||||
|
code: "max_sessions",
|
||||||
|
error: "Maximum terminal sessions reached. Please close an existing terminal and try again.",
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
vi.spyOn(terminalServiceModule, "getTerminalService").mockReturnValue(mockService as any);
|
vi.spyOn(terminalServiceModule, "getTerminalService").mockReturnValue(mockService as any);
|
||||||
|
|
||||||
@@ -4763,7 +4767,102 @@ describe("Terminal session routes", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(res.status).toBe(503);
|
expect(res.status).toBe(503);
|
||||||
expect(res.body.error).toContain("Max sessions");
|
expect(res.body.error).toBe("Maximum terminal sessions reached. Please close an existing terminal and try again.");
|
||||||
|
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 when shell is not allowed", async () => {
|
||||||
|
const mockService = {
|
||||||
|
createSession: vi.fn().mockResolvedValue({
|
||||||
|
success: false,
|
||||||
|
code: "invalid_shell",
|
||||||
|
error: "Shell not allowed. Please use a supported shell (bash, zsh, sh, cmd, powershell).",
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
vi.spyOn(terminalServiceModule, "getTerminalService").mockReturnValue(mockService as any);
|
||||||
|
|
||||||
|
const res = await REQUEST(
|
||||||
|
buildApp(),
|
||||||
|
"POST",
|
||||||
|
"/api/terminal/sessions",
|
||||||
|
JSON.stringify({}),
|
||||||
|
{ "Content-Type": "application/json" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toBe("Shell not allowed. Please use a supported shell (bash, zsh, sh, cmd, powershell).");
|
||||||
|
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 503 when PTY module fails to load", async () => {
|
||||||
|
const mockService = {
|
||||||
|
createSession: vi.fn().mockResolvedValue({
|
||||||
|
success: false,
|
||||||
|
code: "pty_load_failed",
|
||||||
|
error: "Terminal service unavailable. The PTY module could not be loaded.",
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
vi.spyOn(terminalServiceModule, "getTerminalService").mockReturnValue(mockService as any);
|
||||||
|
|
||||||
|
const res = await REQUEST(
|
||||||
|
buildApp(),
|
||||||
|
"POST",
|
||||||
|
"/api/terminal/sessions",
|
||||||
|
JSON.stringify({}),
|
||||||
|
{ "Content-Type": "application/json" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(503);
|
||||||
|
expect(res.body.error).toBe("Terminal service unavailable. The PTY module could not be loaded.");
|
||||||
|
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when PTY spawn fails", async () => {
|
||||||
|
const mockService = {
|
||||||
|
createSession: vi.fn().mockResolvedValue({
|
||||||
|
success: false,
|
||||||
|
code: "pty_spawn_failed",
|
||||||
|
error: "Failed to start terminal shell process.",
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
vi.spyOn(terminalServiceModule, "getTerminalService").mockReturnValue(mockService as any);
|
||||||
|
|
||||||
|
const res = await REQUEST(
|
||||||
|
buildApp(),
|
||||||
|
"POST",
|
||||||
|
"/api/terminal/sessions",
|
||||||
|
JSON.stringify({}),
|
||||||
|
{ "Content-Type": "application/json" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
expect(res.body.error).toBe("Failed to start terminal shell process.");
|
||||||
|
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 201 when session creation succeeds", async () => {
|
||||||
|
const mockService = {
|
||||||
|
createSession: vi.fn().mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
session: { id: "term-123", shell: "/bin/zsh", cwd: "/test" },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
vi.spyOn(terminalServiceModule, "getTerminalService").mockReturnValue(mockService as any);
|
||||||
|
|
||||||
|
const res = await REQUEST(
|
||||||
|
buildApp(),
|
||||||
|
"POST",
|
||||||
|
"/api/terminal/sessions",
|
||||||
|
JSON.stringify({}),
|
||||||
|
{ "Content-Type": "application/json" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body).toEqual({ sessionId: "term-123", shell: "/bin/zsh", cwd: "/test" });
|
||||||
|
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4221,21 +4221,28 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
|||||||
const { cwd, cols, rows } = req.body;
|
const { cwd, cols, rows } = req.body;
|
||||||
const terminalService = getTerminalService(store.getRootDir());
|
const terminalService = getTerminalService(store.getRootDir());
|
||||||
|
|
||||||
const session = await terminalService.createSession({
|
const result = await terminalService.createSession({
|
||||||
cwd,
|
cwd,
|
||||||
cols: typeof cols === "number" ? cols : undefined,
|
cols: typeof cols === "number" ? cols : undefined,
|
||||||
rows: typeof rows === "number" ? rows : undefined,
|
rows: typeof rows === "number" ? rows : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!session) {
|
if (!result.success) {
|
||||||
res.status(503).json({ error: "Failed to create session. Max sessions may be reached." });
|
const statusByCode = {
|
||||||
|
max_sessions: 503,
|
||||||
|
invalid_shell: 400,
|
||||||
|
pty_load_failed: 503,
|
||||||
|
pty_spawn_failed: 500,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
res.status(statusByCode[result.code]).json({ error: result.error });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(201).json({
|
res.status(201).json({
|
||||||
sessionId: session.id,
|
sessionId: result.session.id,
|
||||||
shell: session.shell,
|
shell: result.session.shell,
|
||||||
cwd: session.cwd,
|
cwd: result.session.cwd,
|
||||||
});
|
});
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
res.status(500).json({ error: err.message || "Failed to create terminal session" });
|
res.status(500).json({ error: err.message || "Failed to create terminal session" });
|
||||||
|
|||||||
@@ -47,37 +47,50 @@ describe("TerminalService", () => {
|
|||||||
|
|
||||||
describe("createSession", () => {
|
describe("createSession", () => {
|
||||||
it("creates session with detected shell", async () => {
|
it("creates session with detected shell", async () => {
|
||||||
const session = await service.createSession();
|
const result = await service.createSession();
|
||||||
|
|
||||||
expect(session).toBeTruthy();
|
expect(result.success).toBe(true);
|
||||||
expect(session?.id).toMatch(/^term-\d+-/);
|
if (!result.success) {
|
||||||
expect(session?.cwd).toBe(projectRoot);
|
throw new Error("Expected terminal session creation to succeed");
|
||||||
|
}
|
||||||
|
expect(result.session.id).toMatch(/^term-\d+-/);
|
||||||
|
expect(result.session.cwd).toBe(projectRoot);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns null when session limit reached", async () => {
|
it("returns max_sessions error when session limit reached", async () => {
|
||||||
const limitedService = new TerminalService(projectRoot, 1);
|
const limitedService = new TerminalService(projectRoot, 1);
|
||||||
|
|
||||||
const session1 = await limitedService.createSession();
|
const result1 = await limitedService.createSession();
|
||||||
expect(session1).toBeTruthy();
|
expect(result1.success).toBe(true);
|
||||||
|
|
||||||
const session2 = await limitedService.createSession();
|
const result2 = await limitedService.createSession();
|
||||||
expect(session2).toBeNull();
|
expect(result2).toEqual({
|
||||||
|
success: false,
|
||||||
|
code: "max_sessions",
|
||||||
|
error: "Maximum terminal sessions reached. Please close an existing terminal and try again.",
|
||||||
|
});
|
||||||
|
|
||||||
limitedService.cleanup();
|
limitedService.cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects shells not in allowlist", async () => {
|
it("rejects shells not in allowlist", async () => {
|
||||||
const session = await service.createSession({ shell: "/tmp/evil-shell" });
|
const result = await service.createSession({ shell: "/tmp/evil-shell" });
|
||||||
expect(session).toBeNull();
|
expect(result).toEqual({
|
||||||
|
success: false,
|
||||||
|
code: "invalid_shell",
|
||||||
|
error: "Shell not allowed. Please use a supported shell (bash, zsh, sh, cmd, powershell).",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("write", () => {
|
describe("write", () => {
|
||||||
it("sends data to PTY", async () => {
|
it("sends data to PTY", async () => {
|
||||||
const session = await service.createSession();
|
const createResult = await service.createSession();
|
||||||
expect(session).toBeTruthy();
|
expect(createResult.success).toBe(true);
|
||||||
|
if (!createResult.success) throw new Error("Expected terminal session creation to succeed");
|
||||||
|
const session = createResult.session;
|
||||||
|
|
||||||
const result = service.write(session!.id, "ls -la\n");
|
const result = service.write(session.id, "ls -la\n");
|
||||||
|
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
expect(mockPtyProcess.write).toHaveBeenCalledWith("ls -la\n");
|
expect(mockPtyProcess.write).toHaveBeenCalledWith("ls -la\n");
|
||||||
@@ -89,20 +102,24 @@ describe("TerminalService", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("rejects data with null bytes", async () => {
|
it("rejects data with null bytes", async () => {
|
||||||
const session = await service.createSession();
|
const createResult = await service.createSession();
|
||||||
expect(session).toBeTruthy();
|
expect(createResult.success).toBe(true);
|
||||||
|
if (!createResult.success) throw new Error("Expected terminal session creation to succeed");
|
||||||
|
const session = createResult.session;
|
||||||
|
|
||||||
const result = service.write(session!.id, "test\0malicious");
|
const result = service.write(session.id, "test\0malicious");
|
||||||
expect(result).toBe(false);
|
expect(result).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("resize", () => {
|
describe("resize", () => {
|
||||||
it("updates PTY dimensions", async () => {
|
it("updates PTY dimensions", async () => {
|
||||||
const session = await service.createSession();
|
const createResult = await service.createSession();
|
||||||
expect(session).toBeTruthy();
|
expect(createResult.success).toBe(true);
|
||||||
|
if (!createResult.success) throw new Error("Expected terminal session creation to succeed");
|
||||||
|
const session = createResult.session;
|
||||||
|
|
||||||
const result = service.resize(session!.id, 120, 40);
|
const result = service.resize(session.id, 120, 40);
|
||||||
|
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
expect(mockPtyProcess.resize).toHaveBeenCalledWith(120, 40);
|
expect(mockPtyProcess.resize).toHaveBeenCalledWith(120, 40);
|
||||||
@@ -116,10 +133,12 @@ describe("TerminalService", () => {
|
|||||||
|
|
||||||
describe("killSession", () => {
|
describe("killSession", () => {
|
||||||
it("terminates session", async () => {
|
it("terminates session", async () => {
|
||||||
const session = await service.createSession();
|
const createResult = await service.createSession();
|
||||||
expect(session).toBeTruthy();
|
expect(createResult.success).toBe(true);
|
||||||
|
if (!createResult.success) throw new Error("Expected terminal session creation to succeed");
|
||||||
|
const session = createResult.session;
|
||||||
|
|
||||||
const result = service.killSession(session!.id);
|
const result = service.killSession(session.id);
|
||||||
|
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
expect(mockPtyProcess.kill).toHaveBeenCalledWith("SIGTERM");
|
expect(mockPtyProcess.kill).toHaveBeenCalledWith("SIGTERM");
|
||||||
@@ -139,27 +158,36 @@ describe("TerminalService", () => {
|
|||||||
const session2 = await limitedService.createSession();
|
const session2 = await limitedService.createSession();
|
||||||
const session3 = await limitedService.createSession();
|
const session3 = await limitedService.createSession();
|
||||||
|
|
||||||
expect(session1).toBeTruthy();
|
expect(session1.success).toBe(true);
|
||||||
expect(session2).toBeTruthy();
|
expect(session2.success).toBe(true);
|
||||||
expect(session3).toBeNull();
|
expect(session3).toEqual({
|
||||||
|
success: false,
|
||||||
|
code: "max_sessions",
|
||||||
|
error: "Maximum terminal sessions reached. Please close an existing terminal and try again.",
|
||||||
|
});
|
||||||
|
|
||||||
limitedService.cleanup();
|
limitedService.cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lists active sessions", async () => {
|
it("lists active sessions", async () => {
|
||||||
const session1 = await service.createSession();
|
const result1 = await service.createSession();
|
||||||
const session2 = await service.createSession();
|
const result2 = await service.createSession();
|
||||||
|
expect(result1.success).toBe(true);
|
||||||
|
expect(result2.success).toBe(true);
|
||||||
|
if (!result1.success || !result2.success) throw new Error("Expected terminal session creation to succeed");
|
||||||
|
|
||||||
const sessions = service.getAllSessions();
|
const sessions = service.getAllSessions();
|
||||||
|
|
||||||
expect(sessions).toHaveLength(2);
|
expect(sessions).toHaveLength(2);
|
||||||
expect(sessions.some((s: { id: string }) => s.id === session1?.id)).toBe(true);
|
expect(sessions.some((s: { id: string }) => s.id === result1.session.id)).toBe(true);
|
||||||
expect(sessions.some((s: { id: string }) => s.id === session2?.id)).toBe(true);
|
expect(sessions.some((s: { id: string }) => s.id === result2.session.id)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("cleans up all sessions", async () => {
|
it("cleans up all sessions", async () => {
|
||||||
await service.createSession();
|
const result1 = await service.createSession();
|
||||||
await service.createSession();
|
const result2 = await service.createSession();
|
||||||
|
expect(result1.success).toBe(true);
|
||||||
|
expect(result2.success).toBe(true);
|
||||||
|
|
||||||
expect(service.getSessionCount()).toBe(2);
|
expect(service.getSessionCount()).toBe(2);
|
||||||
|
|
||||||
@@ -171,13 +199,15 @@ describe("TerminalService", () => {
|
|||||||
|
|
||||||
describe("scrollback buffer", () => {
|
describe("scrollback buffer", () => {
|
||||||
it("maintains scrollback buffer", async () => {
|
it("maintains scrollback buffer", async () => {
|
||||||
const session = await service.createSession();
|
const createResult = await service.createSession();
|
||||||
expect(session).toBeTruthy();
|
expect(createResult.success).toBe(true);
|
||||||
|
if (!createResult.success) throw new Error("Expected terminal session creation to succeed");
|
||||||
|
const session = createResult.session;
|
||||||
|
|
||||||
mockPtyProcess._onDataCallback?.("output line 1\n");
|
mockPtyProcess._onDataCallback?.("output line 1\n");
|
||||||
mockPtyProcess._onDataCallback?.("output line 2\n");
|
mockPtyProcess._onDataCallback?.("output line 2\n");
|
||||||
|
|
||||||
const scrollback = service.getScrollback(session!.id);
|
const scrollback = service.getScrollback(session.id);
|
||||||
|
|
||||||
expect(scrollback).toContain("output line 1");
|
expect(scrollback).toContain("output line 1");
|
||||||
expect(scrollback).toContain("output line 2");
|
expect(scrollback).toContain("output line 2");
|
||||||
@@ -194,25 +224,29 @@ describe("TerminalService", () => {
|
|||||||
const dataMock = vi.fn();
|
const dataMock = vi.fn();
|
||||||
service.onData(dataMock);
|
service.onData(dataMock);
|
||||||
|
|
||||||
const session = await service.createSession();
|
const createResult = await service.createSession();
|
||||||
expect(session).toBeTruthy();
|
expect(createResult.success).toBe(true);
|
||||||
|
if (!createResult.success) throw new Error("Expected terminal session creation to succeed");
|
||||||
|
const session = createResult.session;
|
||||||
|
|
||||||
mockPtyProcess._onDataCallback?.("test data");
|
mockPtyProcess._onDataCallback?.("test data");
|
||||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||||
|
|
||||||
expect(dataMock).toHaveBeenCalledWith(session!.id, "test data");
|
expect(dataMock).toHaveBeenCalledWith(session.id, "test data");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("emits exit events", async () => {
|
it("emits exit events", async () => {
|
||||||
const exitMock = vi.fn();
|
const exitMock = vi.fn();
|
||||||
service.onExit(exitMock);
|
service.onExit(exitMock);
|
||||||
|
|
||||||
const session = await service.createSession();
|
const createResult = await service.createSession();
|
||||||
expect(session).toBeTruthy();
|
expect(createResult.success).toBe(true);
|
||||||
|
if (!createResult.success) throw new Error("Expected terminal session creation to succeed");
|
||||||
|
const session = createResult.session;
|
||||||
|
|
||||||
mockPtyProcess._onExitCallback?.({ exitCode: 0 });
|
mockPtyProcess._onExitCallback?.({ exitCode: 0 });
|
||||||
|
|
||||||
expect(exitMock).toHaveBeenCalledWith(session!.id, 0);
|
expect(exitMock).toHaveBeenCalledWith(session.id, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows unsubscribing from events", async () => {
|
it("allows unsubscribing from events", async () => {
|
||||||
@@ -221,8 +255,8 @@ describe("TerminalService", () => {
|
|||||||
|
|
||||||
unsub();
|
unsub();
|
||||||
|
|
||||||
const session = await service.createSession();
|
const createResult = await service.createSession();
|
||||||
expect(session).toBeTruthy();
|
expect(createResult.success).toBe(true);
|
||||||
|
|
||||||
mockPtyProcess._onDataCallback?.("test");
|
mockPtyProcess._onDataCallback?.("test");
|
||||||
|
|
||||||
@@ -271,31 +305,35 @@ describe("TerminalService", () => {
|
|||||||
describe("activity tracking", () => {
|
describe("activity tracking", () => {
|
||||||
it("sets lastActivityAt on session creation", async () => {
|
it("sets lastActivityAt on session creation", async () => {
|
||||||
const before = new Date();
|
const before = new Date();
|
||||||
const session = await service.createSession();
|
const createResult = await service.createSession();
|
||||||
const after = new Date();
|
const after = new Date();
|
||||||
|
|
||||||
expect(session).toBeTruthy();
|
expect(createResult.success).toBe(true);
|
||||||
expect(session!.lastActivityAt.getTime()).toBeGreaterThanOrEqual(before.getTime());
|
if (!createResult.success) throw new Error("Expected terminal session creation to succeed");
|
||||||
expect(session!.lastActivityAt.getTime()).toBeLessThanOrEqual(after.getTime());
|
expect(createResult.session.lastActivityAt.getTime()).toBeGreaterThanOrEqual(before.getTime());
|
||||||
|
expect(createResult.session.lastActivityAt.getTime()).toBeLessThanOrEqual(after.getTime());
|
||||||
});
|
});
|
||||||
|
|
||||||
it("updates lastActivityAt on write", async () => {
|
it("updates lastActivityAt on write", async () => {
|
||||||
const session = await service.createSession();
|
const createResult = await service.createSession();
|
||||||
expect(session).toBeTruthy();
|
expect(createResult.success).toBe(true);
|
||||||
|
if (!createResult.success) throw new Error("Expected terminal session creation to succeed");
|
||||||
|
const session = createResult.session;
|
||||||
|
|
||||||
const initialActivity = session!.lastActivityAt.getTime();
|
const initialActivity = session.lastActivityAt.getTime();
|
||||||
|
|
||||||
// Small delay to ensure time difference
|
// Small delay to ensure time difference
|
||||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
|
||||||
service.write(session!.id, "hello");
|
service.write(session.id, "hello");
|
||||||
|
|
||||||
const updatedSession = service.getSession(session!.id);
|
const updatedSession = service.getSession(session.id);
|
||||||
expect(updatedSession!.lastActivityAt.getTime()).toBeGreaterThan(initialActivity);
|
expect(updatedSession!.lastActivityAt.getTime()).toBeGreaterThan(initialActivity);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("includes lastActivityAt in getAllSessions", async () => {
|
it("includes lastActivityAt in getAllSessions", async () => {
|
||||||
await service.createSession();
|
const createResult = await service.createSession();
|
||||||
|
expect(createResult.success).toBe(true);
|
||||||
const sessions = service.getAllSessions();
|
const sessions = service.getAllSessions();
|
||||||
|
|
||||||
expect(sessions).toHaveLength(1);
|
expect(sessions).toHaveLength(1);
|
||||||
@@ -305,37 +343,41 @@ describe("TerminalService", () => {
|
|||||||
|
|
||||||
describe("stale session detection", () => {
|
describe("stale session detection", () => {
|
||||||
it("returns empty array when no sessions are stale", async () => {
|
it("returns empty array when no sessions are stale", async () => {
|
||||||
await service.createSession();
|
const createResult = await service.createSession();
|
||||||
|
expect(createResult.success).toBe(true);
|
||||||
const stale = service.getStaleSessions(300_000);
|
const stale = service.getStaleSessions(300_000);
|
||||||
expect(stale).toHaveLength(0);
|
expect(stale).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns sessions older than threshold", async () => {
|
it("returns sessions older than threshold", async () => {
|
||||||
const session = await service.createSession();
|
const createResult = await service.createSession();
|
||||||
expect(session).toBeTruthy();
|
expect(createResult.success).toBe(true);
|
||||||
|
if (!createResult.success) throw new Error("Expected terminal session creation to succeed");
|
||||||
|
const session = createResult.session;
|
||||||
|
|
||||||
// Manually backdate the lastActivityAt
|
// Manually backdate the lastActivityAt
|
||||||
session!.lastActivityAt = new Date(Date.now() - 600_000); // 10 min ago
|
session.lastActivityAt = new Date(Date.now() - 600_000); // 10 min ago
|
||||||
|
|
||||||
const stale = service.getStaleSessions(300_000); // 5 min threshold
|
const stale = service.getStaleSessions(300_000); // 5 min threshold
|
||||||
expect(stale).toHaveLength(1);
|
expect(stale).toHaveLength(1);
|
||||||
expect(stale[0].id).toBe(session!.id);
|
expect(stale[0].id).toBe(session.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sorts stale sessions oldest first", async () => {
|
it("sorts stale sessions oldest first", async () => {
|
||||||
const session1 = await service.createSession();
|
const result1 = await service.createSession();
|
||||||
const session2 = await service.createSession();
|
const result2 = await service.createSession();
|
||||||
expect(session1).toBeTruthy();
|
expect(result1.success).toBe(true);
|
||||||
expect(session2).toBeTruthy();
|
expect(result2.success).toBe(true);
|
||||||
|
if (!result1.success || !result2.success) throw new Error("Expected terminal session creation to succeed");
|
||||||
|
|
||||||
// session1 is older (more stale)
|
// session1 is older (more stale)
|
||||||
session1!.lastActivityAt = new Date(Date.now() - 700_000);
|
result1.session.lastActivityAt = new Date(Date.now() - 700_000);
|
||||||
session2!.lastActivityAt = new Date(Date.now() - 600_000);
|
result2.session.lastActivityAt = new Date(Date.now() - 600_000);
|
||||||
|
|
||||||
const stale = service.getStaleSessions(300_000);
|
const stale = service.getStaleSessions(300_000);
|
||||||
expect(stale).toHaveLength(2);
|
expect(stale).toHaveLength(2);
|
||||||
expect(stale[0].id).toBe(session1!.id);
|
expect(stale[0].id).toBe(result1.session.id);
|
||||||
expect(stale[1].id).toBe(session2!.id);
|
expect(stale[1].id).toBe(result2.session.id);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -350,7 +392,10 @@ describe("TerminalService", () => {
|
|||||||
|
|
||||||
const sessions = [];
|
const sessions = [];
|
||||||
for (let i = 0; i < 5; i++) {
|
for (let i = 0; i < 5; i++) {
|
||||||
sessions.push(await svc.createSession());
|
const result = await svc.createSession();
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
if (!result.success) throw new Error("Expected terminal session creation to succeed");
|
||||||
|
sessions.push(result.session);
|
||||||
}
|
}
|
||||||
expect(svc.getSessionCount()).toBe(5);
|
expect(svc.getSessionCount()).toBe(5);
|
||||||
|
|
||||||
@@ -377,7 +422,10 @@ describe("TerminalService", () => {
|
|||||||
// Create 4 sessions (80% of 5)
|
// Create 4 sessions (80% of 5)
|
||||||
const sessions = [];
|
const sessions = [];
|
||||||
for (let i = 0; i < 4; i++) {
|
for (let i = 0; i < 4; i++) {
|
||||||
sessions.push(await svc.createSession());
|
const result = await svc.createSession();
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
if (!result.success) throw new Error("Expected terminal session creation to succeed");
|
||||||
|
sessions.push(result.session);
|
||||||
}
|
}
|
||||||
expect(svc.getSessionCount()).toBe(4);
|
expect(svc.getSessionCount()).toBe(4);
|
||||||
|
|
||||||
@@ -387,7 +435,7 @@ describe("TerminalService", () => {
|
|||||||
|
|
||||||
// Creating a new session should trigger eviction first
|
// Creating a new session should trigger eviction first
|
||||||
const newSession = await svc.createSession();
|
const newSession = await svc.createSession();
|
||||||
expect(newSession).toBeTruthy();
|
expect(newSession.success).toBe(true);
|
||||||
// Should have evicted stale sessions, then created a new one
|
// Should have evicted stale sessions, then created a new one
|
||||||
// After eviction, we target <= 4 (80%), evict oldest stale sessions
|
// After eviction, we target <= 4 (80%), evict oldest stale sessions
|
||||||
// Then create the new session
|
// Then create the new session
|
||||||
@@ -400,7 +448,8 @@ describe("TerminalService", () => {
|
|||||||
const svc = new TerminalService(projectRoot, 5);
|
const svc = new TerminalService(projectRoot, 5);
|
||||||
|
|
||||||
for (let i = 0; i < 5; i++) {
|
for (let i = 0; i < 5; i++) {
|
||||||
await svc.createSession();
|
const result = await svc.createSession();
|
||||||
|
expect(result.success).toBe(true);
|
||||||
}
|
}
|
||||||
// All sessions are fresh, no stale ones
|
// All sessions are fresh, no stale ones
|
||||||
const evicted = svc.evictStaleSessions(300_000);
|
const evicted = svc.evictStaleSessions(300_000);
|
||||||
|
|||||||
@@ -162,6 +162,16 @@ export interface TerminalOptions {
|
|||||||
env?: Record<string, string>;
|
env?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CreateSessionErrorCode =
|
||||||
|
| "max_sessions"
|
||||||
|
| "invalid_shell"
|
||||||
|
| "pty_load_failed"
|
||||||
|
| "pty_spawn_failed";
|
||||||
|
|
||||||
|
export type CreateSessionResult =
|
||||||
|
| { success: true; session: TerminalSession }
|
||||||
|
| { success: false; error: string; code: CreateSessionErrorCode };
|
||||||
|
|
||||||
type DataCallback = (sessionId: string, data: string) => void;
|
type DataCallback = (sessionId: string, data: string) => void;
|
||||||
type ExitCallback = (sessionId: string, exitCode: number) => void;
|
type ExitCallback = (sessionId: string, exitCode: number) => void;
|
||||||
|
|
||||||
@@ -388,7 +398,7 @@ export class TerminalService extends EventEmitter {
|
|||||||
/**
|
/**
|
||||||
* Create a new terminal session
|
* Create a new terminal session
|
||||||
*/
|
*/
|
||||||
async createSession(options: TerminalOptions = {}): Promise<TerminalSession | null> {
|
async createSession(options: TerminalOptions = {}): Promise<CreateSessionResult> {
|
||||||
// Auto-evict stale sessions when at 80% of limit
|
// Auto-evict stale sessions when at 80% of limit
|
||||||
if (this.sessions.size >= Math.floor(this.maxSessions * 0.8)) {
|
if (this.sessions.size >= Math.floor(this.maxSessions * 0.8)) {
|
||||||
this.evictStaleSessions();
|
this.evictStaleSessions();
|
||||||
@@ -397,7 +407,11 @@ export class TerminalService extends EventEmitter {
|
|||||||
// Check session limit
|
// Check session limit
|
||||||
if (this.sessions.size >= this.maxSessions) {
|
if (this.sessions.size >= this.maxSessions) {
|
||||||
console.error(`Max sessions (${this.maxSessions}) reached, refusing new session`);
|
console.error(`Max sessions (${this.maxSessions}) reached, refusing new session`);
|
||||||
return null;
|
return {
|
||||||
|
success: false,
|
||||||
|
code: "max_sessions",
|
||||||
|
error: "Maximum terminal sessions reached. Please close an existing terminal and try again.",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = `term-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
const id = `term-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
||||||
@@ -408,7 +422,11 @@ export class TerminalService extends EventEmitter {
|
|||||||
// Validate shell is allowed
|
// Validate shell is allowed
|
||||||
if (!this.isAllowedShell(shell)) {
|
if (!this.isAllowedShell(shell)) {
|
||||||
console.error(`Shell not allowed: ${shell}`);
|
console.error(`Shell not allowed: ${shell}`);
|
||||||
return null;
|
return {
|
||||||
|
success: false,
|
||||||
|
code: "invalid_shell",
|
||||||
|
error: "Shell not allowed. Please use a supported shell (bash, zsh, sh, cmd, powershell).",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate and resolve working directory
|
// Validate and resolve working directory
|
||||||
@@ -439,10 +457,12 @@ export class TerminalService extends EventEmitter {
|
|||||||
try {
|
try {
|
||||||
pty = await loadPtyModule();
|
pty = await loadPtyModule();
|
||||||
} catch (loadErr) {
|
} catch (loadErr) {
|
||||||
// Native module couldn't be loaded (common in Bun binaries without proper setup)
|
|
||||||
// Return null for graceful degradation - routes will return 503
|
|
||||||
console.error(`[terminal] Failed to load PTY module: ${loadErr}`);
|
console.error(`[terminal] Failed to load PTY module: ${loadErr}`);
|
||||||
return null;
|
return {
|
||||||
|
success: false,
|
||||||
|
code: "pty_load_failed",
|
||||||
|
error: "Terminal service unavailable. The PTY module could not be loaded.",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build PTY spawn options
|
// Build PTY spawn options
|
||||||
@@ -464,7 +484,11 @@ export class TerminalService extends EventEmitter {
|
|||||||
ptyProcess = pty.spawn(shell, shellArgs, ptyOptions);
|
ptyProcess = pty.spawn(shell, shellArgs, ptyOptions);
|
||||||
} catch (spawnError) {
|
} catch (spawnError) {
|
||||||
console.error(`[createSession] PTY spawn failed:`, spawnError);
|
console.error(`[createSession] PTY spawn failed:`, spawnError);
|
||||||
return null;
|
return {
|
||||||
|
success: false,
|
||||||
|
code: "pty_spawn_failed",
|
||||||
|
error: "Failed to start terminal shell process.",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const session: TerminalSession = {
|
const session: TerminalSession = {
|
||||||
@@ -530,7 +554,7 @@ export class TerminalService extends EventEmitter {
|
|||||||
});
|
});
|
||||||
|
|
||||||
console.info(`Session ${id} created successfully`);
|
console.info(`Session ${id} created successfully`);
|
||||||
return session;
|
return { success: true, session };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user