rdev/internal/handlers/agents.go
jordan 56e3f83955 feat: add auth scopes, OpenAPI docs, SDLC guides, and code quality improvements
- Add auth.RequireScope() to all handler routes for proper authorization
- Add SDLC OpenAPI endpoint documentation (state, features, tasks, branches, merge, archive, orchestrator)
- Add SDLC documentation guides (getting-started, cli-reference, api-reference, command-catalog)
- Add artifact_test.go for SDLC artifact coverage
- Add CLAUDE.md rules: auth scopes requirement, error wrapping with %w
- Fix error wrapping to use %w instead of %v throughout codebase
- Improve CLI merge command with conflict detection and resolution
- Fix handler tests to include auth middleware for RequireScope
- Add cookbook tree runner scripts for automated testing

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 13:55:50 -07:00

273 lines
7.6 KiB
Go

// Package handlers provides HTTP handlers for the rdev API.
package handlers
import (
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/orchard9/rdev/internal/auth"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/port"
"github.com/orchard9/rdev/pkg/api"
)
// AgentsHandler handles code agent management endpoints.
type AgentsHandler struct {
registry port.CodeAgentRegistry
}
// NewAgentsHandler creates a new agents handler.
func NewAgentsHandler(registry port.CodeAgentRegistry) *AgentsHandler {
return &AgentsHandler{
registry: registry,
}
}
// Mount registers the agent routes.
func (h *AgentsHandler) Mount(r api.Router) {
r.Route("/agents", func(r chi.Router) {
// Read operations
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).Get("/", h.List)
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).Get("/health", h.Health)
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).Get("/{provider}", h.GetCapabilities)
// Write operations
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Post("/default", h.SetDefault)
})
}
// AgentDTO is the data transfer object for code agents.
type AgentDTO struct {
Provider string `json:"provider"`
Name string `json:"name"`
Available bool `json:"available"`
Default bool `json:"default"`
Models []string `json:"supported_models,omitempty"`
DefaultModel string `json:"default_model,omitempty"`
}
// AgentCapabilitiesDTO is the DTO for agent capabilities.
type AgentCapabilitiesDTO struct {
Provider string `json:"provider"`
SupportsSessionContinuation bool `json:"supports_session_continuation"`
SupportsModelSelection bool `json:"supports_model_selection"`
SupportsToolControl bool `json:"supports_tool_control"`
SupportsStreaming bool `json:"supports_streaming"`
SupportedModels []string `json:"supported_models"`
DefaultModel string `json:"default_model"`
MaxPromptLength int `json:"max_prompt_length,omitempty"`
}
// ListAgentsResponse is the response for GET /agents.
type ListAgentsResponse struct {
Agents []AgentDTO `json:"agents"`
DefaultAgent string `json:"default_agent"`
TotalAgents int `json:"total_agents"`
AvailableCount int `json:"available_count"`
}
// List returns all registered code agents and their status.
// GET /agents
func (h *AgentsHandler) List(w http.ResponseWriter, r *http.Request) {
if h.registry == nil {
api.WriteSuccess(w, r, ListAgentsResponse{
Agents: []AgentDTO{},
DefaultAgent: "",
TotalAgents: 0,
AvailableCount: 0,
})
return
}
providers := h.registry.Available()
defaultProvider := h.registry.DefaultProvider()
// Check availability for each agent
availableAgents := h.registry.AvailableAgents(r.Context())
availableSet := make(map[domain.AgentProvider]bool)
for _, agent := range availableAgents {
availableSet[agent.Provider()] = true
}
agents := make([]AgentDTO, 0, len(providers))
availableCount := 0
for _, provider := range providers {
agent := h.registry.Get(provider)
if agent == nil {
continue
}
caps := agent.Capabilities()
isAvailable := availableSet[provider]
isDefault := provider == defaultProvider
if isAvailable {
availableCount++
}
agents = append(agents, AgentDTO{
Provider: string(provider),
Name: agent.Name(),
Available: isAvailable,
Default: isDefault,
Models: caps.SupportedModels,
DefaultModel: caps.DefaultModel,
})
}
api.WriteSuccess(w, r, ListAgentsResponse{
Agents: agents,
DefaultAgent: string(defaultProvider),
TotalAgents: len(agents),
AvailableCount: availableCount,
})
}
// GetCapabilities returns the capabilities of a specific agent.
// GET /agents/{provider}
func (h *AgentsHandler) GetCapabilities(w http.ResponseWriter, r *http.Request) {
providerStr := chi.URLParam(r, "provider")
provider := domain.AgentProvider(providerStr)
if h.registry == nil {
api.WriteNotFound(w, r, "no agents registered")
return
}
agent := h.registry.Get(provider)
if agent == nil {
api.WriteNotFound(w, r, "agent not found: "+providerStr)
return
}
caps := agent.Capabilities()
api.WriteSuccess(w, r, AgentCapabilitiesDTO{
Provider: string(caps.Provider),
SupportsSessionContinuation: caps.SupportsSessionContinuation,
SupportsModelSelection: caps.SupportsModelSelection,
SupportsToolControl: caps.SupportsToolControl,
SupportsStreaming: caps.SupportsStreaming,
SupportedModels: caps.SupportedModels,
DefaultModel: caps.DefaultModel,
MaxPromptLength: caps.MaxPromptLength,
})
}
// AgentHealthDTO represents the health status of a single agent.
type AgentHealthDTO struct {
Provider string `json:"provider"`
Name string `json:"name"`
Healthy bool `json:"healthy"`
Message string `json:"message"`
Latency string `json:"latency"`
CheckedAt string `json:"checked_at"`
}
// AgentHealthResponse is the response for GET /agents/health.
type AgentHealthResponse struct {
Agents []AgentHealthDTO `json:"agents"`
HealthyCount int `json:"healthy_count"`
TotalCount int `json:"total_count"`
DefaultAgent string `json:"default_agent"`
DefaultHealth bool `json:"default_healthy"`
}
// Health returns the health status of all registered code agents.
// GET /agents/health
func (h *AgentsHandler) Health(w http.ResponseWriter, r *http.Request) {
if h.registry == nil {
api.WriteSuccess(w, r, AgentHealthResponse{
Agents: []AgentHealthDTO{},
HealthyCount: 0,
TotalCount: 0,
})
return
}
providers := h.registry.Available()
defaultProvider := h.registry.DefaultProvider()
agents := make([]AgentHealthDTO, 0, len(providers))
healthyCount := 0
defaultHealthy := false
for _, provider := range providers {
agent := h.registry.Get(provider)
if agent == nil {
continue
}
start := time.Now()
healthy := agent.Available(r.Context())
latency := time.Since(start)
msg := "available"
if !healthy {
msg = "unavailable"
}
if healthy {
healthyCount++
}
if provider == defaultProvider {
defaultHealthy = healthy
}
agents = append(agents, AgentHealthDTO{
Provider: string(provider),
Name: agent.Name(),
Healthy: healthy,
Message: msg,
Latency: latency.String(),
CheckedAt: time.Now().UTC().Format(time.RFC3339),
})
}
api.WriteSuccess(w, r, AgentHealthResponse{
Agents: agents,
HealthyCount: healthyCount,
TotalCount: len(agents),
DefaultAgent: string(defaultProvider),
DefaultHealth: defaultHealthy,
})
}
// SetDefaultRequest is the request body for POST /agents/default.
type SetDefaultRequest struct {
Provider string `json:"provider"`
}
// SetDefault changes the default code agent.
// POST /agents/default
func (h *AgentsHandler) SetDefault(w http.ResponseWriter, r *http.Request) {
if h.registry == nil {
api.WriteBadRequest(w, r, "no agents registered")
return
}
var req SetDefaultRequest
if err := api.DecodeJSON(r, &req); err != nil {
api.WriteBadRequest(w, r, "invalid request body")
return
}
if req.Provider == "" {
api.WriteBadRequest(w, r, "provider is required")
return
}
provider := domain.AgentProvider(req.Provider)
if err := h.registry.SetDefault(provider); err != nil {
api.WriteBadRequest(w, r, err.Error())
return
}
api.WriteSuccess(w, r, map[string]any{
"default_agent": req.Provider,
"message": "default agent updated",
})
}