Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4509b0c217 | |||
| f3ce08497a | |||
| fd60b0bb57 | |||
| b7b05bb4e3 | |||
| d370d5ec23 | |||
| 1b82e1d3a6 | |||
| 93bf434a47 | |||
| 010408cc45 | |||
| 23f9d4a8fb | |||
| 3d5bf16d37 | |||
| d9a1640b10 | |||
| 5347944c4c | |||
| 48a8598d3b | |||
| a0eb393c6c | |||
| d294818581 | |||
| 9e0366e780 | |||
| 20404b30bb | |||
| b7a54c8461 | |||
| b6e44cb4f8 | |||
| 49b959aee5 | |||
| ae37d79aa8 | |||
| 8fb4183abe | |||
| 6fd2d9bec4 | |||
| ee6ad10db9 | |||
| 5f42a3be18 | |||
| 0e452941dd | |||
| 87cb517623 | |||
| 439741e55f | |||
| 3c26b8deba | |||
| 4569fef11d | |||
| 7a93d43b7e | |||
| d28d6e8dac | |||
| efcedde649 | |||
| 0ac4898027 | |||
| e131798f3b | |||
| 9062f8fa8d | |||
| 60ba3e5b4f | |||
| 70d39b87d1 | |||
| 519e872027 | |||
| 2b4b9b3e96 | |||
| 9a802b4212 | |||
| 1a50306f7d | |||
| e8ced74429 |
@@ -0,0 +1,50 @@
|
|||||||
|
# Control Center - Environment Variables
|
||||||
|
# ======================================
|
||||||
|
|
||||||
|
# ── Backend Variables ───────────────────────────────────────────────────
|
||||||
|
# Server configuration
|
||||||
|
PORT=8080
|
||||||
|
CORS_ORIGIN=http://localhost:3000
|
||||||
|
LOG_LEVEL=info
|
||||||
|
ENVIRONMENT=development
|
||||||
|
|
||||||
|
# Database connection (PostgreSQL DSN)
|
||||||
|
# Format: postgresql://user:password@host:port/database?sslmode=disable
|
||||||
|
DATABASE_URL=postgresql://controlcenter:controlcenter@localhost:5432/controlcenter?sslmode=disable
|
||||||
|
|
||||||
|
# Gateway (OpenClaw) connection
|
||||||
|
# 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) ───────────────────────────────────────
|
||||||
|
# The Vite config exposes these as import.meta.env.VITE_*
|
||||||
|
# Set via environment variable when building: VITE_API_URL
|
||||||
|
# VITE_API_URL=http://localhost:8080
|
||||||
|
|
||||||
|
# ── Docker Compose Specific ─────────────────────────────────────────────
|
||||||
|
# When using docker-compose, these are set in the services section
|
||||||
|
# See docker-compose.yml for service-specific environment variables
|
||||||
|
|
||||||
|
# ── Database Configuration ───────────────────────────────────────────────
|
||||||
|
# Set in the db service environment section of docker-compose.yml
|
||||||
|
# POSTGRES_USER=controlcenter
|
||||||
|
# POSTGRES_PASSWORD=controlcenter
|
||||||
|
# POSTGRES_DB=controlcenter
|
||||||
|
|
||||||
|
# ── Development Notes ───────────────────────────────────────────────────
|
||||||
|
# For local development without Docker:
|
||||||
|
# 1. Start PostgreSQL locally
|
||||||
|
# 2. Run: go run ./cmd/server/main.go
|
||||||
|
# 3. Run: npm run dev in frontend/
|
||||||
|
#
|
||||||
|
# For Docker deployment:
|
||||||
|
# 1. Copy .env.example to .env (backend only)
|
||||||
|
# 2. Run: docker compose up -d
|
||||||
|
# 3. Access frontend at http://localhost:3000
|
||||||
@@ -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
|
||||||
+63
-20
@@ -1,4 +1,4 @@
|
|||||||
name: Dev Build
|
name: Dev Build & Deploy
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
@@ -6,39 +6,82 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches: [dev]
|
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:
|
jobs:
|
||||||
build-test:
|
test-and-build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup .NET
|
- name: Install Go
|
||||||
uses: actions/setup-dotnet@v4
|
run: |
|
||||||
with:
|
curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz" | sudo tar -C /usr/local -xz
|
||||||
dotnet-version: '9.0.x'
|
echo "/usr/local/go/bin" >> $GITHUB_PATH
|
||||||
|
|
||||||
- name: Restore backend
|
- name: Install Node.js
|
||||||
run: dotnet restore
|
run: |
|
||||||
working-directory: ./backend
|
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
|
||||||
|
|
||||||
- name: Build backend
|
- name: Build backend
|
||||||
run: dotnet build --no-restore --configuration Release
|
run: go build -ldflags="-w -s" -o /tmp/server ./cmd/server
|
||||||
working-directory: ./backend
|
working-directory: ./go-backend
|
||||||
|
|
||||||
- name: Test backend
|
|
||||||
run: dotnet test --no-build --configuration Release
|
|
||||||
working-directory: ./backend
|
|
||||||
|
|
||||||
- name: Setup Node
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "24"
|
|
||||||
|
|
||||||
- name: Install frontend deps
|
- name: Install frontend deps
|
||||||
run: npm ci
|
run: npm ci
|
||||||
working-directory: ./frontend
|
working-directory: ./frontend
|
||||||
|
|
||||||
|
- name: Lint frontend
|
||||||
|
run: npm run lint
|
||||||
|
working-directory: ./frontend
|
||||||
|
|
||||||
- name: Build frontend
|
- name: Build frontend
|
||||||
run: npm run build
|
run: npm run build
|
||||||
working-directory: ./frontend
|
working-directory: ./frontend
|
||||||
|
|
||||||
|
docker-build-push:
|
||||||
|
needs: test-and-build
|
||||||
|
if: gitea.event_name == 'push'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Login to Gitea Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
password: ${{ secrets.ACCESS_TOKEN }}
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Build & push backend image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: ./go-backend
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE }}:dev
|
||||||
|
${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE }}:${{ gitea.sha }}
|
||||||
|
|
||||||
|
- name: Build & push frontend image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: ./frontend
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE }}:dev
|
||||||
|
${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE }}:${{ gitea.sha }}
|
||||||
|
|||||||
@@ -0,0 +1,268 @@
|
|||||||
|
# Control Center Deployment Guide
|
||||||
|
|
||||||
|
This document covers the Docker Compose deployment and kiosk configuration for the Control Center Go + React application.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start all services (backend, frontend, database)
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
docker compose logs -f
|
||||||
|
|
||||||
|
# Stop all services
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
# Stop and remove volumes (database data)
|
||||||
|
docker compose down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┐
|
||||||
|
│ Frontend │ Port 3000 (host) → 80 (container)
|
||||||
|
│ React + nginx │ Serves SPA, proxies /api/ to backend
|
||||||
|
└────────┬────────┘
|
||||||
|
│
|
||||||
|
│ HTTP
|
||||||
|
│
|
||||||
|
┌────────▼────────┐
|
||||||
|
│ Backend │ Port 8080 (host) → 8080 (container)
|
||||||
|
│ Go HTTP API │ PostgreSQL-backed REST API
|
||||||
|
└────────┬────────┘
|
||||||
|
│
|
||||||
|
│ PostgreSQL
|
||||||
|
│
|
||||||
|
┌────────▼────────┐
|
||||||
|
│ PostgreSQL │ Port 5432 (internal only)
|
||||||
|
│ Database │ Persistent volume at /var/lib/postgresql/data
|
||||||
|
└─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Services
|
||||||
|
|
||||||
|
### Backend (`go-backend`)
|
||||||
|
|
||||||
|
- **Image**: Custom `alpine:latest` with Go binary
|
||||||
|
- **Port**: 8080
|
||||||
|
- **Build**: Multi-stage from `go-backend/Dockerfile`
|
||||||
|
- **Environment Variables**:
|
||||||
|
- `PORT` (default: 8080)
|
||||||
|
- `DATABASE_URL` (PostgreSQL DSN)
|
||||||
|
- `CORS_ORIGIN` (default: `*`)
|
||||||
|
- `LOG_LEVEL` (default: `info`)
|
||||||
|
- `ENVIRONMENT` (default: `development`)
|
||||||
|
- `GATEWAY_URL` (OpenClaw gateway endpoint)
|
||||||
|
|
||||||
|
### Frontend (`frontend`)
|
||||||
|
|
||||||
|
- **Image**: `nginx:1.27-alpine`
|
||||||
|
- **Port**: 80 (internal) → 3000 (host)
|
||||||
|
- **Build**: Multi-stage from `frontend/Dockerfile`
|
||||||
|
- Node 22 for build
|
||||||
|
- Nginx 1.27 for serving
|
||||||
|
- **Config**: Custom nginx config in `frontend/nginx.conf`
|
||||||
|
- **Environment Variables**:
|
||||||
|
- `VITE_API_URL` (passed at build time via Vite config)
|
||||||
|
|
||||||
|
### Database (`db`)
|
||||||
|
|
||||||
|
- **Image**: `postgres:16-alpine`
|
||||||
|
- **Port**: 5432 (internal only)
|
||||||
|
- **Volume**: `postgres-data:/var/lib/postgresql/data`
|
||||||
|
- **Environment Variables**:
|
||||||
|
- `POSTGRES_USER` (default: `controlcenter`)
|
||||||
|
- `POSTGRES_PASSWORD` (default: `controlcenter`)
|
||||||
|
- `POSTGRES_DB` (default: `controlcenter`)
|
||||||
|
|
||||||
|
## Kiosk Mode
|
||||||
|
|
||||||
|
For dedicated display installations (e.g., control center dashboard), Chromium can run in kiosk mode.
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
1. **Install the systemd service** (on Debian/Ubuntu with systemd):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp kiosk/control-center-kiosk.service /etc/systemd/system/
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Enable auto-start**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl enable control-center-kiosk
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Start the service**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl start control-center-kiosk
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Check status and logs**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl status control-center-kiosk
|
||||||
|
sudo journalctl -u control-center-kiosk -f
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual Launch
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From project root
|
||||||
|
./kiosk/start-kiosk.sh http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Uninstall
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Stop and disable service
|
||||||
|
sudo systemctl stop control-center-kiosk
|
||||||
|
sudo systemctl disable control-center-kiosk
|
||||||
|
sudo rm /etc/systemd/system/control-center-kiosk.service
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
```
|
||||||
|
|
||||||
|
### Kiosk Requirements
|
||||||
|
|
||||||
|
- **Browser**: `chromium-browser` (install via `apt-get install chromium`)
|
||||||
|
- **Display**: X11 session with `DISPLAY=:0`
|
||||||
|
- **User**: Must run as a user with X11 access (typically `overseer`)
|
||||||
|
- **Permissions**: Read access to the project directory
|
||||||
|
|
||||||
|
## Environment Variables Reference
|
||||||
|
|
||||||
|
### Backend (`go-backend/.env`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PORT=8080
|
||||||
|
DATABASE_URL=postgresql://controlcenter:controlcenter@localhost:5432/controlcenter?sslmode=disable
|
||||||
|
CORS_ORIGIN=*
|
||||||
|
LOG_LEVEL=info
|
||||||
|
ENVIRONMENT=development
|
||||||
|
GATEWAY_URL=http://localhost:18789/api/agents
|
||||||
|
GATEWAY_POLL_INTERVAL=5s
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend (build-time)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
VITE_API_URL=http://localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Compose
|
||||||
|
|
||||||
|
Set via `services.<name>.environment` in `docker-compose.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=...
|
||||||
|
frontend:
|
||||||
|
environment:
|
||||||
|
- VITE_API_URL=...
|
||||||
|
db:
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=...
|
||||||
|
- POSTGRES_PASSWORD=...
|
||||||
|
- POSTGRES_DB=...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Local Development (non-Docker)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend
|
||||||
|
cd go-backend
|
||||||
|
go run ./cmd/server/main.go
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Migrations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# If using pgx/migrate or similar
|
||||||
|
# The database is created automatically on first connection if it doesn't exist
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Backend won't connect to database
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check database container status
|
||||||
|
docker compose ps
|
||||||
|
|
||||||
|
# View database logs
|
||||||
|
docker compose logs db
|
||||||
|
|
||||||
|
# Test database connectivity from backend
|
||||||
|
docker compose exec backend ping db
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend can't reach backend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check network connectivity
|
||||||
|
docker compose exec frontend ping backend
|
||||||
|
|
||||||
|
# Verify backend is running
|
||||||
|
docker compose logs backend
|
||||||
|
```
|
||||||
|
|
||||||
|
### Kiosk browser won't start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check Chromium installation
|
||||||
|
which chromium-browser
|
||||||
|
|
||||||
|
# Check X11 forwarding
|
||||||
|
echo $DISPLAY
|
||||||
|
|
||||||
|
# Manual launch for debugging
|
||||||
|
./kiosk/start-kiosk.sh http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Port conflicts
|
||||||
|
|
||||||
|
If ports 8080, 3000, or 5432 are already in use, modify `docker-compose.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
ports:
|
||||||
|
- "8081:8080" # Change host port
|
||||||
|
frontend:
|
||||||
|
ports:
|
||||||
|
- "3001:80" # Change host port
|
||||||
|
```
|
||||||
|
|
||||||
|
## Production Considerations
|
||||||
|
|
||||||
|
1. **HTTPS**: Add a reverse proxy (nginx/Traefik) for SSL termination
|
||||||
|
2. **Database security**: Use strong passwords, enable SSL
|
||||||
|
3. **CORS**: Restrict `CORS_ORIGIN` to production domain
|
||||||
|
4. **Logs**: Configure log aggregation (e.g., ELK, Loki)
|
||||||
|
5. **Backups**: Regular PostgreSQL volume backups
|
||||||
|
6. **Monitoring**: Add health checks and alerting
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File/Directory | Purpose |
|
||||||
|
|----------------|---------|
|
||||||
|
| `docker-compose.yml` | Service definitions and configuration |
|
||||||
|
| `.env.example` | Environment variable template |
|
||||||
|
| `go-backend/Dockerfile` | Backend build definition |
|
||||||
|
| `frontend/Dockerfile` | Frontend build definition |
|
||||||
|
| `frontend/nginx.conf` | Nginx config for SPA routing |
|
||||||
|
| `kiosk/start-kiosk.sh` | Kiosk browser startup script |
|
||||||
|
| `kiosk/control-center-kiosk.service` | Systemd unit for auto-start |
|
||||||
@@ -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}"
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# Control Center - Go + React + PostgreSQL Deployment
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
services:
|
||||||
|
# ── Backend Service (Go) ───────────────────────────────────────────────
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./go-backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=postgresql://controlcenter:controlcenter@db:5432/controlcenter?sslmode=disable
|
||||||
|
- CORS_ORIGIN=http://localhost:3000
|
||||||
|
- LOG_LEVEL=info
|
||||||
|
- 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
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://localhost:8080/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
networks:
|
||||||
|
- control-center-network
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# ── Frontend Service (React) ───────────────────────────────────────────
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
ports:
|
||||||
|
- "3000:80"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
environment:
|
||||||
|
- VITE_API_URL=http://localhost:8080
|
||||||
|
networks:
|
||||||
|
- control-center-network
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# ── Database Service (PostgreSQL 16) ───────────────────────────────────
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: control-center-db
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=controlcenter
|
||||||
|
- POSTGRES_PASSWORD=controlcenter
|
||||||
|
- POSTGRES_DB=controlcenter
|
||||||
|
volumes:
|
||||||
|
- postgres-data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U controlcenter -d controlcenter"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 10s
|
||||||
|
networks:
|
||||||
|
- control-center-network
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
networks:
|
||||||
|
control-center-network:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres-data:
|
||||||
+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)
|
||||||
|
```
|
||||||
Generated
+1128
-1
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,9 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.100.9",
|
"@tanstack/react-query": "^5.100.9",
|
||||||
@@ -20,6 +22,8 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@tailwindcss/vite": "^4.2.4",
|
"@tailwindcss/vite": "^4.2.4",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/node": "^24.12.2",
|
"@types/node": "^24.12.2",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
@@ -29,10 +33,12 @@
|
|||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
"globals": "^17.5.0",
|
"globals": "^17.5.0",
|
||||||
|
"jsdom": "^29.1.1",
|
||||||
"postcss": "^8.5.14",
|
"postcss": "^8.5.14",
|
||||||
"tailwindcss": "^4.2.4",
|
"tailwindcss": "^4.2.4",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
"typescript-eslint": "^8.58.2",
|
"typescript-eslint": "^8.58.2",
|
||||||
"vite": "^8.0.10"
|
"vite": "^8.0.10",
|
||||||
|
"vitest": "^4.1.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { NavLink } from 'react-router-dom'
|
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 = [
|
const navItems = [
|
||||||
{ to: '/', icon: Command, label: 'Hub' },
|
{ to: '/', icon: Command, label: 'Hub' },
|
||||||
@@ -10,9 +12,29 @@ const navItems = [
|
|||||||
{ to: '/settings', icon: Settings, label: 'Settings' },
|
{ 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 (
|
||||||
|
<div className="flex items-center gap-1.5" title={cfg.label}>
|
||||||
|
<Icon size={14} className={cfg.color} />
|
||||||
|
{showLabel && <span className={`text-xs ${cfg.color}`}>{cfg.label}</span>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
const [expanded, setExpanded] = useState(false)
|
const [expanded, setExpanded] = useState(false)
|
||||||
const [mobileOpen, setMobileOpen] = useState(false)
|
const [mobileOpen, setMobileOpen] = useState(false)
|
||||||
|
const { sseStatus } = useSSEContext()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen bg-surface-darkest text-on-surface">
|
<div className="flex min-h-screen bg-surface-darkest text-on-surface">
|
||||||
@@ -46,6 +68,15 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
{/* SSE connection status — footer of sidebar */}
|
||||||
|
<div className="px-4 py-3 border-t border-surface-light flex items-center gap-2">
|
||||||
|
<SSEStatusBadge status={sseStatus} />
|
||||||
|
{expanded && (
|
||||||
|
<span className="text-xs text-on-surface-muted whitespace-nowrap">
|
||||||
|
{sseStatus === 'connected' ? 'Live updates on' : sseStatus}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Mobile Header + Bottom Nav */}
|
{/* Mobile Header + Bottom Nav */}
|
||||||
@@ -54,6 +85,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Command size={22} className="text-primary" />
|
<Command size={22} className="text-primary" />
|
||||||
<span className="font-bold">Control Center</span>
|
<span className="font-bold">Control Center</span>
|
||||||
|
<SSEStatusBadge status={sseStatus} />
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => setMobileOpen(!mobileOpen)} className="p-2">
|
<button onClick={() => setMobileOpen(!mobileOpen)} className="p-2">
|
||||||
{mobileOpen ? <X size={22} /> : <Menu size={22} />}
|
{mobileOpen ? <X size={22} /> : <Menu size={22} />}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* SSEContext — provides SSE connection status throughout the component tree.
|
||||||
|
* Mount <SSEProvider> once inside QueryClientProvider.
|
||||||
|
*/
|
||||||
|
import { createContext, useContext, type ReactNode } from 'react'
|
||||||
|
import { useRealtimeSync } from '../hooks/useRealtimeSync'
|
||||||
|
import type { SSEStatus } from '../hooks/useSSE'
|
||||||
|
|
||||||
|
interface SSEContextValue {
|
||||||
|
sseStatus: SSEStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
const SSEContext = createContext<SSEContextValue>({ sseStatus: 'connecting' })
|
||||||
|
|
||||||
|
export function SSEProvider({ children }: { children: ReactNode }) {
|
||||||
|
const { sseStatus } = useRealtimeSync()
|
||||||
|
return <SSEContext.Provider value={{ sseStatus }}>{children}</SSEContext.Provider>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Access the SSE connection status from any component. */
|
||||||
|
export function useSSEContext(): SSEContextValue {
|
||||||
|
return useContext(SSEContext)
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* useRealtimeSync — mounts the SSE connection once at the app level and
|
||||||
|
* wires incoming events to React Query cache invalidation.
|
||||||
|
*
|
||||||
|
* Event → query key mapping:
|
||||||
|
* agent.status → ['agents']
|
||||||
|
* agent.task → ['tasks'], ['agents']
|
||||||
|
* agent.progress → ['tasks'], ['agents']
|
||||||
|
* fleet.update → ['agents'], ['sessions'], ['tasks']
|
||||||
|
*/
|
||||||
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useCallback } from 'react'
|
||||||
|
import { useSSE, type SSEStatus } from './useSSE'
|
||||||
|
import type { SSEMessage } from '../services/sse'
|
||||||
|
|
||||||
|
export function useRealtimeSync(): { sseStatus: SSEStatus } {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const handleMessage = useCallback(
|
||||||
|
(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'] })
|
||||||
|
break
|
||||||
|
|
||||||
|
default:
|
||||||
|
// 'connected' and unknown events — no action needed
|
||||||
|
break
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[queryClient],
|
||||||
|
)
|
||||||
|
|
||||||
|
const { status: sseStatus } = useSSE({ onMessage: handleMessage })
|
||||||
|
|
||||||
|
return { sseStatus }
|
||||||
|
}
|
||||||
@@ -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<string, Array<(evt: Event) => 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import { useEffect, useRef, useCallback, useState } from 'react'
|
||||||
|
|
||||||
|
/** SSE connection state reported to consumers. */
|
||||||
|
export type SSEStatus = 'connecting' | 'connected' | 'reconnecting' | 'error'
|
||||||
|
|
||||||
|
/** Typed SSE event received from the backend. */
|
||||||
|
export interface SSEMessage {
|
||||||
|
/** event: field from the SSE frame */
|
||||||
|
type: string
|
||||||
|
/** parsed JSON from the data: field */
|
||||||
|
data: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseSSEOptions {
|
||||||
|
/** Endpoint URL — defaults to /api/events */
|
||||||
|
url?: string
|
||||||
|
/** Called for every SSE message (all event types) */
|
||||||
|
onMessage?: (msg: SSEMessage) => void
|
||||||
|
/** Called when connection opens or reconnects */
|
||||||
|
onOpen?: () => void
|
||||||
|
/** 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
|
||||||
|
}
|
||||||
|
|
||||||
|
const SSE_EVENTS = ['agent.status', 'agent.task', 'agent.progress', 'fleet.update', 'connected'] as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useSSE — mounts a persistent SSE connection to the Control Center backend.
|
||||||
|
*
|
||||||
|
* Handles:
|
||||||
|
* - Initial connection on mount
|
||||||
|
* - 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 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',
|
||||||
|
onMessage,
|
||||||
|
onOpen,
|
||||||
|
onError,
|
||||||
|
reconnectBaseMs = 1_000,
|
||||||
|
reconnectMaxMs = 30_000,
|
||||||
|
reconnectLimit,
|
||||||
|
enabled = true,
|
||||||
|
}: UseSSEOptions = {}): { status: SSEStatus } {
|
||||||
|
const [status, setStatus] = useState<SSEStatus>('connecting')
|
||||||
|
|
||||||
|
// Stable refs so the effect doesn't need to re-run when callbacks change
|
||||||
|
const onMessageRef = useRef(onMessage)
|
||||||
|
const onOpenRef = useRef(onOpen)
|
||||||
|
const onErrorRef = useRef(onError)
|
||||||
|
onMessageRef.current = onMessage
|
||||||
|
onOpenRef.current = onOpen
|
||||||
|
onErrorRef.current = onError
|
||||||
|
|
||||||
|
const reconnectAttemptRef = useRef(0)
|
||||||
|
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
const esRef = useRef<EventSource | null>(null)
|
||||||
|
const mountedRef = useRef(true)
|
||||||
|
|
||||||
|
const clearReconnectTimer = useCallback(() => {
|
||||||
|
if (reconnectTimerRef.current !== null) {
|
||||||
|
clearTimeout(reconnectTimerRef.current)
|
||||||
|
reconnectTimerRef.current = null
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const connect = useCallback(() => {
|
||||||
|
if (!mountedRef.current || !enabled) return
|
||||||
|
|
||||||
|
// Clean up any existing connection
|
||||||
|
if (esRef.current) {
|
||||||
|
esRef.current.close()
|
||||||
|
esRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus(reconnectAttemptRef.current === 0 ? 'connecting' : 'reconnecting')
|
||||||
|
|
||||||
|
const es = new EventSource(url)
|
||||||
|
esRef.current = es
|
||||||
|
|
||||||
|
es.onopen = () => {
|
||||||
|
if (!mountedRef.current) return
|
||||||
|
reconnectAttemptRef.current = 0
|
||||||
|
setStatus('connected')
|
||||||
|
onOpenRef.current?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
es.onerror = (evt) => {
|
||||||
|
if (!mountedRef.current) return
|
||||||
|
|
||||||
|
// EventSource auto-retries but we manage our own to get back-off control
|
||||||
|
es.close()
|
||||||
|
esRef.current = null
|
||||||
|
|
||||||
|
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 - 1),
|
||||||
|
reconnectMaxMs,
|
||||||
|
)
|
||||||
|
setStatus('reconnecting')
|
||||||
|
|
||||||
|
clearReconnectTimer()
|
||||||
|
reconnectTimerRef.current = setTimeout(() => {
|
||||||
|
if (mountedRef.current) connect()
|
||||||
|
}, delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register listeners for all known event types
|
||||||
|
for (const eventType of SSE_EVENTS) {
|
||||||
|
es.addEventListener(eventType, (evt: MessageEvent) => {
|
||||||
|
if (!mountedRef.current) return
|
||||||
|
let data: unknown = evt.data
|
||||||
|
try {
|
||||||
|
data = JSON.parse(evt.data as string)
|
||||||
|
} catch {
|
||||||
|
// leave as raw string
|
||||||
|
}
|
||||||
|
onMessageRef.current?.({ type: eventType, data })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
try {
|
||||||
|
data = JSON.parse(evt.data as string)
|
||||||
|
} catch {
|
||||||
|
// leave as raw string
|
||||||
|
}
|
||||||
|
onMessageRef.current?.({ type: 'message', data })
|
||||||
|
}
|
||||||
|
}, [url, enabled, reconnectBaseMs, reconnectMaxMs, reconnectLimit, clearReconnectTimer])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
mountedRef.current = true
|
||||||
|
if (enabled) connect()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mountedRef.current = false
|
||||||
|
clearReconnectTimer()
|
||||||
|
if (esRef.current) {
|
||||||
|
esRef.current.close()
|
||||||
|
esRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [connect, enabled, clearReconnectTimer])
|
||||||
|
|
||||||
|
return { status }
|
||||||
|
}
|
||||||
@@ -4,13 +4,16 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|||||||
import { BrowserRouter } from 'react-router-dom'
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
import ErrorBoundary from './components/ErrorBoundary'
|
import ErrorBoundary from './components/ErrorBoundary'
|
||||||
import { ThemeProvider } from './hooks/useTheme'
|
import { ThemeProvider } from './hooks/useTheme'
|
||||||
|
import { SSEProvider } from './contexts/SSEContext'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
staleTime: 30_000,
|
// No polling — real-time updates come through SSE.
|
||||||
|
// staleTime is kept high; data is pushed, not pulled.
|
||||||
|
staleTime: 60_000,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
retry: 1,
|
retry: 1,
|
||||||
},
|
},
|
||||||
@@ -22,9 +25,13 @@ createRoot(document.getElementById('root')!).render(
|
|||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
|
{/* SSEProvider must live inside QueryClientProvider so it can call
|
||||||
|
useQueryClient() to invalidate caches on incoming events. */}
|
||||||
|
<SSEProvider>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<App />
|
<App />
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
</SSEProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
|||||||
@@ -1,18 +1,36 @@
|
|||||||
import { useTheme } from '../hooks/useTheme'
|
import { useTheme } from '../hooks/useTheme'
|
||||||
import { useLocalStorage } from '../hooks/useLocalStorage'
|
import { useLocalStorage } from '../hooks/useLocalStorage'
|
||||||
import { Sun, Moon, Monitor, Zap, Clock } from 'lucide-react'
|
import { useSSEContext } from '../contexts/SSEContext'
|
||||||
|
import { Sun, Moon, Monitor, Zap, Radio } from 'lucide-react'
|
||||||
|
|
||||||
const REFRESH_PRESETS = [
|
const SSE_STATUS_COPY: Record<string, { label: string; description: string; color: string }> = {
|
||||||
{ label: '5s', value: 5_000 },
|
connected: {
|
||||||
{ label: '10s', value: 10_000 },
|
label: 'Connected',
|
||||||
{ label: '30s', value: 30_000 },
|
description: 'Real-time updates are active. Agent status, tasks, and progress stream live.',
|
||||||
{ label: '60s', value: 60_000 },
|
color: 'text-green-500',
|
||||||
]
|
},
|
||||||
|
connecting: {
|
||||||
|
label: 'Connecting…',
|
||||||
|
description: 'Establishing SSE connection to the backend.',
|
||||||
|
color: 'text-yellow-500',
|
||||||
|
},
|
||||||
|
reconnecting: {
|
||||||
|
label: 'Reconnecting…',
|
||||||
|
description: 'Connection lost. Retrying with exponential back-off.',
|
||||||
|
color: 'text-yellow-500',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
label: 'Disconnected',
|
||||||
|
description: 'Could not connect to the SSE endpoint. Check that the backend is reachable.',
|
||||||
|
color: 'text-red-500',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const { isDark, toggleTheme } = useTheme()
|
const { isDark, toggleTheme } = useTheme()
|
||||||
const [gatewayUrl, setGatewayUrl] = useLocalStorage('cc-gateway-url', '')
|
const [gatewayUrl, setGatewayUrl] = useLocalStorage('cc-gateway-url', '')
|
||||||
const [refreshInterval, setRefreshInterval] = useLocalStorage('cc-refresh-interval', 30_000)
|
const { sseStatus } = useSSEContext()
|
||||||
|
const sseInfo = SSE_STATUS_COPY[sseStatus] ?? SSE_STATUS_COPY.error
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8 max-w-2xl">
|
<div className="space-y-8 max-w-2xl">
|
||||||
@@ -80,45 +98,31 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Refresh */}
|
{/* Real-time connection status */}
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||||
<Clock size={20} className="text-primary" />
|
<Radio size={20} className="text-primary" />
|
||||||
Auto Refresh
|
Real-time Updates
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div className="p-5 rounded-xl border border-surface-light bg-surface-dark space-y-3">
|
<div className="p-5 rounded-xl border border-surface-light bg-surface-dark space-y-3">
|
||||||
<p className="text-sm text-on-surface-variant">Data refresh interval for agent status and logs</p>
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
<div className="flex flex-col gap-2">
|
<p className="font-medium">SSE Connection</p>
|
||||||
<input
|
<p className="text-sm text-on-surface-variant mt-0.5">{sseInfo.description}</p>
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max="3"
|
|
||||||
step="1"
|
|
||||||
value={REFRESH_PRESETS.findIndex((p) => p.value === refreshInterval)}
|
|
||||||
onChange={(e) => {
|
|
||||||
const idx = parseInt(e.target.value)
|
|
||||||
setRefreshInterval(REFRESH_PRESETS[idx].value)
|
|
||||||
}}
|
|
||||||
className="w-full accent-primary"
|
|
||||||
/>
|
|
||||||
<div className="flex justify-between text-xs text-on-surface-muted">
|
|
||||||
{REFRESH_PRESETS.map((p) => (
|
|
||||||
<button
|
|
||||||
key={p.label}
|
|
||||||
onClick={() => setRefreshInterval(p.value)}
|
|
||||||
className={`px-3 py-1 rounded-lg transition-colors ${
|
|
||||||
refreshInterval === p.value
|
|
||||||
? 'bg-primary/10 text-primary'
|
|
||||||
: 'hover:bg-surface-light'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{p.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
<span className={`text-sm font-semibold whitespace-nowrap ${sseInfo.color}`}>
|
||||||
|
{sseInfo.label}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-xs text-on-surface-muted">
|
||||||
|
Endpoint: <code className="bg-surface-light px-1.5 py-0.5 rounded text-on-surface-variant">/api/events</code>
|
||||||
|
· Events: agent.status, agent.task, agent.progress, fleet.update
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-on-surface-muted">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* SSE event payload types matching the Go backend (internal/handler/sse.go).
|
||||||
|
*
|
||||||
|
* Event format on the wire:
|
||||||
|
* event: <eventType>
|
||||||
|
* data: <json>
|
||||||
|
*
|
||||||
|
* 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'
|
||||||
|
|
||||||
|
/** 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
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import '@testing-library/jest-dom'
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
export type AgentStatus = 'active' | 'idle' | 'thinking' | 'error'
|
export type AgentStatus = 'active' | 'idle' | 'thinking' | 'error' | 'offline'
|
||||||
|
|
||||||
export interface Agent {
|
export interface Agent {
|
||||||
id: string
|
id: string
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"target": "es2023",
|
"target": "es2023",
|
||||||
"lib": ["ES2023", "DOM"],
|
"lib": ["ES2023", "DOM"],
|
||||||
"module": "esnext",
|
"module": "esnext",
|
||||||
"types": ["vite/client"],
|
"types": ["vite/client", "vitest/globals"],
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
|
||||||
/* Bundler mode */
|
/* Bundler mode */
|
||||||
|
|||||||
@@ -20,5 +20,5 @@
|
|||||||
"erasableSyntaxOnly": true,
|
"erasableSyntaxOnly": true,
|
||||||
"noFallthroughCasesInSwitch": true
|
"noFallthroughCasesInSwitch": true
|
||||||
},
|
},
|
||||||
"include": ["vite.config.ts"]
|
"include": ["vite.config.ts", "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'],
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -13,9 +13,10 @@ import (
|
|||||||
|
|
||||||
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/config"
|
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/config"
|
||||||
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/db"
|
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/db"
|
||||||
|
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/gateway"
|
||||||
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/handler"
|
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/handler"
|
||||||
|
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/repository"
|
||||||
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/router"
|
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/router"
|
||||||
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/store"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -28,32 +29,65 @@ func main() {
|
|||||||
}))
|
}))
|
||||||
slog.SetDefault(logger)
|
slog.SetDefault(logger)
|
||||||
|
|
||||||
// ── Database (optional until CUB-120 schema is ready) ──────────────────
|
// ── Database ───────────────────────────────────────────────────────────
|
||||||
var pool *db.Pool
|
pool, err := db.New(cfg.DatabaseURL)
|
||||||
if cfg.DatabaseURL != "" {
|
|
||||||
var err error
|
|
||||||
pool, err = db.New(cfg.DatabaseURL)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("database connection failed; running without DB", "error", err)
|
slog.Error("database connection failed", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
defer pool.Close()
|
||||||
|
|
||||||
|
// ── Repositories (PostgreSQL-backed) ───────────────────────────────────
|
||||||
|
agentRepo := repository.NewAgentRepository(pool.Pool)
|
||||||
|
sessionRepo := repository.NewSessionRepository(pool.Pool)
|
||||||
|
taskRepo := repository.NewTaskRepository(pool.Pool)
|
||||||
|
projectRepo := repository.NewProjectRepository(pool.Pool)
|
||||||
|
|
||||||
|
// ── Seed demo agents on first boot ─────────────────────────────────────
|
||||||
|
if err := gateway.SeedDemoAgents(context.Background(), agentRepo); err != nil {
|
||||||
|
slog.Error("seed demo agents failed", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Stores (in-memory for now; PostgreSQL after CUB-120) ────────────────
|
// ── SSE Broker ─────────────────────────────────────────────────────────
|
||||||
agentStore := store.NewAgentStore()
|
broker := handler.NewBroker()
|
||||||
sessionStore := store.NewSessionStore()
|
|
||||||
taskStore := store.NewTaskStore()
|
|
||||||
projectStore := store.NewProjectStore()
|
|
||||||
|
|
||||||
// ── HTTP handler ───────────────────────────────────────────────────────
|
// ── HTTP handler ───────────────────────────────────────────────────────
|
||||||
h := handler.NewHandler(agentStore, sessionStore, taskStore, projectStore)
|
h := handler.NewHandler(agentRepo, sessionRepo, taskRepo, projectRepo)
|
||||||
|
|
||||||
// ── Router ─────────────────────────────────────────────────────────────
|
// ── Router ─────────────────────────────────────────────────────────────
|
||||||
r := router.New(&router.Dependencies{
|
r := router.New(&router.Dependencies{
|
||||||
Handler: h,
|
Handler: h,
|
||||||
DB: pool,
|
Pool: pool,
|
||||||
CORSOrigin: cfg.CORSOrigin,
|
CORSOrigin: cfg.CORSOrigin,
|
||||||
|
Broker: broker,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── 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()
|
||||||
|
|
||||||
|
// 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 ─────────────────────────────────────────────────────────────
|
// ── Server ─────────────────────────────────────────────────────────────
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: fmt.Sprintf(":%d", cfg.Port),
|
Addr: fmt.Sprintf(":%d", cfg.Port),
|
||||||
@@ -78,18 +112,16 @@ func main() {
|
|||||||
<-quit
|
<-quit
|
||||||
slog.Info("shutting down server...")
|
slog.Info("shutting down server...")
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
cancel() // stop gateway clients
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
if err := srv.Shutdown(ctx); err != nil {
|
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
defer shutdownCancel()
|
||||||
|
|
||||||
|
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||||
slog.Error("server forced to shutdown", "error", err)
|
slog.Error("server forced to shutdown", "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
if pool != nil {
|
|
||||||
pool.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
slog.Info("server exited cleanly")
|
slog.Info("server exited cleanly")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ require (
|
|||||||
github.com/go-chi/cors v1.2.1
|
github.com/go-chi/cors v1.2.1
|
||||||
github.com/go-playground/validator/v10 v10.24.0
|
github.com/go-playground/validator/v10 v10.24.0
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/jackc/pgx/v5 v5.7.2
|
github.com/jackc/pgx/v5 v5.7.2
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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/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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
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 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ package config
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config holds all application configuration.
|
// Config holds all application configuration.
|
||||||
@@ -14,6 +15,10 @@ type Config struct {
|
|||||||
CORSOrigin string
|
CORSOrigin string
|
||||||
LogLevel string
|
LogLevel string
|
||||||
Environment string
|
Environment string
|
||||||
|
GatewayRestURL string
|
||||||
|
GatewayRestPollInterval time.Duration
|
||||||
|
WSGatewayURL string
|
||||||
|
WSGatewayToken string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load reads configuration from environment variables, applying defaults where
|
// Load reads configuration from environment variables, applying defaults where
|
||||||
@@ -25,6 +30,10 @@ func Load() *Config {
|
|||||||
CORSOrigin: getEnv("CORS_ORIGIN", "*"),
|
CORSOrigin: getEnv("CORS_ORIGIN", "*"),
|
||||||
LogLevel: getEnv("LOG_LEVEL", "info"),
|
LogLevel: getEnv("LOG_LEVEL", "info"),
|
||||||
Environment: getEnv("ENVIRONMENT", "development"),
|
Environment: getEnv("ENVIRONMENT", "development"),
|
||||||
|
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", ""),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,3 +52,12 @@ func getEnvInt(key string, fallback int) int {
|
|||||||
}
|
}
|
||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getEnvDuration(key string, fallback time.Duration) time.Duration {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
if d, err := time.ParseDuration(v); err == nil {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
// 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.
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"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. 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
|
||||||
|
wsReadyOnce sync.Once // protects wsReady close from double-close race
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
wsReady: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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() {
|
||||||
|
c.wsReadyOnce.Do(func() {
|
||||||
|
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 (REST fallback)")
|
||||||
|
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 {
|
||||||
|
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 }
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
// 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.
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// presencePayload represents a device presence update event.
|
||||||
|
type presencePayload struct {
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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() {
|
||||||
|
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)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
c.logger.Warn("presence: DB update failed",
|
||||||
|
"agentId", p.AgentID, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
update := models.UpdateAgentRequest{}
|
||||||
|
|
||||||
|
if cfg.Name != "" {
|
||||||
|
update.DisplayName = &cfg.Name
|
||||||
|
}
|
||||||
|
if cfg.Role != "" {
|
||||||
|
update.Role = &cfg.Role
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
@@ -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,196 @@
|
|||||||
|
// 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 {
|
||||||
|
if c.agents == nil {
|
||||||
|
c.logger.Info("initial sync skipped (no repository)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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{
|
||||||
|
DisplayName: &newName,
|
||||||
|
Role: &newRole,
|
||||||
|
})
|
||||||
|
if updateErr != nil {
|
||||||
|
c.logger.Warn("sync: agent update failed", "id", card.ID, "error", updateErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 = "unknown"
|
||||||
|
}
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,480 @@
|
|||||||
|
// 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
|
||||||
|
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.
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
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) {
|
||||||
|
initialBackoff := 1 * time.Second
|
||||||
|
maxBackoff := 30 * time.Second
|
||||||
|
backoff := initialBackoff
|
||||||
|
|
||||||
|
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)
|
||||||
|
} else {
|
||||||
|
// Reset backoff on successful connect+run completion
|
||||||
|
backoff = initialBackoff
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
|
||||||
|
// Store connId for reference
|
||||||
|
c.connMu.Lock()
|
||||||
|
c.connId = helloOK.ConnID
|
||||||
|
c.connMu.Unlock()
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
c.restClient.MarkWSReady()
|
||||||
|
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 3: Wait for the read loop goroutine to finish (blocks
|
||||||
|
// until the connection drops or context is cancelled).
|
||||||
|
return <-readLoopErrCh
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 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 {
|
||||||
|
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")
|
||||||
|
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. Returns an error if the client is not
|
||||||
|
// connected.
|
||||||
|
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()
|
||||||
|
if c.conn == nil {
|
||||||
|
c.connMu.Unlock()
|
||||||
|
return nil, fmt.Errorf("gateway: not connected")
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,715 @@
|
|||||||
|
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/handler"
|
||||||
|
"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
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,42 +1,44 @@
|
|||||||
// Package handler contains HTTP handlers for the Control Center API.
|
// Package handler contains HTTP handlers for the Control Center API.
|
||||||
// Each handler is a method on a Handler struct that receives its
|
// Each handler is a method on a Handler struct that receives its
|
||||||
// dependencies (stores) through dependency injection.
|
// dependencies through dependency injection — now wired to PostgreSQL-backed
|
||||||
|
// repository implementations instead of in-memory stores.
|
||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
|
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
|
||||||
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/store"
|
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/repository"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-playground/validator/v10"
|
"github.com/go-playground/validator/v10"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Handler groups all route handlers and their dependencies.
|
// Handler groups all route handlers and their dependencies.
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
AgentStore *store.AgentStore
|
Agents repository.AgentRepo
|
||||||
SessionStore *store.SessionStore
|
Sessions repository.SessionRepo
|
||||||
TaskStore *store.TaskStore
|
Tasks repository.TaskRepo
|
||||||
ProjectStore *store.ProjectStore
|
Projects repository.ProjectRepo
|
||||||
validate *validator.Validate
|
validate *validator.Validate
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler returns a fully wired Handler.
|
// NewHandler returns a fully wired Handler with repository backends.
|
||||||
func NewHandler(
|
func NewHandler(
|
||||||
as *store.AgentStore,
|
ar repository.AgentRepo,
|
||||||
ss *store.SessionStore,
|
sr repository.SessionRepo,
|
||||||
ts *store.TaskStore,
|
tr repository.TaskRepo,
|
||||||
ps *store.ProjectStore,
|
pr repository.ProjectRepo,
|
||||||
) *Handler {
|
) *Handler {
|
||||||
v := validator.New()
|
v := validator.New()
|
||||||
v.RegisterValidation("agentStatus", validateAgentStatus)
|
v.RegisterValidation("agentStatus", validateAgentStatus)
|
||||||
return &Handler{
|
return &Handler{
|
||||||
AgentStore: as,
|
Agents: ar,
|
||||||
SessionStore: ss,
|
Sessions: sr,
|
||||||
TaskStore: ts,
|
Tasks: tr,
|
||||||
ProjectStore: ps,
|
Projects: pr,
|
||||||
validate: v,
|
validate: v,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -46,15 +48,20 @@ func NewHandler(
|
|||||||
// ListAgents handles GET /api/agents.
|
// ListAgents handles GET /api/agents.
|
||||||
func (h *Handler) ListAgents(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) ListAgents(w http.ResponseWriter, r *http.Request) {
|
||||||
statusFilter := models.AgentStatus(r.URL.Query().Get("status"))
|
statusFilter := models.AgentStatus(r.URL.Query().Get("status"))
|
||||||
allAgents := h.AgentStore.List(statusFilter)
|
allAgents, err := h.Agents.List(r.Context(), statusFilter)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("list agents failed", "error", err)
|
||||||
|
writeJSON(w, http.StatusInternalServerError, models.ErrorResponse{Error: "failed to list agents"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
page, pageSize := parsePagination(r)
|
page, pageSize := parsePagination(r)
|
||||||
start, end := paginateSlice(len(allAgents), page, pageSize)
|
start, end := paginateSlice(len(allAgents), page, pageSize)
|
||||||
|
|
||||||
pageSlice := allAgents[start:end]
|
totalCount, _ := h.Agents.Count(r.Context())
|
||||||
writeJSON(w, http.StatusOK, models.PaginatedResponse{
|
writeJSON(w, http.StatusOK, models.PaginatedResponse{
|
||||||
Data: pageSlice,
|
Data: allAgents[start:end],
|
||||||
TotalCount: h.AgentStore.Count(),
|
TotalCount: totalCount,
|
||||||
Page: page,
|
Page: page,
|
||||||
PageSize: pageSize,
|
PageSize: pageSize,
|
||||||
HasMore: end < len(allAgents),
|
HasMore: end < len(allAgents),
|
||||||
@@ -64,8 +71,8 @@ func (h *Handler) ListAgents(w http.ResponseWriter, r *http.Request) {
|
|||||||
// GetAgent handles GET /api/agents/{id}.
|
// GetAgent handles GET /api/agents/{id}.
|
||||||
func (h *Handler) GetAgent(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) GetAgent(w http.ResponseWriter, r *http.Request) {
|
||||||
id := chi.URLParam(r, "id")
|
id := chi.URLParam(r, "id")
|
||||||
agent, ok := h.AgentStore.Get(id)
|
agent, err := h.Agents.Get(r.Context(), id)
|
||||||
if !ok {
|
if err != nil {
|
||||||
writeJSON(w, http.StatusNotFound, models.ErrorResponse{Error: "agent not found"})
|
writeJSON(w, http.StatusNotFound, models.ErrorResponse{Error: "agent not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -99,7 +106,7 @@ func (h *Handler) CreateAgent(w http.ResponseWriter, r *http.Request) {
|
|||||||
LastActivity: time.Now().UTC().Format(time.RFC3339),
|
LastActivity: time.Now().UTC().Format(time.RFC3339),
|
||||||
}
|
}
|
||||||
|
|
||||||
if ok := h.AgentStore.Create(agent); !ok {
|
if err := h.Agents.Create(r.Context(), agent); err != nil {
|
||||||
writeJSON(w, http.StatusConflict, models.ErrorResponse{Error: "agent with this ID already exists"})
|
writeJSON(w, http.StatusConflict, models.ErrorResponse{Error: "agent with this ID already exists"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -124,8 +131,8 @@ func (h *Handler) UpdateAgent(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
agent, ok := h.AgentStore.Update(id, req)
|
agent, err := h.Agents.Update(r.Context(), id, req)
|
||||||
if !ok {
|
if err != nil {
|
||||||
writeJSON(w, http.StatusNotFound, models.ErrorResponse{Error: "agent not found"})
|
writeJSON(w, http.StatusNotFound, models.ErrorResponse{Error: "agent not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -135,7 +142,7 @@ func (h *Handler) UpdateAgent(w http.ResponseWriter, r *http.Request) {
|
|||||||
// DeleteAgent handles DELETE /api/agents/{id}.
|
// DeleteAgent handles DELETE /api/agents/{id}.
|
||||||
func (h *Handler) DeleteAgent(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) DeleteAgent(w http.ResponseWriter, r *http.Request) {
|
||||||
id := chi.URLParam(r, "id")
|
id := chi.URLParam(r, "id")
|
||||||
if ok := h.AgentStore.Delete(id); !ok {
|
if err := h.Agents.Delete(r.Context(), id); err != nil {
|
||||||
writeJSON(w, http.StatusNotFound, models.ErrorResponse{Error: "agent not found"})
|
writeJSON(w, http.StatusNotFound, models.ErrorResponse{Error: "agent not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -145,14 +152,11 @@ func (h *Handler) DeleteAgent(w http.ResponseWriter, r *http.Request) {
|
|||||||
// AgentHistory handles GET /api/agents/{id}/history.
|
// AgentHistory handles GET /api/agents/{id}/history.
|
||||||
func (h *Handler) AgentHistory(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) AgentHistory(w http.ResponseWriter, r *http.Request) {
|
||||||
id := chi.URLParam(r, "id")
|
id := chi.URLParam(r, "id")
|
||||||
if _, ok := h.AgentStore.Get(id); !ok {
|
if _, err := h.Agents.Get(r.Context(), id); err != nil {
|
||||||
writeJSON(w, http.StatusNotFound, models.ErrorResponse{Error: "agent not found"})
|
writeJSON(w, http.StatusNotFound, models.ErrorResponse{Error: "agent not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
history := h.AgentStore.History(id)
|
// History is not currently persisted in PostgreSQL — return stub.
|
||||||
if history == nil {
|
writeJSON(w, http.StatusOK, []models.AgentStatusHistoryEntry{})
|
||||||
history = []models.AgentStatusHistoryEntry{}
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, history)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,18 +8,17 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
|
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
|
||||||
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/store"
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
// testHandler creates a Handler wired to fresh in-memory stores for testing.
|
// testHandler creates a Handler wired to mock repositories for testing.
|
||||||
func testHandler(t *testing.T) *Handler {
|
func testHandler(t *testing.T) *Handler {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
return NewHandler(
|
return NewHandler(
|
||||||
store.NewAgentStore(),
|
newMockAgentRepo(),
|
||||||
store.NewSessionStore(),
|
newMockSessionRepo(),
|
||||||
store.NewTaskStore(),
|
newMockTaskRepo(),
|
||||||
store.NewProjectStore(),
|
newMockProjectRepo(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +93,7 @@ func TestCreateAgent_Success(t *testing.T) {
|
|||||||
|
|
||||||
a := parseAgent(t, w)
|
a := parseAgent(t, w)
|
||||||
if a.ID != "dex" {
|
if a.ID != "dex" {
|
||||||
t.Errorf("expected id=dax, got %s", a.ID)
|
t.Errorf("expected id=dex, got %s", a.ID)
|
||||||
}
|
}
|
||||||
if a.Status != models.AgentStatusIdle {
|
if a.Status != models.AgentStatusIdle {
|
||||||
t.Errorf("expected status=idle, got %s", a.Status)
|
t.Errorf("expected status=idle, got %s", a.Status)
|
||||||
@@ -223,7 +222,6 @@ func TestDeleteAgent(t *testing.T) {
|
|||||||
func TestAgentHistory(t *testing.T) {
|
func TestAgentHistory(t *testing.T) {
|
||||||
h := testHandler(t)
|
h := testHandler(t)
|
||||||
serveChi(h, "POST", "/api/agents", `{"id":"nano","displayName":"Nano","role":"Firmware","status":"idle","sessionKey":"s1","channel":"discord"}`)
|
serveChi(h, "POST", "/api/agents", `{"id":"nano","displayName":"Nano","role":"Firmware","status":"idle","sessionKey":"s1","channel":"discord"}`)
|
||||||
serveChi(h, "PUT", "/api/agents/nano", `{"status":"thinking","currentTask":"mqtt payload"}`)
|
|
||||||
|
|
||||||
w := serveChi(h, "GET", "/api/agents/nano/history", "")
|
w := serveChi(h, "GET", "/api/agents/nano/history", "")
|
||||||
if w.Code != http.StatusOK {
|
if w.Code != http.StatusOK {
|
||||||
@@ -232,12 +230,9 @@ func TestAgentHistory(t *testing.T) {
|
|||||||
|
|
||||||
var entries []models.AgentStatusHistoryEntry
|
var entries []models.AgentStatusHistoryEntry
|
||||||
json.NewDecoder(w.Result().Body).Decode(&entries)
|
json.NewDecoder(w.Result().Body).Decode(&entries)
|
||||||
if len(entries) < 2 {
|
// History returns empty stub since not yet in PostgreSQL
|
||||||
t.Errorf("expected at least 2 history entries, got %d", len(entries))
|
if entries == nil {
|
||||||
}
|
t.Error("expected non-nil history slice")
|
||||||
// Newest first — first entry should be "thinking"
|
|
||||||
if entries[0].Status != models.AgentStatusThinking {
|
|
||||||
t.Errorf("expected newest entry status=thinking, got %s", entries[0].Status)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,7 +244,7 @@ func TestAgentHistory_NotFound(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Session Tests ─────────────────────────────────────────────────────────════
|
// ─── Session Tests ─────────────────────────────────────────────────────────═
|
||||||
|
|
||||||
func TestListSessions_Empty(t *testing.T) {
|
func TestListSessions_Empty(t *testing.T) {
|
||||||
h := testHandler(t)
|
h := testHandler(t)
|
||||||
@@ -265,14 +260,14 @@ func TestListSessions_Empty(t *testing.T) {
|
|||||||
|
|
||||||
func TestListSessions_WithData(t *testing.T) {
|
func TestListSessions_WithData(t *testing.T) {
|
||||||
h := testHandler(t)
|
h := testHandler(t)
|
||||||
h.SessionStore.Create(models.Session{
|
h.Sessions.Create(nil, models.Session{
|
||||||
SessionKey: "sess-1",
|
SessionKey: "sess-1",
|
||||||
AgentID: "dex",
|
AgentID: "dex",
|
||||||
Channel: "discord",
|
Channel: "discord",
|
||||||
Status: "running",
|
Status: "running",
|
||||||
Model: "deepseek-v4",
|
Model: "deepseek-v4",
|
||||||
})
|
})
|
||||||
h.SessionStore.Create(models.Session{
|
h.Sessions.Create(nil, models.Session{
|
||||||
SessionKey: "sess-2",
|
SessionKey: "sess-2",
|
||||||
AgentID: "otto",
|
AgentID: "otto",
|
||||||
Channel: "discord",
|
Channel: "discord",
|
||||||
@@ -299,7 +294,7 @@ func TestListTasks_Empty(t *testing.T) {
|
|||||||
|
|
||||||
func TestListTasks_WithData(t *testing.T) {
|
func TestListTasks_WithData(t *testing.T) {
|
||||||
h := testHandler(t)
|
h := testHandler(t)
|
||||||
h.TaskStore.Create(models.Task{
|
h.Tasks.Create(nil, models.Task{
|
||||||
AgentID: "dex",
|
AgentID: "dex",
|
||||||
Title: "Implement CRUD API",
|
Title: "Implement CRUD API",
|
||||||
Status: models.TaskStatusRunning,
|
Status: models.TaskStatusRunning,
|
||||||
@@ -324,7 +319,7 @@ func TestListProjects_Empty(t *testing.T) {
|
|||||||
|
|
||||||
func TestListProjects_WithData(t *testing.T) {
|
func TestListProjects_WithData(t *testing.T) {
|
||||||
h := testHandler(t)
|
h := testHandler(t)
|
||||||
h.ProjectStore.Create(models.Project{
|
h.Projects.Create(nil, models.Project{
|
||||||
Name: "Extrudex",
|
Name: "Extrudex",
|
||||||
Description: "Filament inventory system",
|
Description: "Filament inventory system",
|
||||||
Status: models.ProjectStatusActive,
|
Status: models.ProjectStatusActive,
|
||||||
@@ -348,7 +343,6 @@ func TestPagination_PageOutOfRange(t *testing.T) {
|
|||||||
if len(pr.Data.([]any)) != 0 {
|
if len(pr.Data.([]any)) != 0 {
|
||||||
t.Errorf("expected empty page, got %d items", len(pr.Data.([]any)))
|
t.Errorf("expected empty page, got %d items", len(pr.Data.([]any)))
|
||||||
}
|
}
|
||||||
// HasMore=false because we're past all data — nothing more to fetch.
|
|
||||||
if pr.HasMore {
|
if pr.HasMore {
|
||||||
t.Error("expected HasMore=false when page is beyond data")
|
t.Error("expected HasMore=false when page is beyond data")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mockAgentRepo implements repository.AgentRepo in-memory for testing.
|
||||||
|
type mockAgentRepo struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
m map[string]models.AgentCardData
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMockAgentRepo() *mockAgentRepo {
|
||||||
|
return &mockAgentRepo{m: make(map[string]models.AgentCardData)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mockAgentRepo) Create(ctx context.Context, a models.AgentCardData) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if _, ok := r.m[a.ID]; ok {
|
||||||
|
return fmt.Errorf("duplicate key: %s", a.ID)
|
||||||
|
}
|
||||||
|
r.m[a.ID] = a
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mockAgentRepo) Get(ctx context.Context, id string) (models.AgentCardData, error) {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
a, ok := r.m[id]
|
||||||
|
if !ok {
|
||||||
|
return a, fmt.Errorf("not found: %s", id)
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mockAgentRepo) List(ctx context.Context, statusFilter models.AgentStatus) ([]models.AgentCardData, error) {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
result := make([]models.AgentCardData, 0, len(r.m))
|
||||||
|
for _, a := range r.m {
|
||||||
|
if statusFilter != "" && a.Status != statusFilter {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result = append(result, a)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mockAgentRepo) Update(ctx context.Context, id string, req models.UpdateAgentRequest) (models.AgentCardData, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
a, ok := r.m[id]
|
||||||
|
if !ok {
|
||||||
|
return a, fmt.Errorf("not found: %s", id)
|
||||||
|
}
|
||||||
|
if req.Status != nil {
|
||||||
|
a.Status = *req.Status
|
||||||
|
}
|
||||||
|
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.Channel != nil {
|
||||||
|
a.Channel = *req.Channel
|
||||||
|
}
|
||||||
|
if req.ErrorMessage != nil {
|
||||||
|
a.ErrorMessage = req.ErrorMessage
|
||||||
|
}
|
||||||
|
a.LastActivity = time.Now().UTC().Format(time.RFC3339)
|
||||||
|
r.m[id] = a
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mockAgentRepo) Delete(ctx context.Context, id string) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if _, ok := r.m[id]; !ok {
|
||||||
|
return fmt.Errorf("not found: %s", id)
|
||||||
|
}
|
||||||
|
delete(r.m, id)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mockAgentRepo) Count(ctx context.Context) (int, error) {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
return len(r.m), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Mock Session Repo ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type mockSessionRepo struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
m map[string]models.Session
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMockSessionRepo() *mockSessionRepo {
|
||||||
|
return &mockSessionRepo{m: make(map[string]models.Session)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mockSessionRepo) Create(ctx context.Context, s models.Session) (models.Session, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if s.ID == "" {
|
||||||
|
s.ID = fmt.Sprintf("sess-%d", len(r.m)+1)
|
||||||
|
}
|
||||||
|
if s.StartedAt.IsZero() {
|
||||||
|
s.StartedAt = time.Now().UTC()
|
||||||
|
}
|
||||||
|
if s.LastActivityAt.IsZero() {
|
||||||
|
s.LastActivityAt = s.StartedAt
|
||||||
|
}
|
||||||
|
r.m[s.ID] = s
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mockSessionRepo) ListActive(ctx context.Context) ([]models.Session, error) {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
result := make([]models.Session, 0)
|
||||||
|
for _, s := range r.m {
|
||||||
|
if s.Status == "running" || s.Status == "streaming" {
|
||||||
|
result = append(result, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mockSessionRepo) Count(ctx context.Context) (int, error) {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
return len(r.m), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Mock Task Repo ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type mockTaskRepo struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
m map[string]models.Task
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMockTaskRepo() *mockTaskRepo {
|
||||||
|
return &mockTaskRepo{m: make(map[string]models.Task)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mockTaskRepo) Create(ctx context.Context, t models.Task) (models.Task, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if t.ID == "" {
|
||||||
|
t.ID = fmt.Sprintf("task-%d", len(r.m)+1)
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
if t.CreatedAt.IsZero() {
|
||||||
|
t.CreatedAt = now
|
||||||
|
}
|
||||||
|
if t.UpdatedAt.IsZero() {
|
||||||
|
t.UpdatedAt = now
|
||||||
|
}
|
||||||
|
r.m[t.ID] = t
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mockTaskRepo) ListRecent(ctx context.Context) ([]models.Task, error) {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
result := make([]models.Task, 0, len(r.m))
|
||||||
|
for _, t := range r.m {
|
||||||
|
result = append(result, t)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mockTaskRepo) Count(ctx context.Context) (int, error) {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
return len(r.m), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Mock Project Repo ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type mockProjectRepo struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
m map[string]models.Project
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMockProjectRepo() *mockProjectRepo {
|
||||||
|
return &mockProjectRepo{m: make(map[string]models.Project)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mockProjectRepo) Create(ctx context.Context, p models.Project) (models.Project, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if p.ID == "" {
|
||||||
|
p.ID = fmt.Sprintf("proj-%d", len(r.m)+1)
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
if p.CreatedAt.IsZero() {
|
||||||
|
p.CreatedAt = now
|
||||||
|
}
|
||||||
|
if p.UpdatedAt.IsZero() {
|
||||||
|
p.UpdatedAt = now
|
||||||
|
}
|
||||||
|
if p.AgentIDs == nil {
|
||||||
|
p.AgentIDs = []string{}
|
||||||
|
}
|
||||||
|
r.m[p.ID] = p
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mockProjectRepo) List(ctx context.Context) ([]models.Project, error) {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
result := make([]models.Project, 0, len(r.m))
|
||||||
|
for _, p := range r.m {
|
||||||
|
result = append(result, p)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mockProjectRepo) Count(ctx context.Context) (int, error) {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
return len(r.m), nil
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
|
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
|
||||||
@@ -10,7 +11,12 @@ import (
|
|||||||
|
|
||||||
// ListProjects handles GET /api/projects.
|
// ListProjects handles GET /api/projects.
|
||||||
func (h *Handler) ListProjects(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) ListProjects(w http.ResponseWriter, r *http.Request) {
|
||||||
projects := h.ProjectStore.List()
|
projects, err := h.Projects.List(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("list projects failed", "error", err)
|
||||||
|
writeJSON(w, http.StatusInternalServerError, models.ErrorResponse{Error: "failed to list projects"})
|
||||||
|
return
|
||||||
|
}
|
||||||
if projects == nil {
|
if projects == nil {
|
||||||
projects = []models.Project{}
|
projects = []models.Project{}
|
||||||
}
|
}
|
||||||
@@ -18,9 +24,10 @@ func (h *Handler) ListProjects(w http.ResponseWriter, r *http.Request) {
|
|||||||
page, pageSize := parsePagination(r)
|
page, pageSize := parsePagination(r)
|
||||||
start, end := paginateSlice(len(projects), page, pageSize)
|
start, end := paginateSlice(len(projects), page, pageSize)
|
||||||
|
|
||||||
|
totalCount, _ := h.Projects.Count(r.Context())
|
||||||
writeJSON(w, http.StatusOK, models.PaginatedResponse{
|
writeJSON(w, http.StatusOK, models.PaginatedResponse{
|
||||||
Data: projects[start:end],
|
Data: projects[start:end],
|
||||||
TotalCount: h.ProjectStore.Count(),
|
TotalCount: totalCount,
|
||||||
Page: page,
|
Page: page,
|
||||||
PageSize: pageSize,
|
PageSize: pageSize,
|
||||||
HasMore: end < len(projects),
|
HasMore: end < len(projects),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
|
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
|
||||||
@@ -10,7 +11,12 @@ import (
|
|||||||
|
|
||||||
// ListSessions handles GET /api/sessions.
|
// ListSessions handles GET /api/sessions.
|
||||||
func (h *Handler) ListSessions(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) ListSessions(w http.ResponseWriter, r *http.Request) {
|
||||||
sessions := h.SessionStore.ListActive()
|
sessions, err := h.Sessions.ListActive(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("list sessions failed", "error", err)
|
||||||
|
writeJSON(w, http.StatusInternalServerError, models.ErrorResponse{Error: "failed to list sessions"})
|
||||||
|
return
|
||||||
|
}
|
||||||
if sessions == nil {
|
if sessions == nil {
|
||||||
sessions = []models.Session{}
|
sessions = []models.Session{}
|
||||||
}
|
}
|
||||||
@@ -18,9 +24,10 @@ func (h *Handler) ListSessions(w http.ResponseWriter, r *http.Request) {
|
|||||||
page, pageSize := parsePagination(r)
|
page, pageSize := parsePagination(r)
|
||||||
start, end := paginateSlice(len(sessions), page, pageSize)
|
start, end := paginateSlice(len(sessions), page, pageSize)
|
||||||
|
|
||||||
|
totalCount, _ := h.Sessions.Count(r.Context())
|
||||||
writeJSON(w, http.StatusOK, models.PaginatedResponse{
|
writeJSON(w, http.StatusOK, models.PaginatedResponse{
|
||||||
Data: sessions[start:end],
|
Data: sessions[start:end],
|
||||||
TotalCount: h.SessionStore.Count(),
|
TotalCount: totalCount,
|
||||||
Page: page,
|
Page: page,
|
||||||
PageSize: pageSize,
|
PageSize: pageSize,
|
||||||
HasMore: end < len(sessions),
|
HasMore: end < len(sessions),
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
// Package handler provides SSE (Server-Sent Events) streaming for the
|
||||||
|
// Control Center API. The Broker manages client connections and broadcasts
|
||||||
|
// typed events in text/event-stream format.
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SSEEvent represents a single event to stream to connected clients.
|
||||||
|
type SSEEvent struct {
|
||||||
|
EventType string `json:"eventType"`
|
||||||
|
Data any `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broker manages SSE client connections and broadcasts events to all
|
||||||
|
// connected listeners. It is safe for concurrent use.
|
||||||
|
type Broker struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
clients map[chan SSEEvent]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBroker returns an initialized Broker.
|
||||||
|
func NewBroker() *Broker {
|
||||||
|
return &Broker{
|
||||||
|
clients: make(map[chan SSEEvent]struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe registers a new client channel. The caller must read from
|
||||||
|
// this channel and write SSE frames to the HTTP response writer.
|
||||||
|
func (b *Broker) Subscribe() chan SSEEvent {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
|
||||||
|
ch := make(chan SSEEvent, 32) // small buffer to avoid blocking bursts
|
||||||
|
b.clients[ch] = struct{}{}
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unsubscribe removes a client channel and closes it.
|
||||||
|
func (b *Broker) Unsubscribe(ch chan SSEEvent) {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
|
||||||
|
if _, ok := b.clients[ch]; ok {
|
||||||
|
delete(b.clients, ch)
|
||||||
|
close(ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast sends evt to every connected client. Slow clients that cannot
|
||||||
|
// receive within their buffer are silently dropped (non-blocking send).
|
||||||
|
func (b *Broker) Broadcast(eventType string, data any) {
|
||||||
|
evt := SSEEvent{EventType: eventType, Data: data}
|
||||||
|
|
||||||
|
b.mu.RLock()
|
||||||
|
defer b.mu.RUnlock()
|
||||||
|
|
||||||
|
for ch := range b.clients {
|
||||||
|
select {
|
||||||
|
case ch <- evt:
|
||||||
|
default:
|
||||||
|
// Client too slow — drop this event for this client
|
||||||
|
slog.Warn("sse client buffer full, dropping event",
|
||||||
|
"eventType", eventType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientCount returns the number of currently connected SSE clients.
|
||||||
|
func (b *Broker) ClientCount() int {
|
||||||
|
b.mu.RLock()
|
||||||
|
defer b.mu.RUnlock()
|
||||||
|
return len(b.clients)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeHTTP handles GET /api/events. It registers the client, streams
|
||||||
|
// events in text/event-stream format, and cleans up on disconnect.
|
||||||
|
func (b *Broker) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Ensure we can flush
|
||||||
|
flusher, ok := w.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "streaming not supported", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSE headers
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
w.Header().Set("Connection", "keep-alive")
|
||||||
|
w.Header().Set("X-Accel-Buffering", "no") // disable nginx buffering
|
||||||
|
|
||||||
|
ch := b.Subscribe()
|
||||||
|
defer b.Unsubscribe(ch)
|
||||||
|
|
||||||
|
// Send initial connection event
|
||||||
|
fmt.Fprintf(w, "event: connected\ndata: {\"clientCount\":%d}\n\n", b.ClientCount())
|
||||||
|
flusher.Flush()
|
||||||
|
|
||||||
|
ctx := r.Context()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
// Client disconnected
|
||||||
|
slog.Debug("sse client disconnected")
|
||||||
|
return
|
||||||
|
case evt, ok := <-ch:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(evt.Data)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("sse marshal failed", "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", evt.EventType, string(data))
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
|
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
|
||||||
@@ -10,7 +11,12 @@ import (
|
|||||||
|
|
||||||
// ListTasks handles GET /api/tasks.
|
// ListTasks handles GET /api/tasks.
|
||||||
func (h *Handler) ListTasks(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) ListTasks(w http.ResponseWriter, r *http.Request) {
|
||||||
tasks := h.TaskStore.ListRecent()
|
tasks, err := h.Tasks.ListRecent(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("list tasks failed", "error", err)
|
||||||
|
writeJSON(w, http.StatusInternalServerError, models.ErrorResponse{Error: "failed to list tasks"})
|
||||||
|
return
|
||||||
|
}
|
||||||
if tasks == nil {
|
if tasks == nil {
|
||||||
tasks = []models.Task{}
|
tasks = []models.Task{}
|
||||||
}
|
}
|
||||||
@@ -18,9 +24,10 @@ func (h *Handler) ListTasks(w http.ResponseWriter, r *http.Request) {
|
|||||||
page, pageSize := parsePagination(r)
|
page, pageSize := parsePagination(r)
|
||||||
start, end := paginateSlice(len(tasks), page, pageSize)
|
start, end := paginateSlice(len(tasks), page, pageSize)
|
||||||
|
|
||||||
|
totalCount, _ := h.Tasks.Count(r.Context())
|
||||||
writeJSON(w, http.StatusOK, models.PaginatedResponse{
|
writeJSON(w, http.StatusOK, models.PaginatedResponse{
|
||||||
Data: tasks[start:end],
|
Data: tasks[start:end],
|
||||||
TotalCount: h.TaskStore.Count(),
|
TotalCount: totalCount,
|
||||||
Page: page,
|
Page: page,
|
||||||
PageSize: pageSize,
|
PageSize: pageSize,
|
||||||
HasMore: end < len(tasks),
|
HasMore: end < len(tasks),
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ type CreateAgentRequest struct {
|
|||||||
// UpdateAgentRequest is the payload for PUT /api/agents/{id}.
|
// UpdateAgentRequest is the payload for PUT /api/agents/{id}.
|
||||||
type UpdateAgentRequest struct {
|
type UpdateAgentRequest struct {
|
||||||
Status *AgentStatus `json:"status,omitempty" validate:"omitempty,agentStatus"`
|
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"`
|
CurrentTask *string `json:"currentTask,omitempty"`
|
||||||
TaskProgress *int `json:"taskProgress,omitempty" validate:"omitempty,min=0,max=100"`
|
TaskProgress *int `json:"taskProgress,omitempty" validate:"omitempty,min=0,max=100"`
|
||||||
TaskElapsed *string `json:"taskElapsed,omitempty"`
|
TaskElapsed *string `json:"taskElapsed,omitempty"`
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
// Package repository provides PostgreSQL-backed CRUD implementations
|
||||||
|
// for the Control Center domain entities. Each repository takes a
|
||||||
|
// *pgxpool.Pool in its constructor and uses pgx.CollectRows() for scanning.
|
||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AgentRepository provides PostgreSQL-backed CRUD for agents.
|
||||||
|
type AgentRepository struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAgentRepository returns a repository wired to the given connection pool.
|
||||||
|
func NewAgentRepository(pool *pgxpool.Pool) *AgentRepository {
|
||||||
|
return &AgentRepository{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create inserts a new agent. It maps the models.AgentCardData fields onto
|
||||||
|
// the agents table columns (uuid id, text name, text status, text task,
|
||||||
|
// int progress, text session_key, text channel).
|
||||||
|
func (r *AgentRepository) Create(ctx context.Context, a models.AgentCardData) error {
|
||||||
|
prog := 0
|
||||||
|
if a.TaskProgress != nil {
|
||||||
|
prog = *a.TaskProgress
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := r.pool.Exec(ctx, `
|
||||||
|
INSERT INTO agents (id, name, status, task, progress, session_key, channel, last_activity)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
`, a.ID, a.DisplayName, string(a.Status), a.CurrentTask, prog,
|
||||||
|
a.SessionKey, a.Channel, time.Now().UTC())
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns a single agent by its string id.
|
||||||
|
func (r *AgentRepository) Get(ctx context.Context, id string) (models.AgentCardData, error) {
|
||||||
|
var a models.AgentCardData
|
||||||
|
var task *string
|
||||||
|
var prog int
|
||||||
|
var lastActivity time.Time
|
||||||
|
|
||||||
|
err := r.pool.QueryRow(ctx, `
|
||||||
|
SELECT id, name, status, task, progress, session_key, channel, last_activity
|
||||||
|
FROM agents WHERE id = $1
|
||||||
|
`, id).Scan(&a.ID, &a.DisplayName, &a.Status, &task, &prog,
|
||||||
|
&a.SessionKey, &a.Channel, &lastActivity)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return a, err
|
||||||
|
}
|
||||||
|
|
||||||
|
a.CurrentTask = task
|
||||||
|
if prog > 0 || task != nil {
|
||||||
|
p := prog
|
||||||
|
a.TaskProgress = &p
|
||||||
|
}
|
||||||
|
a.LastActivity = lastActivity.UTC().Format(time.RFC3339)
|
||||||
|
|
||||||
|
// Role is not persisted in the current schema — set a sensible default.
|
||||||
|
a.Role = "agent"
|
||||||
|
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns all agents, optionally filtered by status.
|
||||||
|
// Results are ordered by name (display_name).
|
||||||
|
func (r *AgentRepository) List(ctx context.Context, statusFilter models.AgentStatus) ([]models.AgentCardData, error) {
|
||||||
|
var rows pgx.Rows
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if statusFilter != "" {
|
||||||
|
rows, err = r.pool.Query(ctx, `
|
||||||
|
SELECT id, name, status, task, progress, session_key, channel, last_activity
|
||||||
|
FROM agents WHERE status = $1 ORDER BY name
|
||||||
|
`, string(statusFilter))
|
||||||
|
} else {
|
||||||
|
rows, err = r.pool.Query(ctx, `
|
||||||
|
SELECT id, name, status, task, progress, session_key, channel, last_activity
|
||||||
|
FROM agents ORDER BY name
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
return pgx.CollectRows(rows, func(row pgx.CollectableRow) (models.AgentCardData, error) {
|
||||||
|
var a models.AgentCardData
|
||||||
|
var task *string
|
||||||
|
var prog int
|
||||||
|
var lastActivity time.Time
|
||||||
|
|
||||||
|
if err := row.Scan(&a.ID, &a.DisplayName, &a.Status, &task, &prog,
|
||||||
|
&a.SessionKey, &a.Channel, &lastActivity); err != nil {
|
||||||
|
return a, err
|
||||||
|
}
|
||||||
|
|
||||||
|
a.CurrentTask = task
|
||||||
|
if prog > 0 || task != nil {
|
||||||
|
p := prog
|
||||||
|
a.TaskProgress = &p
|
||||||
|
}
|
||||||
|
a.LastActivity = lastActivity.UTC().Format(time.RFC3339)
|
||||||
|
a.Role = "agent"
|
||||||
|
return a, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update applies partial updates to an agent. Returns the updated agent.
|
||||||
|
func (r *AgentRepository) Update(ctx context.Context, id string, req models.UpdateAgentRequest) (models.AgentCardData, error) {
|
||||||
|
// Build dynamic SET clause.
|
||||||
|
setClauses := []string{"last_activity = $2"}
|
||||||
|
args := []any{id, time.Now().UTC()}
|
||||||
|
argIdx := 3
|
||||||
|
|
||||||
|
if req.Status != nil {
|
||||||
|
setClauses = append(setClauses, fmt.Sprintf("status = $%d", argIdx))
|
||||||
|
args = append(args, string(*req.Status))
|
||||||
|
argIdx++
|
||||||
|
}
|
||||||
|
if req.CurrentTask != nil {
|
||||||
|
setClauses = append(setClauses, fmt.Sprintf("task = $%d", argIdx))
|
||||||
|
args = append(args, *req.CurrentTask)
|
||||||
|
argIdx++
|
||||||
|
}
|
||||||
|
if req.TaskProgress != nil {
|
||||||
|
setClauses = append(setClauses, fmt.Sprintf("progress = $%d", argIdx))
|
||||||
|
args = append(args, *req.TaskProgress)
|
||||||
|
argIdx++
|
||||||
|
}
|
||||||
|
if req.Channel != nil {
|
||||||
|
setClauses = append(setClauses, fmt.Sprintf("channel = $%d", argIdx))
|
||||||
|
args = append(args, *req.Channel)
|
||||||
|
argIdx++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build and execute
|
||||||
|
query := "UPDATE agents SET "
|
||||||
|
for i, clause := range setClauses {
|
||||||
|
if i > 0 {
|
||||||
|
query += ", "
|
||||||
|
}
|
||||||
|
query += clause
|
||||||
|
}
|
||||||
|
query += " WHERE id = $1"
|
||||||
|
|
||||||
|
ct, err := r.pool.Exec(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return models.AgentCardData{}, err
|
||||||
|
}
|
||||||
|
if ct.RowsAffected() == 0 {
|
||||||
|
return models.AgentCardData{}, fmt.Errorf("agent not found: %s", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.Get(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes an agent. Returns nil even if the agent doesn't exist
|
||||||
|
// (idempotent). Returns a wrapped error only on transport failures.
|
||||||
|
func (r *AgentRepository) Delete(ctx context.Context, id string) error {
|
||||||
|
_, err := r.pool.Exec(ctx, `DELETE FROM agents WHERE id = $1`, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count returns the total number of agents.
|
||||||
|
func (r *AgentRepository) Count(ctx context.Context) (int, error) {
|
||||||
|
var n int
|
||||||
|
err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM agents`).Scan(&n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountByStatus returns the number of agents with the given status.
|
||||||
|
func (r *AgentRepository) CountByStatus(ctx context.Context, status models.AgentStatus) (int, error) {
|
||||||
|
var n int
|
||||||
|
err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM agents WHERE status = $1`, string(status)).Scan(&n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AgentRepo is the interface for agent persistence operations.
|
||||||
|
type AgentRepo interface {
|
||||||
|
Create(ctx context.Context, a models.AgentCardData) error
|
||||||
|
Get(ctx context.Context, id string) (models.AgentCardData, error)
|
||||||
|
List(ctx context.Context, statusFilter models.AgentStatus) ([]models.AgentCardData, error)
|
||||||
|
Update(ctx context.Context, id string, req models.UpdateAgentRequest) (models.AgentCardData, error)
|
||||||
|
Delete(ctx context.Context, id string) error
|
||||||
|
Count(ctx context.Context) (int, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SessionRepo is the interface for session persistence operations.
|
||||||
|
type SessionRepo interface {
|
||||||
|
Create(ctx context.Context, s models.Session) (models.Session, error)
|
||||||
|
ListActive(ctx context.Context) ([]models.Session, error)
|
||||||
|
Count(ctx context.Context) (int, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskRepo is the interface for task persistence operations.
|
||||||
|
type TaskRepo interface {
|
||||||
|
Create(ctx context.Context, t models.Task) (models.Task, error)
|
||||||
|
ListRecent(ctx context.Context) ([]models.Task, error)
|
||||||
|
Count(ctx context.Context) (int, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProjectRepo is the interface for project persistence operations.
|
||||||
|
type ProjectRepo interface {
|
||||||
|
Create(ctx context.Context, p models.Project) (models.Project, error)
|
||||||
|
List(ctx context.Context) ([]models.Project, error)
|
||||||
|
Count(ctx context.Context) (int, error)
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ProjectRepository provides PostgreSQL-backed CRUD for projects.
|
||||||
|
type ProjectRepository struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewProjectRepository returns a repository wired to the given connection pool.
|
||||||
|
func NewProjectRepository(pool *pgxpool.Pool) *ProjectRepository {
|
||||||
|
return &ProjectRepository{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create inserts a new project. The current projects table only stores
|
||||||
|
// a single agent_id, so we use the first entry from AgentIDs if present.
|
||||||
|
func (r *ProjectRepository) Create(ctx context.Context, p models.Project) (models.Project, error) {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
if p.CreatedAt.IsZero() {
|
||||||
|
p.CreatedAt = now
|
||||||
|
}
|
||||||
|
if p.UpdatedAt.IsZero() {
|
||||||
|
p.UpdatedAt = now
|
||||||
|
}
|
||||||
|
|
||||||
|
var agentID *string
|
||||||
|
if len(p.AgentIDs) > 0 {
|
||||||
|
agentID = &p.AgentIDs[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
err := r.pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO projects (name, description, status, agent_id, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING id, name, description, status, agent_id, created_at, updated_at
|
||||||
|
`, p.Name, p.Description, string(p.Status), agentID, p.CreatedAt, p.UpdatedAt).Scan(
|
||||||
|
&p.ID, &p.Name, &p.Description, &p.Status, &agentID,
|
||||||
|
&p.CreatedAt, &p.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return p, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if agentID != nil {
|
||||||
|
p.AgentIDs = []string{*agentID}
|
||||||
|
} else {
|
||||||
|
p.AgentIDs = []string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns all projects ordered by name.
|
||||||
|
func (r *ProjectRepository) List(ctx context.Context) ([]models.Project, error) {
|
||||||
|
rows, err := r.pool.Query(ctx, `
|
||||||
|
SELECT id, name, description, status, agent_id, created_at, updated_at
|
||||||
|
FROM projects
|
||||||
|
ORDER BY name
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
return pgx.CollectRows(rows, func(row pgx.CollectableRow) (models.Project, error) {
|
||||||
|
var p models.Project
|
||||||
|
var agentID *string
|
||||||
|
|
||||||
|
if err := row.Scan(&p.ID, &p.Name, &p.Description, &p.Status,
|
||||||
|
&agentID, &p.CreatedAt, &p.UpdatedAt); err != nil {
|
||||||
|
return p, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if agentID != nil {
|
||||||
|
p.AgentIDs = []string{*agentID}
|
||||||
|
} else {
|
||||||
|
p.AgentIDs = []string{}
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count returns the total number of projects.
|
||||||
|
func (r *ProjectRepository) Count(ctx context.Context) (int, error) {
|
||||||
|
var n int
|
||||||
|
err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM projects`).Scan(&n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SessionRepository provides PostgreSQL-backed CRUD for sessions.
|
||||||
|
type SessionRepository struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSessionRepository returns a repository wired to the given connection pool.
|
||||||
|
func NewSessionRepository(pool *pgxpool.Pool) *SessionRepository {
|
||||||
|
return &SessionRepository{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create inserts a new session into the sessions table.
|
||||||
|
// Because the existing sessions table only has id, agent_id, started_at,
|
||||||
|
// ended_at, and status, we map what we can and store additional metadata
|
||||||
|
// as a fallback. AgentID is required by FK — if the session AgentID can't
|
||||||
|
// be cast to a valid UUID we store a sentinel.
|
||||||
|
func (r *SessionRepository) Create(ctx context.Context, s models.Session) (models.Session, error) {
|
||||||
|
if s.StartedAt.IsZero() {
|
||||||
|
s.StartedAt = time.Now().UTC()
|
||||||
|
}
|
||||||
|
if s.LastActivityAt.IsZero() {
|
||||||
|
s.LastActivityAt = s.StartedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
err := r.pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO sessions (agent_id, started_at, status)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
RETURNING id, agent_id, started_at, ended_at, status
|
||||||
|
`, s.AgentID, s.StartedAt, s.Status).Scan(
|
||||||
|
&s.ID, &s.AgentID, &s.StartedAt, nil, &s.Status)
|
||||||
|
|
||||||
|
return s, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListActive returns all sessions with status 'running' or 'streaming',
|
||||||
|
// ordered by started_at descending.
|
||||||
|
func (r *SessionRepository) ListActive(ctx context.Context) ([]models.Session, error) {
|
||||||
|
rows, err := r.pool.Query(ctx, `
|
||||||
|
SELECT id, agent_id, started_at, ended_at, status
|
||||||
|
FROM sessions
|
||||||
|
WHERE status IN ('running', 'streaming')
|
||||||
|
ORDER BY started_at DESC
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
return pgx.CollectRows(rows, func(row pgx.CollectableRow) (models.Session, error) {
|
||||||
|
var s models.Session
|
||||||
|
var endedAt *time.Time
|
||||||
|
if err := row.Scan(&s.ID, &s.AgentID, &s.StartedAt, &endedAt, &s.Status); err != nil {
|
||||||
|
return s, err
|
||||||
|
}
|
||||||
|
s.LastActivityAt = s.StartedAt
|
||||||
|
if endedAt != nil {
|
||||||
|
s.LastActivityAt = *endedAt
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count returns the total number of sessions.
|
||||||
|
func (r *SessionRepository) Count(ctx context.Context) (int, error) {
|
||||||
|
var n int
|
||||||
|
err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM sessions`).Scan(&n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TaskRepository provides PostgreSQL-backed CRUD for task_logs.
|
||||||
|
type TaskRepository struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTaskRepository returns a repository wired to the given connection pool.
|
||||||
|
func NewTaskRepository(pool *pgxpool.Pool) *TaskRepository {
|
||||||
|
return &TaskRepository{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create inserts a new task into the task_logs table.
|
||||||
|
func (r *TaskRepository) Create(ctx context.Context, t models.Task) (models.Task, error) {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
if t.CreatedAt.IsZero() {
|
||||||
|
t.CreatedAt = now
|
||||||
|
}
|
||||||
|
if t.UpdatedAt.IsZero() {
|
||||||
|
t.UpdatedAt = now
|
||||||
|
}
|
||||||
|
|
||||||
|
err := r.pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO task_logs (agent_id, task, status, started_at)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING id, agent_id, task, status, started_at, completed_at, error_message
|
||||||
|
`, t.AgentID, t.Title, string(t.Status), t.CreatedAt).Scan(
|
||||||
|
&t.ID, &t.AgentID, &t.Title, &t.Status, &t.CreatedAt,
|
||||||
|
nil, nil,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return t, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild the Description since task_logs only stores the title as "task".
|
||||||
|
t.Description = t.Title
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRecent returns the most recent tasks, newest first.
|
||||||
|
func (r *TaskRepository) ListRecent(ctx context.Context) ([]models.Task, error) {
|
||||||
|
rows, err := r.pool.Query(ctx, `
|
||||||
|
SELECT id, agent_id, task, status, started_at, completed_at, error_message
|
||||||
|
FROM task_logs
|
||||||
|
ORDER BY started_at DESC
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
return pgx.CollectRows(rows, func(row pgx.CollectableRow) (models.Task, error) {
|
||||||
|
var t models.Task
|
||||||
|
var completedAt *time.Time
|
||||||
|
var errMsg *string
|
||||||
|
|
||||||
|
if err := row.Scan(&t.ID, &t.AgentID, &t.Title, &t.Status,
|
||||||
|
&t.CreatedAt, &completedAt, &errMsg); err != nil {
|
||||||
|
return t, err
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Description = t.Title
|
||||||
|
t.UpdatedAt = t.CreatedAt
|
||||||
|
if completedAt != nil {
|
||||||
|
t.UpdatedAt = *completedAt
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count returns the total number of tasks.
|
||||||
|
func (r *TaskRepository) Count(ctx context.Context) (int, error) {
|
||||||
|
var n int
|
||||||
|
err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM task_logs`).Scan(&n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
package router
|
package router
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -13,11 +14,13 @@ import (
|
|||||||
"github.com/go-chi/cors"
|
"github.com/go-chi/cors"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Dependencies carries the handler and database pool into the router.
|
// Dependencies carries the handler, database pool, SSE broker, and CORS
|
||||||
|
// configuration into the router.
|
||||||
type Dependencies struct {
|
type Dependencies struct {
|
||||||
Handler *handler.Handler
|
Handler *handler.Handler
|
||||||
DB *db.Pool
|
Pool *db.Pool
|
||||||
CORSOrigin string
|
CORSOrigin string
|
||||||
|
Broker *handler.Broker
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a fully-configured chi router with all API routes mounted.
|
// New creates a fully-configured chi router with all API routes mounted.
|
||||||
@@ -49,8 +52,10 @@ func New(deps *Dependencies) *chi.Mux {
|
|||||||
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
|
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
status := "ok"
|
status := "ok"
|
||||||
if deps.DB != nil {
|
if deps.Pool != nil {
|
||||||
if err := deps.DB.Health(r.Context()); err != nil {
|
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := deps.Pool.Ping(ctx); err != nil {
|
||||||
w.WriteHeader(http.StatusServiceUnavailable)
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
status = "db_unhealthy"
|
status = "db_unhealthy"
|
||||||
}
|
}
|
||||||
@@ -60,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
|
||||||
@@ -78,6 +98,9 @@ func New(deps *Dependencies) *chi.Mux {
|
|||||||
|
|
||||||
// Projects
|
// Projects
|
||||||
api.Get("/projects", deps.Handler.ListProjects)
|
api.Get("/projects", deps.Handler.ListProjects)
|
||||||
|
|
||||||
|
// SSE event stream
|
||||||
|
api.Get("/events", deps.Broker.ServeHTTP)
|
||||||
})
|
})
|
||||||
|
|
||||||
return r
|
return r
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Control Center Kiosk Service
|
||||||
|
# =============================
|
||||||
|
# Systemd unit file for auto-starting the Control Center kiosk on boot
|
||||||
|
#
|
||||||
|
# Install: sudo cp control-center-kiosk.service /etc/systemd/system/
|
||||||
|
# Enable: sudo systemctl enable control-center-kiosk
|
||||||
|
# Start: sudo systemctl start control-center-kiosk
|
||||||
|
# Status: sudo systemctl status control-center-kiosk
|
||||||
|
# Logs: sudo journalctl -u control-center-kiosk -f
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=Control Center Kiosk - Chrome Browser Dashboard
|
||||||
|
Documentation=https://code.cubecraftcreations.com/CubeCraft-Creations/Control-Center
|
||||||
|
After=graphical-session.target network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
PartOf=graphical-session.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/home/overseer/projects/Control-Center/kiosk/start-kiosk.sh http://localhost:3000
|
||||||
|
ExecReload=/bin/kill -HUP $MAINPID
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
Environment=DISPLAY=:0
|
||||||
|
Environment=XAUTHORITY=/home/overseer/.Xauthority
|
||||||
|
WorkingDirectory=/home/overseer/projects/Control-Center
|
||||||
|
User=overseer
|
||||||
|
Group=overseer
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=control-center-kiosk
|
||||||
|
|
||||||
|
# Security hardening
|
||||||
|
NoNewPrivileges=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ReadWritePaths=/home/overseer/.config/chromium
|
||||||
|
ReadWritePaths=/var/log/journal
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=graphical-session.target
|
||||||
Executable
+88
@@ -0,0 +1,88 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Control Center Kiosk Startup Script
|
||||||
|
# ====================================
|
||||||
|
# This script launches Chromium in kiosk mode for the Control Center dashboard
|
||||||
|
# Usage: ./start-kiosk.sh [frontend-url]
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
FRONTEND_URL="${1:-http://localhost:3000}"
|
||||||
|
BROWSER_WINDOW="chromium-browser"
|
||||||
|
|
||||||
|
# ── Functions ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
log() {
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
log "Stopping kiosk browser..."
|
||||||
|
pkill -f "chromium-browser.*--kiosk" || true
|
||||||
|
}
|
||||||
|
|
||||||
|
trap cleanup SIGINT SIGTERM
|
||||||
|
|
||||||
|
# ── Check prerequisites ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
check_browser() {
|
||||||
|
if ! command -v chromium-browser &> /dev/null; then
|
||||||
|
log "ERROR: chromium-browser not found"
|
||||||
|
log "Install with: sudo apt-get install chromium"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
check_x_server() {
|
||||||
|
if [ -z "$DISPLAY" ]; then
|
||||||
|
log "ERROR: DISPLAY environment variable not set"
|
||||||
|
log "This script requires an X server session"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Main ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
main() {
|
||||||
|
log "Starting Control Center Kiosk..."
|
||||||
|
log "Frontend URL: $FRONTEND_URL"
|
||||||
|
|
||||||
|
check_browser
|
||||||
|
check_x_server
|
||||||
|
|
||||||
|
# Clean up any existing browser instances
|
||||||
|
cleanup
|
||||||
|
|
||||||
|
# Launch Chromium in kiosk mode
|
||||||
|
# --kiosk: Fullscreen without browser UI
|
||||||
|
# --incognito: Clean session
|
||||||
|
# --noerrdialogs: Suppress error dialogs
|
||||||
|
# --disable-notifications: Disable notifications
|
||||||
|
# --disable-extensions: Disable extensions
|
||||||
|
# --disable-plugins-discovery: Disable plugins
|
||||||
|
# --disable-sync: Disable sync
|
||||||
|
# --disable-web-security: Allow CORS (needed for local API calls)
|
||||||
|
# --ignore-certificate-errors: Ignore SSL errors (for local dev)
|
||||||
|
# --gpu: Enable GPU acceleration
|
||||||
|
# --start-fullscreen: Start in fullscreen mode
|
||||||
|
chromium-browser \
|
||||||
|
--kiosk \
|
||||||
|
--incognito \
|
||||||
|
--noerrdialogs \
|
||||||
|
--disable-notifications \
|
||||||
|
--disable-extensions \
|
||||||
|
--disable-plugins-discovery \
|
||||||
|
--disable-sync \
|
||||||
|
--disable-web-security \
|
||||||
|
--ignore-certificate-errors \
|
||||||
|
--gpu \
|
||||||
|
--start-fullscreen \
|
||||||
|
"$FRONTEND_URL" &
|
||||||
|
|
||||||
|
KIOSK_PID=$!
|
||||||
|
log "Kiosk browser started (PID: $KIOSK_PID)"
|
||||||
|
|
||||||
|
# Wait for browser to exit
|
||||||
|
wait $KIOSK_PID
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user