Start Phase 4: Cloudflare Worker API for match orchestration
- Add worker-api/ with TypeScript + Wrangler setup - D1 database schema (bots, matches, jobs, rating_history) - Glicko-2 rating system implementation with unit tests - Job coordination endpoints (claim, heartbeat, result, fail) - Bot management endpoints (register, list, update, rotate-key) - Cron handlers (matchmaker, health checker, stale job reaper) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
4f77980398
commit
9bd4efc935
13 changed files with 1853 additions and 2 deletions
36
PROGRESS.md
36
PROGRESS.md
|
|
@ -1,8 +1,40 @@
|
|||
# AI Code Battle - Implementation Progress
|
||||
|
||||
## Current Phase: Phase 3 - Replay Viewer
|
||||
## Current Phase: Phase 4 - Match Orchestration
|
||||
|
||||
**Status: ✅ COMPLETE**
|
||||
**Status: 🔄 In Progress**
|
||||
|
||||
### Phase 4 Progress
|
||||
|
||||
- [x] Cloudflare Worker project structure (`worker-api/`)
|
||||
- TypeScript + Wrangler configuration
|
||||
- D1 database schema (bots, matches, jobs, rating_history tables)
|
||||
- [x] Glicko-2 rating system (`worker-api/src/glicko2.ts`)
|
||||
- Rating scale conversion
|
||||
- Rating updates after matches
|
||||
- Rating decay for inactive bots
|
||||
- Unit tests (17 tests)
|
||||
- [x] Job coordination endpoints (`worker-api/src/jobs.ts`)
|
||||
- GET /api/jobs/next - Get next pending job
|
||||
- POST /api/jobs/:id/claim - Claim job for execution
|
||||
- POST /api/jobs/:id/heartbeat - Update job heartbeat
|
||||
- POST /api/jobs/:id/result - Submit match result
|
||||
- POST /api/jobs/:id/fail - Mark job as failed
|
||||
- [x] Bot management endpoints (`worker-api/src/bots.ts`)
|
||||
- POST /api/register - Register new bot
|
||||
- GET /api/bots - List all bots
|
||||
- GET /api/bots/:id - Get bot details
|
||||
- PUT /api/bots/:id - Update bot
|
||||
- POST /api/rotate-key - Rotate API key
|
||||
- GET /api/leaderboard - Get leaderboard
|
||||
- [x] Cron handlers (`worker-api/src/cron.ts`)
|
||||
- Matchmaker (every minute) - Creates match jobs
|
||||
- Health checker (every 15 min) - Pings bot endpoints
|
||||
- Stale job reaper (every 5 min) - Reclaims timed-out jobs
|
||||
- [ ] Match worker container (`cmd/acb-worker/`)
|
||||
- [ ] Rackspace index builder
|
||||
|
||||
### Phase 3 Completed
|
||||
|
||||
### Phase 1 Completed
|
||||
|
||||
|
|
|
|||
17
worker-api/package.json
Normal file
17
worker-api/package.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"name": "acb-api",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "wrangler dev",
|
||||
"deploy": "wrangler deploy",
|
||||
"cf-typegen": "wrangler types",
|
||||
"test": "vitest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20250310.0",
|
||||
"typescript": "^5.8.2",
|
||||
"vitest": "^3.0.9",
|
||||
"wrangler": "^4.4.0"
|
||||
}
|
||||
}
|
||||
112
worker-api/schema.sql
Normal file
112
worker-api/schema.sql
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
-- AI Code Battle D1 Schema
|
||||
-- Phase 4: Match Orchestration
|
||||
|
||||
-- Bots table: stores registered bots
|
||||
CREATE TABLE IF NOT EXISTS bots (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
owner_id TEXT NOT NULL,
|
||||
endpoint_url TEXT NOT NULL,
|
||||
api_key_hash TEXT NOT NULL,
|
||||
rating REAL NOT NULL DEFAULT 1500.0,
|
||||
rating_deviation REAL NOT NULL DEFAULT 350.0,
|
||||
rating_volatility REAL NOT NULL DEFAULT 0.06,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
last_health_check TEXT,
|
||||
health_status TEXT DEFAULT 'unknown',
|
||||
matches_played INTEGER NOT NULL DEFAULT 0,
|
||||
matches_won INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_bots_owner ON bots(owner_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_bots_rating ON bots(rating DESC);
|
||||
|
||||
-- Matches table: stores match metadata
|
||||
CREATE TABLE IF NOT EXISTS matches (
|
||||
id TEXT PRIMARY KEY,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
winner_id TEXT,
|
||||
turns INTEGER,
|
||||
end_reason TEXT,
|
||||
map_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
started_at TEXT,
|
||||
completed_at TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_matches_status ON matches(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_matches_created ON matches(created_at DESC);
|
||||
|
||||
-- Match participants: links bots to matches
|
||||
CREATE TABLE IF NOT EXISTS match_participants (
|
||||
id TEXT PRIMARY KEY,
|
||||
match_id TEXT NOT NULL,
|
||||
bot_id TEXT NOT NULL,
|
||||
player_index INTEGER NOT NULL,
|
||||
score INTEGER NOT NULL DEFAULT 0,
|
||||
rating_before REAL NOT NULL,
|
||||
rating_after REAL,
|
||||
rating_deviation_before REAL NOT NULL,
|
||||
rating_deviation_after REAL,
|
||||
FOREIGN KEY (match_id) REFERENCES matches(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (bot_id) REFERENCES bots(id) ON DELETE CASCADE,
|
||||
UNIQUE(match_id, bot_id),
|
||||
UNIQUE(match_id, player_index)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_match_participants_match ON match_participants(match_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_match_participants_bot ON match_participants(bot_id);
|
||||
|
||||
-- Jobs table: match execution jobs for workers
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
match_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
worker_id TEXT,
|
||||
claimed_at TEXT,
|
||||
heartbeat_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
completed_at TEXT,
|
||||
error_message TEXT,
|
||||
FOREIGN KEY (match_id) REFERENCES matches(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_worker ON jobs(worker_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_heartbeat ON jobs(heartbeat_at);
|
||||
|
||||
-- Rating history: tracks rating changes over time
|
||||
CREATE TABLE IF NOT EXISTS rating_history (
|
||||
id TEXT PRIMARY KEY,
|
||||
bot_id TEXT NOT NULL,
|
||||
match_id TEXT NOT NULL,
|
||||
rating_before REAL NOT NULL,
|
||||
rating_after REAL NOT NULL,
|
||||
rating_deviation REAL NOT NULL,
|
||||
recorded_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (bot_id) REFERENCES bots(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (match_id) REFERENCES matches(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_rating_history_bot ON rating_history(bot_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_rating_history_time ON rating_history(recorded_at DESC);
|
||||
|
||||
-- Maps table: stores generated maps
|
||||
CREATE TABLE IF NOT EXISTS maps (
|
||||
id TEXT PRIMARY KEY,
|
||||
width INTEGER NOT NULL,
|
||||
height INTEGER NOT NULL,
|
||||
walls TEXT NOT NULL,
|
||||
spawns TEXT NOT NULL,
|
||||
cores TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Bot secrets: stores API keys for bots (separate for security)
|
||||
CREATE TABLE IF NOT EXISTS bot_secrets (
|
||||
bot_id TEXT PRIMARY KEY,
|
||||
api_key_hash TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (bot_id) REFERENCES bots(id) ON DELETE CASCADE
|
||||
);
|
||||
240
worker-api/src/bots.ts
Normal file
240
worker-api/src/bots.ts
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
// Bot Management Endpoints
|
||||
|
||||
import type { Env, Bot, CreateBotRequest, ApiResponse } from './types';
|
||||
|
||||
/**
|
||||
* Generate a random API key (256-bit, hex-encoded)
|
||||
*/
|
||||
function generateApiKey(): string {
|
||||
const bytes = new Uint8Array(32);
|
||||
crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash an API key for storage
|
||||
*/
|
||||
async function hashApiKey(key: string): Promise<string> {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(key);
|
||||
const hash = await crypto.subtle.digest('SHA-256', data);
|
||||
return Array.from(new Uint8Array(hash))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/register - Register a new bot
|
||||
*/
|
||||
export async function registerBot(
|
||||
env: Env,
|
||||
request: CreateBotRequest
|
||||
): Promise<ApiResponse<{ id: string; api_key: string }>> {
|
||||
// Validate request
|
||||
if (!request.name || !request.owner_id || !request.endpoint_url) {
|
||||
return { success: false, error: 'Missing required fields' };
|
||||
}
|
||||
|
||||
// Validate endpoint URL
|
||||
try {
|
||||
new URL(request.endpoint_url);
|
||||
} catch {
|
||||
return { success: false, error: 'Invalid endpoint URL' };
|
||||
}
|
||||
|
||||
const botId = crypto.randomUUID();
|
||||
const apiKey = generateApiKey();
|
||||
const apiKeyHash = await hashApiKey(apiKey);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Check if owner already has a bot with this name
|
||||
const existing = await env.DB.prepare(
|
||||
'SELECT id FROM bots WHERE owner_id = ? AND name = ?'
|
||||
)
|
||||
.bind(request.owner_id, request.name)
|
||||
.first();
|
||||
|
||||
if (existing) {
|
||||
return { success: false, error: 'Bot with this name already exists for this owner' };
|
||||
}
|
||||
|
||||
// Create bot
|
||||
await env.DB.prepare(
|
||||
`INSERT INTO bots (id, name, owner_id, endpoint_url, api_key_hash, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.bind(
|
||||
botId,
|
||||
request.name,
|
||||
request.owner_id,
|
||||
request.endpoint_url,
|
||||
apiKeyHash,
|
||||
now,
|
||||
now
|
||||
)
|
||||
.run();
|
||||
|
||||
// Store API key hash separately
|
||||
await env.DB.prepare(
|
||||
`INSERT INTO bot_secrets (bot_id, api_key_hash, created_at)
|
||||
VALUES (?, ?, ?)`
|
||||
)
|
||||
.bind(botId, apiKeyHash, now)
|
||||
.run();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
id: botId,
|
||||
api_key: apiKey, // Return the plain key only on creation
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/bots - List all bots
|
||||
*/
|
||||
export async function listBots(env: Env): Promise<ApiResponse<Bot[]>> {
|
||||
const result = await env.DB.prepare(
|
||||
`SELECT
|
||||
id, name, owner_id, endpoint_url, rating, rating_deviation, rating_volatility,
|
||||
created_at, updated_at, last_health_check, health_status, matches_played, matches_won
|
||||
FROM bots
|
||||
ORDER BY rating DESC`
|
||||
).all<Bot>();
|
||||
|
||||
// Remove sensitive fields
|
||||
const bots = (result.results || []).map((bot) => ({
|
||||
...bot,
|
||||
api_key_hash: '',
|
||||
}));
|
||||
|
||||
return { success: true, data: bots };
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/bots/:id - Get bot details
|
||||
*/
|
||||
export async function getBot(env: Env, botId: string): Promise<ApiResponse<Bot>> {
|
||||
const bot = await env.DB.prepare(
|
||||
`SELECT
|
||||
id, name, owner_id, endpoint_url, rating, rating_deviation, rating_volatility,
|
||||
created_at, updated_at, last_health_check, health_status, matches_played, matches_won
|
||||
FROM bots
|
||||
WHERE id = ?`
|
||||
)
|
||||
.bind(botId)
|
||||
.first<Bot>();
|
||||
|
||||
if (!bot) {
|
||||
return { success: false, error: 'Bot not found' };
|
||||
}
|
||||
|
||||
return { success: true, data: { ...bot, api_key_hash: '' } };
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/bots/:id - Update bot details
|
||||
*/
|
||||
export async function updateBot(
|
||||
env: Env,
|
||||
botId: string,
|
||||
updates: { name?: string; endpoint_url?: string }
|
||||
): Promise<ApiResponse<void>> {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const setClauses: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
|
||||
if (updates.name) {
|
||||
setClauses.push('name = ?');
|
||||
values.push(updates.name);
|
||||
}
|
||||
|
||||
if (updates.endpoint_url) {
|
||||
try {
|
||||
new URL(updates.endpoint_url);
|
||||
setClauses.push('endpoint_url = ?');
|
||||
values.push(updates.endpoint_url);
|
||||
} catch {
|
||||
return { success: false, error: 'Invalid endpoint URL' };
|
||||
}
|
||||
}
|
||||
|
||||
if (setClauses.length === 0) {
|
||||
return { success: false, error: 'No valid updates provided' };
|
||||
}
|
||||
|
||||
setClauses.push('updated_at = ?');
|
||||
values.push(now);
|
||||
values.push(botId);
|
||||
|
||||
const result = await env.DB.prepare(
|
||||
`UPDATE bots SET ${setClauses.join(', ')} WHERE id = ?`
|
||||
)
|
||||
.bind(...values)
|
||||
.run();
|
||||
|
||||
if (result.meta.changes === 0) {
|
||||
return { success: false, error: 'Bot not found' };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/rotate-key - Rotate bot API key
|
||||
*/
|
||||
export async function rotateApiKey(
|
||||
env: Env,
|
||||
botId: string,
|
||||
ownerId: string
|
||||
): Promise<ApiResponse<{ api_key: string }>> {
|
||||
// Verify ownership
|
||||
const bot = await env.DB.prepare('SELECT owner_id FROM bots WHERE id = ?')
|
||||
.bind(botId)
|
||||
.first<{ owner_id: string }>();
|
||||
|
||||
if (!bot) {
|
||||
return { success: false, error: 'Bot not found' };
|
||||
}
|
||||
|
||||
if (bot.owner_id !== ownerId) {
|
||||
return { success: false, error: 'Not authorized' };
|
||||
}
|
||||
|
||||
const newApiKey = generateApiKey();
|
||||
const apiKeyHash = await hashApiKey(newApiKey);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Update bot
|
||||
await env.DB.prepare('UPDATE bots SET api_key_hash = ?, updated_at = ? WHERE id = ?')
|
||||
.bind(apiKeyHash, now, botId)
|
||||
.run();
|
||||
|
||||
// Update secret
|
||||
await env.DB.prepare('UPDATE bot_secrets SET api_key_hash = ? WHERE bot_id = ?')
|
||||
.bind(apiKeyHash, botId)
|
||||
.run();
|
||||
|
||||
return { success: true, data: { api_key: newApiKey } };
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/leaderboard - Get current leaderboard
|
||||
*/
|
||||
export async function getLeaderboard(env: Env): Promise<ApiResponse<Bot[]>> {
|
||||
const result = await env.DB.prepare(
|
||||
`SELECT
|
||||
id, name, owner_id, rating, rating_deviation, matches_played, matches_won,
|
||||
created_at, updated_at, health_status
|
||||
FROM bots
|
||||
WHERE matches_played > 0
|
||||
ORDER BY rating DESC
|
||||
LIMIT 100`
|
||||
).all<Bot>();
|
||||
|
||||
return { success: true, data: result.results || [] };
|
||||
}
|
||||
228
worker-api/src/cron.ts
Normal file
228
worker-api/src/cron.ts
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
// Cron Job Handlers
|
||||
|
||||
import type { Env, Bot } from './types';
|
||||
|
||||
/**
|
||||
* Matchmaker cron: Create match jobs for bots that need games
|
||||
* Runs every minute
|
||||
*/
|
||||
export async function runMatchmaker(env: Env): Promise<{ created: number }> {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Get bots that are healthy and have played fewer than 10 matches today
|
||||
// For simplicity, we'll just pair bots randomly for now
|
||||
// A more sophisticated system would consider rating proximity
|
||||
|
||||
// Get active bots (healthy, played at least one match or registered recently)
|
||||
const bots = await env.DB.prepare(
|
||||
`SELECT id, rating, matches_played FROM bots
|
||||
WHERE health_status = 'healthy'
|
||||
ORDER BY RANDOM()
|
||||
LIMIT 10`
|
||||
).all<Bot>();
|
||||
|
||||
if (!bots.results || bots.results.length < 2) {
|
||||
return { created: 0 };
|
||||
}
|
||||
|
||||
// Get a random map
|
||||
const map = await env.DB.prepare(
|
||||
'SELECT id FROM maps ORDER BY RANDOM() LIMIT 1'
|
||||
).first<{ id: string }>();
|
||||
|
||||
if (!map) {
|
||||
return { created: 0 };
|
||||
}
|
||||
|
||||
let created = 0;
|
||||
|
||||
// Create matches in pairs
|
||||
for (let i = 0; i < bots.results.length - 1; i += 2) {
|
||||
const bot1 = bots.results[i];
|
||||
const bot2 = bots.results[i + 1];
|
||||
|
||||
// Check if these bots already have a pending match together
|
||||
const existingMatch = await env.DB.prepare(
|
||||
`SELECT m.id FROM matches m
|
||||
JOIN match_participants mp1 ON m.id = mp1.match_id
|
||||
JOIN match_participants mp2 ON m.id = mp2.match_id
|
||||
WHERE m.status = 'pending'
|
||||
AND mp1.bot_id = ? AND mp2.bot_id = ?`
|
||||
)
|
||||
.bind(bot1.id, bot2.id)
|
||||
.first();
|
||||
|
||||
if (existingMatch) {
|
||||
continue; // Skip this pair
|
||||
}
|
||||
|
||||
// Create match
|
||||
const matchId = crypto.randomUUID();
|
||||
await env.DB.prepare(
|
||||
`INSERT INTO matches (id, status, map_id, created_at)
|
||||
VALUES (?, 'pending', ?, ?)`
|
||||
)
|
||||
.bind(matchId, map.id, now)
|
||||
.run();
|
||||
|
||||
// Get bot ratings for participants
|
||||
const bot1Data = await env.DB.prepare(
|
||||
'SELECT rating, rating_deviation FROM bots WHERE id = ?'
|
||||
)
|
||||
.bind(bot1.id)
|
||||
.first<{ rating: number; rating_deviation: number }>();
|
||||
|
||||
const bot2Data = await env.DB.prepare(
|
||||
'SELECT rating, rating_deviation FROM bots WHERE id = ?'
|
||||
)
|
||||
.bind(bot2.id)
|
||||
.first<{ rating: number; rating_deviation: number }>();
|
||||
|
||||
if (!bot1Data || !bot2Data) continue;
|
||||
|
||||
// Create participants (player_index 0 and 1)
|
||||
await env.DB.prepare(
|
||||
`INSERT INTO match_participants (id, match_id, bot_id, player_index, score, rating_before, rating_deviation_before)
|
||||
VALUES (?, ?, ?, 0, 0, ?, ?)`
|
||||
)
|
||||
.bind(crypto.randomUUID(), matchId, bot1.id, bot1Data.rating, bot1Data.rating_deviation)
|
||||
.run();
|
||||
|
||||
await env.DB.prepare(
|
||||
`INSERT INTO match_participants (id, match_id, bot_id, player_index, score, rating_before, rating_deviation_before)
|
||||
VALUES (?, ?, ?, 1, 0, ?, ?)`
|
||||
)
|
||||
.bind(crypto.randomUUID(), matchId, bot2.id, bot2Data.rating, bot2Data.rating_deviation)
|
||||
.run();
|
||||
|
||||
// Create job
|
||||
await env.DB.prepare(
|
||||
`INSERT INTO jobs (id, match_id, status, created_at)
|
||||
VALUES (?, ?, 'pending', ?)`
|
||||
)
|
||||
.bind(crypto.randomUUID(), matchId, now)
|
||||
.run();
|
||||
|
||||
created++;
|
||||
}
|
||||
|
||||
return { created };
|
||||
}
|
||||
|
||||
/**
|
||||
* Health checker cron: Ping bot endpoints to check health
|
||||
* Runs every 15 minutes
|
||||
*/
|
||||
export async function runHealthChecker(env: Env): Promise<{ checked: number }> {
|
||||
const bots = await env.DB.prepare(
|
||||
`SELECT id, endpoint_url FROM bots WHERE health_status != 'unhealthy' OR last_health_check IS NULL`
|
||||
).all<{ id: string; endpoint_url: string }>();
|
||||
|
||||
let checked = 0;
|
||||
const now = new Date().toISOString();
|
||||
|
||||
for (const bot of bots.results || []) {
|
||||
try {
|
||||
// Simple health check - just try to connect
|
||||
const response = await fetch(bot.endpoint_url, {
|
||||
method: 'GET',
|
||||
signal: AbortSignal.timeout(5000), // 5 second timeout
|
||||
});
|
||||
|
||||
const status = response.ok ? 'healthy' : 'unhealthy';
|
||||
|
||||
await env.DB.prepare(
|
||||
`UPDATE bots SET health_status = ?, last_health_check = ? WHERE id = ?`
|
||||
)
|
||||
.bind(status, now, bot.id)
|
||||
.run();
|
||||
|
||||
checked++;
|
||||
} catch {
|
||||
// Connection failed
|
||||
await env.DB.prepare(
|
||||
`UPDATE bots SET health_status = 'unhealthy', last_health_check = ? WHERE id = ?`
|
||||
)
|
||||
.bind(now, bot.id)
|
||||
.run();
|
||||
|
||||
checked++;
|
||||
}
|
||||
}
|
||||
|
||||
return { checked };
|
||||
}
|
||||
|
||||
/**
|
||||
* Stale job reaper: Reclaim jobs that have timed out
|
||||
* Runs every 5 minutes
|
||||
*/
|
||||
export async function runStaleJobReaper(env: Env): Promise<{ reclaimed: number }> {
|
||||
const now = new Date();
|
||||
const staleThreshold = new Date(now.getTime() - 5 * 60 * 1000); // 5 minutes ago
|
||||
const staleThresholdStr = staleThreshold.toISOString();
|
||||
|
||||
// Find jobs that have been claimed but haven't had a heartbeat in 5 minutes
|
||||
const staleJobs = await env.DB.prepare(
|
||||
`SELECT id, match_id FROM jobs
|
||||
WHERE status = 'claimed'
|
||||
AND heartbeat_at < ?`
|
||||
)
|
||||
.bind(staleThresholdStr)
|
||||
.all<{ id: string; match_id: string }>();
|
||||
|
||||
let reclaimed = 0;
|
||||
|
||||
for (const job of staleJobs.results || []) {
|
||||
// Reset the job to pending so another worker can claim it
|
||||
await env.DB.prepare(
|
||||
`UPDATE jobs SET
|
||||
status = 'pending',
|
||||
worker_id = NULL,
|
||||
claimed_at = NULL,
|
||||
heartbeat_at = NULL
|
||||
WHERE id = ?`
|
||||
)
|
||||
.bind(job.id)
|
||||
.run();
|
||||
|
||||
// Reset match status to pending
|
||||
await env.DB.prepare(
|
||||
`UPDATE matches SET status = 'pending', started_at = NULL WHERE id = ?`
|
||||
)
|
||||
.bind(job.match_id)
|
||||
.run();
|
||||
|
||||
reclaimed++;
|
||||
}
|
||||
|
||||
return { reclaimed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch cron handler based on event type
|
||||
*/
|
||||
export async function handleCron(
|
||||
env: Env,
|
||||
cron: string
|
||||
): Promise<{ success: boolean; result: unknown }> {
|
||||
// Parse cron expression to determine which handler to run
|
||||
// */1 * * * * = matchmaker (every minute)
|
||||
// */5 * * * * = stale job reaper (every 5 minutes)
|
||||
// */15 * * * * = health checker (every 15 minutes)
|
||||
|
||||
// The cron expression is passed, but we need to determine the type
|
||||
// For simplicity, we'll check the pattern
|
||||
if (cron === '*/1 * * * *' || cron.includes('*/1')) {
|
||||
const result = await runMatchmaker(env);
|
||||
return { success: true, result };
|
||||
} else if (cron === '*/5 * * * *' || cron.includes('*/5')) {
|
||||
const result = await runStaleJobReaper(env);
|
||||
return { success: true, result };
|
||||
} else if (cron === '*/15 * * * *' || cron.includes('*/15')) {
|
||||
const result = await runHealthChecker(env);
|
||||
return { success: true, result };
|
||||
}
|
||||
|
||||
return { success: false, result: 'Unknown cron pattern' };
|
||||
}
|
||||
292
worker-api/src/glicko2.test.ts
Normal file
292
worker-api/src/glicko2.test.ts
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
toGlicko2,
|
||||
fromGlicko2,
|
||||
updateRating,
|
||||
g,
|
||||
E,
|
||||
} from './glicko2';
|
||||
|
||||
describe('Glicko-2 Rating System', () => {
|
||||
describe('Scale Conversion', () => {
|
||||
it('converts rating to Glicko-2 scale correctly', () => {
|
||||
// Default rating 1500 should map to mu=0
|
||||
const result = toGlicko2(1500, 350);
|
||||
expect(result.mu).toBe(0);
|
||||
expect(result.phi).toBeCloseTo(350 / 173.7178, 10);
|
||||
});
|
||||
|
||||
it('converts rating above default correctly', () => {
|
||||
const result = toGlicko2(1900, 100);
|
||||
expect(result.mu).toBeCloseTo(400 / 173.7178, 10);
|
||||
expect(result.phi).toBeCloseTo(100 / 173.7178, 10);
|
||||
});
|
||||
|
||||
it('converts rating below default correctly', () => {
|
||||
const result = toGlicko2(1300, 200);
|
||||
expect(result.mu).toBeCloseTo(-200 / 173.7178, 10);
|
||||
expect(result.phi).toBeCloseTo(200 / 173.7178, 10);
|
||||
});
|
||||
|
||||
it('round-trips correctly', () => {
|
||||
const originalRating = 1650;
|
||||
const originalRd = 150;
|
||||
|
||||
const g2 = toGlicko2(originalRating, originalRd);
|
||||
const result = fromGlicko2(g2);
|
||||
|
||||
expect(result.rating).toBeCloseTo(originalRating, 10);
|
||||
expect(result.rd).toBeCloseTo(originalRd, 10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('g function', () => {
|
||||
it('returns 1 when phi is 0', () => {
|
||||
expect(g(0)).toBe(1);
|
||||
});
|
||||
|
||||
it('decreases as phi increases', () => {
|
||||
const g1 = g(0.1);
|
||||
const g2 = g(0.5);
|
||||
const g3 = g(1.0);
|
||||
|
||||
expect(g1).toBeGreaterThan(g2);
|
||||
expect(g2).toBeGreaterThan(g3);
|
||||
});
|
||||
|
||||
it('returns correct values for known inputs', () => {
|
||||
// g(0.2) ≈ 0.9955 (from paper example)
|
||||
expect(g(0.2)).toBeCloseTo(0.9955, 4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('E function', () => {
|
||||
it('returns 0.5 when ratings are equal', () => {
|
||||
const e = E(0, 0, 0.2);
|
||||
expect(e).toBeCloseTo(0.5, 10);
|
||||
});
|
||||
|
||||
it('returns > 0.5 when player rating is higher', () => {
|
||||
const e = E(0.5, 0, 0.2); // Player rated higher
|
||||
expect(e).toBeGreaterThan(0.5);
|
||||
});
|
||||
|
||||
it('returns < 0.5 when opponent rating is higher', () => {
|
||||
const e = E(0, 0.5, 0.2); // Opponent rated higher
|
||||
expect(e).toBeLessThan(0.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Rating Updates', () => {
|
||||
it('increases rating after win against equal opponent', () => {
|
||||
const bot = {
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
owner_id: 'owner',
|
||||
endpoint_url: 'http://example.com',
|
||||
api_key_hash: 'hash',
|
||||
rating: 1500,
|
||||
rating_deviation: 200,
|
||||
rating_volatility: 0.06,
|
||||
created_at: '2024-01-01',
|
||||
updated_at: '2024-01-01',
|
||||
last_health_check: null,
|
||||
health_status: 'healthy' as const,
|
||||
matches_played: 0,
|
||||
matches_won: 0,
|
||||
};
|
||||
|
||||
const opponents = [
|
||||
{ rating: 1500, rd: 200, score: 1 }, // Win
|
||||
];
|
||||
|
||||
const result = updateRating(bot, opponents);
|
||||
|
||||
// Rating should increase after winning
|
||||
expect(result.rating).toBeGreaterThan(1500);
|
||||
// RD should decrease after playing
|
||||
expect(result.rd).toBeLessThan(200);
|
||||
});
|
||||
|
||||
it('decreases rating after loss against equal opponent', () => {
|
||||
const bot = {
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
owner_id: 'owner',
|
||||
endpoint_url: 'http://example.com',
|
||||
api_key_hash: 'hash',
|
||||
rating: 1500,
|
||||
rating_deviation: 200,
|
||||
rating_volatility: 0.06,
|
||||
created_at: '2024-01-01',
|
||||
updated_at: '2024-01-01',
|
||||
last_health_check: null,
|
||||
health_status: 'healthy' as const,
|
||||
matches_played: 0,
|
||||
matches_won: 0,
|
||||
};
|
||||
|
||||
const opponents = [
|
||||
{ rating: 1500, rd: 200, score: 0 }, // Loss
|
||||
];
|
||||
|
||||
const result = updateRating(bot, opponents);
|
||||
|
||||
// Rating should decrease after losing
|
||||
expect(result.rating).toBeLessThan(1500);
|
||||
// RD should decrease after playing
|
||||
expect(result.rd).toBeLessThan(200);
|
||||
});
|
||||
|
||||
it('handles draw correctly', () => {
|
||||
const bot = {
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
owner_id: 'owner',
|
||||
endpoint_url: 'http://example.com',
|
||||
api_key_hash: 'hash',
|
||||
rating: 1500,
|
||||
rating_deviation: 200,
|
||||
rating_volatility: 0.06,
|
||||
created_at: '2024-01-01',
|
||||
updated_at: '2024-01-01',
|
||||
last_health_check: null,
|
||||
health_status: 'healthy' as const,
|
||||
matches_played: 0,
|
||||
matches_won: 0,
|
||||
};
|
||||
|
||||
const opponents = [
|
||||
{ rating: 1500, rd: 200, score: 0.5 }, // Draw
|
||||
];
|
||||
|
||||
const result = updateRating(bot, opponents);
|
||||
|
||||
// Rating should stay roughly the same against equal opponent
|
||||
expect(result.rating).toBeCloseTo(1500, 1);
|
||||
// RD should decrease after playing
|
||||
expect(result.rd).toBeLessThan(200);
|
||||
});
|
||||
|
||||
it('handles multiple opponents', () => {
|
||||
const bot = {
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
owner_id: 'owner',
|
||||
endpoint_url: 'http://example.com',
|
||||
api_key_hash: 'hash',
|
||||
rating: 1500,
|
||||
rating_deviation: 200,
|
||||
rating_volatility: 0.06,
|
||||
created_at: '2024-01-01',
|
||||
updated_at: '2024-01-01',
|
||||
last_health_check: null,
|
||||
health_status: 'healthy' as const,
|
||||
matches_played: 0,
|
||||
matches_won: 0,
|
||||
};
|
||||
|
||||
const opponents = [
|
||||
{ rating: 1600, rd: 150, score: 1 }, // Win vs higher rated
|
||||
{ rating: 1400, rd: 150, score: 0 }, // Loss vs lower rated
|
||||
];
|
||||
|
||||
const result = updateRating(bot, opponents);
|
||||
|
||||
// Both rating and RD should be updated
|
||||
expect(result.rating).toBeGreaterThan(0);
|
||||
expect(result.rd).toBeLessThan(200);
|
||||
});
|
||||
|
||||
it('increases RD when no games played (rating decay)', () => {
|
||||
const bot = {
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
owner_id: 'owner',
|
||||
endpoint_url: 'http://example.com',
|
||||
api_key_hash: 'hash',
|
||||
rating: 1500,
|
||||
rating_deviation: 100,
|
||||
rating_volatility: 0.06,
|
||||
created_at: '2024-01-01',
|
||||
updated_at: '2024-01-01',
|
||||
last_health_check: null,
|
||||
health_status: 'healthy' as const,
|
||||
matches_played: 0,
|
||||
matches_won: 0,
|
||||
};
|
||||
|
||||
const result = updateRating(bot, []);
|
||||
|
||||
// Rating should stay the same
|
||||
expect(result.rating).toBe(1500);
|
||||
// RD should increase (rating decay)
|
||||
expect(result.rd).toBeGreaterThan(100);
|
||||
});
|
||||
|
||||
it('constrains RD to maximum', () => {
|
||||
const bot = {
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
owner_id: 'owner',
|
||||
endpoint_url: 'http://example.com',
|
||||
api_key_hash: 'hash',
|
||||
rating: 1500,
|
||||
rating_deviation: 340,
|
||||
rating_volatility: 0.5, // High volatility
|
||||
created_at: '2024-01-01',
|
||||
updated_at: '2024-01-01',
|
||||
last_health_check: null,
|
||||
health_status: 'healthy' as const,
|
||||
matches_played: 0,
|
||||
matches_won: 0,
|
||||
};
|
||||
|
||||
const result = updateRating(bot, []);
|
||||
|
||||
// RD should not exceed 350
|
||||
expect(result.rd).toBeLessThanOrEqual(350);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Real-world scenarios', () => {
|
||||
it('matches expected rating change from Glicko-2 paper example', () => {
|
||||
// This is a simplified test based on the Glicko-2 paper
|
||||
// Player with rating 1500, RD 200 playing against:
|
||||
// - Opponent 1: 1400, 30, win (score=1)
|
||||
// - Opponent 2: 1550, 100, loss (score=0)
|
||||
// - Opponent 3: 1700, 300, loss (score=0)
|
||||
|
||||
const bot = {
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
owner_id: 'owner',
|
||||
endpoint_url: 'http://example.com',
|
||||
api_key_hash: 'hash',
|
||||
rating: 1500,
|
||||
rating_deviation: 200,
|
||||
rating_volatility: 0.06,
|
||||
created_at: '2024-01-01',
|
||||
updated_at: '2024-01-01',
|
||||
last_health_check: null,
|
||||
health_status: 'healthy' as const,
|
||||
matches_played: 0,
|
||||
matches_won: 0,
|
||||
};
|
||||
|
||||
const opponents = [
|
||||
{ rating: 1400, rd: 30, score: 1 },
|
||||
{ rating: 1550, rd: 100, score: 0 },
|
||||
{ rating: 1700, rd: 300, score: 0 },
|
||||
];
|
||||
|
||||
const result = updateRating(bot, opponents);
|
||||
|
||||
// The new rating should be in a reasonable range
|
||||
// Based on the paper, expected new rating is approximately 1464
|
||||
expect(result.rating).toBeGreaterThan(1400);
|
||||
expect(result.rating).toBeLessThan(1550);
|
||||
expect(result.rd).toBeLessThan(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
309
worker-api/src/glicko2.ts
Normal file
309
worker-api/src/glicko2.ts
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
// Glicko-2 Rating System Implementation
|
||||
// Based on: http://www.glicko.net/glicko/glicko2.pdf
|
||||
|
||||
import type { Env, Bot, MatchParticipant } from './types';
|
||||
|
||||
// Glicko-2 constants
|
||||
const SCALE = 173.7178; // Rating scale conversion factor
|
||||
const TAU = 0.5; // System constant (constrains volatility change)
|
||||
const DEFAULT_RATING = 1500;
|
||||
const DEFAULT_RD = 350;
|
||||
const DEFAULT_VOLATILITY = 0.06;
|
||||
|
||||
export interface Glicko2Rating {
|
||||
mu: number; // Mean rating (Glicko-2 scale)
|
||||
phi: number; // Rating deviation (Glicko-2 scale)
|
||||
sigma: number; // Volatility
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert rating to Glicko-2 scale
|
||||
*/
|
||||
export function toGlicko2(rating: number, rd: number): Glicko2Rating {
|
||||
return {
|
||||
mu: (rating - DEFAULT_RATING) / SCALE,
|
||||
phi: rd / SCALE,
|
||||
sigma: DEFAULT_VOLATILITY,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert from Glicko-2 scale to original scale
|
||||
*/
|
||||
export function fromGlicko2(g2: Glicko2Rating): { rating: number; rd: number } {
|
||||
return {
|
||||
rating: g2.mu * SCALE + DEFAULT_RATING,
|
||||
rd: g2.phi * SCALE,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute g(phi) function
|
||||
*/
|
||||
function g(phi: number): number {
|
||||
return 1 / Math.sqrt(1 + (3 * phi * phi) / (Math.PI * Math.PI));
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute E(mu, mu_j, phi_j) function
|
||||
*/
|
||||
function E(mu: number, mu_j: number, phi_j: number): number {
|
||||
return 1 / (1 + Math.exp(-g(phi_j) * (mu - mu_j)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute new rating deviation (Step 5/6)
|
||||
*/
|
||||
function computeNewPhi(phi: number, v: number): number {
|
||||
const phiSquared = phi * phi;
|
||||
const vInverse = 1 / v;
|
||||
return 1 / Math.sqrt(1 / phiSquared + vInverse);
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterative algorithm to compute new volatility (Step 5.4)
|
||||
*/
|
||||
function computeNewVolatility(
|
||||
sigma: number,
|
||||
phi: number,
|
||||
v: number,
|
||||
delta: number,
|
||||
tau: number = TAU
|
||||
): number {
|
||||
let a = Math.log(sigma * sigma);
|
||||
const epsilon = 0.000001;
|
||||
|
||||
const f = (x: number): number => {
|
||||
const expX = Math.exp(x);
|
||||
const tmp = phi * phi + v + expX;
|
||||
return (
|
||||
(expX * (delta * delta - phi * phi - v - expX)) / (2 * tmp * tmp) -
|
||||
(x - a) / (tau * tau)
|
||||
);
|
||||
};
|
||||
|
||||
// Set initial bounds
|
||||
let A = a;
|
||||
let B: number;
|
||||
if (delta * delta > phi * phi + v) {
|
||||
B = Math.log(delta * delta - phi * phi - v);
|
||||
} else {
|
||||
let k = 1;
|
||||
while (f(a - k * tau) < 0) {
|
||||
k++;
|
||||
}
|
||||
B = a - k * tau;
|
||||
}
|
||||
|
||||
// Illinois algorithm
|
||||
let fA = f(A);
|
||||
let fB = f(B);
|
||||
|
||||
while (Math.abs(B - A) > epsilon) {
|
||||
const C = A + ((A - B) * fA) / (fB - fA);
|
||||
const fC = f(C);
|
||||
|
||||
if (fC * fB <= 0) {
|
||||
A = B;
|
||||
fA = fB;
|
||||
} else {
|
||||
fA = fA / 2;
|
||||
}
|
||||
|
||||
B = C;
|
||||
fB = fC;
|
||||
}
|
||||
|
||||
return Math.exp(A / 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate rating updates for a bot after a match
|
||||
* @param bot The bot whose rating to update
|
||||
* @param opponents Array of opponent ratings and game outcomes (1=win, 0.5=draw, 0=loss)
|
||||
* @returns New rating values
|
||||
*/
|
||||
export function updateRating(
|
||||
bot: Bot,
|
||||
opponents: Array<{
|
||||
rating: number;
|
||||
rd: number;
|
||||
score: number;
|
||||
}>
|
||||
): { rating: number; rd: number; volatility: number } {
|
||||
if (opponents.length === 0) {
|
||||
// No games played - increase RD over time (rating decay)
|
||||
const phi = bot.rating_deviation / SCALE;
|
||||
const newPhi = Math.min(Math.sqrt(phi * phi + bot.rating_volatility * bot.rating_volatility), 350 / SCALE);
|
||||
return {
|
||||
rating: bot.rating,
|
||||
rd: newPhi * SCALE,
|
||||
volatility: bot.rating_volatility,
|
||||
};
|
||||
}
|
||||
|
||||
// Convert to Glicko-2 scale
|
||||
const g2 = toGlicko2(bot.rating, bot.rating_deviation);
|
||||
g2.sigma = bot.rating_volatility;
|
||||
|
||||
// Step 3: Compute v (variance of game outcomes)
|
||||
let vInverse = 0;
|
||||
for (const opp of opponents) {
|
||||
const oppG2 = toGlicko2(opp.rating, opp.rd);
|
||||
const gPhi = g(oppG2.phi);
|
||||
const eValue = E(g2.mu, oppG2.mu, oppG2.phi);
|
||||
vInverse += gPhi * gPhi * eValue * (1 - eValue);
|
||||
}
|
||||
const v = 1 / vInverse;
|
||||
|
||||
// Step 4: Compute delta (rating improvement)
|
||||
let deltaSum = 0;
|
||||
for (const opp of opponents) {
|
||||
const oppG2 = toGlicko2(opp.rating, opp.rd);
|
||||
const gPhi = g(oppG2.phi);
|
||||
const eValue = E(g2.mu, oppG2.mu, oppG2.phi);
|
||||
deltaSum += gPhi * (opp.score - eValue);
|
||||
}
|
||||
const delta = v * deltaSum;
|
||||
|
||||
// Step 5: Compute new volatility
|
||||
const newSigma = computeNewVolatility(g2.sigma, g2.phi, v, delta);
|
||||
|
||||
// Step 6: Update phi
|
||||
const phiStar = Math.sqrt(g2.phi * g2.phi + newSigma * newSigma);
|
||||
|
||||
// Step 7: Update phi and mu
|
||||
const newPhi = 1 / Math.sqrt(1 / (phiStar * phiStar) + 1 / v);
|
||||
const newMu = g2.mu + newPhi * newPhi * deltaSum;
|
||||
|
||||
// Convert back
|
||||
const result = fromGlicko2({ mu: newMu, phi: newPhi, sigma: newSigma });
|
||||
|
||||
return {
|
||||
rating: result.rating,
|
||||
rd: result.rd,
|
||||
volatility: newSigma,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update ratings for all participants in a completed match
|
||||
*/
|
||||
export async function updateMatchRatings(
|
||||
env: Env,
|
||||
matchId: string,
|
||||
participants: MatchParticipant[],
|
||||
winnerId: string | null
|
||||
): Promise<void> {
|
||||
// Get all bots involved
|
||||
const botIds = participants.map((p) => p.bot_id);
|
||||
const placeholders = botIds.map(() => '?').join(',');
|
||||
|
||||
const bots = await env.DB.prepare(
|
||||
`SELECT * FROM bots WHERE id IN (${placeholders})`
|
||||
)
|
||||
.bind(...botIds)
|
||||
.all<Bot>();
|
||||
|
||||
if (!bots.results || bots.results.length !== participants.length) {
|
||||
throw new Error('Could not find all participant bots');
|
||||
}
|
||||
|
||||
const botMap = new Map(bots.results.map((b) => [b.id, b]));
|
||||
|
||||
// Calculate new ratings for each participant
|
||||
const updates: Array<{
|
||||
botId: string;
|
||||
rating: number;
|
||||
rd: number;
|
||||
volatility: number;
|
||||
won: boolean;
|
||||
}> = [];
|
||||
|
||||
for (const participant of participants) {
|
||||
const bot = botMap.get(participant.bot_id);
|
||||
if (!bot) continue;
|
||||
|
||||
// Build opponent list
|
||||
const opponents = participants
|
||||
.filter((p) => p.bot_id !== participant.bot_id)
|
||||
.map((opp) => {
|
||||
const oppBot = botMap.get(opp.bot_id)!;
|
||||
// Score: 1 for win, 0.5 for draw (if no winner), 0 for loss
|
||||
let score = 0.5;
|
||||
if (winnerId === participant.bot_id) {
|
||||
score = 1;
|
||||
} else if (winnerId === opp.bot_id) {
|
||||
score = 0;
|
||||
}
|
||||
return {
|
||||
rating: oppBot.rating,
|
||||
rd: oppBot.rating_deviation,
|
||||
score,
|
||||
};
|
||||
});
|
||||
|
||||
const newRating = updateRating(bot, opponents);
|
||||
const won = winnerId === participant.bot_id;
|
||||
|
||||
updates.push({
|
||||
botId: participant.bot_id,
|
||||
rating: newRating.rating,
|
||||
rd: newRating.rd,
|
||||
volatility: newRating.volatility,
|
||||
won,
|
||||
});
|
||||
}
|
||||
|
||||
// Apply updates in a batch
|
||||
const now = new Date().toISOString();
|
||||
|
||||
for (const update of updates) {
|
||||
// Update bot rating
|
||||
await env.DB.prepare(
|
||||
`UPDATE bots SET
|
||||
rating = ?,
|
||||
rating_deviation = ?,
|
||||
rating_volatility = ?,
|
||||
matches_played = matches_played + 1,
|
||||
matches_won = matches_won + ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?`
|
||||
)
|
||||
.bind(
|
||||
update.rating,
|
||||
update.rd,
|
||||
update.volatility,
|
||||
update.won ? 1 : 0,
|
||||
now,
|
||||
update.botId
|
||||
)
|
||||
.run();
|
||||
|
||||
// Update participant with rating change
|
||||
await env.DB.prepare(
|
||||
`UPDATE match_participants SET
|
||||
rating_after = ?,
|
||||
rating_deviation_after = ?
|
||||
WHERE match_id = ? AND bot_id = ?`
|
||||
)
|
||||
.bind(update.rating, update.rd, matchId, update.botId)
|
||||
.run();
|
||||
|
||||
// Record rating history
|
||||
await env.DB.prepare(
|
||||
`INSERT INTO rating_history (id, bot_id, match_id, rating_before, rating_after, rating_deviation, recorded_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.bind(
|
||||
crypto.randomUUID(),
|
||||
update.botId,
|
||||
matchId,
|
||||
botMap.get(update.botId)!.rating,
|
||||
update.rating,
|
||||
update.rd,
|
||||
now
|
||||
)
|
||||
.run();
|
||||
}
|
||||
}
|
||||
203
worker-api/src/index.ts
Normal file
203
worker-api/src/index.ts
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
// AI Code Battle Worker API
|
||||
// Phase 4: Match Orchestration
|
||||
|
||||
import type { Env, ApiResponse, ClaimJobRequest, SubmitResultRequest, CreateBotRequest } from './types';
|
||||
import { handleCron } from './cron';
|
||||
import {
|
||||
getNextJob,
|
||||
claimJob,
|
||||
heartbeatJob,
|
||||
submitResult,
|
||||
failJob,
|
||||
} from './jobs';
|
||||
import {
|
||||
registerBot,
|
||||
listBots,
|
||||
getBot,
|
||||
updateBot,
|
||||
rotateApiKey,
|
||||
getLeaderboard,
|
||||
} from './bots';
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const path = url.pathname;
|
||||
const method = request.method;
|
||||
|
||||
// CORS headers
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-API-Key',
|
||||
};
|
||||
|
||||
// Handle preflight
|
||||
if (method === 'OPTIONS') {
|
||||
return new Response(null, { headers: corsHeaders });
|
||||
}
|
||||
|
||||
// Helper for JSON responses
|
||||
const json = <T>(data: ApiResponse<T>, status = 200): Response => {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...corsHeaders,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Helper to verify API key
|
||||
const verifyApiKey = async (): Promise<boolean> => {
|
||||
const apiKey = request.headers.get('X-API-Key');
|
||||
if (!apiKey) return false;
|
||||
return apiKey === env.API_KEY;
|
||||
};
|
||||
|
||||
// Helper to parse JSON body
|
||||
const parseBody = async <T>(): Promise<T | null> => {
|
||||
try {
|
||||
return await request.json<T>();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
// Health check
|
||||
if (path === '/health' || path === '/api/health') {
|
||||
return json({ success: true, data: { status: 'healthy' } });
|
||||
}
|
||||
|
||||
// ============ Job Endpoints (require API key) ============
|
||||
|
||||
if (path === '/api/jobs/next' && method === 'GET') {
|
||||
if (!(await verifyApiKey())) {
|
||||
return json({ success: false, error: 'Unauthorized' }, 401);
|
||||
}
|
||||
const result = await getNextJob(env);
|
||||
return json(result);
|
||||
}
|
||||
|
||||
if (path.match(/^\/api\/jobs\/[^/]+\/claim$/) && method === 'POST') {
|
||||
if (!(await verifyApiKey())) {
|
||||
return json({ success: false, error: 'Unauthorized' }, 401);
|
||||
}
|
||||
const jobId = path.split('/')[3];
|
||||
const body = await parseBody<ClaimJobRequest>();
|
||||
if (!body?.worker_id) {
|
||||
return json({ success: false, error: 'Missing worker_id' }, 400);
|
||||
}
|
||||
const result = await claimJob(env, jobId, body.worker_id);
|
||||
return json(result, result.success ? 200 : 400);
|
||||
}
|
||||
|
||||
if (path.match(/^\/api\/jobs\/[^/]+\/heartbeat$/) && method === 'POST') {
|
||||
if (!(await verifyApiKey())) {
|
||||
return json({ success: false, error: 'Unauthorized' }, 401);
|
||||
}
|
||||
const jobId = path.split('/')[3];
|
||||
const body = await parseBody<{ worker_id: string }>();
|
||||
if (!body?.worker_id) {
|
||||
return json({ success: false, error: 'Missing worker_id' }, 400);
|
||||
}
|
||||
const result = await heartbeatJob(env, jobId, body.worker_id);
|
||||
return json(result, result.success ? 200 : 400);
|
||||
}
|
||||
|
||||
if (path.match(/^\/api\/jobs\/[^/]+\/result$/) && method === 'POST') {
|
||||
if (!(await verifyApiKey())) {
|
||||
return json({ success: false, error: 'Unauthorized' }, 401);
|
||||
}
|
||||
const jobId = path.split('/')[3];
|
||||
const body = await parseBody<SubmitResultRequest>();
|
||||
if (!body) {
|
||||
return json({ success: false, error: 'Invalid request body' }, 400);
|
||||
}
|
||||
const result = await submitResult(env, jobId, body);
|
||||
return json(result, result.success ? 200 : 400);
|
||||
}
|
||||
|
||||
if (path.match(/^\/api\/jobs\/[^/]+\/fail$/) && method === 'POST') {
|
||||
if (!(await verifyApiKey())) {
|
||||
return json({ success: false, error: 'Unauthorized' }, 401);
|
||||
}
|
||||
const jobId = path.split('/')[3];
|
||||
const body = await parseBody<{ worker_id: string; error_message: string }>();
|
||||
if (!body?.worker_id || !body?.error_message) {
|
||||
return json({ success: false, error: 'Missing required fields' }, 400);
|
||||
}
|
||||
const result = await failJob(env, jobId, body.worker_id, body.error_message);
|
||||
return json(result, result.success ? 200 : 400);
|
||||
}
|
||||
|
||||
// ============ Bot Endpoints (public or owner-verified) ============
|
||||
|
||||
if (path === '/api/register' && method === 'POST') {
|
||||
const body = await parseBody<CreateBotRequest>();
|
||||
if (!body) {
|
||||
return json({ success: false, error: 'Invalid request body' }, 400);
|
||||
}
|
||||
const result = await registerBot(env, body);
|
||||
return json(result, result.success ? 201 : 400);
|
||||
}
|
||||
|
||||
if (path === '/api/bots' && method === 'GET') {
|
||||
const result = await listBots(env);
|
||||
return json(result);
|
||||
}
|
||||
|
||||
if (path.match(/^\/api\/bots\/[^/]+$/) && method === 'GET') {
|
||||
const botId = path.split('/')[3];
|
||||
const result = await getBot(env, botId);
|
||||
return json(result, result.success ? 200 : 404);
|
||||
}
|
||||
|
||||
if (path.match(/^\/api\/bots\/[^/]+$/) && method === 'PUT') {
|
||||
const botId = path.split('/')[3];
|
||||
const body = await parseBody<{ name?: string; endpoint_url?: string }>();
|
||||
if (!body) {
|
||||
return json({ success: false, error: 'Invalid request body' }, 400);
|
||||
}
|
||||
const result = await updateBot(env, botId, body);
|
||||
return json(result, result.success ? 200 : 400);
|
||||
}
|
||||
|
||||
if (path === '/api/rotate-key' && method === 'POST') {
|
||||
const body = await parseBody<{ bot_id: string; owner_id: string }>();
|
||||
if (!body?.bot_id || !body?.owner_id) {
|
||||
return json({ success: false, error: 'Missing required fields' }, 400);
|
||||
}
|
||||
const result = await rotateApiKey(env, body.bot_id, body.owner_id);
|
||||
return json(result, result.success ? 200 : 400);
|
||||
}
|
||||
|
||||
if (path === '/api/leaderboard' && method === 'GET') {
|
||||
const result = await getLeaderboard(env);
|
||||
return json(result);
|
||||
}
|
||||
|
||||
// 404 for unmatched routes
|
||||
return json({ success: false, error: 'Not found' }, 404);
|
||||
} catch (error) {
|
||||
console.error('Worker error:', error);
|
||||
return json(
|
||||
{ success: false, error: 'Internal server error' },
|
||||
500
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext): Promise<void> {
|
||||
const cron = event.cron;
|
||||
console.log(`Running scheduled task: ${cron}`);
|
||||
|
||||
try {
|
||||
const result = await handleCron(env, cron);
|
||||
console.log(`Cron result:`, result);
|
||||
} catch (error) {
|
||||
console.error(`Cron error:`, error);
|
||||
}
|
||||
},
|
||||
};
|
||||
244
worker-api/src/jobs.ts
Normal file
244
worker-api/src/jobs.ts
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
// Job Coordination Endpoints
|
||||
|
||||
import type { Env, Job, Match, MatchParticipant, JobClaimResponse, ApiResponse, SubmitResultRequest } from './types';
|
||||
import { updateMatchRatings } from './glicko2';
|
||||
|
||||
/**
|
||||
* GET /api/jobs/next - Get next available job for worker
|
||||
*/
|
||||
export async function getNextJob(env: Env): Promise<ApiResponse<Job | null>> {
|
||||
// Find a pending job, ordered by creation time
|
||||
const result = await env.DB.prepare(
|
||||
`SELECT * FROM jobs
|
||||
WHERE status = 'pending'
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1`
|
||||
).first<Job>();
|
||||
|
||||
return { success: true, data: result || null };
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/jobs/:id/claim - Claim a job for execution
|
||||
*/
|
||||
export async function claimJob(
|
||||
env: Env,
|
||||
jobId: string,
|
||||
workerId: string
|
||||
): Promise<ApiResponse<JobClaimResponse>> {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Try to claim the job atomically
|
||||
const result = await env.DB.prepare(
|
||||
`UPDATE jobs SET
|
||||
status = 'claimed',
|
||||
worker_id = ?,
|
||||
claimed_at = ?,
|
||||
heartbeat_at = ?
|
||||
WHERE id = ? AND status = 'pending'`
|
||||
)
|
||||
.bind(workerId, now, now, jobId)
|
||||
.run();
|
||||
|
||||
if (result.meta.changes === 0) {
|
||||
return { success: false, error: 'Job not found or already claimed' };
|
||||
}
|
||||
|
||||
// Get the job details
|
||||
const job = await env.DB.prepare('SELECT * FROM jobs WHERE id = ?')
|
||||
.bind(jobId)
|
||||
.first<Job>();
|
||||
|
||||
if (!job) {
|
||||
return { success: false, error: 'Job not found' };
|
||||
}
|
||||
|
||||
// Get match details
|
||||
const match = await env.DB.prepare('SELECT * FROM matches WHERE id = ?')
|
||||
.bind(job.match_id)
|
||||
.first<Match>();
|
||||
|
||||
if (!match) {
|
||||
return { success: false, error: 'Match not found' };
|
||||
}
|
||||
|
||||
// Update match status to running
|
||||
await env.DB.prepare(
|
||||
`UPDATE matches SET status = 'running', started_at = ? WHERE id = ?`
|
||||
)
|
||||
.bind(now, match.id)
|
||||
.run();
|
||||
|
||||
// Get participants with their ratings
|
||||
const participants = await env.DB.prepare(
|
||||
`SELECT * FROM match_participants WHERE match_id = ?`
|
||||
)
|
||||
.bind(match.id)
|
||||
.all<MatchParticipant>();
|
||||
|
||||
// Get bot details (endpoint URLs)
|
||||
const botIds = participants.results.map((p) => p.bot_id);
|
||||
const placeholders = botIds.map(() => '?').join(',');
|
||||
const bots = await env.DB.prepare(
|
||||
`SELECT id, endpoint_url FROM bots WHERE id IN (${placeholders})`
|
||||
)
|
||||
.bind(...botIds)
|
||||
.all<{ id: string; endpoint_url: string }>();
|
||||
|
||||
// Get bot secrets (API keys for HMAC auth)
|
||||
const secrets = await env.DB.prepare(
|
||||
`SELECT bot_id, api_key_hash as secret FROM bot_secrets WHERE bot_id IN (${placeholders})`
|
||||
)
|
||||
.bind(...botIds)
|
||||
.all<{ bot_id: string; secret: string }>();
|
||||
|
||||
// Get map details
|
||||
const map = await env.DB.prepare('SELECT * FROM maps WHERE id = ?')
|
||||
.bind(match.map_id)
|
||||
.first<{ id: string; width: number; height: number; walls: string; spawns: string; cores: string }>();
|
||||
|
||||
if (!map) {
|
||||
return { success: false, error: 'Map not found' };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
job: job,
|
||||
match: match,
|
||||
participants: participants.results,
|
||||
map: map,
|
||||
bots: bots.results,
|
||||
bot_secrets: secrets.results,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/jobs/:id/heartbeat - Update job heartbeat
|
||||
*/
|
||||
export async function heartbeatJob(
|
||||
env: Env,
|
||||
jobId: string,
|
||||
workerId: string
|
||||
): Promise<ApiResponse<void>> {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const result = await env.DB.prepare(
|
||||
`UPDATE jobs SET heartbeat_at = ? WHERE id = ? AND worker_id = ?`
|
||||
)
|
||||
.bind(now, jobId, workerId)
|
||||
.run();
|
||||
|
||||
if (result.meta.changes === 0) {
|
||||
return { success: false, error: 'Job not found or not owned by worker' };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/jobs/:id/result - Submit job result
|
||||
*/
|
||||
export async function submitResult(
|
||||
env: Env,
|
||||
jobId: string,
|
||||
result: SubmitResultRequest
|
||||
): Promise<ApiResponse<void>> {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Get the job
|
||||
const job = await env.DB.prepare('SELECT * FROM jobs WHERE id = ?')
|
||||
.bind(jobId)
|
||||
.first<Job>();
|
||||
|
||||
if (!job) {
|
||||
return { success: false, error: 'Job not found' };
|
||||
}
|
||||
|
||||
if (job.status !== 'claimed' && job.status !== 'running') {
|
||||
return { success: false, error: 'Job not in a valid state for result submission' };
|
||||
}
|
||||
|
||||
// Get participants
|
||||
const participants = await env.DB.prepare(
|
||||
'SELECT * FROM match_participants WHERE match_id = ?'
|
||||
)
|
||||
.bind(job.match_id)
|
||||
.all<MatchParticipant>();
|
||||
|
||||
// Update scores
|
||||
for (const [botId, score] of Object.entries(result.scores)) {
|
||||
await env.DB.prepare(
|
||||
`UPDATE match_participants SET score = ? WHERE match_id = ? AND bot_id = ?`
|
||||
)
|
||||
.bind(score, job.match_id, botId)
|
||||
.run();
|
||||
}
|
||||
|
||||
// Update ratings using Glicko-2
|
||||
await updateMatchRatings(env, job.match_id, participants.results, result.winner_id);
|
||||
|
||||
// Update job status
|
||||
await env.DB.prepare(
|
||||
`UPDATE jobs SET status = 'completed', completed_at = ? WHERE id = ?`
|
||||
)
|
||||
.bind(now, jobId)
|
||||
.run();
|
||||
|
||||
// Update match status
|
||||
await env.DB.prepare(
|
||||
`UPDATE matches SET
|
||||
status = 'completed',
|
||||
winner_id = ?,
|
||||
turns = ?,
|
||||
end_reason = ?,
|
||||
completed_at = ?
|
||||
WHERE id = ?`
|
||||
)
|
||||
.bind(result.winner_id, result.turns, result.end_reason, now, job.match_id)
|
||||
.run();
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/jobs/:id/fail - Mark job as failed
|
||||
*/
|
||||
export async function failJob(
|
||||
env: Env,
|
||||
jobId: string,
|
||||
workerId: string,
|
||||
errorMessage: string
|
||||
): Promise<ApiResponse<void>> {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const result = await env.DB.prepare(
|
||||
`UPDATE jobs SET
|
||||
status = 'failed',
|
||||
completed_at = ?,
|
||||
error_message = ?
|
||||
WHERE id = ? AND worker_id = ?`
|
||||
)
|
||||
.bind(now, errorMessage, jobId, workerId)
|
||||
.run();
|
||||
|
||||
if (result.meta.changes === 0) {
|
||||
return { success: false, error: 'Job not found or not owned by worker' };
|
||||
}
|
||||
|
||||
// Also update match status
|
||||
const job = await env.DB.prepare('SELECT match_id FROM jobs WHERE id = ?')
|
||||
.bind(jobId)
|
||||
.first<{ match_id: string }>();
|
||||
|
||||
if (job) {
|
||||
await env.DB.prepare(
|
||||
`UPDATE matches SET status = 'failed', completed_at = ? WHERE id = ?`
|
||||
)
|
||||
.bind(now, job.match_id)
|
||||
.run();
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
122
worker-api/src/types.ts
Normal file
122
worker-api/src/types.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
// AI Code Battle Worker Types
|
||||
|
||||
export interface Env {
|
||||
DB: D1Database;
|
||||
API_KEY: string;
|
||||
ENVIRONMENT: string;
|
||||
}
|
||||
|
||||
// Bot types
|
||||
export interface Bot {
|
||||
id: string;
|
||||
name: string;
|
||||
owner_id: string;
|
||||
endpoint_url: string;
|
||||
api_key_hash: string;
|
||||
rating: number;
|
||||
rating_deviation: number;
|
||||
rating_volatility: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
last_health_check: string | null;
|
||||
health_status: 'healthy' | 'unhealthy' | 'unknown';
|
||||
matches_played: number;
|
||||
matches_won: number;
|
||||
}
|
||||
|
||||
export interface CreateBotRequest {
|
||||
name: string;
|
||||
owner_id: string;
|
||||
endpoint_url: string;
|
||||
}
|
||||
|
||||
// Match types
|
||||
export type MatchStatus = 'pending' | 'running' | 'completed' | 'failed';
|
||||
|
||||
export interface Match {
|
||||
id: string;
|
||||
status: MatchStatus;
|
||||
winner_id: string | null;
|
||||
turns: number | null;
|
||||
end_reason: string | null;
|
||||
map_id: string;
|
||||
created_at: string;
|
||||
started_at: string | null;
|
||||
completed_at: string | null;
|
||||
}
|
||||
|
||||
export interface MatchParticipant {
|
||||
id: string;
|
||||
match_id: string;
|
||||
bot_id: string;
|
||||
player_index: number;
|
||||
score: number;
|
||||
rating_before: number;
|
||||
rating_after: number | null;
|
||||
rating_deviation_before: number;
|
||||
rating_deviation_after: number | null;
|
||||
}
|
||||
|
||||
// Job types
|
||||
export type JobStatus = 'pending' | 'claimed' | 'running' | 'completed' | 'failed' | 'timeout';
|
||||
|
||||
export interface Job {
|
||||
id: string;
|
||||
match_id: string;
|
||||
status: JobStatus;
|
||||
worker_id: string | null;
|
||||
claimed_at: string | null;
|
||||
heartbeat_at: string | null;
|
||||
created_at: string;
|
||||
completed_at: string | null;
|
||||
error_message: string | null;
|
||||
}
|
||||
|
||||
export interface ClaimJobRequest {
|
||||
worker_id: string;
|
||||
}
|
||||
|
||||
export interface SubmitResultRequest {
|
||||
winner_id: string;
|
||||
turns: number;
|
||||
end_reason: string;
|
||||
replay_url: string;
|
||||
scores: Record<string, number>;
|
||||
}
|
||||
|
||||
// Rating types
|
||||
export interface RatingChange {
|
||||
bot_id: string;
|
||||
rating_before: number;
|
||||
rating_after: number;
|
||||
rating_deviation: number;
|
||||
}
|
||||
|
||||
// API Response types
|
||||
export interface ApiResponse<T = unknown> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface JobClaimResponse {
|
||||
job: Job;
|
||||
match: Match;
|
||||
participants: MatchParticipant[];
|
||||
map: {
|
||||
id: string;
|
||||
width: number;
|
||||
height: number;
|
||||
walls: string;
|
||||
spawns: string;
|
||||
cores: string;
|
||||
};
|
||||
bots: Array<{
|
||||
id: string;
|
||||
endpoint_url: string;
|
||||
}>;
|
||||
bot_secrets: Array<{
|
||||
bot_id: string;
|
||||
secret: string;
|
||||
}>;
|
||||
}
|
||||
20
worker-api/tsconfig.json
Normal file
20
worker-api/tsconfig.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["@cloudflare/workers-types"],
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
9
worker-api/vitest.config.ts
Normal file
9
worker-api/vitest.config.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['src/**/*.test.ts'],
|
||||
globals: true,
|
||||
},
|
||||
});
|
||||
23
worker-api/wrangler.toml
Normal file
23
worker-api/wrangler.toml
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
name = "acb-api"
|
||||
main = "src/index.ts"
|
||||
compatibility_date = "2025-03-10"
|
||||
compatibility_flags = ["nodejs_compat"]
|
||||
|
||||
[[d1_databases]]
|
||||
binding = "DB"
|
||||
database_name = "acb-db"
|
||||
database_id = "placeholder-will-be-set-on-deploy"
|
||||
|
||||
[vars]
|
||||
ENVIRONMENT = "development"
|
||||
|
||||
[triggers]
|
||||
crons = [
|
||||
"*/1 * * * *", # Matchmaker: every minute
|
||||
"*/5 * * * *", # Stale job reaper: every 5 minutes
|
||||
"*/15 * * * *" # Health checker: every 15 minutes
|
||||
]
|
||||
|
||||
# API key for worker authentication (set via wrangler secret put API_KEY)
|
||||
# [secrets]
|
||||
# API_KEY
|
||||
Loading…
Add table
Reference in a new issue