Compare commits
2 Commits
fix/securi
...
feat/ms19-
| Author | SHA1 | Date | |
|---|---|---|---|
| e41fedb3c2 | |||
| 5ba77d8952 |
@@ -0,0 +1,23 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "TerminalSessionStatus" AS ENUM ('ACTIVE', 'CLOSED');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "terminal_sessions" (
|
||||
"id" UUID NOT NULL,
|
||||
"workspace_id" UUID NOT NULL,
|
||||
"name" TEXT NOT NULL DEFAULT 'Terminal',
|
||||
"status" "TerminalSessionStatus" NOT NULL DEFAULT 'ACTIVE',
|
||||
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"closed_at" TIMESTAMPTZ,
|
||||
|
||||
CONSTRAINT "terminal_sessions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "terminal_sessions_workspace_id_idx" ON "terminal_sessions"("workspace_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "terminal_sessions_workspace_id_status_idx" ON "terminal_sessions"("workspace_id", "status");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "terminal_sessions" ADD CONSTRAINT "terminal_sessions_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -206,6 +206,11 @@ enum CredentialScope {
|
||||
SYSTEM
|
||||
}
|
||||
|
||||
enum TerminalSessionStatus {
|
||||
ACTIVE
|
||||
CLOSED
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// MODELS
|
||||
// ============================================
|
||||
@@ -297,6 +302,7 @@ model Workspace {
|
||||
federationEventSubscriptions FederationEventSubscription[]
|
||||
llmUsageLogs LlmUsageLog[]
|
||||
userCredentials UserCredential[]
|
||||
terminalSessions TerminalSession[]
|
||||
|
||||
@@index([ownerId])
|
||||
@@map("workspaces")
|
||||
@@ -1507,3 +1513,23 @@ model LlmUsageLog {
|
||||
@@index([conversationId])
|
||||
@@map("llm_usage_logs")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// TERMINAL MODULE
|
||||
// ============================================
|
||||
|
||||
model TerminalSession {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
workspaceId String @map("workspace_id") @db.Uuid
|
||||
name String @default("Terminal")
|
||||
status TerminalSessionStatus @default(ACTIVE)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
|
||||
closedAt DateTime? @map("closed_at") @db.Timestamptz
|
||||
|
||||
// Relations
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([workspaceId])
|
||||
@@index([workspaceId, status])
|
||||
@@map("terminal_sessions")
|
||||
}
|
||||
|
||||
53
apps/api/src/terminal/terminal-session.dto.ts
Normal file
53
apps/api/src/terminal/terminal-session.dto.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Terminal Session DTOs
|
||||
*
|
||||
* Data Transfer Objects for terminal session persistence endpoints.
|
||||
* Validated using class-validator decorators.
|
||||
*/
|
||||
|
||||
import { IsString, IsOptional, MaxLength, IsEnum, IsUUID } from "class-validator";
|
||||
import { TerminalSessionStatus } from "@prisma/client";
|
||||
|
||||
/**
|
||||
* DTO for creating a new terminal session record.
|
||||
*/
|
||||
export class CreateTerminalSessionDto {
|
||||
@IsString()
|
||||
@IsUUID()
|
||||
workspaceId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(128)
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for querying terminal sessions by workspace.
|
||||
*/
|
||||
export class FindTerminalSessionsByWorkspaceDto {
|
||||
@IsString()
|
||||
@IsUUID()
|
||||
workspaceId!: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response shape for a terminal session.
|
||||
*/
|
||||
export class TerminalSessionResponseDto {
|
||||
id!: string;
|
||||
workspaceId!: string;
|
||||
name!: string;
|
||||
status!: TerminalSessionStatus;
|
||||
createdAt!: Date;
|
||||
closedAt!: Date | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for filtering terminal sessions by status.
|
||||
*/
|
||||
export class TerminalSessionStatusFilterDto {
|
||||
@IsOptional()
|
||||
@IsEnum(TerminalSessionStatus)
|
||||
status?: TerminalSessionStatus;
|
||||
}
|
||||
229
apps/api/src/terminal/terminal-session.service.spec.ts
Normal file
229
apps/api/src/terminal/terminal-session.service.spec.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* TerminalSessionService Tests
|
||||
*
|
||||
* Unit tests for database-backed terminal session CRUD:
|
||||
* create, findByWorkspace, close, and findById.
|
||||
* PrismaService is mocked to isolate the service logic.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { NotFoundException } from "@nestjs/common";
|
||||
import { TerminalSessionStatus } from "@prisma/client";
|
||||
import type { TerminalSession } from "@prisma/client";
|
||||
import { TerminalSessionService } from "./terminal-session.service";
|
||||
|
||||
// ==========================================
|
||||
// Helpers
|
||||
// ==========================================
|
||||
|
||||
function makeSession(overrides: Partial<TerminalSession> = {}): TerminalSession {
|
||||
return {
|
||||
id: "session-uuid-1",
|
||||
workspaceId: "workspace-uuid-1",
|
||||
name: "Terminal",
|
||||
status: TerminalSessionStatus.ACTIVE,
|
||||
createdAt: new Date("2026-02-25T00:00:00Z"),
|
||||
closedAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Mock PrismaService
|
||||
// ==========================================
|
||||
|
||||
function makeMockPrisma() {
|
||||
return {
|
||||
terminalSession: {
|
||||
create: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Tests
|
||||
// ==========================================
|
||||
|
||||
describe("TerminalSessionService", () => {
|
||||
let service: TerminalSessionService;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let mockPrisma: any;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockPrisma = makeMockPrisma();
|
||||
service = new TerminalSessionService(mockPrisma);
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// create
|
||||
// ==========================================
|
||||
describe("create", () => {
|
||||
it("should call prisma.terminalSession.create with workspaceId only when no name provided", async () => {
|
||||
const session = makeSession();
|
||||
mockPrisma.terminalSession.create.mockResolvedValueOnce(session);
|
||||
|
||||
const result = await service.create("workspace-uuid-1");
|
||||
|
||||
expect(mockPrisma.terminalSession.create).toHaveBeenCalledWith({
|
||||
data: { workspaceId: "workspace-uuid-1" },
|
||||
});
|
||||
expect(result).toEqual(session);
|
||||
});
|
||||
|
||||
it("should include name in create data when name is provided", async () => {
|
||||
const session = makeSession({ name: "My Terminal" });
|
||||
mockPrisma.terminalSession.create.mockResolvedValueOnce(session);
|
||||
|
||||
const result = await service.create("workspace-uuid-1", "My Terminal");
|
||||
|
||||
expect(mockPrisma.terminalSession.create).toHaveBeenCalledWith({
|
||||
data: { workspaceId: "workspace-uuid-1", name: "My Terminal" },
|
||||
});
|
||||
expect(result).toEqual(session);
|
||||
});
|
||||
|
||||
it("should return the created session", async () => {
|
||||
const session = makeSession();
|
||||
mockPrisma.terminalSession.create.mockResolvedValueOnce(session);
|
||||
|
||||
const result = await service.create("workspace-uuid-1");
|
||||
|
||||
expect(result.id).toBe("session-uuid-1");
|
||||
expect(result.status).toBe(TerminalSessionStatus.ACTIVE);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// findByWorkspace
|
||||
// ==========================================
|
||||
describe("findByWorkspace", () => {
|
||||
it("should query for ACTIVE sessions in the given workspace, ordered by createdAt desc", async () => {
|
||||
const sessions = [makeSession(), makeSession({ id: "session-uuid-2" })];
|
||||
mockPrisma.terminalSession.findMany.mockResolvedValueOnce(sessions);
|
||||
|
||||
const result = await service.findByWorkspace("workspace-uuid-1");
|
||||
|
||||
expect(mockPrisma.terminalSession.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
workspaceId: "workspace-uuid-1",
|
||||
status: TerminalSessionStatus.ACTIVE,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should return an empty array when no active sessions exist", async () => {
|
||||
mockPrisma.terminalSession.findMany.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await service.findByWorkspace("workspace-uuid-empty");
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should not include CLOSED sessions", async () => {
|
||||
// The where clause enforces ACTIVE status — verify it is present
|
||||
mockPrisma.terminalSession.findMany.mockResolvedValueOnce([]);
|
||||
|
||||
await service.findByWorkspace("workspace-uuid-1");
|
||||
|
||||
const callArgs = mockPrisma.terminalSession.findMany.mock.calls[0][0] as {
|
||||
where: { status: TerminalSessionStatus };
|
||||
};
|
||||
expect(callArgs.where.status).toBe(TerminalSessionStatus.ACTIVE);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// close
|
||||
// ==========================================
|
||||
describe("close", () => {
|
||||
it("should set status to CLOSED and set closedAt when session exists", async () => {
|
||||
const existingSession = makeSession();
|
||||
const closedSession = makeSession({
|
||||
status: TerminalSessionStatus.CLOSED,
|
||||
closedAt: new Date("2026-02-25T01:00:00Z"),
|
||||
});
|
||||
|
||||
mockPrisma.terminalSession.findUnique.mockResolvedValueOnce(existingSession);
|
||||
mockPrisma.terminalSession.update.mockResolvedValueOnce(closedSession);
|
||||
|
||||
const result = await service.close("session-uuid-1");
|
||||
|
||||
expect(mockPrisma.terminalSession.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: "session-uuid-1" },
|
||||
});
|
||||
expect(mockPrisma.terminalSession.update).toHaveBeenCalledWith({
|
||||
where: { id: "session-uuid-1" },
|
||||
data: {
|
||||
status: TerminalSessionStatus.CLOSED,
|
||||
closedAt: expect.any(Date),
|
||||
},
|
||||
});
|
||||
expect(result.status).toBe(TerminalSessionStatus.CLOSED);
|
||||
});
|
||||
|
||||
it("should throw NotFoundException when session does not exist", async () => {
|
||||
mockPrisma.terminalSession.findUnique.mockResolvedValueOnce(null);
|
||||
|
||||
await expect(service.close("nonexistent-id")).rejects.toThrow(NotFoundException);
|
||||
expect(mockPrisma.terminalSession.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should include a non-null closedAt timestamp on close", async () => {
|
||||
const existingSession = makeSession();
|
||||
const closedSession = makeSession({
|
||||
status: TerminalSessionStatus.CLOSED,
|
||||
closedAt: new Date(),
|
||||
});
|
||||
|
||||
mockPrisma.terminalSession.findUnique.mockResolvedValueOnce(existingSession);
|
||||
mockPrisma.terminalSession.update.mockResolvedValueOnce(closedSession);
|
||||
|
||||
const result = await service.close("session-uuid-1");
|
||||
|
||||
expect(result.closedAt).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// findById
|
||||
// ==========================================
|
||||
describe("findById", () => {
|
||||
it("should return the session when it exists", async () => {
|
||||
const session = makeSession();
|
||||
mockPrisma.terminalSession.findUnique.mockResolvedValueOnce(session);
|
||||
|
||||
const result = await service.findById("session-uuid-1");
|
||||
|
||||
expect(mockPrisma.terminalSession.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: "session-uuid-1" },
|
||||
});
|
||||
expect(result).toEqual(session);
|
||||
});
|
||||
|
||||
it("should return null when session does not exist", async () => {
|
||||
mockPrisma.terminalSession.findUnique.mockResolvedValueOnce(null);
|
||||
|
||||
const result = await service.findById("no-such-id");
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should find CLOSED sessions as well as ACTIVE ones", async () => {
|
||||
const closedSession = makeSession({
|
||||
status: TerminalSessionStatus.CLOSED,
|
||||
closedAt: new Date(),
|
||||
});
|
||||
mockPrisma.terminalSession.findUnique.mockResolvedValueOnce(closedSession);
|
||||
|
||||
const result = await service.findById("session-uuid-1");
|
||||
|
||||
expect(result?.status).toBe(TerminalSessionStatus.CLOSED);
|
||||
});
|
||||
});
|
||||
});
|
||||
96
apps/api/src/terminal/terminal-session.service.ts
Normal file
96
apps/api/src/terminal/terminal-session.service.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* TerminalSessionService
|
||||
*
|
||||
* Manages database persistence for terminal sessions.
|
||||
* Provides CRUD operations on the TerminalSession model,
|
||||
* enabling session tracking, recovery, and workspace-level listing.
|
||||
*
|
||||
* Session lifecycle:
|
||||
* - create: record a new terminal session with ACTIVE status
|
||||
* - findByWorkspace: return all ACTIVE sessions for a workspace
|
||||
* - close: mark a session as CLOSED, set closedAt timestamp
|
||||
* - findById: retrieve a single session by ID
|
||||
*/
|
||||
|
||||
import { Injectable, NotFoundException, Logger } from "@nestjs/common";
|
||||
import { TerminalSessionStatus } from "@prisma/client";
|
||||
import type { TerminalSession } from "@prisma/client";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
|
||||
@Injectable()
|
||||
export class TerminalSessionService {
|
||||
private readonly logger = new Logger(TerminalSessionService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* Create a new terminal session record in the database.
|
||||
*
|
||||
* @param workspaceId - The workspace this session belongs to
|
||||
* @param name - Optional display name for the session (defaults to "Terminal")
|
||||
* @returns The created TerminalSession record
|
||||
*/
|
||||
async create(workspaceId: string, name?: string): Promise<TerminalSession> {
|
||||
this.logger.log(
|
||||
`Creating terminal session for workspace ${workspaceId}${name !== undefined ? ` (name: ${name})` : ""}`
|
||||
);
|
||||
|
||||
const data: { workspaceId: string; name?: string } = { workspaceId };
|
||||
if (name !== undefined) {
|
||||
data.name = name;
|
||||
}
|
||||
|
||||
return this.prisma.terminalSession.create({ data });
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all ACTIVE terminal sessions for a workspace.
|
||||
*
|
||||
* @param workspaceId - The workspace to query
|
||||
* @returns Array of active TerminalSession records, ordered by creation time (newest first)
|
||||
*/
|
||||
async findByWorkspace(workspaceId: string): Promise<TerminalSession[]> {
|
||||
return this.prisma.terminalSession.findMany({
|
||||
where: {
|
||||
workspaceId,
|
||||
status: TerminalSessionStatus.ACTIVE,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Close a terminal session by setting its status to CLOSED and recording closedAt.
|
||||
*
|
||||
* @param id - The session ID to close
|
||||
* @returns The updated TerminalSession record
|
||||
* @throws NotFoundException if the session does not exist
|
||||
*/
|
||||
async close(id: string): Promise<TerminalSession> {
|
||||
const existing = await this.prisma.terminalSession.findUnique({ where: { id } });
|
||||
|
||||
if (!existing) {
|
||||
throw new NotFoundException(`Terminal session ${id} not found`);
|
||||
}
|
||||
|
||||
this.logger.log(`Closing terminal session ${id} (workspace: ${existing.workspaceId})`);
|
||||
|
||||
return this.prisma.terminalSession.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: TerminalSessionStatus.CLOSED,
|
||||
closedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a terminal session by ID.
|
||||
*
|
||||
* @param id - The session ID to retrieve
|
||||
* @returns The TerminalSession record, or null if not found
|
||||
*/
|
||||
async findById(id: string): Promise<TerminalSession | null> {
|
||||
return this.prisma.terminalSession.findUnique({ where: { id } });
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,11 @@
|
||||
*
|
||||
* Imports:
|
||||
* - AuthModule for WebSocket authentication (verifySession)
|
||||
* - PrismaModule for workspace membership queries
|
||||
* - PrismaModule for workspace membership queries and session persistence
|
||||
*
|
||||
* Providers:
|
||||
* - TerminalService: manages PTY session lifecycle
|
||||
* - TerminalService: manages PTY session lifecycle (in-memory)
|
||||
* - TerminalSessionService: persists session records to the database
|
||||
* - TerminalGateway: WebSocket gateway on /terminal namespace
|
||||
*
|
||||
* The module does not export providers; terminal sessions are
|
||||
@@ -18,11 +19,13 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TerminalGateway } from "./terminal.gateway";
|
||||
import { TerminalService } from "./terminal.service";
|
||||
import { TerminalSessionService } from "./terminal-session.service";
|
||||
import { AuthModule } from "../auth/auth.module";
|
||||
import { PrismaModule } from "../prisma/prisma.module";
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, PrismaModule],
|
||||
providers: [TerminalGateway, TerminalService],
|
||||
providers: [TerminalGateway, TerminalService, TerminalSessionService],
|
||||
exports: [TerminalSessionService],
|
||||
})
|
||||
export class TerminalModule {}
|
||||
|
||||
@@ -294,7 +294,7 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
|
||||
const response = await sendChatMessage(request);
|
||||
|
||||
const assistantMessage: Message = {
|
||||
id: `assistant-${Date.now().toString()}`,
|
||||
id: `assistant-${Date.now().toString()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
role: "assistant",
|
||||
content: response.message.content,
|
||||
createdAt: new Date().toISOString(),
|
||||
@@ -328,7 +328,7 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
|
||||
});
|
||||
|
||||
const errorMessage: Message = {
|
||||
id: `error-${String(Date.now())}`,
|
||||
id: `error-${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
role: "assistant",
|
||||
content: "Something went wrong. Please try again.",
|
||||
createdAt: new Date().toISOString(),
|
||||
|
||||
Reference in New Issue
Block a user