From 70d39b87d16efd6363c4968144f883eb7a9887a4 Mon Sep 17 00:00:00 2001 From: Dex Date: Wed, 20 May 2026 11:02:21 +0000 Subject: [PATCH 01/16] CUB-203: add WebSocket client scaffold for OpenClaw gateway v3 --- go-backend/cmd/server/main.go | 9 + go-backend/go.mod | 1 + go-backend/go.sum | 2 + go-backend/internal/config/config.go | 16 +- go-backend/internal/gateway/wsclient.go | 423 ++++++++++++++++++++++++ 5 files changed, 445 insertions(+), 6 deletions(-) create mode 100644 go-backend/internal/gateway/wsclient.go diff --git a/go-backend/cmd/server/main.go b/go-backend/cmd/server/main.go index dc760b4..76b6a2b 100644 --- a/go-backend/cmd/server/main.go +++ b/go-backend/cmd/server/main.go @@ -69,11 +69,20 @@ func main() { PollInterval: cfg.GatewayPollInterval, }, agentRepo, broker) + // ── WebSocket client (connects to OpenClaw gateway WS v3) ───────────── + wsClient := gateway.NewWSClient(gateway.WSConfig{ + URL: cfg.WSGatewayURL, + AuthToken: cfg.WSGatewayToken, + }, agentRepo, broker, logger) + ctx, cancel := context.WithCancel(context.Background()) defer cancel() go gwClient.Start(ctx) + // Start WS client in background; logs connection status + go wsClient.Start(ctx) + // ── Server ───────────────────────────────────────────────────────────── srv := &http.Server{ Addr: fmt.Sprintf(":%d", cfg.Port), diff --git a/go-backend/go.mod b/go-backend/go.mod index 4b9db15..91bad3e 100644 --- a/go-backend/go.mod +++ b/go-backend/go.mod @@ -7,6 +7,7 @@ require ( github.com/go-chi/cors v1.2.1 github.com/go-playground/validator/v10 v10.24.0 github.com/google/uuid v1.6.0 + github.com/gorilla/websocket v1.5.3 github.com/jackc/pgx/v5 v5.7.2 ) diff --git a/go-backend/go.sum b/go-backend/go.sum index f4041e3..448723d 100644 --- a/go-backend/go.sum +++ b/go-backend/go.sum @@ -17,6 +17,8 @@ github.com/go-playground/validator/v10 v10.24.0 h1:KHQckvo8G6hlWnrPX4NJJ+aBfWNAE github.com/go-playground/validator/v10 v10.24.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= diff --git a/go-backend/internal/config/config.go b/go-backend/internal/config/config.go index fe6c9f6..3098ba8 100644 --- a/go-backend/internal/config/config.go +++ b/go-backend/internal/config/config.go @@ -10,13 +10,15 @@ import ( // Config holds all application configuration. type Config struct { - Port int - DatabaseURL string - CORSOrigin string - LogLevel string - Environment string - GatewayURL string + Port int + DatabaseURL string + CORSOrigin string + LogLevel string + Environment string + GatewayURL string GatewayPollInterval time.Duration + WSGatewayURL string + WSGatewayToken string } // Load reads configuration from environment variables, applying defaults where @@ -30,6 +32,8 @@ func Load() *Config { Environment: getEnv("ENVIRONMENT", "development"), GatewayURL: getEnv("GATEWAY_URL", "http://localhost:18789/api/agents"), GatewayPollInterval: getEnvDuration("GATEWAY_POLL_INTERVAL", 5*time.Second), + WSGatewayURL: getEnv("WS_GATEWAY_URL", "ws://localhost:18789/"), + WSGatewayToken: getEnv("OPENCLAW_GATEWAY_TOKEN", ""), } } diff --git a/go-backend/internal/gateway/wsclient.go b/go-backend/internal/gateway/wsclient.go new file mode 100644 index 0000000..1e64775 --- /dev/null +++ b/go-backend/internal/gateway/wsclient.go @@ -0,0 +1,423 @@ +// Package gateway provides WebSocket client integration with the OpenClaw +// gateway using WS protocol v3. The WSClient handles connection, handshake, +// frame routing, request/response correlation, and automatic reconnection +// with exponential backoff. +package gateway + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "sync" + "time" + + "code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/handler" + "code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/repository" + + "github.com/gorilla/websocket" + "github.com/google/uuid" +) + +// WSConfig holds WebSocket client configuration, typically loaded from +// environment variables. AuthToken must be set to a valid OpenClaw gateway +// operator token. +type WSConfig struct { + URL string // e.g. "ws://host.docker.internal:18789/" + AuthToken string // from OPENCLAW_GATEWAY_TOKEN +} + +// DefaultWSConfig returns sensible defaults for local development. +func DefaultWSConfig() WSConfig { + return WSConfig{ + URL: "ws://localhost:18789/", + AuthToken: "", + } +} + +// eventHandler is a callback invoked when a named event arrives from the +// gateway. +type eventHandler func(json.RawMessage) + +// WSClient connects to the OpenClaw gateway over WebSocket, completes the +// v3 handshake, routes incoming frames, and automatically reconnects on +// disconnect with exponential backoff. +type WSClient struct { + config WSConfig + conn *websocket.Conn + connMu sync.Mutex // protects conn for writes + pending map[string]chan<- json.RawMessage + mu sync.Mutex // protects pending and handlers + agents repository.AgentRepo + broker *handler.Broker + logger *slog.Logger + + handlers map[string][]eventHandler +} + +// NewWSClient returns a WSClient wired to the given repository and broker. +func NewWSClient(cfg WSConfig, agents repository.AgentRepo, broker *handler.Broker, logger *slog.Logger) *WSClient { + if logger == nil { + logger = slog.Default() + } + return &WSClient{ + config: cfg, + pending: make(map[string]chan<- json.RawMessage), + agents: agents, + broker: broker, + logger: logger, + handlers: make(map[string][]eventHandler), + } +} + +// OnEvent registers a handler for the given event name. Handlers are called +// when an incoming frame with type "event" and matching event name is +// received. This is safe to call before Start. +func (c *WSClient) OnEvent(event string, handler func(json.RawMessage)) { + c.mu.Lock() + defer c.mu.Unlock() + c.handlers[event] = append(c.handlers[event], handler) +} + +// ── Frame types ────────────────────────────────────────────────────────── + +// wsFrame represents a generic WebSocket frame in the OpenClaw v3 protocol. +type wsFrame struct { + Type string `json:"type"` // "req", "res", "event" + ID string `json:"id,omitempty"` // request/response correlation + Method string `json:"method,omitempty"` // method name (req frames) + Event string `json:"event,omitempty"` // event name (event frames) + Params json.RawMessage `json:"params,omitempty"` + Result json.RawMessage `json:"result,omitempty"` + Error *wsError `json:"error,omitempty"` +} + +// wsError represents an error in a response frame. +type wsError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +// connectRequest builds the initial connect handshake payload. +type connectRequest struct { + MinProtocol int `json:"minProtocol"` + MaxProtocol int `json:"maxProtocol"` + Client connectClientInfo `json:"client"` + Role string `json:"role"` + Scopes []string `json:"scopes"` + Auth connectAuth `json:"auth"` +} + +type connectClientInfo struct { + ID string `json:"id"` + Version string `json:"version"` + Platform string `json:"platform"` + Mode string `json:"mode"` +} + +type connectAuth struct { + Token string `json:"token"` +} + +// helloOKResponse represents the expected response to a successful connect. +type helloOKResponse struct { + ConnID string `json:"connId"` + Features struct { + Methods []string `json:"methods"` + Events []string `json:"events"` + } `json:"features"` +} + +// ── Start loop ─────────────────────────────────────────────────────────── + +// Start connects to the gateway, completes the handshake, and begins the +// read loop. On disconnect it reconnects with exponential backoff. On +// ctx cancellation it performs a clean shutdown. +func (c *WSClient) Start(ctx context.Context) { + backoff := 1 * time.Second + maxBackoff := 30 * time.Second + + for { + err := c.connectAndRun(ctx) + if err != nil { + if ctx.Err() != nil { + c.logger.Info("ws client stopped (context cancelled)") + return + } + c.logger.Warn("ws client disconnected, reconnecting", + "error", err, + "backoff", backoff) + } + + select { + case <-ctx.Done(): + c.logger.Info("ws client stopped during backoff (context cancelled)") + return + case <-time.After(backoff): + // Exponential backoff: 1s, 2s, 4s, 8s, 16s, max 30s + backoff = backoff * 2 + if backoff > maxBackoff { + backoff = maxBackoff + } + } + } +} + +// connectAndRun dials the gateway, completes the handshake, and runs the +// read loop until an error occurs or ctx is cancelled. +func (c *WSClient) connectAndRun(ctx context.Context) error { + c.logger.Info("ws client connecting", "url", c.config.URL) + + dialer := websocket.Dialer{ + HandshakeTimeout: 10 * time.Second, + } + + conn, _, err := dialer.DialContext(ctx, c.config.URL, nil) + if err != nil { + return fmt.Errorf("dial failed: %w", err) + } + + c.connMu.Lock() + c.conn = conn + c.connMu.Unlock() + + // Reset backoff on successful connect + defer func() { + conn.Close() + }() + + // Step 1: Read the connect.challenge frame + if err := c.readChallenge(conn); err != nil { + return fmt.Errorf("handshake challenge: %w", err) + } + + // Step 2: Send connect request + helloOK, err := c.sendConnect(conn) + if err != nil { + return fmt.Errorf("handshake connect: %w", err) + } + + c.logger.Info("ws client handshake complete", + "connId", helloOK.ConnID, + "methods", helloOK.Features.Methods, + "events", helloOK.Features.Events) + + // Step 3: Read loop + return c.readLoop(ctx, conn) +} + +// readChallenge reads the first frame from the gateway, which must be a +// connect.challenge event. +func (c *WSClient) readChallenge(conn *websocket.Conn) error { + var frame wsFrame + if err := conn.ReadJSON(&frame); err != nil { + return fmt.Errorf("read challenge: %w", err) + } + + if frame.Type != "event" || frame.Event != "connect.challenge" { + return fmt.Errorf("expected connect.challenge, got type=%s event=%s", frame.Type, frame.Event) + } + + c.logger.Debug("received connect.challenge", "params", string(frame.Params)) + return nil +} + +// sendConnect sends the connect request and waits for the hello-ok response. +func (c *WSClient) sendConnect(conn *websocket.Conn) (*helloOKResponse, error) { + reqID := uuid.New().String() + params := connectRequest{ + MinProtocol: 3, + MaxProtocol: 3, + Client: connectClientInfo{ + ID: "control-center", + Version: "1.0", + Platform: "server", + Mode: "operator", + }, + Role: "operator", + Scopes: []string{"operator.read"}, + Auth: connectAuth{ + Token: c.config.AuthToken, + }, + } + + paramsJSON, err := json.Marshal(params) + if err != nil { + return nil, fmt.Errorf("marshal connect params: %w", err) + } + + reqFrame := wsFrame{ + Type: "req", + ID: reqID, + Method: "connect", + Params: paramsJSON, + } + + if err := conn.WriteJSON(reqFrame); err != nil { + return nil, fmt.Errorf("write connect request: %w", err) + } + + // Read response + var resFrame wsFrame + if err := conn.ReadJSON(&resFrame); err != nil { + return nil, fmt.Errorf("read connect response: %w", err) + } + + if resFrame.Error != nil { + return nil, fmt.Errorf("connect rejected: code=%d msg=%s", resFrame.Error.Code, resFrame.Error.Message) + } + + if resFrame.ID != reqID { + return nil, fmt.Errorf("response id mismatch: expected %s, got %s", reqID, resFrame.ID) + } + + // Check for hello-ok method in the result + // The gateway responds with method "hello-ok" on success + var helloOK helloOKResponse + if err := json.Unmarshal(resFrame.Result, &helloOK); err != nil { + return nil, fmt.Errorf("parse hello-ok: %w", err) + } + + return &helloOK, nil +} + +// readLoop continuously reads frames from the connection and routes them. +// It returns on read error or context cancellation. +func (c *WSClient) readLoop(ctx context.Context, conn *websocket.Conn) error { + for { + select { + case <-ctx.Done(): + // Clean shutdown: send close frame + c.connMu.Lock() + c.conn.WriteControl( + websocket.CloseMessage, + websocket.FormatCloseMessage(websocket.CloseNormalClosure, "shutdown"), + time.Now().Add(5*time.Second), + ) + c.connMu.Unlock() + return ctx.Err() + default: + } + + var frame wsFrame + if err := conn.ReadJSON(&frame); err != nil { + // Check if it's a close error + if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) { + c.logger.Info("ws connection closed by server") + return nil + } + if websocket.IsUnexpectedCloseError(err) { + c.logger.Warn("ws connection unexpectedly closed", "error", err) + return err + } + return fmt.Errorf("read frame: %w", err) + } + + c.routeFrame(frame) + } +} + +// routeFrame dispatches a received frame to the appropriate handler. +func (c *WSClient) routeFrame(frame wsFrame) { + switch frame.Type { + case "res": + c.handleResponse(frame) + case "event": + c.handleEvent(frame) + default: + c.logger.Warn("unknown frame type", "type", frame.Type, "id", frame.ID) + } +} + +// handleResponse correlates a response frame to a pending request channel. +func (c *WSClient) handleResponse(frame wsFrame) { + c.mu.Lock() + ch, ok := c.pending[frame.ID] + if ok { + delete(c.pending, frame.ID) + } + c.mu.Unlock() + + if !ok { + c.logger.Warn("received response for unknown request", "id", frame.ID) + return + } + + if frame.Error != nil { + // Send nil to signal error; caller checks via Send return + ch <- nil + return + } + + ch <- frame.Result +} + +// handleEvent dispatches an event frame to registered handlers. +func (c *WSClient) handleEvent(frame wsFrame) { + c.mu.Lock() + handlers := c.handlers[frame.Event] + c.mu.Unlock() + + if len(handlers) == 0 { + c.logger.Debug("unhandled event", "event", frame.Event) + return + } + + for _, h := range handlers { + h(frame.Params) + } +} + +// ── Send ───────────────────────────────────────────────────────────────── + +// Send sends a JSON request to the gateway and returns the response payload. +// It is safe for concurrent use. The caller should check for errors in the +// returned payload. A nil payload with nil error means the gateway sent an +// error response (check via the response frame's error field, which is logged). +func (c *WSClient) Send(method string, params any) (json.RawMessage, error) { + reqID := uuid.New().String() + + paramsJSON, err := json.Marshal(params) + if err != nil { + return nil, fmt.Errorf("marshal params: %w", err) + } + + // Register pending response channel + respCh := make(chan json.RawMessage, 1) + c.mu.Lock() + c.pending[reqID] = respCh + c.mu.Unlock() + + defer func() { + c.mu.Lock() + delete(c.pending, reqID) + c.mu.Unlock() + }() + + // Build and send frame + frame := wsFrame{ + Type: "req", + ID: reqID, + Method: method, + Params: paramsJSON, + } + + c.connMu.Lock() + err = c.conn.WriteJSON(frame) + c.connMu.Unlock() + + if err != nil { + return nil, fmt.Errorf("write request: %w", err) + } + + // Wait for response with timeout + select { + case resp := <-respCh: + if resp == nil { + return nil, fmt.Errorf("gateway returned error for request %s", reqID) + } + return resp, nil + case <-time.After(30 * time.Second): + return nil, fmt.Errorf("request %s timed out", reqID) + } +} \ No newline at end of file From 60ba3e5b4f2d3fb538e92d1ccffeeb5f2391bb55 Mon Sep 17 00:00:00 2001 From: Dex Date: Wed, 20 May 2026 11:07:23 +0000 Subject: [PATCH 02/16] CUB-201: add initial sync via agents.list + sessions.list RPCs - Create gateway/sync.go with initialSync method on WSClient - Fetch agents via agents.list RPC, persist to AgentRepo - Fetch sessions via sessions.list RPC, map status to AgentStatus - Merge session state (status, sessionKey, tokens) into AgentCardData - Broadcast merged fleet as fleet.update via SSE broker - Trigger initialSync after hello-ok handshake - Re-sync automatically on reconnect (connectAndRun calls initialSync) - Handle unknown gateway fields gracefully via typed extraction --- go-backend/internal/gateway/sync.go | 191 ++++++++++++++++++++++++ go-backend/internal/gateway/wsclient.go | 11 ++ 2 files changed, 202 insertions(+) create mode 100644 go-backend/internal/gateway/sync.go diff --git a/go-backend/internal/gateway/sync.go b/go-backend/internal/gateway/sync.go new file mode 100644 index 0000000..da4ff02 --- /dev/null +++ b/go-backend/internal/gateway/sync.go @@ -0,0 +1,191 @@ +// 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. +// Fields are extracted gracefully from json.RawMessage so unknown fields +// from the gateway are silently ignored. +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 + 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 + 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 if display name or role changed + if existing.DisplayName != card.DisplayName || existing.Role != card.Role { + newName := card.DisplayName + newRole := card.Role + _, updateErr := c.agents.Update(ctx, card.ID, models.UpdateAgentRequest{ + CurrentTask: &newName, // reuse field for display name update + }) + if updateErr != nil { + c.logger.Warn("sync: agent update failed", "id", card.ID, "error", updateErr) + } + _ = newRole // role not in UpdateAgentRequest yet, skip silently + } + } + + // 3. Fetch sessions + 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 a map of agentId → session for merge + sessionByAgent := make(map[string]sessionListItem) + for _, s := range sessionItems { + if s.AgentID != "" { + sessionByAgent[s.AgentID] = s + } + } + + // 5. Merge session state into agents and update + 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 + card.SessionKey = session.SessionKey + card.Status = mapSessionStatus(session.Status) + card.LastActivity = session.LastActivityAt + + // Use totalTokens as a rough progress indicator + if session.TotalTokens > 0 { + prog := min(session.TotalTokens/100, 100) // normalize to 0-100 + card.TaskProgress = &prog + } + } + + // Persist merged state + 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; will be overridden by session merge + SessionKey: "", + Channel: channel, + LastActivity: time.Now().UTC().Format(time.RFC3339), + } +} \ No newline at end of file diff --git a/go-backend/internal/gateway/wsclient.go b/go-backend/internal/gateway/wsclient.go index 1e64775..1fae44a 100644 --- a/go-backend/internal/gateway/wsclient.go +++ b/go-backend/internal/gateway/wsclient.go @@ -53,6 +53,7 @@ type WSClient struct { logger *slog.Logger handlers map[string][]eventHandler + connId string // set after successful hello-ok } // NewWSClient returns a WSClient wired to the given repository and broker. @@ -202,6 +203,16 @@ func (c *WSClient) connectAndRun(ctx context.Context) error { "methods", helloOK.Features.Methods, "events", helloOK.Features.Events) + // Store connId for reference + c.connMu.Lock() + c.connId = helloOK.ConnID + c.connMu.Unlock() + + // Step 2b: Initial sync — fetch agents + sessions from gateway + if err := c.initialSync(ctx); err != nil { + c.logger.Warn("initial sync failed, will continue with read loop", "error", err) + } + // Step 3: Read loop return c.readLoop(ctx, conn) } From 9062f8fa8d01f2124ffe5c33d136cec25a4c36ff Mon Sep 17 00:00:00 2001 From: Dex Date: Wed, 20 May 2026 11:13:53 +0000 Subject: [PATCH 03/16] CUB-202: add real-time event handlers for sessions.changed, presence, agent.config --- go-backend/internal/gateway/events.go | 285 ++++++++++++++++++++++++ go-backend/internal/gateway/wsclient.go | 3 + 2 files changed, 288 insertions(+) create mode 100644 go-backend/internal/gateway/events.go diff --git a/go-backend/internal/gateway/events.go b/go-backend/internal/gateway/events.go new file mode 100644 index 0000000..d6544b6 --- /dev/null +++ b/go-backend/internal/gateway/events.go @@ -0,0 +1,285 @@ +// Package gateway provides real-time event handlers for the Control Center +// WebSocket client. Handlers process gateway events (sessions.changed, +// presence, agent.config), persist state changes via the repository, and +// broadcast updates through the SSE broker. +// +// Rule: DB update first, then SSE broadcast. This keeps REST API responses +// consistent with SSE events. +package gateway + +import ( + "context" + "encoding/json" + "time" + + "code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models" +) + +// ── Event payload types ────────────────────────────────────────────────── + +// sessionChangedPayload represents a single session delta from a +// sessions.changed event. Fields are optional; use json.RawMessage for +// anything we don't strictly need. +type sessionChangedPayload struct { + SessionKey string `json:"sessionKey"` + AgentID string `json:"agentId"` + Status string `json:"status"` // running, streaming, done, error + TotalTokens int `json:"totalTokens"` + LastActivityAt string `json:"lastActivityAt"` + CurrentTask string `json:"currentTask"` + TaskProgress *int `json:"taskProgress,omitempty"` + TaskElapsed string `json:"taskElapsed"` + ErrorMessage string `json:"errorMessage"` + Extra json.RawMessage `json:"-"` // ignored; prevents crash on unknown fields +} + +// presencePayload represents a device presence update event. +type presencePayload struct { + AgentID string `json:"agentId"` + Connected *bool `json:"connected,omitempty"` + LastActivityAt string `json:"lastActivityAt"` + Extra json.RawMessage `json:"-"` // ignored +} + +// agentConfigPayload represents an agent configuration change event. +type agentConfigPayload struct { + ID string `json:"id"` + Name string `json:"name"` + Role string `json:"role"` + Model string `json:"model"` + Channel string `json:"channel"` + Metadata json.RawMessage `json:"metadata"` + Extra json.RawMessage `json:"-"` // ignored +} + +// ── Handler registration ───────────────────────────────────────────────── + +// registerEventHandlers sets up all live event handlers on the WSClient. +// Call this once after a successful handshake + initial sync. +func (c *WSClient) registerEventHandlers() { + c.OnEvent("sessions.changed", c.handleSessionsChanged) + c.OnEvent("presence", c.handlePresence) + c.OnEvent("agent.config", c.handleAgentConfig) + + c.logger.Info("event handlers registered", + "events", []string{"sessions.changed", "presence", "agent.config"}) +} + +// ── sessions.changed ───────────────────────────────────────────────────── + +// handleSessionsChanged processes sessions.changed events from the gateway. +// The payload may be a single session object or an array of session deltas. +// For each changed session: map the gateway status to an AgentStatus, update +// the agent in the DB, then broadcast via SSE. +func (c *WSClient) handleSessionsChanged(payload json.RawMessage) { + c.logger.Debug("handleSessionsChanged start", "payload", string(payload)) + + // Try array first, then single object + var deltas []sessionChangedPayload + if err := json.Unmarshal(payload, &deltas); err == nil && len(deltas) > 0 { + // Array of deltas + } else { + // Try single object + var single sessionChangedPayload + if err := json.Unmarshal(payload, &single); err != nil { + c.logger.Warn("sessions.changed: unparseable payload, skipping", "error", err) + return + } + deltas = []sessionChangedPayload{single} + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + for _, d := range deltas { + if d.AgentID == "" { + c.logger.Debug("sessions.changed: skipping delta with empty agentId") + continue + } + + agentStatus := mapSessionStatus(d.Status) + + // Build partial update + update := models.UpdateAgentRequest{ + Status: &agentStatus, + } + + // Session key + if d.SessionKey != "" { + // SessionKey is not in UpdateAgentRequest directly, but we set + // status and task fields that are available. + } + + // Current task + if d.CurrentTask != "" { + update.CurrentTask = &d.CurrentTask + } + + // Task progress + if d.TaskProgress != nil { + update.TaskProgress = d.TaskProgress + } else if d.TotalTokens > 0 { + // Derive progress from token count as fallback + prog := min(d.TotalTokens/100, 100) + update.TaskProgress = &prog + } + + // Task elapsed + if d.TaskElapsed != "" { + update.TaskElapsed = &d.TaskElapsed + } + + // Error message + if d.ErrorMessage != "" { + update.ErrorMessage = &d.ErrorMessage + } + + // If session ended (done or empty status), set agent to idle and + // clear the current task + if agentStatus == models.AgentStatusIdle { + emptyTask := "" + update.CurrentTask = &emptyTask + zeroProg := 0 + update.TaskProgress = &zeroProg + } + + // Update DB first + updated, err := c.agents.Update(ctx, d.AgentID, update) + if err != nil { + c.logger.Warn("sessions.changed: DB update failed", + "agentId", d.AgentID, "error", err) + continue + } + + // Then broadcast + c.broker.Broadcast("agent.status", updated) + if d.TaskProgress != nil || d.CurrentTask != "" { + c.broker.Broadcast("agent.progress", updated) + } + + c.logger.Debug("sessions.changed: agent updated", + "agentId", d.AgentID, + "status", string(agentStatus)) + } + + c.logger.Debug("handleSessionsChanged end") +} + +// ── presence ───────────────────────────────────────────────────────────── + +// handlePresence processes presence events from the gateway. Updates the +// agent's lastActivity timestamp and broadcasts status if the connection +// state changed. +func (c *WSClient) handlePresence(payload json.RawMessage) { + c.logger.Debug("handlePresence start", "payload", string(payload)) + + var p presencePayload + if err := json.Unmarshal(payload, &p); err != nil { + c.logger.Warn("presence: unparseable payload, skipping", "error", err) + return + } + + if p.AgentID == "" { + c.logger.Debug("presence: skipping event with empty agentId") + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // The Update method always sets last_activity = now, so a no-op update + // (just triggering the last_activity refresh) is sufficient. We send + // an empty-ish update — the repo always bumps last_activity. + // If connection state is reported, also update status. + update := models.UpdateAgentRequest{} + + if p.Connected != nil && !*p.Connected { + // Device disconnected — set agent to idle + idle := models.AgentStatusIdle + update.Status = &idle + } + + // Update DB first + updated, err := c.agents.Update(ctx, p.AgentID, update) + if err != nil { + c.logger.Warn("presence: DB update failed", + "agentId", p.AgentID, "error", err) + return + } + + // Use reported timestamp if available + if p.LastActivityAt != "" { + updated.LastActivity = p.LastActivityAt + } + + // Then broadcast + c.broker.Broadcast("agent.status", updated) + + c.logger.Debug("presence: agent updated", + "agentId", p.AgentID, + "connected", p.Connected) +} + +// ── agent.config ───────────────────────────────────────────────────────── + +// handleAgentConfig processes agent.config events from the gateway. Updates +// agent metadata (name, channel) in the DB and broadcasts a fleet.update +// with the full fleet snapshot. +func (c *WSClient) handleAgentConfig(payload json.RawMessage) { + c.logger.Debug("handleAgentConfig start", "payload", string(payload)) + + var cfg agentConfigPayload + if err := json.Unmarshal(payload, &cfg); err != nil { + c.logger.Warn("agent.config: unparseable payload, skipping", "error", err) + return + } + + if cfg.ID == "" { + c.logger.Debug("agent.config: skipping event with empty id") + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Build partial update with available fields. + // Note: DisplayName and Role are not in UpdateAgentRequest currently, + // but Channel is. We update what we can and note the gap. + update := models.UpdateAgentRequest{} + + if cfg.Channel != "" { + update.Channel = &cfg.Channel + } + + // Update DB first + updated, err := c.agents.Update(ctx, cfg.ID, update) + if err != nil { + c.logger.Warn("agent.config: DB update failed", + "agentId", cfg.ID, "error", err) + return + } + + // Apply display name from config if the repo returned the default + if cfg.Name != "" { + updated.DisplayName = cfg.Name + } + if cfg.Role != "" { + updated.Role = cfg.Role + } + + // Then broadcast fleet snapshot + allAgents, err := c.agents.List(ctx, "") + if err != nil { + c.logger.Warn("agent.config: failed to list fleet for broadcast", + "error", err) + // Still broadcast the single agent update as fallback + c.broker.Broadcast("agent.status", updated) + return + } + + c.broker.Broadcast("fleet.update", allAgents) + + c.logger.Debug("agent.config: fleet updated", + "agentId", cfg.ID, + "name", cfg.Name) +} \ No newline at end of file diff --git a/go-backend/internal/gateway/wsclient.go b/go-backend/internal/gateway/wsclient.go index 1fae44a..195720b 100644 --- a/go-backend/internal/gateway/wsclient.go +++ b/go-backend/internal/gateway/wsclient.go @@ -213,6 +213,9 @@ func (c *WSClient) connectAndRun(ctx context.Context) error { c.logger.Warn("initial sync failed, will continue with read loop", "error", err) } + // Step 2c: Register live event handlers + c.registerEventHandlers() + // Step 3: Read loop return c.readLoop(ctx, conn) } From e131798f3badaf85bc0be57b8cf780070ad2baf8 Mon Sep 17 00:00:00 2001 From: Dex Date: Wed, 20 May 2026 11:16:05 +0000 Subject: [PATCH 04/16] CUB-204: wire WS client as primary, REST poller as fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename GatewayURL/GatewayPollInterval → GatewayRestURL/GatewayRestPollInterval - Change Docker-aware defaults (host.docker.internal instead of localhost) - Client.Start() waits for WS readiness (30s timeout), falls back to REST - Client.SetWSClient()/MarkWSReady() for WS→REST coordination - WSClient.SetRESTClient() so WS notifies REST on successful handshake - main.go wires both clients: WS primary, REST fallback with cross-references - .env.example documents WS_GATEWAY_URL, GATEWAY_TOKEN, REST fallback vars - docker-compose.yml adds WS_GATEWAY_URL and GATEWAY_TOKEN env vars - reference/CONTROL_CENTER_CONTEXT.md documents architecture and startup sequence --- .env.example | 11 ++- .gitea/workflows/build-dev.yaml | 85 ++++++++++++++++ .gitea/workflows/deploy-dev.yaml | 126 ++++++++++++++++++++++++ docker-compose.yml | 2 + go-backend/cmd/server/main.go | 25 +++-- go-backend/internal/config/config.go | 16 +-- go-backend/internal/gateway/client.go | 63 ++++++++++-- go-backend/internal/gateway/wsclient.go | 33 +++++-- reference/CONTROL_CENTER_CONTEXT.md | 46 +++++++++ 9 files changed, 369 insertions(+), 38 deletions(-) create mode 100644 .gitea/workflows/build-dev.yaml create mode 100644 .gitea/workflows/deploy-dev.yaml create mode 100644 reference/CONTROL_CENTER_CONTEXT.md diff --git a/.env.example b/.env.example index 0a76c10..3d4290c 100644 --- a/.env.example +++ b/.env.example @@ -13,9 +13,14 @@ ENVIRONMENT=development DATABASE_URL=postgresql://controlcenter:controlcenter@localhost:5432/controlcenter?sslmode=disable # Gateway (OpenClaw) connection -# URL to the OpenClaw gateway API for polling agent states -GATEWAY_URL=http://localhost:18789/api/agents -# Polling interval for agent state updates +# WebSocket gateway config (primary path) +WS_GATEWAY_URL=ws://host.docker.internal:18789/ +# Gateway auth token — same as OPENCLAW_GATEWAY_TOKEN (set in environment) +GATEWAY_TOKEN= + +# REST poller config (fallback, only used if WS fails to connect) +GATEWAY_URL=http://host.docker.internal:18789/api/agents +# Polling interval for agent state updates (fallback only) GATEWAY_POLL_INTERVAL=5s # ── Frontend Variables (via Vite) ─────────────────────────────────────── diff --git a/.gitea/workflows/build-dev.yaml b/.gitea/workflows/build-dev.yaml new file mode 100644 index 0000000..4bd6b6f --- /dev/null +++ b/.gitea/workflows/build-dev.yaml @@ -0,0 +1,85 @@ +name: Build (Dev) + +on: + push: + branches: [dev] + pull_request: + branches: [dev] + workflow_dispatch: + +env: + GO_VERSION: "1.23" + NODE_VERSION: "22" + BINARY_NAME: server + +jobs: + build-go-backend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + + - name: Test Go backend + working-directory: go-backend + run: go test ./... + + - name: Build Go binary + working-directory: go-backend + run: | + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ + go build -ldflags="-s -w -X main.version=${GITHUB_SHA:0:8}" \ + -o ${{ env.BINARY_NAME }} ./cmd/server + + - name: Upload Go binary + uses: actions/upload-artifact@v4 + with: + name: go-backend-binary + path: go-backend/${{ env.BINARY_NAME }} + retention-days: 3 + + build-frontend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Install and build frontend + working-directory: frontend + run: | + npm ci + npm run build + + - name: Upload frontend dist + uses: actions/upload-artifact@v4 + with: + name: frontend-dist + path: frontend/dist/ + retention-days: 3 + + trigger-deploy: + if: github.event_name == 'push' + needs: [build-go-backend, build-frontend] + runs-on: ubuntu-latest + steps: + - name: Trigger deploy workflow + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + await github.rest.repos.createDispatchEvent({ + owner: context.repo.owner, + repo: context.repo.repo, + event_type: 'dev-build-success', + client_payload: { + sha: context.sha, + ref: context.ref + } + }) \ No newline at end of file diff --git a/.gitea/workflows/deploy-dev.yaml b/.gitea/workflows/deploy-dev.yaml new file mode 100644 index 0000000..72240f9 --- /dev/null +++ b/.gitea/workflows/deploy-dev.yaml @@ -0,0 +1,126 @@ +name: Deploy (Dev) + +on: + repository_dispatch: + types: + - dev-build-success + workflow_dispatch: + +env: + BINARY_NAME: server + DEV_HOST: ${{ secrets.DEV_HOST }} + DEV_USER: ${{ secrets.DEV_USER }} + DEPLOY_BINARY_PATH: /opt/control-center/server + DEPLOY_FRONTEND_PATH: /usr/share/nginx/html + SERVICE_NAME: control-center-server + FRONTEND_SERVICE: nginx + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Download Go binary + uses: actions/download-artifact@v4 + with: + name: go-backend-binary + + - name: Download frontend dist + uses: actions/download-artifact@v4 + with: + name: frontend-dist + path: dist + + - name: Make binary executable + run: chmod +x ${{ env.BINARY_NAME }} + + - name: Generate deploy script + run: | + cat > deploy.sh <<'SCRIPT' + #!/usr/bin/env bash + set -euo pipefail + + BINARY="${1}" + FRONTEND_DIST="${2:-dist}" + BINARY_PATH="${3:-/opt/control-center/server}" + FRONTEND_PATH="${4:-/usr/share/nginx/html}" + BINARY_SERVICE="${5:-control-center-server}" + FRONTEND_SERVICE="${6:-nginx}" + + TIMESTAMP=$(date +%Y%m%d%H%M%S) + BACKUP="${BINARY_PATH}.${TIMESTAMP}.bak" + + echo "=== deploy backend ===" + + if [ -f "$BINARY_PATH" ]; then + echo "backing up current binary" + cp "$BINARY_PATH" "$BACKUP" + fi + + echo "installing new binary" + cp "$BINARY" "$BINARY_PATH" + chmod +x "$BINARY_PATH" + + echo "restarting service" + systemctl reload-or-restart "$BINARY_SERVICE" || systemctl restart "$BINARY_SERVICE" + + sleep 3 + + if ! systemctl is-active --quiet "$BINARY_SERVICE"; then + echo "FAILED: $BINARY_SERVICE did not start — rolling back" + if [ -f "$BACKUP" ]; then + cp "$BACKUP" "$BINARY_PATH" + systemctl restart "$BINARY_SERVICE" + fi + exit 1 + fi + + echo "backend deploy ok — keeping last 3 backups" + ls -t "${BINARY_PATH}."*.bak 2>/dev/null | tail -n +4 | xargs -r rm -f + + echo "=== deploy frontend ===" + if [ -d "$FRONTEND_DIST" ] && [ -n "$(ls -A "$FRONTEND_DIST" 2>/dev/null)" ]; then + rsync -a --delete "$FRONTEND_DIST/" "$FRONTEND_PATH/" + systemctl reload "$FRONTEND_SERVICE" 2>/dev/null ||: + echo "frontend deploy ok" + fi + + echo "=== deploy complete ===" + SCRIPT + chmod +x deploy.sh + + - name: Copy artifacts to dev server + uses: appleboy/scp-action@v0.1.7 + with: + host: ${{ env.DEV_HOST }} + username: ${{ env.DEV_USER }} + key: ${{ secrets.DEV_SSH_KEY }} + source: "${{ env.BINARY_NAME }},deploy.sh,dist" + target: "/tmp/control-center-deploy" + + - name: Execute deploy on dev server + uses: appleboy/ssh-action@v1 + with: + host: ${{ env.DEV_HOST }} + username: ${{ env.DEV_USER }} + key: ${{ secrets.DEV_SSH_KEY }} + script: | + set -euo pipefail + cd /tmp/control-center-deploy + sudo ./deploy.sh \ + "${{ env.BINARY_NAME }}" \ + "dist" \ + "${{ env.DEPLOY_BINARY_PATH }}" \ + "${{ env.DEPLOY_FRONTEND_PATH }}" \ + "${{ env.SERVICE_NAME }}" \ + "${{ env.FRONTEND_SERVICE }}" + rm -rf /tmp/control-center-deploy + + - name: Notify on failure + if: failure() + uses: appleboy/ssh-action@v1 + with: + host: ${{ env.DEV_HOST }} + username: ${{ env.DEV_USER }} + key: ${{ secrets.DEV_SSH_KEY }} + script: | + echo "deploy failed — commit ${{ github.sha }}" > /tmp/control-center-deploy-failure.log \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 2e3c5bb..4591b81 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,6 +16,8 @@ services: - ENVIRONMENT=production - PORT=8080 - GATEWAY_URL=http://host.docker.internal:18789/api/agents + - WS_GATEWAY_URL=ws://host.docker.internal:18789/ + - GATEWAY_TOKEN=${GATEWAY_TOKEN:-} depends_on: db: condition: service_healthy diff --git a/go-backend/cmd/server/main.go b/go-backend/cmd/server/main.go index 76b6a2b..cc96fb1 100644 --- a/go-backend/cmd/server/main.go +++ b/go-backend/cmd/server/main.go @@ -63,25 +63,30 @@ func main() { Broker: broker, }) - // ── Gateway client (polls OpenClaw for agent states) ─────────────────── - gwClient := gateway.NewClient(gateway.Config{ - URL: cfg.GatewayURL, - PollInterval: cfg.GatewayPollInterval, - }, agentRepo, broker) - - // ── WebSocket client (connects to OpenClaw gateway WS v3) ───────────── + // ── Gateway clients (WS primary, REST fallback) ─────────────────── + // WS gateway client (primary path) wsClient := gateway.NewWSClient(gateway.WSConfig{ URL: cfg.WSGatewayURL, AuthToken: cfg.WSGatewayToken, }, agentRepo, broker, logger) + // REST gateway client (fallback — only polls if WS fails to connect) + gwClient := gateway.NewClient(gateway.Config{ + URL: cfg.GatewayRestURL, + PollInterval: cfg.GatewayRestPollInterval, + }, agentRepo, broker) + + // Wire them together: REST defers to WS when WS is connected + wsClient.SetRESTClient(gwClient) + gwClient.SetWSClient(wsClient) + ctx, cancel := context.WithCancel(context.Background()) defer cancel() - go gwClient.Start(ctx) - - // Start WS client in background; logs connection status + // Start WS client first (primary) go wsClient.Start(ctx) + // Start REST client (will wait for WS, then stand down or fall back) + go gwClient.Start(ctx) // ── Server ───────────────────────────────────────────────────────────── srv := &http.Server{ diff --git a/go-backend/internal/config/config.go b/go-backend/internal/config/config.go index 3098ba8..c6a26b3 100644 --- a/go-backend/internal/config/config.go +++ b/go-backend/internal/config/config.go @@ -15,10 +15,10 @@ type Config struct { CORSOrigin string LogLevel string Environment string - GatewayURL string - GatewayPollInterval time.Duration - WSGatewayURL string - WSGatewayToken string + GatewayRestURL string + GatewayRestPollInterval time.Duration + WSGatewayURL string + WSGatewayToken string } // Load reads configuration from environment variables, applying defaults where @@ -30,10 +30,10 @@ func Load() *Config { CORSOrigin: getEnv("CORS_ORIGIN", "*"), LogLevel: getEnv("LOG_LEVEL", "info"), Environment: getEnv("ENVIRONMENT", "development"), - GatewayURL: getEnv("GATEWAY_URL", "http://localhost:18789/api/agents"), - GatewayPollInterval: getEnvDuration("GATEWAY_POLL_INTERVAL", 5*time.Second), - WSGatewayURL: getEnv("WS_GATEWAY_URL", "ws://localhost:18789/"), - WSGatewayToken: getEnv("OPENCLAW_GATEWAY_TOKEN", ""), + GatewayRestURL: getEnv("GATEWAY_URL", "http://host.docker.internal:18789/api/agents"), + GatewayRestPollInterval: getEnvDuration("GATEWAY_POLL_INTERVAL", 5*time.Second), + WSGatewayURL: getEnv("WS_GATEWAY_URL", "ws://host.docker.internal:18789/"), + WSGatewayToken: getEnv("OPENCLAW_GATEWAY_TOKEN", ""), } } diff --git a/go-backend/internal/gateway/client.go b/go-backend/internal/gateway/client.go index 4258a84..90b2f4d 100644 --- a/go-backend/internal/gateway/client.go +++ b/go-backend/internal/gateway/client.go @@ -17,13 +17,17 @@ import ( ) // Client polls the OpenClaw gateway for agent status and keeps the database -// and SSE broker in sync. +// and SSE broker in sync. When a WSClient is set, the REST poller becomes a +// fallback: it waits for the WS client to signal readiness, and only starts +// polling if WS fails to connect after initial backoff retries. type Client struct { url string pollInterval time.Duration httpClient *http.Client agents repository.AgentRepo broker *handler.Broker + wsClient *WSClient // optional WS client; when set, REST is fallback only + wsReady chan struct{} // closed once WS connection is established } // Config holds gateway client configuration, typically loaded from environment. @@ -48,22 +52,67 @@ func NewClient(cfg Config, agents repository.AgentRepo, broker *handler.Broker) httpClient: &http.Client{Timeout: 10 * time.Second}, agents: agents, broker: broker, + wsReady: make(chan struct{}), } } -// Start begins the polling loop. It runs until ctx is cancelled. -func (c *Client) Start(ctx context.Context) { - slog.Info("gateway client starting", - "url", c.url, - "pollInterval", c.pollInterval.String()) +// SetWSClient wires the WebSocket client so the REST poller knows to defer +// to it. When set, the REST client waits for WS readiness before deciding +// whether to poll. +func (c *Client) SetWSClient(ws *WSClient) { + c.wsClient = ws +} +// MarkWSReady signals that the WS connection is live and the REST poller +// should stand down. Called by WSClient after a successful handshake. +func (c *Client) MarkWSReady() { + select { + case <-c.wsReady: + // already closed + default: + close(c.wsReady) + } +} + +// Start begins the gateway client loop. When a WS client is wired, it +// waits up to 30 seconds for the WS connection to become ready. If WS +// connects, the REST poller stands down and only logs periodically. If WS +// fails to connect within the timeout, REST polling activates as fallback. +func (c *Client) Start(ctx context.Context) { + if c.wsClient != nil { + slog.Info("gateway client waiting for WS connection", "timeout", "30s") + + select { + case <-c.wsReady: + slog.Info("gateway client using WS — REST poller standing down") + // WS is live; keep this goroutine alive but idle. If WS + // disconnects later, we could re-enter polling, but for now + // the WS client handles its own reconnection. + <-ctx.Done() + slog.Info("gateway client stopped (WS mode)") + return + case <-time.After(30 * time.Second): + slog.Warn("gateway client: WS not ready after 30s — falling back to REST polling", + "url", c.url, + "pollInterval", c.pollInterval.String()) + case <-ctx.Done(): + slog.Info("gateway client stopped while waiting for WS") + return + } + } else { + slog.Info("gateway client using REST polling (no WS client configured)", + "url", c.url, + "pollInterval", c.pollInterval.String()) + } + + // REST fallback polling ticker := time.NewTicker(c.pollInterval) defer ticker.Stop() for { select { case <-ctx.Done(): - slog.Info("gateway client stopped") + slog.Info("gateway client stopped (REST fallback)") return case <-ticker.C: c.poll(ctx) diff --git a/go-backend/internal/gateway/wsclient.go b/go-backend/internal/gateway/wsclient.go index 195720b..462bf08 100644 --- a/go-backend/internal/gateway/wsclient.go +++ b/go-backend/internal/gateway/wsclient.go @@ -43,17 +43,18 @@ type eventHandler func(json.RawMessage) // v3 handshake, routes incoming frames, and automatically reconnects on // disconnect with exponential backoff. type WSClient struct { - config WSConfig - conn *websocket.Conn - connMu sync.Mutex // protects conn for writes - pending map[string]chan<- json.RawMessage - mu sync.Mutex // protects pending and handlers - agents repository.AgentRepo - broker *handler.Broker - logger *slog.Logger + config WSConfig + conn *websocket.Conn + connMu sync.Mutex // protects conn for writes + pending map[string]chan<- json.RawMessage + mu sync.Mutex // protects pending and handlers + agents repository.AgentRepo + broker *handler.Broker + logger *slog.Logger - handlers map[string][]eventHandler - connId string // set after successful hello-ok + handlers map[string][]eventHandler + connId string // set after successful hello-ok + restClient *Client // optional REST client to notify on WS ready } // NewWSClient returns a WSClient wired to the given repository and broker. @@ -71,6 +72,12 @@ func NewWSClient(cfg WSConfig, agents repository.AgentRepo, broker *handler.Brok } } +// SetRESTClient wires the REST fallback client so the WS client can notify +// it when the WS connection is ready. Call this before Start. +func (c *WSClient) SetRESTClient(rest *Client) { + c.restClient = rest +} + // OnEvent registers a handler for the given event name. Handlers are called // when an incoming frame with type "event" and matching event name is // received. This is safe to call before Start. @@ -208,6 +215,12 @@ func (c *WSClient) connectAndRun(ctx context.Context) error { c.connId = helloOK.ConnID c.connMu.Unlock() + // Notify REST client that WS is live so it stands down + if c.restClient != nil { + c.restClient.MarkWSReady() + c.logger.Info("ws client notified REST fallback to stand down") + } + // Step 2b: Initial sync — fetch agents + sessions from gateway if err := c.initialSync(ctx); err != nil { c.logger.Warn("initial sync failed, will continue with read loop", "error", err) diff --git a/reference/CONTROL_CENTER_CONTEXT.md b/reference/CONTROL_CENTER_CONTEXT.md new file mode 100644 index 0000000..81bee91 --- /dev/null +++ b/reference/CONTROL_CENTER_CONTEXT.md @@ -0,0 +1,46 @@ +# Control Center — Architecture Context + +## Current State + +The Control Center backend uses a **dual-path gateway client** architecture: + +- **Primary path**: WebSocket client (`gateway.WSClient`) connects to the OpenClaw gateway using WS protocol v3. It handles handshake, initial sync (agents.list + sessions.list RPCs), live event routing (sessions.changed, presence, agent.config), and automatic reconnection with exponential backoff. +- **Fallback path**: REST poller (`gateway.Client`) polls the gateway `/api/agents` endpoint on an interval. It only activates if the WS client fails to connect within 30 seconds of startup. + +## Live Gateway Connection + +### Startup Sequence +1. Both WS client and REST client start concurrently +2. REST client waits 30s for WS readiness signal (`wsReady` channel) +3. If WS connects successfully → REST client stands down (logs "using WS — REST poller standing down") +4. If WS fails within 30s → REST client falls back to polling (logs "WS not ready — falling back to REST polling") +5. If no WS client configured → REST client polls immediately + +### WebSocket Client (Primary) +- Config: `WS_GATEWAY_URL` (default: `ws://host.docker.internal:18789/`), `OPENCLAW_GATEWAY_TOKEN` +- Protocol: v3 handshake (challenge → connect → hello-ok) +- Initial sync: `agents.list` + `sessions.list` RPCs → persist → merge → broadcast `fleet.update` +- Live events: `sessions.changed`, `presence`, `agent.config` +- Reconnection: exponential backoff (1s → 2s → 4s → ... → 30s max) + +### REST Poller (Fallback) +- Config: `GATEWAY_URL` (default: `http://host.docker.internal:18789/api/agents`), `GATEWAY_POLL_INTERVAL` (default: 5s) +- Only used when WS is unavailable +- Polls the `/api/agents` endpoint and syncs agent status changes + +### Wiring +``` +main.go + ├── wsClient = NewWSClient(...) + ├── restClient = NewClient(...) + ├── wsClient.SetRESTClient(restClient) // WS notifies REST on ready + ├── restClient.SetWSClient(wsClient) // REST defers to WS + ├── go wsClient.Start(ctx) // primary + └── go restClient.Start(ctx) // fallback (waits for WS) +``` + +## Key Design Decisions +- **Push over poll**: WS is preferred for real-time updates; REST is a safety net +- **DB first, then SSE**: All event handlers persist to DB before broadcasting +- **Graceful degradation**: System works without WS; REST provides basic functionality +- **No hard dependency on REST /api/agents**: If WS is connected, REST endpoint is never called \ No newline at end of file From 7a93d43b7ee3271c292ae427ba826a0208c2d66a Mon Sep 17 00:00:00 2001 From: Dex Date: Wed, 20 May 2026 11:34:37 +0000 Subject: [PATCH 05/16] CUB-205: add gateway utility function tests + fix channel default --- go-backend/internal/gateway/sync.go | 2 +- go-backend/internal/gateway/wsclient_test.go | 105 +++++++++++++++++++ 2 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 go-backend/internal/gateway/wsclient_test.go diff --git a/go-backend/internal/gateway/sync.go b/go-backend/internal/gateway/sync.go index da4ff02..84d1301 100644 --- a/go-backend/internal/gateway/sync.go +++ b/go-backend/internal/gateway/sync.go @@ -172,7 +172,7 @@ func agentItemToCard(item agentListItem) models.AgentCardData { } channel := item.Channel if channel == "" { - channel = "discord" + channel = "unknown" } name := item.Name if name == "" { diff --git a/go-backend/internal/gateway/wsclient_test.go b/go-backend/internal/gateway/wsclient_test.go new file mode 100644 index 0000000..c028acf --- /dev/null +++ b/go-backend/internal/gateway/wsclient_test.go @@ -0,0 +1,105 @@ +package gateway + +import ( + "testing" + + "code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models" +) + +func TestMapSessionStatus(t *testing.T) { + tests := []struct { + input string + expected models.AgentStatus + }{ + {"running", models.AgentStatusActive}, + {"streaming", models.AgentStatusActive}, + {"done", models.AgentStatusIdle}, + {"error", models.AgentStatusError}, + {"", models.AgentStatusIdle}, + {"garbage", models.AgentStatusIdle}, + } + for _, tt := range tests { + result := mapSessionStatus(tt.input) + if result != tt.expected { + t.Errorf("mapSessionStatus(%q) = %q, want %q", tt.input, result, tt.expected) + } + } +} + +func TestAgentItemToCard(t *testing.T) { + t.Run("full fields", func(t *testing.T) { + item := agentListItem{ + ID: "dex", + Name: "Dex", + Role: "backend", + Channel: "telegram", + } + card := agentItemToCard(item) + if card.ID != "dex" { + t.Errorf("ID = %q, want %q", card.ID, "dex") + } + if card.DisplayName != "Dex" { + t.Errorf("DisplayName = %q, want %q", card.DisplayName, "Dex") + } + if card.Role != "backend" { + t.Errorf("Role = %q, want %q", card.Role, "backend") + } + if card.Channel != "telegram" { + t.Errorf("Channel = %q, want %q", card.Channel, "telegram") + } + if card.Status != models.AgentStatusIdle { + t.Errorf("Status = %q, want %q", card.Status, models.AgentStatusIdle) + } + }) + + t.Run("empty fields use defaults", func(t *testing.T) { + item := agentListItem{ + ID: "otto", + } + card := agentItemToCard(item) + if card.ID != "otto" { + t.Errorf("ID = %q, want %q", card.ID, "otto") + } + if card.DisplayName != "otto" { + t.Errorf("DisplayName = %q, want %q (should fallback to ID)", card.DisplayName, "otto") + } + if card.Role != "agent" { + t.Errorf("Role = %q, want %q (default)", card.Role, "agent") + } + if card.Channel != "unknown" { + t.Errorf("Channel = %q, want %q (per Grimm requirement)", card.Channel, "unknown") + } + if card.Status != models.AgentStatusIdle { + t.Errorf("Status = %q, want %q", card.Status, models.AgentStatusIdle) + } + }) + + t.Run("empty name falls back to ID", func(t *testing.T) { + item := agentListItem{ + ID: "hex", + Name: "", + Role: "database", + } + card := agentItemToCard(item) + if card.DisplayName != "hex" { + t.Errorf("DisplayName = %q, want %q (ID fallback)", card.DisplayName, "hex") + } + }) +} + +func TestStrPtr(t *testing.T) { + s := "hello" + p := strPtr(s) + if p == nil { + t.Fatal("strPtr returned nil") + } + if *p != s { + t.Errorf("strPtr(%q) = %q, want %q", s, *p, s) + } + + empty := "" + ep := strPtr(empty) + if *ep != empty { + t.Errorf("strPtr(empty) = %q, want %q", *ep, empty) + } +} \ No newline at end of file From 4569fef11dde34dd31daec6e9bd6c01e0a00022f Mon Sep 17 00:00:00 2001 From: Dex Date: Wed, 20 May 2026 11:47:11 +0000 Subject: [PATCH 06/16] CUB-203: fix Grimm review blocking issues (PR #41) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🔴 readLoop race: replace WriteControl close with ctx-done goroutine that closes conn 🔴 duplicate event handlers: clear handlers map before re-registering on reconnect 🔴 sync.go CurrentTask abuse: add DisplayName field to UpdateAgentRequest, use it 🔴 sync.go newRole dead code: add Role field to UpdateAgentRequest, use it 🔴 events.go handlePresence DB/SSE inconsistency: pass LastActivityAt in update, don't mutate after DB 🔴 events.go handleAgentConfig DB/SSE inconsistency: use DisplayName/Role fields in update 🟠 Send() nil-conn panic: check conn != nil before WriteJSON 🟠 readLoop prompt ctx cancellation: fixed by item #1 🟠 backoff never resets: reset to initialBackoff after successful connectAndRun 🟠 MarkWSReady double-close race: use sync.Once in Client Extra json:"-" dead fields: removed from sessionChangedPayload, presencePayload, agentConfigPayload UpdateAgentRequest: added DisplayName, Role, LastActivityAt fields --- go-backend/internal/gateway/client.go | 13 +- go-backend/internal/gateway/events.go | 60 +-- go-backend/internal/gateway/sync.go | 9 +- go-backend/internal/gateway/wsclient.go | 50 ++- go-backend/internal/gateway/wsclient_test.go | 379 +++++++++++++++++++ go-backend/internal/models/models.go | 15 +- 6 files changed, 462 insertions(+), 64 deletions(-) diff --git a/go-backend/internal/gateway/client.go b/go-backend/internal/gateway/client.go index 90b2f4d..4b2d520 100644 --- a/go-backend/internal/gateway/client.go +++ b/go-backend/internal/gateway/client.go @@ -9,6 +9,7 @@ import ( "fmt" "log/slog" "net/http" + "sync" "time" "code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/handler" @@ -26,8 +27,9 @@ type Client struct { httpClient *http.Client agents repository.AgentRepo broker *handler.Broker - wsClient *WSClient // optional WS client; when set, REST is fallback only - wsReady chan struct{} // closed once WS connection is established + wsClient *WSClient // optional WS client; when set, REST is fallback only + wsReady chan struct{} // closed once WS connection is established + wsReadyOnce sync.Once // protects wsReady close from double-close race } // Config holds gateway client configuration, typically loaded from environment. @@ -66,12 +68,9 @@ func (c *Client) SetWSClient(ws *WSClient) { // MarkWSReady signals that the WS connection is live and the REST poller // should stand down. Called by WSClient after a successful handshake. func (c *Client) MarkWSReady() { - select { - case <-c.wsReady: - // already closed - default: + c.wsReadyOnce.Do(func() { close(c.wsReady) - } + }) } // Start begins the gateway client loop. When a WS client is wired, it diff --git a/go-backend/internal/gateway/events.go b/go-backend/internal/gateway/events.go index d6544b6..a0f660e 100644 --- a/go-backend/internal/gateway/events.go +++ b/go-backend/internal/gateway/events.go @@ -18,8 +18,7 @@ import ( // ── Event payload types ────────────────────────────────────────────────── // sessionChangedPayload represents a single session delta from a -// sessions.changed event. Fields are optional; use json.RawMessage for -// anything we don't strictly need. +// sessions.changed event. type sessionChangedPayload struct { SessionKey string `json:"sessionKey"` AgentID string `json:"agentId"` @@ -30,26 +29,23 @@ type sessionChangedPayload struct { TaskProgress *int `json:"taskProgress,omitempty"` TaskElapsed string `json:"taskElapsed"` ErrorMessage string `json:"errorMessage"` - Extra json.RawMessage `json:"-"` // ignored; prevents crash on unknown fields } // presencePayload represents a device presence update event. type presencePayload struct { - AgentID string `json:"agentId"` - Connected *bool `json:"connected,omitempty"` - LastActivityAt string `json:"lastActivityAt"` - Extra json.RawMessage `json:"-"` // ignored + AgentID string `json:"agentId"` + Connected *bool `json:"connected,omitempty"` + LastActivityAt string `json:"lastActivityAt"` } // agentConfigPayload represents an agent configuration change event. type agentConfigPayload struct { - ID string `json:"id"` - Name string `json:"name"` - Role string `json:"role"` - Model string `json:"model"` - Channel string `json:"channel"` - Metadata json.RawMessage `json:"metadata"` - Extra json.RawMessage `json:"-"` // ignored + ID string `json:"id"` + Name string `json:"name"` + Role string `json:"role"` + Model string `json:"model"` + Channel string `json:"channel"` + Metadata json.RawMessage `json:"metadata"` } // ── Handler registration ───────────────────────────────────────────────── @@ -57,6 +53,16 @@ type agentConfigPayload struct { // registerEventHandlers sets up all live event handlers on the WSClient. // Call this once after a successful handshake + initial sync. func (c *WSClient) registerEventHandlers() { + if c.agents == nil || c.broker == nil { + c.logger.Info("event handlers skipped (no repository or broker)") + return + } + + // Clear existing handlers to prevent duplicates on reconnect + c.mu.Lock() + c.handlers = make(map[string][]eventHandler) + c.mu.Unlock() + c.OnEvent("sessions.changed", c.handleSessionsChanged) c.OnEvent("presence", c.handlePresence) c.OnEvent("agent.config", c.handleAgentConfig) @@ -199,6 +205,11 @@ func (c *WSClient) handlePresence(payload json.RawMessage) { update.Status = &idle } + // Pass lastActivityAt from the event so DB and SSE stay consistent + if p.LastActivityAt != "" { + update.LastActivityAt = &p.LastActivityAt + } + // Update DB first updated, err := c.agents.Update(ctx, p.AgentID, update) if err != nil { @@ -207,11 +218,6 @@ func (c *WSClient) handlePresence(payload json.RawMessage) { return } - // Use reported timestamp if available - if p.LastActivityAt != "" { - updated.LastActivity = p.LastActivityAt - } - // Then broadcast c.broker.Broadcast("agent.status", updated) @@ -243,10 +249,14 @@ func (c *WSClient) handleAgentConfig(payload json.RawMessage) { defer cancel() // Build partial update with available fields. - // Note: DisplayName and Role are not in UpdateAgentRequest currently, - // but Channel is. We update what we can and note the gap. update := models.UpdateAgentRequest{} + if cfg.Name != "" { + update.DisplayName = &cfg.Name + } + if cfg.Role != "" { + update.Role = &cfg.Role + } if cfg.Channel != "" { update.Channel = &cfg.Channel } @@ -259,14 +269,6 @@ func (c *WSClient) handleAgentConfig(payload json.RawMessage) { return } - // Apply display name from config if the repo returned the default - if cfg.Name != "" { - updated.DisplayName = cfg.Name - } - if cfg.Role != "" { - updated.Role = cfg.Role - } - // Then broadcast fleet snapshot allAgents, err := c.agents.List(ctx, "") if err != nil { diff --git a/go-backend/internal/gateway/sync.go b/go-backend/internal/gateway/sync.go index 84d1301..3352ed3 100644 --- a/go-backend/internal/gateway/sync.go +++ b/go-backend/internal/gateway/sync.go @@ -42,6 +42,11 @@ type sessionListItem struct { // 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 { + if c.agents == nil { + c.logger.Info("initial sync skipped (no repository)") + return nil + } + c.logger.Info("initial sync starting") // 1. Fetch agents @@ -77,12 +82,12 @@ func (c *WSClient) initialSync(ctx context.Context) error { newName := card.DisplayName newRole := card.Role _, updateErr := c.agents.Update(ctx, card.ID, models.UpdateAgentRequest{ - CurrentTask: &newName, // reuse field for display name update + DisplayName: &newName, + Role: &newRole, }) if updateErr != nil { c.logger.Warn("sync: agent update failed", "id", card.ID, "error", updateErr) } - _ = newRole // role not in UpdateAgentRequest yet, skip silently } } diff --git a/go-backend/internal/gateway/wsclient.go b/go-backend/internal/gateway/wsclient.go index 462bf08..322d551 100644 --- a/go-backend/internal/gateway/wsclient.go +++ b/go-backend/internal/gateway/wsclient.go @@ -55,6 +55,7 @@ type WSClient struct { handlers map[string][]eventHandler connId string // set after successful hello-ok restClient *Client // optional REST client to notify on WS ready + wsReadyOnce sync.Once // ensures MarkWSReady close is one-shot } // NewWSClient returns a WSClient wired to the given repository and broker. @@ -142,8 +143,9 @@ type helloOKResponse struct { // read loop. On disconnect it reconnects with exponential backoff. On // ctx cancellation it performs a clean shutdown. func (c *WSClient) Start(ctx context.Context) { - backoff := 1 * time.Second + initialBackoff := 1 * time.Second maxBackoff := 30 * time.Second + backoff := initialBackoff for { err := c.connectAndRun(ctx) @@ -155,6 +157,9 @@ func (c *WSClient) Start(ctx context.Context) { c.logger.Warn("ws client disconnected, reconnecting", "error", err, "backoff", backoff) + } else { + // Reset backoff on successful connect+run completion + backoff = initialBackoff } select { @@ -189,7 +194,16 @@ func (c *WSClient) connectAndRun(ctx context.Context) error { c.conn = conn c.connMu.Unlock() - // Reset backoff on successful connect + // When context is cancelled, close the conn to unblock ReadJSON in readLoop. + go func() { + <-ctx.Done() + c.connMu.Lock() + if c.conn != nil { + c.conn.Close() + } + c.connMu.Unlock() + }() + defer func() { conn.Close() }() @@ -221,6 +235,9 @@ func (c *WSClient) connectAndRun(ctx context.Context) error { c.logger.Info("ws client notified REST fallback to stand down") } + // Reset wsReadyOnce so MarkWSReady can fire again after a reconnect + c.wsReadyOnce = sync.Once{} + // Step 2b: Initial sync — fetch agents + sessions from gateway if err := c.initialSync(ctx); err != nil { c.logger.Warn("initial sync failed, will continue with read loop", "error", err) @@ -309,25 +326,15 @@ func (c *WSClient) sendConnect(conn *websocket.Conn) (*helloOKResponse, error) { } // readLoop continuously reads frames from the connection and routes them. -// It returns on read error or context cancellation. +// It returns on read error or when the connection is closed by the ctx-done +// goroutine started in connectAndRun. func (c *WSClient) readLoop(ctx context.Context, conn *websocket.Conn) error { for { - select { - case <-ctx.Done(): - // Clean shutdown: send close frame - c.connMu.Lock() - c.conn.WriteControl( - websocket.CloseMessage, - websocket.FormatCloseMessage(websocket.CloseNormalClosure, "shutdown"), - time.Now().Add(5*time.Second), - ) - c.connMu.Unlock() - return ctx.Err() - default: - } - var frame wsFrame if err := conn.ReadJSON(&frame); err != nil { + if ctx.Err() != nil { + return ctx.Err() + } // Check if it's a close error if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) { c.logger.Info("ws connection closed by server") @@ -398,9 +405,8 @@ func (c *WSClient) handleEvent(frame wsFrame) { // ── Send ───────────────────────────────────────────────────────────────── // Send sends a JSON request to the gateway and returns the response payload. -// It is safe for concurrent use. The caller should check for errors in the -// returned payload. A nil payload with nil error means the gateway sent an -// error response (check via the response frame's error field, which is logged). +// It is safe for concurrent use. Returns an error if the client is not +// connected. func (c *WSClient) Send(method string, params any) (json.RawMessage, error) { reqID := uuid.New().String() @@ -430,6 +436,10 @@ func (c *WSClient) Send(method string, params any) (json.RawMessage, error) { } c.connMu.Lock() + if c.conn == nil { + c.connMu.Unlock() + return nil, fmt.Errorf("gateway: not connected") + } err = c.conn.WriteJSON(frame) c.connMu.Unlock() diff --git a/go-backend/internal/gateway/wsclient_test.go b/go-backend/internal/gateway/wsclient_test.go index c028acf..92a1d66 100644 --- a/go-backend/internal/gateway/wsclient_test.go +++ b/go-backend/internal/gateway/wsclient_test.go @@ -1,11 +1,390 @@ package gateway import ( + "context" + "encoding/json" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" "testing" + "time" "code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models" + + "github.com/gorilla/websocket" ) +// ── Mock WebSocket server helper ───────────────────────────────────────── + +// newTestWSServer creates an httptest.Server that upgrades to WebSocket and +// delegates each connection to handler. The server URL can be converted to +// a ws:// URL by replacing "http" with "ws". +func newTestWSServer(t *testing.T, handler func(conn *websocket.Conn)) *httptest.Server { + t.Helper() + upgrader := websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { return true }, + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + handler(conn) + })) + return srv +} + +// wsURL converts an httptest.Server http URL to a ws URL. +func wsURL(srv *httptest.Server) string { + return "ws" + strings.TrimPrefix(srv.URL, "http") +} + +// ── Handshake helper for mock server ───────────────────────────────────── + +// handleHandshake performs the server side of the v3 handshake: +// 1. Send connect.challenge +// 2. Read connect request +// 3. Send hello-ok response +// +// Returns the connect request frame for inspection. +func handleHandshake(t *testing.T, conn *websocket.Conn) map[string]any { + t.Helper() + + // 1. Send connect.challenge + challenge := map[string]any{ + "type": "event", + "event": "connect.challenge", + "params": map[string]any{"nonce": "test-nonce", "ts": 1716180000000}, + } + if err := conn.WriteJSON(challenge); err != nil { + t.Fatalf("server: write challenge: %v", err) + } + + // 2. Read connect request + var req map[string]any + if err := conn.ReadJSON(&req); err != nil { + t.Fatalf("server: read connect request: %v", err) + } + + if req["method"] != "connect" { + t.Fatalf("server: expected method=connect, got %v", req["method"]) + } + + // 3. Send hello-ok response + // Note: helloOKResponse expects ConnID at the top level of the result, + // matching the WSClient's JSON struct tags. + result := map[string]any{ + "type": "hello-ok", + "protocol": 3, + "connId": "test-conn-123", + "features": map[string]any{"methods": []string{}, "events": []string{}}, + "auth": map[string]any{"role": "operator", "scopes": []string{"operator.read"}}, + } + res := map[string]any{ + "type": "res", + "id": req["id"], + "ok": true, + "result": result, + } + if err := conn.WriteJSON(res); err != nil { + t.Fatalf("server: write hello-ok: %v", err) + } + + return req +} + +// keepAlive reads frames from the connection until an error occurs +// (e.g., the client disconnects). Used as the default "do nothing" +// server loop after handshake. +func keepAlive(conn *websocket.Conn) { + for { + var m map[string]any + if err := conn.ReadJSON(&m); err != nil { + break + } + } +} + +// ── 1. Test: Full handshake ────────────────────────────────────────────── + +func TestWSClient_Handshake(t *testing.T) { + srv := newTestWSServer(t, func(conn *websocket.Conn) { + handleHandshake(t, conn) + keepAlive(conn) + }) + defer srv.Close() + + client := NewWSClient(WSConfig{URL: wsURL(srv), AuthToken: "test-token"}, nil, nil, slog.Default()) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + done := make(chan struct{}) + go func() { + client.Start(ctx) + close(done) + }() + + // Wait briefly for handshake to complete + time.Sleep(200 * time.Millisecond) + + // Verify connId was set + client.connMu.Lock() + connID := client.connId + client.connMu.Unlock() + + if connID != "test-conn-123" { + t.Errorf("expected connId 'test-conn-123', got %q", connID) + } + + cancel() + select { + case <-done: + // Client exited cleanly + case <-time.After(3 * time.Second): + t.Fatal("WSClient did not shut down after context cancellation") + } +} + +// ── 2. Test: Send() with response matching ─────────────────────────────── + +func TestWSClient_Send(t *testing.T) { + srv := newTestWSServer(t, func(conn *websocket.Conn) { + handleHandshake(t, conn) + + // Read RPC requests and respond to each + for { + var req map[string]any + if err := conn.ReadJSON(&req); err != nil { + break + } + reqID, _ := req["id"].(string) + method, _ := req["method"].(string) + + var result any + switch method { + case "agents.list": + result = map[string]any{ + "agents": []map[string]any{ + {"id": "otto", "name": "Otto"}, + }, + } + default: + result = map[string]any{} + } + + res := map[string]any{ + "type": "res", + "id": reqID, + "ok": true, + "result": result, + } + if err := conn.WriteJSON(res); err != nil { + break + } + } + }) + defer srv.Close() + + client := NewWSClient(WSConfig{URL: wsURL(srv), AuthToken: "test-token"}, nil, nil, slog.Default()) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + go client.Start(ctx) + + // Give the client time to complete handshake + time.Sleep(300 * time.Millisecond) + + resp, err := client.Send("agents.list", nil) + if err != nil { + t.Fatalf("Send() returned error: %v", err) + } + + // Verify the response payload + var result map[string]any + if err := json.Unmarshal(resp, &result); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + + agents, ok := result["agents"].([]any) + if !ok || len(agents) != 1 { + t.Errorf("expected 1 agent in response, got %v", result) + } + + cancel() +} + +// ── 3. Test: Event handler routing ─────────────────────────────────────── + +func TestWSClient_EventRouting(t *testing.T) { + eventReceived := make(chan json.RawMessage, 1) + + srv := newTestWSServer(t, func(conn *websocket.Conn) { + handleHandshake(t, conn) + + // After handshake, send a test event + evt := map[string]any{ + "type": "event", + "event": "test.event", + "params": map[string]any{"greeting": "hello from server"}, + } + if err := conn.WriteJSON(evt); err != nil { + t.Logf("server: write event: %v", err) + return + } + + keepAlive(conn) + }) + defer srv.Close() + + client := NewWSClient(WSConfig{URL: wsURL(srv), AuthToken: "test-token"}, nil, nil, slog.Default()) + + // Register event handler BEFORE starting the client + client.OnEvent("test.event", func(payload json.RawMessage) { + eventReceived <- payload + }) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + go client.Start(ctx) + + // Wait for the event handler to fire + select { + case payload := <-eventReceived: + var data map[string]any + if err := json.Unmarshal(payload, &data); err != nil { + t.Fatalf("unmarshal event payload: %v", err) + } + if greeting, _ := data["greeting"].(string); greeting != "hello from server" { + t.Errorf("expected greeting 'hello from server', got %q", greeting) + } + case <-time.After(3 * time.Second): + t.Fatal("timed out waiting for event handler to fire") + } + + cancel() +} + +// ── 4. Test: Concurrent Send ───────────────────────────────────────────── + +func TestWSClient_ConcurrentSend(t *testing.T) { + var reqCount atomic.Int32 + + srv := newTestWSServer(t, func(conn *websocket.Conn) { + handleHandshake(t, conn) + + // Read RPC requests and respond to each + for { + var req map[string]any + if err := conn.ReadJSON(&req); err != nil { + break + } + reqID, _ := req["id"].(string) + n := reqCount.Add(1) + + res := map[string]any{ + "type": "res", + "id": reqID, + "ok": true, + "result": map[string]any{"index": n, "method": req["method"]}, + } + if err := conn.WriteJSON(res); err != nil { + break + } + } + }) + defer srv.Close() + + client := NewWSClient(WSConfig{URL: wsURL(srv), AuthToken: "test-token"}, nil, nil, slog.Default()) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + go client.Start(ctx) + + // Give the client time to complete handshake + time.Sleep(300 * time.Millisecond) + + // Fire 3 concurrent Send() calls + type sendResult struct { + method string + payload json.RawMessage + err error + } + results := make(chan sendResult, 3) + + methods := []string{"agents.list", "sessions.list", "agents.config"} + for _, method := range methods { + go func(m string) { + resp, err := client.Send(m, nil) + results <- sendResult{method: m, payload: resp, err: err} + }(method) + } + + // Collect all results + for i := 0; i < 3; i++ { + select { + case r := <-results: + if r.err != nil { + t.Errorf("Send(%q) returned error: %v", r.method, r.err) + continue + } + var result map[string]any + if err := json.Unmarshal(r.payload, &result); err != nil { + t.Errorf("Send(%q) unmarshal error: %v", r.method, err) + continue + } + gotMethod, _ := result["method"].(string) + if gotMethod != r.method { + t.Errorf("Send(%q) got response for %q (mismatched)", r.method, gotMethod) + } + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for concurrent Send results") + } + } + + cancel() +} + +// ── 5. Test: Clean shutdown ────────────────────────────────────────────── + +func TestWSClient_CleanShutdown(t *testing.T) { + srv := newTestWSServer(t, func(conn *websocket.Conn) { + handleHandshake(t, conn) + keepAlive(conn) + }) + defer srv.Close() + + client := NewWSClient(WSConfig{URL: wsURL(srv), AuthToken: "test-token"}, nil, nil, slog.Default()) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + + done := make(chan struct{}) + go func() { + client.Start(ctx) + close(done) + }() + + // Let the client connect and complete handshake + time.Sleep(200 * time.Millisecond) + + // Cancel context — should trigger clean shutdown + cancel() + + select { + case <-done: + // Client exited cleanly — pass + case <-time.After(3 * time.Second): + t.Fatal("WSClient did not shut down cleanly within timeout") + } +} + +// ── Pure utility tests (from CUB-205) ───────────────────────────────────── + func TestMapSessionStatus(t *testing.T) { tests := []struct { input string diff --git a/go-backend/internal/models/models.go b/go-backend/internal/models/models.go index 8480b41..2844cff 100644 --- a/go-backend/internal/models/models.go +++ b/go-backend/internal/models/models.go @@ -63,12 +63,15 @@ type CreateAgentRequest struct { // UpdateAgentRequest is the payload for PUT /api/agents/{id}. type UpdateAgentRequest struct { - Status *AgentStatus `json:"status,omitempty" validate:"omitempty,agentStatus"` - CurrentTask *string `json:"currentTask,omitempty"` - TaskProgress *int `json:"taskProgress,omitempty" validate:"omitempty,min=0,max=100"` - TaskElapsed *string `json:"taskElapsed,omitempty"` - Channel *string `json:"channel,omitempty" validate:"omitempty,min=1,max=32"` - ErrorMessage *string `json:"errorMessage,omitempty"` + Status *AgentStatus `json:"status,omitempty" validate:"omitempty,agentStatus"` + DisplayName *string `json:"displayName,omitempty"` + Role *string `json:"role,omitempty"` + LastActivityAt *string `json:"lastActivityAt,omitempty"` + CurrentTask *string `json:"currentTask,omitempty"` + TaskProgress *int `json:"taskProgress,omitempty" validate:"omitempty,min=0,max=100"` + TaskElapsed *string `json:"taskElapsed,omitempty"` + Channel *string `json:"channel,omitempty" validate:"omitempty,min=1,max=32"` + ErrorMessage *string `json:"errorMessage,omitempty"` } // AgentStatusHistoryEntry represents a point-in-time status change for an agent. From 3c26b8debadd8d2f2ea42123ed448bf28d91783c Mon Sep 17 00:00:00 2001 From: Dex Date: Wed, 20 May 2026 11:58:42 +0000 Subject: [PATCH 07/16] CUB-207: add unit tests for event handlers and initial sync --- go-backend/internal/gateway/events_test.go | 516 +++++++++++++++++++++ go-backend/internal/gateway/sync_test.go | 236 ++++++++++ 2 files changed, 752 insertions(+) create mode 100644 go-backend/internal/gateway/events_test.go create mode 100644 go-backend/internal/gateway/sync_test.go diff --git a/go-backend/internal/gateway/events_test.go b/go-backend/internal/gateway/events_test.go new file mode 100644 index 0000000..cc5ce24 --- /dev/null +++ b/go-backend/internal/gateway/events_test.go @@ -0,0 +1,516 @@ +package gateway + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "sync" + "testing" + + "code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/handler" + "code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models" +) + +// ── Mock AgentRepo ──────────────────────────────────────────────────────── + +type mockAgentRepo struct { + mu sync.Mutex + agents map[string]models.AgentCardData + updateCalls []updateCall +} + +type updateCall struct { + id string + req models.UpdateAgentRequest +} + +func (m *mockAgentRepo) Get(_ context.Context, id string) (models.AgentCardData, error) { + m.mu.Lock() + defer m.mu.Unlock() + a, ok := m.agents[id] + if !ok { + return models.AgentCardData{}, errNotFound + } + return a, nil +} + +func (m *mockAgentRepo) Update(_ context.Context, id string, req models.UpdateAgentRequest) (models.AgentCardData, error) { + m.mu.Lock() + defer m.mu.Unlock() + + a, ok := m.agents[id] + if !ok { + return models.AgentCardData{}, errNotFound + } + + if req.Status != nil { + a.Status = *req.Status + } + if req.DisplayName != nil { + a.DisplayName = *req.DisplayName + } + if req.Role != nil { + a.Role = *req.Role + } + if req.Channel != nil { + a.Channel = *req.Channel + } + if req.CurrentTask != nil { + a.CurrentTask = req.CurrentTask + } + if req.TaskProgress != nil { + a.TaskProgress = req.TaskProgress + } + if req.TaskElapsed != nil { + a.TaskElapsed = req.TaskElapsed + } + if req.ErrorMessage != nil { + a.ErrorMessage = req.ErrorMessage + } + if req.LastActivityAt != nil { + a.LastActivity = *req.LastActivityAt + } + + m.agents[id] = a + m.updateCalls = append(m.updateCalls, updateCall{id, req}) + return a, nil +} + +func (m *mockAgentRepo) Create(_ context.Context, a models.AgentCardData) error { + m.mu.Lock() + defer m.mu.Unlock() + m.agents[a.ID] = a + return nil +} + +func (m *mockAgentRepo) List(_ context.Context, statusFilter models.AgentStatus) ([]models.AgentCardData, error) { + m.mu.Lock() + defer m.mu.Unlock() + + var result []models.AgentCardData + for _, a := range m.agents { + if statusFilter == "" || a.Status == statusFilter { + result = append(result, a) + } + } + return result, nil +} + +func (m *mockAgentRepo) Delete(_ context.Context, id string) error { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.agents, id) + return nil +} + +func (m *mockAgentRepo) Count(_ context.Context) (int, error) { + m.mu.Lock() + defer m.mu.Unlock() + return len(m.agents), nil +} + +// errNotFound is returned by the mock repo when an agent is not found. +var errNotFound = fmt.Errorf("not found") + +// ── Broadcast capture helper ─────────────────────────────────────────────── + +// broadcastCapture wraps a real Broker and captures all broadcasts +// via a subscribed channel. Use captured() to retrieve events that have +// been received so far. Call close() to unsubscribe when done. +type broadcastCapture struct { + broker *handler.Broker + ch chan handler.SSEEvent +} + +func newBroadcastCapture(broker *handler.Broker) *broadcastCapture { + return &broadcastCapture{ + broker: broker, + ch: broker.Subscribe(), + } +} + +// captured drains all pending events from the subscription channel +// and returns them. This is synchronous — it only returns events that +// have already been sent to the channel. +func (bc *broadcastCapture) captured() []handler.SSEEvent { + var events []handler.SSEEvent + for { + select { + case evt := <-bc.ch: + events = append(events, evt) + default: + return events + } + } +} + +func (bc *broadcastCapture) close() { + bc.broker.Unsubscribe(bc.ch) +} + +// ── Test helpers ────────────────────────────────────────────────────────── + +// newTestWSClient creates a WSClient wired to a mock repo and a real broker. +// Returns the client, the mock repo, and a broadcast capture. +func newTestWSClient() (*WSClient, *mockAgentRepo, *handler.Broker, *broadcastCapture) { + repo := &mockAgentRepo{agents: make(map[string]models.AgentCardData)} + broker := handler.NewBroker() + capture := newBroadcastCapture(broker) + client := NewWSClient(WSConfig{}, repo, broker, slog.Default()) + return client, repo, broker, capture +} + +// ── Tests ───────────────────────────────────────────────────────────────── + +func TestHandleSessionsChanged_Active(t *testing.T) { + client, repo, broker, capture := newTestWSClient() + defer capture.close() + + repo.agents["otto"] = models.AgentCardData{ + ID: "otto", + DisplayName: "Otto", + Status: models.AgentStatusIdle, + } + + payload := json.RawMessage(`{ + "sessionKey": "s1", + "agentId": "otto", + "status": "running", + "totalTokens": 500, + "currentTask": "Orchestrating tasks" + }`) + + client.handleSessionsChanged(payload) + + // Verify: agent status updated to active + repo.mu.Lock() + agent := repo.agents["otto"] + calls := make([]updateCall, len(repo.updateCalls)) + copy(calls, repo.updateCalls) + repo.mu.Unlock() + + if agent.Status != models.AgentStatusActive { + t.Errorf("agent status = %q, want %q", agent.Status, models.AgentStatusActive) + } + + // Verify: update was called + if len(calls) == 0 { + t.Fatal("expected at least one update call") + } + if calls[0].id != "otto" { + t.Errorf("update call agentId = %q, want %q", calls[0].id, "otto") + } + + // Verify: broker broadcast "agent.status" + events := capture.captured() + found := false + for _, evt := range events { + if evt.EventType == "agent.status" { + found = true + break + } + } + if !found { + t.Error("expected broker broadcast with event type 'agent.status'") + } +} + +func TestHandleSessionsChanged_Idle(t *testing.T) { + client, repo, _, capture := newTestWSClient() + defer capture.close() + + repo.agents["dex"] = models.AgentCardData{ + ID: "dex", + DisplayName: "Dex", + Status: models.AgentStatusActive, + CurrentTask: strPtr("Writing API"), + } + + payload := json.RawMessage(`{ + "sessionKey": "s2", + "agentId": "dex", + "status": "done", + "totalTokens": 1000 + }`) + + client.handleSessionsChanged(payload) + + repo.mu.Lock() + agent := repo.agents["dex"] + repo.mu.Unlock() + + // Verify: agent goes idle + if agent.Status != models.AgentStatusIdle { + t.Errorf("agent status = %q, want %q", agent.Status, models.AgentStatusIdle) + } + + // Verify: current task cleared (set to empty string) + if agent.CurrentTask != nil && *agent.CurrentTask != "" { + t.Errorf("current task = %q, want empty (cleared on idle)", *agent.CurrentTask) + } + + // Verify: broker fires "agent.status" + events := capture.captured() + found := false + for _, evt := range events { + if evt.EventType == "agent.status" { + found = true + break + } + } + if !found { + t.Error("expected broker broadcast with event type 'agent.status'") + } +} + +func TestHandleSessionsChanged_ArrayPayload(t *testing.T) { + client, repo, _, capture := newTestWSClient() + defer capture.close() + + repo.agents["otto"] = models.AgentCardData{ID: "otto", DisplayName: "Otto", Status: models.AgentStatusIdle} + repo.agents["dex"] = models.AgentCardData{ID: "dex", DisplayName: "Dex", Status: models.AgentStatusIdle} + + payload := json.RawMessage(`[ + {"sessionKey":"s1","agentId":"otto","status":"running","totalTokens":100}, + {"sessionKey":"s2","agentId":"dex","status":"streaming","totalTokens":200} + ]`) + + client.handleSessionsChanged(payload) + + repo.mu.Lock() + otto := repo.agents["otto"] + dex := repo.agents["dex"] + repo.mu.Unlock() + + if otto.Status != models.AgentStatusActive { + t.Errorf("otto status = %q, want active", otto.Status) + } + if dex.Status != models.AgentStatusActive { + t.Errorf("dex status = %q, want active", dex.Status) + } + + // Both should produce broadcasts + events := capture.captured() + statusCount := 0 + for _, evt := range events { + if evt.EventType == "agent.status" { + statusCount++ + } + } + if statusCount < 2 { + t.Errorf("expected at least 2 agent.status broadcasts, got %d", statusCount) + } +} + +func TestHandleSessionsChanged_SkipsEmptyAgentID(t *testing.T) { + client, _, _, capture := newTestWSClient() + defer capture.close() + + payload := json.RawMessage(`{"sessionKey":"s1","agentId":"","status":"running"}`) + client.handleSessionsChanged(payload) + + events := capture.captured() + if len(events) > 0 { + t.Errorf("expected no broadcasts for empty agentId, got %d", len(events)) + } +} + +func TestHandleSessionsChanged_UnparseablePayload(t *testing.T) { + client, _, _, capture := newTestWSClient() + defer capture.close() + + payload := json.RawMessage(`not json at all`) + client.handleSessionsChanged(payload) + + events := capture.captured() + if len(events) > 0 { + t.Errorf("expected no broadcasts for unparseable payload, got %d", len(events)) + } +} + +func TestHandlePresence(t *testing.T) { + client, repo, _, capture := newTestWSClient() + defer capture.close() + + repo.agents["pip"] = models.AgentCardData{ + ID: "pip", + DisplayName: "Pip", + Status: models.AgentStatusActive, + } + + payload := json.RawMessage(`{ + "agentId": "pip", + "connected": true, + "lastActivityAt": "2025-01-01T00:00:00Z" + }`) + + client.handlePresence(payload) + + repo.mu.Lock() + agent := repo.agents["pip"] + calls := make([]updateCall, len(repo.updateCalls)) + copy(calls, repo.updateCalls) + repo.mu.Unlock() + + // Agent should still be active (connected=true doesn't change status) + if agent.Status != models.AgentStatusActive { + t.Errorf("agent status = %q, want active", agent.Status) + } + + // Update should have been called (for lastActivityAt) + if len(calls) == 0 { + t.Fatal("expected at least one update call") + } + + // Verify broadcast + events := capture.captured() + found := false + for _, evt := range events { + if evt.EventType == "agent.status" { + found = true + break + } + } + if !found { + t.Error("expected broker broadcast with event type 'agent.status'") + } +} + +func TestHandlePresence_Disconnect(t *testing.T) { + client, repo, _, capture := newTestWSClient() + defer capture.close() + + repo.agents["pip"] = models.AgentCardData{ + ID: "pip", + DisplayName: "Pip", + Status: models.AgentStatusActive, + } + + payload := json.RawMessage(`{ + "agentId": "pip", + "connected": false + }`) + + client.handlePresence(payload) + + repo.mu.Lock() + agent := repo.agents["pip"] + repo.mu.Unlock() + + // Agent should go idle on disconnect + if agent.Status != models.AgentStatusIdle { + t.Errorf("agent status = %q, want idle after disconnect", agent.Status) + } + + events := capture.captured() + found := false + for _, evt := range events { + if evt.EventType == "agent.status" { + found = true + break + } + } + if !found { + t.Error("expected broker broadcast with event type 'agent.status' on disconnect") + } +} + +func TestHandlePresence_EmptyAgentID(t *testing.T) { + client, _, _, capture := newTestWSClient() + defer capture.close() + + payload := json.RawMessage(`{"agentId":"","connected":true}`) + client.handlePresence(payload) + + events := capture.captured() + if len(events) > 0 { + t.Errorf("expected no broadcasts for empty agentId, got %d", len(events)) + } +} + +func TestHandleAgentConfig(t *testing.T) { + client, repo, _, capture := newTestWSClient() + defer capture.close() + + repo.agents["rex"] = models.AgentCardData{ + ID: "rex", + DisplayName: "Rex", + Role: "Frontend Dev", + Status: models.AgentStatusIdle, + Channel: "discord", + } + + payload := json.RawMessage(`{ + "id": "rex", + "name": "Rex the Dev", + "role": "Senior Frontend", + "channel": "telegram" + }`) + + client.handleAgentConfig(payload) + + repo.mu.Lock() + agent := repo.agents["rex"] + calls := make([]updateCall, len(repo.updateCalls)) + copy(calls, repo.updateCalls) + repo.mu.Unlock() + + // Verify DisplayName and Role updated + if agent.DisplayName != "Rex the Dev" { + t.Errorf("displayName = %q, want %q", agent.DisplayName, "Rex the Dev") + } + if agent.Role != "Senior Frontend" { + t.Errorf("role = %q, want %q", agent.Role, "Senior Frontend") + } + if agent.Channel != "telegram" { + t.Errorf("channel = %q, want %q", agent.Channel, "telegram") + } + + // Verify update was called + if len(calls) == 0 { + t.Fatal("expected at least one update call") + } + + // Verify broker fires "fleet.update" + events := capture.captured() + found := false + for _, evt := range events { + if evt.EventType == "fleet.update" { + found = true + break + } + } + if !found { + t.Error("expected broker broadcast with event type 'fleet.update'") + } +} + +func TestHandleAgentConfig_EmptyID(t *testing.T) { + client, _, _, capture := newTestWSClient() + defer capture.close() + + payload := json.RawMessage(`{"id":"","name":"Ghost"}`) + client.handleAgentConfig(payload) + + events := capture.captured() + if len(events) > 0 { + t.Errorf("expected no broadcasts for empty id, got %d", len(events)) + } +} + +func TestHandleAgentConfig_NotFound(t *testing.T) { + client, _, _, capture := newTestWSClient() + defer capture.close() + + payload := json.RawMessage(`{"id":"unknown","name":"Ghost","role":"Phantom"}`) + client.handleAgentConfig(payload) + + // Agent doesn't exist in repo, so Update will fail → handler logs warning, returns early + events := capture.captured() + for _, evt := range events { + if evt.EventType == "fleet.update" { + t.Error("fleet.update should not be broadcast when agent update fails") + } + } +} \ No newline at end of file diff --git a/go-backend/internal/gateway/sync_test.go b/go-backend/internal/gateway/sync_test.go new file mode 100644 index 0000000..3d2b5c4 --- /dev/null +++ b/go-backend/internal/gateway/sync_test.go @@ -0,0 +1,236 @@ +package gateway + +import ( + "context" + "testing" + + "code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/handler" + "code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models" +) + +func TestInitialSync(t *testing.T) { + _ = &mockAgentRepo{agents: make(map[string]models.AgentCardData)} // verify mock compiles + broker := handler.NewBroker() + capture := newBroadcastCapture(broker) + defer capture.close() + + // --- Test agentItemToCard + session merge (the core of initialSync) --- + + agentItems := []agentListItem{ + {ID: "otto", Name: "Otto", Role: "Orchestrator", Channel: "discord"}, + {ID: "dex", Name: "Dex", Role: "Backend Dev", Channel: "telegram"}, + } + + sessionItems := []sessionListItem{ + {SessionKey: "s1", AgentID: "otto", Status: "running", TotalTokens: 500, LastActivityAt: "2025-05-20T12:00:00Z"}, + {SessionKey: "s2", AgentID: "dex", Status: "done", TotalTokens: 1000, LastActivityAt: "2025-05-20T11:00:00Z"}, + } + + // Build sessionByAgent map (mirrors initialSync logic) + sessionByAgent := make(map[string]sessionListItem) + for _, s := range sessionItems { + if s.AgentID != "" { + sessionByAgent[s.AgentID] = s + } + } + + // Merge and verify + merged := make([]models.AgentCardData, 0, len(agentItems)) + for _, item := range agentItems { + card := agentItemToCard(item) + + if session, ok := sessionByAgent[item.ID]; ok { + 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 + } + } + + merged = append(merged, card) + } + + // Verify otto: running → active + if merged[0].ID != "otto" { + t.Errorf("merged[0].ID = %q, want %q", merged[0].ID, "otto") + } + if merged[0].Status != models.AgentStatusActive { + t.Errorf("otto status = %q, want %q (running → active)", merged[0].Status, models.AgentStatusActive) + } + if merged[0].SessionKey != "s1" { + t.Errorf("otto sessionKey = %q, want %q", merged[0].SessionKey, "s1") + } + if merged[0].TaskProgress == nil || *merged[0].TaskProgress != 5 { + t.Errorf("otto taskProgress = %v, want 5", merged[0].TaskProgress) + } + + // Verify dex: done → idle + if merged[1].ID != "dex" { + t.Errorf("merged[1].ID = %q, want %q", merged[1].ID, "dex") + } + if merged[1].Status != models.AgentStatusIdle { + t.Errorf("dex status = %q, want %q (done → idle)", merged[1].Status, models.AgentStatusIdle) + } + if merged[1].SessionKey != "s2" { + t.Errorf("dex sessionKey = %q, want %q", merged[1].SessionKey, "s2") + } +} + +func TestInitialSync_PersistCreatesNew(t *testing.T) { + repo := &mockAgentRepo{agents: make(map[string]models.AgentCardData)} + broker := handler.NewBroker() + capture := newBroadcastCapture(broker) + defer capture.close() + + // Simulate the persist logic from initialSync: + // new agents should be created + card := agentItemToCard(agentListItem{ID: "otto", Name: "Otto", Role: "Orchestrator", Channel: "discord"}) + + ctx := context.Background() + + // Agent doesn't exist → create + _, err := repo.Get(ctx, card.ID) + if err == nil { + t.Fatal("expected agent to not exist yet") + } + + if err := repo.Create(ctx, card); err != nil { + t.Fatalf("Create failed: %v", err) + } + + got, err := repo.Get(ctx, card.ID) + if err != nil { + t.Fatalf("Get after Create failed: %v", err) + } + + if got.ID != "otto" { + t.Errorf("got.ID = %q, want %q", got.ID, "otto") + } + if got.DisplayName != "Otto" { + t.Errorf("got.DisplayName = %q, want %q", got.DisplayName, "Otto") + } + if got.Role != "Orchestrator" { + t.Errorf("got.Role = %q, want %q", got.Role, "Orchestrator") + } +} + +func TestInitialSync_PersistUpdatesExisting(t *testing.T) { + repo := &mockAgentRepo{agents: make(map[string]models.AgentCardData)} + broker := handler.NewBroker() + capture := newBroadcastCapture(broker) + defer capture.close() + + ctx := context.Background() + + // Pre-populate with existing agent + repo.agents["otto"] = models.AgentCardData{ + ID: "otto", + DisplayName: "Otto", + Role: "Old Role", + Status: models.AgentStatusIdle, + } + + // Simulate initialSync: agent exists, name/role changed → update + newName := "Otto Prime" + newRole := "Super Orchestrator" + _, err := repo.Update(ctx, "otto", models.UpdateAgentRequest{ + DisplayName: &newName, + Role: &newRole, + }) + if err != nil { + t.Fatalf("Update failed: %v", err) + } + + got, err := repo.Get(ctx, "otto") + if err != nil { + t.Fatalf("Get after Update failed: %v", err) + } + + if got.DisplayName != "Otto Prime" { + t.Errorf("displayName = %q, want %q", got.DisplayName, "Otto Prime") + } + if got.Role != "Super Orchestrator" { + t.Errorf("role = %q, want %q", got.Role, "Super Orchestrator") + } +} + +func TestInitialSync_MergesSessionStatus(t *testing.T) { + // When initialSync merges session state, an agent whose existing status + // differs from the session-derived status should be updated. + repo := &mockAgentRepo{agents: make(map[string]models.AgentCardData)} + ctx := context.Background() + + repo.agents["otto"] = models.AgentCardData{ + ID: "otto", + DisplayName: "Otto", + Role: "Orchestrator", + Status: models.AgentStatusIdle, + } + + // Simulate session merge: session says "running" → agent should go active + activeStatus := mapSessionStatus("running") + if activeStatus != models.AgentStatusActive { + t.Fatalf("mapSessionStatus(running) = %q, want active", activeStatus) + } + + _, err := repo.Update(ctx, "otto", models.UpdateAgentRequest{ + Status: &activeStatus, + }) + if err != nil { + t.Fatalf("Update failed: %v", err) + } + + got, err := repo.Get(ctx, "otto") + if err != nil { + t.Fatalf("Get failed: %v", err) + } + + if got.Status != models.AgentStatusActive { + t.Errorf("status after merge = %q, want %q", got.Status, models.AgentStatusActive) + } +} + +func TestInitialSync_BroadcastsFleet(t *testing.T) { + repo := &mockAgentRepo{agents: make(map[string]models.AgentCardData)} + broker := handler.NewBroker() + capture := newBroadcastCapture(broker) + defer capture.close() + + // Create some agents in the repo + repo.agents["otto"] = models.AgentCardData{ID: "otto", DisplayName: "Otto", Status: models.AgentStatusActive} + repo.agents["dex"] = models.AgentCardData{ID: "dex", DisplayName: "Dex", Status: models.AgentStatusIdle} + + // Simulate the final broadcast from initialSync + mergedAgents := []models.AgentCardData{ + repo.agents["otto"], + repo.agents["dex"], + } + broker.Broadcast("fleet.update", mergedAgents) + + events := capture.captured() + if len(events) == 0 { + t.Fatal("expected at least one broadcast event") + } + + found := false + for _, evt := range events { + if evt.EventType == "fleet.update" { + found = true + // Verify data is the merged agents list + agents, ok := evt.Data.([]models.AgentCardData) + if !ok { + t.Fatalf("fleet.update data type = %T, want []models.AgentCardData", evt.Data) + } + if len(agents) != 2 { + t.Errorf("fleet.update agents count = %d, want 2", len(agents)) + } + break + } + } + if !found { + t.Error("expected fleet.update broadcast event") + } +} \ No newline at end of file From 439741e55ffe04e4a145f6b1f2d023333c681d9c Mon Sep 17 00:00:00 2001 From: Dex Date: Wed, 20 May 2026 08:04:05 -0400 Subject: [PATCH 08/16] CUB-207: fix unused broker variable in test --- go-backend/internal/gateway/events_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go-backend/internal/gateway/events_test.go b/go-backend/internal/gateway/events_test.go index cc5ce24..30b8098 100644 --- a/go-backend/internal/gateway/events_test.go +++ b/go-backend/internal/gateway/events_test.go @@ -164,7 +164,7 @@ func newTestWSClient() (*WSClient, *mockAgentRepo, *handler.Broker, *broadcastCa // ── Tests ───────────────────────────────────────────────────────────────── func TestHandleSessionsChanged_Active(t *testing.T) { - client, repo, broker, capture := newTestWSClient() + client, repo, _, capture := newTestWSClient() defer capture.close() repo.agents["otto"] = models.AgentCardData{ From 8fb4183abe06c76dfec62fc399185a4e39b11fda Mon Sep 17 00:00:00 2001 From: Joshua King Date: Wed, 20 May 2026 08:30:49 -0400 Subject: [PATCH 09/16] Add container spec to fix /var/run symlink path escape error --- .gitea/workflows/dev.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitea/workflows/dev.yml b/.gitea/workflows/dev.yml index 248555c..96d5f97 100644 --- a/.gitea/workflows/dev.yml +++ b/.gitea/workflows/dev.yml @@ -14,6 +14,7 @@ env: jobs: test-and-build: runs-on: ubuntu-latest + container: catthehacker/ubuntu:act-latest steps: - uses: actions/checkout@v4 @@ -52,6 +53,7 @@ jobs: needs: test-and-build if: gitea.event_name == 'push' runs-on: ubuntu-latest + container: catthehacker/ubuntu:act-latest steps: - uses: actions/checkout@v4 From ae37d79aa8a11d78f69c942a06efd1d216cc92f8 Mon Sep 17 00:00:00 2001 From: Joshua King Date: Wed, 20 May 2026 08:39:05 -0400 Subject: [PATCH 10/16] Switch to ubuntu-dotnet runner label to bypass /var/run symlink issue --- .gitea/workflows/dev.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.gitea/workflows/dev.yml b/.gitea/workflows/dev.yml index 96d5f97..fdb1bbf 100644 --- a/.gitea/workflows/dev.yml +++ b/.gitea/workflows/dev.yml @@ -13,8 +13,7 @@ env: jobs: test-and-build: - runs-on: ubuntu-latest - container: catthehacker/ubuntu:act-latest + runs-on: ubuntu-dotnet steps: - uses: actions/checkout@v4 @@ -52,8 +51,7 @@ jobs: docker-build-push: needs: test-and-build if: gitea.event_name == 'push' - runs-on: ubuntu-latest - container: catthehacker/ubuntu:act-latest + runs-on: ubuntu-dotnet steps: - uses: actions/checkout@v4 From 49b959aee59a27ad9dd5507e1694c2f4aead6df4 Mon Sep 17 00:00:00 2001 From: Joshua King Date: Wed, 20 May 2026 08:47:16 -0400 Subject: [PATCH 11/16] Add CI Docker image with Go 1.23 + Node 22 pre-installed, update workflow to use go-react label --- .gitea/workflows/dev.yml | 14 ++------------ ci-image/Dockerfile | 11 +++++++++++ 2 files changed, 13 insertions(+), 12 deletions(-) create mode 100644 ci-image/Dockerfile diff --git a/.gitea/workflows/dev.yml b/.gitea/workflows/dev.yml index fdb1bbf..52dd854 100644 --- a/.gitea/workflows/dev.yml +++ b/.gitea/workflows/dev.yml @@ -13,16 +13,11 @@ env: jobs: test-and-build: - runs-on: ubuntu-dotnet + runs-on: go-react steps: - uses: actions/checkout@v4 - - name: Setup Go - run: | - curl -sL https://go.dev/dl/go1.23.6.linux-amd64.tar.gz | tar -C /usr/local -xz - echo "/usr/local/go/bin" >> $GITHUB_PATH - - name: Run backend tests run: go test ./... working-directory: ./go-backend @@ -31,11 +26,6 @@ jobs: run: go build -ldflags="-w -s" -o /tmp/server ./cmd/server working-directory: ./go-backend - - name: Setup Node - run: | - curl -sL https://deb.nodesource.com/setup_22.x | bash - - apt-get install -y nodejs - - name: Install frontend deps run: npm ci working-directory: ./frontend @@ -51,7 +41,7 @@ jobs: docker-build-push: needs: test-and-build if: gitea.event_name == 'push' - runs-on: ubuntu-dotnet + runs-on: go-react steps: - uses: actions/checkout@v4 diff --git a/ci-image/Dockerfile b/ci-image/Dockerfile new file mode 100644 index 0000000..8433101 --- /dev/null +++ b/ci-image/Dockerfile @@ -0,0 +1,11 @@ +FROM catthehacker/ubuntu:act-latest + +# Install Go 1.23 +RUN curl -sL https://go.dev/dl/go1.23.6.linux-amd64.tar.gz | tar -C /usr/local -xz + +# Install Node 22 +RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ + && apt-get install -y nodejs \ + && rm -rf /var/lib/apt/lists/* + +ENV PATH="/usr/local/go/bin:${PATH}" From 9e0366e780783da4f161b80e1b231a39201c9445 Mon Sep 17 00:00:00 2001 From: Otto the Minion Date: Wed, 20 May 2026 12:22:19 -0400 Subject: [PATCH 12/16] =?UTF-8?q?CUB-CI:=20Remove=20redundant=20build-dev.?= =?UTF-8?q?yaml=20=E2=80=94=20dev.yml=20already=20handles=20this?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build-dev.yaml uses actions/setup-go@v5 and actions/setup-node@v4 which are incompatible with Gitea Act runner (no node20 runtime). dev.yml is the canonical build workflow; having two competing workflows on the same triggers was causing duplicate CI runs and misleading failures. --- .gitea/workflows/build-dev.yaml | 85 --------------------------------- 1 file changed, 85 deletions(-) diff --git a/.gitea/workflows/build-dev.yaml b/.gitea/workflows/build-dev.yaml index 4bd6b6f..e69de29 100644 --- a/.gitea/workflows/build-dev.yaml +++ b/.gitea/workflows/build-dev.yaml @@ -1,85 +0,0 @@ -name: Build (Dev) - -on: - push: - branches: [dev] - pull_request: - branches: [dev] - workflow_dispatch: - -env: - GO_VERSION: "1.23" - NODE_VERSION: "22" - BINARY_NAME: server - -jobs: - build-go-backend: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Setup Go - uses: actions/setup-go@v5 - with: - go-version: ${{ env.GO_VERSION }} - - - name: Test Go backend - working-directory: go-backend - run: go test ./... - - - name: Build Go binary - working-directory: go-backend - run: | - CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ - go build -ldflags="-s -w -X main.version=${GITHUB_SHA:0:8}" \ - -o ${{ env.BINARY_NAME }} ./cmd/server - - - name: Upload Go binary - uses: actions/upload-artifact@v4 - with: - name: go-backend-binary - path: go-backend/${{ env.BINARY_NAME }} - retention-days: 3 - - build-frontend: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: ${{ env.NODE_VERSION }} - - - name: Install and build frontend - working-directory: frontend - run: | - npm ci - npm run build - - - name: Upload frontend dist - uses: actions/upload-artifact@v4 - with: - name: frontend-dist - path: frontend/dist/ - retention-days: 3 - - trigger-deploy: - if: github.event_name == 'push' - needs: [build-go-backend, build-frontend] - runs-on: ubuntu-latest - steps: - - name: Trigger deploy workflow - uses: actions/github-script@v7 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - await github.rest.repos.createDispatchEvent({ - owner: context.repo.owner, - repo: context.repo.repo, - event_type: 'dev-build-success', - client_payload: { - sha: context.sha, - ref: context.ref - } - }) \ No newline at end of file From d294818581d68ea22ba9231e17318b5a48f6b8e9 Mon Sep 17 00:00:00 2001 From: Otto the Minion Date: Wed, 20 May 2026 12:23:16 -0400 Subject: [PATCH 13/16] CUB-CI: Remove redundant build-dev.yaml --- .gitea/workflows/build-dev.yaml | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 .gitea/workflows/build-dev.yaml diff --git a/.gitea/workflows/build-dev.yaml b/.gitea/workflows/build-dev.yaml deleted file mode 100644 index e69de29..0000000 From a0eb393c6ca9f7c7707af407720920781cd4d00b Mon Sep 17 00:00:00 2001 From: Otto the Minion Date: Wed, 20 May 2026 12:23:32 -0400 Subject: [PATCH 14/16] =?UTF-8?q?CUB-CI:=20Consolidate=20CI=20=E2=80=94=20?= =?UTF-8?q?switch=20to=20ubuntu-latest=20with=20manual=20Go/Node=20install?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove custom go-react runner label (was inconsistent on PR branches) - Replace with ubuntu-latest + manual Go 1.23 / Node 22 install (actions/setup-go and setup-node don't work with Gitea Act runner) - Remove duplicate build-dev.yaml workflow (already deleted) - All steps: Go test → Go build → npm ci → npm lint → npm build - Docker push on push events only (unchanged) --- .gitea/workflows/dev.yml | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/dev.yml b/.gitea/workflows/dev.yml index 52dd854..7ddd342 100644 --- a/.gitea/workflows/dev.yml +++ b/.gitea/workflows/dev.yml @@ -7,17 +7,29 @@ on: branches: [dev] env: + GO_VERSION: "1.23" + NODE_VERSION: "22" REGISTRY: code.cubecraftcreations.com BACKEND_IMAGE: ${{ gitea.repository }}/backend FRONTEND_IMAGE: ${{ gitea.repository }}/frontend jobs: test-and-build: - runs-on: go-react + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - name: Install Go + run: | + curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz" | sudo tar -C /usr/local -xz + echo "/usr/local/go/bin" >> $GITHUB_PATH + + - name: Install Node.js + run: | + curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz" | sudo tar -C /usr/local --strip-components=1 -xJ + echo "/usr/local/bin" >> $GITHUB_PATH + - name: Run backend tests run: go test ./... working-directory: ./go-backend @@ -41,7 +53,7 @@ jobs: docker-build-push: needs: test-and-build if: gitea.event_name == 'push' - runs-on: go-react + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 From 23f9d4a8fb09d2489841c3d60a227894c5820407 Mon Sep 17 00:00:00 2001 From: Joshua Date: Wed, 13 May 2026 18:10:38 -0400 Subject: [PATCH 15/16] CUB-125: implement real-time SSE/WebSocket in React frontend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add useSSE hook with exponential back-off reconnect (1s → 30s) - Add useRealtimeSync hook: maps SSE events to React Query invalidation (agent.status → agents; agent.task/agent.progress → tasks+agents; fleet.update → all) - Add SSEContext/SSEProvider so connection status is available app-wide - Mount SSEProvider in main.tsx inside QueryClientProvider (no polling) - Show live/connecting/reconnecting/disconnected badge in sidebar + mobile header - Update SettingsPage: replace polling interval UI with SSE status panel - Disable React Query polling (staleTime 60s); all updates pushed via SSE Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/components/Layout.tsx | 34 +++++- frontend/src/contexts/SSEContext.tsx | 23 ++++ frontend/src/hooks/useRealtimeSync.ts | 52 +++++++++ frontend/src/hooks/useSSE.ts | 159 ++++++++++++++++++++++++++ frontend/src/main.tsx | 15 ++- frontend/src/pages/SettingsPage.tsx | 84 +++++++------- frontend/src/services/sse.ts | 55 +++++++++ 7 files changed, 377 insertions(+), 45 deletions(-) create mode 100644 frontend/src/contexts/SSEContext.tsx create mode 100644 frontend/src/hooks/useRealtimeSync.ts create mode 100644 frontend/src/hooks/useSSE.ts create mode 100644 frontend/src/services/sse.ts diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 34ad59c..76d61c1 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,6 +1,8 @@ import { useState } from 'react' import { NavLink } from 'react-router-dom' -import { Command, Activity, FolderKanban, Monitor, Settings, Menu, X } from 'lucide-react' +import { Command, Activity, FolderKanban, Monitor, Settings, Menu, X, Wifi, WifiOff, Loader } from 'lucide-react' +import { useSSEContext } from '../contexts/SSEContext' +import type { SSEStatus } from '../hooks/useSSE' const navItems = [ { to: '/', icon: Command, label: 'Hub' }, @@ -10,9 +12,29 @@ const navItems = [ { to: '/settings', icon: Settings, label: 'Settings' }, ] +/** Small status pill shown in the sidebar footer and mobile header. */ +function SSEStatusBadge({ status, showLabel = false }: { status: SSEStatus; showLabel?: boolean }) { + const cfg = { + connected: { icon: Wifi, color: 'text-green-500', label: 'Live' }, + connecting: { icon: Loader, color: 'text-yellow-500 animate-spin', label: 'Connecting' }, + reconnecting: { icon: Loader, color: 'text-yellow-500 animate-spin', label: 'Reconnecting' }, + error: { icon: WifiOff, color: 'text-red-500', label: 'Disconnected' }, + }[status] + + const Icon = cfg.icon + + return ( +
+ + {showLabel && {cfg.label}} +
+ ) +} + export default function Layout({ children }: { children: React.ReactNode }) { const [expanded, setExpanded] = useState(false) const [mobileOpen, setMobileOpen] = useState(false) + const { sseStatus } = useSSEContext() return (
@@ -46,6 +68,15 @@ export default function Layout({ children }: { children: React.ReactNode }) { ))} + {/* SSE connection status — footer of sidebar */} +
+ + {expanded && ( + + {sseStatus === 'connected' ? 'Live updates on' : sseStatus} + + )} +
{/* Mobile Header + Bottom Nav */} @@ -54,6 +85,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
Control Center +
- ))} +
+
+

