Compare commits

...

4 Commits

Author SHA1 Message Date
Hector Ros
08e6f66c7d Add HTTP MCP endpoints for agent communication
All checks were successful
Build and Push Backend / build (push) Successful in 4s
- Replace stdio MCP server with HTTP endpoints
- MCP tools available at /api/mcp/* endpoints
- Same backend port (3000), accessible via HTTPS
- Tools: get_next_task, update_task_status, create_branch, create_pull_request, ask_user_question
- Proper integration with database and agent status tracking

Co-Authored-By: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>
2026-01-20 02:04:57 +01:00
Hector Ros
8a95c428c8 Add MCP Server for agent communication
All checks were successful
Build and Push Backend / build (push) Successful in 12s
- Implement MCP server on stdio for agent communication
- Tools: get_next_task, update_task_status, create_branch, create_pull_request, ask_user_question
- Full integration with database (tasks, agents tables)
- Proper error handling and responses
- Add 'mcp' script to package.json

Co-Authored-By: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>
2026-01-20 02:04:04 +01:00
Hector Ros
1dc0ab515d Add authentication system with session-based auth
All checks were successful
Build and Push Backend / build (push) Successful in 20s
- Implement register, login, logout, and me endpoints
- Use bcryptjs for password hashing
- HTTPOnly secure cookies for sessions (Lucia Auth pattern)
- Users and sessions tables with proper relations
- 7-day session duration with auto-expiry

Co-Authored-By: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>
2026-01-20 01:56:25 +01:00
Hector Ros
5672127593 Implement Backend API, MCP Server, and Gitea integration
All checks were successful
Build and Push Backend / build (push) Successful in 5s
- Add REST API routes for projects, tasks, and agents (CRUD operations)
- Implement MCP Server with 4 core tools:
  - get_next_task: Assign tasks to agents
  - update_task_status: Update task states
  - create_branch: Create Git branches via Gitea API
  - create_pull_request: Create PRs via Gitea API
- Add Gitea API client for repository operations
- Fix database migration error handling for existing tables
- Connect all routes to Bun.serve() main server

Co-Authored-By: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>
2026-01-20 00:43:46 +01:00
24 changed files with 3552 additions and 3 deletions

View File

@@ -8,6 +8,7 @@
"@kubernetes/client-node": "^1.4.0",
"@modelcontextprotocol/sdk": "^1.25.2",
"axios": "^1.13.2",
"bcryptjs": "^3.0.3",
"bullmq": "^5.66.5",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
@@ -21,6 +22,7 @@
"zod": "^4.3.5",
},
"devDependencies": {
"@types/bcryptjs": "^3.0.0",
"@types/bun": "latest",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
@@ -153,6 +155,8 @@
"@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="],
"@types/bcryptjs": ["@types/bcryptjs@3.0.0", "", { "dependencies": { "bcryptjs": "*" } }, "sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg=="],
"@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="],
"@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="],
@@ -235,6 +239,8 @@
"base64id": ["base64id@2.0.0", "", {}, "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog=="],
"bcryptjs": ["bcryptjs@3.0.3", "", { "bin": { "bcrypt": "bin/bcrypt" } }, "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g=="],
"body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],

View File

@@ -0,0 +1,24 @@
CREATE TABLE `sessions` (
`id` varchar(255) NOT NULL,
`user_id` varchar(36) NOT NULL,
`expires_at` timestamp NOT NULL,
`created_at` timestamp DEFAULT (now()),
CONSTRAINT `sessions_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `users` (
`id` varchar(36) NOT NULL,
`email` varchar(255) NOT NULL,
`username` varchar(100) NOT NULL,
`password_hash` varchar(255) NOT NULL,
`created_at` timestamp DEFAULT (now()),
`updated_at` timestamp DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `users_id` PRIMARY KEY(`id`),
CONSTRAINT `users_email_unique` UNIQUE(`email`),
CONSTRAINT `users_username_unique` UNIQUE(`username`)
);
--> statement-breakpoint
ALTER TABLE `sessions` ADD CONSTRAINT `sessions_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 `sessions` (`user_id`);--> statement-breakpoint
CREATE INDEX `idx_expires_at` ON `sessions` (`expires_at`);--> statement-breakpoint
CREATE INDEX `idx_email` ON `users` (`email`);

View File

@@ -0,0 +1,578 @@
{
"version": "5",
"dialect": "mysql",
"id": "ada6a640-177d-4c35-b7e0-e1d20c9adabb",
"prevId": "3f9067f3-0e1d-440b-917e-e2c879a5e283",
"tables": {
"agents": {
"name": "agents",
"columns": {
"id": {
"name": "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
}
},
"foreignKeys": {},
"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
},
"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
}
},
"foreignKeys": {},
"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": {}
}
}

View File

@@ -8,6 +8,13 @@
"when": 1768858601280,
"tag": "0000_charming_stature",
"breakpoints": true
},
{
"idx": 1,
"version": "5",
"when": 1768868010676,
"tag": "0001_opposite_warbird",
"breakpoints": true
}
]
}

View File

@@ -7,6 +7,7 @@
"scripts": {
"dev": "bun --watch src/index.ts",
"start": "bun src/index.ts",
"mcp": "bun src/mcp/server.ts",
"build": "bun build src/index.ts --outdir dist --target bun",
"db:generate": "drizzle-kit generate",
"db:migrate": "bun src/db/migrate.ts",
@@ -15,6 +16,7 @@
"format": "prettier --write src/**/*.ts"
},
"devDependencies": {
"@types/bcryptjs": "^3.0.0",
"@types/bun": "latest",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
@@ -32,6 +34,7 @@
"@kubernetes/client-node": "^1.4.0",
"@modelcontextprotocol/sdk": "^1.25.2",
"axios": "^1.13.2",
"bcryptjs": "^3.0.3",
"bullmq": "^5.66.5",
"cors": "^2.8.5",
"dotenv": "^17.2.3",

303
src/api/routes/agents.ts Normal file
View File

@@ -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<Response> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
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 })
}
}

