You've already forked agentic-coding-workflow
1048 lines
27 KiB
Go
1048 lines
27 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"
|
|
"log/slog"
|
|
"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.Get("/{id}/usage", h.getSessionUsage)
|
|
// run-history 单会话事件时间线(owner 或 admin)。
|
|
r.Get("/{id}/timeline", h.getSessionTimeline)
|
|
})
|
|
// run-history dashboard 汇总(owner scope;admin 可全量)。
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(middleware.Auth(h.resolver, middleware.AuthOptions{}))
|
|
r.Get("/api/v1/acp/dashboard", h.getDashboard)
|
|
})
|
|
r.Route("/api/v1/acp/permissions", func(r chi.Router) {
|
|
r.Use(middleware.Auth(h.resolver, middleware.AuthOptions{}))
|
|
// 跨会话审批 inbox:调用者所有会话的待审权限请求(HITL)。
|
|
r.Get("/", h.listPendingPermissions)
|
|
r.Post("/{reqID}/approve", h.approvePermission)
|
|
r.Post("/{reqID}/deny", h.denyPermission)
|
|
})
|
|
// Admin ACP 用量聚合(镜像 chat adminUsage);service 层 requireAdmin。
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(middleware.Auth(h.resolver, middleware.AuthOptions{}))
|
|
r.Get("/api/v1/acp/usage", h.adminAcpUsage)
|
|
})
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// listPendingPermissions 返回调用者跨所有会话的待审权限请求(HITL inbox)。
|
|
func (h *Handler) listPendingPermissions(w http.ResponseWriter, r *http.Request) {
|
|
c, err := h.caller(r)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
reqs, err := h.permSvc.ListPendingForUser(r.Context(), c)
|
|
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
|
|
}
|
|
modelID, perr := parseOptionalUUID(req.ModelID)
|
|
if perr != nil {
|
|
writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad model_id"))
|
|
return
|
|
}
|
|
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,
|
|
ModelID: modelID,
|
|
MaxCostUSD: req.MaxCostUSD,
|
|
MaxTokens: req.MaxTokens,
|
|
MaxWallClockSeconds: req.MaxWallClockSeconds,
|
|
}
|
|
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
|
|
}
|
|
if req.ModelID != nil {
|
|
in.SetModelID = true
|
|
if *req.ModelID == "" {
|
|
in.ModelID = nil // 显式清空
|
|
} else {
|
|
mid, merr := uuid.Parse(*req.ModelID)
|
|
if merr != nil {
|
|
writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad model_id"))
|
|
return
|
|
}
|
|
in.ModelID = &mid
|
|
}
|
|
}
|
|
if req.MaxCostUSD != nil {
|
|
in.SetMaxCostUSD = true
|
|
in.MaxCostUSD = positiveOrNil(req.MaxCostUSD)
|
|
}
|
|
if req.MaxTokens != nil {
|
|
in.SetMaxTokens = true
|
|
if *req.MaxTokens > 0 {
|
|
in.MaxTokens = req.MaxTokens
|
|
}
|
|
}
|
|
if req.MaxWallClockSeconds != nil {
|
|
in.SetMaxWallClock = true
|
|
if *req.MaxWallClockSeconds > 0 {
|
|
in.MaxWallClockSeconds = req.MaxWallClockSeconds
|
|
}
|
|
}
|
|
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))
|
|
}
|
|
|
|
// getSessionUsage 返回会话累计成本/token + 最近账目(owner 或 admin)。
|
|
func (h *Handler) getSessionUsage(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
|
|
}
|
|
// 复用 sessSvc.Get 做 owner/admin 访问校验(NotFound/Forbidden 由其返回)。
|
|
s, err := h.sessSvc.Get(r.Context(), c, id)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
const recentLimit = 50
|
|
ledger, err := h.repo.ListSessionUsage(r.Context(), id, recentLimit)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
recent := make([]SessionUsageDTO, 0, len(ledger))
|
|
for _, rec := range ledger {
|
|
recent = append(recent, sessionUsageToDTO(rec))
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, SessionUsageResponse{
|
|
SessionID: s.ID.String(),
|
|
PromptTokens: s.PromptTokens,
|
|
CompletionTokens: s.CompletionTokens,
|
|
ThinkingTokens: s.ThinkingTokens,
|
|
TotalCostUSD: s.TotalCostUSD,
|
|
BudgetMaxCostUSD: s.BudgetMaxCostUSD,
|
|
BudgetMaxTokens: s.BudgetMaxTokens,
|
|
Recent: recent,
|
|
})
|
|
}
|
|
|
|
// adminAcpUsage 返回 ACP 用量聚合(admin only),group=user|model|day,镜像 chat。
|
|
func (h *Handler) adminAcpUsage(w http.ResponseWriter, r *http.Request) {
|
|
c, err := h.caller(r)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
if !c.IsAdmin {
|
|
writeErr(w, r, errs.New(errs.CodeForbidden, "admin only"))
|
|
return
|
|
}
|
|
q := r.URL.Query()
|
|
to := time.Now().UTC()
|
|
from := to.AddDate(0, 0, -30)
|
|
if v := q.Get("from"); v != "" {
|
|
if t, e := time.Parse(time.RFC3339, v); e == nil {
|
|
from = t
|
|
}
|
|
}
|
|
if v := q.Get("to"); v != "" {
|
|
if t, e := time.Parse(time.RFC3339, v); e == nil {
|
|
to = t
|
|
}
|
|
}
|
|
groupBy := q.Get("group")
|
|
if groupBy == "" {
|
|
groupBy = q.Get("group_by")
|
|
}
|
|
if groupBy == "" {
|
|
groupBy = "day"
|
|
}
|
|
|
|
items := make([]AcpUsageItem, 0)
|
|
switch groupBy {
|
|
case "user":
|
|
rows, rerr := h.repo.SummarizeAcpUsageByUser(r.Context(), from, to)
|
|
if rerr != nil {
|
|
writeErr(w, r, rerr)
|
|
return
|
|
}
|
|
for _, row := range rows {
|
|
items = append(items, AcpUsageItem{
|
|
Key: row.UserID.String(), PromptTokens: row.PromptTokens,
|
|
CompletionTokens: row.CompletionTokens, ThinkingTokens: row.ThinkingTokens, CostUSD: row.CostUSD,
|
|
})
|
|
}
|
|
case "model":
|
|
rows, rerr := h.repo.SummarizeAcpUsageByModel(r.Context(), from, to)
|
|
if rerr != nil {
|
|
writeErr(w, r, rerr)
|
|
return
|
|
}
|
|
for _, row := range rows {
|
|
items = append(items, AcpUsageItem{
|
|
Key: row.ModelID.String(), PromptTokens: row.PromptTokens,
|
|
CompletionTokens: row.CompletionTokens, ThinkingTokens: row.ThinkingTokens, CostUSD: row.CostUSD,
|
|
})
|
|
}
|
|
case "day":
|
|
rows, rerr := h.repo.SummarizeAcpUsageByDay(r.Context(), from, to)
|
|
if rerr != nil {
|
|
writeErr(w, r, rerr)
|
|
return
|
|
}
|
|
for _, row := range rows {
|
|
items = append(items, AcpUsageItem{
|
|
Key: row.Day.Format(time.RFC3339), PromptTokens: row.PromptTokens,
|
|
CompletionTokens: row.CompletionTokens, ThinkingTokens: row.ThinkingTokens, CostUSD: row.CostUSD,
|
|
})
|
|
}
|
|
default:
|
|
writeErr(w, r, errs.New(errs.CodeInvalidInput, "invalid group"))
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, AcpUsageResponse{Items: items})
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// getDashboard 返回 run-history 汇总(owner scope;admin 全量)。可选 query:
|
|
// project_id / requirement_id / from / to(RFC3339)。
|
|
func (h *Handler) getDashboard(w http.ResponseWriter, r *http.Request) {
|
|
c, err := h.caller(r)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
q := r.URL.Query()
|
|
var in DashboardInput
|
|
if v := q.Get("project_id"); v != "" {
|
|
id, perr := uuid.Parse(v)
|
|
if perr != nil {
|
|
writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad project_id"))
|
|
return
|
|
}
|
|
in.ProjectID = &id
|
|
}
|
|
if v := q.Get("requirement_id"); v != "" {
|
|
id, perr := uuid.Parse(v)
|
|
if perr != nil {
|
|
writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad requirement_id"))
|
|
return
|
|
}
|
|
in.RequirementID = &id
|
|
}
|
|
if v := q.Get("from"); v != "" {
|
|
t, terr := time.Parse(time.RFC3339, v)
|
|
if terr != nil {
|
|
writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad from"))
|
|
return
|
|
}
|
|
in.From = t
|
|
}
|
|
if v := q.Get("to"); v != "" {
|
|
t, terr := time.Parse(time.RFC3339, v)
|
|
if terr != nil {
|
|
writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad to"))
|
|
return
|
|
}
|
|
in.To = t
|
|
}
|
|
res, err := h.sessSvc.Dashboard(r.Context(), c, in)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, dashboardToResponse(res))
|
|
}
|
|
|
|
// getSessionTimeline 返回单会话事件时间线(owner 或 admin)。
|
|
func (h *Handler) getSessionTimeline(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
|
|
}
|
|
buckets, err := h.sessSvc.Timeline(r.Context(), c, id)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
out := make([]SessionTimelineDTO, 0, len(buckets))
|
|
for _, b := range buckets {
|
|
out = append(out, sessionTimelineToDTO(b))
|
|
}
|
|
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
|
|
}
|
|
// 回合相关联:用户从前端发起 session/prompt 时也登记回合,使其完成时
|
|
// 走与 auto-prompt 相同的检测路径。client 自生成的字符串 id 作为相关联键。
|
|
if m.Method == "session/prompt" {
|
|
if tr := relay.Tracker(); tr != nil {
|
|
if pid, ok := m.IDString(); ok && pid != "" {
|
|
if _, err := tr.StartTurn(ctx, pid); err != nil {
|
|
slog.Warn("acp.ws.start_turn", "session_id", sess.ID, "err", err.Error())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
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
|
|
}
|
|
var modelID *string
|
|
if k.ModelID != nil {
|
|
v := k.ModelID.String()
|
|
modelID = &v
|
|
}
|
|
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,
|
|
ModelID: modelID,
|
|
MaxCostUSD: k.MaxCostUSD,
|
|
MaxTokens: k.MaxTokens,
|
|
MaxWallClockSeconds: k.MaxWallClockSeconds,
|
|
CreatedBy: k.CreatedBy,
|
|
CreatedAt: k.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
|
|
UpdatedAt: k.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z"),
|
|
}
|
|
}
|
|
|
|
// parseOptionalUUID 解析一个可选的字符串 UUID。nil/空串 → (nil, nil);非法 → 错误。
|
|
func parseOptionalUUID(s *string) (*uuid.UUID, error) {
|
|
if s == nil || *s == "" {
|
|
return nil, nil
|
|
}
|
|
id, err := uuid.Parse(*s)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &id, nil
|
|
}
|
|
|
|
// positiveOrNil 返回正数指针,否则 nil(用于"值 <=0 视为清空"语义)。
|
|
func positiveOrNil(f *float64) *float64 {
|
|
if f == nil || *f <= 0 {
|
|
return nil
|
|
}
|
|
return f
|
|
}
|
|
|
|
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
|
|
}
|