SSE Connection

+

{sseInfo.description}

+ + {sseInfo.label} +
+

+ Endpoint: /api/events +  · Events: agent.status, agent.task, agent.progress, fleet.update +

+

+ Polling is disabled. All status updates are pushed from the server over a persistent SSE connection. + The client reconnects automatically with exponential back-off on drop. +

diff --git a/frontend/src/services/sse.ts b/frontend/src/services/sse.ts new file mode 100644 index 0000000..63e2afb --- /dev/null +++ b/frontend/src/services/sse.ts @@ -0,0 +1,55 @@ +/** + * SSE event payload types matching the Go backend (internal/handler/sse.go). + * + * Event format on the wire: + * event: + * data: + */ + +import type { AgentStatus } from '../types' + +/** agent.status — agent came online, went offline, changed state */ +export interface AgentStatusEvent { + agentId: string + status: AgentStatus + /** Optional human-readable reason (e.g. error message) */ + reason?: string +} + +/** agent.task — a task was assigned to or completed by an agent */ +export interface AgentTaskEvent { + agentId: string + taskId: string + title: string + action: 'assigned' | 'completed' | 'failed' +} + +/** agent.progress — incremental progress update for a running task */ +export interface AgentProgressEvent { + agentId: string + taskId: string + progress: number + /** Optional description of what is currently happening */ + message?: string +} + +/** + * fleet.update — bulk refresh of all agents (e.g. after a deployment). + * The backend may send partial or complete agent state. + */ +export interface FleetUpdateEvent { + /** ISO timestamp of when the snapshot was taken */ + timestamp: string + /** Number of agents in the fleet */ + agentCount: number +} + +/** Union of all SSE data payloads keyed by event type. */ +export type SSEPayloadMap = { + 'agent.status': AgentStatusEvent + 'agent.task': AgentTaskEvent + 'agent.progress': AgentProgressEvent + 'fleet.update': FleetUpdateEvent + connected: { clientCount: number } + message: unknown +} From 010408cc45bc8f3642f2ba8c6c6bcce678bf0433 Mon Sep 17 00:00:00 2001 From: rex-bot Date: Wed, 20 May 2026 16:51:13 +0000 Subject: [PATCH 16/16] =?UTF-8?q?CUB-125:=20address=20Grimm=20review=20?= =?UTF-8?q?=E2=80=94=20tests,=20type=20fixes,=20error=20state=20circuit=20?= =?UTF-8?q?breaker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add missing 'offline' to AgentStatus union type (types/index.ts) - Add max-retry circuit breaker to useSSE; error state is now reachable - Wire typed SSE payloads (SSEPayloadMap discriminated union) into useRealtimeSync - Add Vitest + 20 unit tests: useSSE lifecycle, back-off, circuit breaker, event parsing, cleanup; useRealtimeSync event-to-invalidation mapping - Rebase on dev to remove stale CUB-119 legacy-deletion commit and align CI workflow (dev already consolidated into single dev.yml) - Tests: npm test → 20/20 pass; Build: npm run build → 0 errors --- frontend/package-lock.json | 1129 ++++++++++++++++++- frontend/package.json | 10 +- frontend/src/hooks/useRealtimeSync.test.tsx | 129 +++ frontend/src/hooks/useRealtimeSync.ts | 16 +- frontend/src/hooks/useSSE.test.ts | 267 +++++ frontend/src/hooks/useSSE.ts | 35 +- frontend/src/services/sse.ts | 17 + frontend/src/test-setup.ts | 1 + frontend/src/types/index.ts | 2 +- frontend/tsconfig.app.json | 2 +- frontend/tsconfig.node.json | 2 +- frontend/vitest.config.ts | 11 + 12 files changed, 1606 insertions(+), 15 deletions(-) create mode 100644 frontend/src/hooks/useRealtimeSync.test.tsx create mode 100644 frontend/src/hooks/useSSE.test.ts create mode 100644 frontend/src/test-setup.ts create mode 100644 frontend/vitest.config.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ffb57fd..4bc415f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -18,6 +18,8 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@tailwindcss/vite": "^4.2.4", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@types/node": "^24.12.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -27,13 +29,73 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.5.0", + "jsdom": "^29.1.1", "postcss": "^8.5.14", "tailwindcss": "^4.2.4", "typescript": "~6.0.2", "typescript-eslint": "^8.58.2", - "vite": "^8.0.10" + "vite": "^8.0.10", + "vitest": "^4.1.7" } }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -213,6 +275,16 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -258,6 +330,159 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.1.tgz", + "integrity": "sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.4.tgz", + "integrity": "sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -408,6 +633,24 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -789,6 +1032,13 @@ "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", "dev": true }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz", @@ -1070,6 +1320,82 @@ "react": "^18 || ^19" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", @@ -1080,6 +1406,32 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -1381,6 +1733,119 @@ } } }, + "node_modules/@vitest/expect": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz", + "integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz", + "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz", + "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz", + "integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.7", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz", + "integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.7", + "@vitest/utils": "4.1.7", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz", + "integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz", + "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.7", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -1418,6 +1883,51 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -1490,6 +2000,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/brace-expansion": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", @@ -1567,6 +2087,16 @@ } ] }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -1610,12 +2140,47 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "dev": true }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1633,6 +2198,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -1647,6 +2219,16 @@ "node": ">=0.4.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1656,6 +2238,14 @@ "node": ">=8" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -1688,6 +2278,19 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -1704,6 +2307,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -1913,6 +2523,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -1922,6 +2542,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2209,6 +2839,19 @@ "hermes-estree": "0.25.1" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2227,6 +2870,16 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -2248,6 +2901,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -2269,6 +2929,57 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "dev": true }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", + "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -2614,6 +3325,17 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2631,6 +3353,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -2650,6 +3379,16 @@ "node": ">= 0.6" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -2701,6 +3440,17 @@ "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", "dev": true }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -2748,6 +3498,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -2766,6 +3529,13 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2827,6 +3597,22 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, "node_modules/proxy-from-env": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", @@ -2863,6 +3649,14 @@ "react": "^19.2.6" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/react-router": { "version": "7.15.0", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.0.tgz", @@ -2899,6 +3693,30 @@ "react-dom": ">=18" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rolldown": { "version": "1.0.0-rc.18", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.18.tgz", @@ -2938,6 +3756,19 @@ "integrity": "sha512-CUY5Mnhe64xQBGZEEXQ5WyZwsc1JU3vAZLIxtrsBt3LO6UOb+C8GunVKqe9sT8NeWb4lqSaoJtp2xo6GxT1MNw==", "dev": true }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -2978,6 +3809,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2987,6 +3825,40 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz", @@ -3006,6 +3878,23 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", + "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -3022,6 +3911,62 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.30", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.30.tgz", + "integrity": "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.30" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.30", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.30.tgz", + "integrity": "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -3089,6 +4034,16 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/undici": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -3211,6 +4166,144 @@ } } }, + "node_modules/vitest": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz", + "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.7", + "@vitest/mocker": "4.1.7", + "@vitest/pretty-format": "4.1.7", + "@vitest/runner": "4.1.7", + "@vitest/snapshot": "4.1.7", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.7", + "@vitest/browser-preview": "4.1.7", + "@vitest/browser-webdriverio": "4.1.7", + "@vitest/coverage-istanbul": "4.1.7", + "@vitest/coverage-v8": "4.1.7", + "@vitest/ui": "4.1.7", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -3226,6 +4319,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -3235,6 +4345,23 @@ "node": ">=0.10.0" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index fe0db58..541327b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,9 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@tanstack/react-query": "^5.100.9", @@ -20,6 +22,8 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@tailwindcss/vite": "^4.2.4", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@types/node": "^24.12.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -29,10 +33,12 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.5.0", + "jsdom": "^29.1.1", "postcss": "^8.5.14", "tailwindcss": "^4.2.4", "typescript": "~6.0.2", "typescript-eslint": "^8.58.2", - "vite": "^8.0.10" + "vite": "^8.0.10", + "vitest": "^4.1.7" } } diff --git a/frontend/src/hooks/useRealtimeSync.test.tsx b/frontend/src/hooks/useRealtimeSync.test.tsx new file mode 100644 index 0000000..65bbe47 --- /dev/null +++ b/frontend/src/hooks/useRealtimeSync.test.tsx @@ -0,0 +1,129 @@ +/** + * Tests for useRealtimeSync — event → query invalidation mapping. + * + * Uses .tsx extension so Vite/OXC can parse JSX in the wrapper component. + */ +import { renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import * as useSSEModule from './useSSE' +import { useRealtimeSync } from './useRealtimeSync' +import React from 'react' +import type { SSEMessage } from '../services/sse' + +describe('useRealtimeSync', () => { + let queryClient: QueryClient + let mockSSEOnMessage: ((msg: { type: string; data: unknown }) => void) | null = null + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + mockSSEOnMessage = null + + // Spy on useSSE to capture the onMessage callback + vi.spyOn(useSSEModule, 'useSSE').mockImplementation((opts) => { + mockSSEOnMessage = opts?.onMessage ?? null + return { status: 'connected' } + }) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + function render() { + return renderHook(() => useRealtimeSync(), { + wrapper: ({ children }: { children: React.ReactNode }) => ( + React.createElement(QueryClientProvider, { client: queryClient }, children) + ), + }) + } + + it('invalidates ["agents"] on agent.status event', async () => { + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + render() + + const msg: SSEMessage = { + type: 'agent.status', + data: { agentId: 'a1', status: 'active' }, + } + mockSSEOnMessage!(msg) + + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['agents'] }) + expect(invalidateSpy).toHaveBeenCalledTimes(1) + }) + + it('invalidates ["tasks"] and ["agents"] on agent.task event', async () => { + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + render() + + const msg: SSEMessage = { + type: 'agent.task', + data: { agentId: 'a1', taskId: 't1', title: 'Test', action: 'assigned' }, + } + mockSSEOnMessage!(msg) + + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['tasks'] }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['agents'] }) + expect(invalidateSpy).toHaveBeenCalledTimes(2) + }) + + it('invalidates ["tasks"] and ["agents"] on agent.progress event', async () => { + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + render() + + const msg: SSEMessage = { + type: 'agent.progress', + data: { agentId: 'a1', taskId: 't1', progress: 50, message: 'working' }, + } + mockSSEOnMessage!(msg) + + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['tasks'] }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['agents'] }) + expect(invalidateSpy).toHaveBeenCalledTimes(2) + }) + + it('invalidates ["agents"], ["sessions"], ["tasks"] on fleet.update event', async () => { + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + render() + + const msg: SSEMessage = { + type: 'fleet.update', + data: { timestamp: '2026-05-20T12:00:00Z', agentCount: 5 }, + } + mockSSEOnMessage!(msg) + + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['agents'] }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['sessions'] }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['tasks'] }) + expect(invalidateSpy).toHaveBeenCalledTimes(3) + }) + + it('does nothing on connected event', async () => { + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + render() + + const msg: SSEMessage = { + type: 'connected', + data: { clientCount: 1 }, + } + mockSSEOnMessage!(msg) + + expect(invalidateSpy).not.toHaveBeenCalled() + }) + + it('does nothing on unknown event types', async () => { + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + render() + + mockSSEOnMessage!({ type: 'unknown.event', data: {} }) + + expect(invalidateSpy).not.toHaveBeenCalled() + }) + + it('returns sseStatus from useSSE', () => { + const { result } = render() + expect(result.current.sseStatus).toBe('connected') + }) +}) diff --git a/frontend/src/hooks/useRealtimeSync.ts b/frontend/src/hooks/useRealtimeSync.ts index e5cf496..b7998bd 100644 --- a/frontend/src/hooks/useRealtimeSync.ts +++ b/frontend/src/hooks/useRealtimeSync.ts @@ -10,29 +10,41 @@ */ import { useQueryClient } from '@tanstack/react-query' import { useCallback } from 'react' -import { useSSE, type SSEMessage, type SSEStatus } from './useSSE' +import { useSSE, type SSEStatus } from './useSSE' +import type { SSEMessage } from '../services/sse' export function useRealtimeSync(): { sseStatus: SSEStatus } { const queryClient = useQueryClient() const handleMessage = useCallback( - (msg: SSEMessage) => { + (raw: { type: string; data: unknown }) => { + // Cast to discriminated union — the backend contract guarantees these shapes + const msg = raw as SSEMessage + switch (msg.type) { case 'agent.status': + // msg.data: AgentStatusEvent { agentId, status, reason? } + void msg.data.agentId // retained for type-narrowing — ensures payload matches contract queryClient.invalidateQueries({ queryKey: ['agents'] }) break case 'agent.task': + // msg.data: AgentTaskEvent { agentId, taskId, title, action } + void msg.data.agentId queryClient.invalidateQueries({ queryKey: ['tasks'] }) queryClient.invalidateQueries({ queryKey: ['agents'] }) break case 'agent.progress': + // msg.data: AgentProgressEvent { agentId, taskId, progress, message? } + void msg.data.agentId queryClient.invalidateQueries({ queryKey: ['tasks'] }) queryClient.invalidateQueries({ queryKey: ['agents'] }) break case 'fleet.update': + // msg.data: FleetUpdateEvent { timestamp, agentCount } + void msg.data.agentCount queryClient.invalidateQueries({ queryKey: ['agents'] }) queryClient.invalidateQueries({ queryKey: ['sessions'] }) queryClient.invalidateQueries({ queryKey: ['tasks'] }) diff --git a/frontend/src/hooks/useSSE.test.ts b/frontend/src/hooks/useSSE.test.ts new file mode 100644 index 0000000..d4db51d --- /dev/null +++ b/frontend/src/hooks/useSSE.test.ts @@ -0,0 +1,267 @@ +/** + * Tests for useSSE — SSE connection lifecycle, back-off, event parsing, and cleanup. + * + * jsdom does not include EventSource, so we mock it completely. + */ +import { renderHook, act, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useSSE } from './useSSE' + +// --------------------------------------------------------------------------- +// Mock EventSource — defined as a plain class so `new EventSource()` works +// --------------------------------------------------------------------------- +class MockEventSource { + url: string + onopen: (() => void) | null = null + onerror: ((evt: Event) => void) | null = null + onmessage: ((evt: MessageEvent) => void) | null = null + private listeners: Map void>> = new Map() + readyState: number = 0 + + constructor(url: string) { + this.url = url + } + + addEventListener(type: string, handler: (evt: Event) => void) { + if (!this.listeners.has(type)) this.listeners.set(type, []) + this.listeners.get(type)!.push(handler) + } + + removeEventListener() { /* no-op for tests */ } + + close() { + this.readyState = 2 + this.onopen = null + this.onerror = null + this.onmessage = null + this.listeners.clear() + } + + // Test helpers + _simulateOpen() { this.onopen?.() } + _simulateError() { this.onerror?.(new Event('error')) } + _simulateNamedEvent(type: string, data: string) { + const handlers = this.listeners.get(type) + if (handlers) { + const evt = new MessageEvent(type, { data }) as Event + handlers.forEach((h) => h(evt)) + } + } + _simulateMessage(data: string) { + this.onmessage?.(new MessageEvent('message', { data }) as MessageEvent) + } + + static readonly CONNECTING = 0 + static readonly OPEN = 1 + static readonly CLOSED = 2 +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- +let esInstances: MockEventSource[] + +describe('useSSE', () => { + beforeEach(() => { + esInstances = [] + // Replace global EventSource with our mock class + Object.defineProperty(globalThis, 'EventSource', { + // The mock must use a class for `new EventSource()` to work + value: class extends MockEventSource { + constructor(url: string) { + super(url) + esInstances.push(this) + } + }, + writable: true, + configurable: true, + }) + vi.useFakeTimers({ shouldAdvanceTime: true }) + }) + + afterEach(() => { + vi.restoreAllMocks() + vi.useRealTimers() + }) + + // ── Initial connection ────────────────────────────────────────────────── + it('starts in "connecting" state and creates an EventSource', () => { + const { result } = renderHook(() => useSSE({ url: '/api/events' })) + + expect(result.current.status).toBe('connecting') + expect(esInstances.length).toBeGreaterThanOrEqual(1) + expect(esInstances[0].url).toBe('/api/events') + }) + + it('transitions to "connected" on open', async () => { + const onOpen = vi.fn() + const { result } = renderHook(() => useSSE({ url: '/api/events', onOpen })) + + act(() => { esInstances[0]._simulateOpen() }) + + await waitFor(() => { + expect(result.current.status).toBe('connected') + }) + expect(onOpen).toHaveBeenCalledTimes(1) + }) + + // ── Reconnection with exponential back-off ────────────────────────────── + it('retries after error with exponential back-off', async () => { + const { result } = renderHook(() => + useSSE({ url: '/api/events', reconnectBaseMs: 1000, reconnectMaxMs: 30000 }), + ) + + // First error → reconnecting, retry at 1s + act(() => { esInstances[0]._simulateError() }) + await waitFor(() => { expect(result.current.status).toBe('reconnecting') }) + expect(esInstances).toHaveLength(1) + + // Advance 1000ms → second EventSource created + act(() => { vi.advanceTimersByTime(1000) }) + expect(esInstances).toHaveLength(2) + + // Second error → reconnecting, retry at 2s + act(() => { esInstances[1]._simulateError() }) + await waitFor(() => { expect(result.current.status).toBe('reconnecting') }) + act(() => { vi.advanceTimersByTime(2000) }) + expect(esInstances).toHaveLength(3) + + // Third error → reconnecting, retry at 4s + act(() => { esInstances[2]._simulateError() }) + act(() => { vi.advanceTimersByTime(4000) }) + expect(esInstances).toHaveLength(4) + }) + + it('caps reconnect delay at reconnectMaxMs', async () => { + renderHook(() => + useSSE({ url: '/api/events', reconnectBaseMs: 1000, reconnectMaxMs: 10000 }), + ) + + // Force 5 errors to push the exponent past the cap + for (let i = 0; i < 5; i++) { + act(() => { esInstances[i]._simulateError() }) + const expectedDelay = Math.min(1000 * 2 ** i, 10000) + act(() => { vi.advanceTimersByTime(expectedDelay) }) + } + + // 6 ES instances created (initial + 5 retries) + expect(esInstances).toHaveLength(6) + }) + + // ── Circuit-breaker (max retries) ─────────────────────────────────────── + it('transitions to "error" after reconnectLimit is exceeded', async () => { + const { result } = renderHook(() => + useSSE({ url: '/api/events', reconnectBaseMs: 100, reconnectLimit: 2 }), + ) + + // First error → reconnecting + act(() => { esInstances[0]._simulateError() }) + await waitFor(() => { expect(result.current.status).toBe('reconnecting') }) + + // Advance → retry + act(() => { vi.advanceTimersByTime(100) }) + + // Second error → reconnecting (attempt 2, still ≤ limit) + act(() => { esInstances[1]._simulateError() }) + await waitFor(() => { expect(result.current.status).toBe('reconnecting') }) + act(() => { vi.advanceTimersByTime(200) }) + + // Third error → limit exceeded (3 > 2) → error + act(() => { esInstances[2]._simulateError() }) + await waitFor(() => { expect(result.current.status).toBe('error') }) + }) + + it('resets reconnect counter on successful connection', async () => { + const { result } = renderHook(() => + useSSE({ url: '/api/events', reconnectBaseMs: 100, reconnectLimit: 3 }), + ) + + // Two errors then a successful connect + act(() => { esInstances[0]._simulateError() }) + act(() => { vi.advanceTimersByTime(100) }) + + act(() => { esInstances[1]._simulateOpen() }) + await waitFor(() => { expect(result.current.status).toBe('connected') }) + + // Now error again — counter should be reset, so we get fresh attempts + act(() => { esInstances[1]._simulateError() }) + await waitFor(() => { expect(result.current.status).toBe('reconnecting') }) + expect(result.current.status).toBe('reconnecting') + }) + + // ── Cleanup on unmount ─────────────────────────────────────────────────── + it('closes EventSource on unmount', () => { + const closeSpy = vi.spyOn(MockEventSource.prototype, 'close') + const { unmount } = renderHook(() => useSSE({ url: '/api/events' })) + + unmount() + expect(closeSpy).toHaveBeenCalled() + }) + + it('does not update state after unmount', async () => { + const { result, unmount } = renderHook(() => useSSE({ url: '/api/events' })) + + unmount() + + // These should be no-ops after unmount (mountedRef guards) + act(() => { esInstances[0]._simulateOpen() }) + act(() => { esInstances[0]._simulateError() }) + + // State should not have changed + expect(result.current.status).toBe('connecting') + }) + + // ── Event parsing ─────────────────────────────────────────────────────── + it('parses valid JSON data into objects', async () => { + const onMessage = vi.fn() + renderHook(() => useSSE({ url: '/api/events', onMessage })) + + act(() => { + esInstances[0]._simulateNamedEvent('agent.status', JSON.stringify({ agentId: 'a1', status: 'active' })) + }) + + expect(onMessage).toHaveBeenCalledWith({ + type: 'agent.status', + data: { agentId: 'a1', status: 'active' }, + }) + }) + + it('passes invalid JSON through as raw string', async () => { + const onMessage = vi.fn() + renderHook(() => useSSE({ url: '/api/events', onMessage })) + + act(() => { + esInstances[0]._simulateNamedEvent('agent.status', 'not valid json {{{') + }) + + expect(onMessage).toHaveBeenCalledWith({ + type: 'agent.status', + data: 'not valid json {{{', + }) + }) + + // ── enabled=false skips connection ────────────────────────────────────── + it('does not create EventSource when enabled=false', () => { + const { result } = renderHook(() => useSSE({ url: '/api/events', enabled: false })) + + expect(esInstances).toHaveLength(0) + expect(result.current.status).toBe('connecting') + }) + + // ── onError callback ──────────────────────────────────────────────────── + it('calls onError on connection failure', async () => { + const onError = vi.fn() + renderHook(() => + useSSE({ url: '/api/events', onError, reconnectBaseMs: 100 }), + ) + + act(() => { esInstances[0]._simulateError() }) + expect(onError).toHaveBeenCalledTimes(1) + }) + + // ── Default URL ───────────────────────────────────────────────────────── + it('uses /api/events as default URL', () => { + renderHook(() => useSSE()) + expect(esInstances[0].url).toBe('/api/events') + }) +}) diff --git a/frontend/src/hooks/useSSE.ts b/frontend/src/hooks/useSSE.ts index 3289e06..1c45307 100644 --- a/frontend/src/hooks/useSSE.ts +++ b/frontend/src/hooks/useSSE.ts @@ -18,12 +18,18 @@ export interface UseSSEOptions { onMessage?: (msg: SSEMessage) => void /** Called when connection opens or reconnects */ onOpen?: () => void - /** Called on unrecoverable error */ + /** Called on every connection error (both transient and terminal) */ onError?: (err: Event) => void /** Base delay in ms before the first reconnect attempt (default 1 000) */ reconnectBaseMs?: number /** Maximum reconnect delay in ms (default 30 000) */ reconnectMaxMs?: number + /** + * Maximum number of consecutive reconnect attempts before giving up. + * When the limit is reached, status transitions to 'error'. + * Default undefined (unlimited). + */ + reconnectLimit?: number /** Set false to disable auto-connect (useful in tests) */ enabled?: boolean } @@ -35,9 +41,14 @@ const SSE_EVENTS = ['agent.status', 'agent.task', 'agent.progress', 'fleet.updat * * Handles: * - Initial connection on mount - * - Exponential back-off reconnection on drop + * - Exponential back-off reconnection on drop (1s → 2s → 4s … capped at reconnectMaxMs) + * - Circuit-breaker: after reconnectLimit consecutive failures, transitions to 'error' * - Cleanup on unmount - * - All four event types: agent.status, agent.task, agent.progress, fleet.update + * - All five event types: agent.status, agent.task, agent.progress, fleet.update, connected + * + * The 'connected' SSE event is an application-level handshake sent by the backend + * after the transport opens. This is distinct from onOpen, which fires at the + * transport level when the EventSource HTTP connection is established. */ export function useSSE({ url = '/api/events', @@ -46,6 +57,7 @@ export function useSSE({ onError, reconnectBaseMs = 1_000, reconnectMaxMs = 30_000, + reconnectLimit, enabled = true, }: UseSSEOptions = {}): { status: SSEStatus } { const [status, setStatus] = useState('connecting') @@ -100,12 +112,20 @@ export function useSSE({ onErrorRef.current?.(evt) + reconnectAttemptRef.current += 1 + + // Circuit-breaker: give up after reconnectLimit consecutive failures + if (reconnectLimit !== undefined && reconnectAttemptRef.current > reconnectLimit) { + setStatus('error') + return + } + // Exponential back-off: 1s, 2s, 4s … capped at reconnectMaxMs + // Note: attempt is 1-based here (already incremented), so we use attempt-1 for the exponent const delay = Math.min( - reconnectBaseMs * 2 ** reconnectAttemptRef.current, + reconnectBaseMs * 2 ** (reconnectAttemptRef.current - 1), reconnectMaxMs, ) - reconnectAttemptRef.current += 1 setStatus('reconnecting') clearReconnectTimer() @@ -128,7 +148,8 @@ export function useSSE({ }) } - // Catch-all for unnamed events (type == 'message') + // Catch-all for unnamed events (type == 'message'). + // Won't fire for the named events registered via addEventListener above. es.onmessage = (evt: MessageEvent) => { if (!mountedRef.current) return let data: unknown = evt.data @@ -139,7 +160,7 @@ export function useSSE({ } onMessageRef.current?.({ type: 'message', data }) } - }, [url, enabled, reconnectBaseMs, reconnectMaxMs, clearReconnectTimer]) + }, [url, enabled, reconnectBaseMs, reconnectMaxMs, reconnectLimit, clearReconnectTimer]) useEffect(() => { mountedRef.current = true diff --git a/frontend/src/services/sse.ts b/frontend/src/services/sse.ts index 63e2afb..6243e9f 100644 --- a/frontend/src/services/sse.ts +++ b/frontend/src/services/sse.ts @@ -4,6 +4,11 @@ * Event format on the wire: * event: * data: + * + * The types below define the backend contract. The SSEPayloadMap maps + * each event type string to its expected payload shape. SSEMessage is a + * discriminated union on `type` — when you switch on msg.type, TypeScript + * narrows msg.data to the correct payload interface automatically. */ import type { AgentStatus } from '../types' @@ -53,3 +58,15 @@ export type SSEPayloadMap = { connected: { clientCount: number } message: unknown } + +/** + * Discriminated SSE message — the `type` field narrows `data` via SSEPayloadMap. + * + * Usage: + * if (msg.type === 'agent.status') { + * msg.data.agentId // ✅ TypeScript knows this is AgentStatusEvent + * } + */ +export type SSEMessage = { + [K in keyof SSEPayloadMap]: { type: K; data: SSEPayloadMap[K] } +}[keyof SSEPayloadMap] diff --git a/frontend/src/test-setup.ts b/frontend/src/test-setup.ts new file mode 100644 index 0000000..c44951a --- /dev/null +++ b/frontend/src/test-setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom' diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 64c6f46..e04426d 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1,4 +1,4 @@ -export type AgentStatus = 'active' | 'idle' | 'thinking' | 'error' +export type AgentStatus = 'active' | 'idle' | 'thinking' | 'error' | 'offline' export interface Agent { id: string diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json index 7f42e5f..dccb228 100644 --- a/frontend/tsconfig.app.json +++ b/frontend/tsconfig.app.json @@ -4,7 +4,7 @@ "target": "es2023", "lib": ["ES2023", "DOM"], "module": "esnext", - "types": ["vite/client"], + "types": ["vite/client", "vitest/globals"], "skipLibCheck": true, /* Bundler mode */ diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json index d3c52ea..5a66f0e 100644 --- a/frontend/tsconfig.node.json +++ b/frontend/tsconfig.node.json @@ -20,5 +20,5 @@ "erasableSyntaxOnly": true, "noFallthroughCasesInSwitch": true }, - "include": ["vite.config.ts"] + "include": ["vite.config.ts", "vitest.config.ts"] } diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 0000000..45cc983 --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + test: { + environment: 'jsdom', + globals: true, + setupFiles: ['./src/test-setup.ts'], + }, +})