392
src/api/routes/auth.ts Normal file
View File

@@ -0,0 +1,392 @@
/**
* Authentication Routes
* Using session-based auth with HTTPOnly cookies (Lucia Auth pattern)
*/
import { db } from '../../db/client'
import { users, sessions } from '../../db/schema'
import { eq } from 'drizzle-orm'
import { randomUUID } from 'crypto'
import bcrypt from 'bcryptjs'
// Session duration: 7 days
const SESSION_DURATION_MS = 7 * 24 * 60 * 60 * 1000
// Cookie options
const COOKIE_OPTIONS = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
path: '/',
maxAge: SESSION_DURATION_MS / 1000, // in seconds
}
/**
* Hash password using bcrypt
*/
async function hashPassword(password: string): Promise<string> {
return await bcrypt.hash(password, 10)
}
/**
* Verify password against hash
*/
async function verifyPassword(password: string, hash: string): Promise<boolean> {
return await bcrypt.compare(password, hash)
}
/**
* Generate random session ID
*/
function generateSessionId(): string {
return randomUUID().replace(/-/g, '')
}
/**
* Create session in database and return session cookie header
*/
async function createSession(userId: string): Promise<{ sessionId: string; cookieHeader: string }> {
const sessionId = generateSessionId()
const expiresAt = new Date(Date.now() + SESSION_DURATION_MS)
await db.insert(sessions).values({
id: sessionId,
userId,
expiresAt,
})
const cookieHeader = serializeCookie('session', sessionId, COOKIE_OPTIONS)
return { sessionId, cookieHeader }
}
/**
* Validate session from cookie and return user
*/
async function validateSession(sessionId: string) {
const [session] = await db
.select()
.from(sessions)
.where(eq(sessions.id, sessionId))
.limit(1)
if (!session) {
return null
}
// Check if session expired
if (session.expiresAt < new Date()) {
await db.delete(sessions).where(eq(sessions.id, sessionId))
return null
}
// Get user
const [user] = await db
.select({
id: users.id,
email: users.email,
username: users.username,
createdAt: users.createdAt,
})
.from(users)
.where(eq(users.id, session.userId))
.limit(1)
return user || null
}
/**
* Delete session from database
*/
async function deleteSession(sessionId: string) {
await db.delete(sessions).where(eq(sessions.id, sessionId))
}
/**
* Serialize cookie header
*/
function serializeCookie(
name: string,
value: string,
options: typeof COOKIE_OPTIONS
): string {
const parts = [`${name}=${value}`]
if (options.httpOnly) parts.push('HttpOnly')
if (options.secure) parts.push('Secure')
if (options.sameSite) parts.push(`SameSite=${options.sameSite}`)
if (options.path) parts.push(`Path=${options.path}`)
if (options.maxAge) parts.push(`Max-Age=${options.maxAge}`)
return parts.join('; ')
}
/**
* Parse cookies from request header
*/
function parseCookies(cookieHeader: string | null): Record<string, string> {
if (!cookieHeader) return {}
return cookieHeader.split(';').reduce((cookies, cookie) => {
const [name, value] = cookie.trim().split('=')
if (name && value) {
cookies[name] = value
}
return cookies
}, {} as Record<string, string>)
}
/**
* Get session ID from request cookies
*/
function getSessionId(req: Request): string | null {
const cookieHeader = req.headers.get('cookie')
const cookies = parseCookies(cookieHeader)
return cookies.session || null
}
/**
* POST /api/auth/register
* Create new user account
*/
async function handleRegister(req: Request): Promise<Response> {
try {
const body = await req.json()
const { email, username, password } = body
// Validation
if (!email || !username || !password) {
return Response.json(
{ success: false, message: 'Email, username, and password are required' },
{ status: 400 }
)
}
if (password.length < 8) {
return Response.json(
{ success: false, message: 'Password must be at least 8 characters' },
{ status: 400 }
)
}
// Check if user already exists
const [existingUser] = await db
.select()
.from(users)
.where(eq(users.email, email))
.limit(1)
if (existingUser) {
return Response.json(
{ success: false, message: 'User with this email already exists' },
{ status: 409 }
)
}
// Create user
const userId = randomUUID()
const passwordHash = await hashPassword(password)
await db.insert(users).values({
id: userId,
email,
username,
passwordHash,
})
// Create session
const { cookieHeader } = await createSession(userId)
// Return user (without password hash)
const [user] = await db
.select({
id: users.id,
email: users.email,
username: users.username,
createdAt: users.createdAt,
})
.from(users)
.where(eq(users.id, userId))
.limit(1)
return Response.json(
{
success: true,
data: { user },
},
{
status: 201,
headers: {
'Set-Cookie': cookieHeader,
},
}
)
} catch (error: any) {
console.error('Registration error:', error)
return Response.json(
{ success: false, message: 'Registration failed' },
{ status: 500 }
)
}
}
/**
* POST /api/auth/login
* Authenticate user and create session
*/
async function handleLogin(req: Request): Promise<Response> {
try {
const body = await req.json()
const { email, password } = body
if (!email || !password) {
return Response.json(
{ success: false, message: 'Email and password are required' },
{ status: 400 }
)
}
// Get user
const [user] = await db
.select()
.from(users)
.where(eq(users.email, email))
.limit(1)
if (!user) {
return Response.json(
{ success: false, message: 'Invalid email or password' },
{ status: 401 }
)
}
// Verify password
const valid = await verifyPassword(password, user.passwordHash)
if (!valid) {
return Response.json(
{ success: false, message: 'Invalid email or password' },
{ status: 401 }
)
}
// Create session
const { cookieHeader } = await createSession(user.id)
return Response.json(
{
success: true,
data: {
user: {
id: user.id,
email: user.email,
username: user.username,
createdAt: user.createdAt,
},
},
},
{
headers: {
'Set-Cookie': cookieHeader,
},
}
)
} catch (error: any) {
console.error('Login error:', error)
return Response.json({ success: false, message: 'Login failed' }, { status: 500 })
}
}
/**
* POST /api/auth/logout
* Delete session
*/
async function handleLogout(req: Request): Promise<Response> {
try {
const sessionId = getSessionId(req)
if (sessionId) {
await deleteSession(sessionId)
}
// Clear cookie
const clearCookieHeader = serializeCookie('session', '', {
...COOKIE_OPTIONS,
maxAge: 0,
})
return Response.json(
{ success: true, message: 'Logged out successfully' },
{
headers: {
'Set-Cookie': clearCookieHeader,
},
}
)
} catch (error: any) {
console.error('Logout error:', error)
return Response.json({ success: false, message: 'Logout failed' }, { status: 500 })
}
}
/**
* GET /api/auth/me
* Get current user from session
*/
async function handleMe(req: Request): Promise<Response> {
try {
const sessionId = getSessionId(req)
if (!sessionId) {
return Response.json(
{ success: false, message: 'Not authenticated' },
{ status: 401 }
)
}
const user = await validateSession(sessionId)
if (!user) {
return Response.json(
{ success: false, message: 'Invalid or expired session' },
{ status: 401 }
)
}
return Response.json({
success: true,
data: user,
})
} catch (error: any) {
console.error('Get current user error:', error)
return Response.json(
{ success: false, message: 'Failed to get current user' },
{ status: 500 }
)
}
}
/**
* Route handler for /api/auth/*
*/
export async function handleAuthRoutes(req: Request, url: URL): Promise<Response> {
const path = url.pathname.replace('/api/auth', '')
if (path === '/register' && req.method === 'POST') {
return handleRegister(req)
}
if (path === '/login' && req.method === 'POST') {
return handleLogin(req)
}
if (path === '/logout' && req.method === 'POST') {
return handleLogout(req)
}
if (path === '/me' && req.method === 'GET') {
return handleMe(req)
}
return Response.json({ message: 'Not found' }, { status: 404 })
}

