411 lines
13 KiB
TypeScript
411 lines
13 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server';
|
|
import { getDatabase, Agent, db_helpers } from '@/lib/db';
|
|
import { eventBus } from '@/lib/event-bus';
|
|
import { getTemplate, buildAgentConfig } from '@/lib/agent-templates';
|
|
import { writeAgentToConfig, enrichAgentConfigFromWorkspace } from '@/lib/agent-sync';
|
|
import { logAuditEvent } from '@/lib/db';
|
|
import { requireRole } from '@/lib/auth';
|
|
import { mutationLimiter } from '@/lib/rate-limit';
|
|
import { logger } from '@/lib/logger';
|
|
import { validateBody, createAgentSchema } from '@/lib/validation';
|
|
import { runOpenClaw } from '@/lib/command';
|
|
import { config as appConfig } from '@/lib/config';
|
|
import { resolveWithin } from '@/lib/paths';
|
|
import path from 'node:path';
|
|
|
|
/**
|
|
* GET /api/agents - List all agents with optional filtering
|
|
* Query params: status, role, limit, offset
|
|
*/
|
|
export async function GET(request: NextRequest) {
|
|
const auth = requireRole(request, 'viewer')
|
|
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
|
|
|
|
try {
|
|
const db = getDatabase();
|
|
const { searchParams } = new URL(request.url);
|
|
const workspaceId = auth.user.workspace_id ?? 1;
|
|
|
|
// Parse query parameters
|
|
const status = searchParams.get('status');
|
|
const role = searchParams.get('role');
|
|
const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 200);
|
|
const offset = parseInt(searchParams.get('offset') || '0');
|
|
|
|
// Build dynamic query
|
|
let query = 'SELECT * FROM agents WHERE workspace_id = ?';
|
|
const params: any[] = [workspaceId];
|
|
|
|
if (status) {
|
|
query += ' AND status = ?';
|
|
params.push(status);
|
|
}
|
|
|
|
if (role) {
|
|
query += ' AND role = ?';
|
|
params.push(role);
|
|
}
|
|
|
|
query += ' ORDER BY created_at DESC LIMIT ? OFFSET ?';
|
|
params.push(limit, offset);
|
|
|
|
const stmt = db.prepare(query);
|
|
const agents = stmt.all(...params) as Agent[];
|
|
|
|
// Parse JSON config field
|
|
const agentsWithParsedData = agents.map(agent => ({
|
|
...agent,
|
|
config: enrichAgentConfigFromWorkspace(agent.config ? JSON.parse(agent.config) : {})
|
|
}));
|
|
|
|
// Get task counts for each agent (prepare once, reuse per agent)
|
|
const taskCountStmt = db.prepare(`
|
|
SELECT
|
|
COUNT(*) as total,
|
|
SUM(CASE WHEN status = 'assigned' THEN 1 ELSE 0 END) as assigned,
|
|
SUM(CASE WHEN status = 'in_progress' THEN 1 ELSE 0 END) as in_progress,
|
|
SUM(CASE WHEN status = 'done' THEN 1 ELSE 0 END) as completed
|
|
FROM tasks
|
|
WHERE assigned_to = ? AND workspace_id = ?
|
|
`);
|
|
|
|
const agentsWithStats = agentsWithParsedData.map(agent => {
|
|
const taskStats = taskCountStmt.get(agent.name, workspaceId) as any;
|
|
|
|
return {
|
|
...agent,
|
|
taskStats: {
|
|
total: taskStats.total || 0,
|
|
assigned: taskStats.assigned || 0,
|
|
in_progress: taskStats.in_progress || 0,
|
|
completed: taskStats.completed || 0
|
|
}
|
|
};
|
|
});
|
|
|
|
// Get total count for pagination
|
|
let countQuery = 'SELECT COUNT(*) as total FROM agents WHERE workspace_id = ?';
|
|
const countParams: any[] = [workspaceId];
|
|
if (status) {
|
|
countQuery += ' AND status = ?';
|
|
countParams.push(status);
|
|
}
|
|
if (role) {
|
|
countQuery += ' AND role = ?';
|
|
countParams.push(role);
|
|
}
|
|
const countRow = db.prepare(countQuery).get(...countParams) as { total: number };
|
|
|
|
return NextResponse.json({
|
|
agents: agentsWithStats,
|
|
total: countRow.total,
|
|
page: Math.floor(offset / limit) + 1,
|
|
limit
|
|
});
|
|
} catch (error) {
|
|
logger.error({ err: error }, 'GET /api/agents error');
|
|
return NextResponse.json({ error: 'Failed to fetch agents' }, { status: 500 });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* POST /api/agents - Create a new agent
|
|
*/
|
|
export async function POST(request: NextRequest) {
|
|
const auth = requireRole(request, 'operator');
|
|
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status });
|
|
|
|
const rateCheck = mutationLimiter(request);
|
|
if (rateCheck) return rateCheck;
|
|
|
|
try {
|
|
const db = getDatabase();
|
|
const workspaceId = auth.user.workspace_id ?? 1;
|
|
const validated = await validateBody(request, createAgentSchema);
|
|
if ('error' in validated) return validated.error;
|
|
const body = validated.data;
|
|
|
|
const {
|
|
name,
|
|
openclaw_id,
|
|
role,
|
|
session_key,
|
|
soul_content,
|
|
status = 'offline',
|
|
config = {},
|
|
template,
|
|
gateway_config,
|
|
write_to_gateway,
|
|
provision_openclaw_workspace,
|
|
openclaw_workspace_path
|
|
} = body;
|
|
|
|
const openclawId = (openclaw_id || name || 'agent')
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/^-|-$/g, '');
|
|
|
|
// Resolve template if specified
|
|
let finalRole = role;
|
|
let finalConfig: Record<string, any> = { ...config };
|
|
if (template) {
|
|
const tpl = getTemplate(template);
|
|
if (tpl) {
|
|
const builtConfig = buildAgentConfig(tpl, (gateway_config || {}) as any);
|
|
finalConfig = { ...builtConfig, ...finalConfig };
|
|
if (!finalRole) finalRole = tpl.config.identity?.theme || tpl.type;
|
|
}
|
|
} else if (gateway_config) {
|
|
finalConfig = { ...finalConfig, ...(gateway_config as Record<string, any>) };
|
|
}
|
|
|
|
if (!name || !finalRole) {
|
|
return NextResponse.json({ error: 'Name and role are required' }, { status: 400 });
|
|
}
|
|
|
|
// Check if agent name already exists
|
|
const existingAgent = db
|
|
.prepare('SELECT id FROM agents WHERE name = ? AND workspace_id = ?')
|
|
.get(name, workspaceId);
|
|
if (existingAgent) {
|
|
return NextResponse.json({ error: 'Agent name already exists' }, { status: 409 });
|
|
}
|
|
|
|
if (provision_openclaw_workspace) {
|
|
if (!appConfig.openclawStateDir) {
|
|
return NextResponse.json(
|
|
{ error: 'OPENCLAW_STATE_DIR is not configured; cannot provision OpenClaw workspace' },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
|
|
const workspacePath = openclaw_workspace_path
|
|
? path.resolve(openclaw_workspace_path)
|
|
: resolveWithin(appConfig.openclawStateDir, path.join('workspaces', openclawId));
|
|
|
|
try {
|
|
await runOpenClaw(
|
|
['agents', 'add', openclawId, '--name', name, '--workspace', workspacePath, '--non-interactive'],
|
|
{ timeoutMs: 20000 }
|
|
);
|
|
} catch (provisionError: any) {
|
|
logger.error({ err: provisionError, openclawId, workspacePath }, 'OpenClaw workspace provisioning failed');
|
|
return NextResponse.json(
|
|
{ error: provisionError?.message || 'Failed to provision OpenClaw agent workspace' },
|
|
{ status: 502 }
|
|
);
|
|
}
|
|
}
|
|
|
|
const now = Math.floor(Date.now() / 1000);
|
|
|
|
const stmt = db.prepare(`
|
|
INSERT INTO agents (
|
|
name, role, session_key, soul_content, status,
|
|
created_at, updated_at, config, workspace_id
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`);
|
|
|
|
const dbResult = stmt.run(
|
|
name,
|
|
finalRole,
|
|
session_key,
|
|
soul_content,
|
|
status,
|
|
now,
|
|
now,
|
|
JSON.stringify(finalConfig),
|
|
workspaceId
|
|
);
|
|
|
|
const agentId = dbResult.lastInsertRowid as number;
|
|
|
|
// Log activity
|
|
db_helpers.logActivity(
|
|
'agent_created',
|
|
'agent',
|
|
agentId,
|
|
auth.user.username,
|
|
`Created agent: ${name} (${finalRole})${template ? ` from template: ${template}` : ''}`,
|
|
{
|
|
name,
|
|
role: finalRole,
|
|
status,
|
|
session_key,
|
|
template: template || null
|
|
},
|
|
workspaceId
|
|
);
|
|
|
|
// Fetch the created agent
|
|
const createdAgent = db
|
|
.prepare('SELECT * FROM agents WHERE id = ? AND workspace_id = ?')
|
|
.get(agentId, workspaceId) as Agent;
|
|
const parsedAgent = {
|
|
...createdAgent,
|
|
config: JSON.parse(createdAgent.config || '{}'),
|
|
taskStats: { total: 0, assigned: 0, in_progress: 0, completed: 0 }
|
|
};
|
|
|
|
// Broadcast to SSE clients
|
|
eventBus.broadcast('agent.created', parsedAgent);
|
|
|
|
// Write to gateway config if requested
|
|
if (write_to_gateway && finalConfig) {
|
|
try {
|
|
await writeAgentToConfig({
|
|
id: openclawId,
|
|
name,
|
|
...(finalConfig.model && { model: finalConfig.model }),
|
|
...(finalConfig.identity && { identity: finalConfig.identity }),
|
|
...(finalConfig.sandbox && { sandbox: finalConfig.sandbox }),
|
|
...(finalConfig.tools && { tools: finalConfig.tools }),
|
|
...(finalConfig.subagents && { subagents: finalConfig.subagents }),
|
|
...(finalConfig.memorySearch && { memorySearch: finalConfig.memorySearch }),
|
|
});
|
|
|
|
const ipAddress = request.headers.get('x-forwarded-for') || 'unknown';
|
|
logAuditEvent({
|
|
action: 'agent_gateway_create',
|
|
actor: auth.user.username,
|
|
actor_id: auth.user.id,
|
|
target_type: 'agent',
|
|
target_id: agentId as number,
|
|
detail: { name, openclaw_id: openclawId, template: template || null },
|
|
ip_address: ipAddress,
|
|
});
|
|
} catch (gwErr: any) {
|
|
logger.error({ err: gwErr }, 'Gateway write-back failed');
|
|
return NextResponse.json({
|
|
agent: parsedAgent,
|
|
warning: `Agent created in MC but gateway write failed: ${gwErr.message}`
|
|
}, { status: 201 });
|
|
}
|
|
}
|
|
|
|
return NextResponse.json({ agent: parsedAgent }, { status: 201 });
|
|
} catch (error) {
|
|
logger.error({ err: error }, 'POST /api/agents error');
|
|
return NextResponse.json({ error: 'Failed to create agent' }, { status: 500 });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* PUT /api/agents - Update agent status (bulk operation for status updates)
|
|
*/
|
|
export async function PUT(request: NextRequest) {
|
|
const auth = requireRole(request, 'operator');
|
|
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status });
|
|
|
|
const rateCheck = mutationLimiter(request);
|
|
if (rateCheck) return rateCheck;
|
|
|
|
try {
|
|
const db = getDatabase();
|
|
const workspaceId = auth.user.workspace_id ?? 1;
|
|
const body = await request.json();
|
|
|
|
// Handle single agent update or bulk updates
|
|
if (body.name) {
|
|
// Single agent update
|
|
const { name, status, last_activity, config, session_key, soul_content, role } = body;
|
|
|
|
const agent = db
|
|
.prepare('SELECT * FROM agents WHERE name = ? AND workspace_id = ?')
|
|
.get(name, workspaceId) as Agent;
|
|
if (!agent) {
|
|
return NextResponse.json({ error: 'Agent not found' }, { status: 404 });
|
|
}
|
|
|
|
const now = Math.floor(Date.now() / 1000);
|
|
|
|
// Build dynamic update query
|
|
const fieldsToUpdate = [];
|
|
const params: any[] = [];
|
|
|
|
if (status !== undefined) {
|
|
fieldsToUpdate.push('status = ?');
|
|
params.push(status);
|
|
|
|
fieldsToUpdate.push('last_seen = ?');
|
|
params.push(now);
|
|
}
|
|
|
|
if (last_activity !== undefined) {
|
|
fieldsToUpdate.push('last_activity = ?');
|
|
params.push(last_activity);
|
|
}
|
|
|
|
if (config !== undefined) {
|
|
fieldsToUpdate.push('config = ?');
|
|
params.push(JSON.stringify(config));
|
|
}
|
|
|
|
if (session_key !== undefined) {
|
|
fieldsToUpdate.push('session_key = ?');
|
|
params.push(session_key);
|
|
}
|
|
|
|
if (soul_content !== undefined) {
|
|
fieldsToUpdate.push('soul_content = ?');
|
|
params.push(soul_content);
|
|
}
|
|
|
|
if (role !== undefined) {
|
|
fieldsToUpdate.push('role = ?');
|
|
params.push(role);
|
|
}
|
|
|
|
fieldsToUpdate.push('updated_at = ?');
|
|
params.push(now);
|
|
params.push(name, workspaceId);
|
|
|
|
if (fieldsToUpdate.length === 1) { // Only updated_at
|
|
return NextResponse.json({ error: 'No fields to update' }, { status: 400 });
|
|
}
|
|
|
|
const stmt = db.prepare(`
|
|
UPDATE agents
|
|
SET ${fieldsToUpdate.join(', ')}
|
|
WHERE name = ? AND workspace_id = ?
|
|
`);
|
|
|
|
stmt.run(...params);
|
|
|
|
// Log status change if status was updated
|
|
if (status !== undefined && status !== agent.status) {
|
|
db_helpers.logActivity(
|
|
'agent_status_change',
|
|
'agent',
|
|
agent.id,
|
|
name,
|
|
`Agent status changed from ${agent.status} to ${status}`,
|
|
{
|
|
oldStatus: agent.status,
|
|
newStatus: status,
|
|
last_activity
|
|
},
|
|
workspaceId
|
|
);
|
|
}
|
|
|
|
// Broadcast update to SSE clients
|
|
eventBus.broadcast('agent.updated', {
|
|
id: agent.id,
|
|
name,
|
|
...(status !== undefined && { status }),
|
|
...(last_activity !== undefined && { last_activity }),
|
|
...(role !== undefined && { role }),
|
|
updated_at: now,
|
|
});
|
|
|
|
return NextResponse.json({ success: true });
|
|
} else {
|
|
return NextResponse.json({ error: 'Agent name is required' }, { status: 400 });
|
|
}
|
|
} catch (error) {
|
|
logger.error({ err: error }, 'PUT /api/agents error');
|
|
return NextResponse.json({ error: 'Failed to update agent' }, { status: 500 });
|
|
}
|
|
}
|