Files
agentic-coding-workflow/internal/acp/handler.go
T

393 lines
10 KiB
Go

// 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)
}