Release: CI/CD Pipeline & Architecture Updates #177

Merged
jason.woltje merged 173 commits from develop into main 2026-02-01 19:18:48 +00:00
13 changed files with 1234 additions and 0 deletions
Showing only changes of commit de68e657ca - Show all commits

View File

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

View File

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

View File

@@ -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>(AgentTasksController);
service = module.get<AgentTasksService>(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);
});
});
});

View File

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

View File

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

View File

@@ -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>(AgentTasksService);
prisma = module.get<PrismaService>(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
);
});
});
});

View File

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

View File

@@ -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<string, unknown>;
@IsOptional()
@IsObject({ message: "result must be an object" })
result?: Record<string, unknown>;
@IsOptional()
@IsString({ message: "error must be a string" })
error?: string;
}

View File

@@ -0,0 +1,3 @@
export * from "./create-agent-task.dto";
export * from "./update-agent-task.dto";
export * from "./query-agent-tasks.dto";

View File

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

View File

@@ -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<string, unknown>;
@IsOptional()
@IsObject({ message: "result must be an object" })
result?: Record<string, unknown> | null;
@IsOptional()
@IsString({ message: "error must be a string" })
error?: string | null;
}

View File

@@ -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],

View File

@@ -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
*/