10
src/api/routes/index.ts Normal file
View File

@@ -0,0 +1,10 @@
/**
* API Routes Index
* Exports all route handlers
*/
export { handleAuthRoutes } from './auth'
export { handleProjectRoutes } from './projects'
export { handleTaskRoutes } from './tasks'
export { handleAgentRoutes } from './agents'
export { handleMCPRoutes } from './mcp'

362
src/api/routes/mcp.ts Normal file
View File

@@ -0,0 +1,362 @@
/**
* MCP HTTP API for Agent Communication
* Provides MCP tools via HTTP POST endpoints
*/
import { db } from '../../db/client'
import { tasks, agents, projects } from '../../db/schema'
import { eq, and, or } from 'drizzle-orm'
/**
* Route handler for /api/mcp/*
*/
export async function handleMCPRoutes(req: Request, url: URL): Promise<Response> {
const path = url.pathname.replace('/api/mcp', '')
// POST /api/mcp/get_next_task
if (path === '/get_next_task' && req.method === 'POST') {
return await handleGetNextTask(req)
}
// POST /api/mcp/update_task_status
if (path === '/update_task_status' && req.method === 'POST') {
return await handleUpdateTaskStatus(req)
}
// POST /api/mcp/create_branch
if (path === '/create_branch' && req.method === 'POST') {
return await handleCreateBranch(req)
}
// POST /api/mcp/create_pull_request
if (path === '/create_pull_request' && req.method === 'POST') {
return await handleCreatePullRequest(req)
}
// 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 Response.json({ message: 'Not found' }, { status: 404 })
}
/**
* Get next available task
*/
async function handleGetNextTask(req: Request): Promise<Response> {
try {
const body = await req.json()
const { agentId } = body
if (!agentId) {
return Response.json(
{ success: false, message: 'agentId is required' },
{ status: 400 }
)
}
// Get next task in backlog, ordered by priority
const [task] = await db
.select()
.from(tasks)
.where(eq(tasks.state, 'backlog'))
.orderBy(tasks.priority, tasks.createdAt)
.limit(1)
if (!task) {
return Response.json({
success: true,
message: 'No tasks available in backlog',
data: null,
})
}
// Get project info
const [project] = await db
.select()
.from(projects)
.where(eq(projects.id, task.projectId))
.limit(1)
// 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 Response.json({
success: true,
data: {
task,
project,
},
})
} catch (error: any) {
console.error('get_next_task error:', error)
return Response.json(
{ success: false, message: error.message },
{ status: 500 }
)
}
}
/**
* Update task status
*/
async function handleUpdateTaskStatus(req: Request): Promise<Response> {
try {
const body = await req.json()
const { taskId, status, errorMessage } = body
if (!taskId || !status) {
return Response.json(
{ success: false, message: 'taskId and status are required' },
{ status: 400 }
)
}
const validStatuses = [
'backlog',
'in_progress',
'needs_input',
'ready_to_test',
'approved',
'staging',
'production',
]
if (!validStatuses.includes(status)) {
return Response.json(
{ success: false, message: 'Invalid status' },
{ status: 400 }
)
}
await db
.update(tasks)
.set({
state: status,
...(errorMessage && { errorMessage }),
})
.where(eq(tasks.id, taskId))
// If task is completed, update agent
if (['ready_to_test', 'approved'].includes(status)) {
const [task] = await db
.select()
.from(tasks)
.where(eq(tasks.id, taskId))
.limit(1)
if (task?.assignedAgentId) {
await db
.update(agents)
.set({
status: 'idle',
currentTaskId: null,
tasksCompleted: db.sql`${agents.tasksCompleted} + 1`,
})
.where(eq(agents.id, task.assignedAgentId))
}
}
return Response.json({
success: true,
message: `Task ${taskId} updated to ${status}`,
})
} catch (error: any) {
console.error('update_task_status error:', error)
return Response.json(
{ success: false, message: error.message },
{ status: 500 }
)
}
}
/**
* Create branch
*/
async function handleCreateBranch(req: Request): Promise<Response> {
try {
const body = await req.json()
const { taskId, branchName } = body
if (!taskId || !branchName) {
return Response.json(
{ success: false, message: 'taskId and branchName are required' },
{ status: 400 }
)
}
await db
.update(tasks)
.set({
branchName,
})
.where(eq(tasks.id, taskId))
return Response.json({
success: true,
message: `Branch ${branchName} created for task ${taskId}`,
})
} catch (error: any) {
console.error('create_branch error:', error)
return Response.json(
{ success: false, message: error.message },
{ status: 500 }
)
}
}
/**
* Create pull request
*/
async function handleCreatePullRequest(req: Request): Promise<Response> {
try {
const body = await req.json()
const { taskId, title, description, branchName } = body
if (!taskId || !title || !branchName) {
return Response.json(
{ success: false, message: 'taskId, title, and branchName are required' },
{ status: 400 }
)
}
// 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}`
await db
.update(tasks)
.set({
prUrl,
state: 'ready_to_test',
})
.where(eq(tasks.id, taskId))
return Response.json({
success: true,
message: 'Pull request created',
data: {
prUrl,
},
})
} catch (error: any) {
console.error('create_pull_request error:', error)
return Response.json(
{ success: false, message: error.message },
{ status: 500 }
)
}
}
/**
* Ask user question
*/
async function handleAskUserQuestion(req: Request): Promise<Response> {
try {
const body = await req.json()
const { taskId, question } = body
if (!taskId || !question) {
return Response.json(
{ success: false, message: 'taskId and question are required' },
{ status: 400 }
)
}
await db
.update(tasks)
.set({
state: 'needs_input',
errorMessage: question,
})
.where(eq(tasks.id, taskId))
return Response.json({
success: true,
message: 'Question saved. Task marked as needs_input.',
})
} catch (error: any) {
console.error('ask_user_question error:', error)
return Response.json(
{ success: false, message: error.message },
{ status: 500 }
)
}
}

