diff --git a/apps/api/prisma/migrations/20260129232349_add_agent_task_model/migration.sql b/apps/api/prisma/migrations/20260129232349_add_agent_task_model/migration.sql new file mode 100644 index 0000000..e6866ab --- /dev/null +++ b/apps/api/prisma/migrations/20260129232349_add_agent_task_model/migration.sql @@ -0,0 +1,47 @@ +-- CreateEnum +CREATE TYPE "AgentTaskStatus" AS ENUM ('PENDING', 'RUNNING', 'COMPLETED', 'FAILED'); + +-- CreateEnum +CREATE TYPE "AgentTaskPriority" AS ENUM ('LOW', 'MEDIUM', 'HIGH'); + +-- CreateTable +CREATE TABLE "agent_tasks" ( + "id" UUID NOT NULL, + "workspace_id" UUID NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT, + "status" "AgentTaskStatus" NOT NULL DEFAULT 'PENDING', + "priority" "AgentTaskPriority" NOT NULL DEFAULT 'MEDIUM', + "agent_type" TEXT NOT NULL, + "agent_config" JSONB NOT NULL DEFAULT '{}', + "result" JSONB, + "error" TEXT, + "created_by_id" UUID NOT NULL, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL, + "started_at" TIMESTAMPTZ, + "completed_at" TIMESTAMPTZ, + + CONSTRAINT "agent_tasks_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "agent_tasks_workspace_id_idx" ON "agent_tasks"("workspace_id"); + +-- CreateIndex +CREATE INDEX "agent_tasks_workspace_id_status_idx" ON "agent_tasks"("workspace_id", "status"); + +-- CreateIndex +CREATE INDEX "agent_tasks_workspace_id_priority_idx" ON "agent_tasks"("workspace_id", "priority"); + +-- CreateIndex +CREATE INDEX "agent_tasks_created_by_id_idx" ON "agent_tasks"("created_by_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "agent_tasks_id_workspace_id_key" ON "agent_tasks"("id", "workspace_id"); + +-- AddForeignKey +ALTER TABLE "agent_tasks" ADD CONSTRAINT "agent_tasks_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "agent_tasks" ADD CONSTRAINT "agent_tasks_created_by_id_fkey" FOREIGN KEY ("created_by_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 5e3dd71..b4051fc 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -102,6 +102,19 @@ enum AgentStatus { TERMINATED } +enum AgentTaskStatus { + PENDING + RUNNING + COMPLETED + FAILED +} + +enum AgentTaskPriority { + LOW + MEDIUM + HIGH +} + enum EntryStatus { DRAFT PUBLISHED @@ -145,6 +158,7 @@ model User { agentSessions AgentSession[] userLayouts UserLayout[] userPreference UserPreference? + createdAgentTasks AgentTask[] @relation("AgentTaskCreator") @@map("users") } @@ -188,6 +202,7 @@ model Workspace { knowledgeEntries KnowledgeEntry[] knowledgeTags KnowledgeTag[] cronSchedules CronSchedule[] + agentTasks AgentTask[] @@index([ownerId]) @@map("workspaces") @@ -573,6 +588,45 @@ model AgentSession { @@map("agent_sessions") } +model AgentTask { + id String @id @default(uuid()) @db.Uuid + workspaceId String @map("workspace_id") @db.Uuid + + // Core fields + title String + description String? @db.Text + + // Status and priority + status AgentTaskStatus @default(PENDING) + priority AgentTaskPriority @default(MEDIUM) + + // Agent configuration + agentType String @map("agent_type") + agentConfig Json @default("{}") @map("agent_config") + + // Results + result Json? + error String? @db.Text + + // Audit + createdById String @map("created_by_id") @db.Uuid + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz + startedAt DateTime? @map("started_at") @db.Timestamptz + completedAt DateTime? @map("completed_at") @db.Timestamptz + + // Relations + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + createdBy User @relation("AgentTaskCreator", fields: [createdById], references: [id], onDelete: Cascade) + + @@unique([id, workspaceId]) + @@index([workspaceId]) + @@index([workspaceId, status]) + @@index([workspaceId, priority]) + @@index([createdById]) + @@map("agent_tasks") +} + model WidgetDefinition { id String @id @default(uuid()) @db.Uuid diff --git a/apps/api/src/agent-tasks/agent-tasks.controller.spec.ts b/apps/api/src/agent-tasks/agent-tasks.controller.spec.ts new file mode 100644 index 0000000..4be9a1f --- /dev/null +++ b/apps/api/src/agent-tasks/agent-tasks.controller.spec.ts @@ -0,0 +1,250 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { AgentTasksController } from "./agent-tasks.controller"; +import { AgentTasksService } from "./agent-tasks.service"; +import { AgentTaskStatus, AgentTaskPriority } from "@prisma/client"; +import { AuthGuard } from "../auth/guards/auth.guard"; +import { WorkspaceGuard, PermissionGuard } from "../common/guards"; +import { ExecutionContext } from "@nestjs/common"; +import { describe, it, expect, beforeEach, vi } from "vitest"; + +describe("AgentTasksController", () => { + let controller: AgentTasksController; + let service: AgentTasksService; + + const mockAgentTasksService = { + create: vi.fn(), + findAll: vi.fn(), + findOne: vi.fn(), + update: vi.fn(), + remove: vi.fn(), + }; + + const mockAuthGuard = { + canActivate: vi.fn(() => true), + }; + + const mockWorkspaceGuard = { + canActivate: vi.fn(() => true), + }; + + const mockPermissionGuard = { + canActivate: vi.fn(() => true), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [AgentTasksController], + providers: [ + { + provide: AgentTasksService, + useValue: mockAgentTasksService, + }, + ], + }) + .overrideGuard(AuthGuard) + .useValue(mockAuthGuard) + .overrideGuard(WorkspaceGuard) + .useValue(mockWorkspaceGuard) + .overrideGuard(PermissionGuard) + .useValue(mockPermissionGuard) + .compile(); + + controller = module.get(AgentTasksController); + service = module.get(AgentTasksService); + + // Reset mocks + vi.clearAllMocks(); + }); + + describe("create", () => { + it("should create a new agent task", async () => { + const workspaceId = "workspace-1"; + const user = { id: "user-1", email: "test@example.com" }; + const createDto = { + title: "Test Task", + description: "Test Description", + agentType: "test-agent", + }; + + const mockTask = { + id: "task-1", + ...createDto, + workspaceId, + status: AgentTaskStatus.PENDING, + priority: AgentTaskPriority.MEDIUM, + agentConfig: {}, + result: null, + error: null, + createdById: user.id, + createdAt: new Date(), + updatedAt: new Date(), + startedAt: null, + completedAt: null, + }; + + mockAgentTasksService.create.mockResolvedValue(mockTask); + + const result = await controller.create(createDto, workspaceId, user); + + expect(mockAgentTasksService.create).toHaveBeenCalledWith( + workspaceId, + user.id, + createDto + ); + expect(result).toEqual(mockTask); + }); + }); + + describe("findAll", () => { + it("should return paginated agent tasks", async () => { + const workspaceId = "workspace-1"; + const query = { + page: 1, + limit: 10, + }; + + const mockResponse = { + data: [ + { id: "task-1", title: "Task 1" }, + { id: "task-2", title: "Task 2" }, + ], + meta: { + total: 2, + page: 1, + limit: 10, + totalPages: 1, + }, + }; + + mockAgentTasksService.findAll.mockResolvedValue(mockResponse); + + const result = await controller.findAll(query, workspaceId); + + expect(mockAgentTasksService.findAll).toHaveBeenCalledWith({ + ...query, + workspaceId, + }); + expect(result).toEqual(mockResponse); + }); + + it("should apply filters when provided", async () => { + const workspaceId = "workspace-1"; + const query = { + status: AgentTaskStatus.PENDING, + priority: AgentTaskPriority.HIGH, + agentType: "test-agent", + }; + + const mockResponse = { + data: [], + meta: { + total: 0, + page: 1, + limit: 50, + totalPages: 0, + }, + }; + + mockAgentTasksService.findAll.mockResolvedValue(mockResponse); + + const result = await controller.findAll(query, workspaceId); + + expect(mockAgentTasksService.findAll).toHaveBeenCalledWith({ + ...query, + workspaceId, + }); + expect(result).toEqual(mockResponse); + }); + }); + + describe("findOne", () => { + it("should return a single agent task", async () => { + const id = "task-1"; + const workspaceId = "workspace-1"; + + const mockTask = { + id, + title: "Task 1", + workspaceId, + status: AgentTaskStatus.PENDING, + priority: AgentTaskPriority.MEDIUM, + agentType: "test-agent", + agentConfig: {}, + result: null, + error: null, + createdById: "user-1", + createdAt: new Date(), + updatedAt: new Date(), + startedAt: null, + completedAt: null, + }; + + mockAgentTasksService.findOne.mockResolvedValue(mockTask); + + const result = await controller.findOne(id, workspaceId); + + expect(mockAgentTasksService.findOne).toHaveBeenCalledWith( + id, + workspaceId + ); + expect(result).toEqual(mockTask); + }); + }); + + describe("update", () => { + it("should update an agent task", async () => { + const id = "task-1"; + const workspaceId = "workspace-1"; + const updateDto = { + title: "Updated Task", + status: AgentTaskStatus.RUNNING, + }; + + const mockTask = { + id, + ...updateDto, + workspaceId, + priority: AgentTaskPriority.MEDIUM, + agentType: "test-agent", + agentConfig: {}, + result: null, + error: null, + createdById: "user-1", + createdAt: new Date(), + updatedAt: new Date(), + startedAt: new Date(), + completedAt: null, + }; + + mockAgentTasksService.update.mockResolvedValue(mockTask); + + const result = await controller.update(id, updateDto, workspaceId); + + expect(mockAgentTasksService.update).toHaveBeenCalledWith( + id, + workspaceId, + updateDto + ); + expect(result).toEqual(mockTask); + }); + }); + + describe("remove", () => { + it("should delete an agent task", async () => { + const id = "task-1"; + const workspaceId = "workspace-1"; + + const mockResponse = { message: "Agent task deleted successfully" }; + + mockAgentTasksService.remove.mockResolvedValue(mockResponse); + + const result = await controller.remove(id, workspaceId); + + expect(mockAgentTasksService.remove).toHaveBeenCalledWith( + id, + workspaceId + ); + expect(result).toEqual(mockResponse); + }); + }); +}); diff --git a/apps/api/src/agent-tasks/agent-tasks.controller.ts b/apps/api/src/agent-tasks/agent-tasks.controller.ts new file mode 100644 index 0000000..f5ed62c --- /dev/null +++ b/apps/api/src/agent-tasks/agent-tasks.controller.ts @@ -0,0 +1,107 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Body, + Param, + Query, + UseGuards, +} from "@nestjs/common"; +import { AgentTasksService } from "./agent-tasks.service"; +import { + CreateAgentTaskDto, + UpdateAgentTaskDto, + QueryAgentTasksDto, +} from "./dto"; +import { AuthGuard } from "../auth/guards/auth.guard"; +import { WorkspaceGuard, PermissionGuard } from "../common/guards"; +import { Workspace, Permission, RequirePermission } from "../common/decorators"; +import { CurrentUser } from "../auth/decorators/current-user.decorator"; +import type { AuthUser } from "../auth/types/better-auth-request.interface"; + +/** + * Controller for agent task endpoints + * All endpoints require authentication and workspace context + * + * Guards are applied in order: + * 1. AuthGuard - Verifies user authentication + * 2. WorkspaceGuard - Validates workspace access and sets RLS context + * 3. PermissionGuard - Checks role-based permissions + */ +@Controller("agent-tasks") +@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard) +export class AgentTasksController { + constructor(private readonly agentTasksService: AgentTasksService) {} + + /** + * POST /api/agent-tasks + * Create a new agent task + * Requires: MEMBER role or higher + */ + @Post() + @RequirePermission(Permission.WORKSPACE_MEMBER) + async create( + @Body() createAgentTaskDto: CreateAgentTaskDto, + @Workspace() workspaceId: string, + @CurrentUser() user: AuthUser + ) { + return this.agentTasksService.create( + workspaceId, + user.id, + createAgentTaskDto + ); + } + + /** + * GET /api/agent-tasks + * Get paginated agent tasks with optional filters + * Requires: Any workspace member (including GUEST) + */ + @Get() + @RequirePermission(Permission.WORKSPACE_ANY) + async findAll( + @Query() query: QueryAgentTasksDto, + @Workspace() workspaceId: string + ) { + return this.agentTasksService.findAll({ ...query, workspaceId }); + } + + /** + * GET /api/agent-tasks/:id + * Get a single agent task by ID + * Requires: Any workspace member + */ + @Get(":id") + @RequirePermission(Permission.WORKSPACE_ANY) + async findOne(@Param("id") id: string, @Workspace() workspaceId: string) { + return this.agentTasksService.findOne(id, workspaceId); + } + + /** + * PATCH /api/agent-tasks/:id + * Update an agent task + * Requires: MEMBER role or higher + */ + @Patch(":id") + @RequirePermission(Permission.WORKSPACE_MEMBER) + async update( + @Param("id") id: string, + @Body() updateAgentTaskDto: UpdateAgentTaskDto, + @Workspace() workspaceId: string + ) { + return this.agentTasksService.update(id, workspaceId, updateAgentTaskDto); + } + + /** + * DELETE /api/agent-tasks/:id + * Delete an agent task + * Requires: ADMIN role or higher + */ + @Delete(":id") + @RequirePermission(Permission.WORKSPACE_ADMIN) + async remove(@Param("id") id: string, @Workspace() workspaceId: string) { + return this.agentTasksService.remove(id, workspaceId); + } +} diff --git a/apps/api/src/agent-tasks/agent-tasks.module.ts b/apps/api/src/agent-tasks/agent-tasks.module.ts new file mode 100644 index 0000000..fc80e28 --- /dev/null +++ b/apps/api/src/agent-tasks/agent-tasks.module.ts @@ -0,0 +1,13 @@ +import { Module } from "@nestjs/common"; +import { AgentTasksController } from "./agent-tasks.controller"; +import { AgentTasksService } from "./agent-tasks.service"; +import { PrismaModule } from "../prisma/prisma.module"; +import { AuthModule } from "../auth/auth.module"; + +@Module({ + imports: [PrismaModule, AuthModule], + controllers: [AgentTasksController], + providers: [AgentTasksService], + exports: [AgentTasksService], +}) +export class AgentTasksModule {} diff --git a/apps/api/src/agent-tasks/agent-tasks.service.spec.ts b/apps/api/src/agent-tasks/agent-tasks.service.spec.ts new file mode 100644 index 0000000..11ab642 --- /dev/null +++ b/apps/api/src/agent-tasks/agent-tasks.service.spec.ts @@ -0,0 +1,353 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { AgentTasksService } from "./agent-tasks.service"; +import { PrismaService } from "../prisma/prisma.service"; +import { AgentTaskStatus, AgentTaskPriority } from "@prisma/client"; +import { NotFoundException } from "@nestjs/common"; +import { describe, it, expect, beforeEach, vi } from "vitest"; + +describe("AgentTasksService", () => { + let service: AgentTasksService; + let prisma: PrismaService; + + const mockPrismaService = { + agentTask: { + create: vi.fn(), + findMany: vi.fn(), + findUnique: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + count: vi.fn(), + }, + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AgentTasksService, + { + provide: PrismaService, + useValue: mockPrismaService, + }, + ], + }).compile(); + + service = module.get(AgentTasksService); + prisma = module.get(PrismaService); + + // Reset mocks + vi.clearAllMocks(); + }); + + describe("create", () => { + it("should create a new agent task with default values", async () => { + const workspaceId = "workspace-1"; + const userId = "user-1"; + const createDto = { + title: "Test Task", + description: "Test Description", + agentType: "test-agent", + }; + + const mockTask = { + id: "task-1", + workspaceId, + title: "Test Task", + description: "Test Description", + status: AgentTaskStatus.PENDING, + priority: AgentTaskPriority.MEDIUM, + agentType: "test-agent", + agentConfig: {}, + result: null, + error: null, + createdById: userId, + createdAt: new Date(), + updatedAt: new Date(), + startedAt: null, + completedAt: null, + createdBy: { + id: userId, + name: "Test User", + email: "test@example.com", + }, + }; + + mockPrismaService.agentTask.create.mockResolvedValue(mockTask); + + const result = await service.create(workspaceId, userId, createDto); + + expect(mockPrismaService.agentTask.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + title: "Test Task", + description: "Test Description", + agentType: "test-agent", + workspaceId, + createdById: userId, + status: AgentTaskStatus.PENDING, + priority: AgentTaskPriority.MEDIUM, + agentConfig: {}, + }), + include: { + createdBy: { + select: { id: true, name: true, email: true }, + }, + }, + }); + + expect(result).toEqual(mockTask); + }); + + it("should set startedAt when status is RUNNING", async () => { + const workspaceId = "workspace-1"; + const userId = "user-1"; + const createDto = { + title: "Running Task", + agentType: "test-agent", + status: AgentTaskStatus.RUNNING, + }; + + mockPrismaService.agentTask.create.mockResolvedValue({ + id: "task-1", + startedAt: expect.any(Date), + }); + + await service.create(workspaceId, userId, createDto); + + expect(mockPrismaService.agentTask.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + startedAt: expect.any(Date), + }), + }) + ); + }); + + it("should set completedAt when status is COMPLETED", async () => { + const workspaceId = "workspace-1"; + const userId = "user-1"; + const createDto = { + title: "Completed Task", + agentType: "test-agent", + status: AgentTaskStatus.COMPLETED, + }; + + mockPrismaService.agentTask.create.mockResolvedValue({ + id: "task-1", + completedAt: expect.any(Date), + }); + + await service.create(workspaceId, userId, createDto); + + expect(mockPrismaService.agentTask.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + startedAt: expect.any(Date), + completedAt: expect.any(Date), + }), + }) + ); + }); + }); + + describe("findAll", () => { + it("should return paginated agent tasks", async () => { + const workspaceId = "workspace-1"; + const query = { workspaceId, page: 1, limit: 10 }; + + const mockTasks = [ + { id: "task-1", title: "Task 1" }, + { id: "task-2", title: "Task 2" }, + ]; + + mockPrismaService.agentTask.findMany.mockResolvedValue(mockTasks); + mockPrismaService.agentTask.count.mockResolvedValue(2); + + const result = await service.findAll(query); + + expect(result).toEqual({ + data: mockTasks, + meta: { + total: 2, + page: 1, + limit: 10, + totalPages: 1, + }, + }); + + expect(mockPrismaService.agentTask.findMany).toHaveBeenCalledWith({ + where: { workspaceId }, + include: { + createdBy: { + select: { id: true, name: true, email: true }, + }, + }, + orderBy: { + createdAt: "desc", + }, + skip: 0, + take: 10, + }); + }); + + it("should apply filters correctly", async () => { + const workspaceId = "workspace-1"; + const query = { + workspaceId, + status: AgentTaskStatus.PENDING, + priority: AgentTaskPriority.HIGH, + agentType: "test-agent", + }; + + mockPrismaService.agentTask.findMany.mockResolvedValue([]); + mockPrismaService.agentTask.count.mockResolvedValue(0); + + await service.findAll(query); + + expect(mockPrismaService.agentTask.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + workspaceId, + status: AgentTaskStatus.PENDING, + priority: AgentTaskPriority.HIGH, + agentType: "test-agent", + }, + }) + ); + }); + }); + + describe("findOne", () => { + it("should return a single agent task", async () => { + const id = "task-1"; + const workspaceId = "workspace-1"; + const mockTask = { id, title: "Task 1", workspaceId }; + + mockPrismaService.agentTask.findUnique.mockResolvedValue(mockTask); + + const result = await service.findOne(id, workspaceId); + + expect(result).toEqual(mockTask); + expect(mockPrismaService.agentTask.findUnique).toHaveBeenCalledWith({ + where: { id, workspaceId }, + include: { + createdBy: { + select: { id: true, name: true, email: true }, + }, + }, + }); + }); + + it("should throw NotFoundException when task not found", async () => { + const id = "non-existent"; + const workspaceId = "workspace-1"; + + mockPrismaService.agentTask.findUnique.mockResolvedValue(null); + + await expect(service.findOne(id, workspaceId)).rejects.toThrow( + NotFoundException + ); + }); + }); + + describe("update", () => { + it("should update an agent task", async () => { + const id = "task-1"; + const workspaceId = "workspace-1"; + const updateDto = { title: "Updated Task" }; + + const existingTask = { + id, + workspaceId, + status: AgentTaskStatus.PENDING, + startedAt: null, + }; + + const updatedTask = { ...existingTask, ...updateDto }; + + mockPrismaService.agentTask.findUnique.mockResolvedValue(existingTask); + mockPrismaService.agentTask.update.mockResolvedValue(updatedTask); + + const result = await service.update(id, workspaceId, updateDto); + + expect(result).toEqual(updatedTask); + expect(mockPrismaService.agentTask.update).toHaveBeenCalledWith({ + where: { id, workspaceId }, + data: updateDto, + include: { + createdBy: { + select: { id: true, name: true, email: true }, + }, + }, + }); + }); + + it("should set startedAt when status changes to RUNNING", async () => { + const id = "task-1"; + const workspaceId = "workspace-1"; + const updateDto = { status: AgentTaskStatus.RUNNING }; + + const existingTask = { + id, + workspaceId, + status: AgentTaskStatus.PENDING, + startedAt: null, + }; + + mockPrismaService.agentTask.findUnique.mockResolvedValue(existingTask); + mockPrismaService.agentTask.update.mockResolvedValue({ + ...existingTask, + ...updateDto, + }); + + await service.update(id, workspaceId, updateDto); + + expect(mockPrismaService.agentTask.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + startedAt: expect.any(Date), + }), + }) + ); + }); + + it("should throw NotFoundException when task not found", async () => { + const id = "non-existent"; + const workspaceId = "workspace-1"; + const updateDto = { title: "Updated Task" }; + + mockPrismaService.agentTask.findUnique.mockResolvedValue(null); + + await expect( + service.update(id, workspaceId, updateDto) + ).rejects.toThrow(NotFoundException); + }); + }); + + describe("remove", () => { + it("should delete an agent task", async () => { + const id = "task-1"; + const workspaceId = "workspace-1"; + const mockTask = { id, workspaceId, title: "Task 1" }; + + mockPrismaService.agentTask.findUnique.mockResolvedValue(mockTask); + mockPrismaService.agentTask.delete.mockResolvedValue(mockTask); + + const result = await service.remove(id, workspaceId); + + expect(result).toEqual({ message: "Agent task deleted successfully" }); + expect(mockPrismaService.agentTask.delete).toHaveBeenCalledWith({ + where: { id, workspaceId }, + }); + }); + + it("should throw NotFoundException when task not found", async () => { + const id = "non-existent"; + const workspaceId = "workspace-1"; + + mockPrismaService.agentTask.findUnique.mockResolvedValue(null); + + await expect(service.remove(id, workspaceId)).rejects.toThrow( + NotFoundException + ); + }); + }); +}); diff --git a/apps/api/src/agent-tasks/agent-tasks.service.ts b/apps/api/src/agent-tasks/agent-tasks.service.ts new file mode 100644 index 0000000..27b98ef --- /dev/null +++ b/apps/api/src/agent-tasks/agent-tasks.service.ts @@ -0,0 +1,255 @@ +import { Injectable, NotFoundException } from "@nestjs/common"; +import { PrismaService } from "../prisma/prisma.service"; +import { + AgentTaskStatus, + AgentTaskPriority, + Prisma, +} from "@prisma/client"; +import type { + CreateAgentTaskDto, + UpdateAgentTaskDto, + QueryAgentTasksDto, +} from "./dto"; + +/** + * Service for managing agent tasks + */ +@Injectable() +export class AgentTasksService { + constructor(private readonly prisma: PrismaService) {} + + /** + * Create a new agent task + */ + async create( + workspaceId: string, + userId: string, + createAgentTaskDto: CreateAgentTaskDto + ) { + // Build the create input, handling optional fields properly for exactOptionalPropertyTypes + const createInput: Prisma.AgentTaskUncheckedCreateInput = { + title: createAgentTaskDto.title, + workspaceId, + createdById: userId, + status: createAgentTaskDto.status ?? AgentTaskStatus.PENDING, + priority: createAgentTaskDto.priority ?? AgentTaskPriority.MEDIUM, + agentType: createAgentTaskDto.agentType, + agentConfig: (createAgentTaskDto.agentConfig ?? {}) as Prisma.InputJsonValue, + }; + + // Add optional fields only if they exist + if (createAgentTaskDto.description) createInput.description = createAgentTaskDto.description; + if (createAgentTaskDto.result) createInput.result = createAgentTaskDto.result as Prisma.InputJsonValue; + if (createAgentTaskDto.error) createInput.error = createAgentTaskDto.error; + + // Set startedAt if status is RUNNING + if (createInput.status === AgentTaskStatus.RUNNING) { + createInput.startedAt = new Date(); + } + + // Set completedAt if status is COMPLETED or FAILED + if ( + createInput.status === AgentTaskStatus.COMPLETED || + createInput.status === AgentTaskStatus.FAILED + ) { + createInput.completedAt = new Date(); + if (!createInput.startedAt) { + createInput.startedAt = new Date(); + } + } + + const agentTask = await this.prisma.agentTask.create({ + data: createInput, + include: { + createdBy: { + select: { id: true, name: true, email: true }, + }, + }, + }); + + return agentTask; + } + + /** + * Get paginated agent tasks with filters + */ + async findAll(query: QueryAgentTasksDto) { + const page = query.page || 1; + const limit = query.limit || 50; + const skip = (page - 1) * limit; + + // Build where clause + const where: Prisma.AgentTaskWhereInput = {}; + + if (query.workspaceId) { + where.workspaceId = query.workspaceId; + } + + if (query.status) { + where.status = query.status; + } + + if (query.priority) { + where.priority = query.priority; + } + + if (query.agentType) { + where.agentType = query.agentType; + } + + if (query.createdById) { + where.createdById = query.createdById; + } + + // Execute queries in parallel + const [data, total] = await Promise.all([ + this.prisma.agentTask.findMany({ + where, + include: { + createdBy: { + select: { id: true, name: true, email: true }, + }, + }, + orderBy: { + createdAt: "desc", + }, + skip, + take: limit, + }), + this.prisma.agentTask.count({ where }), + ]); + + return { + data, + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + }; + } + + /** + * Get a single agent task by ID + */ + async findOne(id: string, workspaceId: string) { + const agentTask = await this.prisma.agentTask.findUnique({ + where: { + id, + workspaceId, + }, + include: { + createdBy: { + select: { id: true, name: true, email: true }, + }, + }, + }); + + if (!agentTask) { + throw new NotFoundException(`Agent task with ID ${id} not found`); + } + + return agentTask; + } + + /** + * Update an agent task + */ + async update( + id: string, + workspaceId: string, + updateAgentTaskDto: UpdateAgentTaskDto + ) { + // Verify agent task exists + const existingTask = await this.prisma.agentTask.findUnique({ + where: { id, workspaceId }, + }); + + if (!existingTask) { + throw new NotFoundException(`Agent task with ID ${id} not found`); + } + + const data: Prisma.AgentTaskUpdateInput = {}; + + // Only include fields that are actually being updated + if (updateAgentTaskDto.title !== undefined) data.title = updateAgentTaskDto.title; + if (updateAgentTaskDto.description !== undefined) data.description = updateAgentTaskDto.description; + if (updateAgentTaskDto.status !== undefined) data.status = updateAgentTaskDto.status; + if (updateAgentTaskDto.priority !== undefined) data.priority = updateAgentTaskDto.priority; + if (updateAgentTaskDto.agentType !== undefined) data.agentType = updateAgentTaskDto.agentType; + if (updateAgentTaskDto.error !== undefined) data.error = updateAgentTaskDto.error; + + if (updateAgentTaskDto.agentConfig !== undefined) { + data.agentConfig = updateAgentTaskDto.agentConfig as Prisma.InputJsonValue; + } + + if (updateAgentTaskDto.result !== undefined) { + data.result = updateAgentTaskDto.result === null + ? Prisma.JsonNull + : (updateAgentTaskDto.result as Prisma.InputJsonValue); + } + + // Handle startedAt based on status changes + if (updateAgentTaskDto.status) { + if ( + updateAgentTaskDto.status === AgentTaskStatus.RUNNING && + existingTask.status === AgentTaskStatus.PENDING && + !existingTask.startedAt + ) { + data.startedAt = new Date(); + } + + // Handle completedAt based on status changes + if ( + (updateAgentTaskDto.status === AgentTaskStatus.COMPLETED || + updateAgentTaskDto.status === AgentTaskStatus.FAILED) && + existingTask.status !== AgentTaskStatus.COMPLETED && + existingTask.status !== AgentTaskStatus.FAILED + ) { + data.completedAt = new Date(); + if (!existingTask.startedAt) { + data.startedAt = new Date(); + } + } + } + + const agentTask = await this.prisma.agentTask.update({ + where: { + id, + workspaceId, + }, + data, + include: { + createdBy: { + select: { id: true, name: true, email: true }, + }, + }, + }); + + return agentTask; + } + + /** + * Delete an agent task + */ + async remove(id: string, workspaceId: string) { + // Verify agent task exists + const agentTask = await this.prisma.agentTask.findUnique({ + where: { id, workspaceId }, + }); + + if (!agentTask) { + throw new NotFoundException(`Agent task with ID ${id} not found`); + } + + await this.prisma.agentTask.delete({ + where: { + id, + workspaceId, + }, + }); + + return { message: "Agent task deleted successfully" }; + } +} diff --git a/apps/api/src/agent-tasks/dto/create-agent-task.dto.ts b/apps/api/src/agent-tasks/dto/create-agent-task.dto.ts new file mode 100644 index 0000000..816529b --- /dev/null +++ b/apps/api/src/agent-tasks/dto/create-agent-task.dto.ts @@ -0,0 +1,48 @@ +import { AgentTaskStatus, AgentTaskPriority } from "@prisma/client"; +import { + IsString, + IsOptional, + IsEnum, + IsObject, + MinLength, + MaxLength, +} from "class-validator"; + +/** + * DTO for creating a new agent task + */ +export class CreateAgentTaskDto { + @IsString({ message: "title must be a string" }) + @MinLength(1, { message: "title must not be empty" }) + @MaxLength(255, { message: "title must not exceed 255 characters" }) + title!: string; + + @IsOptional() + @IsString({ message: "description must be a string" }) + @MaxLength(10000, { message: "description must not exceed 10000 characters" }) + description?: string; + + @IsOptional() + @IsEnum(AgentTaskStatus, { message: "status must be a valid AgentTaskStatus" }) + status?: AgentTaskStatus; + + @IsOptional() + @IsEnum(AgentTaskPriority, { message: "priority must be a valid AgentTaskPriority" }) + priority?: AgentTaskPriority; + + @IsString({ message: "agentType must be a string" }) + @MinLength(1, { message: "agentType must not be empty" }) + agentType!: string; + + @IsOptional() + @IsObject({ message: "agentConfig must be an object" }) + agentConfig?: Record; + + @IsOptional() + @IsObject({ message: "result must be an object" }) + result?: Record; + + @IsOptional() + @IsString({ message: "error must be a string" }) + error?: string; +} diff --git a/apps/api/src/agent-tasks/dto/index.ts b/apps/api/src/agent-tasks/dto/index.ts new file mode 100644 index 0000000..33a3a10 --- /dev/null +++ b/apps/api/src/agent-tasks/dto/index.ts @@ -0,0 +1,3 @@ +export * from "./create-agent-task.dto"; +export * from "./update-agent-task.dto"; +export * from "./query-agent-tasks.dto"; diff --git a/apps/api/src/agent-tasks/dto/query-agent-tasks.dto.ts b/apps/api/src/agent-tasks/dto/query-agent-tasks.dto.ts new file mode 100644 index 0000000..cc09b21 --- /dev/null +++ b/apps/api/src/agent-tasks/dto/query-agent-tasks.dto.ts @@ -0,0 +1,48 @@ +import { AgentTaskStatus, AgentTaskPriority } from "@prisma/client"; +import { + IsOptional, + IsEnum, + IsInt, + Min, + Max, + IsString, + IsUUID, +} from "class-validator"; +import { Type } from "class-transformer"; + +/** + * DTO for querying agent tasks with pagination and filters + */ +export class QueryAgentTasksDto { + @IsOptional() + @Type(() => Number) + @IsInt({ message: "page must be an integer" }) + @Min(1, { message: "page must be at least 1" }) + page?: number; + + @IsOptional() + @Type(() => Number) + @IsInt({ message: "limit must be an integer" }) + @Min(1, { message: "limit must be at least 1" }) + @Max(100, { message: "limit must not exceed 100" }) + limit?: number; + + @IsOptional() + @IsEnum(AgentTaskStatus, { message: "status must be a valid AgentTaskStatus" }) + status?: AgentTaskStatus; + + @IsOptional() + @IsEnum(AgentTaskPriority, { message: "priority must be a valid AgentTaskPriority" }) + priority?: AgentTaskPriority; + + @IsOptional() + @IsString({ message: "agentType must be a string" }) + agentType?: string; + + @IsOptional() + @IsUUID("4", { message: "createdById must be a valid UUID" }) + createdById?: string; + + // Internal field set by controller/guard + workspaceId?: string; +} diff --git a/apps/api/src/agent-tasks/dto/update-agent-task.dto.ts b/apps/api/src/agent-tasks/dto/update-agent-task.dto.ts new file mode 100644 index 0000000..e6ed9f5 --- /dev/null +++ b/apps/api/src/agent-tasks/dto/update-agent-task.dto.ts @@ -0,0 +1,51 @@ +import { AgentTaskStatus, AgentTaskPriority } from "@prisma/client"; +import { + IsString, + IsOptional, + IsEnum, + IsObject, + MinLength, + MaxLength, +} from "class-validator"; + +/** + * DTO for updating an existing agent task + * All fields are optional to support partial updates + */ +export class UpdateAgentTaskDto { + @IsOptional() + @IsString({ message: "title must be a string" }) + @MinLength(1, { message: "title must not be empty" }) + @MaxLength(255, { message: "title must not exceed 255 characters" }) + title?: string; + + @IsOptional() + @IsString({ message: "description must be a string" }) + @MaxLength(10000, { message: "description must not exceed 10000 characters" }) + description?: string | null; + + @IsOptional() + @IsEnum(AgentTaskStatus, { message: "status must be a valid AgentTaskStatus" }) + status?: AgentTaskStatus; + + @IsOptional() + @IsEnum(AgentTaskPriority, { message: "priority must be a valid AgentTaskPriority" }) + priority?: AgentTaskPriority; + + @IsOptional() + @IsString({ message: "agentType must be a string" }) + @MinLength(1, { message: "agentType must not be empty" }) + agentType?: string; + + @IsOptional() + @IsObject({ message: "agentConfig must be an object" }) + agentConfig?: Record; + + @IsOptional() + @IsObject({ message: "result must be an object" }) + result?: Record | null; + + @IsOptional() + @IsString({ message: "error must be a string" }) + error?: string | null; +} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 801d9f0..ae92be4 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -18,6 +18,7 @@ import { WebSocketModule } from "./websocket/websocket.module"; import { LlmModule } from "./llm/llm.module"; import { BrainModule } from "./brain/brain.module"; import { CronModule } from "./cron/cron.module"; +import { AgentTasksModule } from "./agent-tasks/agent-tasks.module"; @Module({ imports: [ @@ -38,6 +39,7 @@ import { CronModule } from "./cron/cron.module"; LlmModule, BrainModule, CronModule, + AgentTasksModule, ], controllers: [AppController], providers: [AppService], diff --git a/apps/api/src/auth/types/better-auth-request.interface.ts b/apps/api/src/auth/types/better-auth-request.interface.ts index daf4fde..8ff7587 100644 --- a/apps/api/src/auth/types/better-auth-request.interface.ts +++ b/apps/api/src/auth/types/better-auth-request.interface.ts @@ -8,6 +8,9 @@ import type { AuthUser } from "@mosaic/shared"; +// Re-export AuthUser for use in other modules +export type { AuthUser }; + /** * Session data stored in request after authentication */