From 8128eb7fbef82f202f322f56f595c11e2ce7824a Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 26 Feb 2026 02:49:32 +0000 Subject: [PATCH] feat(api): add terminal session persistence with Prisma model and CRUD (#517) Co-authored-by: Jason Woltje Co-committed-by: Jason Woltje --- .../migration.sql | 23 ++ apps/api/prisma/schema.prisma | 26 ++ apps/api/src/terminal/terminal-session.dto.ts | 53 ++++ .../terminal/terminal-session.service.spec.ts | 229 ++++++++++++++++++ .../src/terminal/terminal-session.service.ts | 96 ++++++++ apps/api/src/terminal/terminal.module.ts | 9 +- 6 files changed, 433 insertions(+), 3 deletions(-) create mode 100644 apps/api/prisma/migrations/20260225000000_add_terminal_sessions/migration.sql create mode 100644 apps/api/src/terminal/terminal-session.dto.ts create mode 100644 apps/api/src/terminal/terminal-session.service.spec.ts create mode 100644 apps/api/src/terminal/terminal-session.service.ts diff --git a/apps/api/prisma/migrations/20260225000000_add_terminal_sessions/migration.sql b/apps/api/prisma/migrations/20260225000000_add_terminal_sessions/migration.sql new file mode 100644 index 0000000..6576328 --- /dev/null +++ b/apps/api/prisma/migrations/20260225000000_add_terminal_sessions/migration.sql @@ -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; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index c562279..833e0c6 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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") +} diff --git a/apps/api/src/terminal/terminal-session.dto.ts b/apps/api/src/terminal/terminal-session.dto.ts new file mode 100644 index 0000000..957a868 --- /dev/null +++ b/apps/api/src/terminal/terminal-session.dto.ts @@ -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; +} diff --git a/apps/api/src/terminal/terminal-session.service.spec.ts b/apps/api/src/terminal/terminal-session.service.spec.ts new file mode 100644 index 0000000..f364eeb --- /dev/null +++ b/apps/api/src/terminal/terminal-session.service.spec.ts @@ -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 { + 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); + }); + }); +}); diff --git a/apps/api/src/terminal/terminal-session.service.ts b/apps/api/src/terminal/terminal-session.service.ts new file mode 100644 index 0000000..2f03b9c --- /dev/null +++ b/apps/api/src/terminal/terminal-session.service.ts @@ -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 { + 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 { + 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 { + 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 { + return this.prisma.terminalSession.findUnique({ where: { id } }); + } +} diff --git a/apps/api/src/terminal/terminal.module.ts b/apps/api/src/terminal/terminal.module.ts index 8ddb34c..74099ae 100644 --- a/apps/api/src/terminal/terminal.module.ts +++ b/apps/api/src/terminal/terminal.module.ts @@ -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 {}