247
src/api/routes/projects.ts Normal file
View File

@@ -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<Response> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
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 })
}
}

328
src/api/routes/tasks.ts Normal file
View File

@@ -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<Response> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
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 })
}
}

View File

@@ -0,0 +1,60 @@
/**
* Apply pending migrations directly
*/
import mysql from 'mysql2/promise'
import { readFileSync } from 'fs'
import { join } from 'path'
async function applyMigrations() {
console.log('📝 Applying pending migrations...')
const connection = await mysql.createConnection({
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT || '3306'),
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
})
try {
// Read the latest migration file
const migrationPath = join(import.meta.dir, '../../drizzle/migrations/0001_opposite_warbird.sql')
const migrationSQL = readFileSync(migrationPath, 'utf-8')
// Split by statement-breakpoint and execute each statement
const statements = migrationSQL
.split('-->')
.map((s) => s.trim())
.filter((s) => s && !s.startsWith('statement-breakpoint'))
console.log(`Found ${statements.length} statements to execute`)
for (const statement of statements) {
if (statement) {
try {
await connection.execute(statement)
console.log('✅ Executed statement')
} catch (error: any) {
// Skip if table already exists
if (error.code === 'ER_TABLE_EXISTS_ERROR' || error.errno === 1050) {
console.log('⚠️ Table already exists, skipping...')
} else if (error.code === 'ER_DUP_KEYNAME' || error.errno === 1061) {
console.log('⚠️ Index already exists, skipping...')
} else {
throw error
}
}
}
}
console.log('✅ Migrations applied successfully')
await connection.end()
} catch (error) {
console.error('❌ Migration failed:', error)
await connection.end()
throw error
}
}
await applyMigrations()

