feat(#93): implement agent spawn via federation

Implements FED-010: Agent Spawn via Federation feature that enables
spawning and managing Claude agents on remote federated Mosaic Stack
instances via COMMAND message type.

Features:
- Federation agent command types (spawn, status, kill)
- FederationAgentService for handling agent operations
- Integration with orchestrator's agent spawner/lifecycle services
- API endpoints for spawning, querying status, and killing agents
- Full command routing through federation COMMAND infrastructure
- Comprehensive test coverage (12/12 tests passing)

Architecture:
- Hub → Spoke: Spawn agents on remote instances
- Command flow: FederationController → FederationAgentService →
  CommandService → Remote Orchestrator
- Response handling: Remote orchestrator returns agent status/results
- Security: Connection validation, signature verification

Files created:
- apps/api/src/federation/types/federation-agent.types.ts
- apps/api/src/federation/federation-agent.service.ts
- apps/api/src/federation/federation-agent.service.spec.ts

Files modified:
- apps/api/src/federation/command.service.ts (agent command routing)
- apps/api/src/federation/federation.controller.ts (agent endpoints)
- apps/api/src/federation/federation.module.ts (service registration)
- apps/orchestrator/src/api/agents/agents.controller.ts (status endpoint)
- apps/orchestrator/src/api/agents/agents.module.ts (lifecycle integration)

Testing:
- 12/12 tests passing for FederationAgentService
- All command service tests passing
- TypeScript compilation successful
- Linting passed

Refs #93

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Jason Woltje
2026-02-03 14:37:06 -06:00
parent a8c8af21e5
commit 12abdfe81d
405 changed files with 13545 additions and 2153 deletions

5
apps/api/.env.test Normal file
View File

@@ -0,0 +1,5 @@
DATABASE_URL="postgresql://test:test@localhost:5432/test"
ENCRYPTION_KEY="test-encryption-key-32-characters"
JWT_SECRET="test-jwt-secret"
INSTANCE_NAME="Test Instance"
INSTANCE_URL="https://test.example.com"

View File

@@ -5,6 +5,7 @@ The Mosaic Stack API is a NestJS-based backend service providing REST endpoints
## Overview
The API serves as the central backend for:
- **Task Management** - Create, update, track tasks with filtering and sorting
- **Event Management** - Calendar events and scheduling
- **Project Management** - Organize work into projects
@@ -18,20 +19,20 @@ The API serves as the central backend for:
## Available Modules
| Module | Base Path | Description |
|--------|-----------|-------------|
| **Tasks** | `/api/tasks` | CRUD operations for tasks with filtering |
| **Events** | `/api/events` | Calendar events and scheduling |
| **Projects** | `/api/projects` | Project management |
| **Knowledge** | `/api/knowledge/entries` | Wiki entries with markdown support |
| **Knowledge Tags** | `/api/knowledge/tags` | Tag management for knowledge entries |
| **Ideas** | `/api/ideas` | Quick capture and idea management |
| **Domains** | `/api/domains` | Domain categorization |
| **Personalities** | `/api/personalities` | AI personality configurations |
| **Widgets** | `/api/widgets` | Dashboard widget data |
| **Layouts** | `/api/layouts` | Dashboard layout configuration |
| **Ollama** | `/api/ollama` | LLM integration (generate, chat, embed) |
| **Users** | `/api/users/me/preferences` | User preferences |
| Module | Base Path | Description |
| ------------------ | --------------------------- | ---------------------------------------- |
| **Tasks** | `/api/tasks` | CRUD operations for tasks with filtering |
| **Events** | `/api/events` | Calendar events and scheduling |
| **Projects** | `/api/projects` | Project management |
| **Knowledge** | `/api/knowledge/entries` | Wiki entries with markdown support |
| **Knowledge Tags** | `/api/knowledge/tags` | Tag management for knowledge entries |
| **Ideas** | `/api/ideas` | Quick capture and idea management |
| **Domains** | `/api/domains` | Domain categorization |
| **Personalities** | `/api/personalities` | AI personality configurations |
| **Widgets** | `/api/widgets` | Dashboard widget data |
| **Layouts** | `/api/layouts` | Dashboard layout configuration |
| **Ollama** | `/api/ollama` | LLM integration (generate, chat, embed) |
| **Users** | `/api/users/me/preferences` | User preferences |
### Health Check
@@ -51,11 +52,11 @@ The API uses **BetterAuth** for authentication with the following features:
The API uses a layered guard system:
| Guard | Purpose | Applies To |
|-------|---------|------------|
| **AuthGuard** | Verifies user authentication via Bearer token | Most protected endpoints |
| **WorkspaceGuard** | Validates workspace membership and sets Row-Level Security (RLS) context | Workspace-scoped resources |
| **PermissionGuard** | Enforces role-based access control | Admin operations |
| Guard | Purpose | Applies To |
| ------------------- | ------------------------------------------------------------------------ | -------------------------- |
| **AuthGuard** | Verifies user authentication via Bearer token | Most protected endpoints |
| **WorkspaceGuard** | Validates workspace membership and sets Row-Level Security (RLS) context | Workspace-scoped resources |
| **PermissionGuard** | Enforces role-based access control | Admin operations |
### Workspace Roles
@@ -69,15 +70,16 @@ The API uses a layered guard system:
Used with `@RequirePermission()` decorator:
```typescript
Permission.WORKSPACE_OWNER // Requires OWNER role
Permission.WORKSPACE_ADMIN // Requires ADMIN or OWNER
Permission.WORKSPACE_MEMBER // Requires MEMBER, ADMIN, or OWNER
Permission.WORKSPACE_ANY // Any authenticated member including GUEST
Permission.WORKSPACE_OWNER; // Requires OWNER role
Permission.WORKSPACE_ADMIN; // Requires ADMIN or OWNER
Permission.WORKSPACE_MEMBER; // Requires MEMBER, ADMIN, or OWNER
Permission.WORKSPACE_ANY; // Any authenticated member including GUEST
```
### Providing Workspace Context
Workspace ID can be provided via:
1. **Header**: `X-Workspace-Id: <workspace-id>` (highest priority)
2. **URL Parameter**: `:workspaceId`
3. **Request Body**: `workspaceId` field
@@ -85,7 +87,7 @@ Workspace ID can be provided via:
### Example: Protected Controller
```typescript
@Controller('tasks')
@Controller("tasks")
@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard)
export class TasksController {
@Post()
@@ -98,13 +100,13 @@ export class TasksController {
## Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `PORT` | API server port | `3001` |
| `DATABASE_URL` | PostgreSQL connection string | Required |
| `NODE_ENV` | Environment (`development`, `production`) | - |
| `NEXT_PUBLIC_APP_URL` | Frontend application URL (for CORS) | `http://localhost:3000` |
| `WEB_URL` | WebSocket CORS origin | `http://localhost:3000` |
| Variable | Description | Default |
| --------------------- | ----------------------------------------- | ----------------------- |
| `PORT` | API server port | `3001` |
| `DATABASE_URL` | PostgreSQL connection string | Required |
| `NODE_ENV` | Environment (`development`, `production`) | - |
| `NEXT_PUBLIC_APP_URL` | Frontend application URL (for CORS) | `http://localhost:3000` |
| `WEB_URL` | WebSocket CORS origin | `http://localhost:3000` |
## Running Locally
@@ -117,22 +119,26 @@ export class TasksController {
### Setup
1. **Install dependencies:**
```bash
pnpm install
```
2. **Set up environment variables:**
```bash
cp .env.example .env # If available
# Edit .env with your DATABASE_URL
```
3. **Generate Prisma client:**
```bash
pnpm prisma:generate
```
4. **Run database migrations:**
```bash
pnpm prisma:migrate
```

View File

@@ -57,6 +57,7 @@
"gray-matter": "^4.0.3",
"highlight.js": "^11.11.1",
"ioredis": "^5.9.2",
"jose": "^6.1.3",
"marked": "^17.0.1",
"marked-gfm-heading-id": "^4.1.3",
"marked-highlight": "^2.2.3",

View File

@@ -340,7 +340,8 @@ pnpm prisma migrate deploy
\`\`\`
For setup instructions, see [[development-setup]].`,
summary: "Comprehensive documentation of the Mosaic Stack database schema and Prisma conventions",
summary:
"Comprehensive documentation of the Mosaic Stack database schema and Prisma conventions",
status: EntryStatus.PUBLISHED,
visibility: Visibility.WORKSPACE,
tags: ["architecture", "development"],
@@ -373,7 +374,7 @@ This is a draft document. See [[architecture-overview]] for current state.`,
// Create entries and track them for linking
const createdEntries = new Map<string, any>();
for (const entryData of entries) {
const entry = await tx.knowledgeEntry.create({
data: {
@@ -388,7 +389,7 @@ This is a draft document. See [[architecture-overview]] for current state.`,
updatedBy: user.id,
},
});
createdEntries.set(entryData.slug, entry);
// Create initial version
@@ -406,7 +407,7 @@ This is a draft document. See [[architecture-overview]] for current state.`,
// Add tags
for (const tagSlug of entryData.tags) {
const tag = tags.find(t => t.slug === tagSlug);
const tag = tags.find((t) => t.slug === tagSlug);
if (tag) {
await tx.knowledgeEntryTag.create({
data: {
@@ -427,7 +428,11 @@ This is a draft document. See [[architecture-overview]] for current state.`,
{ source: "welcome", target: "database-schema", text: "database-schema" },
{ source: "architecture-overview", target: "development-setup", text: "development-setup" },
{ source: "architecture-overview", target: "database-schema", text: "database-schema" },
{ source: "development-setup", target: "architecture-overview", text: "architecture-overview" },
{
source: "development-setup",
target: "architecture-overview",
text: "architecture-overview",
},
{ source: "development-setup", target: "database-schema", text: "database-schema" },
{ source: "database-schema", target: "architecture-overview", text: "architecture-overview" },
{ source: "database-schema", target: "development-setup", text: "development-setup" },
@@ -437,7 +442,7 @@ This is a draft document. See [[architecture-overview]] for current state.`,
for (const link of links) {
const sourceEntry = createdEntries.get(link.source);
const targetEntry = createdEntries.get(link.target);
if (sourceEntry && targetEntry) {
await tx.knowledgeLink.create({
data: {

View File

@@ -152,10 +152,7 @@ describe("ActivityController", () => {
const result = await controller.findOne("activity-123", mockWorkspaceId);
expect(result).toEqual(mockActivity);
expect(mockActivityService.findOne).toHaveBeenCalledWith(
"activity-123",
"workspace-123"
);
expect(mockActivityService.findOne).toHaveBeenCalledWith("activity-123", "workspace-123");
});
it("should return null if activity not found", async () => {
@@ -213,11 +210,7 @@ describe("ActivityController", () => {
it("should return audit trail for a task using authenticated user's workspaceId", async () => {
mockActivityService.getAuditTrail.mockResolvedValue(mockAuditTrail);
const result = await controller.getAuditTrail(
EntityType.TASK,
"task-123",
mockWorkspaceId
);
const result = await controller.getAuditTrail(EntityType.TASK, "task-123", mockWorkspaceId);
expect(result).toEqual(mockAuditTrail);
expect(mockActivityService.getAuditTrail).toHaveBeenCalledWith(
@@ -248,11 +241,7 @@ describe("ActivityController", () => {
mockActivityService.getAuditTrail.mockResolvedValue(eventAuditTrail);
const result = await controller.getAuditTrail(
EntityType.EVENT,
"event-123",
mockWorkspaceId
);
const result = await controller.getAuditTrail(EntityType.EVENT, "event-123", mockWorkspaceId);
expect(result).toEqual(eventAuditTrail);
expect(mockActivityService.getAuditTrail).toHaveBeenCalledWith(
@@ -312,11 +301,7 @@ describe("ActivityController", () => {
it("should return empty array if workspaceId is missing (service handles gracefully)", async () => {
mockActivityService.getAuditTrail.mockResolvedValue([]);
const result = await controller.getAuditTrail(
EntityType.TASK,
"task-123",
undefined as any
);
const result = await controller.getAuditTrail(EntityType.TASK, "task-123", undefined as any);
expect(result).toEqual([]);
expect(mockActivityService.getAuditTrail).toHaveBeenCalledWith(

View File

@@ -25,9 +25,7 @@ describe("ActivityLoggingInterceptor", () => {
],
}).compile();
interceptor = module.get<ActivityLoggingInterceptor>(
ActivityLoggingInterceptor
);
interceptor = module.get<ActivityLoggingInterceptor>(ActivityLoggingInterceptor);
activityService = module.get<ActivityService>(ActivityService);
vi.clearAllMocks();
@@ -324,9 +322,7 @@ describe("ActivityLoggingInterceptor", () => {
const context = createMockExecutionContext("POST", {}, {}, user);
const next = createMockCallHandler({ id: "test-123" });
mockActivityService.logActivity.mockRejectedValue(
new Error("Logging failed")
);
mockActivityService.logActivity.mockRejectedValue(new Error("Logging failed"));
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
@@ -727,9 +723,7 @@ describe("ActivityLoggingInterceptor", () => {
expect(logCall.details.data.settings.apiKey).toBe("[REDACTED]");
expect(logCall.details.data.settings.public).toBe("visible_data");
expect(logCall.details.data.settings.auth.token).toBe("[REDACTED]");
expect(logCall.details.data.settings.auth.refreshToken).toBe(
"[REDACTED]"
);
expect(logCall.details.data.settings.auth.refreshToken).toBe("[REDACTED]");
resolve();
});
});

View File

@@ -86,11 +86,7 @@ describe("AgentTasksController", () => {
const result = await controller.create(createDto, workspaceId, user);
expect(mockAgentTasksService.create).toHaveBeenCalledWith(
workspaceId,
user.id,
createDto
);
expect(mockAgentTasksService.create).toHaveBeenCalledWith(workspaceId, user.id, createDto);
expect(result).toEqual(mockTask);
});
});
@@ -183,10 +179,7 @@ describe("AgentTasksController", () => {
const result = await controller.findOne(id, workspaceId);
expect(mockAgentTasksService.findOne).toHaveBeenCalledWith(
id,
workspaceId
);
expect(mockAgentTasksService.findOne).toHaveBeenCalledWith(id, workspaceId);
expect(result).toEqual(mockTask);
});
});
@@ -220,11 +213,7 @@ describe("AgentTasksController", () => {
const result = await controller.update(id, updateDto, workspaceId);
expect(mockAgentTasksService.update).toHaveBeenCalledWith(
id,
workspaceId,
updateDto
);
expect(mockAgentTasksService.update).toHaveBeenCalledWith(id, workspaceId, updateDto);
expect(result).toEqual(mockTask);
});
});
@@ -240,10 +229,7 @@ describe("AgentTasksController", () => {
const result = await controller.remove(id, workspaceId);
expect(mockAgentTasksService.remove).toHaveBeenCalledWith(
id,
workspaceId
);
expect(mockAgentTasksService.remove).toHaveBeenCalledWith(id, workspaceId);
expect(result).toEqual(mockResponse);
});
});

View File

@@ -242,9 +242,7 @@ describe("AgentTasksService", () => {
mockPrismaService.agentTask.findUnique.mockResolvedValue(null);
await expect(service.findOne(id, workspaceId)).rejects.toThrow(
NotFoundException
);
await expect(service.findOne(id, workspaceId)).rejects.toThrow(NotFoundException);
});
});
@@ -316,9 +314,7 @@ describe("AgentTasksService", () => {
mockPrismaService.agentTask.findUnique.mockResolvedValue(null);
await expect(
service.update(id, workspaceId, updateDto)
).rejects.toThrow(NotFoundException);
await expect(service.update(id, workspaceId, updateDto)).rejects.toThrow(NotFoundException);
});
});
@@ -345,9 +341,7 @@ describe("AgentTasksService", () => {
mockPrismaService.agentTask.findUnique.mockResolvedValue(null);
await expect(service.remove(id, workspaceId)).rejects.toThrow(
NotFoundException
);
await expect(service.remove(id, workspaceId)).rejects.toThrow(NotFoundException);
});
});
});

View File

@@ -551,7 +551,8 @@ describe("DiscordService", () => {
Authorization: "Bearer secret_token_12345",
},
};
(errorWithSecrets as any).token = "MTk4NjIyNDgzNDcxOTI1MjQ4.Cl2FMQ.ZnCjm1XVW7vRze4b7Cq4se7kKWs";
(errorWithSecrets as any).token =
"MTk4NjIyNDgzNDcxOTI1MjQ4.Cl2FMQ.ZnCjm1XVW7vRze4b7Cq4se7kKWs";
// Trigger error event handler
expect(mockErrorCallbacks.length).toBeGreaterThan(0);

View File

@@ -5,6 +5,7 @@ This directory contains shared guards and decorators for workspace-based permiss
## Overview
The permission system provides:
- **Workspace isolation** via Row-Level Security (RLS)
- **Role-based access control** (RBAC) using workspace member roles
- **Declarative permission requirements** using decorators
@@ -18,6 +19,7 @@ Located in `../auth/guards/auth.guard.ts`
Verifies user authentication and attaches user data to the request.
**Sets on request:**
- `request.user` - Authenticated user object
- `request.session` - User session data
@@ -26,23 +28,27 @@ Verifies user authentication and attaches user data to the request.
Validates workspace access and sets up RLS context.
**Responsibilities:**
1. Extracts workspace ID from request (header, param, or body)
2. Verifies user is a member of the workspace
3. Sets the current user context for RLS policies
4. Attaches workspace context to the request
**Sets on request:**
- `request.workspace.id` - Validated workspace ID
- `request.user.workspaceId` - Workspace ID (for backward compatibility)
**Workspace ID Sources (in priority order):**
1. `X-Workspace-Id` header
2. `:workspaceId` URL parameter
3. `workspaceId` in request body
**Example:**
```typescript
@Controller('tasks')
@Controller("tasks")
@UseGuards(AuthGuard, WorkspaceGuard)
export class TasksController {
@Get()
@@ -57,23 +63,26 @@ export class TasksController {
Enforces role-based access control using workspace member roles.
**Responsibilities:**
1. Reads required permission from `@RequirePermission()` decorator
2. Fetches user's role in the workspace
3. Checks if role satisfies the required permission
4. Attaches role to request for convenience
**Sets on request:**
- `request.user.workspaceRole` - User's role in the workspace
**Must be used after AuthGuard and WorkspaceGuard.**
**Example:**
```typescript
@Controller('admin')
@Controller("admin")
@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard)
export class AdminController {
@RequirePermission(Permission.WORKSPACE_ADMIN)
@Delete('data')
@Delete("data")
async deleteData() {
// Only ADMIN or OWNER can execute
}
@@ -88,14 +97,15 @@ Specifies the minimum permission level required for a route.
**Permission Levels:**
| Permission | Allowed Roles | Use Case |
|------------|--------------|----------|
| `WORKSPACE_OWNER` | OWNER | Critical operations (delete workspace, transfer ownership) |
| `WORKSPACE_ADMIN` | OWNER, ADMIN | Administrative functions (manage members, settings) |
| `WORKSPACE_MEMBER` | OWNER, ADMIN, MEMBER | Standard operations (create/edit content) |
| `WORKSPACE_ANY` | All roles including GUEST | Read-only or basic access |
| Permission | Allowed Roles | Use Case |
| ------------------ | ------------------------- | ---------------------------------------------------------- |
| `WORKSPACE_OWNER` | OWNER | Critical operations (delete workspace, transfer ownership) |
| `WORKSPACE_ADMIN` | OWNER, ADMIN | Administrative functions (manage members, settings) |
| `WORKSPACE_MEMBER` | OWNER, ADMIN, MEMBER | Standard operations (create/edit content) |
| `WORKSPACE_ANY` | All roles including GUEST | Read-only or basic access |
**Example:**
```typescript
@RequirePermission(Permission.WORKSPACE_ADMIN)
@Post('invite')
@@ -109,6 +119,7 @@ async inviteMember(@Body() inviteDto: InviteDto) {
Parameter decorator to extract the validated workspace ID.
**Example:**
```typescript
@Get()
async getTasks(@Workspace() workspaceId: string) {
@@ -121,6 +132,7 @@ async getTasks(@Workspace() workspaceId: string) {
Parameter decorator to extract the full workspace context.
**Example:**
```typescript
@Get()
async getTasks(@WorkspaceContext() workspace: { id: string }) {
@@ -135,6 +147,7 @@ Located in `../auth/decorators/current-user.decorator.ts`
Extracts the authenticated user from the request.
**Example:**
```typescript
@Post()
async create(@CurrentUser() user: any, @Body() dto: CreateDto) {
@@ -153,7 +166,7 @@ import { WorkspaceGuard, PermissionGuard } from "../common/guards";
import { Workspace, Permission, RequirePermission } from "../common/decorators";
import { CurrentUser } from "../auth/decorators/current-user.decorator";
@Controller('resources')
@Controller("resources")
@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard)
export class ResourcesController {
@Get()
@@ -164,17 +177,13 @@ export class ResourcesController {
@Post()
@RequirePermission(Permission.WORKSPACE_MEMBER)
async create(
@Workspace() workspaceId: string,
@CurrentUser() user: any,
@Body() dto: CreateDto
) {
async create(@Workspace() workspaceId: string, @CurrentUser() user: any, @Body() dto: CreateDto) {
// Members and above can create
}
@Delete(':id')
@Delete(":id")
@RequirePermission(Permission.WORKSPACE_ADMIN)
async delete(@Param('id') id: string) {
async delete(@Param("id") id: string) {
// Only admins can delete
}
}
@@ -185,24 +194,32 @@ export class ResourcesController {
Different endpoints can have different permission requirements:
```typescript
@Controller('projects')
@Controller("projects")
@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard)
export class ProjectsController {
@Get()
@RequirePermission(Permission.WORKSPACE_ANY)
async list() { /* Anyone can view */ }
async list() {
/* Anyone can view */
}
@Post()
@RequirePermission(Permission.WORKSPACE_MEMBER)
async create() { /* Members can create */ }
async create() {
/* Members can create */
}
@Patch('settings')
@Patch("settings")
@RequirePermission(Permission.WORKSPACE_ADMIN)
async updateSettings() { /* Only admins */ }
async updateSettings() {
/* Only admins */
}
@Delete()
@RequirePermission(Permission.WORKSPACE_OWNER)
async deleteProject() { /* Only owner */ }
async deleteProject() {
/* Only owner */
}
}
```
@@ -211,17 +228,19 @@ export class ProjectsController {
The workspace ID can be provided in multiple ways:
**Via Header (Recommended for SPAs):**
```typescript
// Frontend
fetch('/api/tasks', {
fetch("/api/tasks", {
headers: {
'Authorization': 'Bearer <token>',
'X-Workspace-Id': 'workspace-uuid',
}
})
Authorization: "Bearer <token>",
"X-Workspace-Id": "workspace-uuid",
},
});
```
**Via URL Parameter:**
```typescript
@Get(':workspaceId/tasks')
async getTasks(@Param('workspaceId') workspaceId: string) {
@@ -230,6 +249,7 @@ async getTasks(@Param('workspaceId') workspaceId: string) {
```
**Via Request Body:**
```typescript
@Post()
async create(@Body() dto: { workspaceId: string; name: string }) {
@@ -240,6 +260,7 @@ async create(@Body() dto: { workspaceId: string; name: string }) {
## Row-Level Security (RLS)
When `WorkspaceGuard` is applied, it automatically:
1. Calls `setCurrentUser(userId)` to set the RLS context
2. All subsequent database queries are automatically filtered by RLS policies
3. Users can only access data in workspaces they're members of
@@ -249,10 +270,12 @@ When `WorkspaceGuard` is applied, it automatically:
## Testing
Tests are provided for both guards:
- `workspace.guard.spec.ts` - WorkspaceGuard tests
- `permission.guard.spec.ts` - PermissionGuard tests
**Run tests:**
```bash
npm test -- workspace.guard.spec
npm test -- permission.guard.spec

View File

@@ -104,7 +104,7 @@ describe("BaseFilterDto", () => {
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
expect(errors.some(e => e.property === "sortOrder")).toBe(true);
expect(errors.some((e) => e.property === "sortOrder")).toBe(true);
});
it("should accept comma-separated sortBy fields", async () => {
@@ -134,7 +134,7 @@ describe("BaseFilterDto", () => {
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
expect(errors.some(e => e.property === "dateFrom")).toBe(true);
expect(errors.some((e) => e.property === "dateFrom")).toBe(true);
});
it("should reject invalid date format for dateTo", async () => {
@@ -144,7 +144,7 @@ describe("BaseFilterDto", () => {
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
expect(errors.some(e => e.property === "dateTo")).toBe(true);
expect(errors.some((e) => e.property === "dateTo")).toBe(true);
});
it("should trim whitespace from search query", async () => {
@@ -165,6 +165,6 @@ describe("BaseFilterDto", () => {
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
expect(errors.some(e => e.property === "search")).toBe(true);
expect(errors.some((e) => e.property === "search")).toBe(true);
});
});

View File

@@ -44,10 +44,7 @@ describe("PermissionGuard", () => {
vi.clearAllMocks();
});
const createMockExecutionContext = (
user: any,
workspace: any
): ExecutionContext => {
const createMockExecutionContext = (user: any, workspace: any): ExecutionContext => {
const mockRequest = {
user,
workspace,
@@ -67,10 +64,7 @@ describe("PermissionGuard", () => {
const workspaceId = "workspace-456";
it("should allow access when no permission is required", async () => {
const context = createMockExecutionContext(
{ id: userId },
{ id: workspaceId }
);
const context = createMockExecutionContext({ id: userId }, { id: workspaceId });
mockReflector.getAllAndOverride.mockReturnValue(undefined);
@@ -80,10 +74,7 @@ describe("PermissionGuard", () => {
});
it("should allow OWNER to access WORKSPACE_OWNER permission", async () => {
const context = createMockExecutionContext(
{ id: userId },
{ id: workspaceId }
);
const context = createMockExecutionContext({ id: userId }, { id: workspaceId });
mockReflector.getAllAndOverride.mockReturnValue(Permission.WORKSPACE_OWNER);
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({
@@ -99,30 +90,19 @@ describe("PermissionGuard", () => {
});
it("should deny ADMIN access to WORKSPACE_OWNER permission", async () => {
const context = createMockExecutionContext(
{ id: userId },
{ id: workspaceId }
);
const context = createMockExecutionContext({ id: userId }, { id: workspaceId });
mockReflector.getAllAndOverride.mockReturnValue(Permission.WORKSPACE_OWNER);
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({
role: WorkspaceMemberRole.ADMIN,
});
await expect(guard.canActivate(context)).rejects.toThrow(
ForbiddenException
);
await expect(guard.canActivate(context)).rejects.toThrow(ForbiddenException);
});
it("should allow OWNER and ADMIN to access WORKSPACE_ADMIN permission", async () => {
const context1 = createMockExecutionContext(
{ id: userId },
{ id: workspaceId }
);
const context2 = createMockExecutionContext(
{ id: userId },
{ id: workspaceId }
);
const context1 = createMockExecutionContext({ id: userId }, { id: workspaceId });
const context2 = createMockExecutionContext({ id: userId }, { id: workspaceId });
mockReflector.getAllAndOverride.mockReturnValue(Permission.WORKSPACE_ADMIN);
@@ -140,34 +120,20 @@ describe("PermissionGuard", () => {
});
it("should deny MEMBER access to WORKSPACE_ADMIN permission", async () => {
const context = createMockExecutionContext(
{ id: userId },
{ id: workspaceId }
);
const context = createMockExecutionContext({ id: userId }, { id: workspaceId });
mockReflector.getAllAndOverride.mockReturnValue(Permission.WORKSPACE_ADMIN);
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({
role: WorkspaceMemberRole.MEMBER,
});
await expect(guard.canActivate(context)).rejects.toThrow(
ForbiddenException
);
await expect(guard.canActivate(context)).rejects.toThrow(ForbiddenException);
});
it("should allow OWNER, ADMIN, and MEMBER to access WORKSPACE_MEMBER permission", async () => {
const context1 = createMockExecutionContext(
{ id: userId },
{ id: workspaceId }
);
const context2 = createMockExecutionContext(
{ id: userId },
{ id: workspaceId }
);
const context3 = createMockExecutionContext(
{ id: userId },
{ id: workspaceId }
);
const context1 = createMockExecutionContext({ id: userId }, { id: workspaceId });
const context2 = createMockExecutionContext({ id: userId }, { id: workspaceId });
const context3 = createMockExecutionContext({ id: userId }, { id: workspaceId });
mockReflector.getAllAndOverride.mockReturnValue(Permission.WORKSPACE_MEMBER);
@@ -191,26 +157,18 @@ describe("PermissionGuard", () => {
});
it("should deny GUEST access to WORKSPACE_MEMBER permission", async () => {
const context = createMockExecutionContext(
{ id: userId },
{ id: workspaceId }
);
const context = createMockExecutionContext({ id: userId }, { id: workspaceId });
mockReflector.getAllAndOverride.mockReturnValue(Permission.WORKSPACE_MEMBER);
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({
role: WorkspaceMemberRole.GUEST,
});
await expect(guard.canActivate(context)).rejects.toThrow(
ForbiddenException
);
await expect(guard.canActivate(context)).rejects.toThrow(ForbiddenException);
});
it("should allow any role (including GUEST) to access WORKSPACE_ANY permission", async () => {
const context = createMockExecutionContext(
{ id: userId },
{ id: workspaceId }
);
const context = createMockExecutionContext({ id: userId }, { id: workspaceId });
mockReflector.getAllAndOverride.mockReturnValue(Permission.WORKSPACE_ANY);
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({
@@ -227,9 +185,7 @@ describe("PermissionGuard", () => {
mockReflector.getAllAndOverride.mockReturnValue(Permission.WORKSPACE_MEMBER);
await expect(guard.canActivate(context)).rejects.toThrow(
ForbiddenException
);
await expect(guard.canActivate(context)).rejects.toThrow(ForbiddenException);
});
it("should throw ForbiddenException when workspace context is missing", async () => {
@@ -237,42 +193,28 @@ describe("PermissionGuard", () => {
mockReflector.getAllAndOverride.mockReturnValue(Permission.WORKSPACE_MEMBER);
await expect(guard.canActivate(context)).rejects.toThrow(
ForbiddenException
);
await expect(guard.canActivate(context)).rejects.toThrow(ForbiddenException);
});
it("should throw ForbiddenException when user is not a workspace member", async () => {
const context = createMockExecutionContext(
{ id: userId },
{ id: workspaceId }
);
const context = createMockExecutionContext({ id: userId }, { id: workspaceId });
mockReflector.getAllAndOverride.mockReturnValue(Permission.WORKSPACE_MEMBER);
mockPrismaService.workspaceMember.findUnique.mockResolvedValue(null);
await expect(guard.canActivate(context)).rejects.toThrow(
ForbiddenException
);
await expect(guard.canActivate(context)).rejects.toThrow(ForbiddenException);
await expect(guard.canActivate(context)).rejects.toThrow(
"You are not a member of this workspace"
);
});
it("should handle database errors gracefully", async () => {
const context = createMockExecutionContext(
{ id: userId },
{ id: workspaceId }
);
const context = createMockExecutionContext({ id: userId }, { id: workspaceId });
mockReflector.getAllAndOverride.mockReturnValue(Permission.WORKSPACE_MEMBER);
mockPrismaService.workspaceMember.findUnique.mockRejectedValue(
new Error("Database error")
);
mockPrismaService.workspaceMember.findUnique.mockRejectedValue(new Error("Database error"));
await expect(guard.canActivate(context)).rejects.toThrow(
ForbiddenException
);
await expect(guard.canActivate(context)).rejects.toThrow(ForbiddenException);
});
});
});

View File

@@ -58,10 +58,7 @@ describe("WorkspaceGuard", () => {
const workspaceId = "workspace-456";
it("should allow access when user is a workspace member (via header)", async () => {
const context = createMockExecutionContext(
{ id: userId },
{ "x-workspace-id": workspaceId }
);
const context = createMockExecutionContext({ id: userId }, { "x-workspace-id": workspaceId });
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({
workspaceId,
@@ -87,11 +84,7 @@ describe("WorkspaceGuard", () => {
});
it("should allow access when user is a workspace member (via URL param)", async () => {
const context = createMockExecutionContext(
{ id: userId },
{},
{ workspaceId }
);
const context = createMockExecutionContext({ id: userId }, {}, { workspaceId });
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({
workspaceId,
@@ -105,12 +98,7 @@ describe("WorkspaceGuard", () => {
});
it("should allow access when user is a workspace member (via body)", async () => {
const context = createMockExecutionContext(
{ id: userId },
{},
{},
{ workspaceId }
);
const context = createMockExecutionContext({ id: userId }, {}, {}, { workspaceId });
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({
workspaceId,
@@ -154,59 +142,38 @@ describe("WorkspaceGuard", () => {
});
it("should throw ForbiddenException when user is not authenticated", async () => {
const context = createMockExecutionContext(
null,
{ "x-workspace-id": workspaceId }
);
const context = createMockExecutionContext(null, { "x-workspace-id": workspaceId });
await expect(guard.canActivate(context)).rejects.toThrow(
ForbiddenException
);
await expect(guard.canActivate(context)).rejects.toThrow(
"User not authenticated"
);
await expect(guard.canActivate(context)).rejects.toThrow(ForbiddenException);
await expect(guard.canActivate(context)).rejects.toThrow("User not authenticated");
});
it("should throw BadRequestException when workspace ID is missing", async () => {
const context = createMockExecutionContext({ id: userId });
await expect(guard.canActivate(context)).rejects.toThrow(
BadRequestException
);
await expect(guard.canActivate(context)).rejects.toThrow(
"Workspace ID is required"
);
await expect(guard.canActivate(context)).rejects.toThrow(BadRequestException);
await expect(guard.canActivate(context)).rejects.toThrow("Workspace ID is required");
});
it("should throw ForbiddenException when user is not a workspace member", async () => {
const context = createMockExecutionContext(
{ id: userId },
{ "x-workspace-id": workspaceId }
);
const context = createMockExecutionContext({ id: userId }, { "x-workspace-id": workspaceId });
mockPrismaService.workspaceMember.findUnique.mockResolvedValue(null);
await expect(guard.canActivate(context)).rejects.toThrow(
ForbiddenException
);
await expect(guard.canActivate(context)).rejects.toThrow(ForbiddenException);
await expect(guard.canActivate(context)).rejects.toThrow(
"You do not have access to this workspace"
);
});
it("should handle database errors gracefully", async () => {
const context = createMockExecutionContext(
{ id: userId },
{ "x-workspace-id": workspaceId }
);
const context = createMockExecutionContext({ id: userId }, { "x-workspace-id": workspaceId });
mockPrismaService.workspaceMember.findUnique.mockRejectedValue(
new Error("Database connection failed")
);
await expect(guard.canActivate(context)).rejects.toThrow(
ForbiddenException
);
await expect(guard.canActivate(context)).rejects.toThrow(ForbiddenException);
});
});
});

View File

@@ -27,18 +27,14 @@ describe("QueryBuilder", () => {
it("should handle single field", () => {
const result = QueryBuilder.buildSearchFilter("test", ["title"]);
expect(result).toEqual({
OR: [
{ title: { contains: "test", mode: "insensitive" } },
],
OR: [{ title: { contains: "test", mode: "insensitive" } }],
});
});
it("should trim search query", () => {
const result = QueryBuilder.buildSearchFilter(" test ", ["title"]);
expect(result).toEqual({
OR: [
{ title: { contains: "test", mode: "insensitive" } },
],
OR: [{ title: { contains: "test", mode: "insensitive" } }],
});
});
});
@@ -56,26 +52,17 @@ describe("QueryBuilder", () => {
it("should build multi-field sort", () => {
const result = QueryBuilder.buildSortOrder("priority,dueDate", SortOrder.DESC);
expect(result).toEqual([
{ priority: "desc" },
{ dueDate: "desc" },
]);
expect(result).toEqual([{ priority: "desc" }, { dueDate: "desc" }]);
});
it("should handle mixed sorting with custom order per field", () => {
const result = QueryBuilder.buildSortOrder("priority:asc,dueDate:desc");
expect(result).toEqual([
{ priority: "asc" },
{ dueDate: "desc" },
]);
expect(result).toEqual([{ priority: "asc" }, { dueDate: "desc" }]);
});
it("should use default order when not specified per field", () => {
const result = QueryBuilder.buildSortOrder("priority,dueDate", SortOrder.ASC);
expect(result).toEqual([
{ priority: "asc" },
{ dueDate: "asc" },
]);
expect(result).toEqual([{ priority: "asc" }, { dueDate: "asc" }]);
});
});

View File

@@ -60,9 +60,7 @@ describe("CoordinatorIntegrationController - Security", () => {
}),
};
await expect(guard.canActivate(mockContext as any)).rejects.toThrow(
UnauthorizedException
);
await expect(guard.canActivate(mockContext as any)).rejects.toThrow(UnauthorizedException);
});
it("PATCH /coordinator/jobs/:id/status should require authentication", async () => {
@@ -72,9 +70,7 @@ describe("CoordinatorIntegrationController - Security", () => {
}),
};
await expect(guard.canActivate(mockContext as any)).rejects.toThrow(
UnauthorizedException
);
await expect(guard.canActivate(mockContext as any)).rejects.toThrow(UnauthorizedException);
});
it("PATCH /coordinator/jobs/:id/progress should require authentication", async () => {
@@ -84,9 +80,7 @@ describe("CoordinatorIntegrationController - Security", () => {
}),
};
await expect(guard.canActivate(mockContext as any)).rejects.toThrow(
UnauthorizedException
);
await expect(guard.canActivate(mockContext as any)).rejects.toThrow(UnauthorizedException);
});
it("POST /coordinator/jobs/:id/complete should require authentication", async () => {
@@ -96,9 +90,7 @@ describe("CoordinatorIntegrationController - Security", () => {
}),
};
await expect(guard.canActivate(mockContext as any)).rejects.toThrow(
UnauthorizedException
);
await expect(guard.canActivate(mockContext as any)).rejects.toThrow(UnauthorizedException);
});
it("POST /coordinator/jobs/:id/fail should require authentication", async () => {
@@ -108,9 +100,7 @@ describe("CoordinatorIntegrationController - Security", () => {
}),
};
await expect(guard.canActivate(mockContext as any)).rejects.toThrow(
UnauthorizedException
);
await expect(guard.canActivate(mockContext as any)).rejects.toThrow(UnauthorizedException);
});
it("GET /coordinator/jobs/:id should require authentication", async () => {
@@ -120,9 +110,7 @@ describe("CoordinatorIntegrationController - Security", () => {
}),
};
await expect(guard.canActivate(mockContext as any)).rejects.toThrow(
UnauthorizedException
);
await expect(guard.canActivate(mockContext as any)).rejects.toThrow(UnauthorizedException);
});
it("GET /coordinator/health should require authentication", async () => {
@@ -132,9 +120,7 @@ describe("CoordinatorIntegrationController - Security", () => {
}),
};
await expect(guard.canActivate(mockContext as any)).rejects.toThrow(
UnauthorizedException
);
await expect(guard.canActivate(mockContext as any)).rejects.toThrow(UnauthorizedException);
});
});
@@ -161,9 +147,7 @@ describe("CoordinatorIntegrationController - Security", () => {
}),
};
await expect(guard.canActivate(mockContext as any)).rejects.toThrow(
UnauthorizedException
);
await expect(guard.canActivate(mockContext as any)).rejects.toThrow(UnauthorizedException);
await expect(guard.canActivate(mockContext as any)).rejects.toThrow("Invalid API key");
});
});

View File

@@ -83,8 +83,20 @@ describe("CronService", () => {
it("should return all schedules for a workspace", async () => {
const workspaceId = "ws-123";
const expectedSchedules = [
{ id: "cron-1", workspaceId, expression: "0 9 * * *", command: "morning briefing", enabled: true },
{ id: "cron-2", workspaceId, expression: "0 17 * * *", command: "evening summary", enabled: true },
{
id: "cron-1",
workspaceId,
expression: "0 9 * * *",
command: "morning briefing",
enabled: true,
},
{
id: "cron-2",
workspaceId,
expression: "0 17 * * *",
command: "evening summary",
enabled: true,
},
];
mockPrisma.cronSchedule.findMany.mockResolvedValue(expectedSchedules);

View File

@@ -103,18 +103,10 @@ describe("DomainsController", () => {
mockDomainsService.create.mockResolvedValue(mockDomain);
const result = await controller.create(
createDto,
mockWorkspaceId,
mockUser
);
const result = await controller.create(createDto, mockWorkspaceId, mockUser);
expect(result).toEqual(mockDomain);
expect(service.create).toHaveBeenCalledWith(
mockWorkspaceId,
mockUserId,
createDto
);
expect(service.create).toHaveBeenCalledWith(mockWorkspaceId, mockUserId, createDto);
});
});
@@ -170,10 +162,7 @@ describe("DomainsController", () => {
const result = await controller.findOne(mockDomainId, mockWorkspaceId);
expect(result).toEqual(mockDomain);
expect(service.findOne).toHaveBeenCalledWith(
mockDomainId,
mockWorkspaceId
);
expect(service.findOne).toHaveBeenCalledWith(mockDomainId, mockWorkspaceId);
});
});
@@ -187,12 +176,7 @@ describe("DomainsController", () => {
const updatedDomain = { ...mockDomain, ...updateDto };
mockDomainsService.update.mockResolvedValue(updatedDomain);
const result = await controller.update(
mockDomainId,
updateDto,
mockWorkspaceId,
mockUser
);
const result = await controller.update(mockDomainId, updateDto, mockWorkspaceId, mockUser);
expect(result).toEqual(updatedDomain);
expect(service.update).toHaveBeenCalledWith(
@@ -210,11 +194,7 @@ describe("DomainsController", () => {
await controller.remove(mockDomainId, mockWorkspaceId, mockUser);
expect(service.remove).toHaveBeenCalledWith(
mockDomainId,
mockWorkspaceId,
mockUserId
);
expect(service.remove).toHaveBeenCalledWith(mockDomainId, mockWorkspaceId, mockUserId);
});
});
});

View File

@@ -63,11 +63,7 @@ describe("EventsController", () => {
const result = await controller.create(createDto, mockWorkspaceId, mockUser);
expect(result).toEqual(mockEvent);
expect(service.create).toHaveBeenCalledWith(
mockWorkspaceId,
mockUserId,
createDto
);
expect(service.create).toHaveBeenCalledWith(mockWorkspaceId, mockUserId, createDto);
});
it("should pass undefined workspaceId to service (validation handled by guards in production)", async () => {
@@ -153,7 +149,12 @@ describe("EventsController", () => {
await controller.update(mockEventId, updateDto, undefined as any, mockUser);
expect(mockEventsService.update).toHaveBeenCalledWith(mockEventId, undefined, mockUserId, updateDto);
expect(mockEventsService.update).toHaveBeenCalledWith(
mockEventId,
undefined,
mockUserId,
updateDto
);
});
});
@@ -163,11 +164,7 @@ describe("EventsController", () => {
await controller.remove(mockEventId, mockWorkspaceId, mockUser);
expect(service.remove).toHaveBeenCalledWith(
mockEventId,
mockWorkspaceId,
mockUserId
);
expect(service.remove).toHaveBeenCalledWith(mockEventId, mockWorkspaceId, mockUserId);
});
it("should pass undefined workspaceId to service (validation handled by guards in production)", async () => {

View File

@@ -5,6 +5,7 @@
*/
import { Injectable, Logger } from "@nestjs/common";
import { ModuleRef } from "@nestjs/core";
import { HttpService } from "@nestjs/axios";
import { randomUUID } from "crypto";
import { firstValueFrom } from "rxjs";
@@ -26,7 +27,8 @@ export class CommandService {
private readonly prisma: PrismaService,
private readonly federationService: FederationService,
private readonly signatureService: SignatureService,
private readonly httpService: HttpService
private readonly httpService: HttpService,
private readonly moduleRef: ModuleRef
) {}
/**
@@ -158,15 +160,33 @@ export class CommandService {
throw new Error(verificationResult.error ?? "Invalid signature");
}
// Process command (placeholder - would delegate to actual command processor)
// Process command
let responseData: unknown;
let success = true;
let errorMessage: string | undefined;
try {
// TODO: Implement actual command processing
// For now, return a placeholder response
responseData = { message: "Command received and processed" };
// Route agent commands to FederationAgentService
if (commandMessage.commandType.startsWith("agent.")) {
// Import FederationAgentService dynamically to avoid circular dependency
const { FederationAgentService } = await import("./federation-agent.service");
const federationAgentService = this.moduleRef.get(FederationAgentService, {
strict: false,
});
const agentResponse = await federationAgentService.handleAgentCommand(
commandMessage.instanceId,
commandMessage.commandType,
commandMessage.payload
);
success = agentResponse.success;
responseData = agentResponse.data;
errorMessage = agentResponse.error;
} else {
// Other command types can be added here
responseData = { message: "Command received and processed" };
}
} catch (error) {
success = false;
errorMessage = error instanceof Error ? error.message : "Command processing failed";

View File

@@ -0,0 +1,457 @@
/**
* Tests for Federation Agent Service
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
import { Test, TestingModule } from "@nestjs/testing";
import { HttpService } from "@nestjs/axios";
import { ConfigService } from "@nestjs/config";
import { FederationAgentService } from "./federation-agent.service";
import { CommandService } from "./command.service";
import { PrismaService } from "../prisma/prisma.service";
import { FederationConnectionStatus } from "@prisma/client";
import { of, throwError } from "rxjs";
import type {
SpawnAgentCommandPayload,
AgentStatusCommandPayload,
KillAgentCommandPayload,
SpawnAgentResponseData,
AgentStatusResponseData,
KillAgentResponseData,
} from "./types/federation-agent.types";
describe("FederationAgentService", () => {
let service: FederationAgentService;
let commandService: ReturnType<typeof vi.mocked<CommandService>>;
let prisma: ReturnType<typeof vi.mocked<PrismaService>>;
let httpService: ReturnType<typeof vi.mocked<HttpService>>;
let configService: ReturnType<typeof vi.mocked<ConfigService>>;
const mockWorkspaceId = "workspace-1";
const mockConnectionId = "connection-1";
const mockAgentId = "agent-123";
const mockTaskId = "task-456";
const mockOrchestratorUrl = "http://localhost:3001";
beforeEach(async () => {
const mockCommandService = {
sendCommand: vi.fn(),
};
const mockPrisma = {
federationConnection: {
findUnique: vi.fn(),
findFirst: vi.fn(),
},
};
const mockHttpService = {
post: vi.fn(),
get: vi.fn(),
};
const mockConfigService = {
get: vi.fn((key: string) => {
if (key === "orchestrator.url") {
return mockOrchestratorUrl;
}
return undefined;
}),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
FederationAgentService,
{ provide: CommandService, useValue: mockCommandService },
{ provide: PrismaService, useValue: mockPrisma },
{ provide: HttpService, useValue: mockHttpService },
{ provide: ConfigService, useValue: mockConfigService },
],
}).compile();
service = module.get<FederationAgentService>(FederationAgentService);
commandService = module.get(CommandService);
prisma = module.get(PrismaService);
httpService = module.get(HttpService);
configService = module.get(ConfigService);
});
it("should be defined", () => {
expect(service).toBeDefined();
});
describe("spawnAgentOnRemote", () => {
const spawnPayload: SpawnAgentCommandPayload = {
taskId: mockTaskId,
agentType: "worker",
context: {
repository: "git.example.com/org/repo",
branch: "main",
workItems: ["item-1"],
},
};
const mockConnection = {
id: mockConnectionId,
workspaceId: mockWorkspaceId,
remoteInstanceId: "remote-instance-1",
remoteUrl: "https://remote.example.com",
status: FederationConnectionStatus.ACTIVE,
};
it("should spawn agent on remote instance", async () => {
prisma.federationConnection.findUnique.mockResolvedValue(mockConnection as never);
const mockCommandResponse = {
id: "msg-1",
workspaceId: mockWorkspaceId,
connectionId: mockConnectionId,
messageType: "COMMAND" as never,
messageId: "msg-uuid",
commandType: "agent.spawn",
payload: spawnPayload as never,
response: {
agentId: mockAgentId,
status: "spawning",
spawnedAt: "2026-02-03T14:30:00Z",
} as never,
status: "DELIVERED" as never,
createdAt: new Date(),
updatedAt: new Date(),
};
commandService.sendCommand.mockResolvedValue(mockCommandResponse as never);
const result = await service.spawnAgentOnRemote(
mockWorkspaceId,
mockConnectionId,
spawnPayload
);
expect(prisma.federationConnection.findUnique).toHaveBeenCalledWith({
where: { id: mockConnectionId, workspaceId: mockWorkspaceId },
});
expect(commandService.sendCommand).toHaveBeenCalledWith(
mockWorkspaceId,
mockConnectionId,
"agent.spawn",
spawnPayload
);
expect(result).toEqual(mockCommandResponse);
});
it("should throw error if connection not found", async () => {
prisma.federationConnection.findUnique.mockResolvedValue(null);
await expect(
service.spawnAgentOnRemote(mockWorkspaceId, mockConnectionId, spawnPayload)
).rejects.toThrow("Connection not found");
expect(commandService.sendCommand).not.toHaveBeenCalled();
});
it("should throw error if connection not active", async () => {
const inactiveConnection = {
...mockConnection,
status: FederationConnectionStatus.DISCONNECTED,
};
prisma.federationConnection.findUnique.mockResolvedValue(inactiveConnection as never);
await expect(
service.spawnAgentOnRemote(mockWorkspaceId, mockConnectionId, spawnPayload)
).rejects.toThrow("Connection is not active");
expect(commandService.sendCommand).not.toHaveBeenCalled();
});
});
describe("getAgentStatus", () => {
const statusPayload: AgentStatusCommandPayload = {
agentId: mockAgentId,
};
const mockConnection = {
id: mockConnectionId,
workspaceId: mockWorkspaceId,
remoteInstanceId: "remote-instance-1",
remoteUrl: "https://remote.example.com",
status: FederationConnectionStatus.ACTIVE,
};
it("should get agent status from remote instance", async () => {
prisma.federationConnection.findUnique.mockResolvedValue(mockConnection as never);
const mockCommandResponse = {
id: "msg-2",
workspaceId: mockWorkspaceId,
connectionId: mockConnectionId,
messageType: "COMMAND" as never,
messageId: "msg-uuid-2",
commandType: "agent.status",
payload: statusPayload as never,
response: {
agentId: mockAgentId,
taskId: mockTaskId,
status: "running",
spawnedAt: "2026-02-03T14:30:00Z",
startedAt: "2026-02-03T14:30:05Z",
} as never,
status: "DELIVERED" as never,
createdAt: new Date(),
updatedAt: new Date(),
};
commandService.sendCommand.mockResolvedValue(mockCommandResponse as never);
const result = await service.getAgentStatus(mockWorkspaceId, mockConnectionId, mockAgentId);
expect(commandService.sendCommand).toHaveBeenCalledWith(
mockWorkspaceId,
mockConnectionId,
"agent.status",
statusPayload
);
expect(result).toEqual(mockCommandResponse);
});
});
describe("killAgentOnRemote", () => {
const killPayload: KillAgentCommandPayload = {
agentId: mockAgentId,
};
const mockConnection = {
id: mockConnectionId,
workspaceId: mockWorkspaceId,
remoteInstanceId: "remote-instance-1",
remoteUrl: "https://remote.example.com",
status: FederationConnectionStatus.ACTIVE,
};
it("should kill agent on remote instance", async () => {
prisma.federationConnection.findUnique.mockResolvedValue(mockConnection as never);
const mockCommandResponse = {
id: "msg-3",
workspaceId: mockWorkspaceId,
connectionId: mockConnectionId,
messageType: "COMMAND" as never,
messageId: "msg-uuid-3",
commandType: "agent.kill",
payload: killPayload as never,
response: {
agentId: mockAgentId,
status: "killed",
killedAt: "2026-02-03T14:35:00Z",
} as never,
status: "DELIVERED" as never,
createdAt: new Date(),
updatedAt: new Date(),
};
commandService.sendCommand.mockResolvedValue(mockCommandResponse as never);
const result = await service.killAgentOnRemote(
mockWorkspaceId,
mockConnectionId,
mockAgentId
);
expect(commandService.sendCommand).toHaveBeenCalledWith(
mockWorkspaceId,
mockConnectionId,
"agent.kill",
killPayload
);
expect(result).toEqual(mockCommandResponse);
});
});
describe("handleAgentCommand", () => {
const mockConnection = {
id: mockConnectionId,
workspaceId: mockWorkspaceId,
remoteInstanceId: "remote-instance-1",
remoteUrl: "https://remote.example.com",
status: FederationConnectionStatus.ACTIVE,
};
it("should handle agent.spawn command", async () => {
const spawnPayload: SpawnAgentCommandPayload = {
taskId: mockTaskId,
agentType: "worker",
context: {
repository: "git.example.com/org/repo",
branch: "main",
workItems: ["item-1"],
},
};
prisma.federationConnection.findFirst.mockResolvedValue(mockConnection as never);
const mockOrchestratorResponse = {
agentId: mockAgentId,
status: "spawning",
};
httpService.post.mockReturnValue(
of({
data: mockOrchestratorResponse,
status: 200,
statusText: "OK",
headers: {},
config: {} as never,
}) as never
);
const result = await service.handleAgentCommand(
"remote-instance-1",
"agent.spawn",
spawnPayload
);
expect(httpService.post).toHaveBeenCalledWith(
`${mockOrchestratorUrl}/agents/spawn`,
expect.objectContaining({
taskId: mockTaskId,
agentType: "worker",
})
);
expect(result.success).toBe(true);
expect(result.data).toEqual({
agentId: mockAgentId,
status: "spawning",
spawnedAt: expect.any(String),
});
});
it("should handle agent.status command", async () => {
const statusPayload: AgentStatusCommandPayload = {
agentId: mockAgentId,
};
prisma.federationConnection.findFirst.mockResolvedValue(mockConnection as never);
const mockOrchestratorResponse = {
agentId: mockAgentId,
taskId: mockTaskId,
status: "running",
spawnedAt: "2026-02-03T14:30:00Z",
startedAt: "2026-02-03T14:30:05Z",
};
httpService.get.mockReturnValue(
of({
data: mockOrchestratorResponse,
status: 200,
statusText: "OK",
headers: {},
config: {} as never,
}) as never
);
const result = await service.handleAgentCommand(
"remote-instance-1",
"agent.status",
statusPayload
);
expect(httpService.get).toHaveBeenCalledWith(
`${mockOrchestratorUrl}/agents/${mockAgentId}/status`
);
expect(result.success).toBe(true);
expect(result.data).toEqual(mockOrchestratorResponse);
});
it("should handle agent.kill command", async () => {
const killPayload: KillAgentCommandPayload = {
agentId: mockAgentId,
};
prisma.federationConnection.findFirst.mockResolvedValue(mockConnection as never);
const mockOrchestratorResponse = {
message: `Agent ${mockAgentId} killed successfully`,
};
httpService.post.mockReturnValue(
of({
data: mockOrchestratorResponse,
status: 200,
statusText: "OK",
headers: {},
config: {} as never,
}) as never
);
const result = await service.handleAgentCommand(
"remote-instance-1",
"agent.kill",
killPayload
);
expect(httpService.post).toHaveBeenCalledWith(
`${mockOrchestratorUrl}/agents/${mockAgentId}/kill`,
{}
);
expect(result.success).toBe(true);
expect(result.data).toEqual({
agentId: mockAgentId,
status: "killed",
killedAt: expect.any(String),
});
});
it("should return error for unknown command type", async () => {
prisma.federationConnection.findFirst.mockResolvedValue(mockConnection as never);
const result = await service.handleAgentCommand("remote-instance-1", "agent.unknown", {});
expect(result.success).toBe(false);
expect(result.error).toContain("Unknown agent command type: agent.unknown");
});
it("should throw error if connection not found", async () => {
prisma.federationConnection.findFirst.mockResolvedValue(null);
await expect(
service.handleAgentCommand("remote-instance-1", "agent.spawn", {})
).rejects.toThrow("No connection found for remote instance");
});
it("should handle orchestrator errors", async () => {
const spawnPayload: SpawnAgentCommandPayload = {
taskId: mockTaskId,
agentType: "worker",
context: {
repository: "git.example.com/org/repo",
branch: "main",
workItems: ["item-1"],
},
};
prisma.federationConnection.findFirst.mockResolvedValue(mockConnection as never);
httpService.post.mockReturnValue(
throwError(() => new Error("Orchestrator connection failed")) as never
);
const result = await service.handleAgentCommand(
"remote-instance-1",
"agent.spawn",
spawnPayload
);
expect(result.success).toBe(false);
expect(result.error).toContain("Orchestrator connection failed");
});
});
});

View File

@@ -0,0 +1,338 @@
/**
* Federation Agent Service
*
* Handles spawning and managing agents on remote federated instances.
*/
import { Injectable, Logger } from "@nestjs/common";
import { HttpService } from "@nestjs/axios";
import { ConfigService } from "@nestjs/config";
import { firstValueFrom } from "rxjs";
import { PrismaService } from "../prisma/prisma.service";
import { CommandService } from "./command.service";
import { FederationConnectionStatus } from "@prisma/client";
import type { CommandMessageDetails } from "./types/message.types";
import type {
SpawnAgentCommandPayload,
AgentStatusCommandPayload,
KillAgentCommandPayload,
SpawnAgentResponseData,
AgentStatusResponseData,
KillAgentResponseData,
} from "./types/federation-agent.types";
/**
* Agent command response structure
*/
export interface AgentCommandResponse {
/** Whether the command was successful */
success: boolean;
/** Response data if successful */
data?:
| SpawnAgentResponseData
| AgentStatusResponseData
| KillAgentResponseData
| Record<string, unknown>;
/** Error message if failed */
error?: string;
}
@Injectable()
export class FederationAgentService {
private readonly logger = new Logger(FederationAgentService.name);
private readonly orchestratorUrl: string;
constructor(
private readonly prisma: PrismaService,
private readonly commandService: CommandService,
private readonly httpService: HttpService,
private readonly configService: ConfigService
) {
this.orchestratorUrl =
this.configService.get<string>("orchestrator.url") ?? "http://localhost:3001";
this.logger.log(
`FederationAgentService initialized with orchestrator URL: ${this.orchestratorUrl}`
);
}
/**
* Spawn an agent on a remote federated instance
* @param workspaceId Workspace ID
* @param connectionId Federation connection ID
* @param payload Agent spawn command payload
* @returns Command message details
*/
async spawnAgentOnRemote(
workspaceId: string,
connectionId: string,
payload: SpawnAgentCommandPayload
): Promise<CommandMessageDetails> {
this.logger.log(
`Spawning agent on remote instance via connection ${connectionId} for task ${payload.taskId}`
);
// Validate connection exists and is active
const connection = await this.prisma.federationConnection.findUnique({
where: { id: connectionId, workspaceId },
});
if (!connection) {
throw new Error("Connection not found");
}
if (connection.status !== FederationConnectionStatus.ACTIVE) {
throw new Error("Connection is not active");
}
// Send command via federation
const result = await this.commandService.sendCommand(
workspaceId,
connectionId,
"agent.spawn",
payload as unknown as Record<string, unknown>
);
this.logger.log(`Agent spawn command sent successfully: ${result.messageId}`);
return result;
}
/**
* Get agent status from remote instance
* @param workspaceId Workspace ID
* @param connectionId Federation connection ID
* @param agentId Agent ID
* @returns Command message details
*/
async getAgentStatus(
workspaceId: string,
connectionId: string,
agentId: string
): Promise<CommandMessageDetails> {
this.logger.log(`Getting agent status for ${agentId} via connection ${connectionId}`);
// Validate connection exists and is active
const connection = await this.prisma.federationConnection.findUnique({
where: { id: connectionId, workspaceId },
});
if (!connection) {
throw new Error("Connection not found");
}
if (connection.status !== FederationConnectionStatus.ACTIVE) {
throw new Error("Connection is not active");
}
// Send status command
const payload: AgentStatusCommandPayload = { agentId };
const result = await this.commandService.sendCommand(
workspaceId,
connectionId,
"agent.status",
payload as unknown as Record<string, unknown>
);
this.logger.log(`Agent status command sent successfully: ${result.messageId}`);
return result;
}
/**
* Kill an agent on remote instance
* @param workspaceId Workspace ID
* @param connectionId Federation connection ID
* @param agentId Agent ID
* @returns Command message details
*/
async killAgentOnRemote(
workspaceId: string,
connectionId: string,
agentId: string
): Promise<CommandMessageDetails> {
this.logger.log(`Killing agent ${agentId} via connection ${connectionId}`);
// Validate connection exists and is active
const connection = await this.prisma.federationConnection.findUnique({
where: { id: connectionId, workspaceId },
});
if (!connection) {
throw new Error("Connection not found");
}
if (connection.status !== FederationConnectionStatus.ACTIVE) {
throw new Error("Connection is not active");
}
// Send kill command
const payload: KillAgentCommandPayload = { agentId };
const result = await this.commandService.sendCommand(
workspaceId,
connectionId,
"agent.kill",
payload as unknown as Record<string, unknown>
);
this.logger.log(`Agent kill command sent successfully: ${result.messageId}`);
return result;
}
/**
* Handle incoming agent command from remote instance
* @param remoteInstanceId Remote instance ID that sent the command
* @param commandType Command type (agent.spawn, agent.status, agent.kill)
* @param payload Command payload
* @returns Agent command response
*/
async handleAgentCommand(
remoteInstanceId: string,
commandType: string,
payload: Record<string, unknown>
): Promise<AgentCommandResponse> {
this.logger.log(`Handling agent command ${commandType} from ${remoteInstanceId}`);
// Verify connection exists for remote instance
const connection = await this.prisma.federationConnection.findFirst({
where: {
remoteInstanceId,
status: FederationConnectionStatus.ACTIVE,
},
});
if (!connection) {
throw new Error("No connection found for remote instance");
}
// Route command to appropriate handler
try {
switch (commandType) {
case "agent.spawn":
return await this.handleSpawnCommand(payload as unknown as SpawnAgentCommandPayload);
case "agent.status":
return await this.handleStatusCommand(payload as unknown as AgentStatusCommandPayload);
case "agent.kill":
return await this.handleKillCommand(payload as unknown as KillAgentCommandPayload);
default:
throw new Error(`Unknown agent command type: ${commandType}`);
}
} catch (error) {
this.logger.error(`Error handling agent command: ${String(error)}`);
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
};
}
}
/**
* Handle agent spawn command by calling local orchestrator
* @param payload Spawn command payload
* @returns Spawn response
*/
private async handleSpawnCommand(
payload: SpawnAgentCommandPayload
): Promise<AgentCommandResponse> {
this.logger.log(`Processing spawn command for task ${payload.taskId}`);
try {
const orchestratorPayload = {
taskId: payload.taskId,
agentType: payload.agentType,
context: payload.context,
options: payload.options,
};
const response = await firstValueFrom(
this.httpService.post<{ agentId: string; status: string }>(
`${this.orchestratorUrl}/agents/spawn`,
orchestratorPayload
)
);
const spawnedAt = new Date().toISOString();
const responseData: SpawnAgentResponseData = {
agentId: response.data.agentId,
status: response.data.status as "spawning",
spawnedAt,
};
this.logger.log(`Agent spawned successfully: ${responseData.agentId}`);
return {
success: true,
data: responseData,
};
} catch (error) {
this.logger.error(`Failed to spawn agent: ${String(error)}`);
throw error;
}
}
/**
* Handle agent status command by calling local orchestrator
* @param payload Status command payload
* @returns Status response
*/
private async handleStatusCommand(
payload: AgentStatusCommandPayload
): Promise<AgentCommandResponse> {
this.logger.log(`Processing status command for agent ${payload.agentId}`);
try {
const response = await firstValueFrom(
this.httpService.get(`${this.orchestratorUrl}/agents/${payload.agentId}/status`)
);
const responseData: AgentStatusResponseData = response.data as AgentStatusResponseData;
this.logger.log(`Agent status retrieved: ${responseData.status}`);
return {
success: true,
data: responseData,
};
} catch (error) {
this.logger.error(`Failed to get agent status: ${String(error)}`);
throw error;
}
}
/**
* Handle agent kill command by calling local orchestrator
* @param payload Kill command payload
* @returns Kill response
*/
private async handleKillCommand(payload: KillAgentCommandPayload): Promise<AgentCommandResponse> {
this.logger.log(`Processing kill command for agent ${payload.agentId}`);
try {
await firstValueFrom(
this.httpService.post(`${this.orchestratorUrl}/agents/${payload.agentId}/kill`, {})
);
const killedAt = new Date().toISOString();
const responseData: KillAgentResponseData = {
agentId: payload.agentId,
status: "killed",
killedAt,
};
this.logger.log(`Agent killed successfully: ${payload.agentId}`);
return {
success: true,
data: responseData,
};
} catch (error) {
this.logger.error(`Failed to kill agent: ${String(error)}`);
throw error;
}
}
}

View File

@@ -8,10 +8,12 @@ import { Controller, Get, Post, UseGuards, Logger, Req, Body, Param, Query } fro
import { FederationService } from "./federation.service";
import { FederationAuditService } from "./audit.service";
import { ConnectionService } from "./connection.service";
import { FederationAgentService } from "./federation-agent.service";
import { AuthGuard } from "../auth/guards/auth.guard";
import { AdminGuard } from "../auth/guards/admin.guard";
import type { PublicInstanceIdentity } from "./types/instance.types";
import type { ConnectionDetails } from "./types/connection.types";
import type { CommandMessageDetails } from "./types/message.types";
import type { AuthenticatedRequest } from "../common/types/user.types";
import {
InitiateConnectionDto,
@@ -20,6 +22,7 @@ import {
DisconnectConnectionDto,
IncomingConnectionRequestDto,
} from "./dto/connection.dto";
import type { SpawnAgentCommandPayload } from "./types/federation-agent.types";
import { FederationConnectionStatus } from "@prisma/client";
@Controller("api/v1/federation")
@@ -29,7 +32,8 @@ export class FederationController {
constructor(
private readonly federationService: FederationService,
private readonly auditService: FederationAuditService,
private readonly connectionService: ConnectionService
private readonly connectionService: ConnectionService,
private readonly federationAgentService: FederationAgentService
) {}
/**
@@ -211,4 +215,81 @@ export class FederationController {
connectionId: connection.id,
};
}
/**
* Spawn an agent on a remote federated instance
* Requires authentication
*/
@Post("agents/spawn")
@UseGuards(AuthGuard)
async spawnAgentOnRemote(
@Req() req: AuthenticatedRequest,
@Body() body: { connectionId: string; payload: SpawnAgentCommandPayload }
): Promise<CommandMessageDetails> {
if (!req.user?.workspaceId) {
throw new Error("Workspace ID not found in request");
}
this.logger.log(
`User ${req.user.id} spawning agent on remote instance via connection ${body.connectionId}`
);
return this.federationAgentService.spawnAgentOnRemote(
req.user.workspaceId,
body.connectionId,
body.payload
);
}
/**
* Get agent status from remote instance
* Requires authentication
*/
@Get("agents/:agentId/status")
@UseGuards(AuthGuard)
async getAgentStatus(
@Req() req: AuthenticatedRequest,
@Param("agentId") agentId: string,
@Query("connectionId") connectionId: string
): Promise<CommandMessageDetails> {
if (!req.user?.workspaceId) {
throw new Error("Workspace ID not found in request");
}
if (!connectionId) {
throw new Error("connectionId query parameter is required");
}
this.logger.log(
`User ${req.user.id} getting agent ${agentId} status via connection ${connectionId}`
);
return this.federationAgentService.getAgentStatus(req.user.workspaceId, connectionId, agentId);
}
/**
* Kill an agent on remote instance
* Requires authentication
*/
@Post("agents/:agentId/kill")
@UseGuards(AuthGuard)
async killAgentOnRemote(
@Req() req: AuthenticatedRequest,
@Param("agentId") agentId: string,
@Body() body: { connectionId: string }
): Promise<CommandMessageDetails> {
if (!req.user?.workspaceId) {
throw new Error("Workspace ID not found in request");
}
this.logger.log(
`User ${req.user.id} killing agent ${agentId} via connection ${body.connectionId}`
);
return this.federationAgentService.killAgentOnRemote(
req.user.workspaceId,
body.connectionId,
agentId
);
}
}

View File

@@ -24,6 +24,7 @@ import { IdentityResolutionService } from "./identity-resolution.service";
import { QueryService } from "./query.service";
import { CommandService } from "./command.service";
import { EventService } from "./event.service";
import { FederationAgentService } from "./federation-agent.service";
import { PrismaModule } from "../prisma/prisma.module";
@Module({
@@ -55,6 +56,7 @@ import { PrismaModule } from "../prisma/prisma.module";
QueryService,
CommandService,
EventService,
FederationAgentService,
],
exports: [
FederationService,
@@ -67,6 +69,7 @@ import { PrismaModule } from "../prisma/prisma.module";
QueryService,
CommandService,
EventService,
FederationAgentService,
],
})
export class FederationModule {}

View File

@@ -0,0 +1,149 @@
/**
* Federation Agent Command Types
*
* Types for agent spawn commands sent via federation COMMAND messages.
*/
/**
* Agent type options for spawning
*/
export type FederationAgentType = "worker" | "reviewer" | "tester";
/**
* Agent status returned from remote instance
*/
export type FederationAgentStatus = "spawning" | "running" | "completed" | "failed" | "killed";
/**
* Context for agent execution
*/
export interface FederationAgentContext {
/** Git repository URL or path */
repository: string;
/** Git branch to work on */
branch: string;
/** Work items for the agent to complete */
workItems: string[];
/** Optional skills to load */
skills?: string[];
/** Optional instructions */
instructions?: string;
}
/**
* Options for spawning an agent
*/
export interface FederationAgentOptions {
/** Enable Docker sandbox isolation */
sandbox?: boolean;
/** Timeout in milliseconds */
timeout?: number;
/** Maximum retry attempts */
maxRetries?: number;
}
/**
* Payload for agent.spawn command
*/
export interface SpawnAgentCommandPayload {
/** Unique task identifier */
taskId: string;
/** Type of agent to spawn */
agentType: FederationAgentType;
/** Context for task execution */
context: FederationAgentContext;
/** Optional configuration */
options?: FederationAgentOptions;
}
/**
* Payload for agent.status command
*/
export interface AgentStatusCommandPayload {
/** Unique agent identifier */
agentId: string;
}
/**
* Payload for agent.kill command
*/
export interface KillAgentCommandPayload {
/** Unique agent identifier */
agentId: string;
}
/**
* Response data for agent.spawn command
*/
export interface SpawnAgentResponseData {
/** Unique agent identifier */
agentId: string;
/** Current agent status */
status: FederationAgentStatus;
/** Timestamp when agent was spawned */
spawnedAt: string;
}
/**
* Response data for agent.status command
*/
export interface AgentStatusResponseData {
/** Unique agent identifier */
agentId: string;
/** Task identifier */
taskId: string;
/** Current agent status */
status: FederationAgentStatus;
/** Timestamp when agent was spawned */
spawnedAt: string;
/** Timestamp when agent started (if running/completed) */
startedAt?: string;
/** Timestamp when agent completed (if completed/failed/killed) */
completedAt?: string;
/** Error message (if failed) */
error?: string;
/** Agent progress data */
progress?: Record<string, unknown>;
}
/**
* Response data for agent.kill command
*/
export interface KillAgentResponseData {
/** Unique agent identifier */
agentId: string;
/** Status after kill operation */
status: FederationAgentStatus;
/** Timestamp when agent was killed */
killedAt: string;
}
/**
* Details about a federated agent
*/
export interface FederatedAgentDetails {
/** Agent ID */
agentId: string;
/** Task ID */
taskId: string;
/** Remote instance ID where agent is running */
remoteInstanceId: string;
/** Connection ID used to spawn the agent */
connectionId: string;
/** Agent type */
agentType: FederationAgentType;
/** Current status */
status: FederationAgentStatus;
/** Spawn timestamp */
spawnedAt: Date;
/** Start timestamp */
startedAt?: Date;
/** Completion timestamp */
completedAt?: Date;
/** Error message if failed */
error?: string;
/** Context used to spawn agent */
context: FederationAgentContext;
/** Options used to spawn agent */
options?: FederationAgentOptions;
}

View File

@@ -9,3 +9,4 @@ export * from "./connection.types";
export * from "./oidc.types";
export * from "./identity-linking.types";
export * from "./message.types";
export * from "./federation-agent.types";

View File

@@ -375,9 +375,7 @@ describe("HeraldService", () => {
mockDiscord.sendThreadMessage.mockRejectedValue(discordError);
// Act & Assert
await expect(service.broadcastJobEvent(jobId, event)).rejects.toThrow(
"Rate limit exceeded"
);
await expect(service.broadcastJobEvent(jobId, event)).rejects.toThrow("Rate limit exceeded");
});
it("should propagate errors when fetching job events fails", async () => {
@@ -405,9 +403,7 @@ describe("HeraldService", () => {
mockDiscord.isConnected.mockReturnValue(true);
// Act & Assert
await expect(service.broadcastJobEvent(jobId, event)).rejects.toThrow(
"Query timeout"
);
await expect(service.broadcastJobEvent(jobId, event)).rejects.toThrow("Query timeout");
});
it("should include job context in error messages", async () => {

View File

@@ -146,9 +146,9 @@ describe("KnowledgeGraphController", () => {
it("should throw error if entry not found", async () => {
mockGraphService.getEntryGraphBySlug.mockRejectedValue(new Error("Entry not found"));
await expect(
controller.getEntryGraph("workspace-1", "non-existent", {})
).rejects.toThrow("Entry not found");
await expect(controller.getEntryGraph("workspace-1", "non-existent", {})).rejects.toThrow(
"Entry not found"
);
});
});
});

View File

@@ -1,17 +1,17 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { Test, TestingModule } from '@nestjs/testing';
import { KnowledgeCacheService } from './cache.service';
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { Test, TestingModule } from "@nestjs/testing";
import { KnowledgeCacheService } from "./cache.service";
// Integration tests - require running Valkey instance
// Skip in unit test runs, enable with: INTEGRATION_TESTS=true pnpm test
describe.skipIf(!process.env.INTEGRATION_TESTS)('KnowledgeCacheService', () => {
describe.skipIf(!process.env.INTEGRATION_TESTS)("KnowledgeCacheService", () => {
let service: KnowledgeCacheService;
beforeEach(async () => {
// Set environment variables for testing
process.env.KNOWLEDGE_CACHE_ENABLED = 'true';
process.env.KNOWLEDGE_CACHE_TTL = '300';
process.env.VALKEY_URL = 'redis://localhost:6379';
process.env.KNOWLEDGE_CACHE_ENABLED = "true";
process.env.KNOWLEDGE_CACHE_TTL = "300";
process.env.VALKEY_URL = "redis://localhost:6379";
const module: TestingModule = await Test.createTestingModule({
providers: [KnowledgeCacheService],
@@ -27,35 +27,35 @@ describe.skipIf(!process.env.INTEGRATION_TESTS)('KnowledgeCacheService', () => {
}
});
describe('Cache Enabled/Disabled', () => {
it('should be enabled by default', () => {
describe("Cache Enabled/Disabled", () => {
it("should be enabled by default", () => {
expect(service.isEnabled()).toBe(true);
});
it('should be disabled when KNOWLEDGE_CACHE_ENABLED=false', async () => {
process.env.KNOWLEDGE_CACHE_ENABLED = 'false';
it("should be disabled when KNOWLEDGE_CACHE_ENABLED=false", async () => {
process.env.KNOWLEDGE_CACHE_ENABLED = "false";
const module = await Test.createTestingModule({
providers: [KnowledgeCacheService],
}).compile();
const disabledService = module.get<KnowledgeCacheService>(KnowledgeCacheService);
expect(disabledService.isEnabled()).toBe(false);
});
});
describe('Entry Caching', () => {
const workspaceId = 'test-workspace-id';
const slug = 'test-entry';
describe("Entry Caching", () => {
const workspaceId = "test-workspace-id";
const slug = "test-entry";
const entryData = {
id: 'entry-id',
id: "entry-id",
workspaceId,
slug,
title: 'Test Entry',
content: 'Test content',
title: "Test Entry",
content: "Test content",
tags: [],
};
it('should return null on cache miss', async () => {
it("should return null on cache miss", async () => {
if (!service.isEnabled()) {
return; // Skip if cache is disabled
}
@@ -65,206 +65,206 @@ describe.skipIf(!process.env.INTEGRATION_TESTS)('KnowledgeCacheService', () => {
expect(result).toBeNull();
});
it('should cache and retrieve entry data', async () => {
it("should cache and retrieve entry data", async () => {
if (!service.isEnabled()) {
return;
}
await service.onModuleInit();
// Set cache
await service.setEntry(workspaceId, slug, entryData);
// Get from cache
const result = await service.getEntry(workspaceId, slug);
expect(result).toEqual(entryData);
});
it('should invalidate entry cache', async () => {
it("should invalidate entry cache", async () => {
if (!service.isEnabled()) {
return;
}
await service.onModuleInit();
// Set cache
await service.setEntry(workspaceId, slug, entryData);
// Verify it's cached
let result = await service.getEntry(workspaceId, slug);
expect(result).toEqual(entryData);
// Invalidate
await service.invalidateEntry(workspaceId, slug);
// Verify it's gone
result = await service.getEntry(workspaceId, slug);
expect(result).toBeNull();
});
});
describe('Search Caching', () => {
const workspaceId = 'test-workspace-id';
const query = 'test search';
const filters = { status: 'PUBLISHED', page: 1, limit: 20 };
describe("Search Caching", () => {
const workspaceId = "test-workspace-id";
const query = "test search";
const filters = { status: "PUBLISHED", page: 1, limit: 20 };
const searchResults = {
data: [],
pagination: { page: 1, limit: 20, total: 0, totalPages: 0 },
query,
};
it('should cache and retrieve search results', async () => {
it("should cache and retrieve search results", async () => {
if (!service.isEnabled()) {
return;
}
await service.onModuleInit();
// Set cache
await service.setSearch(workspaceId, query, filters, searchResults);
// Get from cache
const result = await service.getSearch(workspaceId, query, filters);
expect(result).toEqual(searchResults);
});
it('should differentiate search results by filters', async () => {
it("should differentiate search results by filters", async () => {
if (!service.isEnabled()) {
return;
}
await service.onModuleInit();
const filters1 = { page: 1, limit: 20 };
const filters2 = { page: 2, limit: 20 };
const results1 = { ...searchResults, pagination: { ...searchResults.pagination, page: 1 } };
const results2 = { ...searchResults, pagination: { ...searchResults.pagination, page: 2 } };
await service.setSearch(workspaceId, query, filters1, results1);
await service.setSearch(workspaceId, query, filters2, results2);
const result1 = await service.getSearch(workspaceId, query, filters1);
const result2 = await service.getSearch(workspaceId, query, filters2);
expect(result1.pagination.page).toBe(1);
expect(result2.pagination.page).toBe(2);
});
it('should invalidate all search caches for workspace', async () => {
it("should invalidate all search caches for workspace", async () => {
if (!service.isEnabled()) {
return;
}
await service.onModuleInit();
// Set multiple search caches
await service.setSearch(workspaceId, 'query1', {}, searchResults);
await service.setSearch(workspaceId, 'query2', {}, searchResults);
await service.setSearch(workspaceId, "query1", {}, searchResults);
await service.setSearch(workspaceId, "query2", {}, searchResults);
// Invalidate all
await service.invalidateSearches(workspaceId);
// Verify both are gone
const result1 = await service.getSearch(workspaceId, 'query1', {});
const result2 = await service.getSearch(workspaceId, 'query2', {});
const result1 = await service.getSearch(workspaceId, "query1", {});
const result2 = await service.getSearch(workspaceId, "query2", {});
expect(result1).toBeNull();
expect(result2).toBeNull();
});
});
describe('Graph Caching', () => {
const workspaceId = 'test-workspace-id';
const entryId = 'entry-id';
describe("Graph Caching", () => {
const workspaceId = "test-workspace-id";
const entryId = "entry-id";
const maxDepth = 2;
const graphData = {
centerNode: { id: entryId, slug: 'test', title: 'Test', tags: [], depth: 0 },
centerNode: { id: entryId, slug: "test", title: "Test", tags: [], depth: 0 },
nodes: [],
edges: [],
stats: { totalNodes: 1, totalEdges: 0, maxDepth },
};
it('should cache and retrieve graph data', async () => {
it("should cache and retrieve graph data", async () => {
if (!service.isEnabled()) {
return;
}
await service.onModuleInit();
// Set cache
await service.setGraph(workspaceId, entryId, maxDepth, graphData);
// Get from cache
const result = await service.getGraph(workspaceId, entryId, maxDepth);
expect(result).toEqual(graphData);
});
it('should differentiate graphs by maxDepth', async () => {
it("should differentiate graphs by maxDepth", async () => {
if (!service.isEnabled()) {
return;
}
await service.onModuleInit();
const graph1 = { ...graphData, stats: { ...graphData.stats, maxDepth: 1 } };
const graph2 = { ...graphData, stats: { ...graphData.stats, maxDepth: 2 } };
await service.setGraph(workspaceId, entryId, 1, graph1);
await service.setGraph(workspaceId, entryId, 2, graph2);
const result1 = await service.getGraph(workspaceId, entryId, 1);
const result2 = await service.getGraph(workspaceId, entryId, 2);
expect(result1.stats.maxDepth).toBe(1);
expect(result2.stats.maxDepth).toBe(2);
});
it('should invalidate all graph caches for workspace', async () => {
it("should invalidate all graph caches for workspace", async () => {
if (!service.isEnabled()) {
return;
}
await service.onModuleInit();
// Set cache
await service.setGraph(workspaceId, entryId, maxDepth, graphData);
// Invalidate
await service.invalidateGraphs(workspaceId);
// Verify it's gone
const result = await service.getGraph(workspaceId, entryId, maxDepth);
expect(result).toBeNull();
});
});
describe('Cache Statistics', () => {
it('should track hits and misses', async () => {
describe("Cache Statistics", () => {
it("should track hits and misses", async () => {
if (!service.isEnabled()) {
return;
}
await service.onModuleInit();
const workspaceId = 'test-workspace-id';
const slug = 'test-entry';
const entryData = { id: '1', slug, title: 'Test' };
const workspaceId = "test-workspace-id";
const slug = "test-entry";
const entryData = { id: "1", slug, title: "Test" };
// Reset stats
service.resetStats();
// Miss
await service.getEntry(workspaceId, slug);
let stats = service.getStats();
expect(stats.misses).toBe(1);
expect(stats.hits).toBe(0);
// Set
await service.setEntry(workspaceId, slug, entryData);
stats = service.getStats();
expect(stats.sets).toBe(1);
// Hit
await service.getEntry(workspaceId, slug);
stats = service.getStats();
@@ -272,21 +272,21 @@ describe.skipIf(!process.env.INTEGRATION_TESTS)('KnowledgeCacheService', () => {
expect(stats.hitRate).toBeCloseTo(0.5); // 1 hit, 1 miss = 50%
});
it('should reset statistics', async () => {
it("should reset statistics", async () => {
if (!service.isEnabled()) {
return;
}
await service.onModuleInit();
const workspaceId = 'test-workspace-id';
const slug = 'test-entry';
const workspaceId = "test-workspace-id";
const slug = "test-entry";
await service.getEntry(workspaceId, slug); // miss
service.resetStats();
const stats = service.getStats();
expect(stats.hits).toBe(0);
expect(stats.misses).toBe(0);
expect(stats.sets).toBe(0);
@@ -295,29 +295,29 @@ describe.skipIf(!process.env.INTEGRATION_TESTS)('KnowledgeCacheService', () => {
});
});
describe('Clear Workspace Cache', () => {
it('should clear all caches for a workspace', async () => {
describe("Clear Workspace Cache", () => {
it("should clear all caches for a workspace", async () => {
if (!service.isEnabled()) {
return;
}
await service.onModuleInit();
const workspaceId = 'test-workspace-id';
const workspaceId = "test-workspace-id";
// Set various caches
await service.setEntry(workspaceId, 'entry1', { id: '1' });
await service.setSearch(workspaceId, 'query', {}, { data: [] });
await service.setGraph(workspaceId, 'entry-id', 1, { nodes: [] });
await service.setEntry(workspaceId, "entry1", { id: "1" });
await service.setSearch(workspaceId, "query", {}, { data: [] });
await service.setGraph(workspaceId, "entry-id", 1, { nodes: [] });
// Clear all
await service.clearWorkspaceCache(workspaceId);
// Verify all are gone
const entry = await service.getEntry(workspaceId, 'entry1');
const search = await service.getSearch(workspaceId, 'query', {});
const graph = await service.getGraph(workspaceId, 'entry-id', 1);
const entry = await service.getEntry(workspaceId, "entry1");
const search = await service.getSearch(workspaceId, "query", {});
const graph = await service.getGraph(workspaceId, "entry-id", 1);
expect(entry).toBeNull();
expect(search).toBeNull();
expect(graph).toBeNull();

View File

@@ -271,9 +271,7 @@ describe("GraphService", () => {
});
it("should filter by status", async () => {
const entries = [
{ ...mockEntry, id: "entry-1", status: "PUBLISHED", tags: [] },
];
const entries = [{ ...mockEntry, id: "entry-1", status: "PUBLISHED", tags: [] }];
mockPrismaService.knowledgeEntry.findMany.mockResolvedValue(entries);
mockPrismaService.knowledgeLink.findMany.mockResolvedValue([]);
@@ -351,9 +349,7 @@ describe("GraphService", () => {
{ id: "entry-1", slug: "entry-1", title: "Entry 1", link_count: "5" },
{ id: "entry-2", slug: "entry-2", title: "Entry 2", link_count: "3" },
]);
mockPrismaService.knowledgeEntry.findMany.mockResolvedValue([
{ id: "orphan-1" },
]);
mockPrismaService.knowledgeEntry.findMany.mockResolvedValue([{ id: "orphan-1" }]);
const result = await service.getGraphStats("workspace-1");

View File

@@ -170,9 +170,9 @@ This is the content of the entry.`;
path: "",
};
await expect(
service.importEntries(workspaceId, userId, file)
).rejects.toThrow(BadRequestException);
await expect(service.importEntries(workspaceId, userId, file)).rejects.toThrow(
BadRequestException
);
});
it("should handle import errors gracefully", async () => {
@@ -195,9 +195,7 @@ Content`;
path: "",
};
mockKnowledgeService.create.mockRejectedValue(
new Error("Database error")
);
mockKnowledgeService.create.mockRejectedValue(new Error("Database error"));
const result = await service.importEntries(workspaceId, userId, file);
@@ -240,10 +238,7 @@ title: Empty Entry
it("should export entries as markdown format", async () => {
mockPrismaService.knowledgeEntry.findMany.mockResolvedValue([mockEntry]);
const result = await service.exportEntries(
workspaceId,
ExportFormat.MARKDOWN
);
const result = await service.exportEntries(workspaceId, ExportFormat.MARKDOWN);
expect(result.filename).toMatch(/knowledge-export-\d{4}-\d{2}-\d{2}\.zip/);
expect(result.stream).toBeDefined();
@@ -289,9 +284,9 @@ title: Empty Entry
it("should throw error when no entries found", async () => {
mockPrismaService.knowledgeEntry.findMany.mockResolvedValue([]);
await expect(
service.exportEntries(workspaceId, ExportFormat.MARKDOWN)
).rejects.toThrow(BadRequestException);
await expect(service.exportEntries(workspaceId, ExportFormat.MARKDOWN)).rejects.toThrow(
BadRequestException
);
});
});
});

View File

@@ -88,27 +88,20 @@ describe("LinkResolutionService", () => {
describe("resolveLink", () => {
describe("Exact title match", () => {
it("should resolve link by exact title match", async () => {
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(
mockEntries[0]
);
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(mockEntries[0]);
const result = await service.resolveLink(
workspaceId,
"TypeScript Guide"
);
const result = await service.resolveLink(workspaceId, "TypeScript Guide");
expect(result).toBe("entry-1");
expect(mockPrismaService.knowledgeEntry.findFirst).toHaveBeenCalledWith(
{
where: {
workspaceId,
title: "TypeScript Guide",
},
select: {
id: true,
},
}
);
expect(mockPrismaService.knowledgeEntry.findFirst).toHaveBeenCalledWith({
where: {
workspaceId,
title: "TypeScript Guide",
},
select: {
id: true,
},
});
});
it("should be case-sensitive for exact title match", async () => {
@@ -116,10 +109,7 @@ describe("LinkResolutionService", () => {
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValueOnce(null);
mockPrismaService.knowledgeEntry.findMany.mockResolvedValueOnce([]);
const result = await service.resolveLink(
workspaceId,
"typescript guide"
);
const result = await service.resolveLink(workspaceId, "typescript guide");
expect(result).toBeNull();
});
@@ -128,41 +118,29 @@ describe("LinkResolutionService", () => {
describe("Slug match", () => {
it("should resolve link by slug", async () => {
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(null);
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValueOnce(
mockEntries[0]
);
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValueOnce(mockEntries[0]);
const result = await service.resolveLink(
workspaceId,
"typescript-guide"
);
const result = await service.resolveLink(workspaceId, "typescript-guide");
expect(result).toBe("entry-1");
expect(mockPrismaService.knowledgeEntry.findUnique).toHaveBeenCalledWith(
{
where: {
workspaceId_slug: {
workspaceId,
slug: "typescript-guide",
},
expect(mockPrismaService.knowledgeEntry.findUnique).toHaveBeenCalledWith({
where: {
workspaceId_slug: {
workspaceId,
slug: "typescript-guide",
},
select: {
id: true,
},
}
);
},
select: {
id: true,
},
});
});
it("should prioritize exact title match over slug match", async () => {
// If exact title matches, slug should not be checked
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(
mockEntries[0]
);
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(mockEntries[0]);
const result = await service.resolveLink(
workspaceId,
"TypeScript Guide"
);
const result = await service.resolveLink(workspaceId, "TypeScript Guide");
expect(result).toBe("entry-1");
expect(mockPrismaService.knowledgeEntry.findUnique).not.toHaveBeenCalled();
@@ -173,14 +151,9 @@ describe("LinkResolutionService", () => {
it("should resolve link by case-insensitive fuzzy match", async () => {
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(null);
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValueOnce(null);
mockPrismaService.knowledgeEntry.findMany.mockResolvedValueOnce([
mockEntries[0],
]);
mockPrismaService.knowledgeEntry.findMany.mockResolvedValueOnce([mockEntries[0]]);
const result = await service.resolveLink(
workspaceId,
"typescript guide"
);
const result = await service.resolveLink(workspaceId, "typescript guide");
expect(result).toBe("entry-1");
expect(mockPrismaService.knowledgeEntry.findMany).toHaveBeenCalledWith({
@@ -216,10 +189,7 @@ describe("LinkResolutionService", () => {
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValueOnce(null);
mockPrismaService.knowledgeEntry.findMany.mockResolvedValueOnce([]);
const result = await service.resolveLink(
workspaceId,
"Non-existent Entry"
);
const result = await service.resolveLink(workspaceId, "Non-existent Entry");
expect(result).toBeNull();
});
@@ -266,14 +236,9 @@ describe("LinkResolutionService", () => {
});
it("should trim whitespace from target before resolving", async () => {
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(
mockEntries[0]
);
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(mockEntries[0]);
const result = await service.resolveLink(
workspaceId,
" TypeScript Guide "
);
const result = await service.resolveLink(workspaceId, " TypeScript Guide ");
expect(result).toBe("entry-1");
expect(mockPrismaService.knowledgeEntry.findFirst).toHaveBeenCalledWith(
@@ -291,23 +256,19 @@ describe("LinkResolutionService", () => {
it("should resolve multiple links in batch", async () => {
// First link: "TypeScript Guide" -> exact title match
// Second link: "react-hooks" -> slug match
mockPrismaService.knowledgeEntry.findFirst.mockImplementation(
async ({ where }: any) => {
if (where.title === "TypeScript Guide") {
return mockEntries[0];
}
return null;
mockPrismaService.knowledgeEntry.findFirst.mockImplementation(async ({ where }: any) => {
if (where.title === "TypeScript Guide") {
return mockEntries[0];
}
);
return null;
});
mockPrismaService.knowledgeEntry.findUnique.mockImplementation(
async ({ where }: any) => {
if (where.workspaceId_slug?.slug === "react-hooks") {
return mockEntries[1];
}
return null;
mockPrismaService.knowledgeEntry.findUnique.mockImplementation(async ({ where }: any) => {
if (where.workspaceId_slug?.slug === "react-hooks") {
return mockEntries[1];
}
);
return null;
});
mockPrismaService.knowledgeEntry.findMany.mockResolvedValue([]);
@@ -344,9 +305,7 @@ describe("LinkResolutionService", () => {
});
it("should deduplicate targets", async () => {
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(
mockEntries[0]
);
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(mockEntries[0]);
const result = await service.resolveLinks(workspaceId, [
"TypeScript Guide",
@@ -357,9 +316,7 @@ describe("LinkResolutionService", () => {
"TypeScript Guide": "entry-1",
});
// Should only be called once for the deduplicated target
expect(mockPrismaService.knowledgeEntry.findFirst).toHaveBeenCalledTimes(
1
);
expect(mockPrismaService.knowledgeEntry.findFirst).toHaveBeenCalledTimes(1);
});
});
@@ -370,10 +327,7 @@ describe("LinkResolutionService", () => {
{ id: "entry-3", title: "React Hooks Advanced" },
]);
const result = await service.getAmbiguousMatches(
workspaceId,
"react hooks"
);
const result = await service.getAmbiguousMatches(workspaceId, "react hooks");
expect(result).toHaveLength(2);
expect(result).toEqual([
@@ -385,10 +339,7 @@ describe("LinkResolutionService", () => {
it("should return empty array when no matches found", async () => {
mockPrismaService.knowledgeEntry.findMany.mockResolvedValueOnce([]);
const result = await service.getAmbiguousMatches(
workspaceId,
"Non-existent"
);
const result = await service.getAmbiguousMatches(workspaceId, "Non-existent");
expect(result).toEqual([]);
});
@@ -398,10 +349,7 @@ describe("LinkResolutionService", () => {
{ id: "entry-1", title: "TypeScript Guide" },
]);
const result = await service.getAmbiguousMatches(
workspaceId,
"typescript guide"
);
const result = await service.getAmbiguousMatches(workspaceId, "typescript guide");
expect(result).toHaveLength(1);
});
@@ -409,8 +357,7 @@ describe("LinkResolutionService", () => {
describe("resolveLinksFromContent", () => {
it("should parse and resolve wiki links from content", async () => {
const content =
"Check out [[TypeScript Guide]] and [[React Hooks]] for more info.";
const content = "Check out [[TypeScript Guide]] and [[React Hooks]] for more info.";
// Mock resolveLink for each target
mockPrismaService.knowledgeEntry.findFirst
@@ -522,9 +469,7 @@ describe("LinkResolutionService", () => {
},
];
mockPrismaService.knowledgeLink.findMany.mockResolvedValueOnce(
mockBacklinks
);
mockPrismaService.knowledgeLink.findMany.mockResolvedValueOnce(mockBacklinks);
const result = await service.getBacklinks(targetEntryId);

View File

@@ -26,7 +26,7 @@ describe.skipIf(!process.env.INTEGRATION_TESTS)("Semantic Search Integration", (
// Initialize services
prisma = new PrismaClient();
const prismaService = prisma as unknown as PrismaService;
// Mock cache service for testing
cacheService = {
getSearch: async () => null,
@@ -37,11 +37,7 @@ describe.skipIf(!process.env.INTEGRATION_TESTS)("Semantic Search Integration", (
} as unknown as KnowledgeCacheService;
embeddingService = new EmbeddingService(prismaService);
searchService = new SearchService(
prismaService,
cacheService,
embeddingService
);
searchService = new SearchService(prismaService, cacheService, embeddingService);
// Create test workspace and user
const workspace = await prisma.workspace.create({
@@ -84,10 +80,7 @@ describe.skipIf(!process.env.INTEGRATION_TESTS)("Semantic Search Integration", (
const title = "Introduction to PostgreSQL";
const content = "PostgreSQL is a powerful open-source database.";
const prepared = embeddingService.prepareContentForEmbedding(
title,
content
);
const prepared = embeddingService.prepareContentForEmbedding(title, content);
// Title should appear twice for weighting
expect(prepared).toContain(title);
@@ -122,10 +115,7 @@ describe.skipIf(!process.env.INTEGRATION_TESTS)("Semantic Search Integration", (
it("should skip semantic search if OpenAI not configured", async () => {
if (!embeddingService.isConfigured()) {
await expect(
searchService.semanticSearch(
"database performance",
testWorkspaceId
)
searchService.semanticSearch("database performance", testWorkspaceId)
).rejects.toThrow();
} else {
// If configured, this is expected to work (tested below)
@@ -156,10 +146,7 @@ describe.skipIf(!process.env.INTEGRATION_TESTS)("Semantic Search Integration", (
entry.title,
entry.content
);
await embeddingService.generateAndStoreEmbedding(
created.id,
preparedContent
);
await embeddingService.generateAndStoreEmbedding(created.id, preparedContent);
}
// Wait a bit for embeddings to be stored
@@ -175,9 +162,7 @@ describe.skipIf(!process.env.INTEGRATION_TESTS)("Semantic Search Integration", (
expect(results.data.length).toBeGreaterThan(0);
// PostgreSQL entry should rank high for "relational database"
const postgresEntry = results.data.find(
(r) => r.slug === "postgresql-intro"
);
const postgresEntry = results.data.find((r) => r.slug === "postgresql-intro");
expect(postgresEntry).toBeDefined();
expect(postgresEntry!.rank).toBeGreaterThan(0);
},
@@ -187,18 +172,13 @@ describe.skipIf(!process.env.INTEGRATION_TESTS)("Semantic Search Integration", (
it.skipIf(!process.env["OPENAI_API_KEY"])(
"should perform hybrid search combining vector and keyword",
async () => {
const results = await searchService.hybridSearch(
"indexing",
testWorkspaceId
);
const results = await searchService.hybridSearch("indexing", testWorkspaceId);
// Should return results
expect(results.data.length).toBeGreaterThan(0);
// Should find the indexing entry
const indexingEntry = results.data.find(
(r) => r.slug === "database-indexing"
);
const indexingEntry = results.data.find((r) => r.slug === "database-indexing");
expect(indexingEntry).toBeDefined();
},
30000
@@ -230,15 +210,10 @@ describe.skipIf(!process.env.INTEGRATION_TESTS)("Semantic Search Integration", (
// Batch generate embeddings
const entriesForEmbedding = entries.map((e) => ({
id: e.id,
content: embeddingService.prepareContentForEmbedding(
e.title,
e.content
),
content: embeddingService.prepareContentForEmbedding(e.title, e.content),
}));
const successCount = await embeddingService.batchGenerateEmbeddings(
entriesForEmbedding
);
const successCount = await embeddingService.batchGenerateEmbeddings(entriesForEmbedding);
expect(successCount).toBe(3);

View File

@@ -48,10 +48,7 @@ describe("TagsController", () => {
const result = await controller.create(createDto, workspaceId);
expect(result).toEqual(mockTag);
expect(mockTagsService.create).toHaveBeenCalledWith(
workspaceId,
createDto
);
expect(mockTagsService.create).toHaveBeenCalledWith(workspaceId, createDto);
});
it("should pass undefined workspaceId to service (validation handled by guards)", async () => {
@@ -108,10 +105,7 @@ describe("TagsController", () => {
const result = await controller.findOne("architecture", workspaceId);
expect(result).toEqual(mockTagWithCount);
expect(mockTagsService.findOne).toHaveBeenCalledWith(
"architecture",
workspaceId
);
expect(mockTagsService.findOne).toHaveBeenCalledWith("architecture", workspaceId);
});
it("should pass undefined workspaceId to service (validation handled by guards)", async () => {
@@ -138,18 +132,10 @@ describe("TagsController", () => {
mockTagsService.update.mockResolvedValue(updatedTag);
const result = await controller.update(
"architecture",
updateDto,
workspaceId
);
const result = await controller.update("architecture", updateDto, workspaceId);
expect(result).toEqual(updatedTag);
expect(mockTagsService.update).toHaveBeenCalledWith(
"architecture",
workspaceId,
updateDto
);
expect(mockTagsService.update).toHaveBeenCalledWith("architecture", workspaceId, updateDto);
});
it("should pass undefined workspaceId to service (validation handled by guards)", async () => {
@@ -171,10 +157,7 @@ describe("TagsController", () => {
await controller.remove("architecture", workspaceId);
expect(mockTagsService.remove).toHaveBeenCalledWith(
"architecture",
workspaceId
);
expect(mockTagsService.remove).toHaveBeenCalledWith("architecture", workspaceId);
});
it("should pass undefined workspaceId to service (validation handled by guards)", async () => {
@@ -206,10 +189,7 @@ describe("TagsController", () => {
const result = await controller.getEntries("architecture", workspaceId);
expect(result).toEqual(mockEntries);
expect(mockTagsService.getEntriesWithTag).toHaveBeenCalledWith(
"architecture",
workspaceId
);
expect(mockTagsService.getEntriesWithTag).toHaveBeenCalledWith("architecture", workspaceId);
});
it("should pass undefined workspaceId to service (validation handled by guards)", async () => {

View File

@@ -2,11 +2,7 @@ import { describe, it, expect, beforeEach, vi } from "vitest";
import { Test, TestingModule } from "@nestjs/testing";
import { TagsService } from "./tags.service";
import { PrismaService } from "../prisma/prisma.service";
import {
NotFoundException,
ConflictException,
BadRequestException,
} from "@nestjs/common";
import { NotFoundException, ConflictException, BadRequestException } from "@nestjs/common";
import type { CreateTagDto, UpdateTagDto } from "./dto";
describe("TagsService", () => {
@@ -113,9 +109,7 @@ describe("TagsService", () => {
mockPrismaService.knowledgeTag.findUnique.mockResolvedValue(mockTag);
await expect(service.create(workspaceId, createDto)).rejects.toThrow(
ConflictException
);
await expect(service.create(workspaceId, createDto)).rejects.toThrow(ConflictException);
});
it("should throw BadRequestException for invalid slug format", async () => {
@@ -124,9 +118,7 @@ describe("TagsService", () => {
slug: "Invalid_Slug!",
};
await expect(service.create(workspaceId, createDto)).rejects.toThrow(
BadRequestException
);
await expect(service.create(workspaceId, createDto)).rejects.toThrow(BadRequestException);
});
it("should generate slug from name with spaces and special chars", async () => {
@@ -135,12 +127,10 @@ describe("TagsService", () => {
};
mockPrismaService.knowledgeTag.findUnique.mockResolvedValue(null);
mockPrismaService.knowledgeTag.create.mockImplementation(
async ({ data }: any) => ({
...mockTag,
slug: data.slug,
})
);
mockPrismaService.knowledgeTag.create.mockImplementation(async ({ data }: any) => ({
...mockTag,
slug: data.slug,
}));
const result = await service.create(workspaceId, createDto);
@@ -183,9 +173,7 @@ describe("TagsService", () => {
describe("findOne", () => {
it("should return a tag by slug", async () => {
const mockTagWithCount = { ...mockTag, _count: { entries: 5 } };
mockPrismaService.knowledgeTag.findUnique.mockResolvedValue(
mockTagWithCount
);
mockPrismaService.knowledgeTag.findUnique.mockResolvedValue(mockTagWithCount);
const result = await service.findOne("architecture", workspaceId);
@@ -208,9 +196,7 @@ describe("TagsService", () => {
it("should throw NotFoundException if tag not found", async () => {
mockPrismaService.knowledgeTag.findUnique.mockResolvedValue(null);
await expect(
service.findOne("nonexistent", workspaceId)
).rejects.toThrow(NotFoundException);
await expect(service.findOne("nonexistent", workspaceId)).rejects.toThrow(NotFoundException);
});
});
@@ -245,9 +231,9 @@ describe("TagsService", () => {
mockPrismaService.knowledgeTag.findUnique.mockResolvedValue(null);
await expect(
service.update("nonexistent", workspaceId, updateDto)
).rejects.toThrow(NotFoundException);
await expect(service.update("nonexistent", workspaceId, updateDto)).rejects.toThrow(
NotFoundException
);
});
it("should throw ConflictException if new slug conflicts", async () => {
@@ -263,9 +249,9 @@ describe("TagsService", () => {
slug: "design",
} as any);
await expect(
service.update("architecture", workspaceId, updateDto)
).rejects.toThrow(ConflictException);
await expect(service.update("architecture", workspaceId, updateDto)).rejects.toThrow(
ConflictException
);
});
});
@@ -292,9 +278,7 @@ describe("TagsService", () => {
it("should throw NotFoundException if tag not found", async () => {
mockPrismaService.knowledgeTag.findUnique.mockResolvedValue(null);
await expect(
service.remove("nonexistent", workspaceId)
).rejects.toThrow(NotFoundException);
await expect(service.remove("nonexistent", workspaceId)).rejects.toThrow(NotFoundException);
});
});
@@ -398,9 +382,9 @@ describe("TagsService", () => {
mockPrismaService.knowledgeTag.findUnique.mockResolvedValue(null);
await expect(
service.findOrCreateTags(workspaceId, slugs, false)
).rejects.toThrow(NotFoundException);
await expect(service.findOrCreateTags(workspaceId, slugs, false)).rejects.toThrow(
NotFoundException
);
});
});
});

View File

@@ -17,9 +17,9 @@ The `wiki-link-parser.ts` utility provides parsing of wiki-style `[[links]]` fro
### Usage
```typescript
import { parseWikiLinks } from './utils/wiki-link-parser';
import { parseWikiLinks } from "./utils/wiki-link-parser";
const content = 'See [[Main Page]] and [[Getting Started|start here]].';
const content = "See [[Main Page]] and [[Getting Started|start here]].";
const links = parseWikiLinks(content);
// Result:
@@ -44,32 +44,41 @@ const links = parseWikiLinks(content);
### Supported Link Formats
#### Basic Link (by title)
```markdown
[[Page Name]]
```
Links to a page by its title. Display text will be "Page Name".
#### Link with Display Text
```markdown
[[Page Name|custom display]]
```
Links to "Page Name" but displays "custom display".
#### Link by Slug
```markdown
[[page-slug-name]]
```
Links to a page by its URL slug (kebab-case).
### Edge Cases
#### Nested Brackets
```markdown
[[Page [with] brackets]] ✓ Parsed correctly
[[Page [with] brackets]] ✓ Parsed correctly
```
Single brackets inside link text are allowed.
#### Code Blocks (Not Parsed)
```markdown
Use `[[WikiLink]]` syntax for linking.
@@ -77,36 +86,41 @@ Use `[[WikiLink]]` syntax for linking.
const link = "[[not parsed]]";
\`\`\`
```
Links inside inline code or fenced code blocks are ignored.
#### Escaped Brackets
```markdown
\[[not a link]] but [[real link]] works
```
Escaped brackets are not parsed as links.
#### Empty or Invalid Links
```markdown
[[]] ✗ Empty link (ignored)
[[ ]] ✗ Whitespace only (ignored)
[[ Target ]] ✓ Trimmed to "Target"
[[]] ✗ Whitespace only (ignored)
[[Target]] ✓ Trimmed to "Target"
```
### Return Type
```typescript
interface WikiLink {
raw: string; // Full matched text: "[[Page Name]]"
target: string; // Target page: "Page Name"
raw: string; // Full matched text: "[[Page Name]]"
target: string; // Target page: "Page Name"
displayText: string; // Display text: "Page Name" or custom
start: number; // Start position in content
end: number; // End position in content
start: number; // Start position in content
end: number; // End position in content
}
```
### Testing
Comprehensive test suite (100% coverage) includes:
- Basic parsing (single, multiple, consecutive links)
- Display text variations
- Edge cases (brackets, escapes, empty links)
@@ -116,6 +130,7 @@ Comprehensive test suite (100% coverage) includes:
- Malformed input handling
Run tests:
```bash
pnpm test --filter=@mosaic/api -- wiki-link-parser.spec.ts
```
@@ -130,6 +145,7 @@ This parser is designed to work with the Knowledge Module's linking system:
4. **Link Rendering**: Replace `[[links]]` with HTML anchors
See related issues:
- #59 - Wiki-link parser (this implementation)
- Future: Link resolution and storage
- Future: Backlink display and navigation
@@ -151,33 +167,38 @@ The `markdown.ts` utility provides secure markdown rendering with GFM (GitHub Fl
### Usage
```typescript
import { renderMarkdown, markdownToPlainText } from './utils/markdown';
import { renderMarkdown, markdownToPlainText } from "./utils/markdown";
// Render markdown to HTML (async)
const html = await renderMarkdown('# Hello **World**');
const html = await renderMarkdown("# Hello **World**");
// Result: <h1 id="hello-world">Hello <strong>World</strong></h1>
// Extract plain text (for search indexing)
const plainText = await markdownToPlainText('# Hello **World**');
const plainText = await markdownToPlainText("# Hello **World**");
// Result: "Hello World"
```
### Supported Markdown Features
#### Basic Formatting
- **Bold**: `**text**` or `__text__`
- *Italic*: `*text*` or `_text_`
- _Italic_: `*text*` or `_text_`
- ~~Strikethrough~~: `~~text~~`
- `Inline code`: `` `code` ``
#### Headers
```markdown
# H1
## H2
### H3
```
#### Lists
```markdown
- Unordered list
- Nested item
@@ -187,19 +208,22 @@ const plainText = await markdownToPlainText('# Hello **World**');
```
#### Task Lists
```markdown
- [ ] Unchecked task
- [x] Completed task
```
#### Tables
```markdown
| Header 1 | Header 2 |
|----------|----------|
| -------- | -------- |
| Cell 1 | Cell 2 |
```
#### Code Blocks
````markdown
```typescript
const greeting: string = "Hello";
@@ -208,12 +232,14 @@ console.log(greeting);
````
#### Links and Images
```markdown
[Link text](https://example.com)
![Alt text](https://example.com/image.png)
```
#### Blockquotes
```markdown
> This is a quote
> Multi-line quote
@@ -233,6 +259,7 @@ The renderer implements multiple layers of security:
### Testing
Comprehensive test suite covers:
- Basic markdown rendering
- GFM features (tables, task lists, strikethrough)
- Code syntax highlighting
@@ -240,6 +267,7 @@ Comprehensive test suite covers:
- Edge cases (unicode, long content, nested structures)
Run tests:
```bash
pnpm test --filter=@mosaic/api -- markdown.spec.ts
```

View File

@@ -1,9 +1,5 @@
import { describe, it, expect } from "vitest";
import {
renderMarkdown,
renderMarkdownSync,
markdownToPlainText,
} from "./markdown";
import { renderMarkdown, renderMarkdownSync, markdownToPlainText } from "./markdown";
describe("Markdown Rendering", () => {
describe("renderMarkdown", () => {
@@ -77,7 +73,7 @@ describe("Markdown Rendering", () => {
const html = await renderMarkdown(markdown);
expect(html).toContain('<input');
expect(html).toContain("<input");
expect(html).toContain('type="checkbox"');
expect(html).toContain('disabled="disabled"'); // Should be disabled for safety
});
@@ -145,16 +141,17 @@ plain text code
const markdown = "![Alt text](https://example.com/image.png)";
const html = await renderMarkdown(markdown);
expect(html).toContain('<img');
expect(html).toContain("<img");
expect(html).toContain('src="https://example.com/image.png"');
expect(html).toContain('alt="Alt text"');
});
it("should allow data URIs for images", async () => {
const markdown = "![Image](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==)";
const markdown =
"![Image](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==)";
const html = await renderMarkdown(markdown);
expect(html).toContain('<img');
expect(html).toContain("<img");
expect(html).toContain('src="data:image/png;base64');
});
});
@@ -164,7 +161,7 @@ plain text code
const markdown = "# My Header Title";
const html = await renderMarkdown(markdown);
expect(html).toContain('<h1');
expect(html).toContain("<h1");
expect(html).toContain('id="');
});
@@ -282,7 +279,7 @@ plain text code
});
it("should strip all HTML tags", async () => {
const markdown = '[Link](https://example.com)\n\n![Image](image.png)';
const markdown = "[Link](https://example.com)\n\n![Image](image.png)";
const plainText = await markdownToPlainText(markdown);
expect(plainText).not.toContain("<a");

View File

@@ -333,9 +333,7 @@ const link = "[[Not A Link]]";
expect(links[0].start).toBe(5);
expect(links[0].end).toBe(23);
expect(content.substring(links[0].start, links[0].end)).toBe(
"[[Target|Display]]"
);
expect(content.substring(links[0].start, links[0].end)).toBe("[[Target|Display]]");
});
it("should track positions in multiline content", () => {

View File

@@ -114,9 +114,9 @@ describe("LayoutsService", () => {
.mockResolvedValueOnce(null) // No default
.mockResolvedValueOnce(null); // No layouts
await expect(
service.findDefault(mockWorkspaceId, mockUserId)
).rejects.toThrow(NotFoundException);
await expect(service.findDefault(mockWorkspaceId, mockUserId)).rejects.toThrow(
NotFoundException
);
});
});
@@ -139,9 +139,9 @@ describe("LayoutsService", () => {
it("should throw NotFoundException if layout not found", async () => {
prisma.userLayout.findUnique.mockResolvedValue(null);
await expect(
service.findOne("invalid-id", mockWorkspaceId, mockUserId)
).rejects.toThrow(NotFoundException);
await expect(service.findOne("invalid-id", mockWorkspaceId, mockUserId)).rejects.toThrow(
NotFoundException
);
});
});
@@ -221,12 +221,7 @@ describe("LayoutsService", () => {
})
);
const result = await service.update(
"layout-1",
mockWorkspaceId,
mockUserId,
updateDto
);
const result = await service.update("layout-1", mockWorkspaceId, mockUserId, updateDto);
expect(result).toBeDefined();
expect(mockFindUnique).toHaveBeenCalled();
@@ -244,9 +239,9 @@ describe("LayoutsService", () => {
})
);
await expect(
service.update("invalid-id", mockWorkspaceId, mockUserId, {})
).rejects.toThrow(NotFoundException);
await expect(service.update("invalid-id", mockWorkspaceId, mockUserId, {})).rejects.toThrow(
NotFoundException
);
});
});
@@ -269,9 +264,9 @@ describe("LayoutsService", () => {
it("should throw NotFoundException if layout not found", async () => {
prisma.userLayout.findUnique.mockResolvedValue(null);
await expect(
service.remove("invalid-id", mockWorkspaceId, mockUserId)
).rejects.toThrow(NotFoundException);
await expect(service.remove("invalid-id", mockWorkspaceId, mockUserId)).rejects.toThrow(
NotFoundException
);
});
});
});

