/** * v2.0.5: Arena routes — competitive dispatch of the same task to multiple agents. * * POST /api/arena — create an arena with 2-5 contestants * GET /api/arena/:id — get all tasks in an arena * POST /api/arena/:id/select/:task_id — mark a task as the arena winner */ import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; import type { Sql } from '../db.js'; const ContestantSchema = z.object({ agent: z.string().max(100).optional(), model: z.string().max(200).optional(), mode_id: z.string().max(200).optional(), thinking_option_id: z.string().max(200).optional(), }); const CreateArenaBody = z.object({ project_id: z.string().uuid(), input: z.string().min(1).max(64_000), contestants: z.array(ContestantSchema).min(2).max(5), }); interface TaskRow { id: string; agent: string | null; model: string | null; mode_id: string | null; thinking_option_id: string | null; state: string; } export function registerArenaRoutes(app: FastifyInstance, sql: Sql): void { // POST /api/arena — create a new arena app.post('/api/arena', async (req, reply) => { const parsed = CreateArenaBody.safeParse(req.body); if (!parsed.success) { reply.code(400); return { error: 'invalid body', details: parsed.error.flatten() }; } const { project_id, input, contestants } = parsed.data; const arenaId = crypto.randomUUID(); const tasks: TaskRow[] = []; for (const contestant of contestants) { const [task] = await sql` INSERT INTO tasks (project_id, input, agent, model, mode_id, thinking_option_id, arena_id) VALUES ( ${project_id}, ${input}, ${contestant.agent ?? null}, ${contestant.model ?? null}, ${contestant.mode_id ?? null}, ${contestant.thinking_option_id ?? null}, ${arenaId} ) RETURNING id, agent, model, mode_id, thinking_option_id, state `; tasks.push(task!); } reply.code(201); return { arena_id: arenaId, tasks: tasks.map((t) => ({ id: t.id, agent: t.agent, model: t.model, mode_id: t.mode_id, thinking_option_id: t.thinking_option_id, state: t.state, })), }; }); // GET /api/arena/:arena_id — list all tasks in an arena app.get<{ Params: { arena_id: string } }>('/api/arena/:arena_id', async (req, reply) => { const { arena_id } = req.params; // Validate UUID format const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; if (!uuidRegex.test(arena_id)) { reply.code(400); return { error: 'invalid arena_id format' }; } const tasks = await sql` SELECT id, project_id, state, input, output_summary, agent, model, mode_id, thinking_option_id, execution_path, session_id, started_at, ended_at, created_at, arena_id FROM tasks WHERE arena_id = ${arena_id} ORDER BY created_at `; if (tasks.length === 0) { reply.code(404); return { error: 'arena not found' }; } return { arena_id, tasks }; }); // POST /api/arena/:arena_id/select/:task_id — mark the winner app.post<{ Params: { arena_id: string; task_id: string } }>( '/api/arena/:arena_id/select/:task_id', async (req, reply) => { const { arena_id, task_id } = req.params; // Verify the task belongs to this arena const rows = await sql<{ id: string; state: string; arena_id: string | null }[]>` SELECT id, state, arena_id FROM tasks WHERE id = ${task_id} `; if (rows.length === 0) { reply.code(404); return { error: 'task not found' }; } const task = rows[0]!; if (task.arena_id !== arena_id) { reply.code(409); return { error: 'task does not belong to this arena' }; } // Mark as selected via output_summary prefix (lightweight — no schema change) await sql` UPDATE tasks SET output_summary = COALESCE('[SELECTED] ' || output_summary, '[SELECTED]') WHERE id = ${task_id} `; return { selected: true, task_id, arena_id }; } ); }