ai-code-battle/cmd/acb-worker/api.go
jedarden 6659027bec Implement match worker container (cmd/acb-worker/)
- Worker polls Cloudflare Worker API for pending match jobs
- Claims jobs and executes matches using the game engine
- Uploads replays to R2 via S3-compatible API
- Sends heartbeats during match execution
- Submits results back to Worker API
- Includes retry logic with exponential backoff
- API client tests for job coordination endpoints

Also fixes glicko2.ts: export g() and E() functions for testing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 08:06:15 -04:00

274 lines
7.3 KiB
Go

// API client for Worker API communication
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
// APIClient communicates with the Worker API.
type APIClient struct {
endpoint string
apiKey string
httpClient *http.Client
maxRetries int
}
// NewAPIClient creates a new API client.
func NewAPIClient(cfg *Config) *APIClient {
return &APIClient{
endpoint: cfg.APIEndpoint,
apiKey: cfg.APIKey,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
maxRetries: cfg.MaxRetries,
}
}
// Job represents a pending job from the API.
type Job struct {
ID string `json:"id"`
MatchID string `json:"match_id"`
Status string `json:"status"`
WorkerID *string `json:"worker_id"`
ClaimedAt *time.Time `json:"claimed_at"`
HeartbeatAt *time.Time `json:"heartbeat_at"`
CreatedAt time.Time `json:"created_at"`
}
// JobClaimResponse contains the data needed to execute a match.
type JobClaimResponse struct {
Job Job `json:"job"`
Match Match `json:"match"`
Participants []Participant `json:"participants"`
Map MapData `json:"map"`
Bots []BotInfo `json:"bots"`
BotSecrets []BotSecret `json:"bot_secrets"`
}
// Match represents match metadata.
type Match struct {
ID string `json:"id"`
Status string `json:"status"`
WinnerID *string `json:"winner_id"`
Turns *int `json:"turns"`
EndReason *string `json:"end_reason"`
MapID string `json:"map_id"`
CreatedAt time.Time `json:"created_at"`
StartedAt *time.Time `json:"started_at"`
CompletedAt *time.Time `json:"completed_at"`
}
// Participant represents a match participant.
type Participant struct {
ID string `json:"id"`
MatchID string `json:"match_id"`
BotID string `json:"bot_id"`
PlayerIndex int `json:"player_index"`
Score int `json:"score"`
RatingBefore int `json:"rating_before"`
RatingAfter *int `json:"rating_after"`
RatingDeviationBefore int `json:"rating_deviation_before"`
RatingDeviationAfter *int `json:"rating_deviation_after"`
}
// MapData represents map configuration.
type MapData struct {
ID string `json:"id"`
Width int `json:"width"`
Height int `json:"height"`
Walls string `json:"walls"`
Spawns string `json:"spawns"`
Cores string `json:"cores"`
}
// BotInfo contains bot endpoint information.
type BotInfo struct {
ID string `json:"id"`
EndpointURL string `json:"endpoint_url"`
}
// BotSecret contains bot authentication secret.
type BotSecret struct {
BotID string `json:"bot_id"`
Secret string `json:"secret"`
}
// APIResponse is a generic API response.
type APIResponse struct {
Success bool `json:"success"`
Data json.RawMessage `json:"data,omitempty"`
Error string `json:"error,omitempty"`
}
// GetNextJob fetches the next pending job.
func (c *APIClient) GetNextJob(ctx context.Context) (*Job, error) {
resp, err := c.doRequest(ctx, "GET", "/api/jobs/next", nil)
if err != nil {
return nil, err
}
var apiResp APIResponse
if err := json.Unmarshal(resp, &apiResp); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
if !apiResp.Success {
return nil, fmt.Errorf("API error: %s", apiResp.Error)
}
if apiResp.Data == nil {
return nil, nil // No pending jobs
}
var job Job
if err := json.Unmarshal(apiResp.Data, &job); err != nil {
return nil, fmt.Errorf("failed to parse job: %w", err)
}
return &job, nil
}
// ClaimJob claims a job for execution.
func (c *APIClient) ClaimJob(ctx context.Context, jobID string, workerID string) (*JobClaimResponse, error) {
body := map[string]string{"worker_id": workerID}
resp, err := c.doRequest(ctx, "POST", "/api/jobs/"+jobID+"/claim", body)
if err != nil {
return nil, err
}
var apiResp APIResponse
if err := json.Unmarshal(resp, &apiResp); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
if !apiResp.Success {
return nil, fmt.Errorf("API error: %s", apiResp.Error)
}
var claimResp JobClaimResponse
if err := json.Unmarshal(apiResp.Data, &claimResp); err != nil {
return nil, fmt.Errorf("failed to parse claim response: %w", err)
}
return &claimResp, nil
}
// Heartbeat sends a heartbeat for a claimed job.
func (c *APIClient) Heartbeat(ctx context.Context, jobID string, workerID string) error {
body := map[string]string{"worker_id": workerID}
_, err := c.doRequest(ctx, "POST", "/api/jobs/"+jobID+"/heartbeat", body)
return err
}
// SubmitResult submits the result of a completed match.
func (c *APIClient) SubmitResult(ctx context.Context, jobID string, result *MatchResult, replayURL string) error {
body := map[string]interface{}{
"winner_id": result.WinnerID,
"turns": result.Turns,
"end_reason": result.EndReason,
"replay_url": replayURL,
"scores": result.Scores,
}
_, err := c.doRequest(ctx, "POST", "/api/jobs/"+jobID+"/result", body)
return err
}
// FailJob marks a job as failed.
func (c *APIClient) FailJob(ctx context.Context, jobID string, workerID string, errorMessage string) error {
body := map[string]string{
"worker_id": workerID,
"error_message": errorMessage,
}
_, err := c.doRequest(ctx, "POST", "/api/jobs/"+jobID+"/fail", body)
return err
}
// doRequest makes an HTTP request with retries.
func (c *APIClient) doRequest(ctx context.Context, method string, path string, body interface{}) ([]byte, error) {
var lastErr error
for attempt := 0; attempt <= c.maxRetries; attempt++ {
if attempt > 0 {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(time.Second * time.Duration(attempt)):
}
}
resp, err := c.doSingleRequest(ctx, method, path, body)
if err != nil {
lastErr = err
// Check if it's a client error (don't retry)
if httpErr, ok := err.(*HTTPError); ok && httpErr.StatusCode >= 400 && httpErr.StatusCode < 500 {
return nil, err
}
continue
}
return resp, nil
}
return nil, fmt.Errorf("request failed after %d retries: %w", c.maxRetries, lastErr)
}
// doSingleRequest makes a single HTTP request.
func (c *APIClient) doSingleRequest(ctx context.Context, method string, path string, body interface{}) ([]byte, error) {
var reqBody io.Reader
if body != nil {
data, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("failed to marshal request body: %w", err)
}
reqBody = bytes.NewReader(data)
}
req, err := http.NewRequestWithContext(ctx, method, c.endpoint+path, reqBody)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.apiKey)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode >= 400 {
return nil, &HTTPError{
StatusCode: resp.StatusCode,
Body: string(respBody),
}
}
return respBody, nil
}
// HTTPError represents an HTTP error response.
type HTTPError struct {
StatusCode int
Body string
}
func (e *HTTPError) Error() string {
return fmt.Sprintf("HTTP %d: %s", e.StatusCode, e.Body)
}