- Evolution page: live polling (10s), activity feed, candidate tracking, statistics section, island overview with live.json schema - Series page: detailed series view with game-by-game results - Seasons page: season list with status and champion display - Predictions page: enhanced prediction UI with open matches - API types: add CycleInfo, Candidate, ActivityEntry, Totals for live.json - Embed: improved embeddable replay widget - Mobile CSS: responsive breakpoints and bottom tab bar - Exporter: enhanced live.json generation with full cycle/candidate data - Matchmaker: series scheduling support with config - Worker: additional database queries for series/season data Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
969 lines
29 KiB
Go
969 lines
29 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/redis/go-redis/v9"
|
|
)
|
|
|
|
// Server is the v1 API server for AI Code Battle.
|
|
// Provides bot registration, job coordination, replay serving,
|
|
// bot profiles, leaderboards, and UI feedback ingestion.
|
|
type Server struct {
|
|
cfg Config
|
|
db *sql.DB
|
|
rdb *redis.Client
|
|
}
|
|
|
|
func (s *Server) RegisterRoutes(mux *http.ServeMux) {
|
|
// Health endpoints
|
|
mux.HandleFunc("GET /health", s.handleHealth)
|
|
mux.HandleFunc("GET /ready", s.handleReady)
|
|
|
|
// Bot registration
|
|
mux.HandleFunc("POST /api/register", s.handleRegister)
|
|
|
|
// Job coordination (for workers)
|
|
mux.HandleFunc("GET /api/job", s.handleGetJob)
|
|
mux.HandleFunc("POST /api/job/", s.handleJobResult)
|
|
|
|
// Replay serving
|
|
mux.HandleFunc("GET /api/replay/", s.handleGetReplay)
|
|
|
|
// Bot profiles and leaderboard
|
|
mux.HandleFunc("GET /api/bot/", s.handleGetBot)
|
|
mux.HandleFunc("GET /api/bots", s.handleListBots)
|
|
|
|
// UI feedback (Agentation overlay)
|
|
mux.HandleFunc("POST /api/ui-feedback", s.handleUIFeedback)
|
|
|
|
// Predictions
|
|
mux.HandleFunc("POST /api/predict", s.handlePredict)
|
|
mux.HandleFunc("GET /api/predictions/open", s.handleOpenPredictions)
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
func writeError(w http.ResponseWriter, status int, msg string) {
|
|
writeJSON(w, status, map[string]string{"error": msg})
|
|
}
|
|
|
|
// handleRegister handles POST /api/register
|
|
// Request body: {"name": "...", "owner": "...", "endpoint_url": "..."}
|
|
// Response: {"bot_id": "...", "shared_secret": "..."}
|
|
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Name string `json:"name"`
|
|
Owner string `json:"owner"`
|
|
EndpointURL string `json:"endpoint_url"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
|
|
// Validate required fields
|
|
if req.Name == "" || req.Owner == "" || req.EndpointURL == "" {
|
|
writeError(w, http.StatusBadRequest, "name, owner, and endpoint_url are required")
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
// Check if name is already taken
|
|
var existingID string
|
|
err := s.db.QueryRowContext(ctx, "SELECT bot_id FROM bots WHERE name = $1", req.Name).Scan(&existingID)
|
|
if err == nil {
|
|
writeError(w, http.StatusConflict, fmt.Sprintf("bot name '%s' is already taken", req.Name))
|
|
return
|
|
} else if err != sql.ErrNoRows {
|
|
log.Printf("database error checking bot name: %v", err)
|
|
writeError(w, http.StatusInternalServerError, "database error")
|
|
return
|
|
}
|
|
|
|
// Generate bot ID and shared secret
|
|
botID, err := generateID("b_", 6)
|
|
if err != nil {
|
|
log.Printf("failed to generate bot ID: %v", err)
|
|
writeError(w, http.StatusInternalServerError, "failed to generate bot ID")
|
|
return
|
|
}
|
|
|
|
sharedSecret, err := generateSecret()
|
|
if err != nil {
|
|
log.Printf("failed to generate secret: %v", err)
|
|
writeError(w, http.StatusInternalServerError, "failed to generate secret")
|
|
return
|
|
}
|
|
|
|
// Encrypt the shared secret
|
|
var encryptedSecret string
|
|
if s.cfg.EncryptionKey != "" {
|
|
encryptedSecret, err = encryptSecret(sharedSecret, s.cfg.EncryptionKey)
|
|
if err != nil {
|
|
log.Printf("failed to encrypt secret: %v", err)
|
|
writeError(w, http.StatusInternalServerError, "failed to encrypt secret")
|
|
return
|
|
}
|
|
} else {
|
|
// If no encryption key configured, store plaintext (not recommended for production)
|
|
encryptedSecret = sharedSecret
|
|
}
|
|
|
|
// Validate bot is reachable by sending a health check
|
|
if err := s.validateBotEndpoint(ctx, req.EndpointURL); err != nil {
|
|
writeError(w, http.StatusBadRequest, fmt.Sprintf("bot endpoint validation failed: %v", err))
|
|
return
|
|
}
|
|
|
|
// Insert bot into database
|
|
_, err = s.db.ExecContext(ctx, `
|
|
INSERT INTO bots (bot_id, name, owner, endpoint_url, shared_secret, status)
|
|
VALUES ($1, $2, $3, $4, $5, 'active')
|
|
`, botID, req.Name, req.Owner, req.EndpointURL, encryptedSecret)
|
|
if err != nil {
|
|
log.Printf("failed to insert bot: %v", err)
|
|
writeError(w, http.StatusInternalServerError, "failed to register bot")
|
|
return
|
|
}
|
|
|
|
log.Printf("registered bot %s (name=%s, owner=%s)", botID, req.Name, req.Owner)
|
|
|
|
writeJSON(w, http.StatusCreated, map[string]string{
|
|
"bot_id": botID,
|
|
"shared_secret": sharedSecret,
|
|
})
|
|
}
|
|
|
|
// validateBotEndpoint checks if the bot endpoint is reachable
|
|
func (s *Server) validateBotEndpoint(ctx context.Context, endpointURL string) error {
|
|
// Remove trailing slash for consistency
|
|
endpointURL = strings.TrimRight(endpointURL, "/")
|
|
|
|
// Try to GET /health endpoint with a timeout
|
|
healthURL := endpointURL + "/health"
|
|
client := &http.Client{Timeout: time.Duration(s.cfg.BotTimeoutSecs) * time.Second}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, healthURL, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid endpoint URL: %w", err)
|
|
}
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("endpoint unreachable: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("health check returned status %d", resp.StatusCode)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// handleGetJob handles GET /api/job
|
|
// Workers poll this endpoint to get the next pending match job.
|
|
// Requires Bearer token authentication (worker API key).
|
|
// Response: job JSON or empty if no jobs available.
|
|
func (s *Server) handleGetJob(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
return
|
|
}
|
|
|
|
// Authenticate worker
|
|
if !s.authenticateWorker(r) {
|
|
writeError(w, http.StatusUnauthorized, "invalid or missing worker API key")
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
// Query for the next pending job
|
|
var job struct {
|
|
JobID string `json:"job_id"`
|
|
MatchID string `json:"match_id"`
|
|
ConfigJSON json.RawMessage `json:"config_json"`
|
|
}
|
|
|
|
err := s.db.QueryRowContext(ctx, `
|
|
SELECT job_id, match_id, config_json
|
|
FROM jobs
|
|
WHERE status = 'pending'
|
|
ORDER BY created_at ASC
|
|
LIMIT 1
|
|
FOR UPDATE SKIP LOCKED
|
|
`).Scan(&job.JobID, &job.MatchID, &job.ConfigJSON)
|
|
|
|
if err == sql.ErrNoRows {
|
|
// No pending jobs
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "no_jobs"})
|
|
return
|
|
} else if err != nil {
|
|
log.Printf("database error getting job: %v", err)
|
|
writeError(w, http.StatusInternalServerError, "database error")
|
|
return
|
|
}
|
|
|
|
// Parse config_json to get match details
|
|
var config struct {
|
|
MapID string `json:"map_id"`
|
|
MapSeed int64 `json:"map_seed"`
|
|
BotIDs []string `json:"bot_ids"`
|
|
PlayerSlots []int `json:"player_slots"`
|
|
}
|
|
if err := json.Unmarshal(job.ConfigJSON, &config); err != nil {
|
|
log.Printf("failed to parse job config: %v", err)
|
|
writeError(w, http.StatusInternalServerError, "invalid job config")
|
|
return
|
|
}
|
|
|
|
// Get map data
|
|
var mapData struct {
|
|
MapID string `json:"map_id"`
|
|
GridWidth int `json:"grid_width"`
|
|
GridHeight int `json:"grid_height"`
|
|
MapJSON json.RawMessage `json:"map_json"`
|
|
}
|
|
err = s.db.QueryRowContext(ctx, `
|
|
SELECT map_id, grid_width, grid_height, map_json
|
|
FROM maps WHERE map_id = $1
|
|
`, config.MapID).Scan(&mapData.MapID, &mapData.GridWidth, &mapData.GridHeight, &mapData.MapJSON)
|
|
if err != nil {
|
|
log.Printf("failed to get map: %v", err)
|
|
writeError(w, http.StatusInternalServerError, "map not found")
|
|
return
|
|
}
|
|
|
|
// Get bot endpoints and secrets
|
|
bots := make([]map[string]interface{}, 0, len(config.BotIDs))
|
|
for _, botID := range config.BotIDs {
|
|
var endpointURL, encryptedSecret string
|
|
err := s.db.QueryRowContext(ctx, `
|
|
SELECT endpoint_url, shared_secret FROM bots WHERE bot_id = $1
|
|
`, botID).Scan(&endpointURL, &encryptedSecret)
|
|
if err != nil {
|
|
log.Printf("failed to get bot %s: %v", botID, err)
|
|
writeError(w, http.StatusInternalServerError, "bot not found")
|
|
return
|
|
}
|
|
|
|
// Decrypt secret if encryption key is configured
|
|
var sharedSecret string
|
|
if s.cfg.EncryptionKey != "" {
|
|
sharedSecret, err = decryptSecret(encryptedSecret, s.cfg.EncryptionKey)
|
|
if err != nil {
|
|
log.Printf("failed to decrypt secret for bot %s: %v", botID, err)
|
|
// Fall back to treating it as plaintext
|
|
sharedSecret = encryptedSecret
|
|
}
|
|
} else {
|
|
sharedSecret = encryptedSecret
|
|
}
|
|
|
|
bots = append(bots, map[string]interface{}{
|
|
"bot_id": botID,
|
|
"endpoint_url": endpointURL,
|
|
"shared_secret": sharedSecret,
|
|
})
|
|
}
|
|
|
|
// Build response
|
|
response := map[string]interface{}{
|
|
"job_id": job.JobID,
|
|
"match_id": job.MatchID,
|
|
"map_id": config.MapID,
|
|
"map_seed": config.MapSeed,
|
|
"map_width": mapData.GridWidth,
|
|
"map_height": mapData.GridHeight,
|
|
"map_json": mapData.MapJSON,
|
|
"bots": bots,
|
|
"player_slots": config.PlayerSlots,
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, response)
|
|
}
|
|
|
|
// handleJobResult handles POST /api/job/{id}/result
|
|
// Workers submit match results here.
|
|
// Requires Bearer token authentication.
|
|
// Request body: {"winner": "...", "turns": 123, "end_reason": "...", "scores": {...}, "replay": {...}}
|
|
func (s *Server) handleJobResult(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
return
|
|
}
|
|
|
|
// Authenticate worker
|
|
if !s.authenticateWorker(r) {
|
|
writeError(w, http.StatusUnauthorized, "invalid or missing worker API key")
|
|
return
|
|
}
|
|
|
|
// Extract job ID from path: /api/job/{id}/result
|
|
pathParts := strings.Split(r.URL.Path, "/")
|
|
if len(pathParts) < 4 || pathParts[3] != "result" {
|
|
writeError(w, http.StatusBadRequest, "invalid path")
|
|
return
|
|
}
|
|
jobID := pathParts[2]
|
|
|
|
var req struct {
|
|
WinnerID string `json:"winner"`
|
|
Turns int `json:"turns"`
|
|
EndReason string `json:"end_reason"`
|
|
Scores map[string]int `json:"scores"`
|
|
Replay json.RawMessage `json:"replay"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
log.Printf("failed to begin transaction: %v", err)
|
|
writeError(w, http.StatusInternalServerError, "database error")
|
|
return
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
// Get match ID for this job
|
|
var matchID string
|
|
err = tx.QueryRowContext(ctx, "SELECT match_id FROM jobs WHERE job_id = $1", jobID).Scan(&matchID)
|
|
if err == sql.ErrNoRows {
|
|
writeError(w, http.StatusNotFound, "job not found")
|
|
return
|
|
} else if err != nil {
|
|
log.Printf("failed to get job: %v", err)
|
|
writeError(w, http.StatusInternalServerError, "database error")
|
|
return
|
|
}
|
|
|
|
// Update job status
|
|
_, err = tx.ExecContext(ctx, `
|
|
UPDATE jobs SET status = 'completed', completed_at = NOW() WHERE job_id = $1
|
|
`, jobID)
|
|
if err != nil {
|
|
log.Printf("failed to update job: %v", err)
|
|
writeError(w, http.StatusInternalServerError, "database error")
|
|
return
|
|
}
|
|
|
|
// Determine winner player index
|
|
var winnerIndex *int
|
|
if req.WinnerID != "" {
|
|
var idx int
|
|
err := tx.QueryRowContext(ctx, `
|
|
SELECT player_slot FROM match_participants WHERE match_id = $1 AND bot_id = $2
|
|
`, matchID, req.WinnerID).Scan(&idx)
|
|
if err == nil {
|
|
winnerIndex = &idx
|
|
}
|
|
}
|
|
|
|
// Update match status
|
|
scoresJSON, _ := json.Marshal(req.Scores)
|
|
_, err = tx.ExecContext(ctx, `
|
|
UPDATE matches
|
|
SET status = 'completed', winner = $1, condition = $2, turn_count = $3, scores_json = $4, completed_at = NOW()
|
|
WHERE match_id = $5
|
|
`, winnerIndex, req.EndReason, req.Turns, scoresJSON, matchID)
|
|
if err != nil {
|
|
log.Printf("failed to update match: %v", err)
|
|
writeError(w, http.StatusInternalServerError, "database error")
|
|
return
|
|
}
|
|
|
|
// Update participant scores
|
|
for botID, score := range req.Scores {
|
|
_, err = tx.ExecContext(ctx, `
|
|
UPDATE match_participants SET score = $1 WHERE match_id = $2 AND bot_id = $3
|
|
`, score, matchID, botID)
|
|
if err != nil {
|
|
log.Printf("failed to update participant score: %v", err)
|
|
}
|
|
}
|
|
|
|
// Note: Rating updates are handled by the worker separately via the rating endpoint
|
|
// or can be computed here if the ratings are provided in the request
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
log.Printf("failed to commit transaction: %v", err)
|
|
writeError(w, http.StatusInternalServerError, "database error")
|
|
return
|
|
}
|
|
|
|
log.Printf("completed job %s, match %s, winner %s", jobID, matchID, req.WinnerID)
|
|
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
}
|
|
|
|
// handleGetReplay handles GET /api/replay/{id}
|
|
// Serves replay JSON from R2 warm cache with B2 fallback.
|
|
func (s *Server) handleGetReplay(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
return
|
|
}
|
|
|
|
// Extract match ID from path: /api/replay/{id}
|
|
pathParts := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/replay/"), "/")
|
|
if len(pathParts) == 0 || pathParts[0] == "" {
|
|
writeError(w, http.StatusBadRequest, "invalid match ID")
|
|
return
|
|
}
|
|
matchID := pathParts[0]
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
// First, try to get from R2 warm cache
|
|
// This requires R2 credentials to be configured
|
|
replayData, err := s.fetchReplayFromR2(ctx, matchID)
|
|
if err == nil {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write(replayData)
|
|
return
|
|
}
|
|
log.Printf("R2 fetch failed for %s: %v", matchID, err)
|
|
|
|
// Fall back to B2 cold archive
|
|
replayData, err = s.fetchReplayFromB2(ctx, matchID)
|
|
if err == nil {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
|
w.Header().Set("X-ACB-Source", "b2")
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write(replayData)
|
|
return
|
|
}
|
|
|
|
log.Printf("B2 fetch also failed for %s: %v", matchID, err)
|
|
writeError(w, http.StatusNotFound, "replay not found")
|
|
}
|
|
|
|
// fetchReplayFromR2 attempts to fetch a replay from R2 warm cache
|
|
func (s *Server) fetchReplayFromR2(ctx context.Context, matchID string) ([]byte, error) {
|
|
// R2 endpoint and credentials would be configured via environment variables
|
|
r2Endpoint := "https://r2.aicodebattle.com" // Default R2 endpoint
|
|
if env := getEnv("ACB_R2_ENDPOINT", ""); env != "" {
|
|
r2Endpoint = env
|
|
}
|
|
|
|
url := fmt.Sprintf("%s/replays/%s.json", r2Endpoint, matchID)
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
client := &http.Client{Timeout: 10 * time.Second}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("R2 returned status %d", resp.StatusCode)
|
|
}
|
|
|
|
return io.ReadAll(resp.Body)
|
|
}
|
|
|
|
// fetchReplayFromB2 attempts to fetch a replay from B2 cold archive
|
|
func (s *Server) fetchReplayFromB2(ctx context.Context, matchID string) ([]byte, error) {
|
|
// B2 endpoint and credentials would be configured via environment variables
|
|
b2Endpoint := "https://b2.aicodebattle.com" // Default B2 endpoint
|
|
if env := getEnv("ACB_B2_ENDPOINT", ""); env != "" {
|
|
b2Endpoint = env
|
|
}
|
|
|
|
url := fmt.Sprintf("%s/replays/%s.json", b2Endpoint, matchID)
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
client := &http.Client{Timeout: 10 * time.Second}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("B2 returned status %d", resp.StatusCode)
|
|
}
|
|
|
|
return io.ReadAll(resp.Body)
|
|
}
|
|
|
|
// handleGetBot handles GET /api/bot/{id}
|
|
// Returns bot profile JSON including rating, record, and metadata.
|
|
func (s *Server) handleGetBot(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
return
|
|
}
|
|
|
|
// Extract bot ID from path: /api/bot/{id}
|
|
pathParts := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/bot/"), "/")
|
|
if len(pathParts) == 0 || pathParts[0] == "" {
|
|
writeError(w, http.StatusBadRequest, "invalid bot ID")
|
|
return
|
|
}
|
|
botID := pathParts[0]
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
// Get bot details
|
|
var bot struct {
|
|
BotID string `json:"bot_id"`
|
|
Name string `json:"name"`
|
|
Owner string `json:"owner"`
|
|
Status string `json:"status"`
|
|
RatingMu float64 `json:"rating_mu"`
|
|
RatingPhi float64 `json:"rating_phi"`
|
|
Evolved bool `json:"evolved"`
|
|
Island *string `json:"island,omitempty"`
|
|
Generation *int `json:"generation,omitempty"`
|
|
ParentIDs *string `json:"parent_ids,omitempty"`
|
|
CreatedAt string `json:"created_at"`
|
|
LastActive *string `json:"last_active,omitempty"`
|
|
}
|
|
|
|
err := s.db.QueryRowContext(ctx, `
|
|
SELECT bot_id, name, owner, status, rating_mu, rating_phi,
|
|
evolved, island, generation, parent_ids,
|
|
to_char(created_at, 'YYYY-MM-DD\"T\"HH24:MI:SSZ') as created_at,
|
|
to_char(last_active, 'YYYY-MM-DD\"T\"HH24:MI:SSZ') as last_active
|
|
FROM bots WHERE bot_id = $1
|
|
`, botID).Scan(
|
|
&bot.BotID, &bot.Name, &bot.Owner, &bot.Status,
|
|
&bot.RatingMu, &bot.RatingPhi, &bot.Evolved,
|
|
&bot.Island, &bot.Generation, &bot.ParentIDs,
|
|
&bot.CreatedAt, &bot.LastActive,
|
|
)
|
|
|
|
if err == sql.ErrNoRows {
|
|
writeError(w, http.StatusNotFound, "bot not found")
|
|
return
|
|
} else if err != nil {
|
|
log.Printf("database error getting bot: %v", err)
|
|
writeError(w, http.StatusInternalServerError, "database error")
|
|
return
|
|
}
|
|
|
|
// Calculate win/loss record
|
|
var wins, losses int
|
|
err = s.db.QueryRowContext(ctx, `
|
|
SELECT
|
|
COUNT(*) FILTER (WHERE mp.bot_id = $1 AND m.winner = (
|
|
SELECT player_slot FROM match_participants WHERE match_id = m.match_id AND bot_id = $1
|
|
)) as wins,
|
|
COUNT(*) FILTER (WHERE mp.bot_id = $1 AND m.winner IS NOT NULL AND m.winner != (
|
|
SELECT player_slot FROM match_participants WHERE match_id = m.match_id AND bot_id = $1
|
|
)) as losses
|
|
FROM match_participants mp
|
|
JOIN matches m ON mp.match_id = m.match_id
|
|
WHERE mp.bot_id = $1 AND m.status = 'completed'
|
|
`, botID).Scan(&wins, &losses)
|
|
if err != nil {
|
|
log.Printf("error getting bot record: %v", err)
|
|
// Continue without record data
|
|
}
|
|
|
|
// Build response
|
|
response := map[string]interface{}{
|
|
"bot_id": bot.BotID,
|
|
"name": bot.Name,
|
|
"owner": bot.Owner,
|
|
"status": bot.Status,
|
|
"rating": bot.RatingMu - 2*bot.RatingPhi, // Conservative rating estimate
|
|
"rating_mu": bot.RatingMu,
|
|
"rating_phi": bot.RatingPhi,
|
|
"evolved": bot.Evolved,
|
|
"island": bot.Island,
|
|
"generation": bot.Generation,
|
|
"parent_ids": bot.ParentIDs,
|
|
"created_at": bot.CreatedAt,
|
|
"last_active": bot.LastActive,
|
|
"record": map[string]int{
|
|
"wins": wins,
|
|
"losses": losses,
|
|
},
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, response)
|
|
}
|
|
|
|
// handleListBots handles GET /api/bots
|
|
// Returns leaderboard snapshot of all active bots.
|
|
func (s *Server) handleListBots(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
// Parse query parameters for pagination
|
|
limit := 100
|
|
offset := 0
|
|
if l := r.URL.Query().Get("limit"); l != "" {
|
|
if n, err := strconv.Atoi(l); err == nil && n > 0 && n <= 1000 {
|
|
limit = n
|
|
}
|
|
}
|
|
if o := r.URL.Query().Get("offset"); o != "" {
|
|
if n, err := strconv.Atoi(o); err == nil && n >= 0 {
|
|
offset = n
|
|
}
|
|
}
|
|
|
|
// Query active bots ordered by rating
|
|
rows, err := s.db.QueryContext(ctx, `
|
|
SELECT b.bot_id, b.name, b.owner, b.rating_mu, b.rating_phi,
|
|
b.evolved, b.island, b.generation,
|
|
to_char(b.created_at, 'YYYY-MM-DD\"T\"HH24:MI:SSZ') as created_at,
|
|
COALESCE(wins.wins, 0) as wins, COALESCE(losses.losses, 0) as losses
|
|
FROM bots b
|
|
LEFT JOIN (
|
|
SELECT mp.bot_id, COUNT(*) FILTER (WHERE m.winner = mp.player_slot) as wins
|
|
FROM match_participants mp
|
|
JOIN matches m ON mp.match_id = m.match_id
|
|
WHERE m.status = 'completed'
|
|
GROUP BY mp.bot_id
|
|
) wins ON b.bot_id = wins.bot_id
|
|
LEFT JOIN (
|
|
SELECT mp.bot_id, COUNT(*) FILTER (WHERE m.winner IS NOT NULL AND m.winner != mp.player_slot) as losses
|
|
FROM match_participants mp
|
|
JOIN matches m ON mp.match_id = m.match_id
|
|
WHERE m.status = 'completed'
|
|
GROUP BY mp.bot_id
|
|
) losses ON b.bot_id = losses.bot_id
|
|
WHERE b.status = 'active'
|
|
ORDER BY (b.rating_mu - 2*b.rating_phi) DESC
|
|
LIMIT $1 OFFSET $2
|
|
`, limit, offset)
|
|
if err != nil {
|
|
log.Printf("database error listing bots: %v", err)
|
|
writeError(w, http.StatusInternalServerError, "database error")
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
bots := make([]map[string]interface{}, 0)
|
|
for rows.Next() {
|
|
var bot struct {
|
|
BotID string `json:"bot_id"`
|
|
Name string `json:"name"`
|
|
Owner string `json:"owner"`
|
|
RatingMu float64 `json:"rating_mu"`
|
|
RatingPhi float64 `json:"rating_phi"`
|
|
Evolved bool `json:"evolved"`
|
|
Island *string `json:"island,omitempty"`
|
|
Generation *int `json:"generation,omitempty"`
|
|
CreatedAt string `json:"created_at"`
|
|
Wins int `json:"wins"`
|
|
Losses int `json:"losses"`
|
|
}
|
|
err := rows.Scan(
|
|
&bot.BotID, &bot.Name, &bot.Owner, &bot.RatingMu, &bot.RatingPhi,
|
|
&bot.Evolved, &bot.Island, &bot.Generation, &bot.CreatedAt,
|
|
&bot.Wins, &bot.Losses,
|
|
)
|
|
if err != nil {
|
|
log.Printf("error scanning bot: %v", err)
|
|
continue
|
|
}
|
|
|
|
bots = append(bots, map[string]interface{}{
|
|
"bot_id": bot.BotID,
|
|
"name": bot.Name,
|
|
"owner": bot.Owner,
|
|
"rating": bot.RatingMu - 2*bot.RatingPhi,
|
|
"rating_mu": bot.RatingMu,
|
|
"rating_phi": bot.RatingPhi,
|
|
"evolved": bot.Evolved,
|
|
"island": bot.Island,
|
|
"generation": bot.Generation,
|
|
"created_at": bot.CreatedAt,
|
|
"record": map[string]int{
|
|
"wins": bot.Wins,
|
|
"losses": bot.Losses,
|
|
},
|
|
})
|
|
}
|
|
|
|
if rows.Err() != nil {
|
|
log.Printf("error iterating bots: %v", rows.Err())
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"bots": bots,
|
|
"limit": limit,
|
|
"offset": offset,
|
|
"count": len(bots),
|
|
})
|
|
}
|
|
|
|
// handlePredict handles POST /api/predict
|
|
// Accepts {match_id, bot_id, confidence} and writes to predictions table.
|
|
// Rejects if the match has already started (status != 'pending').
|
|
func (s *Server) handlePredict(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
MatchID string `json:"match_id"`
|
|
BotID string `json:"bot_id"`
|
|
Predictor string `json:"predictor_id"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
|
|
if req.MatchID == "" || req.BotID == "" || req.Predictor == "" {
|
|
writeError(w, http.StatusBadRequest, "match_id, bot_id, and predictor_id are required")
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
// Verify match exists and hasn't started
|
|
var matchStatus string
|
|
err := s.db.QueryRowContext(ctx, `
|
|
SELECT status FROM matches WHERE match_id = $1
|
|
`, req.MatchID).Scan(&matchStatus)
|
|
if err == sql.ErrNoRows {
|
|
writeError(w, http.StatusNotFound, "match not found")
|
|
return
|
|
} else if err != nil {
|
|
log.Printf("database error checking match: %v", err)
|
|
writeError(w, http.StatusInternalServerError, "database error")
|
|
return
|
|
}
|
|
|
|
if matchStatus != "pending" {
|
|
writeError(w, http.StatusConflict, "match has already started")
|
|
return
|
|
}
|
|
|
|
// Verify bot is a participant in this match
|
|
var participantExists bool
|
|
err = s.db.QueryRowContext(ctx, `
|
|
SELECT EXISTS(
|
|
SELECT 1 FROM match_participants
|
|
WHERE match_id = $1 AND bot_id = $2
|
|
)
|
|
`, req.MatchID, req.BotID).Scan(&participantExists)
|
|
if err != nil {
|
|
log.Printf("database error checking participant: %v", err)
|
|
writeError(w, http.StatusInternalServerError, "database error")
|
|
return
|
|
}
|
|
if !participantExists {
|
|
writeError(w, http.StatusBadRequest, "bot is not a participant in this match")
|
|
return
|
|
}
|
|
|
|
// Insert prediction (UNIQUE constraint handles duplicates)
|
|
var predictionID int64
|
|
err = s.db.QueryRowContext(ctx, `
|
|
INSERT INTO predictions (match_id, predictor_id, predicted_bot)
|
|
VALUES ($1, $2, $3)
|
|
ON CONFLICT (match_id, predictor_id) DO UPDATE SET predicted_bot = $3
|
|
RETURNING id
|
|
`, req.MatchID, req.Predictor, req.BotID).Scan(&predictionID)
|
|
if err != nil {
|
|
log.Printf("failed to insert prediction: %v", err)
|
|
writeError(w, http.StatusInternalServerError, "failed to submit prediction")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
|
"id": predictionID,
|
|
"match_id": req.MatchID,
|
|
"predicted": req.BotID,
|
|
"predictor": req.Predictor,
|
|
})
|
|
}
|
|
|
|
// handleOpenPredictions handles GET /api/predictions/open
|
|
// Returns pending matches that are open for predictions, along with
|
|
// any predictions the requesting predictor has made.
|
|
func (s *Server) handleOpenPredictions(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
return
|
|
}
|
|
|
|
predictorID := r.URL.Query().Get("predictor_id")
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
// Get pending matches with their participants
|
|
rows, err := s.db.QueryContext(ctx, `
|
|
SELECT m.match_id, m.created_at,
|
|
COALESCE(json_agg(json_build_object('bot_id', mp.bot_id, 'name', b.name, 'rating', b.rating_mu - 2*b.rating_phi)) FILTER (WHERE mp.bot_id IS NOT NULL), '[]')
|
|
FROM matches m
|
|
JOIN match_participants mp ON m.match_id = mp.match_id
|
|
JOIN bots b ON mp.bot_id = b.bot_id
|
|
WHERE m.status = 'pending'
|
|
GROUP BY m.match_id, m.created_at
|
|
ORDER BY m.created_at ASC
|
|
LIMIT 20
|
|
`)
|
|
if err != nil {
|
|
log.Printf("database error fetching open matches: %v", err)
|
|
writeError(w, http.StatusInternalServerError, "database error")
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
type MatchPrediction struct {
|
|
MatchID string `json:"match_id"`
|
|
CreatedAt string `json:"created_at"`
|
|
Participants []map[string]interface{} `json:"participants"`
|
|
YourPick *string `json:"your_pick,omitempty"`
|
|
}
|
|
|
|
var matches []MatchPrediction
|
|
for rows.Next() {
|
|
var m MatchPrediction
|
|
var participantsJSON string
|
|
if err := rows.Scan(&m.MatchID, &m.CreatedAt, &participantsJSON); err != nil {
|
|
log.Printf("error scanning match: %v", err)
|
|
continue
|
|
}
|
|
json.Unmarshal([]byte(participantsJSON), &m.Participants)
|
|
|
|
// If predictor_id given, look up their existing prediction
|
|
if predictorID != "" {
|
|
var predictedBot string
|
|
err := s.db.QueryRowContext(ctx, `
|
|
SELECT predicted_bot FROM predictions
|
|
WHERE match_id = $1 AND predictor_id = $2
|
|
`, m.MatchID, predictorID).Scan(&predictedBot)
|
|
if err == nil {
|
|
m.YourPick = &predictedBot
|
|
}
|
|
}
|
|
|
|
matches = append(matches, m)
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"matches": matches,
|
|
})
|
|
}
|
|
|
|
// handleUIFeedback handles POST /api/ui-feedback
|
|
// Accepts Agentation UI feedback (annotations, issues, etc.).
|
|
// Stores in database or logs to disk.
|
|
func (s *Server) handleUIFeedback(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
MatchID string `json:"match_id"`
|
|
Turn int `json:"turn"`
|
|
Type string `json:"type"` // "annotation", "issue", "suggestion"
|
|
Message string `json:"message"`
|
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
|
|
// Validate required fields
|
|
if req.MatchID == "" || req.Type == "" {
|
|
writeError(w, http.StatusBadRequest, "match_id and type are required")
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
// Try to store in database if ui_feedback table exists
|
|
metadataJSON, _ := json.Marshal(req.Metadata)
|
|
_, err := s.db.ExecContext(ctx, `
|
|
INSERT INTO ui_feedback (match_id, turn, type, message, metadata, created_at)
|
|
VALUES ($1, $2, $3, $4, $5, NOW())
|
|
ON CONFLICT DO NOTHING
|
|
`, req.MatchID, req.Turn, req.Type, req.Message, metadataJSON)
|
|
|
|
if err != nil {
|
|
// If table doesn't exist, log to file instead
|
|
log.Printf("[UI-FEEDBACK] match=%s turn=%d type=%s: %s", req.MatchID, req.Turn, req.Type, req.Message)
|
|
// Still return success to not break the UI
|
|
} else {
|
|
log.Printf("[UI-FEEDBACK] stored: match=%s turn=%d type=%s", req.MatchID, req.Turn, req.Type)
|
|
}
|
|
|
|
writeJSON(w, http.StatusCreated, map[string]string{"status": "recorded"})
|
|
}
|
|
|
|
// authenticateWorker checks if the request has a valid worker API key
|
|
func (s *Server) authenticateWorker(r *http.Request) bool {
|
|
authHeader := r.Header.Get("Authorization")
|
|
if authHeader == "" {
|
|
return false
|
|
}
|
|
|
|
// Expect "Bearer {api_key}"
|
|
parts := strings.SplitN(authHeader, " ", 2)
|
|
if len(parts) != 2 || parts[0] != "Bearer" {
|
|
return false
|
|
}
|
|
|
|
return parts[1] == s.cfg.WorkerAPIKey
|
|
}
|
|
|
|
// getEnv gets an environment variable with a default value
|
|
func getEnv(key, defaultValue string) string {
|
|
// This function is a simple helper - in production use the one from config.go
|
|
// For now, inline the logic
|
|
return defaultValue
|
|
}
|