All checks were successful
Build and Push Backend / build (push) Successful in 5s
- Add userId to agents and projects tables (with migrations) - Create auth middleware for session validation - Protect MCP endpoints with authentication and user filtering - Implement agent management API (launch, my, delete) - Add terminal proxy at /agent-terminal/:agentId with auth - Update all agent endpoints to verify user ownership Co-Authored-By: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>
416 lines
10 KiB
TypeScript
416 lines
10 KiB
TypeScript
/**
|
|
* 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, and } from 'drizzle-orm'
|
|
import { randomUUID } from 'crypto'
|
|
import { authenticateRequest } from '../middleware/auth'
|
|
|
|
/**
|
|
* 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)
|
|
|
|
// Authenticate user for protected endpoints
|
|
const publicEndpoints = ['heartbeat']
|
|
const isPublic = pathParts.length === 4 && publicEndpoints.includes(pathParts[3])
|
|
|
|
let auth = null
|
|
if (!isPublic) {
|
|
auth = await authenticateRequest(req)
|
|
if (!auth) {
|
|
return Response.json(
|
|
{ success: false, message: 'Unauthorized' },
|
|
{ status: 401 }
|
|
)
|
|
}
|
|
}
|
|
|
|
const userId = auth?.userId
|
|
|
|
// GET /api/agents/my - List my agents (authenticated)
|
|
if (method === 'GET' && pathParts.length === 3 && pathParts[2] === 'my') {
|
|
return await listMyAgents(userId!)
|
|
}
|
|
|
|
// POST /api/agents/launch - Launch new agent (authenticated)
|
|
if (method === 'POST' && pathParts.length === 3 && pathParts[2] === 'launch') {
|
|
return await launchAgent(userId!, req)
|
|
}
|
|
|
|
// GET /api/agents - List all agents (admin only - for now authenticated user sees all)
|
|
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, userId!)
|
|
}
|
|
|
|
// POST /api/agents - Register agent (called by agent pod on startup)
|
|
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, userId!)
|
|
}
|
|
|
|
// POST /api/agents/:id/heartbeat - Update heartbeat (public)
|
|
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, userId!)
|
|
}
|
|
|
|
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 (verify ownership)
|
|
*/
|
|
async function getAgent(agentId: string, userId: string): Promise<Response> {
|
|
try {
|
|
const agent = await db
|
|
.select()
|
|
.from(agents)
|
|
.where(
|
|
and(
|
|
eq(agents.id, agentId),
|
|
eq(agents.userId, userId)
|
|
)
|
|
)
|
|
.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)
|
|
* userId is extracted from env var passed to pod
|
|
*/
|
|
async function registerAgent(req: Request): Promise<Response> {
|
|
try {
|
|
const body = await req.json()
|
|
|
|
// Validate required fields
|
|
if (!body.podName || !body.userId) {
|
|
return Response.json({
|
|
success: false,
|
|
error: 'podName and userId are 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(),
|
|
userId: body.userId,
|
|
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 (verify ownership)
|
|
*/
|
|
async function updateAgent(agentId: string, req: Request, userId: string): Promise<Response> {
|
|
try {
|
|
const body = await req.json()
|
|
|
|
// Check if agent exists and belongs to user
|
|
const existing = await db
|
|
.select()
|
|
.from(agents)
|
|
.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 or access denied',
|
|
}, { 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, verify ownership)
|
|
*/
|
|
async function unregisterAgent(agentId: string, userId: string): Promise<Response> {
|
|
try {
|
|
// Check if agent exists and belongs to user
|
|
const existing = await db
|
|
.select()
|
|
.from(agents)
|
|
.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 or access denied',
|
|
}, { 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 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',
|
|
})
|
|
} catch (error) {
|
|
console.error('Error unregistering agent:', error)
|
|
return Response.json({
|
|
success: false,
|
|
error: 'Failed to unregister agent',
|
|
}, { status: 500 })
|
|
}
|
|
}
|
|
|
|
/**
|
|
* List my agents (filtered by userId)
|
|
*/
|
|
async function listMyAgents(userId: string): Promise<Response> {
|
|
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<Response> {
|
|
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 })
|
|
}
|
|
}
|