diff --git a/drizzle/migrations/0002_next_xorn.sql b/drizzle/migrations/0002_next_xorn.sql new file mode 100644 index 0000000..7ed1708 --- /dev/null +++ b/drizzle/migrations/0002_next_xorn.sql @@ -0,0 +1,6 @@ +ALTER TABLE `agents` ADD `user_id` varchar(36) NOT NULL;--> statement-breakpoint +ALTER TABLE `projects` ADD `user_id` varchar(36) NOT NULL;--> statement-breakpoint +ALTER TABLE `agents` ADD CONSTRAINT `agents_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `projects` ADD CONSTRAINT `projects_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX `idx_user_id` ON `agents` (`user_id`);--> statement-breakpoint +CREATE INDEX `idx_user_id` ON `projects` (`user_id`); \ No newline at end of file diff --git a/drizzle/migrations/meta/0002_snapshot.json b/drizzle/migrations/meta/0002_snapshot.json new file mode 100644 index 0000000..d28a27c --- /dev/null +++ b/drizzle/migrations/meta/0002_snapshot.json @@ -0,0 +1,634 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "28e89c13-2bd0-4fcd-9b01-e8433c08d71e", + "prevId": "ada6a640-177d-4c35-b7e0-e1d20c9adabb", + "tables": { + "agents": { + "name": "agents", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pod_name": { + "name": "pod_name", + "type": "varchar(253)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "k8s_namespace": { + "name": "k8s_namespace", + "type": "varchar(63)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'agents'" + }, + "status": { + "name": "status", + "type": "enum('idle','busy','error','offline')", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'idle'" + }, + "current_task_id": { + "name": "current_task_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tasks_completed": { + "name": "tasks_completed", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "last_heartbeat": { + "name": "last_heartbeat", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "idx_status": { + "name": "idx_status", + "columns": [ + "status" + ], + "isUnique": false + }, + "idx_user_id": { + "name": "idx_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "agents_user_id_users_id_fk": { + "name": "agents_user_id_users_id_fk", + "tableFrom": "agents", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agents_id": { + "name": "agents_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "agents_pod_name_unique": { + "name": "agents_pod_name_unique", + "columns": [ + "pod_name" + ] + } + }, + "checkConstraint": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "gitea_repo_id": { + "name": "gitea_repo_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "gitea_repo_url": { + "name": "gitea_repo_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "gitea_owner": { + "name": "gitea_owner", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "gitea_repo_name": { + "name": "gitea_repo_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_branch": { + "name": "default_branch", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'main'" + }, + "k8s_namespace": { + "name": "k8s_namespace", + "type": "varchar(63)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "docker_image": { + "name": "docker_image", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "env_vars": { + "name": "env_vars", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "replicas": { + "name": "replicas", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 1 + }, + "cpu_limit": { + "name": "cpu_limit", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'500m'" + }, + "memory_limit": { + "name": "memory_limit", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'512Mi'" + }, + "status": { + "name": "status", + "type": "enum('active','paused','archived')", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "idx_status": { + "name": "idx_status", + "columns": [ + "status" + ], + "isUnique": false + }, + "idx_user_id": { + "name": "idx_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "projects_user_id_users_id_fk": { + "name": "projects_user_id_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "projects_id": { + "name": "projects_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "projects_k8s_namespace_unique": { + "name": "projects_k8s_namespace_unique", + "columns": [ + "k8s_namespace" + ] + } + }, + "checkConstraint": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "idx_user_id": { + "name": "idx_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_expires_at": { + "name": "idx_expires_at", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sessions_id": { + "name": "sessions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "tasks": { + "name": "tasks", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "priority": { + "name": "priority", + "type": "enum('low','medium','high','urgent')", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'medium'" + }, + "state": { + "name": "state", + "type": "enum('backlog','in_progress','needs_input','ready_to_test','approved','staging','production')", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'backlog'" + }, + "assigned_agent_id": { + "name": "assigned_agent_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch_name": { + "name": "branch_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pr_url": { + "name": "pr_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "preview_url": { + "name": "preview_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "idx_project_state": { + "name": "idx_project_state", + "columns": [ + "project_id", + "state" + ], + "isUnique": false + } + }, + "foreignKeys": { + "tasks_project_id_projects_id_fk": { + "name": "tasks_project_id_projects_id_fk", + "tableFrom": "tasks", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_assigned_agent_id_agents_id_fk": { + "name": "tasks_assigned_agent_id_agents_id_fk", + "tableFrom": "tasks", + "tableTo": "agents", + "columnsFrom": [ + "assigned_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tasks_id": { + "name": "tasks_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "idx_email": { + "name": "idx_email", + "columns": [ + "email" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ] + }, + "users_username_unique": { + "name": "users_username_unique", + "columns": [ + "username" + ] + } + }, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/migrations/meta/_journal.json b/drizzle/migrations/meta/_journal.json index a522094..9fc4203 100644 --- a/drizzle/migrations/meta/_journal.json +++ b/drizzle/migrations/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1768868010676, "tag": "0001_opposite_warbird", "breakpoints": true + }, + { + "idx": 2, + "version": "5", + "when": 1768924360408, + "tag": "0002_next_xorn", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/api/middleware/auth.ts b/src/api/middleware/auth.ts new file mode 100644 index 0000000..af856f1 --- /dev/null +++ b/src/api/middleware/auth.ts @@ -0,0 +1,80 @@ +/** + * Authentication Middleware + * Validates session cookies and extracts user ID + */ + +import { db } from '../../db/client' +import { users, sessions } from '../../db/schema' +import { eq } from 'drizzle-orm' + +export interface AuthContext { + userId: string + user: { + id: string + email: string + username: string + } +} + +/** + * Authenticate request using session cookie + * Returns null if authentication fails + */ +export async function authenticateRequest(req: Request): Promise { + const sessionId = getSessionIdFromCookie(req) + if (!sessionId) { + return null + } + + // Query session with user join + const [result] = await db + .select({ + sessionId: sessions.id, + sessionExpiresAt: sessions.expiresAt, + userId: users.id, + userEmail: users.email, + username: users.username, + }) + .from(sessions) + .innerJoin(users, eq(sessions.userId, users.id)) + .where(eq(sessions.id, sessionId)) + .limit(1) + + if (!result) { + return null + } + + // Check if session expired + if (result.sessionExpiresAt < new Date()) { + return null + } + + return { + userId: result.userId, + user: { + id: result.userId, + email: result.userEmail, + username: result.username, + }, + } +} + +/** + * Extract session ID from cookie header + */ +function getSessionIdFromCookie(req: Request): string | null { + const cookieHeader = req.headers.get('cookie') + if (!cookieHeader) { + return null + } + + const cookies = cookieHeader.split(';').reduce((acc, cookie) => { + const [name, value] = cookie.trim().split('=') + if (name && value) { + acc[name] = value + } + return acc + }, {} as Record) + + return cookies.session || null +} diff --git a/src/api/routes/agents.ts b/src/api/routes/agents.ts index ec84043..1b87974 100644 --- a/src/api/routes/agents.ts +++ b/src/api/routes/agents.ts @@ -5,8 +5,9 @@ import { db } from '../../db/client' import { agents, tasks } from '../../db/schema' -import { eq } from 'drizzle-orm' +import { eq, and } from 'drizzle-orm' import { randomUUID } from 'crypto' +import { authenticateRequest } from '../middleware/auth' /** * Handle all agent routes @@ -15,7 +16,34 @@ export async function handleAgentRoutes(req: Request, url: URL): Promise { } /** - * Get single agent + * Get single agent (verify ownership) */ -async function getAgent(agentId: string): Promise { +async function getAgent(agentId: string, userId: string): Promise { try { const agent = await db .select() .from(agents) - .where(eq(agents.id, agentId)) + .where( + and( + eq(agents.id, agentId), + eq(agents.userId, userId) + ) + ) .limit(1) if (agent.length === 0) { @@ -114,16 +147,17 @@ async function getAgent(agentId: string): Promise { /** * Register new agent (called when pod starts) + * userId is extracted from env var passed to pod */ async function registerAgent(req: Request): Promise { try { const body = await req.json() // Validate required fields - if (!body.podName) { + if (!body.podName || !body.userId) { return Response.json({ success: false, - error: 'podName is required', + error: 'podName and userId are required', }, { status: 400 }) } @@ -145,6 +179,7 @@ async function registerAgent(req: Request): Promise { const newAgent = { id: randomUUID(), + userId: body.userId, podName: body.podName, k8sNamespace: body.k8sNamespace || 'agents', status: 'idle' as const, @@ -169,23 +204,28 @@ async function registerAgent(req: Request): Promise { } /** - * Update agent status and current task + * Update agent status and current task (verify ownership) */ -async function updateAgent(agentId: string, req: Request): Promise { +async function updateAgent(agentId: string, req: Request, userId: string): Promise { try { const body = await req.json() - // Check if agent exists + // Check if agent exists and belongs to user const existing = await db .select() .from(agents) - .where(eq(agents.id, agentId)) + .where( + and( + eq(agents.id, agentId), + eq(agents.userId, userId) + ) + ) .limit(1) if (existing.length === 0) { return Response.json({ success: false, - error: 'Agent not found', + error: 'Agent not found or access denied', }, { status: 404 }) } @@ -260,21 +300,26 @@ async function updateHeartbeat(agentId: string): Promise { } /** - * Unregister agent (called when pod terminates) + * Unregister agent (called when pod terminates, verify ownership) */ -async function unregisterAgent(agentId: string): Promise { +async function unregisterAgent(agentId: string, userId: string): Promise { try { - // Check if agent exists + // Check if agent exists and belongs to user const existing = await db .select() .from(agents) - .where(eq(agents.id, agentId)) + .where( + and( + eq(agents.id, agentId), + eq(agents.userId, userId) + ) + ) .limit(1) if (existing.length === 0) { return Response.json({ success: false, - error: 'Agent not found', + error: 'Agent not found or access denied', }, { status: 404 }) } @@ -286,9 +331,11 @@ async function unregisterAgent(agentId: string): Promise { .where(eq(tasks.id, existing[0].currentTaskId)) } - // Delete agent + // Delete agent from DB await db.delete(agents).where(eq(agents.id, agentId)) + // TODO: Delete K8s pod if it exists + return Response.json({ success: true, message: 'Agent unregistered', @@ -301,3 +348,68 @@ async function unregisterAgent(agentId: string): Promise { }, { status: 500 }) } } + +/** + * List my agents (filtered by userId) + */ +async function listMyAgents(userId: string): Promise { + try { + const myAgents = await db + .select() + .from(agents) + .where(eq(agents.userId, userId)) + + return Response.json({ + success: true, + data: myAgents, + count: myAgents.length, + }) + } catch (error) { + console.error('Error listing my agents:', error) + return Response.json({ + success: false, + error: 'Failed to list agents', + }, { status: 500 }) + } +} + +/** + * Launch new agent (create pod dynamically) + * TODO: Integrate with K8s API to create pod + */ +async function launchAgent(userId: string, req: Request): Promise { + try { + const agentId = randomUUID() + const podName = `claude-agent-${userId.slice(0, 8)}-${Date.now()}` + + // Create agent record in DB + const newAgent = { + id: agentId, + userId, + podName, + k8sNamespace: 'agents', + status: 'idle' as const, + currentTaskId: null, + tasksCompleted: 0, + lastHeartbeat: new Date(), + } + + await db.insert(agents).values(newAgent) + + // TODO: Create K8s pod using K8s API + // For now, just return the agent record + // In production, this would call kubectl or use @kubernetes/client-node + + return Response.json({ + success: true, + data: newAgent, + message: 'Agent launch initiated. Pod will be created shortly.', + }, { status: 201 }) + } catch (error) { + console.error('Error launching agent:', error) + return Response.json({ + success: false, + error: 'Failed to launch agent', + }, { status: 500 }) + } +} diff --git a/src/api/routes/mcp.ts b/src/api/routes/mcp.ts index a8fb48c..3a42759 100644 --- a/src/api/routes/mcp.ts +++ b/src/api/routes/mcp.ts @@ -5,7 +5,8 @@ import { db } from '../../db/client' import { tasks, agents, projects } from '../../db/schema' -import { eq, and, or } from 'drizzle-orm' +import { eq, and, or, inArray } from 'drizzle-orm' +import { authenticateRequest } from '../middleware/auth' /** * Route handler for /api/mcp/* @@ -13,99 +14,117 @@ import { eq, and, or } from 'drizzle-orm' export async function handleMCPRoutes(req: Request, url: URL): Promise { const path = url.pathname.replace('/api/mcp', '') + // /tools endpoint doesn't require authentication + if (path === '/tools' && req.method === 'GET') { + return handleGetTools() + } + + // Authenticate all other MCP requests + const auth = await authenticateRequest(req) + if (!auth) { + return Response.json( + { success: false, message: 'Unauthorized' }, + { status: 401 } + ) + } + + const userId = auth.userId + // POST /api/mcp/get_next_task if (path === '/get_next_task' && req.method === 'POST') { - return await handleGetNextTask(req) + return await handleGetNextTask(req, userId) } // POST /api/mcp/update_task_status if (path === '/update_task_status' && req.method === 'POST') { - return await handleUpdateTaskStatus(req) + return await handleUpdateTaskStatus(req, userId) } // POST /api/mcp/create_branch if (path === '/create_branch' && req.method === 'POST') { - return await handleCreateBranch(req) + return await handleCreateBranch(req, userId) } // POST /api/mcp/create_pull_request if (path === '/create_pull_request' && req.method === 'POST') { - return await handleCreatePullRequest(req) + return await handleCreatePullRequest(req, userId) } // POST /api/mcp/ask_user_question if (path === '/ask_user_question' && req.method === 'POST') { - return await handleAskUserQuestion(req) - } - - // GET /api/mcp/tools - List available tools - if (path === '/tools' && req.method === 'GET') { - return Response.json({ - success: true, - tools: [ - { - name: 'get_next_task', - description: 'Get the next available task from the backlog', - endpoint: '/api/mcp/get_next_task', - method: 'POST', - params: { - agentId: 'string (required)', - }, - }, - { - name: 'update_task_status', - description: 'Update the status of a task', - endpoint: '/api/mcp/update_task_status', - method: 'POST', - params: { - taskId: 'string (required)', - status: 'string (required)', - errorMessage: 'string (optional)', - }, - }, - { - name: 'create_branch', - description: 'Create a Git branch for a task', - endpoint: '/api/mcp/create_branch', - method: 'POST', - params: { - taskId: 'string (required)', - branchName: 'string (required)', - }, - }, - { - name: 'create_pull_request', - description: 'Create a pull request in Gitea', - endpoint: '/api/mcp/create_pull_request', - method: 'POST', - params: { - taskId: 'string (required)', - title: 'string (required)', - description: 'string (optional)', - branchName: 'string (required)', - }, - }, - { - name: 'ask_user_question', - description: 'Ask the user a question', - endpoint: '/api/mcp/ask_user_question', - method: 'POST', - params: { - taskId: 'string (required)', - question: 'string (required)', - }, - }, - ], - }) + return await handleAskUserQuestion(req, userId) } return Response.json({ message: 'Not found' }, { status: 404 }) } /** - * Get next available task + * Get available tools (public endpoint) */ -async function handleGetNextTask(req: Request): Promise { +function handleGetTools(): Response { + return Response.json({ + success: true, + tools: [ + { + name: 'get_next_task', + description: 'Get the next available task from the backlog', + endpoint: '/api/mcp/get_next_task', + method: 'POST', + params: { + agentId: 'string (required)', + }, + }, + { + name: 'update_task_status', + description: 'Update the status of a task', + endpoint: '/api/mcp/update_task_status', + method: 'POST', + params: { + taskId: 'string (required)', + status: 'string (required)', + errorMessage: 'string (optional)', + }, + }, + { + name: 'create_branch', + description: 'Create a Git branch for a task', + endpoint: '/api/mcp/create_branch', + method: 'POST', + params: { + taskId: 'string (required)', + branchName: 'string (required)', + }, + }, + { + name: 'create_pull_request', + description: 'Create a pull request in Gitea', + endpoint: '/api/mcp/create_pull_request', + method: 'POST', + params: { + taskId: 'string (required)', + title: 'string (required)', + description: 'string (optional)', + branchName: 'string (required)', + }, + }, + { + name: 'ask_user_question', + description: 'Ask the user a question', + endpoint: '/api/mcp/ask_user_question', + method: 'POST', + params: { + taskId: 'string (required)', + question: 'string (required)', + }, + }, + ], + }) +} + +/** + * Get next available task (filtered by user's projects) + */ +async function handleGetNextTask(req: Request, userId: string): Promise { try { const body = await req.json() const { agentId } = body @@ -117,11 +136,46 @@ async function handleGetNextTask(req: Request): Promise { ) } - // Get next task in backlog, ordered by priority + // Verify agent belongs to this user + const [agent] = await db + .select() + .from(agents) + .where(and(eq(agents.id, agentId), eq(agents.userId, userId))) + .limit(1) + + if (!agent) { + return Response.json( + { success: false, message: 'Agent not found or does not belong to user' }, + { status: 403 } + ) + } + + // Get user's projects + const userProjects = await db + .select({ id: projects.id }) + .from(projects) + .where(eq(projects.userId, userId)) + + if (userProjects.length === 0) { + return Response.json({ + success: true, + message: 'No projects available for this user', + data: null, + }) + } + + const projectIds = userProjects.map(p => p.id) + + // Get next task in backlog from user's projects, ordered by priority const [task] = await db .select() .from(tasks) - .where(eq(tasks.state, 'backlog')) + .where( + and( + eq(tasks.state, 'backlog'), + inArray(tasks.projectId, projectIds) + ) + ) .orderBy(tasks.priority, tasks.createdAt) .limit(1) @@ -175,9 +229,9 @@ async function handleGetNextTask(req: Request): Promise { } /** - * Update task status + * Update task status (verify user owns the task's project) */ -async function handleUpdateTaskStatus(req: Request): Promise { +async function handleUpdateTaskStatus(req: Request, userId: string): Promise { try { const body = await req.json() const { taskId, status, errorMessage } = body @@ -206,6 +260,26 @@ async function handleUpdateTaskStatus(req: Request): Promise { ) } + // Verify task belongs to user's project + const [task] = await db + .select() + .from(tasks) + .innerJoin(projects, eq(tasks.projectId, projects.id)) + .where( + and( + eq(tasks.id, taskId), + eq(projects.userId, userId) + ) + ) + .limit(1) + + if (!task) { + return Response.json( + { success: false, message: 'Task not found or access denied' }, + { status: 403 } + ) + } + await db .update(tasks) .set({ @@ -216,13 +290,13 @@ async function handleUpdateTaskStatus(req: Request): Promise { // If task is completed, update agent if (['ready_to_test', 'approved'].includes(status)) { - const [task] = await db + const [taskData] = await db .select() .from(tasks) .where(eq(tasks.id, taskId)) .limit(1) - if (task?.assignedAgentId) { + if (taskData?.assignedAgentId) { await db .update(agents) .set({ @@ -230,7 +304,7 @@ async function handleUpdateTaskStatus(req: Request): Promise { currentTaskId: null, tasksCompleted: db.sql`${agents.tasksCompleted} + 1`, }) - .where(eq(agents.id, task.assignedAgentId)) + .where(eq(agents.id, taskData.assignedAgentId)) } } @@ -248,9 +322,9 @@ async function handleUpdateTaskStatus(req: Request): Promise { } /** - * Create branch + * Create branch (verify user owns the task's project) */ -async function handleCreateBranch(req: Request): Promise { +async function handleCreateBranch(req: Request, userId: string): Promise { try { const body = await req.json() const { taskId, branchName } = body @@ -262,6 +336,26 @@ async function handleCreateBranch(req: Request): Promise { ) } + // Verify task belongs to user's project + const [task] = await db + .select() + .from(tasks) + .innerJoin(projects, eq(tasks.projectId, projects.id)) + .where( + and( + eq(tasks.id, taskId), + eq(projects.userId, userId) + ) + ) + .limit(1) + + if (!task) { + return Response.json( + { success: false, message: 'Task not found or access denied' }, + { status: 403 } + ) + } + await db .update(tasks) .set({ @@ -283,9 +377,9 @@ async function handleCreateBranch(req: Request): Promise { } /** - * Create pull request + * Create pull request (verify user owns the task's project) */ -async function handleCreatePullRequest(req: Request): Promise { +async function handleCreatePullRequest(req: Request, userId: string): Promise { try { const body = await req.json() const { taskId, title, description, branchName } = body @@ -297,6 +391,26 @@ async function handleCreatePullRequest(req: Request): Promise { ) } + // Verify task belongs to user's project + const [task] = await db + .select() + .from(tasks) + .innerJoin(projects, eq(tasks.projectId, projects.id)) + .where( + and( + eq(tasks.id, taskId), + eq(projects.userId, userId) + ) + ) + .limit(1) + + if (!task) { + return Response.json( + { success: false, message: 'Task not found or access denied' }, + { status: 403 } + ) + } + // TODO: Integrate with Gitea API to actually create PR // For now, just update task with placeholder PR URL const prUrl = `https://git.fuq.tv/pulls/${taskId}` @@ -326,9 +440,9 @@ async function handleCreatePullRequest(req: Request): Promise { } /** - * Ask user question + * Ask user question (verify user owns the task's project) */ -async function handleAskUserQuestion(req: Request): Promise { +async function handleAskUserQuestion(req: Request, userId: string): Promise { try { const body = await req.json() const { taskId, question } = body @@ -340,6 +454,26 @@ async function handleAskUserQuestion(req: Request): Promise { ) } + // Verify task belongs to user's project + const [task] = await db + .select() + .from(tasks) + .innerJoin(projects, eq(tasks.projectId, projects.id)) + .where( + and( + eq(tasks.id, taskId), + eq(projects.userId, userId) + ) + ) + .limit(1) + + if (!task) { + return Response.json( + { success: false, message: 'Task not found or access denied' }, + { status: 403 } + ) + } + await db .update(tasks) .set({ diff --git a/src/db/schema.ts b/src/db/schema.ts index 5291651..db5271d 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -56,6 +56,7 @@ export const sessions = mysqlTable('sessions', { export const projects = mysqlTable('projects', { id: varchar('id', { length: 36 }).primaryKey(), + userId: varchar('user_id', { length: 36 }).notNull().references(() => users.id, { onDelete: 'cascade' }), name: varchar('name', { length: 255 }).notNull(), description: text('description'), @@ -84,6 +85,7 @@ export const projects = mysqlTable('projects', { updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(), }, (table) => ({ statusIdx: index('idx_status').on(table.status), + userIdIdx: index('idx_user_id').on(table.userId), })) // ============================================ @@ -92,6 +94,7 @@ export const projects = mysqlTable('projects', { export const agents = mysqlTable('agents', { id: varchar('id', { length: 36 }).primaryKey(), + userId: varchar('user_id', { length: 36 }).notNull().references(() => users.id, { onDelete: 'cascade' }), // K8s podName: varchar('pod_name', { length: 253 }).notNull().unique(), @@ -110,6 +113,7 @@ export const agents = mysqlTable('agents', { updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(), }, (table) => ({ statusIdx: index('idx_status').on(table.status), + userIdIdx: index('idx_user_id').on(table.userId), })) // ============================================ @@ -157,7 +161,24 @@ export const tasks = mysqlTable('tasks', { // RELATIONS // ============================================ -export const projectsRelations = relations(projects, ({ many }) => ({ +export const usersRelations = relations(users, ({ many }) => ({ + sessions: many(sessions), + projects: many(projects), + agents: many(agents), +})) + +export const sessionsRelations = relations(sessions, ({ one }) => ({ + user: one(users, { + fields: [sessions.userId], + references: [users.id], + }), +})) + +export const projectsRelations = relations(projects, ({ one, many }) => ({ + user: one(users, { + fields: [projects.userId], + references: [users.id], + }), tasks: many(tasks), })) @@ -173,6 +194,10 @@ export const tasksRelations = relations(tasks, ({ one }) => ({ })) export const agentsRelations = relations(agents, ({ one }) => ({ + user: one(users, { + fields: [agents.userId], + references: [users.id], + }), currentTask: one(tasks, { fields: [agents.currentTaskId], references: [tasks.id], diff --git a/src/index.ts b/src/index.ts index c848222..b7adc5b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,8 +4,11 @@ */ import { runMigrations } from './db/migrate' -import { testConnection } from './db/client' +import { testConnection, db } from './db/client' import { handleAuthRoutes, handleProjectRoutes, handleTaskRoutes, handleAgentRoutes, handleMCPRoutes } from './api/routes' +import { authenticateRequest } from './api/middleware/auth' +import { agents } from './db/schema' +import { eq, and } from 'drizzle-orm' console.log('🚀 Starting AiWorker Backend...') console.log(`Bun version: ${Bun.version}`) @@ -64,6 +67,59 @@ const server = Bun.serve({ return handleMCPRoutes(req, url) } + // Agent terminal proxy + if (url.pathname.startsWith('/agent-terminal/')) { + const pathParts = url.pathname.split('/').filter(Boolean) + const agentId = pathParts[1] + + // Authenticate user + const auth = await authenticateRequest(req) + if (!auth) { + return Response.json( + { success: false, message: 'Unauthorized' }, + { status: 401 } + ) + } + + // Get agent and verify ownership + const [agent] = await db + .select() + .from(agents) + .where( + and( + eq(agents.id, agentId), + eq(agents.userId, auth.userId) + ) + ) + .limit(1) + + if (!agent) { + return Response.json( + { success: false, message: 'Agent not found or access denied' }, + { status: 403 } + ) + } + + // Proxy to agent terminal + const agentUrl = `http://${agent.podName}.agents.svc.cluster.local:7681${url.pathname.replace(`/agent-terminal/${agentId}`, '')}${url.search}` + + try { + const response = await fetch(agentUrl, { + method: req.method, + headers: req.headers, + body: req.body, + }) + + return response + } catch (error) { + console.error('Terminal proxy error:', error) + return Response.json( + { success: false, message: 'Failed to connect to agent terminal' }, + { status: 502 } + ) + } + } + // Generic API info if (url.pathname.startsWith('/api/')) { return Response.json({