Files
kandev/apps/backend/internal/agent/settings/controller/agent_crud.go
T
2026-05-25 08:14:45 +01:00

257 lines
7.4 KiB
Go

package controller
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"github.com/kandev/kandev/internal/agent/agents"
"github.com/kandev/kandev/internal/agent/discovery"
"github.com/kandev/kandev/internal/agent/settings/dto"
"github.com/kandev/kandev/internal/agent/settings/models"
)
func (c *Controller) GetAgent(ctx context.Context, id string) (*dto.AgentDTO, error) {
agent, err := c.repo.GetAgent(ctx, id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrAgentNotFound
}
return nil, err
}
profiles, err := c.repo.ListAgentProfiles(ctx, agent.ID)
if err != nil {
return nil, err
}
result := toAgentDTO(agent, filterGlobalProfiles(profiles))
c.applyCapabilityStatus(&result, agent.Name)
c.applyBillingType(&result, agent.Name)
return &result, nil
}
func (c *Controller) ListAgents(ctx context.Context) (*dto.ListAgentsResponse, error) {
agents, err := c.repo.ListAgents(ctx)
if err != nil {
return nil, err
}
payload := make([]dto.AgentDTO, 0, len(agents))
for _, agent := range agents {
profiles, err := c.repo.ListAgentProfiles(ctx, agent.ID)
if err != nil {
return nil, err
}
entry := toAgentDTO(agent, filterGlobalProfiles(profiles))
c.applyCapabilityStatus(&entry, agent.Name)
c.applyBillingType(&entry, agent.Name)
payload = append(payload, entry)
}
return &dto.ListAgentsResponse{Agents: payload, Total: len(payload)}, nil
}
// filterGlobalProfiles drops workspace-scoped (office) rows from a profile
// list so the kanban-facing GET /api/v1/agents endpoints only surface global
// CLI profiles. Office agents live in the same agent_profiles table since
// ADR 0005 Wave G but are scoped by workspace_id; the kanban task picker
// must not show them.
func filterGlobalProfiles(profiles []*models.AgentProfile) []*models.AgentProfile {
out := make([]*models.AgentProfile, 0, len(profiles))
for _, p := range profiles {
if p.WorkspaceID == "" {
out = append(out, p)
}
}
return out
}
type CreateAgentRequest struct {
Name string
WorkspaceID *string
Profiles []CreateAgentProfileRequest
}
type CreateAgentProfileRequest struct {
Name string
Model string
Mode string
// CLIFlags is the explicit list to persist. When nil the list is seeded
// from the agent's curated PermissionSettings() catalogue (all disabled
// by default) so a fresh profile opens with the agent's suggestions.
CLIFlags []dto.CLIFlagDTO
EnvVars []dto.ProfileEnvVarDTO
}
func (c *Controller) CreateAgent(ctx context.Context, req CreateAgentRequest) (*dto.AgentDTO, error) {
if req.Name == "" {
return nil, fmt.Errorf("name is required")
}
existing, err := c.repo.GetAgentByName(ctx, req.Name)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, err
}
if err == nil && existing != nil {
return nil, fmt.Errorf("agent already configured: %s", req.Name)
}
discoveryResults, err := c.detectAgents(ctx)
if err != nil {
return nil, err
}
matched, err := c.findMatchedAvailability(req.Name, discoveryResults)
if err != nil {
return nil, err
}
agentConfig, agOk := c.agentRegistry.Get(req.Name)
if !agOk {
return nil, fmt.Errorf("unknown agent: %s", req.Name)
}
displayName, err := c.resolveDisplayName(agentConfig, req.Name)
if err != nil {
return nil, err
}
agent := &models.Agent{
Name: matched.Name,
WorkspaceID: req.WorkspaceID,
SupportsMCP: matched.SupportsMCP,
MCPConfigPath: matched.MCPConfigPath,
}
if err := c.repo.CreateAgent(ctx, agent); err != nil {
return nil, err
}
profiles, err := c.createAgentProfiles(ctx, agent.ID, displayName, req.Profiles, agentConfig)
if err != nil {
return nil, err
}
result := toAgentDTO(agent, profiles)
c.applyCapabilityStatus(&result, agent.Name)
return &result, nil
}
// applyCapabilityStatus populates the DTO's capability fields from the host
// utility cache. No-op when the host utility is unavailable or the agent
// isn't in the cache (e.g. mock, tui-only, or pre-probe).
func (c *Controller) applyCapabilityStatus(d *dto.AgentDTO, agentName string) {
if c.hostUtility == nil {
return
}
caps, ok := c.hostUtility.Get(agentName)
if !ok {
return
}
d.CapabilityStatus = string(caps.Status)
d.CapabilityError = caps.Error
}
// applyBillingType populates BillingType on each profile in the DTO.
// It calls BillingType() on the registered agent implementation at read time
// so credential detection runs once per request and is not stored in the DB.
func (c *Controller) applyBillingType(d *dto.AgentDTO, agentName string) {
ag, ok := c.agentRegistry.Get(agentName)
if !ok {
return
}
bt := string(ag.BillingType())
for i := range d.Profiles {
d.Profiles[i].BillingType = bt
}
}
func (c *Controller) findMatchedAvailability(name string, results []discovery.Availability) (*discovery.Availability, error) {
for _, result := range results {
if result.Name == name {
if !result.Available {
return nil, fmt.Errorf("agent not installed: %s", name)
}
r := result
return &r, nil
}
}
return nil, fmt.Errorf("unknown agent: %s", name)
}
func (c *Controller) createAgentProfiles(ctx context.Context, agentID, displayName string, profileReqs []CreateAgentProfileRequest, agentConfig agents.Agent) ([]*models.AgentProfile, error) {
profiles := make([]*models.AgentProfile, 0, len(profileReqs))
for _, profileReq := range profileReqs {
if profileReq.CLIFlags != nil {
if err := validateCLIFlagDTOs(profileReq.CLIFlags); err != nil {
return nil, err
}
}
if err := validateProfileEnvVarDTOs(profileReq.EnvVars); err != nil {
return nil, err
}
cliFlags := cliFlagsFromDTO(profileReq.CLIFlags)
if profileReq.CLIFlags == nil {
cliFlags = seedCLIFlags(agentConfig)
}
profile := &models.AgentProfile{
AgentID: agentID,
Name: profileReq.Name,
AgentDisplayName: displayName,
Model: profileReq.Model,
Mode: profileReq.Mode,
CLIFlags: cliFlags,
EnvVars: envVarsFromDTO(profileReq.EnvVars),
}
if err := c.repo.CreateAgentProfile(ctx, profile); err != nil {
return nil, err
}
profiles = append(profiles, profile)
}
return profiles, nil
}
type UpdateAgentRequest struct {
ID string
WorkspaceID *string
SupportsMCP *bool
MCPConfigPath *string
}
func (c *Controller) UpdateAgent(ctx context.Context, req UpdateAgentRequest) (*dto.AgentDTO, error) {
agent, err := c.repo.GetAgent(ctx, req.ID)
if err != nil {
return nil, ErrAgentNotFound
}
if req.WorkspaceID != nil {
agent.WorkspaceID = req.WorkspaceID
}
if req.SupportsMCP != nil {
agent.SupportsMCP = *req.SupportsMCP
}
if req.MCPConfigPath != nil {
agent.MCPConfigPath = *req.MCPConfigPath
}
if err := c.repo.UpdateAgent(ctx, agent); err != nil {
return nil, err
}
profiles, err := c.repo.ListAgentProfiles(ctx, agent.ID)
if err != nil {
return nil, err
}
result := toAgentDTO(agent, filterGlobalProfiles(profiles))
return &result, nil
}
func (c *Controller) DeleteAgent(ctx context.Context, id string) error {
// If the agent has a tui_config, unregister from the in-memory registry
agent, err := c.repo.GetAgent(ctx, id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ErrAgentNotFound
}
return err
}
if agent.TUIConfig != nil {
_ = c.agentRegistry.Unregister(agent.Name)
}
if err := c.repo.DeleteAgent(ctx, id); err != nil {
if strings.Contains(err.Error(), "agent not found") {
return ErrAgentNotFound
}
return err
}
return nil
}