76
src/db/force-migrate.ts Normal file
View File

@@ -0,0 +1,76 @@
/**
* Force apply all migration statements
*/
import mysql from 'mysql2/promise'
const statements = [
`CREATE TABLE IF NOT EXISTS \`sessions\` (
\`id\` varchar(255) NOT NULL,
\`user_id\` varchar(36) NOT NULL,
\`expires_at\` timestamp NOT NULL,
\`created_at\` timestamp DEFAULT (now()),
CONSTRAINT \`sessions_id\` PRIMARY KEY(\`id\`)
)`,
`CREATE TABLE IF NOT EXISTS \`users\` (
\`id\` varchar(36) NOT NULL,
\`email\` varchar(255) NOT NULL,
\`username\` varchar(100) NOT NULL,
\`password_hash\` varchar(255) NOT NULL,
\`created_at\` timestamp DEFAULT (now()),
\`updated_at\` timestamp DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT \`users_id\` PRIMARY KEY(\`id\`),
CONSTRAINT \`users_email_unique\` UNIQUE(\`email\`),
CONSTRAINT \`users_username_unique\` UNIQUE(\`username\`)
)`,
`ALTER TABLE \`sessions\`
ADD CONSTRAINT \`sessions_user_id_users_id_fk\`
FOREIGN KEY (\`user_id\`) REFERENCES \`users\`(\`id\`)
ON DELETE cascade ON UPDATE no action`,
`CREATE INDEX IF NOT EXISTS \`idx_user_id\` ON \`sessions\` (\`user_id\`)`,
`CREATE INDEX IF NOT EXISTS \`idx_expires_at\` ON \`sessions\` (\`expires_at\`)`,
`CREATE INDEX IF NOT EXISTS \`idx_email\` ON \`users\` (\`email\`)`,
]
async function forceMigrate() {
console.log('🔧 Force migrating database...')
const connection = await mysql.createConnection({
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT || '3306'),
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
})
try {
for (const [index, statement] of statements.entries()) {
console.log(`Executing statement ${index + 1}/${statements.length}...`)
try {
await connection.execute(statement)
console.log('✅ Success')
} catch (error: any) {
if (
error.code === 'ER_TABLE_EXISTS_ERROR' ||
error.code === 'ER_DUP_KEYNAME' ||
error.errno === 1050 ||
error.errno === 1061
) {
console.log('⚠️ Already exists, skipping...')
} else {
console.error('Error:', error.message)
// Continue with other statements
}
}
}
console.log('✅ All migrations applied')
await connection.end()
} catch (error) {
console.error('❌ Migration failed:', error)
await connection.end()
throw error
}
}
await forceMigrate()