View File

@@ -48,11 +48,7 @@ describe("OllamaController", () => {
});
expect(result).toEqual(mockResponse);
expect(mockOllamaService.generate).toHaveBeenCalledWith(
"Hello",
undefined,
undefined
);
expect(mockOllamaService.generate).toHaveBeenCalledWith("Hello", undefined, undefined);
});
it("should generate with options and custom model", async () => {
@@ -84,9 +80,7 @@ describe("OllamaController", () => {
describe("chat", () => {
it("should complete chat conversation", async () => {
const messages: ChatMessage[] = [
{ role: "user", content: "Hello!" },
];
const messages: ChatMessage[] = [{ role: "user", content: "Hello!" }];
const mockResponse = {
model: "llama3.2",
@@ -104,11 +98,7 @@ describe("OllamaController", () => {
});
expect(result).toEqual(mockResponse);
expect(mockOllamaService.chat).toHaveBeenCalledWith(
messages,
undefined,
undefined
);
expect(mockOllamaService.chat).toHaveBeenCalledWith(messages, undefined, undefined);
});
it("should chat with options and custom model", async () => {
@@ -158,10 +148,7 @@ describe("OllamaController", () => {
});
expect(result).toEqual(mockResponse);
expect(mockOllamaService.embed).toHaveBeenCalledWith(
"Sample text",
undefined
);
expect(mockOllamaService.embed).toHaveBeenCalledWith("Sample text", undefined);
});
it("should embed with custom model", async () => {
@@ -177,10 +164,7 @@ describe("OllamaController", () => {
});
expect(result).toEqual(mockResponse);
expect(mockOllamaService.embed).toHaveBeenCalledWith(
"Test",
"nomic-embed-text"
);
expect(mockOllamaService.embed).toHaveBeenCalledWith("Test", "nomic-embed-text");
});
});

View File

@@ -2,11 +2,7 @@ import { describe, it, expect, beforeEach, vi } from "vitest";
import { Test, TestingModule } from "@nestjs/testing";
import { OllamaService } from "./ollama.service";
import { HttpException, HttpStatus } from "@nestjs/common";
import type {
GenerateOptionsDto,
ChatMessage,
ChatOptionsDto,
} from "./dto";
import type { GenerateOptionsDto, ChatMessage, ChatOptionsDto } from "./dto";
describe("OllamaService", () => {
let service: OllamaService;
@@ -133,9 +129,7 @@ describe("OllamaService", () => {
mockFetch.mockRejectedValue(new Error("Network error"));
await expect(service.generate("Hello")).rejects.toThrow(HttpException);
await expect(service.generate("Hello")).rejects.toThrow(
"Failed to connect to Ollama"
);
await expect(service.generate("Hello")).rejects.toThrow("Failed to connect to Ollama");
});
it("should throw HttpException on non-ok response", async () => {
@@ -163,12 +157,9 @@ describe("OllamaService", () => {
],
}).compile();
const shortTimeoutService =
shortTimeoutModule.get<OllamaService>(OllamaService);
const shortTimeoutService = shortTimeoutModule.get<OllamaService>(OllamaService);
await expect(shortTimeoutService.generate("Hello")).rejects.toThrow(
HttpException
);
await expect(shortTimeoutService.generate("Hello")).rejects.toThrow(HttpException);
});
});
@@ -210,9 +201,7 @@ describe("OllamaService", () => {
});
it("should chat with custom options", async () => {
const messages: ChatMessage[] = [
{ role: "user", content: "Hello!" },
];
const messages: ChatMessage[] = [{ role: "user", content: "Hello!" }];
const options: ChatOptionsDto = {
temperature: 0.5,
@@ -251,9 +240,9 @@ describe("OllamaService", () => {
it("should throw HttpException on chat error", async () => {
mockFetch.mockRejectedValue(new Error("Connection refused"));
await expect(
service.chat([{ role: "user", content: "Hello" }])
).rejects.toThrow(HttpException);
await expect(service.chat([{ role: "user", content: "Hello" }])).rejects.toThrow(
HttpException
);
});
});

View File

@@ -23,9 +23,7 @@ describe("PrismaService", () => {
describe("onModuleInit", () => {
it("should connect to the database", async () => {
const connectSpy = vi
.spyOn(service, "$connect")
.mockResolvedValue(undefined);
const connectSpy = vi.spyOn(service, "$connect").mockResolvedValue(undefined);
await service.onModuleInit();
@@ -42,9 +40,7 @@ describe("PrismaService", () => {
describe("onModuleDestroy", () => {
it("should disconnect from the database", async () => {
const disconnectSpy = vi
.spyOn(service, "$disconnect")
.mockResolvedValue(undefined);
const disconnectSpy = vi.spyOn(service, "$disconnect").mockResolvedValue(undefined);
await service.onModuleDestroy();
@@ -62,9 +58,7 @@ describe("PrismaService", () => {
});
it("should return false when database is not accessible", async () => {
vi
.spyOn(service, "$queryRaw")
.mockRejectedValue(new Error("Database error"));
vi.spyOn(service, "$queryRaw").mockRejectedValue(new Error("Database error"));
const result = await service.isHealthy();
@@ -100,9 +94,7 @@ describe("PrismaService", () => {
});
it("should return connected false when query fails", async () => {
vi
.spyOn(service, "$queryRaw")
.mockRejectedValue(new Error("Query failed"));
vi.spyOn(service, "$queryRaw").mockRejectedValue(new Error("Query failed"));
const result = await service.getConnectionInfo();

View File

@@ -62,11 +62,7 @@ describe("ProjectsController", () => {
const result = await controller.create(createDto, mockWorkspaceId, mockUser);
expect(result).toEqual(mockProject);
expect(service.create).toHaveBeenCalledWith(
mockWorkspaceId,
mockUserId,
createDto
);
expect(service.create).toHaveBeenCalledWith(mockWorkspaceId, mockUserId, createDto);
});
it("should pass undefined workspaceId to service (validation handled by guards)", async () => {
@@ -74,7 +70,9 @@ describe("ProjectsController", () => {
await controller.create({ name: "Test" }, undefined as any, mockUser);
expect(mockProjectsService.create).toHaveBeenCalledWith(undefined, mockUserId, { name: "Test" });
expect(mockProjectsService.create).toHaveBeenCalledWith(undefined, mockUserId, {
name: "Test",
});
});
});
@@ -149,7 +147,12 @@ describe("ProjectsController", () => {
await controller.update(mockProjectId, updateDto, undefined as any, mockUser);
expect(mockProjectsService.update).toHaveBeenCalledWith(mockProjectId, undefined, mockUserId, updateDto);
expect(mockProjectsService.update).toHaveBeenCalledWith(
mockProjectId,
undefined,
mockUserId,
updateDto
);
});
});
@@ -159,11 +162,7 @@ describe("ProjectsController", () => {
await controller.remove(mockProjectId, mockWorkspaceId, mockUser);
expect(service.remove).toHaveBeenCalledWith(
mockProjectId,
mockWorkspaceId,
mockUserId
);
expect(service.remove).toHaveBeenCalledWith(mockProjectId, mockWorkspaceId, mockUserId);
});
it("should pass undefined workspaceId to service (validation handled by guards)", async () => {

View File

@@ -55,9 +55,7 @@ describe("StitcherController - Security", () => {
}),
};
await expect(guard.canActivate(mockContext as any)).rejects.toThrow(
UnauthorizedException
);
await expect(guard.canActivate(mockContext as any)).rejects.toThrow(UnauthorizedException);
});
it("POST /stitcher/dispatch should require authentication", async () => {
@@ -67,9 +65,7 @@ describe("StitcherController - Security", () => {
}),
};
await expect(guard.canActivate(mockContext as any)).rejects.toThrow(
UnauthorizedException
);
await expect(guard.canActivate(mockContext as any)).rejects.toThrow(UnauthorizedException);
});
});
@@ -96,9 +92,7 @@ describe("StitcherController - Security", () => {
}),
};
await expect(guard.canActivate(mockContext as any)).rejects.toThrow(
UnauthorizedException
);
await expect(guard.canActivate(mockContext as any)).rejects.toThrow(UnauthorizedException);
await expect(guard.canActivate(mockContext as any)).rejects.toThrow("Invalid API key");
});
@@ -111,9 +105,7 @@ describe("StitcherController - Security", () => {
}),
};
await expect(guard.canActivate(mockContext as any)).rejects.toThrow(
UnauthorizedException
);
await expect(guard.canActivate(mockContext as any)).rejects.toThrow(UnauthorizedException);
await expect(guard.canActivate(mockContext as any)).rejects.toThrow("No API key provided");
});
});
@@ -133,9 +125,7 @@ describe("StitcherController - Security", () => {
}),
};
await expect(guard.canActivate(mockContext as any)).rejects.toThrow(
UnauthorizedException
);
await expect(guard.canActivate(mockContext as any)).rejects.toThrow(UnauthorizedException);
});
});
});

View File

@@ -24,7 +24,7 @@ describe("QueryTasksDto", () => {
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
expect(errors.some(e => e.property === "workspaceId")).toBe(true);
expect(errors.some((e) => e.property === "workspaceId")).toBe(true);
});
it("should accept valid status filter", async () => {

View File

@@ -106,18 +106,10 @@ describe("TasksController", () => {
mockTasksService.create.mockResolvedValue(mockTask);
const result = await controller.create(
createDto,
mockWorkspaceId,
mockRequest.user
);
const result = await controller.create(createDto, mockWorkspaceId, mockRequest.user);
expect(result).toEqual(mockTask);
expect(service.create).toHaveBeenCalledWith(
mockWorkspaceId,
mockUserId,
createDto
);
expect(service.create).toHaveBeenCalledWith(mockWorkspaceId, mockUserId, createDto);
});
});
@@ -247,11 +239,7 @@ describe("TasksController", () => {
await controller.remove(mockTaskId, mockWorkspaceId, mockRequest.user);
expect(service.remove).toHaveBeenCalledWith(
mockTaskId,
mockWorkspaceId,
mockUserId
);
expect(service.remove).toHaveBeenCalledWith(mockTaskId, mockWorkspaceId, mockUserId);
});
it("should throw error if workspaceId not found", async () => {
@@ -262,11 +250,7 @@ describe("TasksController", () => {
await controller.remove(mockTaskId, mockWorkspaceId, mockRequest.user);
expect(service.remove).toHaveBeenCalledWith(
mockTaskId,
mockWorkspaceId,
mockUserId
);
expect(service.remove).toHaveBeenCalledWith(mockTaskId, mockWorkspaceId, mockUserId);
});
});
});

