feat(api): add terminal session persistence with Prisma model and CRUD service
Some checks failed
ci/woodpecker/push/api Pipeline failed

Adds database-backed TerminalSession model with ACTIVE/CLOSED status enum,
migration SQL, TerminalSessionService (create/findByWorkspace/close/findById),
DTO file with class-validator decorators, unit tests (12 tests), and module
registration. Workspace relation and indexed columns enable efficient session
listing and recovery.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-25 20:46:58 -06:00
parent 5ba77d8952
commit e41fedb3c2
6 changed files with 433 additions and 3 deletions

View File

@@ -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;

View File

@@ -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")
}

View 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;
}

View 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);
});
});
});

View 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 } });
}
}

View File

@@ -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 {}