Files
agentic-coding-workflow/internal/acp/handler.go
T
2026-06-22 08:55:57 +08:00

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
}