// 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" "time" "github.com/go-chi/chi/v5" "github.com/google/uuid" ws "github.com/coder/websocket" "github.com/coder/websocket/wsjson" "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) } // WSConfig controls WebSocket heartbeat and buffering. type WSConfig struct { PingInterval time.Duration PongTimeout time.Duration SendBuffer int } // Handler exposes ACP HTTP endpoints. type Handler struct { akSvc AgentKindService sessSvc SessionService sup *Supervisor repo Repository permSvc *PermissionService resolver middleware.SessionResolver adminLookup AdminLookup enc *crypto.Encryptor cfg WSConfig } // NewHandler constructs Handler with full dependencies. func NewHandler(ak AgentKindService, ss SessionService, sup *Supervisor, repo Repository, permSvc *PermissionService, resolver middleware.SessionResolver, al AdminLookup, enc *crypto.Encryptor, cfg WSConfig) *Handler { return &Handler{ akSvc: ak, sessSvc: ss, sup: sup, repo: repo, permSvc: permSvc, resolver: resolver, adminLookup: al, enc: enc, cfg: cfg, } } // 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) r.Get("/{id}/ws", h.sessionWS) r.Get("/{id}/permissions", h.listSessionPermissions) }) r.Route("/api/v1/acp/permissions", func(r chi.Router) { r.Use(middleware.Auth(h.resolver, middleware.AuthOptions{})) r.Post("/{reqID}/approve", h.approvePermission) r.Post("/{reqID}/deny", h.denyPermission) }) } // listSessionPermissions 返回某会话的待审权限请求(owner 或 admin)。 func (h *Handler) listSessionPermissions(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 } reqs, err := h.permSvc.ListPendingBySession(r.Context(), c, id) if err != nil { writeErr(w, r, err) return } out := make([]PermissionRequestDTO, 0, len(reqs)) for _, p := range reqs { out = append(out, permissionRequestToDTO(p)) } httpx.WriteJSON(w, http.StatusOK, out) } type approvePermissionReq struct { OptionID string `json:"option_id"` } func (h *Handler) approvePermission(w http.ResponseWriter, r *http.Request) { h.resolvePermission(w, r, true) } func (h *Handler) denyPermission(w http.ResponseWriter, r *http.Request) { h.resolvePermission(w, r, false) } func (h *Handler) resolvePermission(w http.ResponseWriter, r *http.Request, approve bool) { c, err := h.caller(r) if err != nil { writeErr(w, r, err) return } reqID, perr := uuid.Parse(chi.URLParam(r, "reqID")) if perr != nil { writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad request id")) return } var optionID string if approve { var body approvePermissionReq // 批准时 option_id 可选(缺省自动挑选 allow 类);请求体可空。 _ = json.NewDecoder(r.Body).Decode(&body) optionID = body.OptionID } if err := h.permSvc.Resolve(r.Context(), c, reqID, approve, optionID); err != nil { writeErr(w, r, err) return } w.WriteHeader(http.StatusNoContent) } 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, ToolAllowlist: req.ToolAllowlist, } 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, ToolAllowlist: req.ToolAllowlist, } 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) } // ===== WebSocket ===== func (h *Handler) sessionWS(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 } sess, err := h.sessSvc.Get(r.Context(), c, id) if 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 } } conn, err := ws.Accept(w, r, &ws.AcceptOptions{ InsecureSkipVerify: false, }) if err != nil { return } defer conn.Close(ws.StatusInternalError, "internal") ctx, cancel := context.WithCancel(r.Context()) defer cancel() // 1. State event stateEv := map[string]any{ "kind": "state", "session_id": id.String(), "status": string(sess.Status), "branch": sess.Branch, "since": since, } if err := wsjson.Write(ctx, conn, stateEv); err != nil { return } // 2. Push history history, _ := h.repo.ListEventsSince(ctx, id, since, int32(5000)) var lastSentID = since for _, e := range history { env := EventEnvelope{ ID: e.ID, Direction: string(e.Direction), Kind: string(e.RPCKind), Method: derefStr(e.Method), Payload: e.Payload, Truncated: e.Truncated, } if err := wsjson.Write(ctx, conn, env); err != nil { return } lastSentID = e.ID } // 3. Live: subscribe + heartbeat + client messages relay := h.sup.GetRelay(id) if relay == nil { conn.Close(ws.StatusNormalClosure, "session ended") return } sub := relay.Subscribe(c.UserID) defer relay.Unsubscribe(sub.ID) // Heartbeat goroutine pingTicker := time.NewTicker(h.cfg.PingInterval) defer pingTicker.Stop() go func() { for { select { case <-ctx.Done(): return case <-pingTicker.C: pingCtx, pcancel := context.WithTimeout(ctx, h.cfg.PongTimeout) err := conn.Ping(pingCtx) pcancel() if err != nil { cancel() return } } } }() // Client → server reader (goroutine) clientMsgCh := make(chan *Message, 8) go func() { defer close(clientMsgCh) for { _, data, err := conn.Read(ctx) if err != nil { return } var m Message if err := json.Unmarshal(data, &m); err != nil { _ = wsjson.Write(ctx, conn, map[string]any{ "kind": "client_error", "message": "bad json", }) continue } clientMsgCh <- &m } }() // Main loop for { select { case <-ctx.Done(): return case env, ok := <-sub.Send: if !ok { conn.Close(ws.StatusNormalClosure, "relay closed") return } if env.ID <= lastSentID && env.ID != 0 { continue } if err := wsjson.Write(ctx, conn, env); err != nil { return } if env.ID > lastSentID { lastSentID = env.ID } case m, ok := <-clientMsgCh: if !ok { return } if c.UserID != sess.UserID { _ = wsjson.Write(ctx, conn, map[string]any{ "kind": "client_error", "message": "only owner can prompt/cancel", }) continue } if m.Method != "session/prompt" && m.Method != "session/cancel" { _ = wsjson.Write(ctx, conn, map[string]any{ "kind": "client_error", "message": "method not allowed", }) continue } if err := relay.SendClient(m); err != nil { _ = wsjson.Write(ctx, conn, map[string]any{ "kind": "client_error", "message": err.Error(), }) } } } } // ===== 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, ToolAllowlist: append([]string(nil), k.ToolAllowlist...), 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) } func derefStr(p *string) string { if p == nil { return "" } return *p }