Compare commits
2 Commits
fcb2f662e5
...
chore/task
| Author | SHA1 | Date | |
|---|---|---|---|
| f9588d2ce1 | |||
| 294446043a |
@@ -34,9 +34,3 @@ CVE-2026-26996 # HIGH: minimatch DoS via specially crafted glob patterns (needs
|
|||||||
# OpenBao 2.5.0 compiled with Go 1.25.6, fix needs Go >= 1.25.7.
|
# OpenBao 2.5.0 compiled with Go 1.25.6, fix needs Go >= 1.25.7.
|
||||||
# Cannot build OpenBao from source (large project). Waiting for upstream release.
|
# Cannot build OpenBao from source (large project). Waiting for upstream release.
|
||||||
CVE-2025-68121 # CRITICAL: crypto/tls session resumption
|
CVE-2025-68121 # CRITICAL: crypto/tls session resumption
|
||||||
|
|
||||||
# === multer CVEs (upstream via @nestjs/platform-express) ===
|
|
||||||
# multer <2.1.0 — waiting on NestJS to update their dependency
|
|
||||||
# These are DoS vulnerabilities in file upload handling
|
|
||||||
GHSA-xf7r-hgr6-v32p # HIGH: DoS via incomplete cleanup
|
|
||||||
GHSA-v52c-386h-88mc # HIGH: DoS via resource exhaustion
|
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
-- CreateTable
|
|
||||||
CREATE TABLE "agent_memories" (
|
|
||||||
"id" UUID NOT NULL,
|
|
||||||
"workspace_id" UUID NOT NULL,
|
|
||||||
"agent_id" TEXT NOT NULL,
|
|
||||||
"key" TEXT NOT NULL,
|
|
||||||
"value" JSONB NOT NULL,
|
|
||||||
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updated_at" TIMESTAMPTZ NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "agent_memories_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "agent_memories_workspace_id_agent_id_key_key" ON "agent_memories"("workspace_id", "agent_id", "key");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "agent_memories_workspace_id_idx" ON "agent_memories"("workspace_id");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "agent_memories_agent_id_idx" ON "agent_memories"("agent_id");
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "agent_memories" ADD CONSTRAINT "agent_memories_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
-- CreateTable
|
|
||||||
CREATE TABLE "findings" (
|
|
||||||
"id" UUID NOT NULL,
|
|
||||||
"workspace_id" UUID NOT NULL,
|
|
||||||
"task_id" UUID,
|
|
||||||
"agent_id" TEXT NOT NULL,
|
|
||||||
"type" TEXT NOT NULL,
|
|
||||||
"title" TEXT NOT NULL,
|
|
||||||
"data" JSONB NOT NULL,
|
|
||||||
"summary" TEXT NOT NULL,
|
|
||||||
"embedding" vector(1536),
|
|
||||||
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updated_at" TIMESTAMPTZ NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "findings_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "findings_id_workspace_id_key" ON "findings"("id", "workspace_id");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "findings_workspace_id_idx" ON "findings"("workspace_id");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "findings_agent_id_idx" ON "findings"("agent_id");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "findings_type_idx" ON "findings"("type");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "findings_task_id_idx" ON "findings"("task_id");
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "findings" ADD CONSTRAINT "findings_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "findings" ADD CONSTRAINT "findings_task_id_fkey" FOREIGN KEY ("task_id") REFERENCES "agent_tasks"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
||||||
@@ -298,8 +298,6 @@ model Workspace {
|
|||||||
agents Agent[]
|
agents Agent[]
|
||||||
agentSessions AgentSession[]
|
agentSessions AgentSession[]
|
||||||
agentTasks AgentTask[]
|
agentTasks AgentTask[]
|
||||||
findings Finding[]
|
|
||||||
agentMemories AgentMemory[]
|
|
||||||
userLayouts UserLayout[]
|
userLayouts UserLayout[]
|
||||||
knowledgeEntries KnowledgeEntry[]
|
knowledgeEntries KnowledgeEntry[]
|
||||||
knowledgeTags KnowledgeTag[]
|
knowledgeTags KnowledgeTag[]
|
||||||
@@ -691,7 +689,6 @@ model AgentTask {
|
|||||||
createdBy User @relation("AgentTaskCreator", fields: [createdById], references: [id], onDelete: Cascade)
|
createdBy User @relation("AgentTaskCreator", fields: [createdById], references: [id], onDelete: Cascade)
|
||||||
createdById String @map("created_by_id") @db.Uuid
|
createdById String @map("created_by_id") @db.Uuid
|
||||||
runnerJobs RunnerJob[]
|
runnerJobs RunnerJob[]
|
||||||
findings Finding[]
|
|
||||||
|
|
||||||
@@unique([id, workspaceId])
|
@@unique([id, workspaceId])
|
||||||
@@index([workspaceId])
|
@@index([workspaceId])
|
||||||
@@ -701,33 +698,6 @@ model AgentTask {
|
|||||||
@@map("agent_tasks")
|
@@map("agent_tasks")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Finding {
|
|
||||||
id String @id @default(uuid()) @db.Uuid
|
|
||||||
workspaceId String @map("workspace_id") @db.Uuid
|
|
||||||
taskId String? @map("task_id") @db.Uuid
|
|
||||||
|
|
||||||
agentId String @map("agent_id")
|
|
||||||
type String
|
|
||||||
title String
|
|
||||||
data Json
|
|
||||||
summary String @db.Text
|
|
||||||
// Note: vector dimension (1536) must match EMBEDDING_DIMENSION constant in @mosaic/shared
|
|
||||||
embedding Unsupported("vector(1536)")?
|
|
||||||
|
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
|
|
||||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
|
|
||||||
|
|
||||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
|
||||||
task AgentTask? @relation(fields: [taskId], references: [id], onDelete: SetNull)
|
|
||||||
|
|
||||||
@@unique([id, workspaceId])
|
|
||||||
@@index([workspaceId])
|
|
||||||
@@index([agentId])
|
|
||||||
@@index([type])
|
|
||||||
@@index([taskId])
|
|
||||||
@@map("findings")
|
|
||||||
}
|
|
||||||
|
|
||||||
model AgentSession {
|
model AgentSession {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
workspaceId String @map("workspace_id") @db.Uuid
|
workspaceId String @map("workspace_id") @db.Uuid
|
||||||
@@ -765,23 +735,6 @@ model AgentSession {
|
|||||||
@@map("agent_sessions")
|
@@map("agent_sessions")
|
||||||
}
|
}
|
||||||
|
|
||||||
model AgentMemory {
|
|
||||||
id String @id @default(uuid()) @db.Uuid
|
|
||||||
workspaceId String @map("workspace_id") @db.Uuid
|
|
||||||
agentId String @map("agent_id")
|
|
||||||
key String
|
|
||||||
value Json
|
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
|
|
||||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
|
|
||||||
|
|
||||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@unique([workspaceId, agentId, key])
|
|
||||||
@@index([workspaceId])
|
|
||||||
@@index([agentId])
|
|
||||||
@@map("agent_memories")
|
|
||||||
}
|
|
||||||
|
|
||||||
model WidgetDefinition {
|
model WidgetDefinition {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
import { Test, TestingModule } from "@nestjs/testing";
|
|
||||||
import { AgentMemoryController } from "./agent-memory.controller";
|
|
||||||
import { AgentMemoryService } from "./agent-memory.service";
|
|
||||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
|
||||||
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
|
|
||||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
||||||
|
|
||||||
describe("AgentMemoryController", () => {
|
|
||||||
let controller: AgentMemoryController;
|
|
||||||
|
|
||||||
const mockAgentMemoryService = {
|
|
||||||
upsert: vi.fn(),
|
|
||||||
findAll: vi.fn(),
|
|
||||||
findOne: vi.fn(),
|
|
||||||
remove: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockGuard = { canActivate: vi.fn(() => true) };
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
|
||||||
controllers: [AgentMemoryController],
|
|
||||||
providers: [
|
|
||||||
{
|
|
||||||
provide: AgentMemoryService,
|
|
||||||
useValue: mockAgentMemoryService,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
.overrideGuard(AuthGuard)
|
|
||||||
.useValue(mockGuard)
|
|
||||||
.overrideGuard(WorkspaceGuard)
|
|
||||||
.useValue(mockGuard)
|
|
||||||
.overrideGuard(PermissionGuard)
|
|
||||||
.useValue(mockGuard)
|
|
||||||
.compile();
|
|
||||||
|
|
||||||
controller = module.get<AgentMemoryController>(AgentMemoryController);
|
|
||||||
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
const workspaceId = "workspace-1";
|
|
||||||
const agentId = "agent-1";
|
|
||||||
const key = "context";
|
|
||||||
|
|
||||||
describe("upsert", () => {
|
|
||||||
it("should upsert a memory entry", async () => {
|
|
||||||
const dto = { value: { foo: "bar" } };
|
|
||||||
const mockEntry = { id: "mem-1", workspaceId, agentId, key, value: dto.value };
|
|
||||||
|
|
||||||
mockAgentMemoryService.upsert.mockResolvedValue(mockEntry);
|
|
||||||
|
|
||||||
const result = await controller.upsert(agentId, key, dto, workspaceId);
|
|
||||||
|
|
||||||
expect(mockAgentMemoryService.upsert).toHaveBeenCalledWith(workspaceId, agentId, key, dto);
|
|
||||||
expect(result).toEqual(mockEntry);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("findAll", () => {
|
|
||||||
it("should list all memory entries for an agent", async () => {
|
|
||||||
const mockEntries = [
|
|
||||||
{ id: "mem-1", key: "a", value: 1 },
|
|
||||||
{ id: "mem-2", key: "b", value: 2 },
|
|
||||||
];
|
|
||||||
|
|
||||||
mockAgentMemoryService.findAll.mockResolvedValue(mockEntries);
|
|
||||||
|
|
||||||
const result = await controller.findAll(agentId, workspaceId);
|
|
||||||
|
|
||||||
expect(mockAgentMemoryService.findAll).toHaveBeenCalledWith(workspaceId, agentId);
|
|
||||||
expect(result).toEqual(mockEntries);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("findOne", () => {
|
|
||||||
it("should get a single memory entry", async () => {
|
|
||||||
const mockEntry = { id: "mem-1", key, value: "v" };
|
|
||||||
|
|
||||||
mockAgentMemoryService.findOne.mockResolvedValue(mockEntry);
|
|
||||||
|
|
||||||
const result = await controller.findOne(agentId, key, workspaceId);
|
|
||||||
|
|
||||||
expect(mockAgentMemoryService.findOne).toHaveBeenCalledWith(workspaceId, agentId, key);
|
|
||||||
expect(result).toEqual(mockEntry);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("remove", () => {
|
|
||||||
it("should delete a memory entry", async () => {
|
|
||||||
const mockResponse = { message: "Memory entry deleted successfully" };
|
|
||||||
|
|
||||||
mockAgentMemoryService.remove.mockResolvedValue(mockResponse);
|
|
||||||
|
|
||||||
const result = await controller.remove(agentId, key, workspaceId);
|
|
||||||
|
|
||||||
expect(mockAgentMemoryService.remove).toHaveBeenCalledWith(workspaceId, agentId, key);
|
|
||||||
expect(result).toEqual(mockResponse);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
import {
|
|
||||||
Controller,
|
|
||||||
Get,
|
|
||||||
Put,
|
|
||||||
Delete,
|
|
||||||
Body,
|
|
||||||
Param,
|
|
||||||
UseGuards,
|
|
||||||
HttpCode,
|
|
||||||
HttpStatus,
|
|
||||||
} from "@nestjs/common";
|
|
||||||
import { AgentMemoryService } from "./agent-memory.service";
|
|
||||||
import { UpsertAgentMemoryDto } from "./dto";
|
|
||||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
|
||||||
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
|
|
||||||
import { Workspace, Permission, RequirePermission } from "../common/decorators";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Controller for per-agent key/value memory endpoints.
|
|
||||||
* All endpoints require authentication and workspace context.
|
|
||||||
*
|
|
||||||
* Guards are applied in order:
|
|
||||||
* 1. AuthGuard - Verifies user authentication
|
|
||||||
* 2. WorkspaceGuard - Validates workspace access
|
|
||||||
* 3. PermissionGuard - Checks role-based permissions
|
|
||||||
*/
|
|
||||||
@Controller("agents/:agentId/memory")
|
|
||||||
@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard)
|
|
||||||
export class AgentMemoryController {
|
|
||||||
constructor(private readonly agentMemoryService: AgentMemoryService) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PUT /api/agents/:agentId/memory/:key
|
|
||||||
* Upsert a memory entry for an agent
|
|
||||||
* Requires: MEMBER role or higher
|
|
||||||
*/
|
|
||||||
@Put(":key")
|
|
||||||
@RequirePermission(Permission.WORKSPACE_MEMBER)
|
|
||||||
async upsert(
|
|
||||||
@Param("agentId") agentId: string,
|
|
||||||
@Param("key") key: string,
|
|
||||||
@Body() dto: UpsertAgentMemoryDto,
|
|
||||||
@Workspace() workspaceId: string
|
|
||||||
) {
|
|
||||||
return this.agentMemoryService.upsert(workspaceId, agentId, key, dto);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/agents/:agentId/memory
|
|
||||||
* List all memory entries for an agent
|
|
||||||
* Requires: Any workspace member (including GUEST)
|
|
||||||
*/
|
|
||||||
@Get()
|
|
||||||
@RequirePermission(Permission.WORKSPACE_ANY)
|
|
||||||
async findAll(@Param("agentId") agentId: string, @Workspace() workspaceId: string) {
|
|
||||||
return this.agentMemoryService.findAll(workspaceId, agentId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/agents/:agentId/memory/:key
|
|
||||||
* Get a single memory entry by key
|
|
||||||
* Requires: Any workspace member (including GUEST)
|
|
||||||
*/
|
|
||||||
@Get(":key")
|
|
||||||
@RequirePermission(Permission.WORKSPACE_ANY)
|
|
||||||
async findOne(
|
|
||||||
@Param("agentId") agentId: string,
|
|
||||||
@Param("key") key: string,
|
|
||||||
@Workspace() workspaceId: string
|
|
||||||
) {
|
|
||||||
return this.agentMemoryService.findOne(workspaceId, agentId, key);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DELETE /api/agents/:agentId/memory/:key
|
|
||||||
* Remove a memory entry
|
|
||||||
* Requires: MEMBER role or higher
|
|
||||||
*/
|
|
||||||
@Delete(":key")
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@RequirePermission(Permission.WORKSPACE_MEMBER)
|
|
||||||
async remove(
|
|
||||||
@Param("agentId") agentId: string,
|
|
||||||
@Param("key") key: string,
|
|
||||||
@Workspace() workspaceId: string
|
|
||||||
) {
|
|
||||||
return this.agentMemoryService.remove(workspaceId, agentId, key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import { Module } from "@nestjs/common";
|
|
||||||
import { AgentMemoryController } from "./agent-memory.controller";
|
|
||||||
import { AgentMemoryService } from "./agent-memory.service";
|
|
||||||
import { PrismaModule } from "../prisma/prisma.module";
|
|
||||||
import { AuthModule } from "../auth/auth.module";
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [PrismaModule, AuthModule],
|
|
||||||
controllers: [AgentMemoryController],
|
|
||||||
providers: [AgentMemoryService],
|
|
||||||
exports: [AgentMemoryService],
|
|
||||||
})
|
|
||||||
export class AgentMemoryModule {}
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
import { Test, TestingModule } from "@nestjs/testing";
|
|
||||||
import { AgentMemoryService } from "./agent-memory.service";
|
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
|
||||||
import { NotFoundException } from "@nestjs/common";
|
|
||||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
||||||
|
|
||||||
describe("AgentMemoryService", () => {
|
|
||||||
let service: AgentMemoryService;
|
|
||||||
|
|
||||||
const mockPrismaService = {
|
|
||||||
agentMemory: {
|
|
||||||
upsert: vi.fn(),
|
|
||||||
findMany: vi.fn(),
|
|
||||||
findUnique: vi.fn(),
|
|
||||||
delete: vi.fn(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
|
||||||
providers: [
|
|
||||||
AgentMemoryService,
|
|
||||||
{
|
|
||||||
provide: PrismaService,
|
|
||||||
useValue: mockPrismaService,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}).compile();
|
|
||||||
|
|
||||||
service = module.get<AgentMemoryService>(AgentMemoryService);
|
|
||||||
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
const workspaceId = "workspace-1";
|
|
||||||
const agentId = "agent-1";
|
|
||||||
const key = "session-context";
|
|
||||||
|
|
||||||
describe("upsert", () => {
|
|
||||||
it("should upsert a memory entry", async () => {
|
|
||||||
const dto = { value: { data: "some context" } };
|
|
||||||
const mockEntry = {
|
|
||||||
id: "mem-1",
|
|
||||||
workspaceId,
|
|
||||||
agentId,
|
|
||||||
key,
|
|
||||||
value: dto.value,
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
mockPrismaService.agentMemory.upsert.mockResolvedValue(mockEntry);
|
|
||||||
|
|
||||||
const result = await service.upsert(workspaceId, agentId, key, dto);
|
|
||||||
|
|
||||||
expect(mockPrismaService.agentMemory.upsert).toHaveBeenCalledWith({
|
|
||||||
where: { workspaceId_agentId_key: { workspaceId, agentId, key } },
|
|
||||||
create: { workspaceId, agentId, key, value: dto.value },
|
|
||||||
update: { value: dto.value },
|
|
||||||
});
|
|
||||||
expect(result).toEqual(mockEntry);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("findAll", () => {
|
|
||||||
it("should return all memory entries for an agent", async () => {
|
|
||||||
const mockEntries = [
|
|
||||||
{ id: "mem-1", key: "a", value: 1 },
|
|
||||||
{ id: "mem-2", key: "b", value: 2 },
|
|
||||||
];
|
|
||||||
|
|
||||||
mockPrismaService.agentMemory.findMany.mockResolvedValue(mockEntries);
|
|
||||||
|
|
||||||
const result = await service.findAll(workspaceId, agentId);
|
|
||||||
|
|
||||||
expect(mockPrismaService.agentMemory.findMany).toHaveBeenCalledWith({
|
|
||||||
where: { workspaceId, agentId },
|
|
||||||
orderBy: { key: "asc" },
|
|
||||||
});
|
|
||||||
expect(result).toEqual(mockEntries);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("findOne", () => {
|
|
||||||
it("should return a memory entry by key", async () => {
|
|
||||||
const mockEntry = { id: "mem-1", workspaceId, agentId, key, value: "ctx" };
|
|
||||||
|
|
||||||
mockPrismaService.agentMemory.findUnique.mockResolvedValue(mockEntry);
|
|
||||||
|
|
||||||
const result = await service.findOne(workspaceId, agentId, key);
|
|
||||||
|
|
||||||
expect(mockPrismaService.agentMemory.findUnique).toHaveBeenCalledWith({
|
|
||||||
where: { workspaceId_agentId_key: { workspaceId, agentId, key } },
|
|
||||||
});
|
|
||||||
expect(result).toEqual(mockEntry);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw NotFoundException when key not found", async () => {
|
|
||||||
mockPrismaService.agentMemory.findUnique.mockResolvedValue(null);
|
|
||||||
|
|
||||||
await expect(service.findOne(workspaceId, agentId, key)).rejects.toThrow(NotFoundException);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("remove", () => {
|
|
||||||
it("should delete a memory entry", async () => {
|
|
||||||
const mockEntry = { id: "mem-1", workspaceId, agentId, key, value: "x" };
|
|
||||||
|
|
||||||
mockPrismaService.agentMemory.findUnique.mockResolvedValue(mockEntry);
|
|
||||||
mockPrismaService.agentMemory.delete.mockResolvedValue(mockEntry);
|
|
||||||
|
|
||||||
const result = await service.remove(workspaceId, agentId, key);
|
|
||||||
|
|
||||||
expect(mockPrismaService.agentMemory.delete).toHaveBeenCalledWith({
|
|
||||||
where: { workspaceId_agentId_key: { workspaceId, agentId, key } },
|
|
||||||
});
|
|
||||||
expect(result).toEqual({ message: "Memory entry deleted successfully" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw NotFoundException when key not found", async () => {
|
|
||||||
mockPrismaService.agentMemory.findUnique.mockResolvedValue(null);
|
|
||||||
|
|
||||||
await expect(service.remove(workspaceId, agentId, key)).rejects.toThrow(NotFoundException);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
|
||||||
import { Prisma } from "@prisma/client";
|
|
||||||
import type { UpsertAgentMemoryDto } from "./dto";
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class AgentMemoryService {
|
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Upsert a memory entry for an agent.
|
|
||||||
*/
|
|
||||||
async upsert(workspaceId: string, agentId: string, key: string, dto: UpsertAgentMemoryDto) {
|
|
||||||
return this.prisma.agentMemory.upsert({
|
|
||||||
where: {
|
|
||||||
workspaceId_agentId_key: { workspaceId, agentId, key },
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
workspaceId,
|
|
||||||
agentId,
|
|
||||||
key,
|
|
||||||
value: dto.value as Prisma.InputJsonValue,
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
value: dto.value as Prisma.InputJsonValue,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List all memory entries for an agent in a workspace.
|
|
||||||
*/
|
|
||||||
async findAll(workspaceId: string, agentId: string) {
|
|
||||||
return this.prisma.agentMemory.findMany({
|
|
||||||
where: { workspaceId, agentId },
|
|
||||||
orderBy: { key: "asc" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a single memory entry by key.
|
|
||||||
*/
|
|
||||||
async findOne(workspaceId: string, agentId: string, key: string) {
|
|
||||||
const entry = await this.prisma.agentMemory.findUnique({
|
|
||||||
where: {
|
|
||||||
workspaceId_agentId_key: { workspaceId, agentId, key },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!entry) {
|
|
||||||
throw new NotFoundException(`Memory key "${key}" not found for agent "${agentId}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return entry;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a memory entry by key.
|
|
||||||
*/
|
|
||||||
async remove(workspaceId: string, agentId: string, key: string) {
|
|
||||||
const entry = await this.prisma.agentMemory.findUnique({
|
|
||||||
where: {
|
|
||||||
workspaceId_agentId_key: { workspaceId, agentId, key },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!entry) {
|
|
||||||
throw new NotFoundException(`Memory key "${key}" not found for agent "${agentId}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.prisma.agentMemory.delete({
|
|
||||||
where: {
|
|
||||||
workspaceId_agentId_key: { workspaceId, agentId, key },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return { message: "Memory entry deleted successfully" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from "./upsert-agent-memory.dto";
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { IsNotEmpty } from "class-validator";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DTO for upserting an agent memory entry.
|
|
||||||
* The value accepts any JSON-serializable data.
|
|
||||||
*/
|
|
||||||
export class UpsertAgentMemoryDto {
|
|
||||||
@IsNotEmpty({ message: "value must not be empty" })
|
|
||||||
value!: unknown;
|
|
||||||
}
|
|
||||||
@@ -27,8 +27,6 @@ import { LlmUsageModule } from "./llm-usage/llm-usage.module";
|
|||||||
import { BrainModule } from "./brain/brain.module";
|
import { BrainModule } from "./brain/brain.module";
|
||||||
import { CronModule } from "./cron/cron.module";
|
import { CronModule } from "./cron/cron.module";
|
||||||
import { AgentTasksModule } from "./agent-tasks/agent-tasks.module";
|
import { AgentTasksModule } from "./agent-tasks/agent-tasks.module";
|
||||||
import { FindingsModule } from "./findings/findings.module";
|
|
||||||
import { AgentMemoryModule } from "./agent-memory/agent-memory.module";
|
|
||||||
import { ValkeyModule } from "./valkey/valkey.module";
|
import { ValkeyModule } from "./valkey/valkey.module";
|
||||||
import { BullMqModule } from "./bullmq/bullmq.module";
|
import { BullMqModule } from "./bullmq/bullmq.module";
|
||||||
import { StitcherModule } from "./stitcher/stitcher.module";
|
import { StitcherModule } from "./stitcher/stitcher.module";
|
||||||
@@ -102,8 +100,6 @@ import { RlsContextInterceptor } from "./common/interceptors/rls-context.interce
|
|||||||
BrainModule,
|
BrainModule,
|
||||||
CronModule,
|
CronModule,
|
||||||
AgentTasksModule,
|
AgentTasksModule,
|
||||||
FindingsModule,
|
|
||||||
AgentMemoryModule,
|
|
||||||
RunnerJobsModule,
|
RunnerJobsModule,
|
||||||
JobEventsModule,
|
JobEventsModule,
|
||||||
JobStepsModule,
|
JobStepsModule,
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
import { IsObject, IsOptional, IsString, IsUUID, MaxLength, MinLength } from "class-validator";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DTO for creating a finding
|
|
||||||
*/
|
|
||||||
export class CreateFindingDto {
|
|
||||||
@IsOptional()
|
|
||||||
@IsUUID("4", { message: "taskId must be a valid UUID" })
|
|
||||||
taskId?: string;
|
|
||||||
|
|
||||||
@IsString({ message: "agentId must be a string" })
|
|
||||||
@MinLength(1, { message: "agentId must not be empty" })
|
|
||||||
@MaxLength(255, { message: "agentId must not exceed 255 characters" })
|
|
||||||
agentId!: string;
|
|
||||||
|
|
||||||
@IsString({ message: "type must be a string" })
|
|
||||||
@MinLength(1, { message: "type must not be empty" })
|
|
||||||
@MaxLength(100, { message: "type must not exceed 100 characters" })
|
|
||||||
type!: string;
|
|
||||||
|
|
||||||
@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;
|
|
||||||
|
|
||||||
@IsObject({ message: "data must be an object" })
|
|
||||||
data!: Record<string, unknown>;
|
|
||||||
|
|
||||||
@IsString({ message: "summary must be a string" })
|
|
||||||
@MinLength(1, { message: "summary must not be empty" })
|
|
||||||
@MaxLength(20000, { message: "summary must not exceed 20000 characters" })
|
|
||||||
summary!: string;
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export { CreateFindingDto } from "./create-finding.dto";
|
|
||||||
export { QueryFindingsDto } from "./query-findings.dto";
|
|
||||||
export { SearchFindingsDto } from "./search-findings.dto";
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import { Type } from "class-transformer";
|
|
||||||
import { IsInt, IsOptional, IsString, IsUUID, Max, Min } from "class-validator";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DTO for querying findings with filters and pagination
|
|
||||||
*/
|
|
||||||
export class QueryFindingsDto {
|
|
||||||
@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()
|
|
||||||
@IsString({ message: "agentId must be a string" })
|
|
||||||
agentId?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString({ message: "type must be a string" })
|
|
||||||
type?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsUUID("4", { message: "taskId must be a valid UUID" })
|
|
||||||
taskId?: string;
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import { Type } from "class-transformer";
|
|
||||||
import {
|
|
||||||
IsInt,
|
|
||||||
IsNumber,
|
|
||||||
IsOptional,
|
|
||||||
IsString,
|
|
||||||
IsUUID,
|
|
||||||
Max,
|
|
||||||
MaxLength,
|
|
||||||
Min,
|
|
||||||
} from "class-validator";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DTO for finding semantic similarity search
|
|
||||||
*/
|
|
||||||
export class SearchFindingsDto {
|
|
||||||
@IsString({ message: "query must be a string" })
|
|
||||||
@MaxLength(1000, { message: "query must not exceed 1000 characters" })
|
|
||||||
query!: string;
|
|
||||||
|
|
||||||
@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()
|
|
||||||
@Type(() => Number)
|
|
||||||
@IsNumber({}, { message: "similarityThreshold must be a number" })
|
|
||||||
@Min(0, { message: "similarityThreshold must be at least 0" })
|
|
||||||
@Max(1, { message: "similarityThreshold must not exceed 1" })
|
|
||||||
similarityThreshold?: number;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString({ message: "agentId must be a string" })
|
|
||||||
agentId?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString({ message: "type must be a string" })
|
|
||||||
type?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsUUID("4", { message: "taskId must be a valid UUID" })
|
|
||||||
taskId?: string;
|
|
||||||
}
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
import { Test, TestingModule } from "@nestjs/testing";
|
|
||||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
||||||
import { FindingsController } from "./findings.controller";
|
|
||||||
import { FindingsService } from "./findings.service";
|
|
||||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
|
||||||
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
|
|
||||||
import { CreateFindingDto, QueryFindingsDto, SearchFindingsDto } from "./dto";
|
|
||||||
|
|
||||||
describe("FindingsController", () => {
|
|
||||||
let controller: FindingsController;
|
|
||||||
let service: FindingsService;
|
|
||||||
|
|
||||||
const mockFindingsService = {
|
|
||||||
create: vi.fn(),
|
|
||||||
findAll: vi.fn(),
|
|
||||||
findOne: vi.fn(),
|
|
||||||
search: vi.fn(),
|
|
||||||
remove: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockAuthGuard = {
|
|
||||||
canActivate: vi.fn(() => true),
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockWorkspaceGuard = {
|
|
||||||
canActivate: vi.fn(() => true),
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockPermissionGuard = {
|
|
||||||
canActivate: vi.fn(() => true),
|
|
||||||
};
|
|
||||||
|
|
||||||
const workspaceId = "550e8400-e29b-41d4-a716-446655440001";
|
|
||||||
const findingId = "550e8400-e29b-41d4-a716-446655440002";
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
|
||||||
controllers: [FindingsController],
|
|
||||||
providers: [
|
|
||||||
{
|
|
||||||
provide: FindingsService,
|
|
||||||
useValue: mockFindingsService,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
.overrideGuard(AuthGuard)
|
|
||||||
.useValue(mockAuthGuard)
|
|
||||||
.overrideGuard(WorkspaceGuard)
|
|
||||||
.useValue(mockWorkspaceGuard)
|
|
||||||
.overrideGuard(PermissionGuard)
|
|
||||||
.useValue(mockPermissionGuard)
|
|
||||||
.compile();
|
|
||||||
|
|
||||||
controller = module.get<FindingsController>(FindingsController);
|
|
||||||
service = module.get<FindingsService>(FindingsService);
|
|
||||||
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should be defined", () => {
|
|
||||||
expect(controller).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("create", () => {
|
|
||||||
it("should create a finding", async () => {
|
|
||||||
const createDto: CreateFindingDto = {
|
|
||||||
agentId: "research-agent",
|
|
||||||
type: "security",
|
|
||||||
title: "SQL injection risk",
|
|
||||||
data: { severity: "high" },
|
|
||||||
summary: "Potential SQL injection in search endpoint.",
|
|
||||||
};
|
|
||||||
|
|
||||||
const createdFinding = {
|
|
||||||
id: findingId,
|
|
||||||
workspaceId,
|
|
||||||
taskId: null,
|
|
||||||
...createDto,
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
mockFindingsService.create.mockResolvedValue(createdFinding);
|
|
||||||
|
|
||||||
const result = await controller.create(createDto, workspaceId);
|
|
||||||
|
|
||||||
expect(result).toEqual(createdFinding);
|
|
||||||
expect(service.create).toHaveBeenCalledWith(workspaceId, createDto);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("findAll", () => {
|
|
||||||
it("should return paginated findings", async () => {
|
|
||||||
const query: QueryFindingsDto = {
|
|
||||||
page: 1,
|
|
||||||
limit: 10,
|
|
||||||
type: "security",
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = {
|
|
||||||
data: [],
|
|
||||||
meta: {
|
|
||||||
total: 0,
|
|
||||||
page: 1,
|
|
||||||
limit: 10,
|
|
||||||
totalPages: 0,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
mockFindingsService.findAll.mockResolvedValue(response);
|
|
||||||
|
|
||||||
const result = await controller.findAll(query, workspaceId);
|
|
||||||
|
|
||||||
expect(result).toEqual(response);
|
|
||||||
expect(service.findAll).toHaveBeenCalledWith(workspaceId, query);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("findOne", () => {
|
|
||||||
it("should return a finding", async () => {
|
|
||||||
const finding = {
|
|
||||||
id: findingId,
|
|
||||||
workspaceId,
|
|
||||||
taskId: null,
|
|
||||||
agentId: "research-agent",
|
|
||||||
type: "security",
|
|
||||||
title: "SQL injection risk",
|
|
||||||
data: { severity: "high" },
|
|
||||||
summary: "Potential SQL injection in search endpoint.",
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
mockFindingsService.findOne.mockResolvedValue(finding);
|
|
||||||
|
|
||||||
const result = await controller.findOne(findingId, workspaceId);
|
|
||||||
|
|
||||||
expect(result).toEqual(finding);
|
|
||||||
expect(service.findOne).toHaveBeenCalledWith(findingId, workspaceId);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("search", () => {
|
|
||||||
it("should perform semantic search", async () => {
|
|
||||||
const searchDto: SearchFindingsDto = {
|
|
||||||
query: "sql injection",
|
|
||||||
limit: 5,
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = {
|
|
||||||
data: [
|
|
||||||
{
|
|
||||||
id: findingId,
|
|
||||||
workspaceId,
|
|
||||||
taskId: null,
|
|
||||||
agentId: "research-agent",
|
|
||||||
type: "security",
|
|
||||||
title: "SQL injection risk",
|
|
||||||
data: { severity: "high" },
|
|
||||||
summary: "Potential SQL injection in search endpoint.",
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
score: 0.91,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
meta: {
|
|
||||||
total: 1,
|
|
||||||
page: 1,
|
|
||||||
limit: 5,
|
|
||||||
totalPages: 1,
|
|
||||||
},
|
|
||||||
query: "sql injection",
|
|
||||||
};
|
|
||||||
|
|
||||||
mockFindingsService.search.mockResolvedValue(response);
|
|
||||||
|
|
||||||
const result = await controller.search(searchDto, workspaceId);
|
|
||||||
|
|
||||||
expect(result).toEqual(response);
|
|
||||||
expect(service.search).toHaveBeenCalledWith(workspaceId, searchDto);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("remove", () => {
|
|
||||||
it("should delete a finding", async () => {
|
|
||||||
const response = { message: "Finding deleted successfully" };
|
|
||||||
mockFindingsService.remove.mockResolvedValue(response);
|
|
||||||
|
|
||||||
const result = await controller.remove(findingId, workspaceId);
|
|
||||||
|
|
||||||
expect(result).toEqual(response);
|
|
||||||
expect(service.remove).toHaveBeenCalledWith(findingId, workspaceId);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
import { Body, Controller, Delete, Get, Param, Post, Query, UseGuards } from "@nestjs/common";
|
|
||||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
|
||||||
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
|
|
||||||
import { Workspace, Permission, RequirePermission } from "../common/decorators";
|
|
||||||
import { CreateFindingDto, QueryFindingsDto, SearchFindingsDto } from "./dto";
|
|
||||||
import {
|
|
||||||
FindingsService,
|
|
||||||
FindingsSearchResponse,
|
|
||||||
PaginatedFindingsResponse,
|
|
||||||
} from "./findings.service";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Controller for findings endpoints
|
|
||||||
* All endpoints require authentication and workspace context
|
|
||||||
*/
|
|
||||||
@Controller("findings")
|
|
||||||
@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard)
|
|
||||||
export class FindingsController {
|
|
||||||
constructor(private readonly findingsService: FindingsService) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/findings
|
|
||||||
* Create a new finding and embed its summary
|
|
||||||
* Requires: MEMBER role or higher
|
|
||||||
*/
|
|
||||||
@Post()
|
|
||||||
@RequirePermission(Permission.WORKSPACE_MEMBER)
|
|
||||||
async create(@Body() createFindingDto: CreateFindingDto, @Workspace() workspaceId: string) {
|
|
||||||
return this.findingsService.create(workspaceId, createFindingDto);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/findings
|
|
||||||
* Get paginated findings with optional filters
|
|
||||||
* Requires: Any workspace member
|
|
||||||
*/
|
|
||||||
@Get()
|
|
||||||
@RequirePermission(Permission.WORKSPACE_ANY)
|
|
||||||
async findAll(
|
|
||||||
@Query() query: QueryFindingsDto,
|
|
||||||
@Workspace() workspaceId: string
|
|
||||||
): Promise<PaginatedFindingsResponse> {
|
|
||||||
return this.findingsService.findAll(workspaceId, query);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/findings/:id
|
|
||||||
* Get a single finding by ID
|
|
||||||
* Requires: Any workspace member
|
|
||||||
*/
|
|
||||||
@Get(":id")
|
|
||||||
@RequirePermission(Permission.WORKSPACE_ANY)
|
|
||||||
async findOne(@Param("id") id: string, @Workspace() workspaceId: string) {
|
|
||||||
return this.findingsService.findOne(id, workspaceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/findings/search
|
|
||||||
* Semantic search findings by vector similarity
|
|
||||||
* Requires: Any workspace member
|
|
||||||
*/
|
|
||||||
@Post("search")
|
|
||||||
@RequirePermission(Permission.WORKSPACE_ANY)
|
|
||||||
async search(
|
|
||||||
@Body() searchDto: SearchFindingsDto,
|
|
||||||
@Workspace() workspaceId: string
|
|
||||||
): Promise<FindingsSearchResponse> {
|
|
||||||
return this.findingsService.search(workspaceId, searchDto);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DELETE /api/findings/:id
|
|
||||||
* Delete a finding
|
|
||||||
* Requires: ADMIN role or higher
|
|
||||||
*/
|
|
||||||
@Delete(":id")
|
|
||||||
@RequirePermission(Permission.WORKSPACE_ADMIN)
|
|
||||||
async remove(@Param("id") id: string, @Workspace() workspaceId: string) {
|
|
||||||
return this.findingsService.remove(id, workspaceId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { Module } from "@nestjs/common";
|
|
||||||
import { PrismaModule } from "../prisma/prisma.module";
|
|
||||||
import { AuthModule } from "../auth/auth.module";
|
|
||||||
import { KnowledgeModule } from "../knowledge/knowledge.module";
|
|
||||||
import { FindingsController } from "./findings.controller";
|
|
||||||
import { FindingsService } from "./findings.service";
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [PrismaModule, AuthModule, KnowledgeModule],
|
|
||||||
controllers: [FindingsController],
|
|
||||||
providers: [FindingsService],
|
|
||||||
exports: [FindingsService],
|
|
||||||
})
|
|
||||||
export class FindingsModule {}
|
|
||||||
@@ -1,300 +0,0 @@
|
|||||||
import { Test, TestingModule } from "@nestjs/testing";
|
|
||||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
||||||
import { BadRequestException, NotFoundException } from "@nestjs/common";
|
|
||||||
import { FindingsService } from "./findings.service";
|
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
|
||||||
import { EmbeddingService } from "../knowledge/services/embedding.service";
|
|
||||||
|
|
||||||
describe("FindingsService", () => {
|
|
||||||
let service: FindingsService;
|
|
||||||
let prisma: PrismaService;
|
|
||||||
let embeddingService: EmbeddingService;
|
|
||||||
|
|
||||||
const mockWorkspaceId = "550e8400-e29b-41d4-a716-446655440001";
|
|
||||||
const mockFindingId = "550e8400-e29b-41d4-a716-446655440002";
|
|
||||||
const mockTaskId = "550e8400-e29b-41d4-a716-446655440003";
|
|
||||||
|
|
||||||
const mockPrismaService = {
|
|
||||||
finding: {
|
|
||||||
create: vi.fn(),
|
|
||||||
findMany: vi.fn(),
|
|
||||||
findUnique: vi.fn(),
|
|
||||||
count: vi.fn(),
|
|
||||||
delete: vi.fn(),
|
|
||||||
},
|
|
||||||
agentTask: {
|
|
||||||
findUnique: vi.fn(),
|
|
||||||
},
|
|
||||||
$queryRaw: vi.fn(),
|
|
||||||
$executeRaw: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockEmbeddingService = {
|
|
||||||
isConfigured: vi.fn(),
|
|
||||||
generateEmbedding: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
|
||||||
providers: [
|
|
||||||
FindingsService,
|
|
||||||
{
|
|
||||||
provide: PrismaService,
|
|
||||||
useValue: mockPrismaService,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: EmbeddingService,
|
|
||||||
useValue: mockEmbeddingService,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}).compile();
|
|
||||||
|
|
||||||
service = module.get<FindingsService>(FindingsService);
|
|
||||||
prisma = module.get<PrismaService>(PrismaService);
|
|
||||||
embeddingService = module.get<EmbeddingService>(EmbeddingService);
|
|
||||||
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should be defined", () => {
|
|
||||||
expect(service).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("create", () => {
|
|
||||||
it("should create a finding and store embedding when configured", async () => {
|
|
||||||
const createDto = {
|
|
||||||
taskId: mockTaskId,
|
|
||||||
agentId: "research-agent",
|
|
||||||
type: "security",
|
|
||||||
title: "SQL injection risk",
|
|
||||||
data: { severity: "high" },
|
|
||||||
summary: "Potential SQL injection in search endpoint.",
|
|
||||||
};
|
|
||||||
|
|
||||||
const createdFinding = {
|
|
||||||
id: mockFindingId,
|
|
||||||
workspaceId: mockWorkspaceId,
|
|
||||||
...createDto,
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
mockPrismaService.agentTask.findUnique.mockResolvedValue({
|
|
||||||
id: mockTaskId,
|
|
||||||
workspaceId: mockWorkspaceId,
|
|
||||||
});
|
|
||||||
mockPrismaService.finding.create.mockResolvedValue(createdFinding);
|
|
||||||
mockPrismaService.finding.findUnique.mockResolvedValue(createdFinding);
|
|
||||||
mockEmbeddingService.isConfigured.mockReturnValue(true);
|
|
||||||
mockEmbeddingService.generateEmbedding.mockResolvedValue([0.1, 0.2, 0.3]);
|
|
||||||
mockPrismaService.$executeRaw.mockResolvedValue(1);
|
|
||||||
|
|
||||||
const result = await service.create(mockWorkspaceId, createDto);
|
|
||||||
|
|
||||||
expect(result).toEqual(createdFinding);
|
|
||||||
expect(prisma.finding.create).toHaveBeenCalledWith({
|
|
||||||
data: expect.objectContaining({
|
|
||||||
workspaceId: mockWorkspaceId,
|
|
||||||
taskId: mockTaskId,
|
|
||||||
agentId: "research-agent",
|
|
||||||
type: "security",
|
|
||||||
title: "SQL injection risk",
|
|
||||||
}),
|
|
||||||
select: expect.any(Object),
|
|
||||||
});
|
|
||||||
expect(embeddingService.generateEmbedding).toHaveBeenCalledWith(createDto.summary);
|
|
||||||
expect(prisma.$executeRaw).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should create a finding without embedding when not configured", async () => {
|
|
||||||
const createDto = {
|
|
||||||
agentId: "research-agent",
|
|
||||||
type: "security",
|
|
||||||
title: "SQL injection risk",
|
|
||||||
data: { severity: "high" },
|
|
||||||
summary: "Potential SQL injection in search endpoint.",
|
|
||||||
};
|
|
||||||
|
|
||||||
const createdFinding = {
|
|
||||||
id: mockFindingId,
|
|
||||||
workspaceId: mockWorkspaceId,
|
|
||||||
taskId: null,
|
|
||||||
...createDto,
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
mockPrismaService.finding.create.mockResolvedValue(createdFinding);
|
|
||||||
mockEmbeddingService.isConfigured.mockReturnValue(false);
|
|
||||||
|
|
||||||
const result = await service.create(mockWorkspaceId, createDto);
|
|
||||||
|
|
||||||
expect(result).toEqual(createdFinding);
|
|
||||||
expect(embeddingService.generateEmbedding).not.toHaveBeenCalled();
|
|
||||||
expect(prisma.$executeRaw).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("findAll", () => {
|
|
||||||
it("should return paginated findings with filters", async () => {
|
|
||||||
const findings = [
|
|
||||||
{
|
|
||||||
id: mockFindingId,
|
|
||||||
workspaceId: mockWorkspaceId,
|
|
||||||
taskId: null,
|
|
||||||
agentId: "research-agent",
|
|
||||||
type: "security",
|
|
||||||
title: "SQL injection risk",
|
|
||||||
data: { severity: "high" },
|
|
||||||
summary: "Potential SQL injection in search endpoint.",
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
mockPrismaService.finding.findMany.mockResolvedValue(findings);
|
|
||||||
mockPrismaService.finding.count.mockResolvedValue(1);
|
|
||||||
|
|
||||||
const result = await service.findAll(mockWorkspaceId, {
|
|
||||||
page: 1,
|
|
||||||
limit: 10,
|
|
||||||
type: "security",
|
|
||||||
agentId: "research-agent",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
|
||||||
data: findings,
|
|
||||||
meta: {
|
|
||||||
total: 1,
|
|
||||||
page: 1,
|
|
||||||
limit: 10,
|
|
||||||
totalPages: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(prisma.finding.findMany).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
where: {
|
|
||||||
workspaceId: mockWorkspaceId,
|
|
||||||
type: "security",
|
|
||||||
agentId: "research-agent",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("findOne", () => {
|
|
||||||
it("should return a finding", async () => {
|
|
||||||
const finding = {
|
|
||||||
id: mockFindingId,
|
|
||||||
workspaceId: mockWorkspaceId,
|
|
||||||
taskId: null,
|
|
||||||
agentId: "research-agent",
|
|
||||||
type: "security",
|
|
||||||
title: "SQL injection risk",
|
|
||||||
data: { severity: "high" },
|
|
||||||
summary: "Potential SQL injection in search endpoint.",
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
mockPrismaService.finding.findUnique.mockResolvedValue(finding);
|
|
||||||
|
|
||||||
const result = await service.findOne(mockFindingId, mockWorkspaceId);
|
|
||||||
|
|
||||||
expect(result).toEqual(finding);
|
|
||||||
expect(prisma.finding.findUnique).toHaveBeenCalledWith({
|
|
||||||
where: {
|
|
||||||
id: mockFindingId,
|
|
||||||
workspaceId: mockWorkspaceId,
|
|
||||||
},
|
|
||||||
select: expect.any(Object),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw when finding does not exist", async () => {
|
|
||||||
mockPrismaService.finding.findUnique.mockResolvedValue(null);
|
|
||||||
|
|
||||||
await expect(service.findOne(mockFindingId, mockWorkspaceId)).rejects.toThrow(
|
|
||||||
NotFoundException
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("search", () => {
|
|
||||||
it("should throw BadRequestException when embeddings are not configured", async () => {
|
|
||||||
mockEmbeddingService.isConfigured.mockReturnValue(false);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
service.search(mockWorkspaceId, {
|
|
||||||
query: "sql injection",
|
|
||||||
})
|
|
||||||
).rejects.toThrow(BadRequestException);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return similarity-ranked search results", async () => {
|
|
||||||
mockEmbeddingService.isConfigured.mockReturnValue(true);
|
|
||||||
mockEmbeddingService.generateEmbedding.mockResolvedValue([0.1, 0.2, 0.3]);
|
|
||||||
mockPrismaService.$queryRaw
|
|
||||||
.mockResolvedValueOnce([
|
|
||||||
{
|
|
||||||
id: mockFindingId,
|
|
||||||
workspace_id: mockWorkspaceId,
|
|
||||||
task_id: null,
|
|
||||||
agent_id: "research-agent",
|
|
||||||
type: "security",
|
|
||||||
title: "SQL injection risk",
|
|
||||||
data: { severity: "high" },
|
|
||||||
summary: "Potential SQL injection in search endpoint.",
|
|
||||||
created_at: new Date(),
|
|
||||||
updated_at: new Date(),
|
|
||||||
score: 0.91,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
.mockResolvedValueOnce([{ count: BigInt(1) }]);
|
|
||||||
|
|
||||||
const result = await service.search(mockWorkspaceId, {
|
|
||||||
query: "sql injection",
|
|
||||||
page: 1,
|
|
||||||
limit: 5,
|
|
||||||
similarityThreshold: 0.5,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.query).toBe("sql injection");
|
|
||||||
expect(result.data).toHaveLength(1);
|
|
||||||
expect(result.data[0].score).toBe(0.91);
|
|
||||||
expect(result.meta.total).toBe(1);
|
|
||||||
expect(prisma.$queryRaw).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("remove", () => {
|
|
||||||
it("should delete a finding", async () => {
|
|
||||||
mockPrismaService.finding.findUnique.mockResolvedValue({
|
|
||||||
id: mockFindingId,
|
|
||||||
workspaceId: mockWorkspaceId,
|
|
||||||
});
|
|
||||||
mockPrismaService.finding.delete.mockResolvedValue({
|
|
||||||
id: mockFindingId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await service.remove(mockFindingId, mockWorkspaceId);
|
|
||||||
|
|
||||||
expect(result).toEqual({ message: "Finding deleted successfully" });
|
|
||||||
expect(prisma.finding.delete).toHaveBeenCalledWith({
|
|
||||||
where: {
|
|
||||||
id: mockFindingId,
|
|
||||||
workspaceId: mockWorkspaceId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw when finding does not exist", async () => {
|
|
||||||
mockPrismaService.finding.findUnique.mockResolvedValue(null);
|
|
||||||
|
|
||||||
await expect(service.remove(mockFindingId, mockWorkspaceId)).rejects.toThrow(
|
|
||||||
NotFoundException
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,337 +0,0 @@
|
|||||||
import { BadRequestException, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
|
||||||
import { Prisma } from "@prisma/client";
|
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
|
||||||
import { EmbeddingService } from "../knowledge/services/embedding.service";
|
|
||||||
import type { CreateFindingDto, QueryFindingsDto, SearchFindingsDto } from "./dto";
|
|
||||||
|
|
||||||
const findingSelect = {
|
|
||||||
id: true,
|
|
||||||
workspaceId: true,
|
|
||||||
taskId: true,
|
|
||||||
agentId: true,
|
|
||||||
type: true,
|
|
||||||
title: true,
|
|
||||||
data: true,
|
|
||||||
summary: true,
|
|
||||||
createdAt: true,
|
|
||||||
updatedAt: true,
|
|
||||||
} satisfies Prisma.FindingSelect;
|
|
||||||
|
|
||||||
type FindingRecord = Prisma.FindingGetPayload<{ select: typeof findingSelect }>;
|
|
||||||
|
|
||||||
interface RawFindingSearchResult {
|
|
||||||
id: string;
|
|
||||||
workspace_id: string;
|
|
||||||
task_id: string | null;
|
|
||||||
agent_id: string;
|
|
||||||
type: string;
|
|
||||||
title: string;
|
|
||||||
data: Prisma.JsonValue;
|
|
||||||
summary: string;
|
|
||||||
created_at: Date;
|
|
||||||
updated_at: Date;
|
|
||||||
score: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FindingSearchResult extends FindingRecord {
|
|
||||||
score: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PaginatedMeta {
|
|
||||||
total: number;
|
|
||||||
page: number;
|
|
||||||
limit: number;
|
|
||||||
totalPages: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PaginatedFindingsResponse {
|
|
||||||
data: FindingRecord[];
|
|
||||||
meta: PaginatedMeta;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FindingsSearchResponse {
|
|
||||||
data: FindingSearchResult[];
|
|
||||||
meta: PaginatedMeta;
|
|
||||||
query: string;
|
|
||||||
similarityThreshold: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Service for managing structured findings with vector search support
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class FindingsService {
|
|
||||||
private readonly logger = new Logger(FindingsService.name);
|
|
||||||
private readonly defaultSimilarityThreshold: number;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly prisma: PrismaService,
|
|
||||||
private readonly embeddingService: EmbeddingService
|
|
||||||
) {
|
|
||||||
const parsedThreshold = Number.parseFloat(process.env.FINDINGS_SIMILARITY_THRESHOLD ?? "0.5");
|
|
||||||
|
|
||||||
this.defaultSimilarityThreshold =
|
|
||||||
Number.isFinite(parsedThreshold) && parsedThreshold >= 0 && parsedThreshold <= 1
|
|
||||||
? parsedThreshold
|
|
||||||
: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a finding and generate its embedding from the summary when available
|
|
||||||
*/
|
|
||||||
async create(workspaceId: string, createFindingDto: CreateFindingDto): Promise<FindingRecord> {
|
|
||||||
if (createFindingDto.taskId) {
|
|
||||||
const task = await this.prisma.agentTask.findUnique({
|
|
||||||
where: {
|
|
||||||
id: createFindingDto.taskId,
|
|
||||||
workspaceId,
|
|
||||||
},
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!task) {
|
|
||||||
throw new NotFoundException(`Agent task with ID ${createFindingDto.taskId} not found`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const createInput: Prisma.FindingUncheckedCreateInput = {
|
|
||||||
workspaceId,
|
|
||||||
agentId: createFindingDto.agentId,
|
|
||||||
type: createFindingDto.type,
|
|
||||||
title: createFindingDto.title,
|
|
||||||
data: createFindingDto.data as Prisma.InputJsonValue,
|
|
||||||
summary: createFindingDto.summary,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (createFindingDto.taskId) {
|
|
||||||
createInput.taskId = createFindingDto.taskId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const finding = await this.prisma.finding.create({
|
|
||||||
data: createInput,
|
|
||||||
select: findingSelect,
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.generateAndStoreEmbedding(finding.id, workspaceId, finding.summary);
|
|
||||||
|
|
||||||
if (this.embeddingService.isConfigured()) {
|
|
||||||
return this.findOne(finding.id, workspaceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return finding;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get paginated findings with optional filters
|
|
||||||
*/
|
|
||||||
async findAll(workspaceId: string, query: QueryFindingsDto): Promise<PaginatedFindingsResponse> {
|
|
||||||
const page = query.page ?? 1;
|
|
||||||
const limit = query.limit ?? 50;
|
|
||||||
const skip = (page - 1) * limit;
|
|
||||||
|
|
||||||
const where: Prisma.FindingWhereInput = {
|
|
||||||
workspaceId,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (query.agentId) {
|
|
||||||
where.agentId = query.agentId;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (query.type) {
|
|
||||||
where.type = query.type;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (query.taskId) {
|
|
||||||
where.taskId = query.taskId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [data, total] = await Promise.all([
|
|
||||||
this.prisma.finding.findMany({
|
|
||||||
where,
|
|
||||||
select: findingSelect,
|
|
||||||
orderBy: {
|
|
||||||
createdAt: "desc",
|
|
||||||
},
|
|
||||||
skip,
|
|
||||||
take: limit,
|
|
||||||
}),
|
|
||||||
this.prisma.finding.count({ where }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
data,
|
|
||||||
meta: {
|
|
||||||
total,
|
|
||||||
page,
|
|
||||||
limit,
|
|
||||||
totalPages: Math.ceil(total / limit),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a single finding by ID
|
|
||||||
*/
|
|
||||||
async findOne(id: string, workspaceId: string): Promise<FindingRecord> {
|
|
||||||
const finding = await this.prisma.finding.findUnique({
|
|
||||||
where: {
|
|
||||||
id,
|
|
||||||
workspaceId,
|
|
||||||
},
|
|
||||||
select: findingSelect,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!finding) {
|
|
||||||
throw new NotFoundException(`Finding with ID ${id} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return finding;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Semantic search findings using vector similarity
|
|
||||||
*/
|
|
||||||
async search(workspaceId: string, searchDto: SearchFindingsDto): Promise<FindingsSearchResponse> {
|
|
||||||
if (!this.embeddingService.isConfigured()) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
"Finding vector search requires OPENAI_API_KEY to be configured"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const page = searchDto.page ?? 1;
|
|
||||||
const limit = searchDto.limit ?? 20;
|
|
||||||
const offset = (page - 1) * limit;
|
|
||||||
const similarityThreshold = searchDto.similarityThreshold ?? this.defaultSimilarityThreshold;
|
|
||||||
const distanceThreshold = 1 - similarityThreshold;
|
|
||||||
|
|
||||||
const queryEmbedding = await this.embeddingService.generateEmbedding(searchDto.query);
|
|
||||||
const embeddingString = `[${queryEmbedding.join(",")}]`;
|
|
||||||
|
|
||||||
const agentFilter = searchDto.agentId
|
|
||||||
? Prisma.sql`AND f.agent_id = ${searchDto.agentId}`
|
|
||||||
: Prisma.sql``;
|
|
||||||
const typeFilter = searchDto.type ? Prisma.sql`AND f.type = ${searchDto.type}` : Prisma.sql``;
|
|
||||||
const taskFilter = searchDto.taskId
|
|
||||||
? Prisma.sql`AND f.task_id = ${searchDto.taskId}::uuid`
|
|
||||||
: Prisma.sql``;
|
|
||||||
|
|
||||||
const searchResults = await this.prisma.$queryRaw<RawFindingSearchResult[]>`
|
|
||||||
SELECT
|
|
||||||
f.id,
|
|
||||||
f.workspace_id,
|
|
||||||
f.task_id,
|
|
||||||
f.agent_id,
|
|
||||||
f.type,
|
|
||||||
f.title,
|
|
||||||
f.data,
|
|
||||||
f.summary,
|
|
||||||
f.created_at,
|
|
||||||
f.updated_at,
|
|
||||||
(1 - (f.embedding <=> ${embeddingString}::vector)) AS score
|
|
||||||
FROM findings f
|
|
||||||
WHERE f.workspace_id = ${workspaceId}::uuid
|
|
||||||
AND f.embedding IS NOT NULL
|
|
||||||
${agentFilter}
|
|
||||||
${typeFilter}
|
|
||||||
${taskFilter}
|
|
||||||
AND (f.embedding <=> ${embeddingString}::vector) <= ${distanceThreshold}
|
|
||||||
ORDER BY f.embedding <=> ${embeddingString}::vector
|
|
||||||
LIMIT ${limit}
|
|
||||||
OFFSET ${offset}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const countResult = await this.prisma.$queryRaw<[{ count: bigint }]>`
|
|
||||||
SELECT COUNT(*) as count
|
|
||||||
FROM findings f
|
|
||||||
WHERE f.workspace_id = ${workspaceId}::uuid
|
|
||||||
AND f.embedding IS NOT NULL
|
|
||||||
${agentFilter}
|
|
||||||
${typeFilter}
|
|
||||||
${taskFilter}
|
|
||||||
AND (f.embedding <=> ${embeddingString}::vector) <= ${distanceThreshold}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const total = Number(countResult[0].count);
|
|
||||||
|
|
||||||
const data: FindingSearchResult[] = searchResults.map((row) => ({
|
|
||||||
id: row.id,
|
|
||||||
workspaceId: row.workspace_id,
|
|
||||||
taskId: row.task_id,
|
|
||||||
agentId: row.agent_id,
|
|
||||||
type: row.type,
|
|
||||||
title: row.title,
|
|
||||||
data: row.data,
|
|
||||||
summary: row.summary,
|
|
||||||
createdAt: row.created_at,
|
|
||||||
updatedAt: row.updated_at,
|
|
||||||
score: row.score,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
|
||||||
data,
|
|
||||||
meta: {
|
|
||||||
total,
|
|
||||||
page,
|
|
||||||
limit,
|
|
||||||
totalPages: Math.ceil(total / limit),
|
|
||||||
},
|
|
||||||
query: searchDto.query,
|
|
||||||
similarityThreshold,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a finding
|
|
||||||
*/
|
|
||||||
async remove(id: string, workspaceId: string): Promise<{ message: string }> {
|
|
||||||
const existingFinding = await this.prisma.finding.findUnique({
|
|
||||||
where: {
|
|
||||||
id,
|
|
||||||
workspaceId,
|
|
||||||
},
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!existingFinding) {
|
|
||||||
throw new NotFoundException(`Finding with ID ${id} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.prisma.finding.delete({
|
|
||||||
where: {
|
|
||||||
id,
|
|
||||||
workspaceId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return { message: "Finding deleted successfully" };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate and persist embedding for a finding summary
|
|
||||||
*/
|
|
||||||
private async generateAndStoreEmbedding(
|
|
||||||
findingId: string,
|
|
||||||
workspaceId: string,
|
|
||||||
summary: string
|
|
||||||
): Promise<void> {
|
|
||||||
if (!this.embeddingService.isConfigured()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const embedding = await this.embeddingService.generateEmbedding(summary);
|
|
||||||
const embeddingString = `[${embedding.join(",")}]`;
|
|
||||||
|
|
||||||
await this.prisma.$executeRaw`
|
|
||||||
UPDATE findings
|
|
||||||
SET embedding = ${embeddingString}::vector,
|
|
||||||
updated_at = NOW()
|
|
||||||
WHERE id = ${findingId}::uuid
|
|
||||||
AND workspace_id = ${workspaceId}::uuid
|
|
||||||
`;
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
this.logger.warn(`Failed to generate embedding for finding ${findingId}: ${message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
# MS22 Agent Memory Module
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Add per-agent key/value store: AgentMemory model + NestJS module with CRUD endpoints.
|
|
||||||
|
|
||||||
## Issues
|
|
||||||
|
|
||||||
- MS22-DB-002: Add AgentMemory schema model
|
|
||||||
- MS22-API-002: Add agent-memory NestJS module
|
|
||||||
|
|
||||||
## Plan
|
|
||||||
|
|
||||||
1. AgentMemory model → schema.prisma (after AgentSession, line 736)
|
|
||||||
2. Add `agentMemories AgentMemory[]` relation to Workspace model
|
|
||||||
3. Create apps/api/src/agent-memory/ with service, controller, DTOs, specs
|
|
||||||
4. Register in app.module.ts
|
|
||||||
5. Migrate: `prisma migrate dev --name ms22_agent_memory`
|
|
||||||
6. lint + build
|
|
||||||
7. Commit
|
|
||||||
|
|
||||||
## Endpoints
|
|
||||||
|
|
||||||
- PUT /api/agents/:agentId/memory/:key (upsert)
|
|
||||||
- GET /api/agents/:agentId/memory (list all)
|
|
||||||
- GET /api/agents/:agentId/memory/:key (get one)
|
|
||||||
- DELETE /api/agents/:agentId/memory/:key (remove)
|
|
||||||
|
|
||||||
## Auth
|
|
||||||
|
|
||||||
- @UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard)
|
|
||||||
- @Workspace() decorator for workspaceId
|
|
||||||
- Permission.WORKSPACE_MEMBER for write ops
|
|
||||||
- Permission.WORKSPACE_ANY for read ops
|
|
||||||
|
|
||||||
## Schema
|
|
||||||
|
|
||||||
```prisma
|
|
||||||
model AgentMemory {
|
|
||||||
id String @id @default(uuid()) @db.Uuid
|
|
||||||
workspaceId String @map("workspace_id") @db.Uuid
|
|
||||||
agentId String @map("agent_id")
|
|
||||||
key String
|
|
||||||
value Json
|
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
|
|
||||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
|
|
||||||
|
|
||||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@unique([workspaceId, agentId, key])
|
|
||||||
@@index([workspaceId])
|
|
||||||
@@index([agentId])
|
|
||||||
@@map("agent_memories")
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Progress
|
|
||||||
|
|
||||||
- [ ] Schema
|
|
||||||
- [ ] Module files
|
|
||||||
- [ ] app.module.ts
|
|
||||||
- [ ] Migration
|
|
||||||
- [ ] lint/build
|
|
||||||
- [ ] Commit
|
|
||||||
@@ -74,8 +74,7 @@
|
|||||||
"tough-cookie": ">=4.1.3",
|
"tough-cookie": ">=4.1.3",
|
||||||
"undici": ">=6.23.0",
|
"undici": ">=6.23.0",
|
||||||
"rollup": ">=4.59.0",
|
"rollup": ">=4.59.0",
|
||||||
"serialize-javascript": ">=7.0.3",
|
"serialize-javascript": ">=7.0.3"
|
||||||
"multer": ">=2.1.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
21
pnpm-lock.yaml
generated
21
pnpm-lock.yaml
generated
@@ -17,7 +17,6 @@ overrides:
|
|||||||
undici: '>=6.23.0'
|
undici: '>=6.23.0'
|
||||||
rollup: '>=4.59.0'
|
rollup: '>=4.59.0'
|
||||||
serialize-javascript: '>=7.0.3'
|
serialize-javascript: '>=7.0.3'
|
||||||
multer: '>=2.1.0'
|
|
||||||
|
|
||||||
importers:
|
importers:
|
||||||
|
|
||||||
@@ -1604,7 +1603,6 @@ packages:
|
|||||||
|
|
||||||
'@mosaicstack/telemetry-client@0.1.1':
|
'@mosaicstack/telemetry-client@0.1.1':
|
||||||
resolution: {integrity: sha512-1udg6p4cs8rhQgQ2pKCfi7EpRlJieRRhA5CIqthRQ6HQZLgQ0wH+632jEulov3rlHSM1iplIQ+AAe5DWrvSkEA==, tarball: https://git.mosaicstack.dev/api/packages/mosaic/npm/%40mosaicstack%2Ftelemetry-client/-/0.1.1/telemetry-client-0.1.1.tgz}
|
resolution: {integrity: sha512-1udg6p4cs8rhQgQ2pKCfi7EpRlJieRRhA5CIqthRQ6HQZLgQ0wH+632jEulov3rlHSM1iplIQ+AAe5DWrvSkEA==, tarball: https://git.mosaicstack.dev/api/packages/mosaic/npm/%40mosaicstack%2Ftelemetry-client/-/0.1.1/telemetry-client-0.1.1.tgz}
|
||||||
engines: {node: '>=18'}
|
|
||||||
|
|
||||||
'@mrleebo/prisma-ast@0.13.1':
|
'@mrleebo/prisma-ast@0.13.1':
|
||||||
resolution: {integrity: sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw==}
|
resolution: {integrity: sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw==}
|
||||||
@@ -5807,6 +5805,10 @@ packages:
|
|||||||
mkdirp-classic@0.5.3:
|
mkdirp-classic@0.5.3:
|
||||||
resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==}
|
resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==}
|
||||||
|
|
||||||
|
mkdirp@0.5.6:
|
||||||
|
resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
mkdirp@3.0.1:
|
mkdirp@3.0.1:
|
||||||
resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==}
|
resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -5835,8 +5837,8 @@ packages:
|
|||||||
msgpackr@1.11.5:
|
msgpackr@1.11.5:
|
||||||
resolution: {integrity: sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==}
|
resolution: {integrity: sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==}
|
||||||
|
|
||||||
multer@2.1.0:
|
multer@2.0.2:
|
||||||
resolution: {integrity: sha512-TBm6j41rxNohqawsxlsWsNNh/VdV4QFXcBvRcPhXaA05EZ79z0qJ2bQFpync6JBoHTeNY5Q1JpG7AlTjdlfAEA==}
|
resolution: {integrity: sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==}
|
||||||
engines: {node: '>= 10.16.0'}
|
engines: {node: '>= 10.16.0'}
|
||||||
|
|
||||||
mute-stream@2.0.0:
|
mute-stream@2.0.0:
|
||||||
@@ -8840,7 +8842,7 @@ snapshots:
|
|||||||
'@nestjs/core': 11.1.12(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.12)(@nestjs/websockets@11.1.12)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
'@nestjs/core': 11.1.12(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.12)(@nestjs/websockets@11.1.12)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
cors: 2.8.5
|
cors: 2.8.5
|
||||||
express: 5.2.1
|
express: 5.2.1
|
||||||
multer: 2.1.0
|
multer: 2.0.2
|
||||||
path-to-regexp: 8.3.0
|
path-to-regexp: 8.3.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
@@ -13389,6 +13391,10 @@ snapshots:
|
|||||||
|
|
||||||
mkdirp-classic@0.5.3: {}
|
mkdirp-classic@0.5.3: {}
|
||||||
|
|
||||||
|
mkdirp@0.5.6:
|
||||||
|
dependencies:
|
||||||
|
minimist: 1.2.8
|
||||||
|
|
||||||
mkdirp@3.0.1: {}
|
mkdirp@3.0.1: {}
|
||||||
|
|
||||||
mlly@1.8.0:
|
mlly@1.8.0:
|
||||||
@@ -13430,12 +13436,15 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
msgpackr-extract: 3.0.3
|
msgpackr-extract: 3.0.3
|
||||||
|
|
||||||
multer@2.1.0:
|
multer@2.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
append-field: 1.0.0
|
append-field: 1.0.0
|
||||||
busboy: 1.6.0
|
busboy: 1.6.0
|
||||||
concat-stream: 2.0.0
|
concat-stream: 2.0.0
|
||||||
|
mkdirp: 0.5.6
|
||||||
|
object-assign: 4.1.1
|
||||||
type-is: 1.6.18
|
type-is: 1.6.18
|
||||||
|
xtend: 4.0.2
|
||||||
|
|
||||||
mute-stream@2.0.0: {}
|
mute-stream@2.0.0: {}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user