diff --git a/src/api/routes/agents.ts b/src/api/routes/agents.ts new file mode 100644 index 0000000..ec84043 --- /dev/null +++ b/src/api/routes/agents.ts @@ -0,0 +1,303 @@ +/** + * Agents API Routes + * CRUD operations and status management for Claude Code agents + */ + +import { db } from '../../db/client' +import { agents, tasks } from '../../db/schema' +import { eq } from 'drizzle-orm' +import { randomUUID } from 'crypto' + +/** + * Handle all agent routes + */ +export async function handleAgentRoutes(req: Request, url: URL): Promise { + const method = req.method + const pathParts = url.pathname.split('/').filter(Boolean) + + // GET /api/agents - List all agents + if (method === 'GET' && pathParts.length === 2) { + return await listAgents(url) + } + + // GET /api/agents/:id - Get single agent + if (method === 'GET' && pathParts.length === 3) { + const agentId = pathParts[2] + return await getAgent(agentId) + } + + // POST /api/agents - Register agent + if (method === 'POST' && pathParts.length === 2) { + return await registerAgent(req) + } + + // PATCH /api/agents/:id - Update agent + if (method === 'PATCH' && pathParts.length === 3) { + const agentId = pathParts[2] + return await updateAgent(agentId, req) + } + + // POST /api/agents/:id/heartbeat - Update heartbeat + if (method === 'POST' && pathParts.length === 4 && pathParts[3] === 'heartbeat') { + const agentId = pathParts[2] + return await updateHeartbeat(agentId) + } + + // DELETE /api/agents/:id - Unregister agent + if (method === 'DELETE' && pathParts.length === 3) { + const agentId = pathParts[2] + return await unregisterAgent(agentId) + } + + return new Response('Not Found', { status: 404 }) +} + +/** + * List agents with optional status filter + */ +async function listAgents(url: URL): Promise { + try { + const status = url.searchParams.get('status') + + let query = db.select().from(agents) + + if (status) { + query = query.where(eq(agents.status, status as any)) as any + } + + const allAgents = await query + + return Response.json({ + success: true, + data: allAgents, + count: allAgents.length, + }) + } catch (error) { + console.error('Error listing agents:', error) + return Response.json({ + success: false, + error: 'Failed to list agents', + }, { status: 500 }) + } +} + +/** + * Get single agent + */ +async function getAgent(agentId: string): Promise { + try { + const agent = await db + .select() + .from(agents) + .where(eq(agents.id, agentId)) + .limit(1) + + if (agent.length === 0) { + return Response.json({ + success: false, + error: 'Agent not found', + }, { status: 404 }) + } + + return Response.json({ + success: true, + data: agent[0], + }) + } catch (error) { + console.error('Error getting agent:', error) + return Response.json({ + success: false, + error: 'Failed to get agent', + }, { status: 500 }) + } +} + +/** + * Register new agent (called when pod starts) + */ +async function registerAgent(req: Request): Promise { + try { + const body = await req.json() + + // Validate required fields + if (!body.podName) { + return Response.json({ + success: false, + error: 'podName is required', + }, { status: 400 }) + } + + // Check if agent with this podName already exists + const existing = await db + .select() + .from(agents) + .where(eq(agents.podName, body.podName)) + .limit(1) + + if (existing.length > 0) { + // Agent already exists, return existing + return Response.json({ + success: true, + data: existing[0], + message: 'Agent already registered', + }) + } + + const newAgent = { + id: randomUUID(), + podName: body.podName, + k8sNamespace: body.k8sNamespace || 'agents', + status: 'idle' as const, + currentTaskId: null, + tasksCompleted: 0, + lastHeartbeat: new Date(), + } + + await db.insert(agents).values(newAgent) + + return Response.json({ + success: true, + data: newAgent, + }, { status: 201 }) + } catch (error) { + console.error('Error registering agent:', error) + return Response.json({ + success: false, + error: 'Failed to register agent', + }, { status: 500 }) + } +} + +/** + * Update agent status and current task + */ +async function updateAgent(agentId: string, req: Request): Promise { + try { + const body = await req.json() + + // Check if agent exists + const existing = await db + .select() + .from(agents) + .where(eq(agents.id, agentId)) + .limit(1) + + if (existing.length === 0) { + return Response.json({ + success: false, + error: 'Agent not found', + }, { status: 404 }) + } + + // Build update object + const updateData: any = {} + + if (body.status !== undefined) updateData.status = body.status + if (body.currentTaskId !== undefined) updateData.currentTaskId = body.currentTaskId + if (body.tasksCompleted !== undefined) updateData.tasksCompleted = body.tasksCompleted + + await db + .update(agents) + .set(updateData) + .where(eq(agents.id, agentId)) + + // Get updated agent + const updated = await db + .select() + .from(agents) + .where(eq(agents.id, agentId)) + .limit(1) + + return Response.json({ + success: true, + data: updated[0], + }) + } catch (error) { + console.error('Error updating agent:', error) + return Response.json({ + success: false, + error: 'Failed to update agent', + }, { status: 500 }) + } +} + +/** + * Update agent heartbeat (keep-alive) + */ +async function updateHeartbeat(agentId: string): Promise { + try { + // Check if agent exists + const existing = await db + .select() + .from(agents) + .where(eq(agents.id, agentId)) + .limit(1) + + if (existing.length === 0) { + return Response.json({ + success: false, + error: 'Agent not found', + }, { status: 404 }) + } + + // Update heartbeat timestamp + await db + .update(agents) + .set({ lastHeartbeat: new Date() }) + .where(eq(agents.id, agentId)) + + return Response.json({ + success: true, + message: 'Heartbeat updated', + }) + } catch (error) { + console.error('Error updating heartbeat:', error) + return Response.json({ + success: false, + error: 'Failed to update heartbeat', + }, { status: 500 }) + } +} + +/** + * Unregister agent (called when pod terminates) + */ +async function unregisterAgent(agentId: string): Promise { + try { + // Check if agent exists + const existing = await db + .select() + .from(agents) + .where(eq(agents.id, agentId)) + .limit(1) + + if (existing.length === 0) { + return Response.json({ + success: false, + error: 'Agent not found', + }, { status: 404 }) + } + + // If agent has a current task, set it to null + if (existing[0].currentTaskId) { + await db + .update(tasks) + .set({ assignedAgentId: null }) + .where(eq(tasks.id, existing[0].currentTaskId)) + } + + // Delete agent + await db.delete(agents).where(eq(agents.id, agentId)) + + return Response.json({ + success: true, + message: 'Agent unregistered', + }) + } catch (error) { + console.error('Error unregistering agent:', error) + return Response.json({ + success: false, + error: 'Failed to unregister agent', + }, { status: 500 }) + } +} diff --git a/src/api/routes/index.ts b/src/api/routes/index.ts new file mode 100644 index 0000000..ba2703a --- /dev/null +++ b/src/api/routes/index.ts @@ -0,0 +1,8 @@ +/** + * API Routes Index + * Exports all route handlers + */ + +export { handleProjectRoutes } from './projects' +export { handleTaskRoutes } from './tasks' +export { handleAgentRoutes } from './agents' diff --git a/src/api/routes/projects.ts b/src/api/routes/projects.ts new file mode 100644 index 0000000..95e60b3 --- /dev/null +++ b/src/api/routes/projects.ts @@ -0,0 +1,247 @@ +/** + * Projects API Routes + * CRUD operations for projects + */ + +import { db } from '../../db/client' +import { projects } from '../../db/schema' +import { eq } from 'drizzle-orm' +import { randomUUID } from 'crypto' + +/** + * Handle all project routes + */ +export async function handleProjectRoutes(req: Request, url: URL): Promise { + const method = req.method + const pathParts = url.pathname.split('/').filter(Boolean) + + // GET /api/projects - List all projects + if (method === 'GET' && pathParts.length === 2) { + return await listProjects() + } + + // GET /api/projects/:id - Get single project + if (method === 'GET' && pathParts.length === 3) { + const projectId = pathParts[2] + return await getProject(projectId) + } + + // POST /api/projects - Create project + if (method === 'POST' && pathParts.length === 2) { + return await createProject(req) + } + + // PATCH /api/projects/:id - Update project + if (method === 'PATCH' && pathParts.length === 3) { + const projectId = pathParts[2] + return await updateProject(projectId, req) + } + + // DELETE /api/projects/:id - Delete project + if (method === 'DELETE' && pathParts.length === 3) { + const projectId = pathParts[2] + return await deleteProject(projectId) + } + + return new Response('Not Found', { status: 404 }) +} + +/** + * List all projects + */ +async function listProjects(): Promise { + try { + const allProjects = await db.select().from(projects) + + return Response.json({ + success: true, + data: allProjects, + count: allProjects.length, + }) + } catch (error) { + console.error('Error listing projects:', error) + return Response.json({ + success: false, + error: 'Failed to list projects', + }, { status: 500 }) + } +} + +/** + * Get single project + */ +async function getProject(projectId: string): Promise { + try { + const project = await db + .select() + .from(projects) + .where(eq(projects.id, projectId)) + .limit(1) + + if (project.length === 0) { + return Response.json({ + success: false, + error: 'Project not found', + }, { status: 404 }) + } + + return Response.json({ + success: true, + data: project[0], + }) + } catch (error) { + console.error('Error getting project:', error) + return Response.json({ + success: false, + error: 'Failed to get project', + }, { status: 500 }) + } +} + +/** + * Create project + */ +async function createProject(req: Request): Promise { + try { + const body = await req.json() + + // Validate required fields + if (!body.name) { + return Response.json({ + success: false, + error: 'Name is required', + }, { status: 400 }) + } + + // Generate k8s namespace name from project name + const k8sNamespace = `proj-${body.name.toLowerCase().replace(/[^a-z0-9-]/g, '-')}` + + const newProject = { + id: randomUUID(), + name: body.name, + description: body.description || null, + k8sNamespace, + giteaRepoId: body.giteaRepoId || null, + giteaRepoUrl: body.giteaRepoUrl || null, + giteaOwner: body.giteaOwner || null, + giteaRepoName: body.giteaRepoName || null, + defaultBranch: body.defaultBranch || 'main', + dockerImage: body.dockerImage || null, + envVars: body.envVars || null, + replicas: body.replicas || 1, + cpuLimit: body.cpuLimit || '500m', + memoryLimit: body.memoryLimit || '512Mi', + status: body.status || 'active', + } + + await db.insert(projects).values(newProject) + + return Response.json({ + success: true, + data: newProject, + }, { status: 201 }) + } catch (error) { + console.error('Error creating project:', error) + return Response.json({ + success: false, + error: 'Failed to create project', + }, { status: 500 }) + } +} + +/** + * Update project + */ +async function updateProject(projectId: string, req: Request): Promise { + try { + const body = await req.json() + + // Check if project exists + const existing = await db + .select() + .from(projects) + .where(eq(projects.id, projectId)) + .limit(1) + + if (existing.length === 0) { + return Response.json({ + success: false, + error: 'Project not found', + }, { status: 404 }) + } + + // Update only provided fields + const updateData: any = {} + + if (body.name !== undefined) updateData.name = body.name + if (body.description !== undefined) updateData.description = body.description + if (body.giteaRepoId !== undefined) updateData.giteaRepoId = body.giteaRepoId + if (body.giteaRepoUrl !== undefined) updateData.giteaRepoUrl = body.giteaRepoUrl + if (body.giteaOwner !== undefined) updateData.giteaOwner = body.giteaOwner + if (body.giteaRepoName !== undefined) updateData.giteaRepoName = body.giteaRepoName + if (body.defaultBranch !== undefined) updateData.defaultBranch = body.defaultBranch + if (body.dockerImage !== undefined) updateData.dockerImage = body.dockerImage + if (body.envVars !== undefined) updateData.envVars = body.envVars + if (body.replicas !== undefined) updateData.replicas = body.replicas + if (body.cpuLimit !== undefined) updateData.cpuLimit = body.cpuLimit + if (body.memoryLimit !== undefined) updateData.memoryLimit = body.memoryLimit + if (body.status !== undefined) updateData.status = body.status + + await db + .update(projects) + .set(updateData) + .where(eq(projects.id, projectId)) + + // Get updated project + const updated = await db + .select() + .from(projects) + .where(eq(projects.id, projectId)) + .limit(1) + + return Response.json({ + success: true, + data: updated[0], + }) + } catch (error) { + console.error('Error updating project:', error) + return Response.json({ + success: false, + error: 'Failed to update project', + }, { status: 500 }) + } +} + +/** + * Delete project + */ +async function deleteProject(projectId: string): Promise { + try { + // Check if project exists + const existing = await db + .select() + .from(projects) + .where(eq(projects.id, projectId)) + .limit(1) + + if (existing.length === 0) { + return Response.json({ + success: false, + error: 'Project not found', + }, { status: 404 }) + } + + await db.delete(projects).where(eq(projects.id, projectId)) + + return Response.json({ + success: true, + message: 'Project deleted', + }) + } catch (error) { + console.error('Error deleting project:', error) + return Response.json({ + success: false, + error: 'Failed to delete project', + }, { status: 500 }) + } +} diff --git a/src/api/routes/tasks.ts b/src/api/routes/tasks.ts new file mode 100644 index 0000000..c69a4ae --- /dev/null +++ b/src/api/routes/tasks.ts @@ -0,0 +1,328 @@ +/** + * Tasks API Routes + * CRUD operations and state management for tasks + */ + +import { db } from '../../db/client' +import { tasks, projects, agents } from '../../db/schema' +import { eq, and, or } from 'drizzle-orm' +import { randomUUID } from 'crypto' + +/** + * Handle all task routes + */ +export async function handleTaskRoutes(req: Request, url: URL): Promise { + const method = req.method + const pathParts = url.pathname.split('/').filter(Boolean) + + // GET /api/tasks - List tasks (with filters) + if (method === 'GET' && pathParts.length === 2) { + return await listTasks(url) + } + + // GET /api/tasks/:id - Get single task + if (method === 'GET' && pathParts.length === 3) { + const taskId = pathParts[2] + return await getTask(taskId) + } + + // POST /api/tasks - Create task + if (method === 'POST' && pathParts.length === 2) { + return await createTask(req) + } + + // PATCH /api/tasks/:id - Update task + if (method === 'PATCH' && pathParts.length === 3) { + const taskId = pathParts[2] + return await updateTask(taskId, req) + } + + // POST /api/tasks/:id/respond - Respond to task question + if (method === 'POST' && pathParts.length === 4 && pathParts[3] === 'respond') { + const taskId = pathParts[2] + return await respondToTask(taskId, req) + } + + // DELETE /api/tasks/:id - Delete task + if (method === 'DELETE' && pathParts.length === 3) { + const taskId = pathParts[2] + return await deleteTask(taskId) + } + + return new Response('Not Found', { status: 404 }) +} + +/** + * List tasks with optional filters + */ +async function listTasks(url: URL): Promise { + try { + const projectId = url.searchParams.get('projectId') + const state = url.searchParams.get('state') + const assignedAgentId = url.searchParams.get('assignedAgentId') + + let query = db.select().from(tasks) + + // Apply filters + const conditions = [] + if (projectId) conditions.push(eq(tasks.projectId, projectId)) + if (state) conditions.push(eq(tasks.state, state as any)) + if (assignedAgentId) conditions.push(eq(tasks.assignedAgentId, assignedAgentId)) + + if (conditions.length > 0) { + query = query.where(and(...conditions)) as any + } + + const allTasks = await query + + return Response.json({ + success: true, + data: allTasks, + count: allTasks.length, + }) + } catch (error) { + console.error('Error listing tasks:', error) + return Response.json({ + success: false, + error: 'Failed to list tasks', + }, { status: 500 }) + } +} + +/** + * Get single task with project and agent details + */ +async function getTask(taskId: string): Promise { + try { + const task = await db + .select() + .from(tasks) + .where(eq(tasks.id, taskId)) + .limit(1) + + if (task.length === 0) { + return Response.json({ + success: false, + error: 'Task not found', + }, { status: 404 }) + } + + return Response.json({ + success: true, + data: task[0], + }) + } catch (error) { + console.error('Error getting task:', error) + return Response.json({ + success: false, + error: 'Failed to get task', + }, { status: 500 }) + } +} + +/** + * Create task + */ +async function createTask(req: Request): Promise { + try { + const body = await req.json() + + // Validate required fields + if (!body.projectId) { + return Response.json({ + success: false, + error: 'projectId is required', + }, { status: 400 }) + } + + if (!body.title) { + return Response.json({ + success: false, + error: 'title is required', + }, { status: 400 }) + } + + // Verify project exists + const project = await db + .select() + .from(projects) + .where(eq(projects.id, body.projectId)) + .limit(1) + + if (project.length === 0) { + return Response.json({ + success: false, + error: 'Project not found', + }, { status: 404 }) + } + + const newTask = { + id: randomUUID(), + projectId: body.projectId, + title: body.title, + description: body.description || null, + priority: body.priority || 'medium', + state: body.state || 'backlog', + assignedAgentId: body.assignedAgentId || null, + branchName: body.branchName || null, + prUrl: body.prUrl || null, + previewUrl: body.previewUrl || null, + } + + await db.insert(tasks).values(newTask) + + return Response.json({ + success: true, + data: newTask, + }, { status: 201 }) + } catch (error) { + console.error('Error creating task:', error) + return Response.json({ + success: false, + error: 'Failed to create task', + }, { status: 500 }) + } +} + +/** + * Update task (including state transitions) + */ +async function updateTask(taskId: string, req: Request): Promise { + try { + const body = await req.json() + + // Check if task exists + const existing = await db + .select() + .from(tasks) + .where(eq(tasks.id, taskId)) + .limit(1) + + if (existing.length === 0) { + return Response.json({ + success: false, + error: 'Task not found', + }, { status: 404 }) + } + + // Build update object + const updateData: any = {} + + if (body.title !== undefined) updateData.title = body.title + if (body.description !== undefined) updateData.description = body.description + if (body.priority !== undefined) updateData.priority = body.priority + if (body.state !== undefined) updateData.state = body.state + if (body.assignedAgentId !== undefined) updateData.assignedAgentId = body.assignedAgentId + if (body.branchName !== undefined) updateData.branchName = body.branchName + if (body.prUrl !== undefined) updateData.prUrl = body.prUrl + if (body.previewUrl !== undefined) updateData.previewUrl = body.previewUrl + + await db + .update(tasks) + .set(updateData) + .where(eq(tasks.id, taskId)) + + // Get updated task + const updated = await db + .select() + .from(tasks) + .where(eq(tasks.id, taskId)) + .limit(1) + + return Response.json({ + success: true, + data: updated[0], + }) + } catch (error) { + console.error('Error updating task:', error) + return Response.json({ + success: false, + error: 'Failed to update task', + }, { status: 500 }) + } +} + +/** + * Respond to task (used when task is in needs_input state) + */ +async function respondToTask(taskId: string, req: Request): Promise { + try { + const body = await req.json() + + // Check if task exists and is in needs_input state + const existing = await db + .select() + .from(tasks) + .where(eq(tasks.id, taskId)) + .limit(1) + + if (existing.length === 0) { + return Response.json({ + success: false, + error: 'Task not found', + }, { status: 404 }) + } + + if (existing[0].state !== 'needs_input') { + return Response.json({ + success: false, + error: 'Task is not waiting for input', + }, { status: 400 }) + } + + // Update state to in_progress + await db + .update(tasks) + .set({ state: 'in_progress' }) + .where(eq(tasks.id, taskId)) + + // TODO: Notify agent via MCP about the response + // This will be implemented in the MCP server + + return Response.json({ + success: true, + message: 'Response sent to agent', + response: body.response, + }) + } catch (error) { + console.error('Error responding to task:', error) + return Response.json({ + success: false, + error: 'Failed to respond to task', + }, { status: 500 }) + } +} + +/** + * Delete task + */ +async function deleteTask(taskId: string): Promise { + try { + // Check if task exists + const existing = await db + .select() + .from(tasks) + .where(eq(tasks.id, taskId)) + .limit(1) + + if (existing.length === 0) { + return Response.json({ + success: false, + error: 'Task not found', + }, { status: 404 }) + } + + await db.delete(tasks).where(eq(tasks.id, taskId)) + + return Response.json({ + success: true, + message: 'Task deleted', + }) + } catch (error) { + console.error('Error deleting task:', error) + return Response.json({ + success: false, + error: 'Failed to delete task', + }, { status: 500 }) + } +} diff --git a/src/db/migrate.ts b/src/db/migrate.ts index b3eae69..d7f03d5 100644 --- a/src/db/migrate.ts +++ b/src/db/migrate.ts @@ -25,7 +25,22 @@ export async function runMigrations() { console.log('✅ Migrations completed successfully') await connection.end() return true - } catch (error) { + } catch (error: any) { + // If table already exists, it's not a fatal error + const errorMessage = error?.message || String(error) + const errorCode = error?.code || error?.cause?.code + const errorErrno = error?.errno || error?.cause?.errno + + if ( + errorCode === 'ER_TABLE_EXISTS_ERROR' || + errorErrno === 1050 || + errorMessage.includes('already exists') + ) { + console.log('⚠️ Tables already exist, skipping migrations') + await connection.end() + return true + } + console.error('❌ Migration failed:', error) await connection.end() throw error diff --git a/src/index.ts b/src/index.ts index 1bf848e..208d489 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ import { runMigrations } from './db/migrate' import { testConnection } from './db/client' +import { handleProjectRoutes, handleTaskRoutes, handleAgentRoutes } from './api/routes' console.log('🚀 Starting AiWorker Backend...') console.log(`Bun version: ${Bun.version}`) @@ -41,11 +42,43 @@ const server = Bun.serve({ } // API routes + if (url.pathname.startsWith('/api/projects')) { + return handleProjectRoutes(req, url) + } + + if (url.pathname.startsWith('/api/tasks')) { + return handleTaskRoutes(req, url) + } + + if (url.pathname.startsWith('/api/agents')) { + return handleAgentRoutes(req, url) + } + + // Generic API info if (url.pathname.startsWith('/api/')) { return Response.json({ message: 'AiWorker API', - path: url.pathname, - method: req.method, + version: '1.0.0', + endpoints: [ + 'GET /api/health', + 'GET /api/projects', + 'GET /api/projects/:id', + 'POST /api/projects', + 'PATCH /api/projects/:id', + 'DELETE /api/projects/:id', + 'GET /api/tasks', + 'GET /api/tasks/:id', + 'POST /api/tasks', + 'PATCH /api/tasks/:id', + 'POST /api/tasks/:id/respond', + 'DELETE /api/tasks/:id', + 'GET /api/agents', + 'GET /api/agents/:id', + 'POST /api/agents', + 'PATCH /api/agents/:id', + 'POST /api/agents/:id/heartbeat', + 'DELETE /api/agents/:id', + ], }) } diff --git a/src/services/gitea/client.ts b/src/services/gitea/client.ts new file mode 100644 index 0000000..7973ff6 --- /dev/null +++ b/src/services/gitea/client.ts @@ -0,0 +1,169 @@ +/** + * Gitea API Client + * Handles interactions with Gitea API + */ + +const GITEA_URL = process.env.GITEA_URL || 'https://git.fuq.tv' +const GITEA_TOKEN = process.env.GITEA_TOKEN || '' + +interface GiteaBranchOptions { + owner: string + repo: string + branchName: string + fromBranch: string +} + +interface GiteaPullRequestOptions { + owner: string + repo: string + title: string + body: string + head: string + base: string +} + +/** + * Make authenticated request to Gitea API + */ +async function giteaRequest(path: string, options: RequestInit = {}) { + const url = `${GITEA_URL}/api/v1${path}` + + const response = await fetch(url, { + ...options, + headers: { + 'Authorization': `token ${GITEA_TOKEN}`, + 'Content-Type': 'application/json', + ...options.headers, + }, + }) + + if (!response.ok) { + const error = await response.text() + throw new Error(`Gitea API error: ${response.status} - ${error}`) + } + + return response.json() +} + +/** + * Create a new branch in Gitea + */ +export async function createGiteaBranch(options: GiteaBranchOptions) { + const { owner, repo, branchName, fromBranch } = options + + try { + // Get the ref of the source branch + const refData = await giteaRequest( + `/repos/${owner}/${repo}/git/refs/heads/${fromBranch}` + ) + + // Create new branch from the ref + const result = await giteaRequest( + `/repos/${owner}/${repo}/branches`, + { + method: 'POST', + body: JSON.stringify({ + new_branch_name: branchName, + old_ref_name: fromBranch, + }), + } + ) + + return result + } catch (error: any) { + console.error('Error creating Gitea branch:', error) + throw error + } +} + +/** + * Create a Pull Request in Gitea + */ +export async function createGiteaPullRequest(options: GiteaPullRequestOptions) { + const { owner, repo, title, body, head, base } = options + + try { + const result = await giteaRequest( + `/repos/${owner}/${repo}/pulls`, + { + method: 'POST', + body: JSON.stringify({ + title, + body, + head, + base, + }), + } + ) + + return result + } catch (error: any) { + console.error('Error creating Gitea PR:', error) + throw error + } +} + +/** + * Get repository information + */ +export async function getGiteaRepo(owner: string, repo: string) { + try { + return await giteaRequest(`/repos/${owner}/${repo}`) + } catch (error: any) { + console.error('Error getting Gitea repo:', error) + throw error + } +} + +/** + * Create a new repository + */ +export async function createGiteaRepo(name: string, description: string, isPrivate = false) { + try { + const result = await giteaRequest( + `/user/repos`, + { + method: 'POST', + body: JSON.stringify({ + name, + description, + private: isPrivate, + auto_init: true, + default_branch: 'main', + }), + } + ) + + return result + } catch (error: any) { + console.error('Error creating Gitea repo:', error) + throw error + } +} + +/** + * Merge a Pull Request + */ +export async function mergeGiteaPullRequest( + owner: string, + repo: string, + prNumber: number, + method: 'merge' | 'rebase' | 'squash' = 'merge' +) { + try { + const result = await giteaRequest( + `/repos/${owner}/${repo}/pulls/${prNumber}/merge`, + { + method: 'POST', + body: JSON.stringify({ + Do: method, + }), + } + ) + + return result + } catch (error: any) { + console.error('Error merging Gitea PR:', error) + throw error + } +} diff --git a/src/services/mcp/server.ts b/src/services/mcp/server.ts new file mode 100644 index 0000000..9ada2c6 --- /dev/null +++ b/src/services/mcp/server.ts @@ -0,0 +1,178 @@ +/** + * MCP Server - Model Context Protocol + * Handles communication with Claude Code agents + */ + +import { Server } from '@modelcontextprotocol/sdk/server/index.js' +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' +import { + CallToolRequestSchema, + ListToolsRequestSchema, +} from '@modelcontextprotocol/sdk/types.js' + +import { handleGetNextTask } from './tools/get-next-task' +import { handleUpdateTaskStatus } from './tools/update-task-status' +import { handleCreateBranch } from './tools/create-branch' +import { handleCreatePullRequest } from './tools/create-pull-request' + +/** + * MCP Server for agent communication + */ +export class MCPServer { + private server: Server + + constructor() { + this.server = new Server( + { + name: 'aiworker-mcp', + version: '1.0.0', + }, + { + capabilities: { + tools: {}, + }, + } + ) + + this.setupHandlers() + } + + private setupHandlers() { + // List available tools + this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: 'get_next_task', + description: 'Get the next available task from the queue and assign it to the agent', + inputSchema: { + type: 'object', + properties: { + agentId: { + type: 'string', + description: 'UUID of the agent requesting a task', + }, + capabilities: { + type: 'array', + items: { type: 'string' }, + description: 'List of agent capabilities (optional)', + }, + }, + required: ['agentId'], + }, + }, + { + name: 'update_task_status', + description: 'Update the status of a task', + inputSchema: { + type: 'object', + properties: { + taskId: { + type: 'string', + description: 'UUID of the task', + }, + status: { + type: 'string', + enum: ['in_progress', 'needs_input', 'ready_to_test', 'approved', 'staging', 'production'], + description: 'New status for the task', + }, + metadata: { + type: 'object', + description: 'Additional metadata about the update (optional)', + }, + }, + required: ['taskId', 'status'], + }, + }, + { + name: 'create_branch', + description: 'Create a new branch in Gitea for the task', + inputSchema: { + type: 'object', + properties: { + taskId: { + type: 'string', + description: 'UUID of the task', + }, + branchName: { + type: 'string', + description: 'Name for the branch (optional, auto-generated if not provided)', + }, + }, + required: ['taskId'], + }, + }, + { + name: 'create_pull_request', + description: 'Create a Pull Request in Gitea with the task changes', + inputSchema: { + type: 'object', + properties: { + taskId: { + type: 'string', + description: 'UUID of the task', + }, + title: { + type: 'string', + description: 'Title for the PR', + }, + description: { + type: 'string', + description: 'Description/body of the PR (supports markdown)', + }, + }, + required: ['taskId', 'title', 'description'], + }, + }, + ], + })) + + // Handle tool calls + this.server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params + + try { + switch (name) { + case 'get_next_task': + return await handleGetNextTask(args) + + case 'update_task_status': + return await handleUpdateTaskStatus(args) + + case 'create_branch': + return await handleCreateBranch(args) + + case 'create_pull_request': + return await handleCreatePullRequest(args) + + default: + throw new Error(`Unknown tool: ${name}`) + } + } catch (error: any) { + return { + content: [ + { + type: 'text', + text: `Error: ${error.message}`, + }, + ], + isError: true, + } + } + }) + } + + /** + * Start the MCP server with stdio transport + */ + async start() { + const transport = new StdioServerTransport() + await this.server.connect(transport) + console.log('🤖 MCP Server started (stdio transport)') + } +} + +// Run if executed directly +if (import.meta.main) { + const server = new MCPServer() + await server.start() +} diff --git a/src/services/mcp/tools/create-branch.ts b/src/services/mcp/tools/create-branch.ts new file mode 100644 index 0000000..c518d49 --- /dev/null +++ b/src/services/mcp/tools/create-branch.ts @@ -0,0 +1,87 @@ +/** + * MCP Tool: create_branch + * Creates a new branch in Gitea for the task + */ + +import { db } from '../../../db/client' +import { tasks, projects } from '../../../db/schema' +import { eq } from 'drizzle-orm' +import { createGiteaBranch } from '../../gitea/client' + +interface CreateBranchArgs { + taskId: string + branchName?: string +} + +/** + * Generate branch name from task + */ +function generateBranchName(task: any): string { + const shortId = task.id.substring(0, 8) + const slugified = task.title + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') + .substring(0, 40) + + return `task-${shortId}-${slugified}` +} + +export async function handleCreateBranch(args: any) { + const { taskId, branchName } = args as CreateBranchArgs + + try { + // Get task and project + const task = await db + .select() + .from(tasks) + .where(eq(tasks.id, taskId)) + .limit(1) + + if (task.length === 0) { + throw new Error('Task not found') + } + + const project = await db + .select() + .from(projects) + .where(eq(projects.id, task[0].projectId)) + .limit(1) + + if (project.length === 0) { + throw new Error('Project not found') + } + + // Generate or use provided branch name + const finalBranchName = branchName || generateBranchName(task[0]) + + // Create branch in Gitea + const result = await createGiteaBranch({ + owner: project[0].giteaOwner || 'admin', + repo: project[0].giteaRepoName || '', + branchName: finalBranchName, + fromBranch: project[0].defaultBranch || 'main', + }) + + // Update task with branch name + await db + .update(tasks) + .set({ branchName: finalBranchName }) + .where(eq(tasks.id, taskId)) + + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + success: true, + branchName: finalBranchName, + repoUrl: project[0].giteaRepoUrl, + }), + }, + ], + } + } catch (error: any) { + throw new Error(`Failed to create branch: ${error.message}`) + } +} diff --git a/src/services/mcp/tools/create-pull-request.ts b/src/services/mcp/tools/create-pull-request.ts new file mode 100644 index 0000000..d4bbacd --- /dev/null +++ b/src/services/mcp/tools/create-pull-request.ts @@ -0,0 +1,80 @@ +/** + * MCP Tool: create_pull_request + * Creates a Pull Request in Gitea with the task changes + */ + +import { db } from '../../../db/client' +import { tasks, projects } from '../../../db/schema' +import { eq } from 'drizzle-orm' +import { createGiteaPullRequest } from '../../gitea/client' + +interface CreatePullRequestArgs { + taskId: string + title: string + description: string +} + +export async function handleCreatePullRequest(args: any) { + const { taskId, title, description } = args as CreatePullRequestArgs + + try { + // Get task and project + const task = await db + .select() + .from(tasks) + .where(eq(tasks.id, taskId)) + .limit(1) + + if (task.length === 0) { + throw new Error('Task not found') + } + + if (!task[0].branchName) { + throw new Error('Task has no branch. Create a branch first with create_branch') + } + + const project = await db + .select() + .from(projects) + .where(eq(projects.id, task[0].projectId)) + .limit(1) + + if (project.length === 0) { + throw new Error('Project not found') + } + + // Create PR in Gitea + const result = await createGiteaPullRequest({ + owner: project[0].giteaOwner || 'admin', + repo: project[0].giteaRepoName || '', + title, + body: description, + head: task[0].branchName, + base: project[0].defaultBranch || 'main', + }) + + // Update task with PR info + await db + .update(tasks) + .set({ + prUrl: result.html_url, + state: 'ready_to_test', + }) + .where(eq(tasks.id, taskId)) + + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + success: true, + prUrl: result.html_url, + prNumber: result.number, + }), + }, + ], + } + } catch (error: any) { + throw new Error(`Failed to create pull request: ${error.message}`) + } +} diff --git a/src/services/mcp/tools/get-next-task.ts b/src/services/mcp/tools/get-next-task.ts new file mode 100644 index 0000000..7763969 --- /dev/null +++ b/src/services/mcp/tools/get-next-task.ts @@ -0,0 +1,113 @@ +/** + * MCP Tool: get_next_task + * Gets the next available task from the queue and assigns it to the agent + */ + +import { db } from '../../../db/client' +import { tasks, projects, agents } from '../../../db/schema' +import { eq, and } from 'drizzle-orm' + +interface GetNextTaskArgs { + agentId: string + capabilities?: string[] +} + +export async function handleGetNextTask(args: any) { + const { agentId, capabilities } = args as GetNextTaskArgs + + try { + // Verify agent exists + const agent = await db + .select() + .from(agents) + .where(eq(agents.id, agentId)) + .limit(1) + + if (agent.length === 0) { + throw new Error('Agent not found') + } + + // Get next available task (state = 'backlog', priority descending) + const availableTasks = await db + .select() + .from(tasks) + .where(eq(tasks.state, 'backlog')) + .orderBy(tasks.priority) + .limit(1) + + if (availableTasks.length === 0) { + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + task: null, + message: 'No tasks available', + }), + }, + ], + } + } + + const task = availableTasks[0] + + // Get project details + const project = await db + .select() + .from(projects) + .where(eq(projects.id, task.projectId)) + .limit(1) + + if (project.length === 0) { + throw new Error('Project not found for task') + } + + // Assign task to agent + await db + .update(tasks) + .set({ + state: 'in_progress', + assignedAgentId: agentId, + }) + .where(eq(tasks.id, task.id)) + + // Update agent status + await db + .update(agents) + .set({ + status: 'busy', + currentTaskId: task.id, + }) + .where(eq(agents.id, agentId)) + + // Return task with project info + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + task: { + id: task.id, + title: task.title, + description: task.description, + priority: task.priority, + state: 'in_progress', + project: { + id: project[0].id, + name: project[0].name, + giteaRepoUrl: project[0].giteaRepoUrl, + giteaOwner: project[0].giteaOwner, + giteaRepoName: project[0].giteaRepoName, + defaultBranch: project[0].defaultBranch, + dockerImage: project[0].dockerImage, + k8sNamespace: project[0].k8sNamespace, + }, + }, + }), + }, + ], + } + } catch (error: any) { + throw new Error(`Failed to get next task: ${error.message}`) + } +} diff --git a/src/services/mcp/tools/update-task-status.ts b/src/services/mcp/tools/update-task-status.ts new file mode 100644 index 0000000..bb14364 --- /dev/null +++ b/src/services/mcp/tools/update-task-status.ts @@ -0,0 +1,67 @@ +/** + * MCP Tool: update_task_status + * Updates the status of a task + */ + +import { db } from '../../../db/client' +import { tasks, agents } from '../../../db/schema' +import { eq } from 'drizzle-orm' + +interface UpdateTaskStatusArgs { + taskId: string + status: 'in_progress' | 'needs_input' | 'ready_to_test' | 'approved' | 'staging' | 'production' + metadata?: Record +} + +export async function handleUpdateTaskStatus(args: any) { + const { taskId, status, metadata } = args as UpdateTaskStatusArgs + + try { + // Verify task exists + const task = await db + .select() + .from(tasks) + .where(eq(tasks.id, taskId)) + .limit(1) + + if (task.length === 0) { + throw new Error('Task not found') + } + + // Update task status + await db + .update(tasks) + .set({ state: status }) + .where(eq(tasks.id, taskId)) + + // If task is completed or ready_to_test, update agent status to idle + if (status === 'ready_to_test' || status === 'approved') { + if (task[0].assignedAgentId) { + await db + .update(agents) + .set({ + status: 'idle', + currentTaskId: null, + tasksCompleted: db.raw('tasks_completed + 1') as any, + }) + .where(eq(agents.id, task[0].assignedAgentId)) + } + } + + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + success: true, + taskId, + newStatus: status, + metadata, + }), + }, + ], + } + } catch (error: any) { + throw new Error(`Failed to update task status: ${error.message}`) + } +}