View File

@@ -69,8 +69,8 @@ docker compose up -d valkey
### 1. Inject the Service
```typescript
import { Injectable } from '@nestjs/common';
import { ValkeyService } from './valkey/valkey.service';
import { Injectable } from "@nestjs/common";
import { ValkeyService } from "./valkey/valkey.service";
@Injectable()
export class MyService {
@@ -82,11 +82,11 @@ export class MyService {
```typescript
const task = await this.valkeyService.enqueue({
type: 'send-email',
type: "send-email",
data: {
to: 'user@example.com',
subject: 'Welcome!',
body: 'Hello, welcome to Mosaic Stack',
to: "user@example.com",
subject: "Welcome!",
body: "Hello, welcome to Mosaic Stack",
},
});
@@ -102,11 +102,11 @@ const task = await this.valkeyService.dequeue();
if (task) {
console.log(task.status); // 'processing'
try {
// Do work...
await sendEmail(task.data);
// Mark as completed
await this.valkeyService.updateStatus(task.id, {
status: TaskStatus.COMPLETED,
@@ -129,8 +129,8 @@ const status = await this.valkeyService.getStatus(taskId);
if (status) {
console.log(status.status); // 'completed' | 'failed' | 'processing' | 'pending'
console.log(status.data); // Task metadata
console.log(status.error); // Error message if failed
console.log(status.data); // Task metadata
console.log(status.error); // Error message if failed
}
```
@@ -143,7 +143,7 @@ console.log(`${length} tasks in queue`);
// Health check
const healthy = await this.valkeyService.healthCheck();
console.log(`Valkey is ${healthy ? 'healthy' : 'down'}`);
console.log(`Valkey is ${healthy ? "healthy" : "down"}`);
// Clear queue (use with caution!)
await this.valkeyService.clearQueue();
@@ -181,12 +181,12 @@ export class EmailWorker {
private async startWorker() {
while (true) {
const task = await this.valkeyService.dequeue();
if (task) {
await this.processTask(task);
} else {
// No tasks, wait 5 seconds
await new Promise(resolve => setTimeout(resolve, 5000));
await new Promise((resolve) => setTimeout(resolve, 5000));
}
}
}
@@ -194,14 +194,14 @@ export class EmailWorker {
private async processTask(task: TaskDto) {
try {
switch (task.type) {
case 'send-email':
case "send-email":
await this.sendEmail(task.data);
break;
case 'generate-report':
case "generate-report":
await this.generateReport(task.data);
break;
}
await this.valkeyService.updateStatus(task.id, {
status: TaskStatus.COMPLETED,
});
@@ -222,10 +222,10 @@ export class EmailWorker {
export class ScheduledTasks {
constructor(private readonly valkeyService: ValkeyService) {}
@Cron('0 0 * * *') // Daily at midnight
@Cron("0 0 * * *") // Daily at midnight
async dailyReport() {
await this.valkeyService.enqueue({
type: 'daily-report',
type: "daily-report",
data: { date: new Date().toISOString() },
});
}
@@ -241,6 +241,7 @@ pnpm test valkey.service.spec.ts
```
Tests cover:
- ✅ Connection and initialization
- ✅ Enqueue operations
- ✅ Dequeue FIFO behavior
@@ -254,9 +255,11 @@ Tests cover:
### ValkeyService Methods
#### `enqueue(task: EnqueueTaskDto): Promise<TaskDto>`
Add a task to the queue.
**Parameters:**
- `task.type` (string): Task type identifier
- `task.data` (object): Task metadata
@@ -265,6 +268,7 @@ Add a task to the queue.
---
#### `dequeue(): Promise<TaskDto | null>`
Get the next task from the queue (FIFO).
**Returns:** Next task with status updated to PROCESSING, or null if queue is empty
@@ -272,9 +276,11 @@ Get the next task from the queue (FIFO).
---
#### `getStatus(taskId: string): Promise<TaskDto | null>`
Retrieve task status and metadata.
**Parameters:**
- `taskId` (string): Task UUID
**Returns:** Task data or null if not found
@@ -282,9 +288,11 @@ Retrieve task status and metadata.
---
#### `updateStatus(taskId: string, update: UpdateTaskStatusDto): Promise<TaskDto | null>`
Update task status and optionally add results or errors.
**Parameters:**
- `taskId` (string): Task UUID
- `update.status` (TaskStatus): New status
- `update.error` (string, optional): Error message for failed tasks
@@ -295,6 +303,7 @@ Update task status and optionally add results or errors.
---
#### `getQueueLength(): Promise<number>`
Get the number of tasks in queue.
**Returns:** Queue length
@@ -302,11 +311,13 @@ Get the number of tasks in queue.
---
#### `clearQueue(): Promise<void>`
Remove all tasks from queue (metadata remains until TTL).
---
#### `healthCheck(): Promise<boolean>`
Verify Valkey connectivity.
**Returns:** true if connected, false otherwise
@@ -314,6 +325,7 @@ Verify Valkey connectivity.
## Migration Notes
If upgrading from BullMQ or another queue system:
1. Task IDs are UUIDs (not incremental)
2. No built-in retry mechanism (implement in worker)
3. No job priorities (strict FIFO)
@@ -329,7 +341,7 @@ For advanced features like retries, priorities, or scheduled jobs, consider wrap
// Check Valkey connectivity
const healthy = await this.valkeyService.healthCheck();
if (!healthy) {
console.error('Valkey is not responding');
console.error("Valkey is not responding");
}
```
@@ -349,6 +361,7 @@ docker exec -it mosaic-valkey valkey-cli DEL mosaic:task:queue
### Debug Logging
The service logs all operations at `info` level. Check application logs for:
- Task enqueue/dequeue operations
- Status updates
- Connection events
@@ -356,6 +369,7 @@ The service logs all operations at `info` level. Check application logs for:
## Future Enhancements
Potential improvements for consideration:
- [ ] Task priorities (weighted queues)
- [ ] Retry mechanism with exponential backoff
- [ ] Delayed/scheduled tasks

View File

@@ -1,10 +1,10 @@
import { Test, TestingModule } from '@nestjs/testing';
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { ValkeyService } from './valkey.service';
import { TaskStatus } from './dto/task.dto';
import { Test, TestingModule } from "@nestjs/testing";
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
import { ValkeyService } from "./valkey.service";
import { TaskStatus } from "./dto/task.dto";
// Mock ioredis module
vi.mock('ioredis', () => {
vi.mock("ioredis", () => {
// In-memory store for mocked Redis
const store = new Map<string, string>();
const lists = new Map<string, string[]>();
@@ -13,13 +13,13 @@ vi.mock('ioredis', () => {
class MockRedisClient {
// Connection methods
async ping() {
return 'PONG';
return "PONG";
}
async quit() {
return undefined;
}
on() {
return this;
}
@@ -27,9 +27,9 @@ vi.mock('ioredis', () => {
// String operations
async setex(key: string, ttl: number, value: string) {
store.set(key, value);
return 'OK';
return "OK";
}
async get(key: string) {
return store.get(key) || null;
}
@@ -43,7 +43,7 @@ vi.mock('ioredis', () => {
list.push(...values);
return list.length;
}
async lpop(key: string) {
const list = lists.get(key);
if (!list || list.length === 0) {
@@ -51,15 +51,15 @@ vi.mock('ioredis', () => {
}
return list.shift()!;
}
async llen(key: string) {
const list = lists.get(key);
return list ? list.length : 0;
}
async del(...keys: string[]) {
let deleted = 0;
keys.forEach(key => {
keys.forEach((key) => {
if (store.delete(key)) deleted++;
if (lists.delete(key)) deleted++;
});
@@ -78,16 +78,16 @@ vi.mock('ioredis', () => {
};
});
describe('ValkeyService', () => {
describe("ValkeyService", () => {
let service: ValkeyService;
let module: TestingModule;
beforeEach(async () => {
// Clear environment
process.env.VALKEY_URL = 'redis://localhost:6379';
process.env.VALKEY_URL = "redis://localhost:6379";
// Clear the mock store before each test
const Redis = await import('ioredis');
const Redis = await import("ioredis");
(Redis.default as any).__clearStore();
module = await Test.createTestingModule({
@@ -95,7 +95,7 @@ describe('ValkeyService', () => {
}).compile();
service = module.get<ValkeyService>(ValkeyService);
// Initialize the service
await service.onModuleInit();
});
@@ -104,41 +104,41 @@ describe('ValkeyService', () => {
await service.onModuleDestroy();
});
describe('initialization', () => {
it('should be defined', () => {
describe("initialization", () => {
it("should be defined", () => {
expect(service).toBeDefined();
});
it('should connect to Valkey on module init', async () => {
it("should connect to Valkey on module init", async () => {
expect(service).toBeDefined();
const healthCheck = await service.healthCheck();
expect(healthCheck).toBe(true);
});
});
describe('enqueue', () => {
it('should enqueue a task successfully', async () => {
describe("enqueue", () => {
it("should enqueue a task successfully", async () => {
const taskDto = {
type: 'test-task',
data: { message: 'Hello World' },
type: "test-task",
data: { message: "Hello World" },
};
const result = await service.enqueue(taskDto);
expect(result).toBeDefined();
expect(result.id).toBeDefined();
expect(result.type).toBe('test-task');
expect(result.data).toEqual({ message: 'Hello World' });
expect(result.type).toBe("test-task");
expect(result.data).toEqual({ message: "Hello World" });
expect(result.status).toBe(TaskStatus.PENDING);
expect(result.createdAt).toBeDefined();
expect(result.updatedAt).toBeDefined();
});
it('should increment queue length when enqueueing', async () => {
it("should increment queue length when enqueueing", async () => {
const initialLength = await service.getQueueLength();
await service.enqueue({
type: 'task-1',
type: "task-1",
data: {},
});
@@ -147,20 +147,20 @@ describe('ValkeyService', () => {
});
});
describe('dequeue', () => {
it('should return null when queue is empty', async () => {
describe("dequeue", () => {
it("should return null when queue is empty", async () => {
const result = await service.dequeue();
expect(result).toBeNull();
});
it('should dequeue tasks in FIFO order', async () => {
it("should dequeue tasks in FIFO order", async () => {
const task1 = await service.enqueue({
type: 'task-1',
type: "task-1",
data: { order: 1 },
});
const task2 = await service.enqueue({
type: 'task-2',
type: "task-2",
data: { order: 2 },
});
@@ -173,9 +173,9 @@ describe('ValkeyService', () => {
expect(dequeued2?.status).toBe(TaskStatus.PROCESSING);
});
it('should update task status to PROCESSING when dequeued', async () => {
it("should update task status to PROCESSING when dequeued", async () => {
const task = await service.enqueue({
type: 'test-task',
type: "test-task",
data: {},
});
@@ -187,73 +187,73 @@ describe('ValkeyService', () => {
});
});
describe('getStatus', () => {
it('should return null for non-existent task', async () => {
const status = await service.getStatus('non-existent-id');
describe("getStatus", () => {
it("should return null for non-existent task", async () => {
const status = await service.getStatus("non-existent-id");
expect(status).toBeNull();
});
it('should return task status for existing task', async () => {
it("should return task status for existing task", async () => {
const task = await service.enqueue({
type: 'test-task',
data: { key: 'value' },
type: "test-task",
data: { key: "value" },
});
const status = await service.getStatus(task.id);
expect(status).toBeDefined();
expect(status?.id).toBe(task.id);
expect(status?.type).toBe('test-task');
expect(status?.data).toEqual({ key: 'value' });
expect(status?.type).toBe("test-task");
expect(status?.data).toEqual({ key: "value" });
});
});
describe('updateStatus', () => {
it('should update task status to COMPLETED', async () => {
describe("updateStatus", () => {
it("should update task status to COMPLETED", async () => {
const task = await service.enqueue({
type: 'test-task',
type: "test-task",
data: {},
});
const updated = await service.updateStatus(task.id, {
status: TaskStatus.COMPLETED,
result: { output: 'success' },
result: { output: "success" },
});
expect(updated).toBeDefined();
expect(updated?.status).toBe(TaskStatus.COMPLETED);
expect(updated?.completedAt).toBeDefined();
expect(updated?.data).toEqual({ output: 'success' });
expect(updated?.data).toEqual({ output: "success" });
});
it('should update task status to FAILED with error', async () => {
it("should update task status to FAILED with error", async () => {
const task = await service.enqueue({
type: 'test-task',
type: "test-task",
data: {},
});
const updated = await service.updateStatus(task.id, {
status: TaskStatus.FAILED,
error: 'Task failed due to error',
error: "Task failed due to error",
});
expect(updated).toBeDefined();
expect(updated?.status).toBe(TaskStatus.FAILED);
expect(updated?.error).toBe('Task failed due to error');
expect(updated?.error).toBe("Task failed due to error");
expect(updated?.completedAt).toBeDefined();
});
it('should return null when updating non-existent task', async () => {
const updated = await service.updateStatus('non-existent-id', {
it("should return null when updating non-existent task", async () => {
const updated = await service.updateStatus("non-existent-id", {
status: TaskStatus.COMPLETED,
});
expect(updated).toBeNull();
});
it('should preserve existing data when updating status', async () => {
it("should preserve existing data when updating status", async () => {
const task = await service.enqueue({
type: 'test-task',
data: { original: 'data' },
type: "test-task",
data: { original: "data" },
});
await service.updateStatus(task.id, {
@@ -261,28 +261,28 @@ describe('ValkeyService', () => {
});
const status = await service.getStatus(task.id);
expect(status?.data).toEqual({ original: 'data' });
expect(status?.data).toEqual({ original: "data" });
});
});
describe('getQueueLength', () => {
it('should return 0 for empty queue', async () => {
describe("getQueueLength", () => {
it("should return 0 for empty queue", async () => {
const length = await service.getQueueLength();
expect(length).toBe(0);
});
it('should return correct queue length', async () => {
await service.enqueue({ type: 'task-1', data: {} });
await service.enqueue({ type: 'task-2', data: {} });
await service.enqueue({ type: 'task-3', data: {} });
it("should return correct queue length", async () => {
await service.enqueue({ type: "task-1", data: {} });
await service.enqueue({ type: "task-2", data: {} });
await service.enqueue({ type: "task-3", data: {} });
const length = await service.getQueueLength();
expect(length).toBe(3);
});
it('should decrease when tasks are dequeued', async () => {
await service.enqueue({ type: 'task-1', data: {} });
await service.enqueue({ type: 'task-2', data: {} });
it("should decrease when tasks are dequeued", async () => {
await service.enqueue({ type: "task-1", data: {} });
await service.enqueue({ type: "task-2", data: {} });
expect(await service.getQueueLength()).toBe(2);
@@ -294,10 +294,10 @@ describe('ValkeyService', () => {
});
});
describe('clearQueue', () => {
it('should clear all tasks from queue', async () => {
await service.enqueue({ type: 'task-1', data: {} });
await service.enqueue({ type: 'task-2', data: {} });
describe("clearQueue", () => {
it("should clear all tasks from queue", async () => {
await service.enqueue({ type: "task-1", data: {} });
await service.enqueue({ type: "task-2", data: {} });
expect(await service.getQueueLength()).toBe(2);
@@ -306,21 +306,21 @@ describe('ValkeyService', () => {
});
});
describe('healthCheck', () => {
it('should return true when Valkey is healthy', async () => {
describe("healthCheck", () => {
it("should return true when Valkey is healthy", async () => {
const healthy = await service.healthCheck();
expect(healthy).toBe(true);
});
});
describe('integration flow', () => {
it('should handle complete task lifecycle', async () => {
describe("integration flow", () => {
it("should handle complete task lifecycle", async () => {
// 1. Enqueue task
const task = await service.enqueue({
type: 'email-notification',
type: "email-notification",
data: {
to: 'user@example.com',
subject: 'Test Email',
to: "user@example.com",
subject: "Test Email",
},
});
@@ -335,8 +335,8 @@ describe('ValkeyService', () => {
const completedTask = await service.updateStatus(task.id, {
status: TaskStatus.COMPLETED,
result: {
to: 'user@example.com',
subject: 'Test Email',
to: "user@example.com",
subject: "Test Email",
sentAt: new Date().toISOString(),
},
});
@@ -350,11 +350,11 @@ describe('ValkeyService', () => {
expect(finalStatus?.data.sentAt).toBeDefined();
});
it('should handle multiple concurrent tasks', async () => {
it("should handle multiple concurrent tasks", async () => {
const tasks = await Promise.all([
service.enqueue({ type: 'task-1', data: { id: 1 } }),
service.enqueue({ type: 'task-2', data: { id: 2 } }),
service.enqueue({ type: 'task-3', data: { id: 3 } }),
service.enqueue({ type: "task-1", data: { id: 1 } }),
service.enqueue({ type: "task-2", data: { id: 2 } }),
service.enqueue({ type: "task-3", data: { id: 3 } }),
]);
expect(await service.getQueueLength()).toBe(3);

View File

@@ -1,9 +1,9 @@
import { Test, TestingModule } from '@nestjs/testing';
import { WebSocketGateway } from './websocket.gateway';
import { AuthService } from '../auth/auth.service';
import { PrismaService } from '../prisma/prisma.service';
import { Server, Socket } from 'socket.io';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { Test, TestingModule } from "@nestjs/testing";
import { WebSocketGateway } from "./websocket.gateway";
import { AuthService } from "../auth/auth.service";
import { PrismaService } from "../prisma/prisma.service";
import { Server, Socket } from "socket.io";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
interface AuthenticatedSocket extends Socket {
data: {
@@ -12,7 +12,7 @@ interface AuthenticatedSocket extends Socket {
};
}
describe('WebSocketGateway', () => {
describe("WebSocketGateway", () => {
let gateway: WebSocketGateway;
let authService: AuthService;
let prismaService: PrismaService;
@@ -53,7 +53,7 @@ describe('WebSocketGateway', () => {
// Mock authenticated client
mockClient = {
id: 'test-socket-id',
id: "test-socket-id",
join: vi.fn(),
leave: vi.fn(),
emit: vi.fn(),
@@ -61,7 +61,7 @@ describe('WebSocketGateway', () => {
data: {},
handshake: {
auth: {
token: 'valid-token',
token: "valid-token",
},
},
} as unknown as AuthenticatedSocket;
@@ -76,36 +76,36 @@ describe('WebSocketGateway', () => {
}
});
describe('Authentication', () => {
it('should validate token and populate socket.data on successful authentication', async () => {
describe("Authentication", () => {
it("should validate token and populate socket.data on successful authentication", async () => {
const mockSessionData = {
user: { id: 'user-123', email: 'test@example.com' },
session: { id: 'session-123' },
user: { id: "user-123", email: "test@example.com" },
session: { id: "session-123" },
};
vi.spyOn(authService, 'verifySession').mockResolvedValue(mockSessionData);
vi.spyOn(prismaService.workspaceMember, 'findFirst').mockResolvedValue({
userId: 'user-123',
workspaceId: 'workspace-456',
role: 'MEMBER',
vi.spyOn(authService, "verifySession").mockResolvedValue(mockSessionData);
vi.spyOn(prismaService.workspaceMember, "findFirst").mockResolvedValue({
userId: "user-123",
workspaceId: "workspace-456",
role: "MEMBER",
} as never);
await gateway.handleConnection(mockClient);
expect(authService.verifySession).toHaveBeenCalledWith('valid-token');
expect(mockClient.data.userId).toBe('user-123');
expect(mockClient.data.workspaceId).toBe('workspace-456');
expect(authService.verifySession).toHaveBeenCalledWith("valid-token");
expect(mockClient.data.userId).toBe("user-123");
expect(mockClient.data.workspaceId).toBe("workspace-456");
});
it('should disconnect client with invalid token', async () => {
vi.spyOn(authService, 'verifySession').mockResolvedValue(null);
it("should disconnect client with invalid token", async () => {
vi.spyOn(authService, "verifySession").mockResolvedValue(null);
await gateway.handleConnection(mockClient);
expect(mockClient.disconnect).toHaveBeenCalled();
});
it('should disconnect client without token', async () => {
it("should disconnect client without token", async () => {
const clientNoToken = {
...mockClient,
handshake: { auth: {} },
@@ -116,23 +116,23 @@ describe('WebSocketGateway', () => {
expect(clientNoToken.disconnect).toHaveBeenCalled();
});
it('should disconnect client if token verification throws error', async () => {
vi.spyOn(authService, 'verifySession').mockRejectedValue(new Error('Invalid token'));
it("should disconnect client if token verification throws error", async () => {
vi.spyOn(authService, "verifySession").mockRejectedValue(new Error("Invalid token"));
await gateway.handleConnection(mockClient);
expect(mockClient.disconnect).toHaveBeenCalled();
});
it('should have connection timeout mechanism in place', () => {
it("should have connection timeout mechanism in place", () => {
// This test verifies that the gateway has a CONNECTION_TIMEOUT_MS constant
// The actual timeout is tested indirectly through authentication failure tests
expect((gateway as { CONNECTION_TIMEOUT_MS: number }).CONNECTION_TIMEOUT_MS).toBe(5000);
});
});
describe('Rate Limiting', () => {
it('should reject connections exceeding rate limit', async () => {
describe("Rate Limiting", () => {
it("should reject connections exceeding rate limit", async () => {
// Mock rate limiter to return false (limit exceeded)
const rateLimitedClient = { ...mockClient } as AuthenticatedSocket;
@@ -146,109 +146,109 @@ describe('WebSocketGateway', () => {
// expect(rateLimitedClient.disconnect).toHaveBeenCalled();
});
it('should allow connections within rate limit', async () => {
it("should allow connections within rate limit", async () => {
const mockSessionData = {
user: { id: 'user-123', email: 'test@example.com' },
session: { id: 'session-123' },
user: { id: "user-123", email: "test@example.com" },
session: { id: "session-123" },
};
vi.spyOn(authService, 'verifySession').mockResolvedValue(mockSessionData);
vi.spyOn(prismaService.workspaceMember, 'findFirst').mockResolvedValue({
userId: 'user-123',
workspaceId: 'workspace-456',
role: 'MEMBER',
vi.spyOn(authService, "verifySession").mockResolvedValue(mockSessionData);
vi.spyOn(prismaService.workspaceMember, "findFirst").mockResolvedValue({
userId: "user-123",
workspaceId: "workspace-456",
role: "MEMBER",
} as never);
await gateway.handleConnection(mockClient);
expect(mockClient.disconnect).not.toHaveBeenCalled();
expect(mockClient.data.userId).toBe('user-123');
expect(mockClient.data.userId).toBe("user-123");
});
});
describe('Workspace Access Validation', () => {
it('should verify user has access to workspace', async () => {
describe("Workspace Access Validation", () => {
it("should verify user has access to workspace", async () => {
const mockSessionData = {
user: { id: 'user-123', email: 'test@example.com' },
session: { id: 'session-123' },
user: { id: "user-123", email: "test@example.com" },
session: { id: "session-123" },
};
vi.spyOn(authService, 'verifySession').mockResolvedValue(mockSessionData);
vi.spyOn(prismaService.workspaceMember, 'findFirst').mockResolvedValue({
userId: 'user-123',
workspaceId: 'workspace-456',
role: 'MEMBER',
vi.spyOn(authService, "verifySession").mockResolvedValue(mockSessionData);
vi.spyOn(prismaService.workspaceMember, "findFirst").mockResolvedValue({
userId: "user-123",
workspaceId: "workspace-456",
role: "MEMBER",
} as never);
await gateway.handleConnection(mockClient);
expect(prismaService.workspaceMember.findFirst).toHaveBeenCalledWith({
where: { userId: 'user-123' },
where: { userId: "user-123" },
select: { workspaceId: true, userId: true, role: true },
});
});
it('should disconnect client without workspace access', async () => {
it("should disconnect client without workspace access", async () => {
const mockSessionData = {
user: { id: 'user-123', email: 'test@example.com' },
session: { id: 'session-123' },
user: { id: "user-123", email: "test@example.com" },
session: { id: "session-123" },
};
vi.spyOn(authService, 'verifySession').mockResolvedValue(mockSessionData);
vi.spyOn(prismaService.workspaceMember, 'findFirst').mockResolvedValue(null);
vi.spyOn(authService, "verifySession").mockResolvedValue(mockSessionData);
vi.spyOn(prismaService.workspaceMember, "findFirst").mockResolvedValue(null);
await gateway.handleConnection(mockClient);
expect(mockClient.disconnect).toHaveBeenCalled();
});
it('should only allow joining workspace rooms user has access to', async () => {
it("should only allow joining workspace rooms user has access to", async () => {
const mockSessionData = {
user: { id: 'user-123', email: 'test@example.com' },
session: { id: 'session-123' },
user: { id: "user-123", email: "test@example.com" },
session: { id: "session-123" },
};
vi.spyOn(authService, 'verifySession').mockResolvedValue(mockSessionData);
vi.spyOn(prismaService.workspaceMember, 'findFirst').mockResolvedValue({
userId: 'user-123',
workspaceId: 'workspace-456',
role: 'MEMBER',
vi.spyOn(authService, "verifySession").mockResolvedValue(mockSessionData);
vi.spyOn(prismaService.workspaceMember, "findFirst").mockResolvedValue({
userId: "user-123",
workspaceId: "workspace-456",
role: "MEMBER",
} as never);
await gateway.handleConnection(mockClient);
// Should join the workspace room they have access to
expect(mockClient.join).toHaveBeenCalledWith('workspace:workspace-456');
expect(mockClient.join).toHaveBeenCalledWith("workspace:workspace-456");
});
});
describe('handleConnection', () => {
describe("handleConnection", () => {
beforeEach(() => {
const mockSessionData = {
user: { id: 'user-123', email: 'test@example.com' },
session: { id: 'session-123' },
user: { id: "user-123", email: "test@example.com" },
session: { id: "session-123" },
};
vi.spyOn(authService, 'verifySession').mockResolvedValue(mockSessionData);
vi.spyOn(prismaService.workspaceMember, 'findFirst').mockResolvedValue({
userId: 'user-123',
workspaceId: 'workspace-456',
role: 'MEMBER',
vi.spyOn(authService, "verifySession").mockResolvedValue(mockSessionData);
vi.spyOn(prismaService.workspaceMember, "findFirst").mockResolvedValue({
userId: "user-123",
workspaceId: "workspace-456",
role: "MEMBER",
} as never);
mockClient.data = {
userId: 'user-123',
workspaceId: 'workspace-456',
userId: "user-123",
workspaceId: "workspace-456",
};
});
it('should join client to workspace room on connection', async () => {
it("should join client to workspace room on connection", async () => {
await gateway.handleConnection(mockClient);
expect(mockClient.join).toHaveBeenCalledWith('workspace:workspace-456');
expect(mockClient.join).toHaveBeenCalledWith("workspace:workspace-456");
});
it('should reject connection without authentication', async () => {
it("should reject connection without authentication", async () => {
const unauthClient = {
...mockClient,
data: {},
@@ -261,23 +261,23 @@ describe('WebSocketGateway', () => {
});
});
describe('handleDisconnect', () => {
it('should leave workspace room on disconnect', () => {
describe("handleDisconnect", () => {
it("should leave workspace room on disconnect", () => {
// Populate data as if client was authenticated
const authenticatedClient = {
...mockClient,
data: {
userId: 'user-123',
workspaceId: 'workspace-456',
userId: "user-123",
workspaceId: "workspace-456",
},
} as unknown as AuthenticatedSocket;
gateway.handleDisconnect(authenticatedClient);
expect(authenticatedClient.leave).toHaveBeenCalledWith('workspace:workspace-456');
expect(authenticatedClient.leave).toHaveBeenCalledWith("workspace:workspace-456");
});
it('should not throw error when disconnecting unauthenticated client', () => {
it("should not throw error when disconnecting unauthenticated client", () => {
const unauthenticatedClient = {
...mockClient,
data: {},
@@ -287,279 +287,279 @@ describe('WebSocketGateway', () => {
});
});
describe('emitTaskCreated', () => {
it('should emit task:created event to workspace room', () => {
describe("emitTaskCreated", () => {
it("should emit task:created event to workspace room", () => {
const task = {
id: 'task-1',
title: 'Test Task',
workspaceId: 'workspace-456',
id: "task-1",
title: "Test Task",
workspaceId: "workspace-456",
};
gateway.emitTaskCreated('workspace-456', task);
gateway.emitTaskCreated("workspace-456", task);
expect(mockServer.to).toHaveBeenCalledWith('workspace:workspace-456');
expect(mockServer.emit).toHaveBeenCalledWith('task:created', task);
expect(mockServer.to).toHaveBeenCalledWith("workspace:workspace-456");
expect(mockServer.emit).toHaveBeenCalledWith("task:created", task);
});
});
describe('emitTaskUpdated', () => {
it('should emit task:updated event to workspace room', () => {
describe("emitTaskUpdated", () => {
it("should emit task:updated event to workspace room", () => {
const task = {
id: 'task-1',
title: 'Updated Task',
workspaceId: 'workspace-456',
id: "task-1",
title: "Updated Task",
workspaceId: "workspace-456",
};
gateway.emitTaskUpdated('workspace-456', task);
gateway.emitTaskUpdated("workspace-456", task);
expect(mockServer.to).toHaveBeenCalledWith('workspace:workspace-456');
expect(mockServer.emit).toHaveBeenCalledWith('task:updated', task);
expect(mockServer.to).toHaveBeenCalledWith("workspace:workspace-456");
expect(mockServer.emit).toHaveBeenCalledWith("task:updated", task);
});
});
describe('emitTaskDeleted', () => {
it('should emit task:deleted event to workspace room', () => {
const taskId = 'task-1';
describe("emitTaskDeleted", () => {
it("should emit task:deleted event to workspace room", () => {
const taskId = "task-1";
gateway.emitTaskDeleted('workspace-456', taskId);
gateway.emitTaskDeleted("workspace-456", taskId);
expect(mockServer.to).toHaveBeenCalledWith('workspace:workspace-456');
expect(mockServer.emit).toHaveBeenCalledWith('task:deleted', { id: taskId });
expect(mockServer.to).toHaveBeenCalledWith("workspace:workspace-456");
expect(mockServer.emit).toHaveBeenCalledWith("task:deleted", { id: taskId });
});
});
describe('emitEventCreated', () => {
it('should emit event:created event to workspace room', () => {
describe("emitEventCreated", () => {
it("should emit event:created event to workspace room", () => {
const event = {
id: 'event-1',
title: 'Test Event',
workspaceId: 'workspace-456',
id: "event-1",
title: "Test Event",
workspaceId: "workspace-456",
};
gateway.emitEventCreated('workspace-456', event);
gateway.emitEventCreated("workspace-456", event);
expect(mockServer.to).toHaveBeenCalledWith('workspace:workspace-456');
expect(mockServer.emit).toHaveBeenCalledWith('event:created', event);
expect(mockServer.to).toHaveBeenCalledWith("workspace:workspace-456");
expect(mockServer.emit).toHaveBeenCalledWith("event:created", event);
});
});
describe('emitEventUpdated', () => {
it('should emit event:updated event to workspace room', () => {
describe("emitEventUpdated", () => {
it("should emit event:updated event to workspace room", () => {
const event = {
id: 'event-1',
title: 'Updated Event',
workspaceId: 'workspace-456',
id: "event-1",
title: "Updated Event",
workspaceId: "workspace-456",
};
gateway.emitEventUpdated('workspace-456', event);
gateway.emitEventUpdated("workspace-456", event);
expect(mockServer.to).toHaveBeenCalledWith('workspace:workspace-456');
expect(mockServer.emit).toHaveBeenCalledWith('event:updated', event);
expect(mockServer.to).toHaveBeenCalledWith("workspace:workspace-456");
expect(mockServer.emit).toHaveBeenCalledWith("event:updated", event);
});
});
describe('emitEventDeleted', () => {
it('should emit event:deleted event to workspace room', () => {
const eventId = 'event-1';
describe("emitEventDeleted", () => {
it("should emit event:deleted event to workspace room", () => {
const eventId = "event-1";
gateway.emitEventDeleted('workspace-456', eventId);
gateway.emitEventDeleted("workspace-456", eventId);
expect(mockServer.to).toHaveBeenCalledWith('workspace:workspace-456');
expect(mockServer.emit).toHaveBeenCalledWith('event:deleted', { id: eventId });
expect(mockServer.to).toHaveBeenCalledWith("workspace:workspace-456");
expect(mockServer.emit).toHaveBeenCalledWith("event:deleted", { id: eventId });
});
});
describe('emitProjectUpdated', () => {
it('should emit project:updated event to workspace room', () => {
describe("emitProjectUpdated", () => {
it("should emit project:updated event to workspace room", () => {
const project = {
id: 'project-1',
name: 'Updated Project',
workspaceId: 'workspace-456',
id: "project-1",
name: "Updated Project",
workspaceId: "workspace-456",
};
gateway.emitProjectUpdated('workspace-456', project);
gateway.emitProjectUpdated("workspace-456", project);
expect(mockServer.to).toHaveBeenCalledWith('workspace:workspace-456');
expect(mockServer.emit).toHaveBeenCalledWith('project:updated', project);
expect(mockServer.to).toHaveBeenCalledWith("workspace:workspace-456");
expect(mockServer.emit).toHaveBeenCalledWith("project:updated", project);
});
});
describe('Job Events', () => {
describe('emitJobCreated', () => {
it('should emit job:created event to workspace jobs room', () => {
describe("Job Events", () => {
describe("emitJobCreated", () => {
it("should emit job:created event to workspace jobs room", () => {
const job = {
id: 'job-1',
workspaceId: 'workspace-456',
type: 'code-task',
status: 'PENDING',
id: "job-1",
workspaceId: "workspace-456",
type: "code-task",
status: "PENDING",
};
gateway.emitJobCreated('workspace-456', job);
gateway.emitJobCreated("workspace-456", job);
expect(mockServer.to).toHaveBeenCalledWith('workspace:workspace-456:jobs');
expect(mockServer.emit).toHaveBeenCalledWith('job:created', job);
expect(mockServer.to).toHaveBeenCalledWith("workspace:workspace-456:jobs");
expect(mockServer.emit).toHaveBeenCalledWith("job:created", job);
});
it('should emit job:created event to specific job room', () => {
it("should emit job:created event to specific job room", () => {
const job = {
id: 'job-1',
workspaceId: 'workspace-456',
type: 'code-task',
status: 'PENDING',
id: "job-1",
workspaceId: "workspace-456",
type: "code-task",
status: "PENDING",
};
gateway.emitJobCreated('workspace-456', job);
gateway.emitJobCreated("workspace-456", job);
expect(mockServer.to).toHaveBeenCalledWith('job:job-1');
expect(mockServer.to).toHaveBeenCalledWith("job:job-1");
});
});
describe('emitJobStatusChanged', () => {
it('should emit job:status event to workspace jobs room', () => {
describe("emitJobStatusChanged", () => {
it("should emit job:status event to workspace jobs room", () => {
const data = {
id: 'job-1',
workspaceId: 'workspace-456',
status: 'RUNNING',
previousStatus: 'PENDING',
id: "job-1",
workspaceId: "workspace-456",
status: "RUNNING",
previousStatus: "PENDING",
};
gateway.emitJobStatusChanged('workspace-456', 'job-1', data);
gateway.emitJobStatusChanged("workspace-456", "job-1", data);
expect(mockServer.to).toHaveBeenCalledWith('workspace:workspace-456:jobs');
expect(mockServer.emit).toHaveBeenCalledWith('job:status', data);
expect(mockServer.to).toHaveBeenCalledWith("workspace:workspace-456:jobs");
expect(mockServer.emit).toHaveBeenCalledWith("job:status", data);
});
it('should emit job:status event to specific job room', () => {
it("should emit job:status event to specific job room", () => {
const data = {
id: 'job-1',
workspaceId: 'workspace-456',
status: 'RUNNING',
previousStatus: 'PENDING',
id: "job-1",
workspaceId: "workspace-456",
status: "RUNNING",
previousStatus: "PENDING",
};
gateway.emitJobStatusChanged('workspace-456', 'job-1', data);
gateway.emitJobStatusChanged("workspace-456", "job-1", data);
expect(mockServer.to).toHaveBeenCalledWith('job:job-1');
expect(mockServer.to).toHaveBeenCalledWith("job:job-1");
});
});
describe('emitJobProgress', () => {
it('should emit job:progress event to workspace jobs room', () => {
describe("emitJobProgress", () => {
it("should emit job:progress event to workspace jobs room", () => {
const data = {
id: 'job-1',
workspaceId: 'workspace-456',
id: "job-1",
workspaceId: "workspace-456",
progressPercent: 45,
message: 'Processing step 2 of 4',
message: "Processing step 2 of 4",
};
gateway.emitJobProgress('workspace-456', 'job-1', data);
gateway.emitJobProgress("workspace-456", "job-1", data);
expect(mockServer.to).toHaveBeenCalledWith('workspace:workspace-456:jobs');
expect(mockServer.emit).toHaveBeenCalledWith('job:progress', data);
expect(mockServer.to).toHaveBeenCalledWith("workspace:workspace-456:jobs");
expect(mockServer.emit).toHaveBeenCalledWith("job:progress", data);
});
it('should emit job:progress event to specific job room', () => {
it("should emit job:progress event to specific job room", () => {
const data = {
id: 'job-1',
workspaceId: 'workspace-456',
id: "job-1",
workspaceId: "workspace-456",
progressPercent: 45,
message: 'Processing step 2 of 4',
message: "Processing step 2 of 4",
};
gateway.emitJobProgress('workspace-456', 'job-1', data);
gateway.emitJobProgress("workspace-456", "job-1", data);
expect(mockServer.to).toHaveBeenCalledWith('job:job-1');
expect(mockServer.to).toHaveBeenCalledWith("job:job-1");
});
});
describe('emitStepStarted', () => {
it('should emit step:started event to workspace jobs room', () => {
describe("emitStepStarted", () => {
it("should emit step:started event to workspace jobs room", () => {
const data = {
id: 'step-1',
jobId: 'job-1',
workspaceId: 'workspace-456',
name: 'Build',
id: "step-1",
jobId: "job-1",
workspaceId: "workspace-456",
name: "Build",
};
gateway.emitStepStarted('workspace-456', 'job-1', data);
gateway.emitStepStarted("workspace-456", "job-1", data);
expect(mockServer.to).toHaveBeenCalledWith('workspace:workspace-456:jobs');
expect(mockServer.emit).toHaveBeenCalledWith('step:started', data);
expect(mockServer.to).toHaveBeenCalledWith("workspace:workspace-456:jobs");
expect(mockServer.emit).toHaveBeenCalledWith("step:started", data);
});
it('should emit step:started event to specific job room', () => {
it("should emit step:started event to specific job room", () => {
const data = {
id: 'step-1',
jobId: 'job-1',
workspaceId: 'workspace-456',
name: 'Build',
id: "step-1",
jobId: "job-1",
workspaceId: "workspace-456",
name: "Build",
};
gateway.emitStepStarted('workspace-456', 'job-1', data);
gateway.emitStepStarted("workspace-456", "job-1", data);
expect(mockServer.to).toHaveBeenCalledWith('job:job-1');
expect(mockServer.to).toHaveBeenCalledWith("job:job-1");
});
});
describe('emitStepCompleted', () => {
it('should emit step:completed event to workspace jobs room', () => {
describe("emitStepCompleted", () => {
it("should emit step:completed event to workspace jobs room", () => {
const data = {
id: 'step-1',
jobId: 'job-1',
workspaceId: 'workspace-456',
name: 'Build',
id: "step-1",
jobId: "job-1",
workspaceId: "workspace-456",
name: "Build",
success: true,
};
gateway.emitStepCompleted('workspace-456', 'job-1', data);
gateway.emitStepCompleted("workspace-456", "job-1", data);
expect(mockServer.to).toHaveBeenCalledWith('workspace:workspace-456:jobs');
expect(mockServer.emit).toHaveBeenCalledWith('step:completed', data);
expect(mockServer.to).toHaveBeenCalledWith("workspace:workspace-456:jobs");
expect(mockServer.emit).toHaveBeenCalledWith("step:completed", data);
});
it('should emit step:completed event to specific job room', () => {
it("should emit step:completed event to specific job room", () => {
const data = {
id: 'step-1',
jobId: 'job-1',
workspaceId: 'workspace-456',
name: 'Build',
id: "step-1",
jobId: "job-1",
workspaceId: "workspace-456",
name: "Build",
success: true,
};
gateway.emitStepCompleted('workspace-456', 'job-1', data);
gateway.emitStepCompleted("workspace-456", "job-1", data);
expect(mockServer.to).toHaveBeenCalledWith('job:job-1');
expect(mockServer.to).toHaveBeenCalledWith("job:job-1");
});
});
describe('emitStepOutput', () => {
it('should emit step:output event to workspace jobs room', () => {
describe("emitStepOutput", () => {
it("should emit step:output event to workspace jobs room", () => {
const data = {
id: 'step-1',
jobId: 'job-1',
workspaceId: 'workspace-456',
output: 'Build completed successfully',
id: "step-1",
jobId: "job-1",
workspaceId: "workspace-456",
output: "Build completed successfully",
timestamp: new Date().toISOString(),
};
gateway.emitStepOutput('workspace-456', 'job-1', data);
gateway.emitStepOutput("workspace-456", "job-1", data);
expect(mockServer.to).toHaveBeenCalledWith('workspace:workspace-456:jobs');
expect(mockServer.emit).toHaveBeenCalledWith('step:output', data);
expect(mockServer.to).toHaveBeenCalledWith("workspace:workspace-456:jobs");
expect(mockServer.emit).toHaveBeenCalledWith("step:output", data);
});
it('should emit step:output event to specific job room', () => {
it("should emit step:output event to specific job room", () => {
const data = {
id: 'step-1',
jobId: 'job-1',
workspaceId: 'workspace-456',
output: 'Build completed successfully',
id: "step-1",
jobId: "job-1",
workspaceId: "workspace-456",
output: "Build completed successfully",
timestamp: new Date().toISOString(),
};
gateway.emitStepOutput('workspace-456', 'job-1', data);
gateway.emitStepOutput("workspace-456", "job-1", data);
expect(mockServer.to).toHaveBeenCalledWith('job:job-1');
expect(mockServer.to).toHaveBeenCalledWith("job:job-1");
});
});
});

View File

@@ -1,9 +1,11 @@
import {
Controller,
Post,
Get,
Body,
Param,
BadRequestException,
NotFoundException,
Logger,
UsePipes,
ValidationPipe,
@@ -11,6 +13,7 @@ import {
} from "@nestjs/common";
import { QueueService } from "../../queue/queue.service";
import { AgentSpawnerService } from "../../spawner/agent-spawner.service";
import { AgentLifecycleService } from "../../spawner/agent-lifecycle.service";
import { KillswitchService } from "../../killswitch/killswitch.service";
import { SpawnAgentDto, SpawnAgentResponseDto } from "./dto/spawn-agent.dto";
@@ -24,6 +27,7 @@ export class AgentsController {
constructor(
private readonly queueService: QueueService,
private readonly spawnerService: AgentSpawnerService,
private readonly lifecycleService: AgentLifecycleService,
private readonly killswitchService: KillswitchService
) {}
@@ -66,6 +70,64 @@ export class AgentsController {
}
}
/**
* Get agent status
* @param agentId Agent ID to query
* @returns Agent status details
*/
@Get(":agentId/status")
async getAgentStatus(@Param("agentId") agentId: string): Promise<{
agentId: string;
taskId: string;
status: string;
spawnedAt: string;
startedAt?: string;
completedAt?: string;
error?: string;
}> {
this.logger.log(`Received status request for agent: ${agentId}`);
try {
// Try to get from lifecycle service (Valkey)
const lifecycleState = await this.lifecycleService.getAgentLifecycleState(agentId);
if (lifecycleState) {
return {
agentId: lifecycleState.agentId,
taskId: lifecycleState.taskId,
status: lifecycleState.status,
spawnedAt: lifecycleState.startedAt ?? new Date().toISOString(),
startedAt: lifecycleState.startedAt,
completedAt: lifecycleState.completedAt,
error: lifecycleState.error,
};
}
// Fallback to spawner service (in-memory)
const session = this.spawnerService.getAgentSession(agentId);
if (session) {
return {
agentId: session.agentId,
taskId: session.taskId,
status: session.state,
spawnedAt: session.spawnedAt.toISOString(),
completedAt: session.completedAt?.toISOString(),
error: session.error,
};
}
throw new NotFoundException(`Agent ${agentId} not found`);
} catch (error: unknown) {
if (error instanceof NotFoundException) {
throw error;
}
const errorMessage = error instanceof Error ? error.message : String(error);
this.logger.error(`Failed to get agent status: ${errorMessage}`);
throw new Error(`Failed to get agent status: ${errorMessage}`);
}
}
/**
* Kill a single agent immediately
* @param agentId Agent ID to kill

View File

@@ -3,9 +3,10 @@ import { AgentsController } from "./agents.controller";
import { QueueModule } from "../../queue/queue.module";
import { SpawnerModule } from "../../spawner/spawner.module";
import { KillswitchModule } from "../../killswitch/killswitch.module";
import { ValkeyModule } from "../../valkey/valkey.module";
@Module({
imports: [QueueModule, SpawnerModule, KillswitchModule],
imports: [QueueModule, SpawnerModule, KillswitchModule, ValkeyModule],
controllers: [AgentsController],
})
export class AgentsModule {}