Continuação de conversa no terminal, histórico de agendamentos, webhooks e melhorias gerais
- Terminal com input de chat: após execução, permite continuar conversa com o agente via --resume do CLI, mantendo contexto da sessão (sessionId persistido) - Nova rota POST /api/agents/:id/continue para retomar sessões - Executor com função resume() para spawnar claude com --resume <sessionId> - Histórico de agendamentos agora busca do executionsStore (persistente) com dados completos: agente, tarefa, status, duração, custo e link para detalhes no modal - Execuções de agendamento tagueadas com source:'schedule' e scheduleId - Correção da expressão cron duplicada na UI de agendamentos - cronToHuman trata expressões com minuto específico (ex: 37 3 * * * → Todo dia às 03:37) - Botão "Copiar cURL" nos cards de webhook com payload de exemplo contextual - Webhooks component (webhooks.js) adicionado ao repositório
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
import { Router } from 'express';
|
||||
import { execSync } from 'child_process';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import crypto from 'crypto';
|
||||
import os from 'os';
|
||||
import * as manager from '../agents/manager.js';
|
||||
import { tasksStore, settingsStore, executionsStore } from '../store/db.js';
|
||||
import { tasksStore, settingsStore, executionsStore, webhooksStore } from '../store/db.js';
|
||||
import * as scheduler from '../agents/scheduler.js';
|
||||
import * as pipeline from '../agents/pipeline.js';
|
||||
import { getBinPath, updateMaxConcurrent } from '../agents/executor.js';
|
||||
@@ -10,6 +12,7 @@ import { invalidateAgentMapCache } from '../agents/pipeline.js';
|
||||
import { cached } from '../cache/index.js';
|
||||
|
||||
const router = Router();
|
||||
export const hookRouter = Router();
|
||||
|
||||
let wsbroadcast = null;
|
||||
let wsBroadcastTo = null;
|
||||
@@ -126,6 +129,20 @@ router.post('/agents/:id/execute', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
@@ -200,7 +217,12 @@ router.post('/schedules', (req, res) => {
|
||||
|
||||
router.get('/schedules/history', (req, res) => {
|
||||
try {
|
||||
res.json(scheduler.getHistory());
|
||||
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 });
|
||||
}
|
||||
@@ -284,10 +306,12 @@ router.delete('/pipelines/:id', (req, res) => {
|
||||
|
||||
router.post('/pipelines/:id/execute', (req, res) => {
|
||||
try {
|
||||
const { input } = req.body;
|
||||
const { input, workingDirectory } = req.body;
|
||||
if (!input) return res.status(400).json({ error: 'input é obrigatório' });
|
||||
const clientId = req.headers['x-client-id'] || null;
|
||||
pipeline.executePipeline(req.params.id, input, (msg) => wsCallback(msg, clientId)).catch(() => {});
|
||||
const options = {};
|
||||
if (workingDirectory) options.workingDirectory = workingDirectory;
|
||||
pipeline.executePipeline(req.params.id, input, (msg) => wsCallback(msg, clientId), options).catch(() => {});
|
||||
res.status(202).json({ pipelineId: req.params.id, status: 'started' });
|
||||
} catch (err) {
|
||||
const status = err.message.includes('não encontrado') ? 404 : 400;
|
||||
@@ -305,6 +329,174 @@ router.post('/pipelines/:id/cancel', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
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.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 updateData = {};
|
||||
if (req.body.name !== undefined) updateData.name = req.body.name;
|
||||
if (req.body.active !== undefined) updateData.active = !!req.body.active;
|
||||
|
||||
const updated = webhooksStore.update(req.params.id, updateData);
|
||||
res.json(updated);
|
||||
} catch (err) {
|
||||
res.status(400).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';
|
||||
const options = {};
|
||||
if (payload.workingDirectory) options.workingDirectory = payload.workingDirectory;
|
||||
pipeline.executePipeline(webhook.targetId, input, (msg) => {
|
||||
if (wsbroadcast) wsbroadcast(msg);
|
||||
}, options).catch(() => {});
|
||||
res.status(202).json({ pipelineId: webhook.targetId, status: 'started', webhook: webhook.name });
|
||||
}
|
||||
} 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) => {
|
||||
@@ -315,6 +507,15 @@ router.get('/system/status', (req, res) => {
|
||||
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,
|
||||
@@ -335,6 +536,13 @@ router.get('/system/status', (req, res) => {
|
||||
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);
|
||||
@@ -355,7 +563,7 @@ router.get('/system/info', (req, res) => {
|
||||
}
|
||||
}
|
||||
res.json({
|
||||
serverVersion: '1.0.0',
|
||||
serverVersion: '1.1.0',
|
||||
nodeVersion: process.version,
|
||||
claudeVersion: claudeVersionCache,
|
||||
platform: `${os.platform()} ${os.arch()}`,
|
||||
|
||||
Reference in New Issue
Block a user