// 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), } }