View File

@@ -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

View File

@@ -17,6 +17,39 @@ import {
index,
} from 'drizzle-orm/mysql-core'
// ============================================
// USERS TABLE
// ============================================
export const users = mysqlTable('users', {
id: varchar('id', { length: 36 }).primaryKey(),
email: varchar('email', { length: 255 }).notNull().unique(),
username: varchar('username', { length: 100 }).notNull().unique(),
passwordHash: varchar('password_hash', { length: 255 }).notNull(),
// Timestamps
createdAt: timestamp('created_at').defaultNow(),
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(),
}, (table) => ({
emailIdx: index('idx_email').on(table.email),
}))
// ============================================
// SESSIONS TABLE
// ============================================
export const sessions = mysqlTable('sessions', {
id: varchar('id', { length: 255 }).primaryKey(),
userId: varchar('user_id', { length: 36 }).notNull().references(() => users.id, { onDelete: 'cascade' }),
expiresAt: timestamp('expires_at').notNull(),
// Timestamps
createdAt: timestamp('created_at').defaultNow(),
}, (table) => ({
userIdIdx: index('idx_user_id').on(table.userId),
expiresAtIdx: index('idx_expires_at').on(table.expiresAt),
}))
// ============================================
// PROJECTS TABLE
// ============================================

View File

