// Package acp handler.go exposes the ACP module's HTTP / WS endpoints. // Part 1: agent_kinds routes only. Part 2 will add sessions REST + WS upgrader. package acp import ( "context" "encoding/json" "net/http" "sort" "strings" "github.com/go-chi/chi/v5" "github.com/google/uuid" "github.com/yan1h/agent-coding-workflow/internal/infra/crypto" "github.com/yan1h/agent-coding-workflow/internal/infra/errs" httpx "github.com/yan1h/agent-coding-workflow/internal/transport/http" "github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware" ) // AdminLookup resolves whether a user is an admin (mirrors chat/workspace pattern). type AdminLookup interface { IsAdmin(ctx context.Context, userID uuid.UUID) (bool, error) } // Handler exposes ACP HTTP endpoints. Part 2 will extend with SessionService and Supervisor. type Handler struct { akSvc AgentKindService resolver middleware.SessionResolver adminLookup AdminLookup enc *crypto.Encryptor } // NewHandler constructs Handler (Part 1: agent_kinds only). func NewHandler(ak AgentKindService, resolver middleware.SessionResolver, al AdminLookup, enc *crypto.Encryptor) *Handler { return &Handler{akSvc: ak, resolver: resolver, adminLookup: al, enc: enc} } // Mount registers /api/v1/acp/* routes. func (h *Handler) Mount(r chi.Router) { r.Route("/api/v1/acp/agent-kinds", func(r chi.Router) { r.Use(middleware.Auth(h.resolver, middleware.AuthOptions{})) r.Get("/", h.listAgentKinds) r.Post("/", h.createAgentKind) r.Get("/{id}", h.getAgentKind) r.Patch("/{id}", h.updateAgentKind) r.Delete("/{id}", h.deleteAgentKind) }) } func (h *Handler) caller(r *http.Request) (Caller, error) { uid, ok := middleware.UserIDFromContext(r.Context()) if !ok { return Caller{}, errs.New(errs.CodeUnauthorized, "not authenticated") } admin, err := h.adminLookup.IsAdmin(r.Context(), uid) if err != nil { return Caller{}, err } return Caller{UserID: uid, IsAdmin: admin}, nil } func (h *Handler) listAgentKinds(w http.ResponseWriter, r *http.Request) { c, err := h.caller(r) if err != nil { writeErr(w, r, err) return } ks, err := h.akSvc.List(r.Context(), c) if err != nil { writeErr(w, r, err) return } if c.IsAdmin { out := make([]AgentKindAdminDTO, 0, len(ks)) for _, k := range ks { out = append(out, h.agentKindToAdminDTO(k)) } httpx.WriteJSON(w, http.StatusOK, out) return } out := make([]AgentKindPublicDTO, 0, len(ks)) for _, k := range ks { out = append(out, agentKindToPublicDTO(k)) } httpx.WriteJSON(w, http.StatusOK, out) } func (h *Handler) createAgentKind(w http.ResponseWriter, r *http.Request) { c, err := h.caller(r) if err != nil { writeErr(w, r, err) return } var req CreateAgentKindReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad json")) return } enabled := true if req.Enabled != nil { enabled = *req.Enabled } in := CreateAgentKindInput{ Name: strings.TrimSpace(req.Name), DisplayName: req.DisplayName, Description: req.Description, BinaryPath: strings.TrimSpace(req.BinaryPath), Args: req.Args, Env: req.Env, Enabled: enabled, } out, err := h.akSvc.Create(r.Context(), c, in) if err != nil { writeErr(w, r, err) return } httpx.WriteJSON(w, http.StatusCreated, h.agentKindToAdminDTO(out)) } func (h *Handler) getAgentKind(w http.ResponseWriter, r *http.Request) { c, err := h.caller(r) if err != nil { writeErr(w, r, err) return } id, perr := uuid.Parse(chi.URLParam(r, "id")) if perr != nil { writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad id")) return } k, err := h.akSvc.Get(r.Context(), c, id) if err != nil { writeErr(w, r, err) return } if c.IsAdmin { httpx.WriteJSON(w, http.StatusOK, h.agentKindToAdminDTO(k)) return } httpx.WriteJSON(w, http.StatusOK, agentKindToPublicDTO(k)) } func (h *Handler) updateAgentKind(w http.ResponseWriter, r *http.Request) { c, err := h.caller(r) if err != nil { writeErr(w, r, err) return } id, perr := uuid.Parse(chi.URLParam(r, "id")) if perr != nil { writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad id")) return } var req UpdateAgentKindReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad json")) return } in := UpdateAgentKindInput{ DisplayName: req.DisplayName, Description: req.Description, BinaryPath: req.BinaryPath, Args: req.Args, Env: req.Env, Enabled: req.Enabled, } out, err := h.akSvc.Update(r.Context(), c, id, in) if err != nil { writeErr(w, r, err) return } httpx.WriteJSON(w, http.StatusOK, h.agentKindToAdminDTO(out)) } func (h *Handler) deleteAgentKind(w http.ResponseWriter, r *http.Request) { c, err := h.caller(r) if err != nil { writeErr(w, r, err) return } id, perr := uuid.Parse(chi.URLParam(r, "id")) if perr != nil { writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad id")) return } if err := h.akSvc.Delete(r.Context(), c, id); err != nil { writeErr(w, r, err) return } w.WriteHeader(http.StatusNoContent) } // ===== DTO converters ===== // agentKindToAdminDTO is a Handler method because env-key extraction needs the encryptor. func (h *Handler) agentKindToAdminDTO(k *AgentKind) AgentKindAdminDTO { envKeys := []string{} if len(k.EncryptedEnv) > 0 { if env, err := decryptEnv(h.enc, k.EncryptedEnv); err == nil { for key := range env { envKeys = append(envKeys, key) } sort.Strings(envKeys) } } return AgentKindAdminDTO{ ID: k.ID, Name: k.Name, DisplayName: k.DisplayName, Description: k.Description, BinaryPath: k.BinaryPath, Args: append([]string(nil), k.Args...), EnvKeys: envKeys, Enabled: k.Enabled, CreatedBy: k.CreatedBy, CreatedAt: k.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"), UpdatedAt: k.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z"), } } func agentKindToPublicDTO(k *AgentKind) AgentKindPublicDTO { return AgentKindPublicDTO{ ID: k.ID, Name: k.Name, DisplayName: k.DisplayName, Enabled: k.Enabled, } } // ===== transport helpers ===== func writeErr(w http.ResponseWriter, r *http.Request, err error) { httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()), err) }