CUB-200: implement WebSocket gateway client with v3 protocol
Replace REST poller with WebSocket client as primary gateway connection: - wsclient.go: WebSocket client with v3 handshake (connect.challenge → connect → hello-ok), frame routing (req/res/event), JSON-RPC Send(), auto-reconnect with exponential backoff (1s → 30s max) - sync.go: Initial sync via agents.list + sessions.list RPCs, merge session runtime state into AgentCardData, broadcast fleet.update - events.go: Real-time event handlers for sessions.changed, presence, and agent.config — DB update first, then SSE broadcast - client.go: REST poller retained as fallback (WS is primary) - config.go: Add GATEWAY_WS_URL and OPENCLAW_GATEWAY_TOKEN env vars - main.go: Wire WS client as primary, REST as fallback - .env.example: Document new WS config vars Fallback: If WS connection fails, seeded demo data + REST polling remain available.
This commit is contained in:
187
go-backend/internal/gateway/sync.go
Normal file
187
go-backend/internal/gateway/sync.go
Normal file
@@ -0,0 +1,187 @@
|
||||
// Package gateway provides the initial sync logic that fetches agent and
|
||||
// session data from the OpenClaw gateway via WS RPCs after handshake,
|
||||
// persists to the repository, merges session state into agent cards, and
|
||||
// broadcasts the merged fleet to SSE clients.
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
|
||||
)
|
||||
|
||||
// ── RPC response types ───────────────────────────────────────────────────
|
||||
|
||||
// agentListItem represents a single agent returned by the agents.list RPC.
|
||||
type agentListItem struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Model string `json:"model"`
|
||||
Role string `json:"role"`
|
||||
Channel string `json:"channel"`
|
||||
Metadata json.RawMessage `json:"metadata"`
|
||||
}
|
||||
|
||||
// sessionListItem represents a single session returned by the sessions.list RPC.
|
||||
type sessionListItem struct {
|
||||
SessionKey string `json:"sessionKey"`
|
||||
AgentID string `json:"agentId"`
|
||||
Status string `json:"status"` // running, done, streaming, error
|
||||
TotalTokens int `json:"totalTokens"`
|
||||
LastActivityAt string `json:"lastActivityAt"`
|
||||
}
|
||||
|
||||
// ── Sync logic ──────────────────────────────────────────────────────────
|
||||
|
||||
// initialSync fetches agents and sessions from the gateway via WS RPCs,
|
||||
// persists them, merges session state into agent cards, and broadcasts
|
||||
// the merged fleet as a fleet.update event.
|
||||
func (c *WSClient) initialSync(ctx context.Context) error {
|
||||
c.logger.Info("initial sync starting")
|
||||
|
||||
// 1. Fetch agents via RPC
|
||||
agentsRaw, err := c.Send("agents.list", nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("agents.list RPC: %w", err)
|
||||
}
|
||||
|
||||
var agentItems []agentListItem
|
||||
if err := json.Unmarshal(agentsRaw, &agentItems); err != nil {
|
||||
return fmt.Errorf("parse agents.list response: %w", err)
|
||||
}
|
||||
|
||||
c.logger.Info("agents.list received", "count", len(agentItems))
|
||||
|
||||
// 2. Persist each agent (create if not exists, update if changed)
|
||||
for _, item := range agentItems {
|
||||
card := agentItemToCard(item)
|
||||
|
||||
existing, err := c.agents.Get(ctx, card.ID)
|
||||
if err != nil {
|
||||
// Agent doesn't exist — create it
|
||||
if createErr := c.agents.Create(ctx, card); createErr != nil {
|
||||
c.logger.Warn("sync: agent create failed", "id", card.ID, "error", createErr)
|
||||
continue
|
||||
}
|
||||
c.logger.Info("sync: agent created", "id", card.ID)
|
||||
continue
|
||||
}
|
||||
|
||||
// Agent exists — update display name or role if changed
|
||||
if existing.DisplayName != card.DisplayName || existing.Role != card.Role {
|
||||
// Update what we can via UpdateAgentRequest
|
||||
channel := card.Channel
|
||||
_, updateErr := c.agents.Update(ctx, card.ID, models.UpdateAgentRequest{
|
||||
Channel: &channel,
|
||||
})
|
||||
if updateErr != nil {
|
||||
c.logger.Warn("sync: agent update failed", "id", card.ID, "error", updateErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fetch sessions via RPC
|
||||
sessionsRaw, err := c.Send("sessions.list", nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("sessions.list RPC: %w", err)
|
||||
}
|
||||
|
||||
var sessionItems []sessionListItem
|
||||
if err := json.Unmarshal(sessionsRaw, &sessionItems); err != nil {
|
||||
return fmt.Errorf("parse sessions.list response: %w", err)
|
||||
}
|
||||
|
||||
c.logger.Info("sessions.list received", "count", len(sessionItems))
|
||||
|
||||
// 4. Build agentId → session map for merge
|
||||
sessionByAgent := make(map[string]sessionListItem)
|
||||
for _, s := range sessionItems {
|
||||
if s.AgentID != "" {
|
||||
sessionByAgent[s.AgentID] = s
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Merge session state into agents, update DB, and collect for broadcast
|
||||
mergedAgents := make([]models.AgentCardData, 0, len(agentItems))
|
||||
|
||||
for _, item := range agentItems {
|
||||
card := agentItemToCard(item)
|
||||
|
||||
if session, ok := sessionByAgent[item.ID]; ok {
|
||||
// Merge session state into agent card
|
||||
card.SessionKey = session.SessionKey
|
||||
card.Status = mapSessionStatus(session.Status)
|
||||
card.LastActivity = session.LastActivityAt
|
||||
|
||||
if session.TotalTokens > 0 {
|
||||
prog := min(session.TotalTokens/100, 100)
|
||||
card.TaskProgress = &prog
|
||||
}
|
||||
}
|
||||
|
||||
// Persist merged status change
|
||||
existing, err := c.agents.Get(ctx, card.ID)
|
||||
if err == nil && existing.Status != card.Status {
|
||||
status := card.Status
|
||||
_, updateErr := c.agents.Update(ctx, card.ID, models.UpdateAgentRequest{
|
||||
Status: &status,
|
||||
})
|
||||
if updateErr != nil {
|
||||
c.logger.Warn("sync: agent status update failed", "id", card.ID, "error", updateErr)
|
||||
}
|
||||
}
|
||||
|
||||
mergedAgents = append(mergedAgents, card)
|
||||
}
|
||||
|
||||
// 6. Broadcast the full merged fleet
|
||||
c.broker.Broadcast("fleet.update", mergedAgents)
|
||||
c.logger.Info("initial sync complete", "agents", len(mergedAgents))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// mapSessionStatus converts a gateway session status string to an AgentStatus.
|
||||
// - "running" / "streaming" → active
|
||||
// - "error" → error
|
||||
// - "done" / "" / other → idle
|
||||
func mapSessionStatus(status string) models.AgentStatus {
|
||||
switch status {
|
||||
case "running", "streaming":
|
||||
return models.AgentStatusActive
|
||||
case "error":
|
||||
return models.AgentStatusError
|
||||
default:
|
||||
return models.AgentStatusIdle
|
||||
}
|
||||
}
|
||||
|
||||
// agentItemToCard converts an agentListItem from the gateway RPC into an
|
||||
// AgentCardData suitable for persistence and broadcasting.
|
||||
func agentItemToCard(item agentListItem) models.AgentCardData {
|
||||
role := item.Role
|
||||
if role == "" {
|
||||
role = "agent"
|
||||
}
|
||||
channel := item.Channel
|
||||
if channel == "" {
|
||||
channel = "discord"
|
||||
}
|
||||
name := item.Name
|
||||
if name == "" {
|
||||
name = item.ID
|
||||
}
|
||||
|
||||
return models.AgentCardData{
|
||||
ID: item.ID,
|
||||
DisplayName: name,
|
||||
Role: role,
|
||||
Status: models.AgentStatusIdle, // default; overridden by session merge
|
||||
SessionKey: "",
|
||||
Channel: channel,
|
||||
LastActivity: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user