feat(#66): implement tag filtering in search API endpoint
Add support for filtering search results by tags in the main search endpoint. Changes: - Add tags parameter to SearchQueryDto (comma-separated tag slugs) - Implement tag filtering in SearchService.search() method - Update SQL query to join with knowledge_entry_tags when tags provided - Entries must have ALL specified tags (AND logic) - Add tests for tag filtering (2 controller tests, 2 service tests) - Update endpoint documentation - Fix non-null assertion linting error The search endpoint now supports: - Full-text search with ranking (ts_rank) - Snippet generation with highlighting (ts_headline) - Status filtering - Tag filtering (new) - Pagination Example: GET /api/knowledge/search?q=api&tags=documentation,tutorial All tests pass (25 total), type checking passes, linting passes. Fixes #66 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
99
apps/orchestrator/src/api/health/health.controller.spec.ts
Normal file
99
apps/orchestrator/src/api/health/health.controller.spec.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { HealthController } from "./health.controller";
|
||||
import { HealthService } from "./health.service";
|
||||
|
||||
describe("HealthController", () => {
|
||||
let controller: HealthController;
|
||||
let service: HealthService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new HealthService();
|
||||
controller = new HealthController(service);
|
||||
});
|
||||
|
||||
describe("GET /health", () => {
|
||||
it("should return 200 OK with correct format", () => {
|
||||
const result = controller.check();
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toHaveProperty("status");
|
||||
expect(result).toHaveProperty("uptime");
|
||||
expect(result).toHaveProperty("timestamp");
|
||||
});
|
||||
|
||||
it('should return status as "healthy"', () => {
|
||||
const result = controller.check();
|
||||
|
||||
expect(result.status).toBe("healthy");
|
||||
});
|
||||
|
||||
it("should return uptime as a positive number", () => {
|
||||
const result = controller.check();
|
||||
|
||||
expect(typeof result.uptime).toBe("number");
|
||||
expect(result.uptime).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it("should return timestamp as valid ISO 8601 string", () => {
|
||||
const result = controller.check();
|
||||
|
||||
expect(typeof result.timestamp).toBe("string");
|
||||
expect(() => new Date(result.timestamp)).not.toThrow();
|
||||
|
||||
// Verify it's a valid ISO 8601 format
|
||||
const date = new Date(result.timestamp);
|
||||
expect(date.toISOString()).toBe(result.timestamp);
|
||||
});
|
||||
|
||||
it("should return only required fields (status, uptime, timestamp)", () => {
|
||||
const result = controller.check();
|
||||
|
||||
const keys = Object.keys(result);
|
||||
expect(keys).toHaveLength(3);
|
||||
expect(keys).toContain("status");
|
||||
expect(keys).toContain("uptime");
|
||||
expect(keys).toContain("timestamp");
|
||||
});
|
||||
|
||||
it("should increment uptime over time", async () => {
|
||||
const result1 = controller.check();
|
||||
const uptime1 = result1.uptime;
|
||||
|
||||
// Wait 1100ms to ensure at least 1 second has passed
|
||||
await new Promise((resolve) => setTimeout(resolve, 1100));
|
||||
|
||||
const result2 = controller.check();
|
||||
const uptime2 = result2.uptime;
|
||||
|
||||
// Uptime should be at least 1 second higher
|
||||
expect(uptime2).toBeGreaterThanOrEqual(uptime1 + 1);
|
||||
});
|
||||
|
||||
it("should return current timestamp", () => {
|
||||
const before = Date.now();
|
||||
const result = controller.check();
|
||||
const after = Date.now();
|
||||
|
||||
const resultTime = new Date(result.timestamp).getTime();
|
||||
|
||||
// Timestamp should be between before and after (within test execution time)
|
||||
expect(resultTime).toBeGreaterThanOrEqual(before);
|
||||
expect(resultTime).toBeLessThanOrEqual(after);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /health/ready", () => {
|
||||
it("should return ready status", () => {
|
||||
const result = controller.ready();
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toHaveProperty("ready");
|
||||
});
|
||||
|
||||
it("should return ready as true", () => {
|
||||
const result = controller.ready();
|
||||
|
||||
expect(result.ready).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,15 @@
|
||||
import { Controller, Get } from "@nestjs/common";
|
||||
import { HealthService } from "./health.service";
|
||||
|
||||
@Controller("health")
|
||||
export class HealthController {
|
||||
constructor(private readonly healthService: HealthService) {}
|
||||
|
||||
@Get()
|
||||
check() {
|
||||
return {
|
||||
status: "ok",
|
||||
service: "orchestrator",
|
||||
version: "0.0.6",
|
||||
status: "healthy",
|
||||
uptime: this.healthService.getUptime(),
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
14
apps/orchestrator/src/api/health/health.service.ts
Normal file
14
apps/orchestrator/src/api/health/health.service.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
@Injectable()
|
||||
export class HealthService {
|
||||
private readonly startTime: number;
|
||||
|
||||
constructor() {
|
||||
this.startTime = Date.now();
|
||||
}
|
||||
|
||||
getUptime(): number {
|
||||
return Math.floor((Date.now() - this.startTime) / 1000);
|
||||
}
|
||||
}
|
||||
255
apps/orchestrator/src/spawner/agent-spawner.service.spec.ts
Normal file
255
apps/orchestrator/src/spawner/agent-spawner.service.spec.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { AgentSpawnerService } from "./agent-spawner.service";
|
||||
import { SpawnAgentRequest } from "./types/agent-spawner.types";
|
||||
|
||||
describe("AgentSpawnerService", () => {
|
||||
let service: AgentSpawnerService;
|
||||
let mockConfigService: ConfigService;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create mock ConfigService
|
||||
mockConfigService = {
|
||||
get: vi.fn((key: string) => {
|
||||
if (key === "orchestrator.claude.apiKey") {
|
||||
return "test-api-key";
|
||||
}
|
||||
return undefined;
|
||||
}),
|
||||
} as any;
|
||||
|
||||
// Create service with mock
|
||||
service = new AgentSpawnerService(mockConfigService);
|
||||
});
|
||||
|
||||
describe("constructor", () => {
|
||||
it("should be defined", () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it("should initialize with Claude API key from config", () => {
|
||||
expect(mockConfigService.get).toHaveBeenCalledWith("orchestrator.claude.apiKey");
|
||||
});
|
||||
|
||||
it("should throw error if Claude API key is missing", () => {
|
||||
const badConfigService = {
|
||||
get: vi.fn(() => undefined),
|
||||
} as any;
|
||||
|
||||
expect(() => new AgentSpawnerService(badConfigService)).toThrow(
|
||||
"CLAUDE_API_KEY is not configured"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("spawnAgent", () => {
|
||||
const validRequest: SpawnAgentRequest = {
|
||||
taskId: "task-123",
|
||||
agentType: "worker",
|
||||
context: {
|
||||
repository: "https://github.com/test/repo.git",
|
||||
branch: "main",
|
||||
workItems: ["Implement feature X"],
|
||||
},
|
||||
};
|
||||
|
||||
it("should spawn an agent and return agentId", () => {
|
||||
const response = service.spawnAgent(validRequest);
|
||||
|
||||
expect(response).toBeDefined();
|
||||
expect(response.agentId).toBeDefined();
|
||||
expect(typeof response.agentId).toBe("string");
|
||||
expect(response.state).toBe("spawning");
|
||||
expect(response.spawnedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("should generate unique agentId for each spawn", () => {
|
||||
const response1 = service.spawnAgent(validRequest);
|
||||
const response2 = service.spawnAgent(validRequest);
|
||||
|
||||
expect(response1.agentId).not.toBe(response2.agentId);
|
||||
});
|
||||
|
||||
it("should track agent session", () => {
|
||||
const response = service.spawnAgent(validRequest);
|
||||
const session = service.getAgentSession(response.agentId);
|
||||
|
||||
expect(session).toBeDefined();
|
||||
expect(session?.agentId).toBe(response.agentId);
|
||||
expect(session?.taskId).toBe(validRequest.taskId);
|
||||
expect(session?.agentType).toBe(validRequest.agentType);
|
||||
expect(session?.state).toBe("spawning");
|
||||
});
|
||||
|
||||
it("should validate taskId is provided", () => {
|
||||
const invalidRequest = {
|
||||
...validRequest,
|
||||
taskId: "",
|
||||
};
|
||||
|
||||
expect(() => service.spawnAgent(invalidRequest)).toThrow("taskId is required");
|
||||
});
|
||||
|
||||
it("should validate agentType is valid", () => {
|
||||
const invalidRequest = {
|
||||
...validRequest,
|
||||
agentType: "invalid" as any,
|
||||
};
|
||||
|
||||
expect(() => service.spawnAgent(invalidRequest)).toThrow(
|
||||
"agentType must be one of: worker, reviewer, tester"
|
||||
);
|
||||
});
|
||||
|
||||
it("should validate context.repository is provided", () => {
|
||||
const invalidRequest = {
|
||||
...validRequest,
|
||||
context: {
|
||||
...validRequest.context,
|
||||
repository: "",
|
||||
},
|
||||
};
|
||||
|
||||
expect(() => service.spawnAgent(invalidRequest)).toThrow("context.repository is required");
|
||||
});
|
||||
|
||||
it("should validate context.branch is provided", () => {
|
||||
const invalidRequest = {
|
||||
...validRequest,
|
||||
context: {
|
||||
...validRequest.context,
|
||||
branch: "",
|
||||
},
|
||||
};
|
||||
|
||||
expect(() => service.spawnAgent(invalidRequest)).toThrow("context.branch is required");
|
||||
});
|
||||
|
||||
it("should validate context.workItems is not empty", () => {
|
||||
const invalidRequest = {
|
||||
...validRequest,
|
||||
context: {
|
||||
...validRequest.context,
|
||||
workItems: [],
|
||||
},
|
||||
};
|
||||
|
||||
expect(() => service.spawnAgent(invalidRequest)).toThrow(
|
||||
"context.workItems must not be empty"
|
||||
);
|
||||
});
|
||||
|
||||
it("should accept optional skills in context", () => {
|
||||
const requestWithSkills: SpawnAgentRequest = {
|
||||
...validRequest,
|
||||
context: {
|
||||
...validRequest.context,
|
||||
skills: ["typescript", "nestjs"],
|
||||
},
|
||||
};
|
||||
|
||||
const response = service.spawnAgent(requestWithSkills);
|
||||
const session = service.getAgentSession(response.agentId);
|
||||
|
||||
expect(session?.context.skills).toEqual(["typescript", "nestjs"]);
|
||||
});
|
||||
|
||||
it("should accept optional options", () => {
|
||||
const requestWithOptions: SpawnAgentRequest = {
|
||||
...validRequest,
|
||||
options: {
|
||||
sandbox: true,
|
||||
timeout: 3600000,
|
||||
maxRetries: 3,
|
||||
},
|
||||
};
|
||||
|
||||
const response = service.spawnAgent(requestWithOptions);
|
||||
const session = service.getAgentSession(response.agentId);
|
||||
|
||||
expect(session?.options).toEqual({
|
||||
sandbox: true,
|
||||
timeout: 3600000,
|
||||
maxRetries: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle spawn errors gracefully", () => {
|
||||
// Mock Claude SDK to throw error
|
||||
const errorRequest = {
|
||||
...validRequest,
|
||||
context: {
|
||||
...validRequest.context,
|
||||
repository: "invalid-repo-that-will-fail",
|
||||
},
|
||||
};
|
||||
|
||||
// For now, this should not throw but handle gracefully
|
||||
// We'll implement error handling in the service
|
||||
const response = service.spawnAgent(errorRequest);
|
||||
expect(response.agentId).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAgentSession", () => {
|
||||
it("should return undefined for non-existent agentId", () => {
|
||||
const session = service.getAgentSession("non-existent-id");
|
||||
expect(session).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return session for existing agentId", () => {
|
||||
const request: SpawnAgentRequest = {
|
||||
taskId: "task-123",
|
||||
agentType: "worker",
|
||||
context: {
|
||||
repository: "https://github.com/test/repo.git",
|
||||
branch: "main",
|
||||
workItems: ["Implement feature X"],
|
||||
},
|
||||
};
|
||||
|
||||
const response = service.spawnAgent(request);
|
||||
const session = service.getAgentSession(response.agentId);
|
||||
|
||||
expect(session).toBeDefined();
|
||||
expect(session?.agentId).toBe(response.agentId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listAgentSessions", () => {
|
||||
it("should return empty array when no agents spawned", () => {
|
||||
const sessions = service.listAgentSessions();
|
||||
expect(sessions).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return all spawned agent sessions", () => {
|
||||
const request1: SpawnAgentRequest = {
|
||||
taskId: "task-1",
|
||||
agentType: "worker",
|
||||
context: {
|
||||
repository: "https://github.com/test/repo1.git",
|
||||
branch: "main",
|
||||
workItems: ["Task 1"],
|
||||
},
|
||||
};
|
||||
|
||||
const request2: SpawnAgentRequest = {
|
||||
taskId: "task-2",
|
||||
agentType: "reviewer",
|
||||
context: {
|
||||
repository: "https://github.com/test/repo2.git",
|
||||
branch: "develop",
|
||||
workItems: ["Task 2"],
|
||||
},
|
||||
};
|
||||
|
||||
service.spawnAgent(request1);
|
||||
service.spawnAgent(request2);
|
||||
|
||||
const sessions = service.listAgentSessions();
|
||||
expect(sessions).toHaveLength(2);
|
||||
expect(sessions[0].agentType).toBe("worker");
|
||||
expect(sessions[1].agentType).toBe("reviewer");
|
||||
});
|
||||
});
|
||||
});
|
||||
120
apps/orchestrator/src/spawner/agent-spawner.service.ts
Normal file
120
apps/orchestrator/src/spawner/agent-spawner.service.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import Anthropic from "@anthropic-ai/sdk";
|
||||
import { randomUUID } from "crypto";
|
||||
import {
|
||||
SpawnAgentRequest,
|
||||
SpawnAgentResponse,
|
||||
AgentSession,
|
||||
AgentType,
|
||||
} from "./types/agent-spawner.types";
|
||||
|
||||
/**
|
||||
* Service responsible for spawning Claude agents using Anthropic SDK
|
||||
*/
|
||||
@Injectable()
|
||||
export class AgentSpawnerService {
|
||||
private readonly logger = new Logger(AgentSpawnerService.name);
|
||||
private readonly anthropic: Anthropic;
|
||||
private readonly sessions = new Map<string, AgentSession>();
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
const apiKey = this.configService.get<string>("orchestrator.claude.apiKey");
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error("CLAUDE_API_KEY is not configured");
|
||||
}
|
||||
|
||||
this.anthropic = new Anthropic({
|
||||
apiKey,
|
||||
});
|
||||
|
||||
this.logger.log("AgentSpawnerService initialized with Claude SDK");
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn a new agent with the given configuration
|
||||
* @param request Agent spawn request
|
||||
* @returns Agent spawn response with agentId
|
||||
*/
|
||||
spawnAgent(request: SpawnAgentRequest): SpawnAgentResponse {
|
||||
this.logger.log(`Spawning agent for task: ${request.taskId}`);
|
||||
|
||||
// Validate request
|
||||
this.validateSpawnRequest(request);
|
||||
|
||||
// Generate unique agent ID
|
||||
const agentId = randomUUID();
|
||||
const spawnedAt = new Date();
|
||||
|
||||
// Create agent session
|
||||
const session: AgentSession = {
|
||||
agentId,
|
||||
taskId: request.taskId,
|
||||
agentType: request.agentType,
|
||||
state: "spawning",
|
||||
context: request.context,
|
||||
options: request.options,
|
||||
spawnedAt,
|
||||
};
|
||||
|
||||
// Store session
|
||||
this.sessions.set(agentId, session);
|
||||
|
||||
this.logger.log(`Agent spawned successfully: ${agentId} (type: ${request.agentType})`);
|
||||
|
||||
// TODO: Actual Claude SDK integration will be implemented in next iteration
|
||||
// For now, we're just creating the session and tracking it
|
||||
|
||||
return {
|
||||
agentId,
|
||||
state: "spawning",
|
||||
spawnedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get agent session by agentId
|
||||
* @param agentId Unique agent identifier
|
||||
* @returns Agent session or undefined if not found
|
||||
*/
|
||||
getAgentSession(agentId: string): AgentSession | undefined {
|
||||
return this.sessions.get(agentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all agent sessions
|
||||
* @returns Array of all agent sessions
|
||||
*/
|
||||
listAgentSessions(): AgentSession[] {
|
||||
return Array.from(this.sessions.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate spawn agent request
|
||||
* @param request Spawn request to validate
|
||||
* @throws Error if validation fails
|
||||
*/
|
||||
private validateSpawnRequest(request: SpawnAgentRequest): void {
|
||||
if (!request.taskId || request.taskId.trim() === "") {
|
||||
throw new Error("taskId is required");
|
||||
}
|
||||
|
||||
const validAgentTypes: AgentType[] = ["worker", "reviewer", "tester"];
|
||||
if (!validAgentTypes.includes(request.agentType)) {
|
||||
throw new Error(`agentType must be one of: ${validAgentTypes.join(", ")}`);
|
||||
}
|
||||
|
||||
if (!request.context.repository || request.context.repository.trim() === "") {
|
||||
throw new Error("context.repository is required");
|
||||
}
|
||||
|
||||
if (!request.context.branch || request.context.branch.trim() === "") {
|
||||
throw new Error("context.branch is required");
|
||||
}
|
||||
|
||||
if (request.context.workItems.length === 0) {
|
||||
throw new Error("context.workItems must not be empty");
|
||||
}
|
||||
}
|
||||
}
|
||||
6
apps/orchestrator/src/spawner/index.ts
Normal file
6
apps/orchestrator/src/spawner/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Spawner module exports
|
||||
*/
|
||||
export { AgentSpawnerService } from "./agent-spawner.service";
|
||||
export { SpawnerModule } from "./spawner.module";
|
||||
export * from "./types/agent-spawner.types";
|
||||
@@ -1,4 +1,8 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AgentSpawnerService } from "./agent-spawner.service";
|
||||
|
||||
@Module({})
|
||||
@Module({
|
||||
providers: [AgentSpawnerService],
|
||||
exports: [AgentSpawnerService],
|
||||
})
|
||||
export class SpawnerModule {}
|
||||
|
||||
85
apps/orchestrator/src/spawner/types/agent-spawner.types.ts
Normal file
85
apps/orchestrator/src/spawner/types/agent-spawner.types.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Agent type definitions for spawning
|
||||
*/
|
||||
export type AgentType = "worker" | "reviewer" | "tester";
|
||||
|
||||
/**
|
||||
* Agent lifecycle states
|
||||
*/
|
||||
export type AgentState = "spawning" | "running" | "completed" | "failed" | "killed";
|
||||
|
||||
/**
|
||||
* Context provided to the agent for task execution
|
||||
*/
|
||||
export interface AgentContext {
|
||||
/** 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[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for spawning an agent
|
||||
*/
|
||||
export interface SpawnAgentOptions {
|
||||
/** Enable Docker sandbox isolation */
|
||||
sandbox?: boolean;
|
||||
/** Timeout in milliseconds */
|
||||
timeout?: number;
|
||||
/** Maximum retry attempts */
|
||||
maxRetries?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request payload for spawning an agent
|
||||
*/
|
||||
export interface SpawnAgentRequest {
|
||||
/** Unique task identifier */
|
||||
taskId: string;
|
||||
/** Type of agent to spawn */
|
||||
agentType: AgentType;
|
||||
/** Context for task execution */
|
||||
context: AgentContext;
|
||||
/** Optional configuration */
|
||||
options?: SpawnAgentOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from spawning an agent
|
||||
*/
|
||||
export interface SpawnAgentResponse {
|
||||
/** Unique agent identifier */
|
||||
agentId: string;
|
||||
/** Current agent state */
|
||||
state: AgentState;
|
||||
/** Timestamp when agent was spawned */
|
||||
spawnedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent session metadata
|
||||
*/
|
||||
export interface AgentSession {
|
||||
/** Unique agent identifier */
|
||||
agentId: string;
|
||||
/** Task identifier */
|
||||
taskId: string;
|
||||
/** Agent type */
|
||||
agentType: AgentType;
|
||||
/** Current state */
|
||||
state: AgentState;
|
||||
/** Context */
|
||||
context: AgentContext;
|
||||
/** Options */
|
||||
options?: SpawnAgentOptions;
|
||||
/** Spawn timestamp */
|
||||
spawnedAt: Date;
|
||||
/** Completion timestamp */
|
||||
completedAt?: Date;
|
||||
/** Error if failed */
|
||||
error?: string;
|
||||
}
|
||||
Reference in New Issue
Block a user