917 lines
32 KiB
Go
917 lines
32 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"github.com/kandev/kandev/internal/agent/agents"
|
|
"github.com/kandev/kandev/internal/agent/mcpconfig"
|
|
"github.com/kandev/kandev/internal/agent/settings/controller"
|
|
"github.com/kandev/kandev/internal/agent/settings/dto"
|
|
"github.com/kandev/kandev/internal/authz"
|
|
"github.com/kandev/kandev/internal/common/httpmw"
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
ws "github.com/kandev/kandev/pkg/websocket"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
const queryTrue = "true"
|
|
|
|
var availableAgentsBroadcastTimeout = 10 * time.Second
|
|
|
|
type Handlers struct {
|
|
controller *controller.Controller
|
|
hub Broadcaster
|
|
logger *logger.Logger
|
|
interlock gin.HandlerFunc
|
|
}
|
|
|
|
type Broadcaster interface {
|
|
Broadcast(msg *ws.Message)
|
|
}
|
|
|
|
func NewHandlers(ctrl *controller.Controller, hub Broadcaster, log *logger.Logger, interlockToken string) *Handlers {
|
|
return &Handlers{
|
|
controller: ctrl,
|
|
hub: hub,
|
|
logger: log.WithFields(zap.String("component", "agent-settings-handlers")),
|
|
interlock: httpmw.InterimSettingsInterlock(interlockToken),
|
|
}
|
|
}
|
|
|
|
func RegisterRoutes(router *gin.Engine, ctrl *controller.Controller, hub Broadcaster, log *logger.Logger, interlockToken string) {
|
|
// Wire the install job store with the same broadcaster used by other
|
|
// agent-settings notifications so streaming install events reach the UI.
|
|
ctrl.SetJobBroadcaster(hub)
|
|
handlers := NewHandlers(ctrl, hub, log, interlockToken)
|
|
handlers.registerHTTP(router)
|
|
}
|
|
|
|
func (h *Handlers) registerHTTP(router *gin.Engine) {
|
|
api := router.Group("/api/v1")
|
|
// Agents, agent profiles and the runtimes behind them are org configuration:
|
|
// org.config.manage is the scope that names exactly this surface. Reads stay
|
|
// open to any identity; every mutation is gated. h.interlock is a
|
|
// concurrent-edit guard, not an authorization check, so it does not stand in
|
|
// for this.
|
|
cfg := authz.RequireOrgScope(authz.ScopeOrgConfigManage)
|
|
api.GET("/agents/discovery", h.httpDiscoverAgents)
|
|
api.GET("/agents/available", h.httpListAvailableAgents)
|
|
api.GET("/agents", h.httpListAgents)
|
|
api.POST("/agents", cfg, h.interlock, h.httpCreateAgent)
|
|
api.POST("/agents/tui", cfg, h.interlock, h.httpCreateCustomTUIAgent)
|
|
api.GET("/agents/tui/mcp-strategies", h.httpListMCPStrategies)
|
|
api.PATCH("/agents/tui/:id/mcp", cfg, h.interlock, h.httpUpdateCustomTUIAgentMCP)
|
|
api.GET("/agents/:id", h.httpGetAgent)
|
|
api.PATCH("/agents/:id", cfg, h.interlock, h.httpUpdateAgent)
|
|
api.DELETE("/agents/:id", cfg, h.interlock, h.httpDeleteAgent)
|
|
api.POST("/agents/:id/profiles", cfg, h.interlock, h.httpCreateProfile)
|
|
api.GET("/agents/:id/logo", h.httpGetAgentLogo)
|
|
api.GET("/agent-models/:agentName", h.httpGetAgentModels)
|
|
api.POST("/agent-models/:agentName/resolve", h.httpResolveAgentModelConfig)
|
|
api.POST("/agent-command-preview/:agentName", h.httpPreviewAgentCommand)
|
|
api.POST("/agent-install/:agentName", cfg, h.interlock, h.httpInstallAgent)
|
|
api.GET("/agent-update/status", h.httpListAgentUpdateStatuses)
|
|
api.GET("/agent-update/:agentName/preview", h.httpPreviewAgentUpdate)
|
|
api.GET("/agent-install/jobs", h.httpListInstallJobs)
|
|
api.GET("/agent-install/jobs/:id", h.httpGetInstallJob)
|
|
api.POST("/agent-update/:agentName", cfg, h.interlock, h.httpUpdateAgentRuntime)
|
|
api.GET("/agent-update/jobs", h.httpListAgentUpdateJobs)
|
|
api.GET("/agent-update/jobs/:id", h.httpGetAgentUpdateJob)
|
|
api.PATCH("/agent-profiles/:id", cfg, h.interlock, h.httpUpdateProfile)
|
|
api.DELETE("/agent-profiles/:id", cfg, h.interlock, h.httpDeleteProfile)
|
|
api.POST("/agent-profiles/:id/duplicate", cfg, h.interlock, h.httpDuplicateProfile)
|
|
api.GET("/agent-profiles/:id/mcp-config", h.httpGetProfileMcpConfig)
|
|
api.POST("/agent-profiles/:id/mcp-config", cfg, h.interlock, h.httpUpdateProfileMcpConfig)
|
|
}
|
|
|
|
func (h *Handlers) httpDiscoverAgents(c *gin.Context) {
|
|
h.controller.InvalidateDiscoveryCache()
|
|
resp, err := h.controller.ListDiscovery(c.Request.Context())
|
|
if err != nil {
|
|
h.logger.Error("failed to discover agents", zap.Error(err))
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to discover agents"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, resp)
|
|
h.broadcastAvailableAgentsAsync()
|
|
}
|
|
|
|
func (h *Handlers) httpListAvailableAgents(c *gin.Context) {
|
|
resp, err := h.controller.ListAvailableAgents(c.Request.Context())
|
|
if err != nil {
|
|
h.logger.Error("failed to list available agents", zap.Error(err))
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list available agents"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, resp)
|
|
}
|
|
|
|
// httpInstallAgent enqueues an async install and returns the job snapshot.
|
|
// The install runs in a goroutine; clients track progress via WS
|
|
// (agent.install.started/output/finished) or by polling /agent-install/jobs/:id.
|
|
//
|
|
// Idempotent: a second POST for the same agent while a job is running returns
|
|
// the existing job_id rather than starting a duplicate.
|
|
func (h *Handlers) httpInstallAgent(c *gin.Context) {
|
|
name, ok := requireAgentName(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
h.enqueueMaintenance(c, name, "install", func() (any, error) {
|
|
return h.controller.EnqueueInstall(name)
|
|
}, classifyInstallError)
|
|
}
|
|
|
|
func (h *Handlers) httpUpdateAgentRuntime(c *gin.Context) {
|
|
name, ok := requireAgentName(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
var request dto.AgentUpdateRequest
|
|
if err := c.ShouldBindJSON(&request); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "target version is required"})
|
|
return
|
|
}
|
|
request.TargetVersion = strings.TrimSpace(request.TargetVersion)
|
|
if request.UseDefault && request.TargetVersion != "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "target version and use_default cannot be combined"})
|
|
return
|
|
}
|
|
if !request.UseDefault && request.TargetVersion == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "target version is required"})
|
|
return
|
|
}
|
|
h.enqueueMaintenance(c, name, "update", func() (any, error) {
|
|
if request.UseDefault {
|
|
return h.controller.EnqueueAgentUpdateUseDefault(c.Request.Context(), name)
|
|
}
|
|
return h.controller.EnqueueAgentUpdate(c.Request.Context(), name, request.TargetVersion)
|
|
}, classifyUpdateError)
|
|
}
|
|
|
|
func (h *Handlers) httpPreviewAgentUpdate(c *gin.Context) {
|
|
name, ok := requireAgentName(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
targetVersion := strings.TrimSpace(c.Query("target_version"))
|
|
useDefault := false
|
|
if raw := strings.TrimSpace(c.Query("use_default")); raw != "" {
|
|
parsed, err := strconv.ParseBool(raw)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "use_default must be a boolean"})
|
|
return
|
|
}
|
|
useDefault = parsed
|
|
}
|
|
if useDefault && targetVersion != "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "target version and use_default cannot be combined"})
|
|
return
|
|
}
|
|
var preview *dto.AgentUpdatePreviewDTO
|
|
var err error
|
|
if useDefault {
|
|
preview, err = h.controller.PreviewAgentUpdateUseDefault(c.Request.Context(), name)
|
|
} else {
|
|
preview, err = h.controller.PreviewAgentUpdate(c.Request.Context(), name, targetVersion)
|
|
}
|
|
if err == nil {
|
|
c.JSON(http.StatusOK, preview)
|
|
return
|
|
}
|
|
if status, message, matched := classifyUpdatePreviewError(err); matched {
|
|
c.JSON(status, gin.H{"error": message})
|
|
return
|
|
}
|
|
h.logger.Error("failed to preview agent runtime update", zap.String("agent", name), zap.Error(err))
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to preview agent update"})
|
|
}
|
|
|
|
func (h *Handlers) httpListAgentUpdateStatuses(c *gin.Context) {
|
|
resp, err := h.controller.ListAgentUpdateStatuses(c.Request.Context())
|
|
if err != nil {
|
|
h.logger.Error("failed to list agent runtime update statuses", zap.Error(err))
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list agent update statuses"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, resp)
|
|
}
|
|
|
|
func (h *Handlers) broadcastProfileMCPConfigUpdated(profileID, workspaceID string) {
|
|
if h.hub == nil || profileID == "" {
|
|
return
|
|
}
|
|
var scopedWorkspaceID any
|
|
if workspaceID != "" {
|
|
scopedWorkspaceID = workspaceID
|
|
}
|
|
notification, err := ws.NewNotification(ws.ActionAgentProfileMCPConfigUpdated, gin.H{
|
|
"profile_id": profileID,
|
|
"workspace_id": scopedWorkspaceID,
|
|
})
|
|
if err != nil {
|
|
return
|
|
}
|
|
if workspaceID != "" {
|
|
workspaceHub, ok := h.hub.(interface {
|
|
BroadcastToWorkspaceOrDrop(string, *ws.Message)
|
|
})
|
|
if ok {
|
|
workspaceHub.BroadcastToWorkspaceOrDrop(workspaceID, notification)
|
|
}
|
|
return
|
|
}
|
|
//ws:global profile MCP updates without workspace ownership are global.
|
|
h.hub.Broadcast(notification)
|
|
}
|
|
|
|
func requireAgentName(c *gin.Context) (string, bool) {
|
|
name := strings.TrimSpace(c.Param("agentName"))
|
|
if name == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "agent name is required"})
|
|
return "", false
|
|
}
|
|
return name, true
|
|
}
|
|
|
|
func writeMaintenanceConflictIfPresent(c *gin.Context, err error) bool {
|
|
var conflict *controller.MaintenanceConflictError
|
|
if !errors.As(err, &conflict) {
|
|
return false
|
|
}
|
|
writeMaintenanceConflict(c, conflict)
|
|
return true
|
|
}
|
|
|
|
type maintenanceErrorClassifier func(error) (int, string, bool)
|
|
|
|
func (h *Handlers) enqueueMaintenance(
|
|
c *gin.Context,
|
|
name string,
|
|
kind string,
|
|
enqueue func() (any, error),
|
|
classify maintenanceErrorClassifier,
|
|
) {
|
|
job, err := enqueue()
|
|
if err == nil {
|
|
c.JSON(http.StatusAccepted, job)
|
|
return
|
|
}
|
|
if writeMaintenanceConflictIfPresent(c, err) {
|
|
return
|
|
}
|
|
if status, message, matched := classify(err); matched {
|
|
c.JSON(status, gin.H{"error": message})
|
|
return
|
|
}
|
|
h.logger.Error("failed to enqueue agent maintenance",
|
|
zap.String("agent", name), zap.String("kind", kind), zap.Error(err))
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to enqueue agent " + kind})
|
|
}
|
|
|
|
func classifyInstallError(err error) (int, string, bool) {
|
|
switch {
|
|
case errors.Is(err, controller.ErrAgentNotFound):
|
|
return http.StatusNotFound, "agent not found", true
|
|
case errors.Is(err, controller.ErrInstallScriptEmpty):
|
|
return http.StatusBadRequest, "agent has no install script", true
|
|
case errors.Is(err, controller.ErrJobStoreUnavailable):
|
|
return http.StatusServiceUnavailable, "install service not ready", true
|
|
default:
|
|
return 0, "", false
|
|
}
|
|
}
|
|
|
|
func classifyUpdateError(err error) (int, string, bool) {
|
|
switch {
|
|
case errors.Is(err, controller.ErrAgentNotFound):
|
|
return http.StatusNotFound, "agent not found", true
|
|
case errors.Is(err, controller.ErrRuntimeUpdateUnsupported):
|
|
return http.StatusBadRequest, "agent runtime update unsupported", true
|
|
case errors.Is(err, controller.ErrRuntimeUpdaterUnavailable):
|
|
return http.StatusServiceUnavailable, "agent update service not ready", true
|
|
case errors.Is(err, controller.ErrRuntimeUpdateTargetRequired):
|
|
return http.StatusBadRequest, "target version is required", true
|
|
case errors.Is(err, controller.ErrRuntimeUpdateTargetInvalid):
|
|
return http.StatusBadRequest, "target version is invalid", true
|
|
case errors.Is(err, controller.ErrRuntimeUpdateTargetMissing):
|
|
return http.StatusBadRequest, "target version is not published", true
|
|
default:
|
|
return 0, "", false
|
|
}
|
|
}
|
|
|
|
func classifyUpdatePreviewError(err error) (int, string, bool) {
|
|
if status, message, matched := classifyUpdateError(err); matched {
|
|
return status, message, true
|
|
}
|
|
if errors.Is(err, controller.ErrRuntimeUpdatePreviewFailed) {
|
|
return http.StatusBadGateway, "unable to resolve runtime versions", true
|
|
}
|
|
if errors.Is(err, controller.ErrRuntimeUpdateTargetInvalid) {
|
|
return http.StatusBadRequest, "target version is invalid", true
|
|
}
|
|
if errors.Is(err, controller.ErrRuntimeUpdateTargetMissing) {
|
|
return http.StatusBadRequest, "target version is not published", true
|
|
}
|
|
return 0, "", false
|
|
}
|
|
|
|
func (h *Handlers) httpListAgentUpdateJobs(c *gin.Context) {
|
|
c.JSON(http.StatusOK, dto.ListAgentUpdateJobsResponse{Jobs: h.controller.ListAgentUpdateJobs()})
|
|
}
|
|
|
|
func (h *Handlers) httpGetAgentUpdateJob(c *gin.Context) {
|
|
id := strings.TrimSpace(c.Param("id"))
|
|
if id == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "job id is required"})
|
|
return
|
|
}
|
|
job, ok := h.controller.GetAgentUpdateJob(id)
|
|
if !ok {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "job not found"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, job)
|
|
}
|
|
|
|
func writeMaintenanceConflict(c *gin.Context, conflict *controller.MaintenanceConflictError) {
|
|
c.JSON(http.StatusConflict, gin.H{
|
|
"error": conflict.Error(),
|
|
"active_job_id": conflict.Active.JobID,
|
|
"active_kind": conflict.Active.Kind,
|
|
})
|
|
}
|
|
|
|
func (h *Handlers) httpListInstallJobs(c *gin.Context) {
|
|
c.JSON(http.StatusOK, dto.ListInstallJobsResponse{Jobs: h.controller.ListInstallJobs()})
|
|
}
|
|
|
|
func (h *Handlers) httpGetInstallJob(c *gin.Context) {
|
|
id := strings.TrimSpace(c.Param("id"))
|
|
if id == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "job id is required"})
|
|
return
|
|
}
|
|
job, ok := h.controller.GetInstallJob(id)
|
|
if !ok {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "job not found"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, job)
|
|
}
|
|
|
|
func (h *Handlers) broadcastAvailableAgentsAsync() {
|
|
if h.hub == nil {
|
|
return
|
|
}
|
|
go func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), availableAgentsBroadcastTimeout)
|
|
defer cancel()
|
|
|
|
availableResp, err := h.controller.ListAvailableAgents(ctx)
|
|
if err != nil {
|
|
h.logger.Error("failed to list available agents for broadcast", zap.Error(err))
|
|
return
|
|
}
|
|
notification, _ := ws.NewNotification(ws.ActionAgentAvailableUpdated, gin.H{
|
|
"agents": availableResp.Agents,
|
|
})
|
|
h.hub.Broadcast(notification)
|
|
}()
|
|
}
|
|
|
|
func (h *Handlers) httpListAgents(c *gin.Context) {
|
|
resp, err := h.controller.ListAgents(c.Request.Context())
|
|
if err != nil {
|
|
h.logger.Error("failed to list agents", zap.Error(err))
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list agents"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, resp)
|
|
}
|
|
|
|
type createAgentRequest struct {
|
|
Name string `json:"name"`
|
|
WorkspaceID *string `json:"workspace_id,omitempty"`
|
|
Profiles []createAgentProfileRequest `json:"profiles,omitempty"`
|
|
}
|
|
|
|
type createAgentProfileRequest struct {
|
|
Name string `json:"name"`
|
|
Model string `json:"model"`
|
|
FallbackModel string `json:"fallback_model,omitempty"`
|
|
AutoFallback bool `json:"auto_fallback"`
|
|
RequireExactModel bool `json:"require_exact_model"`
|
|
CursorMCPAuthEnabled *bool `json:"cursor_mcp_auth_enabled,omitempty"`
|
|
Mode string `json:"mode,omitempty"`
|
|
CLIFlags []dto.CLIFlagDTO `json:"cli_flags,omitempty"`
|
|
EnvVars []dto.ProfileEnvVarDTO `json:"env_vars,omitempty"`
|
|
CommandPrefix string `json:"command_prefix,omitempty"`
|
|
}
|
|
|
|
func (h *Handlers) httpCreateAgent(c *gin.Context) {
|
|
var body createAgentRequest
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
|
|
return
|
|
}
|
|
if body.Name == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
|
return
|
|
}
|
|
profiles := make([]controller.CreateAgentProfileRequest, 0, len(body.Profiles))
|
|
for _, profile := range body.Profiles {
|
|
if strings.TrimSpace(profile.Name) == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "profile name is required"})
|
|
return
|
|
}
|
|
profiles = append(profiles, controller.CreateAgentProfileRequest{
|
|
Name: profile.Name,
|
|
Model: profile.Model,
|
|
FallbackModel: profile.FallbackModel,
|
|
AutoFallback: profile.AutoFallback,
|
|
RequireExactModel: profile.RequireExactModel,
|
|
CursorMCPAuthEnabled: profile.CursorMCPAuthEnabled,
|
|
Mode: profile.Mode,
|
|
CLIFlags: profile.CLIFlags,
|
|
EnvVars: profile.EnvVars,
|
|
CommandPrefix: profile.CommandPrefix,
|
|
})
|
|
}
|
|
resp, err := h.controller.CreateAgent(c.Request.Context(), controller.CreateAgentRequest{
|
|
Name: body.Name,
|
|
WorkspaceID: body.WorkspaceID,
|
|
Profiles: profiles,
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, controller.ErrInvalidProfileEnvVars) || errors.Is(err, controller.ErrInvalidCommandPrefix) ||
|
|
errors.Is(err, controller.ErrInvalidProviderConfig) ||
|
|
errors.Is(err, controller.ErrRequireExactModelNeedsModel) || errors.Is(err, controller.ErrRequireExactModelUnsupported) {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
h.logger.Error("failed to create agent", zap.Error(err))
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, resp)
|
|
}
|
|
|
|
func (h *Handlers) httpGetAgent(c *gin.Context) {
|
|
resp, err := h.controller.GetAgent(c.Request.Context(), c.Param("id"))
|
|
if err != nil {
|
|
if err == controller.ErrAgentNotFound {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"})
|
|
return
|
|
}
|
|
h.logger.Error("failed to get agent", zap.Error(err))
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get agent"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, resp)
|
|
}
|
|
|
|
type updateAgentRequest struct {
|
|
WorkspaceID *string `json:"workspace_id,omitempty"`
|
|
SupportsMCP *bool `json:"supports_mcp,omitempty"`
|
|
MCPConfigPath *string `json:"mcp_config_path,omitempty"`
|
|
}
|
|
|
|
func (h *Handlers) httpUpdateAgent(c *gin.Context) {
|
|
var body updateAgentRequest
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
|
|
return
|
|
}
|
|
resp, err := h.controller.UpdateAgent(c.Request.Context(), controller.UpdateAgentRequest{
|
|
ID: c.Param("id"),
|
|
WorkspaceID: body.WorkspaceID,
|
|
SupportsMCP: body.SupportsMCP,
|
|
MCPConfigPath: body.MCPConfigPath,
|
|
})
|
|
if err != nil {
|
|
if err == controller.ErrAgentNotFound {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"})
|
|
return
|
|
}
|
|
h.logger.Error("failed to update agent", zap.Error(err))
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update agent"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, resp)
|
|
}
|
|
|
|
func (h *Handlers) httpDeleteAgent(c *gin.Context) {
|
|
if err := h.controller.DeleteAgent(c.Request.Context(), c.Param("id")); err != nil {
|
|
if err == controller.ErrAgentNotFound {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"})
|
|
return
|
|
}
|
|
h.logger.Error("failed to delete agent", zap.Error(err))
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete agent"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"success": true})
|
|
h.broadcastAvailableAgentsAsync()
|
|
}
|
|
|
|
type updateProfileMcpConfigRequest struct {
|
|
Enabled *bool `json:"enabled"`
|
|
Servers map[string]mcpconfig.ServerDef `json:"servers"`
|
|
MCPServers map[string]mcpconfig.ServerDef `json:"mcpServers"`
|
|
Meta map[string]any `json:"meta,omitempty"`
|
|
}
|
|
|
|
func (h *Handlers) httpGetProfileMcpConfig(c *gin.Context) {
|
|
profileID := c.Param("id")
|
|
if profileID == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "profile id is required"})
|
|
return
|
|
}
|
|
|
|
resp, err := h.controller.GetAgentProfileMcpConfig(c.Request.Context(), profileID)
|
|
if err != nil {
|
|
if err == controller.ErrAgentProfileNotFound {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "agent profile not found"})
|
|
return
|
|
}
|
|
if err == controller.ErrAgentMcpUnsupported {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "mcp not supported by agent"})
|
|
return
|
|
}
|
|
h.logger.Error("failed to get mcp config", zap.Error(err))
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get mcp config"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, resp)
|
|
}
|
|
|
|
func (h *Handlers) httpUpdateProfileMcpConfig(c *gin.Context) {
|
|
profileID := c.Param("id")
|
|
if profileID == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "profile id is required"})
|
|
return
|
|
}
|
|
|
|
var body updateProfileMcpConfigRequest
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
|
|
return
|
|
}
|
|
if body.Enabled == nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "enabled is required"})
|
|
return
|
|
}
|
|
servers := body.Servers
|
|
if len(servers) == 0 && len(body.MCPServers) > 0 {
|
|
servers = body.MCPServers
|
|
}
|
|
if servers == nil {
|
|
servers = map[string]mcpconfig.ServerDef{}
|
|
}
|
|
|
|
resp, err := h.controller.UpdateAgentProfileMcpConfig(c.Request.Context(), profileID, controller.UpdateAgentProfileMcpConfigRequest{
|
|
Enabled: *body.Enabled,
|
|
Servers: servers,
|
|
Meta: body.Meta,
|
|
})
|
|
if err != nil {
|
|
if err == controller.ErrAgentProfileNotFound {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "agent profile not found"})
|
|
return
|
|
}
|
|
if err == controller.ErrAgentMcpUnsupported {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "mcp not supported by agent"})
|
|
return
|
|
}
|
|
h.logger.Error("failed to update mcp config", zap.Error(err))
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update mcp config"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, resp)
|
|
h.broadcastProfileMCPConfigUpdated(profileID, resp.WorkspaceID)
|
|
}
|
|
|
|
type createProfileRequest = dto.ProfileCreateRequest
|
|
|
|
func (h *Handlers) httpCreateProfile(c *gin.Context) {
|
|
var body createProfileRequest
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
|
|
return
|
|
}
|
|
body.AgentID = c.Param("id")
|
|
if err := body.Validate(); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "profile name is required"})
|
|
return
|
|
}
|
|
resp, err := h.controller.CreateProfile(c.Request.Context(), controller.CreateProfileRequestFromDTO(body))
|
|
if err != nil {
|
|
if errors.Is(err, controller.ErrDynamicAgentRoutingDisabled) {
|
|
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
if errors.Is(err, controller.ErrDynamicProfileCandidatesRequired) ||
|
|
errors.Is(err, controller.ErrDynamicProfilePositions) ||
|
|
errors.Is(err, controller.ErrDynamicProfileRule) ||
|
|
errors.Is(err, controller.ErrDynamicProfileCandidate) {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
if errors.Is(err, controller.ErrInvalidProfileEnvVars) || errors.Is(err, controller.ErrInvalidCommandPrefix) ||
|
|
errors.Is(err, controller.ErrInvalidProviderConfig) ||
|
|
errors.Is(err, controller.ErrRequireExactModelNeedsModel) || errors.Is(err, controller.ErrRequireExactModelUnsupported) {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
h.logger.Error("failed to create profile", zap.Error(err))
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create profile"})
|
|
return
|
|
}
|
|
h.broadcastProfileEvent(c.Request.Context(), ws.ActionAgentProfileCreated, resp)
|
|
c.JSON(http.StatusOK, resp)
|
|
}
|
|
|
|
type updateProfileRequest = dto.ProfileUpdateRequest
|
|
|
|
func (h *Handlers) httpUpdateProfile(c *gin.Context) {
|
|
var body updateProfileRequest
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
|
|
return
|
|
}
|
|
body.ID = c.Param("id")
|
|
body.Force = c.Query("force") == queryTrue
|
|
if err := body.Validate(); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "profile id is required"})
|
|
return
|
|
}
|
|
if body.Name != nil && strings.TrimSpace(*body.Name) == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "profile name is required"})
|
|
return
|
|
}
|
|
resp, err := h.controller.UpdateProfile(c.Request.Context(), controller.UpdateProfileRequestFromDTO(body))
|
|
if err != nil {
|
|
if err == controller.ErrAgentProfileNotFound {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "agent profile not found"})
|
|
return
|
|
}
|
|
if errors.Is(err, controller.ErrInvalidProfileEnvVars) || errors.Is(err, controller.ErrInvalidCommandPrefix) ||
|
|
errors.Is(err, controller.ErrInvalidProviderConfig) ||
|
|
errors.Is(err, controller.ErrRequireExactModelNeedsModel) || errors.Is(err, controller.ErrRequireExactModelUnsupported) {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
if errors.Is(err, controller.ErrDynamicAgentRoutingDisabled) ||
|
|
errors.Is(err, controller.ErrDynamicProfileVersionConflict) {
|
|
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
if errors.Is(err, controller.ErrDynamicProfileCandidatesRequired) ||
|
|
errors.Is(err, controller.ErrDynamicProfilePositions) ||
|
|
errors.Is(err, controller.ErrDynamicProfileRule) ||
|
|
errors.Is(err, controller.ErrDynamicProfileCandidate) {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
var inUseErr *controller.ErrProfileInUseDetail
|
|
if errors.As(err, &inUseErr) {
|
|
c.JSON(http.StatusConflict, gin.H{
|
|
"error": "agent profile is in use",
|
|
"utility_agents": inUseErr.UtilityAgents,
|
|
"dynamic_profiles": inUseErr.DynamicProfiles,
|
|
})
|
|
return
|
|
}
|
|
h.logger.Error("failed to update profile", zap.Error(err))
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update profile"})
|
|
return
|
|
}
|
|
h.broadcastProfileEvent(c.Request.Context(), ws.ActionAgentProfileUpdated, resp)
|
|
c.JSON(http.StatusOK, resp)
|
|
}
|
|
|
|
// httpDuplicateProfile copies a profile's full configuration into a new row
|
|
// named "<source> copy" and returns the new profile. No request body: the
|
|
// copy name is derived server-side. The existing agent.profile.created
|
|
// notification lets every open settings surface pick the copy up live.
|
|
func (h *Handlers) httpDuplicateProfile(c *gin.Context) {
|
|
profileID := c.Param("id")
|
|
if profileID == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "profile id is required"})
|
|
return
|
|
}
|
|
resp, err := h.controller.DuplicateProfile(c.Request.Context(), controller.DuplicateProfileRequest{
|
|
ID: profileID,
|
|
})
|
|
if err != nil {
|
|
if err == controller.ErrAgentProfileNotFound {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "agent profile not found"})
|
|
return
|
|
}
|
|
if errors.Is(err, controller.ErrDynamicProfileDuplicationUnsupported) {
|
|
c.JSON(http.StatusConflict, gin.H{"error": "dynamic profiles cannot be duplicated"})
|
|
return
|
|
}
|
|
h.logger.Error("failed to duplicate profile", zap.Error(err))
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to duplicate profile"})
|
|
return
|
|
}
|
|
h.broadcastProfileEvent(c.Request.Context(), ws.ActionAgentProfileCreated, resp)
|
|
c.JSON(http.StatusOK, resp)
|
|
}
|
|
|
|
// broadcastProfileEvent fans a profile create/update/delete event out.
|
|
// Kanban profiles (empty WorkspaceID) go to every settings client.
|
|
// Office-scoped profiles are routed through the workspace-scoped broadcaster
|
|
// so their configuration (env vars, servers, ...) never leaks across
|
|
// workspace/user boundaries — the HTTP agent list hides them via
|
|
// filterGlobalProfiles, and the WS path must not contradict that. When the
|
|
// hub does not support workspace routing (test fakes), the office event is
|
|
// dropped fail-closed.
|
|
func (h *Handlers) broadcastProfileEvent(ctx context.Context, action string, profile *dto.AgentProfileDTO) {
|
|
if h.hub == nil {
|
|
return
|
|
}
|
|
// Profile events can arrive before settings-agent hydration. Include the
|
|
// capability needed by sessionless pickers so they need not guess.
|
|
inferenceCapable := false
|
|
if action != ws.ActionAgentProfileDeleted {
|
|
agent, err := h.controller.GetAgent(ctx, profile.AgentID)
|
|
if err != nil {
|
|
h.logger.Warn("failed to load agent capability for profile event", zap.Error(err))
|
|
} else {
|
|
inferenceCapable = agent.InferenceCapable
|
|
}
|
|
}
|
|
notification, _ := ws.NewNotification(action, gin.H{
|
|
"profile": profile,
|
|
"inference_capable": inferenceCapable,
|
|
})
|
|
if profile.WorkspaceID != "" {
|
|
if workspaceHub, ok := h.hub.(interface {
|
|
BroadcastToWorkspaceOrDrop(string, *ws.Message)
|
|
}); ok {
|
|
workspaceHub.BroadcastToWorkspaceOrDrop(profile.WorkspaceID, notification)
|
|
}
|
|
return
|
|
}
|
|
h.hub.Broadcast(notification)
|
|
}
|
|
|
|
func (h *Handlers) httpDeleteProfile(c *gin.Context) {
|
|
force := c.Query("force") == "true"
|
|
profile, err := h.controller.DeleteProfile(c.Request.Context(), c.Param("id"), force)
|
|
if err != nil {
|
|
if err == controller.ErrAgentProfileNotFound {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "agent profile not found"})
|
|
return
|
|
}
|
|
var inUseErr *controller.ErrProfileInUseDetail
|
|
if errors.As(err, &inUseErr) {
|
|
c.JSON(http.StatusConflict, gin.H{
|
|
"error": "agent profile is in use",
|
|
"active_sessions": inUseErr.ActiveSessions,
|
|
"watchers": inUseErr.Watchers,
|
|
"routing_tiers": inUseErr.RoutingTiers,
|
|
"automations": inUseErr.Automations,
|
|
"utility_agents": inUseErr.UtilityAgents,
|
|
"dynamic_profiles": inUseErr.DynamicProfiles,
|
|
})
|
|
return
|
|
}
|
|
h.logger.Error("failed to delete profile", zap.Error(err))
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete profile"})
|
|
return
|
|
}
|
|
h.broadcastProfileEvent(c.Request.Context(), ws.ActionAgentProfileDeleted, profile)
|
|
c.JSON(http.StatusOK, gin.H{"success": true})
|
|
}
|
|
|
|
type commandPreviewRequest struct {
|
|
Model string `json:"model"`
|
|
PermissionSettings map[string]bool `json:"permission_settings"`
|
|
CLIPassthrough bool `json:"cli_passthrough"`
|
|
CLIFlags []dto.CLIFlagDTO `json:"cli_flags"`
|
|
CommandPrefix string `json:"command_prefix,omitempty"`
|
|
}
|
|
|
|
func (h *Handlers) httpPreviewAgentCommand(c *gin.Context) {
|
|
agentName := c.Param("agentName")
|
|
if agentName == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "agent name is required"})
|
|
return
|
|
}
|
|
|
|
var body commandPreviewRequest
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
|
|
return
|
|
}
|
|
|
|
resp, err := h.controller.PreviewAgentCommand(c.Request.Context(), agentName, controller.CommandPreviewRequest{
|
|
Model: body.Model,
|
|
PermissionSettings: body.PermissionSettings,
|
|
CLIPassthrough: body.CLIPassthrough,
|
|
CLIFlags: body.CLIFlags,
|
|
CommandPrefix: body.CommandPrefix,
|
|
})
|
|
if err != nil {
|
|
h.logger.Error("failed to preview agent command", zap.Error(err))
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, resp)
|
|
}
|
|
|
|
// httpGetAgentLogo serves agent SVG logos.
|
|
// Note: :id here is the agent name (e.g. "claude-code"), not the UUID used by other /agents/:id routes.
|
|
func (h *Handlers) httpGetAgentLogo(c *gin.Context) {
|
|
agentName := c.Param("id")
|
|
if agentName == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "agent name is required"})
|
|
return
|
|
}
|
|
|
|
variant := agents.LogoLight
|
|
if c.Query("variant") == "dark" {
|
|
variant = agents.LogoDark
|
|
}
|
|
|
|
data, err := h.controller.GetAgentLogo(c.Request.Context(), agentName, variant)
|
|
if err != nil {
|
|
if err == controller.ErrAgentNotFound || err == controller.ErrLogoNotAvailable {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
h.logger.Error("failed to get agent logo", zap.Error(err))
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get agent logo"})
|
|
return
|
|
}
|
|
|
|
c.Header("Cache-Control", "public, max-age=86400")
|
|
c.Data(http.StatusOK, "image/svg+xml", data)
|
|
}
|
|
|
|
func (h *Handlers) httpGetAgentModels(c *gin.Context) {
|
|
agentName := c.Param("agentName")
|
|
if agentName == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "agent name is required"})
|
|
return
|
|
}
|
|
|
|
// Check for refresh query parameter
|
|
refresh := c.Query("refresh") == "true"
|
|
|
|
resp, err := h.controller.FetchDynamicModels(c.Request.Context(), agentName, refresh)
|
|
if err != nil {
|
|
if errors.Is(err, controller.ErrAgentNotFound) {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"})
|
|
return
|
|
}
|
|
h.logger.Error("failed to fetch agent models", zap.Error(err))
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, resp)
|
|
}
|
|
|
|
func (h *Handlers) httpResolveAgentModelConfig(c *gin.Context) {
|
|
agentName := strings.TrimSpace(c.Param("agentName"))
|
|
if agentName == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "agent name is required"})
|
|
return
|
|
}
|
|
var req dto.ResolveAgentModelConfigRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid model resolution request"})
|
|
return
|
|
}
|
|
resp, err := h.controller.ResolveAgentModelConfig(c.Request.Context(), agentName, req)
|
|
if err != nil {
|
|
if errors.Is(err, controller.ErrModelRequired) {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "model is required"})
|
|
return
|
|
}
|
|
if errors.Is(err, controller.ErrAgentNotFound) {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"})
|
|
return
|
|
}
|
|
h.logger.Error("failed to resolve agent model options", zap.Error(err))
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to resolve agent model options"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, resp)
|
|
}
|