You've already forked agentic-coding-workflow
744 lines
18 KiB
Go
744 lines
18 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"
|
|
"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)
|
|
// 配置文件管理(admin only,service 层鉴权)。rel_path 含斜杠,
|
|
// 不放 URL path,统一走 body / query。
|
|
r.Get("/{id}/config-files", h.listConfigFiles)
|
|
r.Put("/{id}/config-files", h.upsertConfigFile)
|
|
r.Delete("/{id}/config-files", h.deleteConfigFile)
|
|
})
|
|
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,
|
|
ClientType: ClientType(req.ClientType),
|
|
MCPServers: req.MCPServers,
|
|
}
|
|
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,
|
|
MCPServers: req.MCPServers,
|
|
}
|
|
if req.ClientType != nil {
|
|
ct := ClientType(*req.ClientType)
|
|
in.ClientType = &ct
|
|
}
|
|
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)
|
|
}
|
|
|
|
// ===== AgentKind 配置文件 endpoints =====
|
|
|
|
func (h *Handler) listConfigFiles(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
|
|
}
|
|
files, err := h.akSvc.ListConfigFiles(r.Context(), c, id)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
out := make([]ConfigFileDTO, 0, len(files))
|
|
for _, f := range files {
|
|
out = append(out, configFileToDTO(f))
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
func (h *Handler) upsertConfigFile(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 UpsertConfigFileReq
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad json"))
|
|
return
|
|
}
|
|
out, err := h.akSvc.UpsertConfigFile(r.Context(), c, id, req.RelPath, req.Content)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, configFileToDTO(out))
|
|
}
|
|
|
|
func (h *Handler) deleteConfigFile(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
|
|
}
|
|
relPath := r.URL.Query().Get("rel_path")
|
|
if err := h.akSvc.DeleteConfigFile(r.Context(), c, id, relPath); 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
|
|
}
|
|
f := SessionListFilter{All: r.URL.Query().Get("all") == "true"}
|
|
if v := r.URL.Query().Get("requirement_id"); v != "" {
|
|
id, err := uuid.Parse(v)
|
|
if err != nil {
|
|
writeErr(w, r, errs.New(errs.CodeInvalidInput, "requirement_id 非法"))
|
|
return
|
|
}
|
|
f.RequirementID = &id
|
|
}
|
|
list, err := h.sessSvc.List(r.Context(), c, f)
|
|
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,
|
|
Subprotocols: []string{"acp-v1"},
|
|
})
|
|
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)
|
|
}
|
|
}
|
|
mcpServers := []McpServerSpec{}
|
|
if servers, err := decryptMCPServers(h.enc, k.EncryptedMCPServers); err == nil {
|
|
mcpServers = servers
|
|
}
|
|
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...),
|
|
ClientType: string(k.ClientType),
|
|
MCPServers: mcpServers,
|
|
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
|
|
}
|