Files
Duy /zuey/andGitHub 43049d3b3f feat(http): unblock CLI P6 backend APIs (#44)
* feat(api): unblock trace follow and provider reconnect

* test(cron): stabilize run log execution test

* feat(http): add CLI P6 backend endpoints
2026-05-22 20:59:47 +07:00

268 lines
8.5 KiB
Go

package http
import (
"encoding/json"
"errors"
"net/http"
"slices"
"strconv"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/permissions"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/sessions"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
// SessionsHandler exposes read-only HTTP session discovery for automation.
type SessionsHandler struct {
sessions store.SessionStore
ownerIDs []string
}
func NewSessionsHandler(s store.SessionStore, ownerIDs []string) *SessionsHandler {
return &SessionsHandler{sessions: s, ownerIDs: ownerIDs}
}
func (h *SessionsHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /v1/sessions", requireAuth("", h.handleList))
mux.HandleFunc("POST /v1/chat/sessions/{key}/branch", requireAuth("", h.handleBranch))
mux.HandleFunc("GET /v1/chat/sessions/{key}/history/follow", requireAuth("", h.handleHistoryFollow))
}
func (h *SessionsHandler) handleList(w http.ResponseWriter, r *http.Request) {
locale := store.LocaleFromContext(r.Context())
userID := store.UserIDFromContext(r.Context())
role := permissions.Role(store.RoleFromContext(r.Context()))
limit := parsePositiveInt(r.URL.Query().Get("limit"), 20)
offset := parseNonNegativeInt(r.URL.Query().Get("offset"), 0)
opts := store.SessionListOpts{
AgentID: firstSessionQueryValue(r.URL.Query().Get("agent_id"), r.URL.Query().Get("agentId")),
Channel: r.URL.Query().Get("channel"),
Limit: limit,
Offset: offset,
TenantID: store.TenantIDFromContext(r.Context()),
}
if !canSeeAllHTTP(role, h.ownerIDs, userID) {
if userID == "" {
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgUserIDHeader))
return
}
opts.UserID = userID
}
result := h.sessions.ListPagedRich(r.Context(), opts)
writeJSON(w, http.StatusOK, map[string]any{
"sessions": result.Sessions,
"total": result.Total,
"limit": limit,
"offset": offset,
})
}
func (h *SessionsHandler) handleBranch(w http.ResponseWriter, r *http.Request) {
locale := store.LocaleFromContext(r.Context())
sourceKey := r.PathValue("key")
sourceAgent, _ := sessions.ParseSessionKey(sourceKey)
if sourceAgent == "" {
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, "invalid session key"))
return
}
source := h.sessions.Get(r.Context(), sourceKey)
if source == nil {
writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "session", sourceKey))
return
}
if !h.canAccessSession(r, source) {
writeError(w, http.StatusForbidden, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "session"))
return
}
var body struct {
NewSessionKey string `json:"new_session_key"`
UpToIndex *int `json:"up_to_index"`
Label string `json:"label"`
Metadata map[string]string `json:"metadata"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON))
return
}
if body.UpToIndex == nil {
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "up_to_index"))
return
}
newKey := body.NewSessionKey
if newKey == "" {
newKey = "agent:" + sourceAgent + ":branch:direct:" + uuid.NewString()
} else if targetAgent, _ := sessions.ParseSessionKey(newKey); targetAgent == "" || targetAgent != sourceAgent {
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, "new_session_key must use the same agent key"))
return
}
brancher, ok := h.sessions.(store.SessionBranchStore)
if !ok {
writeError(w, http.StatusServiceUnavailable, protocol.ErrInternal, i18n.T(locale, i18n.MsgInvalidRequest, "session branching unavailable"))
return
}
branch, copied, err := brancher.BranchSession(r.Context(), sourceKey, store.SessionBranchOpts{
NewKey: newKey,
UpToIndex: *body.UpToIndex,
Label: body.Label,
Metadata: body.Metadata,
})
if err != nil {
switch {
case errors.Is(err, store.ErrSessionAlreadyExists):
writeError(w, http.StatusConflict, protocol.ErrFailedPrecondition, i18n.T(locale, i18n.MsgAlreadyExists, "session", newKey))
case errors.Is(err, store.ErrInvalidSessionBranch):
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, "up_to_index out of range"))
case errors.Is(err, store.ErrSessionNotFound):
writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "session", sourceKey))
default:
writeError(w, http.StatusInternalServerError, protocol.ErrInternal, err.Error())
}
return
}
writeJSON(w, http.StatusCreated, map[string]any{
"ok": true,
"source_key": sourceKey,
"session_key": branch.Key,
"copied_messages": copied,
"total_messages": len(source.Messages),
"label": branch.Label,
})
}
func (h *SessionsHandler) handleHistoryFollow(w http.ResponseWriter, r *http.Request) {
locale := store.LocaleFromContext(r.Context())
sessionKey := r.PathValue("key")
sess := h.sessions.Get(r.Context(), sessionKey)
if sess == nil {
writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "session", sessionKey))
return
}
if !h.canAccessSession(r, sess) {
writeError(w, http.StatusForbidden, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "session"))
return
}
cursor, ok := parseStrictNonNegativeInt(r.URL.Query().Get("cursor"), 0)
if !ok {
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, "cursor must be a non-negative integer"))
return
}
limit, ok := parseStrictPositiveInt(r.URL.Query().Get("limit"), 50, 200)
if !ok {
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, "limit must be a positive integer"))
return
}
history := h.sessions.GetHistory(r.Context(), sessionKey)
total := len(history)
if cursor > total {
writeJSON(w, http.StatusOK, map[string]any{
"session_key": sessionKey,
"cursor": cursor,
"next_cursor": total,
"total": total,
"messages": []any{},
"reset": true,
"updated": sess.Updated.UTC().Format(time.RFC3339Nano),
})
return
}
end := cursor + limit
if end > total {
end = total
}
messages := append([]providers.Message(nil), history[cursor:end]...)
secret := FileSigningKey()
for i := range messages {
messages[i].Content = SignFileURLs(messages[i].Content, secret)
for j := range messages[i].MediaRefs {
messages[i].MediaRefs[j].Path = SignMediaPath(messages[i].MediaRefs[j].Path, secret)
}
}
writeJSON(w, http.StatusOK, map[string]any{
"session_key": sessionKey,
"cursor": cursor,
"next_cursor": end,
"total": total,
"messages": messages,
"reset": false,
"updated": sess.Updated.UTC().Format(time.RFC3339Nano),
})
}
func (h *SessionsHandler) canAccessSession(r *http.Request, sess *store.SessionData) bool {
userID := store.UserIDFromContext(r.Context())
role := permissions.Role(store.RoleFromContext(r.Context()))
return canSeeAllHTTP(role, h.ownerIDs, userID) || (userID != "" && sess.UserID == userID)
}
func canSeeAllHTTP(role permissions.Role, ownerIDs []string, userID string) bool {
if permissions.HasMinRole(role, permissions.RoleAdmin) {
return true
}
return userID != "" && slices.Contains(ownerIDs, userID)
}
func parsePositiveInt(raw string, fallback int) int {
n, err := strconv.Atoi(raw)
if err != nil || n <= 0 {
return fallback
}
return n
}
func parseStrictPositiveInt(raw string, fallback, maxValue int) (int, bool) {
if raw == "" {
return fallback, true
}
n, err := strconv.Atoi(raw)
if err != nil || n <= 0 {
return 0, false
}
if n > maxValue {
n = maxValue
}
return n, true
}
func parseStrictNonNegativeInt(raw string, fallback int) (int, bool) {
if raw == "" {
return fallback, true
}
n, err := strconv.Atoi(raw)
if err != nil || n < 0 {
return 0, false
}
return n, true
}
func parseNonNegativeInt(raw string, fallback int) int {
n, err := strconv.Atoi(raw)
if err != nil || n < 0 {
return fallback
}
return n
}
func firstSessionQueryValue(values ...string) string {
for _, v := range values {
if v != "" {
return v
}
}
return ""
}