Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4509b0c217 | |||
| f3ce08497a | |||
| fd60b0bb57 | |||
| b7b05bb4e3 | |||
| d370d5ec23 | |||
| 1b82e1d3a6 | |||
| 93bf434a47 | |||
| 010408cc45 | |||
| 23f9d4a8fb | |||
| 3d5bf16d37 | |||
| d9a1640b10 | |||
| 5347944c4c | |||
| 20404b30bb | |||
| 6fd2d9bec4 | |||
| 439741e55f | |||
| 3c26b8deba | |||
| d28d6e8dac |
+1
-1
@@ -32,7 +32,7 @@ GATEWAY_POLL_INTERVAL=5s
|
|||||||
# When using docker-compose, these are set in the services section
|
# When using docker-compose, these are set in the services section
|
||||||
# See docker-compose.yml for service-specific environment variables
|
# See docker-compose.yml for service-specific environment variables
|
||||||
|
|
||||||
# ── Database Configuration ─────────────────────────────────────────────
|
# ── Database Configuration ───────────────────────────────────────────────
|
||||||
# Set in the db service environment section of docker-compose.yml
|
# Set in the db service environment section of docker-compose.yml
|
||||||
# POSTGRES_USER=controlcenter
|
# POSTGRES_USER=controlcenter
|
||||||
# POSTGRES_PASSWORD=controlcenter
|
# POSTGRES_PASSWORD=controlcenter
|
||||||
|
|||||||
+304
@@ -0,0 +1,304 @@
|
|||||||
|
# Control Center — Project Context
|
||||||
|
|
||||||
|
> **Last updated:** 2026-05-21
|
||||||
|
> **Repo:** `CubeCraft-Creations/Control-Center` | **Host:** `code.cubecraftcreations.com`
|
||||||
|
> **Local clone:** `/mnt/ai-storage/projects/Control-Center` | **Default branch:** `dev`
|
||||||
|
> **Discord:** `DISCORD_DEV_CONTROL_CENTER_CHANNEL_ID`
|
||||||
|
> **Linear Epic:** [CUB-119](https://linear.app/cubecraft-creations/issue/CUB-119)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Real-time dashboard for monitoring and controlling the OpenClaw agent fleet. Displays agent statuses, active tasks, sessions, and projects. Uses SSE for live updates from the Go backend, which connects to the OpenClaw gateway via WebSocket for live agent data.
|
||||||
|
|
||||||
|
**Completed refactor:** ASP.NET Core + Angular → Go + React is done (CUB-119 epic). All legacy code is removed from git.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
| Layer | Technology | Notes |
|
||||||
|
|-------|-----------|-------|
|
||||||
|
| Backend | Go 1.24+ | Chi router, pgx (PostgreSQL), SSE broker, gorilla/websocket |
|
||||||
|
| Frontend | React 18 + TypeScript | Vite, Tailwind CSS, React Query, TanStack Router |
|
||||||
|
| Database | PostgreSQL 16+ | snake_case naming, 2 migrations |
|
||||||
|
| Real-time | SSE + WebSocket | SSE for browser, WebSocket for OpenClaw gateway |
|
||||||
|
| Gateway Integration | WebSocket client | OpenClaw gateway `/ws` — live agent + session RPC |
|
||||||
|
| API Client | TypeScript SDK | `api-client/` — shared models, WS client, HTTP client |
|
||||||
|
| CI/CD | Gitea Actions | `.gitea/workflows/dev.yml`, `deploy-dev.yaml` |
|
||||||
|
| Deployment | Docker Compose | PostgreSQL + Go backend + React/nginx |
|
||||||
|
| Testing | Vitest, Go test | Unit + integration tests for WS client, gateway, handlers |
|
||||||
|
| Design | Kiosk layout | Bottom nav (mobile), nav rail (desktop), quick-jump drawer |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
OpenClaw Gateway (WebSocket)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Go Backend (Chi + pgx)
|
||||||
|
├── Gateway WS Client (connect, reconnect, agents.list, sessions.list RPC)
|
||||||
|
├── SSE Broker (fan-out: agent.status, agent.task, fleet.update)
|
||||||
|
├── REST API (/api/agents, /api/sessions, /api/tasks, /api/projects)
|
||||||
|
└── Repository/Store layers → PostgreSQL
|
||||||
|
│
|
||||||
|
├── SSE /api/events
|
||||||
|
▼
|
||||||
|
React Frontend
|
||||||
|
├── SSEProvider → useRealtimeSync → React Query cache
|
||||||
|
├── HubPage (dashboard), LogsPage, ProjectsPage, SessionsPage, SettingsPage
|
||||||
|
└── Layout (header bar + nav rail + bottom nav + quick-jump drawer)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Architecture Decisions
|
||||||
|
1. **Go replaced ASP.NET Core** — lighter runtime, faster cold-start, better concurrency for gateway polling
|
||||||
|
2. **React replaced Angular** — lighter than Angular for dashboard/kiosk use
|
||||||
|
3. **SSE over SignalR** — simpler server-side, unidirectional events sufficient for browser updates
|
||||||
|
4. **WebSocket for gateway integration** — bidirectional needed for RPC (agents.list, sessions.list)
|
||||||
|
5. **PostgreSQL** — shared with Extrudex pattern; migrations in `go-backend/migrations/`
|
||||||
|
6. **Agent state seeded on first boot** via `gateway.SeedDemoAgents` for offline dev
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
Control-Center/
|
||||||
|
├── go-backend/ # Go backend
|
||||||
|
│ ├── cmd/server/main.go # Entrypoint, wire deps, start gateway poller
|
||||||
|
│ ├── Dockerfile / go.mod / go.sum
|
||||||
|
│ ├── migrations/ # 001_initial_schema, 002_add_indexes
|
||||||
|
│ └── internal/
|
||||||
|
│ ├── config/config.go # Env vars (DATABASE_URL, GATEWAY_URL, etc.)
|
||||||
|
│ ├── db/db.go # PostgreSQL pool (pgx)
|
||||||
|
│ ├── gateway/
|
||||||
|
│ │ ├── client.go # GW poller → sync DB + SSE fan-out
|
||||||
|
│ │ ├── events.go # SSE event broker
|
||||||
|
│ │ ├── events_test.go
|
||||||
|
│ │ ├── sync.go # Initial sync from gateway
|
||||||
|
│ │ ├── sync_test.go
|
||||||
|
│ │ ├── wsclient.go # WebSocket client (handshake, connect, reconnect, RPC)
|
||||||
|
│ │ └── wsclient_test.go
|
||||||
|
│ ├── handler/
|
||||||
|
│ │ ├── agent.go # CRUD + history
|
||||||
|
│ │ ├── project.go # List projects
|
||||||
|
│ │ ├── session.go # List sessions
|
||||||
|
│ │ ├── sse.go # SSE broker: subscribe + broadcast
|
||||||
|
│ │ ├── task.go # List tasks
|
||||||
|
│ │ ├── helpers.go
|
||||||
|
│ │ ├── handler_test.go
|
||||||
|
│ │ └── mock_repos_test.go
|
||||||
|
│ ├── models/models.go # Domain types
|
||||||
|
│ ├── repository/ # DB access layer + interfaces
|
||||||
|
│ ├── router/router.go # Chi router: REST + SSE mount
|
||||||
|
│ └── store/ # Agent, Project, Session, Task stores
|
||||||
|
├── api-client/ # Shared TypeScript SDK
|
||||||
|
│ └── src/
|
||||||
|
│ ├── models/types.ts # Agent, Session, Task, Project, SSE event types
|
||||||
|
│ ├── services/http-client.ts # Axios REST client
|
||||||
|
│ ├── utils/
|
||||||
|
│ │ ├── config.ts # Client config
|
||||||
|
│ │ └── status-mapper.ts # Agent status → display mapping
|
||||||
|
│ └── websocket/
|
||||||
|
│ └── ws-client.ts # WebSocket client (handshake, Send, RPC, reconnector)
|
||||||
|
├── frontend/ # React frontend
|
||||||
|
│ ├── Dockerfile + nginx.conf
|
||||||
|
│ ├── package.json + vite.config.ts
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── App.tsx / main.tsx
|
||||||
|
│ │ ├── components/
|
||||||
|
│ │ │ ├── ErrorBoundary.tsx
|
||||||
|
│ │ │ └── Layout.tsx # Header bar + nav rail + bottom nav + quick-jump
|
||||||
|
│ │ ├── contexts/
|
||||||
|
│ │ │ └── SSEContext.tsx # SSEProvider — wraps entire app
|
||||||
|
│ │ ├── hooks/
|
||||||
|
│ │ │ ├── useLocalStorage.ts
|
||||||
|
│ │ │ ├── useRealtimeSync.ts # SSE messages → React Query cache
|
||||||
|
│ │ │ ├── useRealtimeSync.test.tsx
|
||||||
|
│ │ │ ├── useSSE.ts # SSE: connect, reconnect, typed events
|
||||||
|
│ │ │ ├── useSSE.test.ts
|
||||||
|
│ │ │ └── useTheme.tsx
|
||||||
|
│ │ ├── pages/
|
||||||
|
│ │ │ ├── HubPage.tsx # Fleet dashboard (agent grid + stats)
|
||||||
|
│ │ │ ├── LogsPage.tsx # Agent log viewer
|
||||||
|
│ │ │ ├── ProjectsPage.tsx # Project list
|
||||||
|
│ │ │ ├── SessionsPage.tsx # Session list
|
||||||
|
│ │ │ └── SettingsPage.tsx # Settings + theme toggle
|
||||||
|
│ │ ├── services/
|
||||||
|
│ │ │ ├── api.ts # Axios REST client
|
||||||
|
│ │ │ └── sse.ts # SSE utilities
|
||||||
|
│ │ └── types/index.ts
|
||||||
|
│ └── vitest.config.ts
|
||||||
|
├── frontend-legacy/ # Original Angular frontend (kept for reference, not in git)
|
||||||
|
├── backend/ # Original ASP.NET backend (kept for reference, not in git)
|
||||||
|
│ ├── ControlCenter/ # ASP.NET Core project
|
||||||
|
│ └── Api/ # API layer
|
||||||
|
├── design/
|
||||||
|
│ ├── command-hub-spec.md # Detailed design spec
|
||||||
|
│ └── mockups/ # Desktop kiosk, mobile, quick-jump drawer
|
||||||
|
├── kiosk/
|
||||||
|
│ ├── control-center-kiosk.service # Systemd service
|
||||||
|
│ └── start-kiosk.sh # Kiosk startup script
|
||||||
|
├── reference/
|
||||||
|
│ └── CONTROL_CENTER_CONTEXT.md # Older context file (superseded by this one)
|
||||||
|
├── ci-image/Dockerfile # CI build image
|
||||||
|
├── docker-compose.yml
|
||||||
|
└── .env.example
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database Schema (PostgreSQL)
|
||||||
|
|
||||||
|
### agents
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| id | UUID PK | |
|
||||||
|
| display_name | VARCHAR(256) NOT NULL | |
|
||||||
|
| role | VARCHAR(256) | |
|
||||||
|
| status | VARCHAR(32) | active, idle, thinking, error |
|
||||||
|
| current_task | VARCHAR(512) | |
|
||||||
|
| task_progress | INTEGER DEFAULT 0 | |
|
||||||
|
| session_key | VARCHAR(256) | |
|
||||||
|
| channel | VARCHAR(256) | |
|
||||||
|
| last_activity | TIMESTAMP | |
|
||||||
|
| error_message | TEXT | |
|
||||||
|
| created_at | TIMESTAMP | |
|
||||||
|
| updated_at | TIMESTAMP | |
|
||||||
|
|
||||||
|
### sessions
|
||||||
|
| Column | Type |
|
||||||
|
|--------|------|
|
||||||
|
| id | UUID PK |
|
||||||
|
| session_key | VARCHAR(256) UNIQUE |
|
||||||
|
| agent_id | UUID FK → agents |
|
||||||
|
| channel | VARCHAR(256) |
|
||||||
|
| status | VARCHAR(32) |
|
||||||
|
| context_tokens | INTEGER |
|
||||||
|
| total_tokens | INTEGER |
|
||||||
|
| estimated_cost | NUMERIC |
|
||||||
|
| model | VARCHAR(256) |
|
||||||
|
| started_at | TIMESTAMP |
|
||||||
|
| last_activity_at | TIMESTAMP |
|
||||||
|
|
||||||
|
### tasks
|
||||||
|
| Column | Type |
|
||||||
|
|--------|------|
|
||||||
|
| id | UUID PK |
|
||||||
|
| agent_id | UUID FK → agents |
|
||||||
|
| title | VARCHAR(512) |
|
||||||
|
| description | TEXT |
|
||||||
|
| status | VARCHAR(32) |
|
||||||
|
| progress | INTEGER DEFAULT 0 |
|
||||||
|
| session_key | VARCHAR(256) |
|
||||||
|
| created_at, updated_at | TIMESTAMP |
|
||||||
|
|
||||||
|
### projects
|
||||||
|
| Column | Type |
|
||||||
|
|--------|------|
|
||||||
|
| id | UUID PK |
|
||||||
|
| name | VARCHAR(256) UNIQUE |
|
||||||
|
| description | TEXT |
|
||||||
|
| status | VARCHAR(32) |
|
||||||
|
| agent_ids | TEXT[] |
|
||||||
|
| created_at, updated_at | TIMESTAMP |
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| GET | /health | Health check (probes DB) |
|
||||||
|
| GET | /api/agents | List all agents |
|
||||||
|
| POST | /api/agents | Create agent |
|
||||||
|
| GET | /api/agents/:id | Get agent detail |
|
||||||
|
| PUT | /api/agents/:id | Update agent |
|
||||||
|
| DELETE | /api/agents/:id | Delete agent |
|
||||||
|
| GET | /api/agents/:id/history | Agent status history |
|
||||||
|
| GET | /api/sessions | List sessions |
|
||||||
|
| GET | /api/tasks | List tasks |
|
||||||
|
| GET | /api/projects | List projects |
|
||||||
|
| GET | /api/events | SSE event stream |
|
||||||
|
|
||||||
|
## SSE Events
|
||||||
|
|
||||||
|
| Event Type | Payload |
|
||||||
|
|------------|---------|
|
||||||
|
| `agent.status` | Agent status change |
|
||||||
|
| `agent.task` | Agent current task updated |
|
||||||
|
| `agent.progress` | Task progress percentage |
|
||||||
|
| `fleet.update` | Full fleet snapshot |
|
||||||
|
| `connected` | Connection established |
|
||||||
|
|
||||||
|
## CI/CD Pipeline
|
||||||
|
|
||||||
|
### dev.yml
|
||||||
|
- Lint + typecheck: Go vet + golangci-lint + tsc
|
||||||
|
- Test: Go test + vitest
|
||||||
|
- Build: Go build + npm build
|
||||||
|
- Docker: Build and push images
|
||||||
|
- Triggers: push to dev/main
|
||||||
|
|
||||||
|
### deploy-dev.yaml
|
||||||
|
- Workflow dispatch
|
||||||
|
- SCP deploy script to dev host
|
||||||
|
- systemctl restart with rollback
|
||||||
|
|
||||||
|
## Docker Compose
|
||||||
|
|
||||||
|
| Service | Image/Build | Ports | Depends On |
|
||||||
|
|---------|-------------|-------|------------|
|
||||||
|
| postgres | postgres:16-alpine | 5432 | — |
|
||||||
|
| backend | ./go-backend/Dockerfile | 8080 | postgres (healthy) |
|
||||||
|
| frontend | ./frontend/Dockerfile | 3000 | backend (healthy) |
|
||||||
|
|
||||||
|
## Linear Issue Map
|
||||||
|
|
||||||
|
| CUB | Title | Status |
|
||||||
|
|-----|-------|--------|
|
||||||
|
| 119 | **Epic: Control Center Refactor — .NET → Go + React** | Todo |
|
||||||
|
| 120 | PostgreSQL schema + migrations | Done |
|
||||||
|
| 121 | React pages wired to real API | Done |
|
||||||
|
| 122 | React frontend scaffold | Done |
|
||||||
|
| 123 | Gateway integration + SSE streaming | Done |
|
||||||
|
| 124 | Go backend scaffold | Done |
|
||||||
|
| 125 | Real-time SSE frontend | Done |
|
||||||
|
| 126 | Docker Compose deployment | Done |
|
||||||
|
| 127 | CRUD API endpoints | Done |
|
||||||
|
| 200 | Live WebSocket gateway client (CUB-200-207 sub-epic) | In Review |
|
||||||
|
| 201 | agents.list + sessions.list RPC and data mapping | In Review |
|
||||||
|
| 202 | Real-time event subscription + SSE fan-out | In Review |
|
||||||
|
| 203 | WS client scaffold — handshake, connect, reconnect loop | In Review |
|
||||||
|
| 204 | Config, wiring, and graceful fallback | In Review |
|
||||||
|
| 205 | Unit tests — gateway utility functions | In Review |
|
||||||
|
| 206 | Unit tests — WSClient handshake, Send/RPC, frame router, reconnect | In Review |
|
||||||
|
| 207 | Unit tests — event handlers and initial sync | In Review |
|
||||||
|
|
||||||
|
### Legacy Issues (Angular/ASP.NET — all Done)
|
||||||
|
CUB-19 through CUB-63: All 27 Control Center issues completed, including minion mapping, breakroom UI, dark mode theme, agent cards, quick-jump drawer, adaptive nav, SignalR hub, and status animations.
|
||||||
|
|
||||||
|
## Known Limitations / Next Steps
|
||||||
|
|
||||||
|
1. Agent detail/history views are scaffolded but not fully implemented
|
||||||
|
2. 16-bit minion breakroom concept (CUB-59-63) was on Angular — needs React port if desired
|
||||||
|
3. `.env` must be created from `.env.example` with a valid `GATEWAY_URL` for live agent data
|
||||||
|
4. Docker containers not currently running — start with `docker compose up --build -d`
|
||||||
|
|
||||||
|
## Default Agent Assignments
|
||||||
|
|
||||||
|
| Area | Agent | Notes |
|
||||||
|
|------|-------|-------|
|
||||||
|
| Backend (Go API, Gateway WS, SSE) | Dex | gitea-dex MCP |
|
||||||
|
| Database (PostgreSQL schema) | Hex | gitea-hex MCP |
|
||||||
|
| Frontend (React, Tailwind) | Rex | gitea-rex MCP |
|
||||||
|
| Design (wireframes, UX) | Sketch | |
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/ai-storage/projects/Control-Center
|
||||||
|
git checkout dev
|
||||||
|
git pull origin dev
|
||||||
|
|
||||||
|
# Docker Compose (recommended)
|
||||||
|
cp .env.example .env # edit GATEWAY_URL first
|
||||||
|
docker compose up --build -d
|
||||||
|
|
||||||
|
# Manual
|
||||||
|
cd go-backend && go run cmd/server/main.go # → :8080
|
||||||
|
cd frontend && npm install && npm run dev # → :5173 (Vite proxy to :8080)
|
||||||
|
```
|
||||||
@@ -112,7 +112,7 @@ func main() {
|
|||||||
<-quit
|
<-quit
|
||||||
slog.Info("shutting down server...")
|
slog.Info("shutting down server...")
|
||||||
|
|
||||||
cancel() // stop gateway polling
|
cancel() // stop gateway clients
|
||||||
|
|
||||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second)
|
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
defer shutdownCancel()
|
defer shutdownCancel()
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
// Package gateway provides an OpenClaw gateway integration client that
|
// Package gateway provides an OpenClaw gateway integration client that
|
||||||
// polls agent states, persists them via the repository layer, and broadcasts
|
// polls agent states, persists them via the repository layer, and broadcasts
|
||||||
// changes through the SSE broker for real-time frontend updates.
|
// changes through the SSE broker for real-time frontend updates.
|
||||||
|
//
|
||||||
|
// When a WSClient is wired via SetWSClient, the REST poller becomes a
|
||||||
|
// fallback: it waits for the WS client to signal readiness, and only starts
|
||||||
|
// polling if WS fails to connect within 30 seconds.
|
||||||
package gateway
|
package gateway
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -140,7 +144,6 @@ func (c *Client) poll(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, ga := range agents {
|
for _, ga := range agents {
|
||||||
// Check if agent already exists; if so, update; otherwise create.
|
|
||||||
existing, err := c.agents.Get(ctx, ga.ID)
|
existing, err := c.agents.Get(ctx, ga.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Not found — create it
|
// Not found — create it
|
||||||
|
|||||||
@@ -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, _, 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -229,7 +229,34 @@ func (c *WSClient) connectAndRun(ctx context.Context) error {
|
|||||||
c.connId = helloOK.ConnID
|
c.connId = helloOK.ConnID
|
||||||
c.connMu.Unlock()
|
c.connMu.Unlock()
|
||||||
|
|
||||||
// Notify REST client that WS is live so it stands down
|
// Step 2b: Register live event handlers BEFORE starting the read
|
||||||
|
// loop. This eliminates the race window where readLoop dispatches
|
||||||
|
// live events as "unhandled" because no handlers are registered yet.
|
||||||
|
// The handlers only depend on c.agents and c.broker, which are wired
|
||||||
|
// in the constructor — they do not need initialSync to have completed.
|
||||||
|
c.registerEventHandlers()
|
||||||
|
|
||||||
|
// Step 2c: Start the read loop in a goroutine so that Send() in
|
||||||
|
// initialSync can receive responses. The read loop goroutine will
|
||||||
|
// continue running after initialSync completes, routing live events
|
||||||
|
// and any future RPC responses. Because handlers are already
|
||||||
|
// registered, any events arriving during or after initialSync are
|
||||||
|
// dispatched correctly.
|
||||||
|
readLoopErrCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
readLoopErrCh <- c.readLoop(ctx, conn)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Step 2d: Initial sync — fetch agents + sessions from gateway.
|
||||||
|
// This works because the read loop is active and will route
|
||||||
|
// response frames back to Send() via handleResponse.
|
||||||
|
if err := c.initialSync(ctx); err != nil {
|
||||||
|
c.logger.Warn("initial sync failed, will continue with read loop", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify REST client that WS is live so it stands down.
|
||||||
|
// This must happen AFTER initialSync so that the REST poller
|
||||||
|
// doesn't start polling while we're still syncing.
|
||||||
if c.restClient != nil {
|
if c.restClient != nil {
|
||||||
c.restClient.MarkWSReady()
|
c.restClient.MarkWSReady()
|
||||||
c.logger.Info("ws client notified REST fallback to stand down")
|
c.logger.Info("ws client notified REST fallback to stand down")
|
||||||
@@ -238,16 +265,9 @@ func (c *WSClient) connectAndRun(ctx context.Context) error {
|
|||||||
// Reset wsReadyOnce so MarkWSReady can fire again after a reconnect
|
// Reset wsReadyOnce so MarkWSReady can fire again after a reconnect
|
||||||
c.wsReadyOnce = sync.Once{}
|
c.wsReadyOnce = sync.Once{}
|
||||||
|
|
||||||
// Step 2b: Initial sync — fetch agents + sessions from gateway
|
// Step 3: Wait for the read loop goroutine to finish (blocks
|
||||||
if err := c.initialSync(ctx); err != nil {
|
// until the connection drops or context is cancelled).
|
||||||
c.logger.Warn("initial sync failed, will continue with read loop", "error", err)
|
return <-readLoopErrCh
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2c: Register live event handlers
|
|
||||||
c.registerEventHandlers()
|
|
||||||
|
|
||||||
// Step 3: Read loop
|
|
||||||
return c.readLoop(ctx, conn)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// readChallenge reads the first frame from the gateway, which must be a
|
// readChallenge reads the first frame from the gateway, which must be a
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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/models"
|
||||||
|
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
@@ -466,6 +467,236 @@ func TestAgentItemToCard(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 6. Test: Initial sync ordering (readLoop active before Send) ──────────
|
||||||
|
|
||||||
|
// TestConnectAndRun_InitialSyncOrdering verifies that the WS client
|
||||||
|
// completes initial sync successfully. This test would hang/timeout if
|
||||||
|
// readLoop were NOT started before initialSync, because Send() relies on
|
||||||
|
// readLoop→routeFrame→handleResponse to deliver RPC responses.
|
||||||
|
func TestConnectAndRun_InitialSyncOrdering(t *testing.T) {
|
||||||
|
repo := &mockAgentRepo{agents: make(map[string]models.AgentCardData)}
|
||||||
|
broker := handler.NewBroker()
|
||||||
|
capture := newBroadcastCapture(broker)
|
||||||
|
defer capture.close()
|
||||||
|
|
||||||
|
srv := newTestWSServer(t, func(conn *websocket.Conn) {
|
||||||
|
// Handshake
|
||||||
|
handleHandshake(t, conn)
|
||||||
|
|
||||||
|
// After handshake, respond to RPCs
|
||||||
|
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{
|
||||||
|
{"id": "otto", "name": "Otto", "role": "Orchestrator", "channel": "discord"},
|
||||||
|
{"id": "dex", "name": "Dex", "role": "Backend Dev", "channel": "telegram"},
|
||||||
|
}
|
||||||
|
case "sessions.list":
|
||||||
|
result = []map[string]any{
|
||||||
|
{"sessionKey": "s1", "agentId": "otto", "status": "running", "totalTokens": 500, "lastActivityAt": "2025-05-20T12:00:00Z"},
|
||||||
|
}
|
||||||
|
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"}, repo, broker, slog.Default())
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
client.Start(ctx)
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait for initial sync to complete by checking repo state.
|
||||||
|
// The agents should be persisted from the RPC responses.
|
||||||
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
repo.mu.Lock()
|
||||||
|
_, ottoOK := repo.agents["otto"]
|
||||||
|
_, dexOK := repo.agents["dex"]
|
||||||
|
repo.mu.Unlock()
|
||||||
|
if ottoOK && dexOK {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
repo.mu.Lock()
|
||||||
|
_, ottoOK := repo.agents["otto"]
|
||||||
|
_, dexOK := repo.agents["dex"]
|
||||||
|
repo.mu.Unlock()
|
||||||
|
|
||||||
|
if !ottoOK {
|
||||||
|
t.Error("otto not found in repo after initial sync — readLoop may not have been active before Send()")
|
||||||
|
}
|
||||||
|
if !dexOK {
|
||||||
|
t.Error("dex not found in repo after initial sync — readLoop may not have been active before Send()")
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("WSClient did not shut down cleanly")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 7. Test: Event not lost during initial sync (regression) ───────────────
|
||||||
|
|
||||||
|
// TestConnectAndRun_EventNotLostDuringSync verifies that live gateway events
|
||||||
|
// arriving during initial sync are NOT dropped. This is a regression test
|
||||||
|
// for the race where readLoop started before registerEventHandlers(),
|
||||||
|
// causing events read during that window to be logged as "unhandled" and lost.
|
||||||
|
//
|
||||||
|
// The mock server sends a live event (sessions.changed) right after the
|
||||||
|
// handshake, interleaved with the RPC responses for agents.list and
|
||||||
|
// sessions.list. The test asserts the event is received by the handler.
|
||||||
|
func TestConnectAndRun_EventNotLostDuringSync(t *testing.T) {
|
||||||
|
repo := &mockAgentRepo{agents: make(map[string]models.AgentCardData)}
|
||||||
|
broker := handler.NewBroker()
|
||||||
|
capture := newBroadcastCapture(broker)
|
||||||
|
defer capture.close()
|
||||||
|
|
||||||
|
// Pre-seed an agent so the event handler can update it.
|
||||||
|
repo.agents["otto"] = models.AgentCardData{
|
||||||
|
ID: "otto",
|
||||||
|
DisplayName: "Otto",
|
||||||
|
Status: models.AgentStatusIdle,
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := newTestWSServer(t, func(conn *websocket.Conn) {
|
||||||
|
// Handshake
|
||||||
|
handleHandshake(t, conn)
|
||||||
|
|
||||||
|
// After handshake, process RPCs and inject a live event.
|
||||||
|
for {
|
||||||
|
var req map[string]any
|
||||||
|
if err := conn.ReadJSON(&req); err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
reqID, _ := req["id"].(string)
|
||||||
|
method, _ := req["method"].(string)
|
||||||
|
|
||||||
|
// Respond to agents.list RPC
|
||||||
|
if method == "agents.list" {
|
||||||
|
// Before responding, inject a live event — simulates
|
||||||
|
// a gateway pushing a presence update during sync.
|
||||||
|
evt := map[string]any{
|
||||||
|
"type": "event",
|
||||||
|
"event": "presence",
|
||||||
|
"params": map[string]any{"agentId": "otto", "connected": true, "lastActivityAt": "2025-05-20T12:30:00Z"},
|
||||||
|
}
|
||||||
|
if err := conn.WriteJSON(evt); err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now send the RPC response
|
||||||
|
res := map[string]any{
|
||||||
|
"type": "res",
|
||||||
|
"id": reqID,
|
||||||
|
"ok": true,
|
||||||
|
"result": []map[string]any{
|
||||||
|
{"id": "otto", "name": "Otto", "role": "Orchestrator", "channel": "discord"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := conn.WriteJSON(res); err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Respond to sessions.list RPC
|
||||||
|
if method == "sessions.list" {
|
||||||
|
res := map[string]any{
|
||||||
|
"type": "res",
|
||||||
|
"id": reqID,
|
||||||
|
"ok": true,
|
||||||
|
"result": []map[string]any{},
|
||||||
|
}
|
||||||
|
if err := conn.WriteJSON(res); err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default response for other methods
|
||||||
|
res := map[string]any{
|
||||||
|
"type": "res",
|
||||||
|
"id": reqID,
|
||||||
|
"ok": true,
|
||||||
|
"result": map[string]any{},
|
||||||
|
}
|
||||||
|
if err := conn.WriteJSON(res); err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
client := NewWSClient(WSConfig{URL: wsURL(srv), AuthToken: "test-token"}, repo, broker, slog.Default())
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
client.Start(ctx)
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait for the presence event to be processed by checking the repo.
|
||||||
|
// The presence handler updates the agent, so we check for the
|
||||||
|
// lastActivityAt change.
|
||||||
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
|
var lastActivity string
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
repo.mu.Lock()
|
||||||
|
if a, ok := repo.agents["otto"]; ok {
|
||||||
|
lastActivity = a.LastActivity
|
||||||
|
}
|
||||||
|
repo.mu.Unlock()
|
||||||
|
if lastActivity == "2025-05-20T12:30:00Z" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
if lastActivity != "2025-05-20T12:30:00Z" {
|
||||||
|
t.Errorf("presence event during sync was lost: lastActivity = %q, want %q", lastActivity, "2025-05-20T12:30:00Z")
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("WSClient did not shut down cleanly")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestStrPtr(t *testing.T) {
|
func TestStrPtr(t *testing.T) {
|
||||||
s := "hello"
|
s := "hello"
|
||||||
p := strPtr(s)
|
p := strPtr(s)
|
||||||
|
|||||||
@@ -65,6 +65,21 @@ func New(deps *Dependencies) *chi.Mux {
|
|||||||
|
|
||||||
// ── API v1 routes ──────────────────────────────────────────────────────
|
// ── API v1 routes ──────────────────────────────────────────────────────
|
||||||
r.Route("/api", func(api chi.Router) {
|
r.Route("/api", func(api chi.Router) {
|
||||||
|
// Health check (under /api)
|
||||||
|
api.Get("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
status := "ok"
|
||||||
|
if deps.Pool != nil {
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := deps.Pool.Ping(ctx); err != nil {
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
status = "db_unhealthy"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.Write([]byte(`{"status":"` + status + `"}`))
|
||||||
|
})
|
||||||
|
|
||||||
// Agents CRUD
|
// Agents CRUD
|
||||||
api.Route("/agents", func(agents chi.Router) {
|
api.Route("/agents", func(agents chi.Router) {
|
||||||
agents.Get("/", deps.Handler.ListAgents) // GET /api/agents
|
agents.Get("/", deps.Handler.ListAgents) // GET /api/agents
|
||||||
|
|||||||
Reference in New Issue
Block a user