Files
stack/apps/orchestrator/src/api/agents/agents.controller.ts
Jason Woltje c9ad3a661a
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
fix(CQ-ORCH-9): Deduplicate spawn validation logic
Remove duplicate validateSpawnRequest from AgentsController. Validation
is now handled exclusively by:
1. ValidationPipe + DTO decorators (HTTP layer, class-validator)
2. AgentSpawnerService.validateSpawnRequest (business logic layer)

This eliminates the maintenance burden and divergence risk of having
identical validation in two places. Controller tests for the removed
duplicate validation are also removed since they are fully covered by
the service tests and DTO validation decorators.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 14:09:06 -06:00

247 lines
7.4 KiB
TypeScript

import {
Controller,
Post,
Get,
Body,
Param,
NotFoundException,
Logger,
UsePipes,
ValidationPipe,
HttpCode,
UseGuards,
ParseUUIDPipe,
} from "@nestjs/common";
import { Throttle } from "@nestjs/throttler";
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";
import { OrchestratorApiKeyGuard } from "../../common/guards/api-key.guard";
import { OrchestratorThrottlerGuard } from "../../common/guards/throttler.guard";
/**
* Controller for agent management endpoints
*
* All endpoints require API key authentication via X-API-Key header.
* Set ORCHESTRATOR_API_KEY environment variable to configure the expected key.
*
* Rate limits:
* - Status endpoints: 200 requests/minute
* - Spawn/kill endpoints: 10 requests/minute (strict)
* - Default: 100 requests/minute
*/
@Controller("agents")
@UseGuards(OrchestratorApiKeyGuard, OrchestratorThrottlerGuard)
export class AgentsController {
private readonly logger = new Logger(AgentsController.name);
constructor(
private readonly queueService: QueueService,
private readonly spawnerService: AgentSpawnerService,
private readonly lifecycleService: AgentLifecycleService,
private readonly killswitchService: KillswitchService
) {}
/**
* Spawn a new agent for the given task
* @param dto Spawn agent request
* @returns Agent spawn response with agentId and status
*/
@Post("spawn")
@Throttle({ strict: { limit: 10, ttl: 60000 } })
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
async spawn(@Body() dto: SpawnAgentDto): Promise<SpawnAgentResponseDto> {
this.logger.log(`Received spawn request for task: ${dto.taskId}`);
try {
// Validation is handled by:
// 1. ValidationPipe + DTO decorators at the HTTP layer
// 2. AgentSpawnerService.validateSpawnRequest for business logic
// Spawn agent using spawner service
const spawnResponse = this.spawnerService.spawnAgent({
taskId: dto.taskId,
agentType: dto.agentType,
context: dto.context,
});
// Queue task in Valkey
await this.queueService.addTask(dto.taskId, dto.context, {
priority: 5, // Default priority
});
this.logger.log(`Agent spawned successfully: ${spawnResponse.agentId}`);
// Return response
return {
agentId: spawnResponse.agentId,
status: "spawning",
};
} catch (error) {
this.logger.error(`Failed to spawn agent: ${String(error)}`);
throw error;
}
}
/**
* List all agents
* @returns Array of all agent sessions with their status
*/
@Get()
@Throttle({ status: { limit: 200, ttl: 60000 } })
listAgents(): {
agentId: string;
taskId: string;
status: string;
agentType: string;
spawnedAt: string;
completedAt?: string;
error?: string;
}[] {
this.logger.log("Received request to list all agents");
try {
// Get all sessions from spawner service
const sessions = this.spawnerService.listAgentSessions();
// Map to response format
const agents = sessions.map((session) => ({
agentId: session.agentId,
taskId: session.taskId,
status: session.state,
agentType: session.agentType,
spawnedAt: session.spawnedAt.toISOString(),
completedAt: session.completedAt?.toISOString(),
error: session.error,
}));
this.logger.log(`Found ${agents.length.toString()} agents`);
return agents;
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.logger.error(`Failed to list agents: ${errorMessage}`);
throw new Error(`Failed to list agents: ${errorMessage}`);
}
}
/**
* Get agent status
* @param agentId Agent ID to query
* @returns Agent status details
*/
@Get(":agentId/status")
@Throttle({ status: { limit: 200, ttl: 60000 } })
async getAgentStatus(@Param("agentId", ParseUUIDPipe) 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
* @returns Success message
*/
@Post(":agentId/kill")
@Throttle({ strict: { limit: 10, ttl: 60000 } })
@HttpCode(200)
async killAgent(@Param("agentId", ParseUUIDPipe) agentId: string): Promise<{ message: string }> {
this.logger.warn(`Received kill request for agent: ${agentId}`);
try {
await this.killswitchService.killAgent(agentId);
this.logger.warn(`Agent ${agentId} killed successfully`);
return {
message: `Agent ${agentId} killed successfully`,
};
} catch (error) {
this.logger.error(`Failed to kill agent ${agentId}: ${String(error)}`);
throw error;
}
}
/**
* Kill all active agents
* @returns Summary of kill operation
*/
@Post("kill-all")
@Throttle({ strict: { limit: 10, ttl: 60000 } })
@HttpCode(200)
async killAllAgents(): Promise<{
message: string;
total: number;
killed: number;
failed: number;
errors?: string[];
}> {
this.logger.warn("Received kill-all request");
try {
const result = await this.killswitchService.killAllAgents();
this.logger.warn(
`Kill all completed: ${result.killed.toString()} killed, ${result.failed.toString()} failed out of ${result.total.toString()}`
);
return {
message: `Kill all completed: ${result.killed.toString()} killed, ${result.failed.toString()} failed`,
...result,
};
} catch (error) {
this.logger.error(`Failed to kill all agents: ${String(error)}`);
throw error;
}
}
}