Files
Control-Center/go-backend/internal/gateway/client.go
Joshua e8ced74429
All checks were successful
Dev Build / build-test (pull_request) Successful in 2m23s
CUB-123: integrate gateway, wire PostgreSQL repositories, add SSE streaming
- Create repository/ package with pgx-backed CRUD for agents, sessions, tasks, projects
- Define AgentRepo/SessionRepo/TaskRepo/ProjectRepo interfaces
- Update handler to use repository interfaces instead of in-memory stores
- Add SSE broker with GET /api/events endpoint (text/event-stream)
- Add gateway client that polls OpenClaw for agent states
- Add GATEWAY_URL and GATEWAY_POLL_INTERVAL config fields
- Seed 5 demo agents (Otto, Rex, Dex, Hex, Pip) on empty DB
- Update router to wire SSE broker
- All 21 handler tests pass with mock repos
2026-05-08 19:58:06 -04:00

199 lines
5.5 KiB
Go

// Package gateway provides an OpenClaw gateway integration client that
// polls agent states, persists them via the repository layer, and broadcasts
// changes through the SSE broker for real-time frontend updates.
package gateway
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"time"
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/handler"
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/repository"
)
// Client polls the OpenClaw gateway for agent status and keeps the database
// and SSE broker in sync.
type Client struct {
url string
pollInterval time.Duration
httpClient *http.Client
agents repository.AgentRepo
broker *handler.Broker
}
// Config holds gateway client configuration, typically loaded from environment.
type Config struct {
URL string
PollInterval time.Duration
}
// DefaultConfig returns sensible defaults for local development.
func DefaultConfig() Config {
return Config{
URL: "http://localhost:18789/api/agents",
PollInterval: 5 * time.Second,
}
}
// NewClient returns a gateway client wired to the given repository and broker.
func NewClient(cfg Config, agents repository.AgentRepo, broker *handler.Broker) *Client {
return &Client{
url: cfg.URL,
pollInterval: cfg.PollInterval,
httpClient: &http.Client{Timeout: 10 * time.Second},
agents: agents,
broker: broker,
}
}
// 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())
ticker := time.NewTicker(c.pollInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
slog.Info("gateway client stopped")
return
case <-ticker.C:
c.poll(ctx)
}
}
}
// poll fetches agent states from the gateway and syncs to the database.
func (c *Client) poll(ctx context.Context) {
resp, err := c.httpClient.Get(c.url)
if err != nil {
slog.Warn("gateway poll failed", "error", err)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
slog.Warn("gateway returned non-200", "status", resp.StatusCode)
return
}
var agents []models.AgentCardData
if err := json.NewDecoder(resp.Body).Decode(&agents); err != nil {
slog.Warn("gateway response parse failed", "error", err)
return
}
for _, ga := range agents {
// Check if agent already exists; if so, update; otherwise create.
existing, err := c.agents.Get(ctx, ga.ID)
if err != nil {
// Not found — create it
if err := c.agents.Create(ctx, ga); err != nil {
slog.Warn("gateway agent create failed", "id", ga.ID, "error", err)
continue
}
slog.Info("gateway agent created", "id", ga.ID, "status", ga.Status)
c.broker.Broadcast("agent.status", ga)
continue
}
// If status changed, update and broadcast
if existing.Status != ga.Status {
updated, err := c.agents.Update(ctx, ga.ID, models.UpdateAgentRequest{
Status: &ga.Status,
})
if err != nil {
slog.Warn("gateway agent update failed", "id", ga.ID, "error", err)
continue
}
c.broker.Broadcast("agent.status", updated)
slog.Debug("agent status changed",
"id", ga.ID,
"from", existing.Status,
"to", ga.Status)
}
}
}
// SeedDemoAgents inserts the five known demo agents if the agents table is
// empty. Call this once on application startup after migrations have run.
func SeedDemoAgents(ctx context.Context, agents repository.AgentRepo) error {
count, err := agents.Count(ctx)
if err != nil {
return fmt.Errorf("count agents for seeding: %w", err)
}
if count > 0 {
return nil // already seeded
}
slog.Info("seeding demo agents")
demoAgents := []models.AgentCardData{
{
ID: "otto",
DisplayName: "Otto",
Role: "Orchestrator",
Status: models.AgentStatusActive,
CurrentTask: strPtr("Orchestrating tasks"),
SessionKey: "otto-session",
Channel: "discord",
LastActivity: time.Now().UTC().Format(time.RFC3339),
},
{
ID: "rex",
DisplayName: "Rex",
Role: "Frontend Dev",
Status: models.AgentStatusIdle,
SessionKey: "rex-session",
Channel: "discord",
LastActivity: time.Now().UTC().Add(-10 * time.Minute).Format(time.RFC3339),
},
{
ID: "dex",
DisplayName: "Dex",
Role: "Backend Dev",
Status: models.AgentStatusThinking,
CurrentTask: strPtr("Designing API contracts"),
SessionKey: "dex-session",
Channel: "discord",
LastActivity: time.Now().UTC().Format(time.RFC3339),
},
{
ID: "hex",
DisplayName: "Hex",
Role: "Database Specialist",
Status: models.AgentStatusActive,
CurrentTask: strPtr("Reviewing schema migrations"),
SessionKey: "hex-session",
Channel: "discord",
LastActivity: time.Now().UTC().Format(time.RFC3339),
},
{
ID: "pip",
DisplayName: "Pip",
Role: "Edge Device Dev",
Status: models.AgentStatusIdle,
SessionKey: "pip-session",
Channel: "discord",
LastActivity: time.Now().UTC().Add(-1 * time.Hour).Format(time.RFC3339),
},
}
for _, a := range demoAgents {
if err := agents.Create(ctx, a); err != nil {
return fmt.Errorf("seed agent %s: %w", a.ID, err)
}
}
slog.Info("demo agents seeded", "count", len(demoAgents))
return nil
}
func strPtr(s string) *string { return &s }