@@ -5,6 +5,7 @@
import { runMigrations } from './db/migrate'
import { testConnection } from './db/client'
import { handleAuthRoutes, handleProjectRoutes, handleTaskRoutes, handleAgentRoutes, handleMCPRoutes } from './api/routes'
console.log('🚀 Starting AiWorker Backend...')
console.log(`Bun version: ${Bun.version}`)
@@ -40,12 +41,58 @@ const server = Bun.serve({
return handleHealthCheck()
}
// Auth routes
if (url.pathname.startsWith('/api/auth')) {
return handleAuthRoutes(req, url)
}
// 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)
}
// MCP routes for agent communication
if (url.pathname.startsWith('/api/mcp')) {
return handleMCPRoutes(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',
'POST /api/auth/register',
'POST /api/auth/login',
'POST /api/auth/logout',
'GET /api/auth/me',
'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',
],
})
}

327
src/mcp/server.ts Normal file
View File

@@ -0,0 +1,327 @@
/**
* MCP Server for Agent Communication
* Port 3100
*
* Provides tools for Claude Code agents:
* - get_next_task: Get next available task
* - update_task_status: Update task state
* - create_branch: Create Git branch for task
* - create_pull_request: Create PR in Gitea
*/
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import {
CallToolRequestSchema,
ListToolsRequestSchema,
ToolSchema,
} from '@modelcontextprotocol/sdk/types.js'
import { db } from '../db/client'
import { tasks, agents } from '../db/schema'
import { eq, and } from 'drizzle-orm'
// MCP Server instance
const server = new Server(
{
name: 'aiworker-mcp-server',
version: '1.0.0',
},
{
capabilities: {
tools: {},
},
}
)
/**
* List available tools
*/
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: 'get_next_task',
description: 'Get the next available task from the backlog',
inputSchema: {
type: 'object' as const,
properties: {
agentId: {
type: 'string',
description: 'ID of the agent requesting the task',
},
},
required: ['agentId'],
},
},
{
name: 'update_task_status',
description: 'Update the status of a task',
inputSchema: {
type: 'object' as const,
properties: {
taskId: {
type: 'string',
description: 'ID of the task to update',
},
status: {
type: 'string',
enum: ['backlog', 'in_progress', 'needs_input', 'ready_to_test', 'approved', 'staging', 'production'],
description: 'New status for the task',
},
errorMessage: {
type: 'string',
description: 'Optional error message if task failed',
},
},
required: ['taskId', 'status'],
},
},
{
name: 'create_branch',
description: 'Create a Git branch for a task',
inputSchema: {
type: 'object' as const,
properties: {
taskId: {
type: 'string',
description: 'ID of the task',
},
branchName: {
type: 'string',
description: 'Name of the branch to create',
},
},
required: ['taskId', 'branchName'],
},
},
{
name: 'create_pull_request',
description: 'Create a pull request in Gitea for a task',
inputSchema: {
type: 'object' as const,
properties: {
taskId: {
type: 'string',
description: 'ID of the task',
},
title: {
type: 'string',
description: 'PR title',
},
description: {
type: 'string',
description: 'PR description',
},
branchName: {
type: 'string',
description: 'Source branch name',
},
},
required: ['taskId', 'title', 'branchName'],
},
},
{
name: 'ask_user_question',
description: 'Ask the user a question when clarification is needed',
inputSchema: {
type: 'object' as const,
properties: {
taskId: {
type: 'string',
description: 'ID of the task',
},
question: {
type: 'string',
description: 'Question to ask the user',
},
},
required: ['taskId', 'question'],
},
},
],
}
})
/**
* Handle tool calls
*/
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params
try {
switch (name) {
case 'get_next_task':
return await handleGetNextTask(args as any)
case 'update_task_status':
return await handleUpdateTaskStatus(args as any)
case 'create_branch':
return await handleCreateBranch(args as any)
case 'create_pull_request':
return await handleCreatePullRequest(args as any)
case 'ask_user_question':
return await handleAskUserQuestion(args as any)
default:
throw new Error(`Unknown tool: ${name}`)
}
} catch (error: any) {
return {
content: [
{
type: 'text' as const,
text: `Error: ${error.message}`,
},
],
isError: true,
}
}
})
/**
* Get next available task
*/
async function handleGetNextTask(args: { agentId: string }) {
const [task] = await db
.select()
.from(tasks)
.where(eq(tasks.state, 'backlog'))
.orderBy(tasks.priority, tasks.createdAt)
.limit(1)
if (!task) {
return {
content: [
{
type: 'text' as const,
text: 'No tasks available in backlog',
},
],
}
}
// Assign task to agent
await db.update(tasks).set({
state: 'in_progress',
assignedAgentId: args.agentId,
}).where(eq(tasks.id, task.id))
// Update agent status
await db.update(agents).set({
status: 'busy',
currentTaskId: task.id,
}).where(eq(agents.id, args.agentId))
return {
content: [
{
type: 'text' as const,
text: JSON.stringify(task, null, 2),
},
],
}
}
/**
* Update task status
*/
async function handleUpdateTaskStatus(args: {
taskId: string
status: string
errorMessage?: string
}) {
await db.update(tasks).set({
state: args.status as any,
...(args.errorMessage && { errorMessage: args.errorMessage }),
}).where(eq(tasks.id, args.taskId))
return {
content: [
{
type: 'text' as const,
text: `Task ${args.taskId} updated to ${args.status}`,
},
],
}
}
/**
* Create branch
*/
async function handleCreateBranch(args: { taskId: string; branchName: string }) {
await db.update(tasks).set({
branchName: args.branchName,
}).where(eq(tasks.id, args.taskId))
return {
content: [
{
type: 'text' as const,
text: `Branch ${args.branchName} created for task ${args.taskId}`,
},
],
}
}
/**
* Create pull request
*/
async function handleCreatePullRequest(args: {
taskId: string
title: string
description?: string
branchName: string
}) {
// TODO: Integrate with Gitea API to actually create PR
const prUrl = `https://git.fuq.tv/pulls/${args.taskId}`
await db.update(tasks).set({
prUrl,
state: 'ready_to_test',
}).where(eq(tasks.id, args.taskId))
return {
content: [
{
type: 'text' as const,
text: `Pull request created: ${prUrl}`,
},
],
}
}
/**
* Ask user question
*/
async function handleAskUserQuestion(args: { taskId: string; question: string }) {
await db.update(tasks).set({
state: 'needs_input',
errorMessage: args.question,
}).where(eq(tasks.id, args.taskId))
return {
content: [
{
type: 'text' as const,
text: `Question saved for task ${args.taskId}. Task marked as needs_input.`,
},
],
}
}
/**
* Start MCP server
*/
async function main() {
const transport = new StdioServerTransport()
await server.connect(transport)
console.error('MCP server running on stdio')
}
main().catch((error) => {
console.error('Fatal error in MCP server:', error)
process.exit(1)
})

View File

@@ -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
}
}

178
src/services/mcp/server.ts Normal file
View File

@@ -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()
}

View File

@@ -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}`)
}
}

View File

@@ -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}`)
}
}

View File

@@ -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}`)
}
}

View File

@@ -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<string, any>
}
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}`)
}
}

37
test-auth.ts Normal file
View File

@@ -0,0 +1,37 @@
/**
* Test authentication directly
*/
import { db } from './src/db/client'
import { users } from './src/db/schema'
import { randomUUID } from 'crypto'
import bcrypt from 'bcryptjs'
async function testAuth() {
try {
console.log('Testing auth...')
// Hash password
const passwordHash = await bcrypt.hash('test123', 10)
console.log('Password hash:', passwordHash)
// Insert user
const userId = randomUUID()
console.log('Inserting user with ID:', userId)
await db.insert(users).values({
id: userId,
email: 'test@test.com',
username: 'testuser',
passwordHash,
})
console.log('User inserted successfully!')
} catch (error) {
console.error('Error:', error)
}
process.exit(0)
}
testAuth()