Files
app-server/modules/agent.go
T
2026-08-07 13:36:56 +08:00

97 lines
2.8 KiB
Go

package modules
import (
"crypto/subtle"
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/spf13/viper"
)
// AgentModule is the app-server-facing control plane for external Agent
// adapters. Message delivery remains in MessageModule; this module makes the
// adapter's liveness and delivery cursor observable without exposing Hermes.
type AgentModule struct {
vp *viper.Viper
mu sync.RWMutex
entries map[string]agentHeartbeat
}
type agentHeartbeat struct {
Status string `json:"status"`
LastMessageSeq uint64 `json:"last_message_seq"`
Detail string `json:"detail,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
type agentHeartbeatRequest struct {
Status string `json:"status" binding:"required"`
LastMessageSeq uint64 `json:"last_message_seq"`
Detail string `json:"detail"`
}
func NewAgentModule(vp *viper.Viper) *AgentModule {
return &AgentModule{vp: vp, entries: make(map[string]agentHeartbeat)}
}
func (m *AgentModule) RegisterRoutes(r *gin.Engine) {
r.GET("/agents/:uid/health", m.handleHealth)
r.POST("/agents/:uid/heartbeat", m.handleHeartbeat)
}
func (m *AgentModule) configuredSecret() string {
return m.vp.GetString("agent.shared_secret")
}
func (m *AgentModule) authorize(c *gin.Context) bool {
secret := m.configuredSecret()
if secret == "" {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Agent heartbeat 未配置 shared_secret"})
return false
}
provided := c.GetHeader("X-LineUp-Agent-Secret")
if subtle.ConstantTimeCompare([]byte(provided), []byte(secret)) != 1 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Agent heartbeat 认证失败"})
return false
}
return true
}
func (m *AgentModule) handleHeartbeat(c *gin.Context) {
if !m.authorize(c) {
return
}
var req agentHeartbeatRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误: status必填"})
return
}
entry := agentHeartbeat{
Status: req.Status, LastMessageSeq: req.LastMessageSeq, Detail: req.Detail, UpdatedAt: time.Now().UTC(),
}
m.mu.Lock()
m.entries[c.Param("uid")] = entry
m.mu.Unlock()
c.JSON(http.StatusOK, gin.H{"status": "ok", "updated_at": entry.UpdatedAt})
}
func (m *AgentModule) handleHealth(c *gin.Context) {
uid := c.Param("uid")
m.mu.RLock()
entry, found := m.entries[uid]
m.mu.RUnlock()
if !found {
c.JSON(http.StatusOK, gin.H{"uid": uid, "status": "offline", "known": false})
return
}
// A worker normally reports once per poll. Keep a generous threshold so a
// slow Hermes turn does not look dead while it is still being supervised.
online := time.Since(entry.UpdatedAt) <= 45*time.Second
c.JSON(http.StatusOK, gin.H{
"uid": uid, "status": entry.Status, "known": true, "online": online,
"last_message_seq": entry.LastMessageSeq, "detail": entry.Detail, "updated_at": entry.UpdatedAt,
})
}