rdev/internal/handlers/agents.go
jordan 8282d60c69 feat: implement composable monorepo template system with component architecture
Adds the composable monorepo template system that generates project skeletons
with pluggable components (service, worker, app-react, app-astro, cli).

Key changes:
- Monorepo skeleton templates with shared pkg/, scripts/, and git hooks
- Component templates (service, worker, app-react, app-astro, cli) with
  Dockerfiles, CI steps, and component.yaml manifests
- Component domain model with validation and dependency resolution
- Component handler endpoints for CRUD and composition
- Template provider extended with BuildComposableProject and component assembly
- Deployer extended with composable project deployment support
- Handler timeout constants (TimeoutFastLookup through TimeoutLongRunning)
- envutil package for centralized env var reads with defaults
- api.DecodeJSON helper for standardized request body decoding
- Standardized response helpers (WriteBadRequest, WriteNotFound, etc.)
- Replaced fullstack-app cookbook with composable-app cookbook
- Hardened handler timeouts, logging, and error responses across all handlers

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 19:11:42 -07:00

269 lines
7.3 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/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) {
r.Get("/", h.List)
r.Get("/health", h.Health)
r.Get("/{provider}", h.GetCapabilities)
r.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",
})
}