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:
Frederico Castro
2026-02-26 04:01:12 -03:00
parent 22a3ce9262
commit 93d9027e2c
18 changed files with 1609 additions and 75 deletions

View File

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