// Package acp handler.go exposes the ACP module's HTTP / WS endpoints. // Part 1: agent_kinds routes. Part 2 (G1/G2): sessions REST endpoints. // Part 3 (G3): WebSocket upgrader. package acp import ( "context" "encoding/json" "net/http" "sort" "strconv" "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. type Handler struct { akSvc AgentKindService sessSvc SessionService sup *Supervisor repo Repository resolver middleware.SessionResolver adminLookup AdminLookup enc *crypto.Encryptor } // NewHandler constructs Handler with full dependencies. func NewHandler(ak AgentKindService, ss SessionService, sup *Supervisor, repo Repository, resolver middleware.SessionResolver, al AdminLookup, enc *crypto.Encryptor) *Handler { return &Handler{ akSvc: ak, sessSvc: ss, sup: sup, repo: repo, 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) }) r.Route("/api/v1/acp/sessions", func(r chi.Router) { r.Use(middleware.Auth(h.resolver, middleware.AuthOptions{})) r.Get("/", h.listSessions) r.Post("/", h.createSession) r.Get("/{id}", h.getSession) r.Delete("/{id}", h.terminateSession) r.Get("/{id}/events", h.getEvents) // /{id}/ws — added in G3 (WebSocket) }) } 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) } // ===== Session endpoints ===== func (h *Handler) listSessions(w http.ResponseWriter, r *http.Request) { c, err := h.caller(r) if err != nil { writeErr(w, r, err) return } all := r.URL.Query().Get("all") == "true" list, err := h.sessSvc.List(r.Context(), c, all) if err != nil { writeErr(w, r, err) return } out := make([]SessionDTO, 0, len(list)) for _, s := range list { out = append(out, sessionToDTO(s)) } httpx.WriteJSON(w, http.StatusOK, out) } func (h *Handler) createSession(w http.ResponseWriter, r *http.Request) { c, err := h.caller(r) if err != nil { writeErr(w, r, err) return } var req CreateSessionReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad json")) return } wsID, perr := uuid.Parse(req.WorkspaceID) if perr != nil { writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad workspace_id")) return } akID, perr := uuid.Parse(req.AgentKindID) if perr != nil { writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad agent_kind_id")) return } in := CreateSessionInput{ WorkspaceID: wsID, AgentKindID: akID, Branch: req.Branch, IssueNumber: req.IssueNumber, RequirementNumber: req.RequirementNumber, InitialPrompt: req.InitialPrompt, } out, err := h.sessSvc.Create(r.Context(), c, in) if err != nil { writeErr(w, r, err) return } httpx.WriteJSON(w, http.StatusCreated, sessionToDTO(out)) } func (h *Handler) getSession(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 } s, err := h.sessSvc.Get(r.Context(), c, id) if err != nil { writeErr(w, r, err) return } httpx.WriteJSON(w, http.StatusOK, sessionToDTO(s)) } func (h *Handler) terminateSession(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.sessSvc.Terminate(r.Context(), c, id); err != nil { writeErr(w, r, err) return } w.WriteHeader(http.StatusNoContent) } func (h *Handler) getEvents(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.sessSvc.Get(r.Context(), c, id); err != nil { writeErr(w, r, err) return } var since int64 if v := r.URL.Query().Get("since"); v != "" { if n, err := strconv.ParseInt(v, 10, 64); err == nil { since = n } } evs, err := h.repo.ListEventsSince(r.Context(), id, since, 1000) if err != nil { writeErr(w, r, err) return } out := make([]EventEnvelope, 0, len(evs)) for _, e := range evs { method := "" if e.Method != nil { method = *e.Method } out = append(out, EventEnvelope{ ID: e.ID, Direction: string(e.Direction), Kind: string(e.RPCKind), Method: method, Payload: e.Payload, Truncated: e.Truncated, }) } httpx.WriteJSON(w, http.StatusOK, out) } // ===== 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) }