import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; import type { Sql } from '../db.js'; interface InferenceApi { cancel: (sessionId: string, chatId: string) => Promise; } const CreateBody = z.object({ project_id: z.string().uuid(), input: z.string().min(1).max(64_000), agent: z.string().max(100).optional(), model: z.string().max(200).optional(), }); const ListQuery = z.object({ state: z.enum(['pending', 'running', 'completed', 'failed', 'blocked', 'cancelled']).optional(), project_id: z.string().uuid().optional(), }); export function registerTaskRoutes(app: FastifyInstance, sql: Sql, inference: InferenceApi): void { // POST /api/tasks — create a new task app.post('/api/tasks', async (req, reply) => { const parsed = CreateBody.safeParse(req.body); if (!parsed.success) { reply.code(400); return { error: 'invalid body', details: parsed.error.flatten() }; } const { project_id, input, agent, model } = parsed.data; const [task] = await sql<{ id: string; state: string }[]>` INSERT INTO tasks (project_id, input, agent, model) VALUES (${project_id}, ${input}, ${agent ?? null}, ${model ?? null}) RETURNING id, state `; reply.code(201); return { id: task!.id, state: task!.state }; }); // GET /api/tasks — list tasks with optional filters app.get('/api/tasks', async (req, _reply) => { const parsed = ListQuery.safeParse(req.query); if (!parsed.success) { return { error: 'invalid query', details: parsed.error.flatten() }; } const { state, project_id } = parsed.data; // Build query with optional filters if (state && project_id) { return sql` SELECT id, project_id, state, input, output_summary, agent, model, execution_path, session_id, started_at, ended_at, created_at FROM tasks WHERE state = ${state} AND project_id = ${project_id} ORDER BY created_at DESC LIMIT 100 `; } else if (state) { return sql` SELECT id, project_id, state, input, output_summary, agent, model, execution_path, session_id, started_at, ended_at, created_at FROM tasks WHERE state = ${state} ORDER BY created_at DESC LIMIT 100 `; } else if (project_id) { return sql` SELECT id, project_id, state, input, output_summary, agent, model, execution_path, session_id, started_at, ended_at, created_at FROM tasks WHERE project_id = ${project_id} ORDER BY created_at DESC LIMIT 100 `; } else { return sql` SELECT id, project_id, state, input, output_summary, agent, model, execution_path, session_id, started_at, ended_at, created_at FROM tasks ORDER BY created_at DESC LIMIT 100 `; } }); // GET /api/tasks/:id — single task detail app.get<{ Params: { id: string } }>('/api/tasks/:id', async (req, reply) => { const rows = await sql` SELECT id, project_id, parent_task_id, state, input, output_summary, agent, model, execution_path, worktree_path, session_id, cost_tokens, started_at, ended_at, created_at FROM tasks WHERE id = ${req.params.id} `; if (rows.length === 0) { reply.code(404); return { error: 'task not found' }; } return rows[0]; }); // POST /api/tasks/:id/cancel — cancel a pending or running task app.post<{ Params: { id: string } }>('/api/tasks/:id/cancel', async (req, reply) => { const taskId = req.params.id; // Get current task state + session info const rows = await sql<{ id: string; state: string; session_id: string | null }[]>` SELECT id, state, session_id FROM tasks WHERE id = ${taskId} `; if (rows.length === 0) { reply.code(404); return { error: 'task not found' }; } const task = rows[0]!; if (task.state !== 'pending' && task.state !== 'running') { reply.code(409); return { error: `cannot cancel task in state '${task.state}'` }; } // If running, try to cancel inference if (task.state === 'running' && task.session_id) { // Find active chat in the task's session const chats = await sql<{ id: string }[]>` SELECT id FROM chats WHERE session_id = ${task.session_id} AND status = 'open' `; for (const chat of chats) { await inference.cancel(task.session_id, chat.id); } } await sql` UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId} AND state IN ('pending', 'running') `; return { cancelled: true }; }); }