You've already forked agentic-coding-workflow
bugfix
This commit is contained in:
+329
-25
@@ -6,6 +6,7 @@ package acp
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
@@ -83,12 +84,27 @@ func (h *Handler) Mount(r chi.Router) {
|
||||
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)。
|
||||
@@ -115,6 +131,25 @@ func (h *Handler) listSessionPermissions(w http.ResponseWriter, r *http.Request)
|
||||
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"`
|
||||
}
|
||||
@@ -205,17 +240,26 @@ func (h *Handler) createAgentKind(w http.ResponseWriter, r *http.Request) {
|
||||
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,
|
||||
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 {
|
||||
@@ -278,6 +322,35 @@ func (h *Handler) updateAgentKind(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
@@ -457,6 +530,123 @@ func (h *Handler) getSession(w http.ResponseWriter, r *http.Request) {
|
||||
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 {
|
||||
@@ -519,6 +709,80 @@ func (h *Handler) getEvents(w http.ResponseWriter, r *http.Request) {
|
||||
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) {
|
||||
@@ -675,6 +939,17 @@ func (h *Handler) sessionWS(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
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",
|
||||
@@ -702,22 +977,51 @@ func (h *Handler) agentKindToAdminDTO(k *AgentKind) AgentKindAdminDTO {
|
||||
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"),
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user