- Trocar ícone archive (lixeira) por download em todos os botões - Adicionar botão de excluir com ícone trash-2 em cada entrada - Rota DELETE /api/files com proteção contra exclusão da raiz - Confirmação via modal antes de excluir
1164 lines
39 KiB
JavaScript
1164 lines
39 KiB
JavaScript
import { Router } from 'express';
|
|
import { execFile, spawn as spawnProcess } from 'child_process';
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
import crypto from 'crypto';
|
|
import os from 'os';
|
|
import multer from 'multer';
|
|
import * as manager from '../agents/manager.js';
|
|
import { tasksStore, settingsStore, executionsStore, webhooksStore, notificationsStore, secretsStore, agentVersionsStore } from '../store/db.js';
|
|
import * as scheduler from '../agents/scheduler.js';
|
|
import * as pipeline from '../agents/pipeline.js';
|
|
import { getBinPath, updateMaxConcurrent, cancelAllExecutions, getActiveExecutions } from '../agents/executor.js';
|
|
import { invalidateAgentMapCache } from '../agents/pipeline.js';
|
|
import { cached } from '../cache/index.js';
|
|
import { readdirSync, readFileSync, unlinkSync, existsSync, mkdirSync, statSync, createReadStream, rmSync } from 'fs';
|
|
import { join, dirname, resolve as pathResolve, extname, basename, relative } from 'path';
|
|
import { createGzip } from 'zlib';
|
|
import { Readable } from 'stream';
|
|
import { fileURLToPath } from 'url';
|
|
|
|
const __apiDirname = dirname(fileURLToPath(import.meta.url));
|
|
const REPORTS_DIR = join(__apiDirname, '..', '..', 'data', 'reports');
|
|
const UPLOADS_DIR = join(__apiDirname, '..', '..', 'data', 'uploads');
|
|
|
|
if (!existsSync(UPLOADS_DIR)) mkdirSync(UPLOADS_DIR, { recursive: true });
|
|
|
|
const upload = multer({
|
|
storage: multer.diskStorage({
|
|
destination: (req, file, cb) => {
|
|
const sessionDir = join(UPLOADS_DIR, req.uploadSessionId || 'tmp');
|
|
if (!existsSync(sessionDir)) mkdirSync(sessionDir, { recursive: true });
|
|
cb(null, sessionDir);
|
|
},
|
|
filename: (req, file, cb) => {
|
|
const safe = file.originalname.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 200);
|
|
cb(null, `${Date.now()}-${safe}`);
|
|
},
|
|
}),
|
|
limits: { fileSize: 10 * 1024 * 1024, files: 20 },
|
|
});
|
|
|
|
const router = Router();
|
|
export const hookRouter = Router();
|
|
|
|
let wsbroadcast = null;
|
|
let wsBroadcastTo = null;
|
|
|
|
export function setWsBroadcast(fn) {
|
|
wsbroadcast = fn;
|
|
}
|
|
|
|
export function setWsBroadcastTo(fn) {
|
|
wsBroadcastTo = fn;
|
|
}
|
|
|
|
function wsCallback(message, clientId) {
|
|
if (clientId && wsBroadcastTo) wsBroadcastTo(clientId, message);
|
|
else if (wsbroadcast) wsbroadcast(message);
|
|
}
|
|
|
|
router.get('/settings', (req, res) => {
|
|
try {
|
|
res.json(settingsStore.get());
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.put('/settings', (req, res) => {
|
|
try {
|
|
const allowed = ['defaultModel', 'defaultWorkdir', 'maxConcurrent'];
|
|
const data = {};
|
|
for (const key of allowed) {
|
|
if (req.body[key] !== undefined) data[key] = req.body[key];
|
|
}
|
|
if (data.maxConcurrent !== undefined) {
|
|
data.maxConcurrent = Math.max(1, Math.min(20, parseInt(data.maxConcurrent) || 5));
|
|
updateMaxConcurrent(data.maxConcurrent);
|
|
}
|
|
const saved = settingsStore.save(data);
|
|
res.json(saved);
|
|
} catch (err) {
|
|
res.status(400).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.get('/agents', (req, res) => {
|
|
try {
|
|
res.json(manager.getAllAgents());
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.get('/agents/:id', (req, res) => {
|
|
try {
|
|
const agent = manager.getAgentById(req.params.id);
|
|
if (!agent) return res.status(404).json({ error: 'Agente não encontrado' });
|
|
res.json(agent);
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.post('/agents', (req, res) => {
|
|
try {
|
|
const agent = manager.createAgent(req.body);
|
|
invalidateAgentMapCache();
|
|
res.status(201).json(agent);
|
|
} catch (err) {
|
|
res.status(400).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.post('/agents/import', (req, res) => {
|
|
try {
|
|
const agent = manager.importAgent(req.body);
|
|
invalidateAgentMapCache();
|
|
res.status(201).json(agent);
|
|
} catch (err) {
|
|
res.status(400).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.put('/agents/:id', (req, res) => {
|
|
try {
|
|
const agent = manager.updateAgent(req.params.id, req.body);
|
|
if (!agent) return res.status(404).json({ error: 'Agente não encontrado' });
|
|
invalidateAgentMapCache();
|
|
res.json(agent);
|
|
} catch (err) {
|
|
res.status(400).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.delete('/agents/:id', (req, res) => {
|
|
try {
|
|
const deleted = manager.deleteAgent(req.params.id);
|
|
if (!deleted) return res.status(404).json({ error: 'Agente não encontrado' });
|
|
invalidateAgentMapCache();
|
|
res.status(204).send();
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.post('/uploads', (req, res, next) => {
|
|
req.uploadSessionId = uuidv4();
|
|
next();
|
|
}, upload.array('files', 20), (req, res) => {
|
|
try {
|
|
const files = (req.files || []).map(f => ({
|
|
originalName: f.originalname,
|
|
path: f.path,
|
|
size: f.size,
|
|
}));
|
|
res.json({ sessionId: req.uploadSessionId, files });
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
function buildContextFilesPrompt(contextFiles) {
|
|
if (!Array.isArray(contextFiles) || contextFiles.length === 0) return '';
|
|
const lines = contextFiles.map(f => `- ${f.path} (${f.originalName})`);
|
|
return `\n\nArquivos de contexto anexados (leia cada um deles antes de iniciar):\n${lines.join('\n')}`;
|
|
}
|
|
|
|
router.post('/agents/:id/execute', (req, res) => {
|
|
try {
|
|
const { task, instructions, contextFiles, workingDirectory } = req.body;
|
|
if (!task) return res.status(400).json({ error: 'task é obrigatório' });
|
|
const clientId = req.headers['x-client-id'] || null;
|
|
const filesPrompt = buildContextFilesPrompt(contextFiles);
|
|
const fullTask = task + filesPrompt;
|
|
const metadata = {};
|
|
if (workingDirectory) metadata.workingDirectoryOverride = workingDirectory;
|
|
const executionId = manager.executeTask(req.params.id, fullTask, instructions, (msg) => wsCallback(msg, clientId), metadata);
|
|
res.status(202).json({ executionId, status: 'started' });
|
|
} catch (err) {
|
|
const status = err.message.includes('não encontrado') ? 404 : 400;
|
|
res.status(status).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.post('/agents/:id/continue', (req, res) => {
|
|
try {
|
|
const { sessionId, message } = req.body;
|
|
if (!sessionId) return res.status(400).json({ error: 'sessionId é obrigatório' });
|
|
if (!message) return res.status(400).json({ error: 'message é obrigatório' });
|
|
const clientId = req.headers['x-client-id'] || null;
|
|
const executionId = manager.continueConversation(req.params.id, sessionId, message, (msg) => wsCallback(msg, clientId));
|
|
res.status(202).json({ executionId, status: 'started' });
|
|
} catch (err) {
|
|
const status = err.message.includes('não encontrado') ? 404 : 400;
|
|
res.status(status).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.post('/agents/:id/cancel/:executionId', (req, res) => {
|
|
try {
|
|
const cancelled = manager.cancelExecution(req.params.executionId);
|
|
if (!cancelled) return res.status(404).json({ error: 'Execução não encontrada ou já finalizada' });
|
|
res.json({ cancelled: true });
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.get('/agents/:id/export', (req, res) => {
|
|
try {
|
|
const exported = manager.exportAgent(req.params.id);
|
|
if (!exported) return res.status(404).json({ error: 'Agente não encontrado' });
|
|
res.json(exported);
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.get('/agents/:id/secrets', (req, res) => {
|
|
try {
|
|
const agent = manager.getAgentById(req.params.id);
|
|
if (!agent) return res.status(404).json({ error: 'Agente não encontrado' });
|
|
const all = secretsStore.getAll();
|
|
const agentSecrets = all
|
|
.filter((s) => s.agentId === req.params.id)
|
|
.map((s) => ({ name: s.name, created_at: s.created_at }));
|
|
res.json(agentSecrets);
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.post('/agents/:id/secrets', (req, res) => {
|
|
try {
|
|
const agent = manager.getAgentById(req.params.id);
|
|
if (!agent) return res.status(404).json({ error: 'Agente não encontrado' });
|
|
const { name, value } = req.body;
|
|
if (!name || !value) return res.status(400).json({ error: 'name e value são obrigatórios' });
|
|
const all = secretsStore.getAll();
|
|
const existing = all.find((s) => s.agentId === req.params.id && s.name === name);
|
|
if (existing) {
|
|
secretsStore.update(existing.id, { value });
|
|
return res.json({ name, updated: true });
|
|
}
|
|
secretsStore.create({ agentId: req.params.id, name, value });
|
|
res.status(201).json({ name, created: true });
|
|
} catch (err) {
|
|
res.status(400).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.delete('/agents/:id/secrets/:name', (req, res) => {
|
|
try {
|
|
const secretName = decodeURIComponent(req.params.name);
|
|
const all = secretsStore.getAll();
|
|
const secret = all.find((s) => s.agentId === req.params.id && s.name === secretName);
|
|
if (!secret) return res.status(404).json({ error: 'Secret não encontrado' });
|
|
secretsStore.delete(secret.id);
|
|
res.status(204).send();
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.get('/agents/:id/versions', (req, res) => {
|
|
try {
|
|
const agent = manager.getAgentById(req.params.id);
|
|
if (!agent) return res.status(404).json({ error: 'Agente não encontrado' });
|
|
const all = agentVersionsStore.getAll();
|
|
const versions = all
|
|
.filter((v) => v.agentId === req.params.id)
|
|
.sort((a, b) => b.version - a.version);
|
|
res.json(versions);
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.post('/agents/:id/versions/:version/restore', (req, res) => {
|
|
try {
|
|
const agent = manager.getAgentById(req.params.id);
|
|
if (!agent) return res.status(404).json({ error: 'Agente não encontrado' });
|
|
const versionNum = parseInt(req.params.version);
|
|
const all = agentVersionsStore.getAll();
|
|
const target = all.find((v) => v.agentId === req.params.id && v.version === versionNum);
|
|
if (!target) return res.status(404).json({ error: 'Versão não encontrada' });
|
|
if (!target.snapshot) return res.status(400).json({ error: 'Snapshot da versão não disponível' });
|
|
const { id, created_at, updated_at, ...snapshotData } = target.snapshot;
|
|
const restored = manager.updateAgent(req.params.id, snapshotData);
|
|
if (!restored) return res.status(500).json({ error: 'Falha ao restaurar versão' });
|
|
invalidateAgentMapCache();
|
|
agentVersionsStore.create({
|
|
agentId: req.params.id,
|
|
version: Math.max(...all.filter((v) => v.agentId === req.params.id).map((v) => v.version), 0) + 1,
|
|
changes: ['restore'],
|
|
changelog: `Restaurado para versão ${versionNum}`,
|
|
snapshot: structuredClone(restored),
|
|
});
|
|
res.json(restored);
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.post('/agents/:id/duplicate', async (req, res) => {
|
|
try {
|
|
const agent = manager.getAgentById(req.params.id);
|
|
if (!agent) return res.status(404).json({ error: 'Agente não encontrado' });
|
|
const { id, created_at, updated_at, executions, ...rest } = agent;
|
|
const duplicate = {
|
|
...rest,
|
|
agent_name: `${agent.agent_name} (cópia)`,
|
|
executions: [],
|
|
status: 'active',
|
|
};
|
|
const created = manager.createAgent(duplicate);
|
|
invalidateAgentMapCache();
|
|
res.status(201).json(created);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
router.get('/tasks', (req, res) => {
|
|
try {
|
|
res.json(tasksStore.getAll());
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.post('/tasks', (req, res) => {
|
|
try {
|
|
if (!req.body.name) return res.status(400).json({ error: 'name é obrigatório' });
|
|
res.status(201).json(tasksStore.create(req.body));
|
|
} catch (err) {
|
|
res.status(400).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.put('/tasks/:id', (req, res) => {
|
|
try {
|
|
const task = tasksStore.update(req.params.id, req.body);
|
|
if (!task) return res.status(404).json({ error: 'Tarefa não encontrada' });
|
|
res.json(task);
|
|
} catch (err) {
|
|
res.status(400).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.delete('/tasks/:id', (req, res) => {
|
|
try {
|
|
const deleted = tasksStore.delete(req.params.id);
|
|
if (!deleted) return res.status(404).json({ error: 'Tarefa não encontrada' });
|
|
res.status(204).send();
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.post('/schedules', (req, res) => {
|
|
try {
|
|
const { agentId, taskDescription, cronExpression } = req.body;
|
|
if (!agentId || !taskDescription || !cronExpression) {
|
|
return res.status(400).json({ error: 'agentId, taskDescription e cronExpression são obrigatórios' });
|
|
}
|
|
const clientId = req.headers['x-client-id'] || null;
|
|
const result = manager.scheduleTask(agentId, taskDescription, cronExpression, (msg) => wsCallback(msg, clientId));
|
|
res.status(201).json(result);
|
|
} catch (err) {
|
|
const status = err.message.includes('não encontrado') ? 404 : 400;
|
|
res.status(status).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.get('/schedules/history', (req, res) => {
|
|
try {
|
|
const limit = parseInt(req.query.limit) || 20;
|
|
const items = executionsStore.getAll()
|
|
.filter((e) => e.source === 'schedule')
|
|
.sort((a, b) => new Date(b.startedAt) - new Date(a.startedAt))
|
|
.slice(0, limit);
|
|
res.json(items);
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.get('/schedules', (req, res) => {
|
|
try {
|
|
res.json(scheduler.getSchedules());
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.put('/schedules/:id', (req, res) => {
|
|
try {
|
|
const clientId = req.headers['x-client-id'] || null;
|
|
const updated = manager.updateScheduleTask(req.params.id, req.body, (msg) => wsCallback(msg, clientId));
|
|
if (!updated) return res.status(404).json({ error: 'Agendamento não encontrado' });
|
|
res.json(updated);
|
|
} catch (err) {
|
|
const status = err.message.includes('não encontrado') ? 404 : 400;
|
|
res.status(status).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.delete('/schedules/:taskId', (req, res) => {
|
|
try {
|
|
const removed = scheduler.unschedule(req.params.taskId);
|
|
if (!removed) return res.status(404).json({ error: 'Agendamento não encontrado' });
|
|
res.status(204).send();
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.get('/pipelines', (req, res) => {
|
|
try {
|
|
res.json(pipeline.getAllPipelines());
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.get('/pipelines/:id', (req, res) => {
|
|
try {
|
|
const found = pipeline.getPipeline(req.params.id);
|
|
if (!found) return res.status(404).json({ error: 'Pipeline não encontrado' });
|
|
res.json(found);
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.post('/pipelines', (req, res) => {
|
|
try {
|
|
res.status(201).json(pipeline.createPipeline(req.body));
|
|
} catch (err) {
|
|
res.status(400).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.put('/pipelines/:id', (req, res) => {
|
|
try {
|
|
const updated = pipeline.updatePipeline(req.params.id, req.body);
|
|
if (!updated) return res.status(404).json({ error: 'Pipeline não encontrado' });
|
|
res.json(updated);
|
|
} catch (err) {
|
|
res.status(400).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.delete('/pipelines/:id', (req, res) => {
|
|
try {
|
|
const deleted = pipeline.deletePipeline(req.params.id);
|
|
if (!deleted) return res.status(404).json({ error: 'Pipeline não encontrado' });
|
|
res.status(204).send();
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.post('/pipelines/:id/execute', async (req, res) => {
|
|
try {
|
|
const { input, workingDirectory, contextFiles } = req.body;
|
|
if (!input) return res.status(400).json({ error: 'input é obrigatório' });
|
|
const clientId = req.headers['x-client-id'] || null;
|
|
const options = {};
|
|
if (workingDirectory) options.workingDirectory = workingDirectory;
|
|
const filesPrompt = buildContextFilesPrompt(contextFiles);
|
|
const fullInput = input + filesPrompt;
|
|
const result = pipeline.executePipeline(req.params.id, fullInput, (msg) => wsCallback(msg, clientId), options);
|
|
result.catch(() => {});
|
|
res.status(202).json({ pipelineId: req.params.id, status: 'started' });
|
|
} catch (err) {
|
|
const status = err.message.includes('não encontrado') || err.message.includes('desativado') ? 400 : 500;
|
|
res.status(status).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.post('/pipelines/:id/cancel', (req, res) => {
|
|
try {
|
|
const cancelled = pipeline.cancelPipeline(req.params.id);
|
|
if (!cancelled) return res.status(404).json({ error: 'Pipeline não está em execução' });
|
|
res.json({ cancelled: true });
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.post('/pipelines/:id/approve', (req, res) => {
|
|
try {
|
|
const approved = pipeline.approvePipelineStep(req.params.id);
|
|
if (!approved) return res.status(404).json({ error: 'Nenhuma aprovação pendente para este pipeline' });
|
|
res.json({ approved: true });
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.post('/pipelines/:id/reject', (req, res) => {
|
|
try {
|
|
const rejected = pipeline.rejectPipelineStep(req.params.id);
|
|
if (!rejected) return res.status(404).json({ error: 'Nenhuma aprovação pendente para este pipeline' });
|
|
res.json({ rejected: true });
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.post('/pipelines/resume/:executionId', async (req, res) => {
|
|
try {
|
|
const clientId = req.headers['x-client-id'] || null;
|
|
const result = pipeline.resumePipeline(req.params.executionId, (msg) => wsCallback(msg, clientId));
|
|
result.catch(() => {});
|
|
res.status(202).json({ status: 'resumed' });
|
|
} catch (err) {
|
|
const status = err.message.includes('não encontrad') ? 404 : 400;
|
|
res.status(status).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.get('/webhooks', (req, res) => {
|
|
try {
|
|
res.json(webhooksStore.getAll());
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.post('/webhooks', (req, res) => {
|
|
try {
|
|
const { name, targetType, targetId } = req.body;
|
|
if (!name || !targetType || !targetId) {
|
|
return res.status(400).json({ error: 'name, targetType e targetId são obrigatórios' });
|
|
}
|
|
if (!['agent', 'pipeline'].includes(targetType)) {
|
|
return res.status(400).json({ error: 'targetType deve ser "agent" ou "pipeline"' });
|
|
}
|
|
|
|
const token = crypto.randomBytes(24).toString('hex');
|
|
const webhook = webhooksStore.create({
|
|
name,
|
|
targetType,
|
|
targetId,
|
|
token,
|
|
active: true,
|
|
lastTriggeredAt: null,
|
|
triggerCount: 0,
|
|
});
|
|
|
|
res.status(201).json(webhook);
|
|
} catch (err) {
|
|
res.status(400).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.put('/webhooks/:id', (req, res) => {
|
|
try {
|
|
const existing = webhooksStore.getById(req.params.id);
|
|
if (!existing) return res.status(404).json({ error: 'Webhook não encontrado' });
|
|
const allowed = ['name', 'targetType', 'targetId', 'active'];
|
|
const updateData = {};
|
|
for (const key of allowed) {
|
|
if (req.body[key] !== undefined) updateData[key] = req.body[key];
|
|
}
|
|
const updated = webhooksStore.update(req.params.id, updateData);
|
|
res.json(updated);
|
|
} catch (err) {
|
|
res.status(400).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.post('/webhooks/:id/test', async (req, res) => {
|
|
try {
|
|
const wh = webhooksStore.getById(req.params.id);
|
|
if (!wh) return res.status(404).json({ error: 'Webhook não encontrado' });
|
|
|
|
if (wh.targetType === 'agent') {
|
|
const executionId = manager.executeTask(wh.targetId, 'Teste de webhook', '', (msg) => {
|
|
if (wsbroadcast) wsbroadcast(msg);
|
|
}, { source: 'webhook-test', webhookId: wh.id });
|
|
res.status(202).json({ success: true, message: 'Webhook disparado com sucesso', executionId });
|
|
} else if (wh.targetType === 'pipeline') {
|
|
pipeline.executePipeline(wh.targetId, 'Teste de webhook', (msg) => {
|
|
if (wsbroadcast) wsbroadcast(msg);
|
|
}).catch(() => {});
|
|
res.status(202).json({ success: true, message: 'Pipeline disparada com sucesso', pipelineId: wh.targetId });
|
|
} else {
|
|
return res.status(400).json({ error: `targetType inválido: ${wh.targetType}` });
|
|
}
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.delete('/webhooks/:id', (req, res) => {
|
|
try {
|
|
const deleted = webhooksStore.delete(req.params.id);
|
|
if (!deleted) return res.status(404).json({ error: 'Webhook não encontrado' });
|
|
res.status(204).send();
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
hookRouter.post('/:token', (req, res) => {
|
|
try {
|
|
const webhooks = webhooksStore.getAll();
|
|
const webhook = webhooks.find((w) => w.token === req.params.token);
|
|
|
|
if (!webhook) return res.status(404).json({ error: 'Webhook não encontrado' });
|
|
if (!webhook.active) return res.status(403).json({ error: 'Webhook desativado' });
|
|
|
|
webhooksStore.update(webhook.id, {
|
|
lastTriggeredAt: new Date().toISOString(),
|
|
triggerCount: (webhook.triggerCount || 0) + 1,
|
|
});
|
|
|
|
const payload = req.body || {};
|
|
|
|
if (webhook.targetType === 'agent') {
|
|
const task = payload.task || payload.message || payload.input || 'Webhook trigger';
|
|
const instructions = payload.instructions || '';
|
|
const executionId = manager.executeTask(webhook.targetId, task, instructions, (msg) => {
|
|
if (wsbroadcast) wsbroadcast(msg);
|
|
});
|
|
res.status(202).json({ executionId, status: 'started', webhook: webhook.name });
|
|
} else if (webhook.targetType === 'pipeline') {
|
|
const input = payload.input || payload.task || payload.message || 'Webhook trigger';
|
|
pipeline.executePipeline(webhook.targetId, input, (msg) => {
|
|
if (wsbroadcast) wsbroadcast(msg);
|
|
}).catch(() => {});
|
|
res.status(202).json({ pipelineId: webhook.targetId, status: 'started', webhook: webhook.name });
|
|
} else {
|
|
return res.status(400).json({ error: `targetType inválido: ${webhook.targetType}` });
|
|
}
|
|
} catch (err) {
|
|
const status = err.message.includes('não encontrado') ? 404 : 500;
|
|
res.status(status).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.get('/stats/costs', (req, res) => {
|
|
try {
|
|
const days = parseInt(req.query.days) || 30;
|
|
const cutoff = new Date();
|
|
cutoff.setDate(cutoff.getDate() - days);
|
|
|
|
const items = executionsStore.getAll().filter((e) => {
|
|
if (!e.startedAt) return false;
|
|
return new Date(e.startedAt) >= cutoff;
|
|
});
|
|
|
|
let totalCost = 0;
|
|
let totalExecutions = 0;
|
|
const byAgent = {};
|
|
const byDay = {};
|
|
|
|
for (const item of items) {
|
|
const cost = item.costUsd || item.totalCostUsd || 0;
|
|
if (cost <= 0) continue;
|
|
|
|
totalCost += cost;
|
|
totalExecutions++;
|
|
|
|
const agentName = item.agentName || item.pipelineName || 'Desconhecido';
|
|
if (!byAgent[agentName]) byAgent[agentName] = { cost: 0, count: 0 };
|
|
byAgent[agentName].cost += cost;
|
|
byAgent[agentName].count++;
|
|
|
|
const day = item.startedAt.slice(0, 10);
|
|
if (!byDay[day]) byDay[day] = 0;
|
|
byDay[day] += cost;
|
|
}
|
|
|
|
const topAgents = Object.entries(byAgent)
|
|
.map(([name, data]) => ({ name, ...data }))
|
|
.sort((a, b) => b.cost - a.cost)
|
|
.slice(0, 10);
|
|
|
|
res.json({
|
|
totalCost: Math.round(totalCost * 10000) / 10000,
|
|
totalExecutions,
|
|
period: days,
|
|
topAgents,
|
|
dailyCosts: byDay,
|
|
});
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
const SYSTEM_STATUS_TTL = 5_000;
|
|
|
|
router.get('/system/status', (req, res) => {
|
|
try {
|
|
const status = cached('system:status', SYSTEM_STATUS_TTL, () => {
|
|
const agents = manager.getAllAgents();
|
|
const activeExecutions = manager.getActiveExecutions();
|
|
const schedules = scheduler.getSchedules();
|
|
const pipelines = pipeline.getAllPipelines();
|
|
const activePipelines = pipeline.getActivePipelines();
|
|
const webhooks = webhooksStore.getAll();
|
|
|
|
const todayCost = (() => {
|
|
const today = new Date().toISOString().slice(0, 10);
|
|
return executionsStore.getAll()
|
|
.filter((e) => e.startedAt && e.startedAt.startsWith(today))
|
|
.reduce((sum, e) => sum + (e.costUsd || e.totalCostUsd || 0), 0);
|
|
})();
|
|
|
|
return {
|
|
agents: {
|
|
total: agents.length,
|
|
active: agents.filter((a) => a.status === 'active').length,
|
|
inactive: agents.filter((a) => a.status === 'inactive').length,
|
|
},
|
|
executions: {
|
|
active: activeExecutions.length,
|
|
today: manager.getDailyExecutionCount(),
|
|
list: activeExecutions,
|
|
},
|
|
schedules: {
|
|
total: schedules.length,
|
|
active: schedules.filter((s) => s.active).length,
|
|
},
|
|
pipelines: {
|
|
total: pipelines.length,
|
|
active: pipelines.filter((p) => p.status === 'active').length,
|
|
running: activePipelines.length,
|
|
},
|
|
webhooks: {
|
|
total: webhooks.length,
|
|
active: webhooks.filter((w) => w.active).length,
|
|
},
|
|
costs: {
|
|
today: Math.round(todayCost * 10000) / 10000,
|
|
},
|
|
};
|
|
});
|
|
res.json(status);
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
let claudeVersionCache = null;
|
|
|
|
router.get('/system/info', async (req, res) => {
|
|
try {
|
|
if (claudeVersionCache === null) {
|
|
try {
|
|
claudeVersionCache = await new Promise((resolve, reject) => {
|
|
execFile(getBinPath(), ['--version'], { timeout: 5000 }, (err, stdout) => {
|
|
if (err) reject(err);
|
|
else resolve(stdout.toString().trim());
|
|
});
|
|
});
|
|
} catch {
|
|
claudeVersionCache = 'N/A';
|
|
}
|
|
}
|
|
res.json({
|
|
serverVersion: '1.1.0',
|
|
nodeVersion: process.version,
|
|
claudeVersion: claudeVersionCache,
|
|
platform: `${os.platform()} ${os.arch()}`,
|
|
uptime: Math.floor(process.uptime()),
|
|
});
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.get('/executions/history', (req, res) => {
|
|
try {
|
|
const limit = parseInt(req.query.limit) || 50;
|
|
const offset = parseInt(req.query.offset) || 0;
|
|
const typeFilter = req.query.type || '';
|
|
const statusFilter = req.query.status || '';
|
|
const search = (req.query.search || '').toLowerCase();
|
|
|
|
let items = executionsStore.getAll();
|
|
|
|
if (typeFilter) items = items.filter((e) => e.type === typeFilter);
|
|
if (statusFilter) items = items.filter((e) => e.status === statusFilter);
|
|
if (search) {
|
|
items = items.filter((e) => {
|
|
const name = (e.agentName || e.pipelineName || '').toLowerCase();
|
|
const task = (e.task || e.input || '').toLowerCase();
|
|
return name.includes(search) || task.includes(search);
|
|
});
|
|
}
|
|
|
|
items.sort((a, b) => new Date(b.startedAt) - new Date(a.startedAt));
|
|
const total = items.length;
|
|
const paged = items.slice(offset, offset + limit);
|
|
|
|
res.json({ items: paged, total });
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.get('/executions/history/:id', (req, res) => {
|
|
try {
|
|
const exec = executionsStore.getById(req.params.id);
|
|
if (!exec) return res.status(404).json({ error: 'Execução não encontrada' });
|
|
res.json(exec);
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.delete('/executions/history/:id', (req, res) => {
|
|
try {
|
|
const deleted = executionsStore.delete(req.params.id);
|
|
if (!deleted) return res.status(404).json({ error: 'Execução não encontrada' });
|
|
res.status(204).send();
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.delete('/executions/history', (req, res) => {
|
|
try {
|
|
executionsStore.save([]);
|
|
res.status(204).send();
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.get('/executions/active', (req, res) => {
|
|
try {
|
|
res.json(manager.getActiveExecutions());
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.post('/executions/cancel-all', (req, res) => {
|
|
try {
|
|
const activePipelines = pipeline.getActivePipelines();
|
|
for (const p of activePipelines) {
|
|
pipeline.cancelPipeline(p.pipelineId);
|
|
}
|
|
cancelAllExecutions();
|
|
const running = executionsStore.getAll().filter(e => e.status === 'running' || e.status === 'awaiting_approval');
|
|
for (const e of running) {
|
|
executionsStore.update(e.id, { status: 'canceled', endedAt: new Date().toISOString() });
|
|
}
|
|
res.json({ cancelled: true });
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.get('/executions/recent', (req, res) => {
|
|
try {
|
|
const limit = parseInt(req.query.limit) || 20;
|
|
const items = executionsStore.getAll();
|
|
items.sort((a, b) => new Date(b.startedAt) - new Date(a.startedAt));
|
|
res.json(items.slice(0, limit));
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.post('/executions/:id/retry', async (req, res) => {
|
|
try {
|
|
const execution = executionsStore.getById(req.params.id);
|
|
if (!execution) return res.status(404).json({ error: 'Execução não encontrada' });
|
|
if (!['error', 'canceled'].includes(execution.status)) {
|
|
return res.status(400).json({ error: 'Apenas execuções com erro ou canceladas podem ser reexecutadas' });
|
|
}
|
|
const clientId = req.headers['x-client-id'] || null;
|
|
if (execution.type === 'pipeline') {
|
|
pipeline.executePipeline(execution.pipelineId, execution.input, (msg) => wsCallback(msg, clientId)).catch(() => {});
|
|
return res.json({ success: true, message: 'Pipeline reexecutado' });
|
|
}
|
|
manager.executeTask(execution.agentId, execution.task, null, (msg) => wsCallback(msg, clientId));
|
|
res.json({ success: true, message: 'Execução reiniciada' });
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
router.get('/executions/export', async (req, res) => {
|
|
try {
|
|
const executions = executionsStore.getAll();
|
|
const headers = ['ID', 'Tipo', 'Nome', 'Status', 'Início', 'Fim', 'Duração (ms)', 'Custo (USD)', 'Turnos'];
|
|
const rows = executions.map(e => [
|
|
e.id,
|
|
e.type || 'agent',
|
|
e.agentName || e.pipelineName || '',
|
|
e.status,
|
|
e.startedAt || '',
|
|
e.endedAt || '',
|
|
e.durationMs || '',
|
|
e.costUsd || e.totalCostUsd || '',
|
|
e.numTurns || '',
|
|
]);
|
|
const csv = [headers.join(','), ...rows.map(r => r.map(v => `"${String(v).replace(/"/g, '""')}"`).join(','))].join('\n');
|
|
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
|
res.setHeader('Content-Disposition', `attachment; filename=executions_${new Date().toISOString().split('T')[0]}.csv`);
|
|
res.send('\uFEFF' + csv);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
router.get('/stats/charts', async (req, res) => {
|
|
try {
|
|
const days = parseInt(req.query.days) || 7;
|
|
const executions = executionsStore.getAll();
|
|
const now = new Date();
|
|
const labels = [];
|
|
const executionCounts = [];
|
|
const costData = [];
|
|
const successCounts = [];
|
|
const errorCounts = [];
|
|
|
|
for (let i = days - 1; i >= 0; i--) {
|
|
const date = new Date(now);
|
|
date.setDate(date.getDate() - i);
|
|
const dateStr = date.toISOString().split('T')[0];
|
|
labels.push(dateStr);
|
|
const dayExecs = executions.filter(e => e.startedAt && e.startedAt.startsWith(dateStr));
|
|
executionCounts.push(dayExecs.length);
|
|
costData.push(+(dayExecs.reduce((sum, e) => sum + (e.costUsd || e.totalCostUsd || 0), 0)).toFixed(4));
|
|
successCounts.push(dayExecs.filter(e => e.status === 'completed').length);
|
|
errorCounts.push(dayExecs.filter(e => e.status === 'error').length);
|
|
}
|
|
|
|
const agentCounts = {};
|
|
executions.forEach(e => {
|
|
if (e.agentName) agentCounts[e.agentName] = (agentCounts[e.agentName] || 0) + 1;
|
|
});
|
|
const topAgents = Object.entries(agentCounts)
|
|
.sort((a, b) => b[1] - a[1])
|
|
.slice(0, 5)
|
|
.map(([name, count]) => ({ name, count }));
|
|
|
|
const statusDist = {};
|
|
executions.forEach(e => { statusDist[e.status] = (statusDist[e.status] || 0) + 1; });
|
|
|
|
res.json({ labels, executionCounts, costData, successCounts, errorCounts, topAgents, statusDistribution: statusDist });
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
router.get('/notifications', async (req, res) => {
|
|
try {
|
|
const notifications = notificationsStore.getAll();
|
|
const unreadCount = notifications.filter(n => !n.read).length;
|
|
res.json({ notifications: notifications.slice(-50).reverse(), unreadCount });
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
router.post('/notifications/:id/read', (req, res) => {
|
|
try {
|
|
const updated = notificationsStore.update(req.params.id, { read: true });
|
|
if (!updated) return res.status(404).json({ error: 'Notificação não encontrada' });
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
router.post('/notifications/read-all', (req, res) => {
|
|
try {
|
|
const notifications = notificationsStore.getAll();
|
|
for (const n of notifications) {
|
|
if (!n.read) notificationsStore.update(n.id, { read: true });
|
|
}
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
router.delete('/notifications', async (req, res) => {
|
|
try {
|
|
notificationsStore.save([]);
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
router.get('/reports', (req, res) => {
|
|
try {
|
|
if (!existsSync(REPORTS_DIR)) return res.json([]);
|
|
const files = readdirSync(REPORTS_DIR)
|
|
.filter(f => f.endsWith('.md'))
|
|
.sort((a, b) => b.localeCompare(a))
|
|
.slice(0, 100);
|
|
res.json(files);
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.get('/reports/:filename', (req, res) => {
|
|
try {
|
|
const filename = req.params.filename.replace(/[^a-zA-Z0-9À-ÿ_.\-]/g, '');
|
|
if (!filename.endsWith('.md')) return res.status(400).json({ error: 'Formato inválido' });
|
|
const filepath = join(REPORTS_DIR, filename);
|
|
const resolved = pathResolve(filepath);
|
|
if (!resolved.startsWith(pathResolve(REPORTS_DIR))) {
|
|
return res.status(400).json({ error: 'Caminho inválido' });
|
|
}
|
|
if (!existsSync(filepath)) return res.status(404).json({ error: 'Relatório não encontrado' });
|
|
const content = readFileSync(filepath, 'utf-8');
|
|
res.json({ filename, content });
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.delete('/reports/:filename', (req, res) => {
|
|
try {
|
|
const filename = req.params.filename.replace(/[^a-zA-Z0-9À-ÿ_.\-]/g, '');
|
|
const filepath = join(REPORTS_DIR, filename);
|
|
const resolved = pathResolve(filepath);
|
|
if (!resolved.startsWith(pathResolve(REPORTS_DIR))) {
|
|
return res.status(400).json({ error: 'Caminho inválido' });
|
|
}
|
|
if (!existsSync(filepath)) return res.status(404).json({ error: 'Relatório não encontrado' });
|
|
unlinkSync(filepath);
|
|
res.json({ success: true });
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
const PROJECTS_DIR = '/home/projetos';
|
|
|
|
function resolveProjectPath(requestedPath) {
|
|
const decoded = decodeURIComponent(requestedPath || '');
|
|
const resolved = pathResolve(PROJECTS_DIR, decoded);
|
|
if (!resolved.startsWith(PROJECTS_DIR)) return null;
|
|
return resolved;
|
|
}
|
|
|
|
router.get('/files', (req, res) => {
|
|
try {
|
|
const targetPath = resolveProjectPath(req.query.path || '');
|
|
if (!targetPath) return res.status(400).json({ error: 'Caminho inválido' });
|
|
if (!existsSync(targetPath)) return res.status(404).json({ error: 'Diretório não encontrado' });
|
|
|
|
const stat = statSync(targetPath);
|
|
if (!stat.isDirectory()) return res.status(400).json({ error: 'Caminho não é um diretório' });
|
|
|
|
const entries = readdirSync(targetPath, { withFileTypes: true })
|
|
.filter(e => !e.name.startsWith('.'))
|
|
.map(entry => {
|
|
const fullPath = join(targetPath, entry.name);
|
|
try {
|
|
const s = statSync(fullPath);
|
|
return {
|
|
name: entry.name,
|
|
type: entry.isDirectory() ? 'directory' : 'file',
|
|
size: entry.isDirectory() ? null : s.size,
|
|
modified: s.mtime.toISOString(),
|
|
extension: entry.isDirectory() ? null : extname(entry.name).slice(1).toLowerCase(),
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
})
|
|
.filter(Boolean)
|
|
.sort((a, b) => {
|
|
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
|
|
return a.name.localeCompare(b.name);
|
|
});
|
|
|
|
const relativePath = relative(PROJECTS_DIR, targetPath) || '';
|
|
|
|
res.json({
|
|
path: relativePath,
|
|
parent: relativePath ? dirname(relativePath) : null,
|
|
entries,
|
|
});
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.get('/files/download', (req, res) => {
|
|
try {
|
|
const targetPath = resolveProjectPath(req.query.path || '');
|
|
if (!targetPath) return res.status(400).json({ error: 'Caminho inválido' });
|
|
if (!existsSync(targetPath)) return res.status(404).json({ error: 'Arquivo não encontrado' });
|
|
|
|
const stat = statSync(targetPath);
|
|
if (!stat.isFile()) return res.status(400).json({ error: 'Caminho não é um arquivo' });
|
|
|
|
const filename = basename(targetPath);
|
|
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(filename)}"`);
|
|
res.setHeader('Content-Length', stat.size);
|
|
createReadStream(targetPath).pipe(res);
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.get('/files/download-folder', (req, res) => {
|
|
try {
|
|
const targetPath = resolveProjectPath(req.query.path || '');
|
|
if (!targetPath) return res.status(400).json({ error: 'Caminho inválido' });
|
|
if (!existsSync(targetPath)) return res.status(404).json({ error: 'Pasta não encontrada' });
|
|
|
|
const stat = statSync(targetPath);
|
|
if (!stat.isDirectory()) return res.status(400).json({ error: 'Caminho não é uma pasta' });
|
|
|
|
const folderName = basename(targetPath) || 'projetos';
|
|
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(folderName)}.tar.gz"`);
|
|
res.setHeader('Content-Type', 'application/gzip');
|
|
|
|
const parentDir = dirname(targetPath);
|
|
const dirName = basename(targetPath);
|
|
const tar = spawnProcess('tar', ['-czf', '-', '-C', parentDir, dirName]);
|
|
tar.stdout.pipe(res);
|
|
tar.stderr.on('data', () => {});
|
|
tar.on('error', (err) => {
|
|
if (!res.headersSent) res.status(500).json({ error: err.message });
|
|
});
|
|
|
|
req.on('close', () => { try { tar.kill(); } catch {} });
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.delete('/files', (req, res) => {
|
|
try {
|
|
const targetPath = resolveProjectPath(req.query.path || '');
|
|
if (!targetPath) return res.status(400).json({ error: 'Caminho inválido' });
|
|
if (targetPath === PROJECTS_DIR) return res.status(400).json({ error: 'Não é permitido excluir o diretório raiz' });
|
|
if (!existsSync(targetPath)) return res.status(404).json({ error: 'Arquivo ou pasta não encontrado' });
|
|
|
|
const stat = statSync(targetPath);
|
|
if (stat.isDirectory()) {
|
|
rmSync(targetPath, { recursive: true, force: true });
|
|
} else {
|
|
unlinkSync(targetPath);
|
|
}
|
|
|
|
res.json({ success: true });
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
export default router;
|