diff --git a/package-lock.json b/package-lock.json index fba7433..40c3d8a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,8 +8,10 @@ "name": "agents-orchestrator", "version": "1.1.0", "dependencies": { + "compression": "^1.8.1", "express": "^4.21.0", "express-rate-limit": "^8.2.1", + "helmet": "^8.1.0", "node-cron": "^3.0.3", "uuid": "^10.0.0", "ws": "^8.18.0" @@ -96,6 +98,45 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -416,6 +457,15 @@ "node": ">= 0.4" } }, + "node_modules/helmet": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz", + "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -601,6 +651,15 @@ "node": ">= 0.8" } }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", diff --git a/package.json b/package.json index 70c4586..74edd8d 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,10 @@ "dev": "node --watch server.js" }, "dependencies": { + "compression": "^1.8.1", "express": "^4.21.0", "express-rate-limit": "^8.2.1", + "helmet": "^8.1.0", "node-cron": "^3.0.3", "uuid": "^10.0.0", "ws": "^8.18.0" diff --git a/public/css/styles.css b/public/css/styles.css index 59cd1fc..8776079 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -529,12 +529,19 @@ textarea { .agent-actions { display: flex; + align-items: center; gap: 8px; - padding: 12px 20px; + padding: 12px 16px; border-top: 1px solid var(--border-primary); margin-top: auto; } +.agent-actions-icons { + display: flex; + gap: 4px; + margin-left: auto; +} + .badge { display: inline-flex; align-items: center; @@ -683,7 +690,9 @@ textarea { .btn-sm.btn-icon { width: 30px; height: 30px; + min-width: 30px; padding: 0; + flex-shrink: 0; } .btn-lg { @@ -3243,6 +3252,7 @@ tbody tr:hover td { .agent-card-actions { display: flex; + flex-wrap: wrap; gap: 8px; padding: 12px 16px; border-top: 1px solid var(--border-primary); @@ -4268,3 +4278,235 @@ body, .sidebar, .header, .card, .modal-content, .input, .select, textarea, .metr .report-toast:hover { text-decoration: underline; } + +/* ─── Secrets Management ─── */ + +.form-divider { + height: 1px; + background-color: var(--border-primary); + margin: 24px 0 20px; +} + +.form-section-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 15px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 8px; +} + +.form-section-title i, +.form-section-title svg { + width: 16px; + height: 16px; + color: var(--accent); +} + +.secrets-list { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 12px; +} + +.secret-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 12px; + background-color: var(--bg-tertiary); + border: 1px solid var(--border-primary); + border-radius: 8px; + transition: border-color 0.2s; +} + +.secret-item:hover { + border-color: var(--border-secondary); +} + +.secret-name { + font-size: 13px; + font-weight: 500; + color: var(--text-primary); + min-width: 0; + flex: 1; +} + +.secret-value-placeholder { + font-size: 12px; + color: var(--text-muted); + letter-spacing: 2px; +} + +.secrets-add-form { + display: flex; + gap: 8px; + align-items: flex-start; +} + +.secrets-add-form .input { + flex: 1; + min-width: 0; +} + +.secrets-add-form .input:first-child { + max-width: 220px; + font-family: 'JetBrains Mono', monospace; + font-size: 13px; + text-transform: uppercase; +} + +/* ─── Version History Timeline ─── */ + +.versions-timeline { + display: flex; + flex-direction: column; +} + +.version-item { + display: flex; + gap: 16px; + position: relative; +} + +.version-node { + display: flex; + flex-direction: column; + align-items: center; + flex-shrink: 0; + width: 20px; +} + +.version-dot { + width: 12px; + height: 12px; + border-radius: 50%; + background-color: var(--border-secondary); + border: 2px solid var(--bg-secondary); + flex-shrink: 0; + z-index: 1; +} + +.version-dot--active { + background-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-glow); +} + +.version-line { + width: 2px; + flex: 1; + background-color: var(--border-primary); + min-height: 20px; +} + +.version-content { + flex: 1; + padding-bottom: 24px; + min-width: 0; +} + +.version-header { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + margin-bottom: 6px; +} + +.version-number { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + font-family: 'JetBrains Mono', monospace; +} + +.version-date { + font-size: 12px; + color: var(--text-muted); +} + +.version-changes { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-bottom: 6px; +} + +.version-field-badge { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 4px; + font-size: 11px; + font-weight: 500; + background-color: var(--accent-glow); + color: var(--accent); +} + +.version-changelog { + font-size: 13px; + color: var(--text-secondary); + line-height: 1.5; +} + +.version-item--latest .version-content { + padding-bottom: 20px; +} + +/* ─── Light theme overrides for new elements ─── */ + +[data-theme="light"] .secret-item { + background-color: var(--bg-tertiary); + border-color: var(--border-color); +} + +[data-theme="light"] .secret-item:hover { + border-color: #c8ccd6; +} + +[data-theme="light"] .version-dot { + border-color: var(--bg-secondary); + background-color: #c8ccd6; +} + +[data-theme="light"] .version-dot--active { + background-color: var(--accent); +} + +[data-theme="light"] .version-line { + background-color: var(--border-color); +} + +[data-theme="light"] .version-field-badge { + background-color: rgba(99, 102, 241, 0.1); +} + +[data-theme="light"] .form-divider { + background-color: var(--border-color); +} + +[data-theme="light"] .secrets-add-form .input { + background-color: var(--bg-input); + border-color: var(--border-color); + color: var(--text-primary); +} + +/* ─── Responsive adjustments ─── */ + +@media (max-width: 768px) { + .secrets-add-form { + flex-direction: column; + } + + .secrets-add-form .input:first-child { + max-width: 100%; + } + + .version-header { + flex-direction: column; + align-items: flex-start; + gap: 4px; + } +} diff --git a/public/index.html b/public/index.html index 8cd7a7a..eabea66 100644 --- a/public/index.html +++ b/public/index.html @@ -838,6 +838,28 @@ +
+
+ +
+ + +
+
+ +
+
@@ -852,6 +874,24 @@
+ +
+ + + `; diff --git a/public/js/components/webhooks.js b/public/js/components/webhooks.js index c96519d..888dda9 100644 --- a/public/js/components/webhooks.js +++ b/public/js/components/webhooks.js @@ -181,7 +181,10 @@ const WebhooksUI = { async test(webhookId) { try { const result = await API.webhooks.test(webhookId); - Toast.success(result.message || 'Webhook testado com sucesso'); + Toast.success(result.message || 'Webhook disparado com sucesso'); + if (result.executionId || result.pipelineId) { + App.navigateTo('terminal'); + } } catch (err) { Toast.error(`Erro ao testar webhook: ${err.message}`); } diff --git a/server.js b/server.js index 45aad62..ddd8a1f 100644 --- a/server.js +++ b/server.js @@ -6,6 +6,8 @@ import { dirname, join } from 'path'; import { v4 as uuidv4 } from 'uuid'; import crypto from 'crypto'; import rateLimit from 'express-rate-limit'; +import helmet from 'helmet'; +import compression from 'compression'; import apiRouter, { setWsBroadcast, setWsBroadcastTo, hookRouter } from './src/routes/api.js'; import * as manager from './src/agents/manager.js'; import { setGlobalBroadcast } from './src/agents/manager.js'; @@ -14,16 +16,17 @@ import { flushAllStores } from './src/store/db.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const PORT = process.env.PORT || 3000; +const HOST = process.env.HOST || '127.0.0.1'; const AUTH_TOKEN = process.env.AUTH_TOKEN || ''; const ALLOWED_ORIGIN = process.env.ALLOWED_ORIGIN || 'http://localhost:3000'; const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || ''; + function timingSafeCompare(a, b) { if (typeof a !== 'string' || typeof b !== 'string') return false; - const bufA = Buffer.from(a); - const bufB = Buffer.from(b); - if (bufA.length !== bufB.length) return false; - return crypto.timingSafeEqual(bufA, bufB); + const hashA = crypto.createHash('sha256').update(a).digest(); + const hashB = crypto.createHash('sha256').update(b).digest(); + return crypto.timingSafeEqual(hashA, hashB); } const apiLimiter = rateLimit({ @@ -34,6 +37,14 @@ const apiLimiter = rateLimit({ message: { error: 'Limite de requisições excedido. Tente novamente em breve.' }, }); +const hookLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 30, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Limite de requisições de webhook excedido.' }, +}); + function verifyWebhookSignature(req, res, next) { if (!WEBHOOK_SECRET) return next(); const sig = req.headers['x-hub-signature-256']; @@ -77,24 +88,29 @@ app.get('/api/health', (req, res) => { }); }); +app.use(helmet({ + contentSecurityPolicy: false, +})); + +app.use(compression()); + app.use('/api', apiLimiter); -if (AUTH_TOKEN) { - app.use('/api', (req, res, next) => { - const header = req.headers.authorization || ''; - const token = header.startsWith('Bearer ') ? header.slice(7) : req.query.token; - if (!timingSafeCompare(token, AUTH_TOKEN)) { - return res.status(401).json({ error: 'Token de autenticação inválido' }); - } - next(); - }); -} +app.use('/api', (req, res, next) => { + if (!AUTH_TOKEN) return next(); + const header = req.headers.authorization || ''; + const token = header.startsWith('Bearer ') ? header.slice(7) : req.query.token; + if (!timingSafeCompare(token, AUTH_TOKEN)) { + return res.status(401).json({ error: 'Token de autenticação inválido' }); + } + next(); +}); app.use(express.json({ - verify: (req, res, buf) => { req.rawBody = buf; }, + verify: (req, res, buf) => { req.rawBody = buf || Buffer.alloc(0); }, })); -app.use('/hook', verifyWebhookSignature, hookRouter); -app.use(express.static(join(__dirname, 'public'))); +app.use('/hook', hookLimiter, verifyWebhookSignature, hookRouter); +app.use(express.static(join(__dirname, 'public'), { maxAge: '1h', etag: true })); app.use('/api', apiRouter); const connectedClients = new Map(); @@ -104,20 +120,30 @@ wss.on('connection', (ws, req) => { if (AUTH_TOKEN) { const token = new URL(req.url, 'http://localhost').searchParams.get('token'); - if (token !== AUTH_TOKEN) { + if (!timingSafeCompare(token, AUTH_TOKEN)) { ws.close(4001, 'Token inválido'); return; } } ws.clientId = clientId; + ws.isAlive = true; connectedClients.set(clientId, ws); + ws.on('pong', () => { ws.isAlive = true; }); ws.on('close', () => connectedClients.delete(clientId)); ws.on('error', () => connectedClients.delete(clientId)); ws.send(JSON.stringify({ type: 'connected', clientId })); }); +const wsHeartbeat = setInterval(() => { + wss.clients.forEach(ws => { + if (!ws.isAlive) return ws.terminate(); + ws.isAlive = false; + ws.ping(); + }); +}, 30000); + function broadcast(message) { const payload = JSON.stringify(message); for (const [, client] of connectedClients) { @@ -145,6 +171,8 @@ function gracefulShutdown(signal) { flushAllStores(); console.log('Dados persistidos.'); + clearInterval(wsHeartbeat); + httpServer.close(() => { console.log('Servidor HTTP encerrado.'); process.exit(0); @@ -159,10 +187,18 @@ function gracefulShutdown(signal) { process.on('SIGTERM', () => gracefulShutdown('SIGTERM')); process.on('SIGINT', () => gracefulShutdown('SIGINT')); +process.on('uncaughtException', (err) => { + console.error('[FATAL] Exceção não capturada:', err.message); + console.error(err.stack); +}); + +process.on('unhandledRejection', (reason) => { + console.error('[WARN] Promise rejeitada não tratada:', reason); +}); + manager.restoreSchedules(); -httpServer.listen(PORT, () => { - console.log(`Painel administrativo disponível em http://localhost:${PORT}`); +httpServer.listen(PORT, HOST, () => { + console.log(`Painel administrativo disponível em http://${HOST}:${PORT}`); console.log(`WebSocket server ativo na mesma porta.`); - if (AUTH_TOKEN) console.log('Autenticação por token ativada.'); }); diff --git a/src/agents/executor.js b/src/agents/executor.js index c1f61a0..e030c9c 100644 --- a/src/agents/executor.js +++ b/src/agents/executor.js @@ -1,10 +1,14 @@ import { spawn } from 'child_process'; import { existsSync } from 'fs'; +import path from 'path'; import { v4 as uuidv4 } from 'uuid'; import { settingsStore } from '../store/db.js'; const CLAUDE_BIN = resolveBin(); const activeExecutions = new Map(); +const MAX_OUTPUT_SIZE = 512 * 1024; +const MAX_ERROR_SIZE = 100 * 1024; +const ALLOWED_DIRECTORIES = (process.env.ALLOWED_DIRECTORIES || '').split(',').map(d => d.trim()).filter(Boolean); let maxConcurrent = settingsStore.get().maxConcurrent || 5; @@ -12,6 +16,12 @@ export function updateMaxConcurrent(value) { maxConcurrent = Math.max(1, Math.min(20, parseInt(value) || 5)); } +function isDirectoryAllowed(dir) { + if (ALLOWED_DIRECTORIES.length === 0) return true; + const resolved = path.resolve(dir); + return ALLOWED_DIRECTORIES.some(allowed => resolved.startsWith(path.resolve(allowed))); +} + function resolveBin() { if (process.env.CLAUDE_BIN) return process.env.CLAUDE_BIN; const home = process.env.HOME || ''; @@ -34,13 +44,16 @@ function sanitizeText(str) { .slice(0, 50000); } -function cleanEnv() { +function cleanEnv(agentSecrets) { const env = { ...process.env }; delete env.CLAUDECODE; delete env.ANTHROPIC_API_KEY; if (!env.CLAUDE_CODE_MAX_OUTPUT_TOKENS) { env.CLAUDE_CODE_MAX_OUTPUT_TOKENS = '128000'; } + if (agentSecrets && typeof agentSecrets === 'object') { + Object.assign(env, agentSecrets); + } return env; } @@ -161,52 +174,21 @@ function extractSystemInfo(event) { return null; } -export function execute(agentConfig, task, callbacks = {}) { - if (activeExecutions.size >= maxConcurrent) { - const err = new Error(`Limite de ${maxConcurrent} execuções simultâneas atingido`); - if (callbacks.onError) callbacks.onError(err, uuidv4()); - return null; - } - - const executionId = uuidv4(); +function processChildOutput(child, executionId, callbacks, options = {}) { const { onData, onError, onComplete } = callbacks; - - const prompt = buildPrompt(task.description || task, task.instructions); - const args = buildArgs(agentConfig, prompt); - - const spawnOptions = { - env: cleanEnv(), - stdio: ['ignore', 'pipe', 'pipe'], - }; - - if (agentConfig.workingDirectory && agentConfig.workingDirectory.trim()) { - if (!existsSync(agentConfig.workingDirectory)) { - const err = new Error(`Diretório de trabalho não encontrado: ${agentConfig.workingDirectory}`); - if (onError) onError(err, executionId); - return executionId; - } - spawnOptions.cwd = agentConfig.workingDirectory; - } - - console.log(`[executor] Iniciando: ${executionId} | Modelo: ${agentConfig.model || 'claude-sonnet-4-6'}`); - - const child = spawn(CLAUDE_BIN, args, spawnOptions); - let hadError = false; - - activeExecutions.set(executionId, { - process: child, - agentConfig, - task, - startedAt: new Date().toISOString(), - executionId, - }); - + const timeoutMs = options.timeout || 1800000; + const sessionIdOverride = options.sessionIdOverride || null; let outputBuffer = ''; let errorBuffer = ''; let fullText = ''; let resultMeta = null; - let turnCount = 0; + let hadError = false; + + const timeout = setTimeout(() => { + child.kill('SIGTERM'); + setTimeout(() => { if (!child.killed) child.kill('SIGKILL'); }, 5000); + }, timeoutMs); function processEvent(parsed) { if (!parsed) return; @@ -221,7 +203,9 @@ export function execute(agentConfig, task, callbacks = {}) { const text = extractText(parsed); if (text) { - fullText += text; + if (fullText.length < MAX_OUTPUT_SIZE) { + fullText += text; + } if (onData) onData({ type: 'chunk', content: text }, executionId); } @@ -242,7 +226,7 @@ export function execute(agentConfig, task, callbacks = {}) { durationMs: parsed.duration_ms || 0, durationApiMs: parsed.duration_api_ms || 0, numTurns: parsed.num_turns || 0, - sessionId: parsed.session_id || '', + sessionId: parsed.session_id || sessionIdOverride || '', }; } } @@ -255,7 +239,9 @@ export function execute(agentConfig, task, callbacks = {}) { child.stderr.on('data', (chunk) => { const str = chunk.toString(); - errorBuffer += str; + if (errorBuffer.length < MAX_ERROR_SIZE) { + errorBuffer += str; + } const lines = str.split('\n').filter(l => l.trim()); for (const line of lines) { if (onData) onData({ type: 'stderr', content: line.trim() }, executionId); @@ -263,6 +249,7 @@ export function execute(agentConfig, task, callbacks = {}) { }); child.on('error', (err) => { + clearTimeout(timeout); console.log(`[executor][error] ${err.message}`); hadError = true; activeExecutions.delete(executionId); @@ -270,21 +257,81 @@ export function execute(agentConfig, task, callbacks = {}) { }); child.on('close', (code) => { + clearTimeout(timeout); + const wasCanceled = activeExecutions.get(executionId)?.canceled || false; activeExecutions.delete(executionId); if (hadError) return; - if (outputBuffer.trim()) processEvent(parseStreamLine(outputBuffer)); - if (onComplete) { onComplete({ executionId, exitCode: code, result: fullText, stderr: errorBuffer, + canceled: wasCanceled, ...(resultMeta || {}), }, executionId); } }); +} + +function validateWorkingDirectory(agentConfig, executionId, onError) { + if (!agentConfig.workingDirectory || !agentConfig.workingDirectory.trim()) return true; + + if (!isDirectoryAllowed(agentConfig.workingDirectory)) { + const err = new Error(`Diretório de trabalho não permitido: ${agentConfig.workingDirectory}`); + if (onError) onError(err, executionId); + return false; + } + + if (!existsSync(agentConfig.workingDirectory)) { + const err = new Error(`Diretório de trabalho não encontrado: ${agentConfig.workingDirectory}`); + if (onError) onError(err, executionId); + return false; + } + + return true; +} + +export function execute(agentConfig, task, callbacks = {}, secrets = null) { + if (activeExecutions.size >= maxConcurrent) { + const err = new Error(`Limite de ${maxConcurrent} execuções simultâneas atingido`); + if (callbacks.onError) callbacks.onError(err, uuidv4()); + return null; + } + + const executionId = uuidv4(); + const { onData, onError, onComplete } = callbacks; + + if (!validateWorkingDirectory(agentConfig, executionId, onError)) return null; + + const prompt = buildPrompt(task.description || task, task.instructions); + const args = buildArgs(agentConfig, prompt); + + const spawnOptions = { + env: cleanEnv(secrets), + stdio: ['ignore', 'pipe', 'pipe'], + }; + + if (agentConfig.workingDirectory && agentConfig.workingDirectory.trim()) { + spawnOptions.cwd = agentConfig.workingDirectory; + } + + console.log(`[executor] Iniciando: ${executionId} | Modelo: ${agentConfig.model || 'claude-sonnet-4-6'}`); + + const child = spawn(CLAUDE_BIN, args, spawnOptions); + + activeExecutions.set(executionId, { + process: child, + agentConfig, + task, + startedAt: new Date().toISOString(), + executionId, + }); + + processChildOutput(child, executionId, { onData, onError, onComplete }, { + timeout: agentConfig.timeout || 1800000, + }); return executionId; } @@ -299,6 +346,8 @@ export function resume(agentConfig, sessionId, message, callbacks = {}) { const executionId = uuidv4(); const { onData, onError, onComplete } = callbacks; + if (!validateWorkingDirectory(agentConfig, executionId, onError)) return null; + const model = agentConfig.model || 'claude-sonnet-4-6'; const args = [ '--resume', sessionId, @@ -319,18 +368,12 @@ export function resume(agentConfig, sessionId, message, callbacks = {}) { }; if (agentConfig.workingDirectory && agentConfig.workingDirectory.trim()) { - if (!existsSync(agentConfig.workingDirectory)) { - const err = new Error(`Diretório de trabalho não encontrado: ${agentConfig.workingDirectory}`); - if (onError) onError(err, executionId); - return executionId; - } spawnOptions.cwd = agentConfig.workingDirectory; } console.log(`[executor] Resumindo sessão: ${sessionId} | Execução: ${executionId}`); const child = spawn(CLAUDE_BIN, args, spawnOptions); - let hadError = false; activeExecutions.set(executionId, { process: child, @@ -340,86 +383,9 @@ export function resume(agentConfig, sessionId, message, callbacks = {}) { executionId, }); - let outputBuffer = ''; - let errorBuffer = ''; - let fullText = ''; - let resultMeta = null; - let turnCount = 0; - - function processEvent(parsed) { - if (!parsed) return; - - const tools = extractToolInfo(parsed); - if (tools) { - for (const t of tools) { - const msg = t.detail ? `${t.name}: ${t.detail}` : t.name; - if (onData) onData({ type: 'tool', content: msg, toolName: t.name }, executionId); - } - } - - const text = extractText(parsed); - if (text) { - fullText += text; - if (onData) onData({ type: 'chunk', content: text }, executionId); - } - - const sysInfo = extractSystemInfo(parsed); - if (sysInfo) { - if (onData) onData({ type: 'system', content: sysInfo }, executionId); - } - - if (parsed.type === 'assistant') { - turnCount++; - if (onData) onData({ type: 'turn', content: `Turno ${turnCount}`, turn: turnCount }, executionId); - } - - if (parsed.type === 'result') { - resultMeta = { - costUsd: parsed.cost_usd || 0, - totalCostUsd: parsed.total_cost_usd || 0, - durationMs: parsed.duration_ms || 0, - durationApiMs: parsed.duration_api_ms || 0, - numTurns: parsed.num_turns || 0, - sessionId: parsed.session_id || sessionId, - }; - } - } - - child.stdout.on('data', (chunk) => { - const lines = (outputBuffer + chunk.toString()).split('\n'); - outputBuffer = lines.pop(); - for (const line of lines) processEvent(parseStreamLine(line)); - }); - - child.stderr.on('data', (chunk) => { - const str = chunk.toString(); - errorBuffer += str; - const lines = str.split('\n').filter(l => l.trim()); - for (const line of lines) { - if (onData) onData({ type: 'stderr', content: line.trim() }, executionId); - } - }); - - child.on('error', (err) => { - console.log(`[executor][error] ${err.message}`); - hadError = true; - activeExecutions.delete(executionId); - if (onError) onError(err, executionId); - }); - - child.on('close', (code) => { - activeExecutions.delete(executionId); - if (hadError) return; - if (outputBuffer.trim()) processEvent(parseStreamLine(outputBuffer)); - if (onComplete) { - onComplete({ - executionId, - exitCode: code, - result: fullText, - stderr: errorBuffer, - ...(resultMeta || {}), - }, executionId); - } + processChildOutput(child, executionId, { onData, onError, onComplete }, { + timeout: agentConfig.timeout || 1800000, + sessionIdOverride: sessionId, }); return executionId; @@ -428,8 +394,8 @@ export function resume(agentConfig, sessionId, message, callbacks = {}) { export function cancel(executionId) { const execution = activeExecutions.get(executionId); if (!execution) return false; + execution.canceled = true; execution.process.kill('SIGTERM'); - activeExecutions.delete(executionId); return true; } diff --git a/src/agents/manager.js b/src/agents/manager.js index d01837a..537bbcc 100644 --- a/src/agents/manager.js +++ b/src/agents/manager.js @@ -1,5 +1,6 @@ import { v4 as uuidv4 } from 'uuid'; -import { agentsStore, schedulesStore, executionsStore, notificationsStore } from '../store/db.js'; +import cron from 'node-cron'; +import { agentsStore, schedulesStore, executionsStore, notificationsStore, secretsStore, agentVersionsStore, withLock } from '../store/db.js'; import * as executor from './executor.js'; import * as scheduler from './scheduler.js'; import { generateAgentReport } from '../reports/generator.js'; @@ -99,6 +100,13 @@ export function createAgent(data) { export function updateAgent(id, data) { const existing = agentsStore.getById(id); if (!existing) return null; + + agentVersionsStore.create({ + agentId: id, + version: existing, + changedFields: Object.keys(data).filter(k => k !== 'id'), + }); + const updateData = {}; if (data.agent_name !== undefined) updateData.agent_name = data.agent_name; if (data.description !== undefined) updateData.description = data.description; @@ -114,25 +122,44 @@ export function deleteAgent(id) { return agentsStore.delete(id); } +function loadAgentSecrets(agentId) { + const all = secretsStore.getAll(); + const agentSecrets = all.filter(s => s.agentId === agentId); + if (agentSecrets.length === 0) return null; + const env = {}; + for (const s of agentSecrets) env[s.name] = s.value; + return env; +} + export function executeTask(agentId, task, instructions, wsCallback, metadata = {}) { const agent = agentsStore.getById(agentId); if (!agent) throw new Error(`Agente ${agentId} não encontrado`); if (agent.status !== 'active') throw new Error(`Agente ${agentId} está inativo`); + const retryEnabled = agent.config?.retryOnFailure === true; + const maxRetries = Math.min(Math.max(parseInt(agent.config?.maxRetries) || 1, 1), 3); + const attempt = metadata._retryAttempt || 1; + const cb = getWsCallback(wsCallback); const taskText = typeof task === 'string' ? task : task.description; const startedAt = new Date().toISOString(); - const historyRecord = executionsStore.create({ - type: 'agent', - ...metadata, - agentId, - agentName: agent.agent_name, - task: taskText, - instructions: instructions || '', - status: 'running', - startedAt, - }); + const historyRecord = metadata._historyRecordId + ? { id: metadata._historyRecordId } + : executionsStore.create({ + type: 'agent', + ...metadata, + agentId, + agentName: agent.agent_name, + task: taskText, + instructions: instructions || '', + status: 'running', + startedAt, + }); + + if (metadata._retryAttempt) { + executionsStore.update(historyRecord.id, { status: 'running', error: null }); + } const execRecord = { executionId: null, @@ -143,6 +170,8 @@ export function executeTask(agentId, task, instructions, wsCallback, metadata = status: 'running', }; + const agentSecrets = loadAgentSecrets(agentId); + const executionId = executor.execute( agent.config, { description: task, instructions }, @@ -153,6 +182,31 @@ export function executeTask(agentId, task, instructions, wsCallback, metadata = onError: (err, execId) => { const endedAt = new Date().toISOString(); updateExecutionRecord(agentId, execId, { status: 'error', error: err.message, endedAt }); + + if (retryEnabled && attempt < maxRetries) { + const delayMs = attempt * 5000; + executionsStore.update(historyRecord.id, { status: 'retrying', error: err.message, attempt, endedAt }); + if (cb) cb({ + type: 'execution_retry', + executionId: execId, + agentId, + data: { attempt, maxRetries, nextRetryIn: delayMs / 1000, reason: err.message }, + }); + setTimeout(() => { + try { + executeTask(agentId, task, instructions, wsCallback, { + ...metadata, + _retryAttempt: attempt + 1, + _historyRecordId: historyRecord.id, + }); + } catch (retryErr) { + executionsStore.update(historyRecord.id, { status: 'error', error: retryErr.message, endedAt: new Date().toISOString() }); + if (cb) cb({ type: 'execution_error', executionId: execId, agentId, data: { error: retryErr.message } }); + } + }, delayMs); + return; + } + executionsStore.update(historyRecord.id, { status: 'error', error: err.message, endedAt }); createNotification('error', 'Execução falhou', `Agente "${agent.agent_name}" encontrou um erro`, { agentId, executionId: execId }); if (cb) cb({ type: 'execution_error', executionId: execId, agentId, data: { error: err.message } }); @@ -178,10 +232,11 @@ export function executeTask(agentId, task, instructions, wsCallback, metadata = const report = generateAgentReport(updated); if (cb) cb({ type: 'report_generated', executionId: execId, agentId, reportFile: report.filename }); } - } catch (e) {} + } catch (e) { console.error('[manager] Erro ao gerar relatório:', e.message); } if (cb) cb({ type: 'execution_complete', executionId: execId, agentId, data: result }); }, - } + }, + agentSecrets ); if (!executionId) { @@ -203,18 +258,15 @@ export function executeTask(agentId, task, instructions, wsCallback, metadata = return executionId; } -function updateRecentBuffer(executionId, updates) { - const entry = recentExecBuffer.find((e) => e.executionId === executionId); - if (entry) Object.assign(entry, updates); -} - -function updateExecutionRecord(agentId, executionId, updates) { - const agent = agentsStore.getById(agentId); - if (!agent) return; - const executions = (agent.executions || []).map((exec) => - exec.executionId === executionId ? { ...exec, ...updates } : exec - ); - agentsStore.update(agentId, { executions }); +async function updateExecutionRecord(agentId, executionId, updates) { + await withLock(`agent:${agentId}`, () => { + const agent = agentsStore.getById(agentId); + if (!agent) return; + const executions = (agent.executions || []).map((exec) => + exec.executionId === executionId ? { ...exec, ...updates } : exec + ); + agentsStore.update(agentId, { executions }); + }); } export function getRecentExecutions(limit = 20) { @@ -225,6 +277,10 @@ export function scheduleTask(agentId, taskDescription, cronExpression, wsCallbac const agent = agentsStore.getById(agentId); if (!agent) throw new Error(`Agente ${agentId} não encontrado`); + if (!cron.validate(cronExpression)) { + throw new Error(`Expressão cron inválida: ${cronExpression}`); + } + const scheduleId = uuidv4(); const items = schedulesStore.getAll(); items.push({ @@ -314,7 +370,7 @@ export function continueConversation(agentId, sessionId, message, wsCallback) { const report = generateAgentReport(updated); if (cb) cb({ type: 'report_generated', executionId: execId, agentId, reportFile: report.filename }); } - } catch (e) {} + } catch (e) { console.error('[manager] Erro ao gerar relatório:', e.message); } if (cb) cb({ type: 'execution_complete', executionId: execId, agentId, data: result }); }, } diff --git a/src/agents/pipeline.js b/src/agents/pipeline.js index 391435d..d04de0f 100644 --- a/src/agents/pipeline.js +++ b/src/agents/pipeline.js @@ -100,9 +100,9 @@ function executeStepAsPromise(agentConfig, prompt, pipelineState, wsCallback, pi }); } -function waitForApproval(pipelineId, stepIndex, previousOutput, agentName, wsCallback) { +function waitForApproval(executionId, pipelineId, stepIndex, previousOutput, agentName, wsCallback) { return new Promise((resolve) => { - const state = activePipelines.get(pipelineId); + const state = activePipelines.get(executionId); if (!state) { resolve(false); return; } state.pendingApproval = { @@ -116,6 +116,7 @@ function waitForApproval(pipelineId, stepIndex, previousOutput, agentName, wsCal wsCallback({ type: 'pipeline_approval_required', pipelineId, + executionId, stepIndex, agentName, previousOutput: previousOutput.slice(0, 3000), @@ -124,8 +125,16 @@ function waitForApproval(pipelineId, stepIndex, previousOutput, agentName, wsCal }); } -export function approvePipelineStep(pipelineId) { - const state = activePipelines.get(pipelineId); +function findPipelineState(idOrExecId) { + if (activePipelines.has(idOrExecId)) return activePipelines.get(idOrExecId); + for (const [, state] of activePipelines) { + if (state.pipelineId === idOrExecId) return state; + } + return null; +} + +export function approvePipelineStep(id) { + const state = findPipelineState(id); if (!state?.pendingApproval) return false; const { resolve } = state.pendingApproval; state.pendingApproval = null; @@ -133,8 +142,8 @@ export function approvePipelineStep(pipelineId) { return true; } -export function rejectPipelineStep(pipelineId) { - const state = activePipelines.get(pipelineId); +export function rejectPipelineStep(id) { + const state = findPipelineState(id); if (!state?.pendingApproval) return false; const { resolve } = state.pendingApproval; state.pendingApproval = null; @@ -145,9 +154,11 @@ export function rejectPipelineStep(pipelineId) { export async function executePipeline(pipelineId, initialInput, wsCallback, options = {}) { const pl = pipelinesStore.getById(pipelineId); if (!pl) throw new Error(`Pipeline ${pipelineId} não encontrado`); + if (pl.status !== 'active') throw new Error(`Pipeline "${pl.name}" está desativado`); - const pipelineState = { currentExecutionId: null, currentStep: 0, canceled: false, pendingApproval: null }; - activePipelines.set(pipelineId, pipelineState); + const executionId = uuidv4(); + const pipelineState = { pipelineId, currentExecutionId: null, currentStep: 0, canceled: false, pendingApproval: null }; + activePipelines.set(executionId, pipelineState); const historyRecord = executionsStore.create({ type: 'pipeline', @@ -181,7 +192,7 @@ export async function executePipeline(pipelineId, initialInput, wsCallback, opti wsCallback({ type: 'pipeline_status', pipelineId, status: 'awaiting_approval', stepIndex: i }); } - const approved = await waitForApproval(pipelineId, i, currentInput, prevAgentName, wsCallback); + const approved = await waitForApproval(executionId, pipelineId, i, currentInput, prevAgentName, wsCallback); if (!approved) { pipelineState.canceled = true; @@ -257,7 +268,7 @@ export async function executePipeline(pipelineId, initialInput, wsCallback, opti } } - activePipelines.delete(pipelineId); + activePipelines.delete(executionId); const finalStatus = pipelineState.canceled ? 'canceled' : 'completed'; executionsStore.update(historyRecord.id, { @@ -273,13 +284,13 @@ export async function executePipeline(pipelineId, initialInput, wsCallback, opti const report = generatePipelineReport(updated); if (wsCallback) wsCallback({ type: 'report_generated', pipelineId, reportFile: report.filename }); } - } catch (e) {} - if (wsCallback) wsCallback({ type: 'pipeline_complete', pipelineId, results, totalCostUsd: totalCost }); + } catch (e) { console.error('[pipeline] Erro ao gerar relatório:', e.message); } + if (wsCallback) wsCallback({ type: 'pipeline_complete', pipelineId, executionId, results, totalCostUsd: totalCost }); } - return results; + return { executionId, results }; } catch (err) { - activePipelines.delete(pipelineId); + activePipelines.delete(executionId); executionsStore.update(historyRecord.id, { status: 'error', error: err.message, @@ -298,8 +309,14 @@ export async function executePipeline(pipelineId, initialInput, wsCallback, opti } } -export function cancelPipeline(pipelineId) { - const state = activePipelines.get(pipelineId); +export function cancelPipeline(id) { + let executionId = id; + let state = activePipelines.get(id); + if (!state) { + for (const [execId, s] of activePipelines) { + if (s.pipelineId === id) { state = s; executionId = execId; break; } + } + } if (!state) return false; state.canceled = true; if (state.pendingApproval) { @@ -307,14 +324,12 @@ export function cancelPipeline(pipelineId) { state.pendingApproval = null; } if (state.currentExecutionId) executor.cancel(state.currentExecutionId); - activePipelines.delete(pipelineId); + activePipelines.delete(executionId); const allExecs = executionsStore.getAll(); - const idx = allExecs.findIndex(e => e.pipelineId === pipelineId && (e.status === 'running' || e.status === 'awaiting_approval')); - if (idx !== -1) { - allExecs[idx].status = 'canceled'; - allExecs[idx].endedAt = new Date().toISOString(); - executionsStore.save(allExecs); + const exec = allExecs.find(e => e.pipelineId === state.pipelineId && (e.status === 'running' || e.status === 'awaiting_approval')); + if (exec) { + executionsStore.update(exec.id, { status: 'canceled', endedAt: new Date().toISOString() }); } return true; @@ -322,7 +337,8 @@ export function cancelPipeline(pipelineId) { export function getActivePipelines() { return Array.from(activePipelines.entries()).map(([id, state]) => ({ - pipelineId: id, + executionId: id, + pipelineId: state.pipelineId, currentStep: state.currentStep, currentExecutionId: state.currentExecutionId, pendingApproval: !!state.pendingApproval, diff --git a/src/agents/scheduler.js b/src/agents/scheduler.js index 0c9b241..9abd90c 100644 --- a/src/agents/scheduler.js +++ b/src/agents/scheduler.js @@ -17,7 +17,11 @@ function addToHistory(entry) { function matchesCronPart(part, value) { if (part === '*') return true; - if (part.startsWith('*/')) return value % parseInt(part.slice(2)) === 0; + if (part.startsWith('*/')) { + const divisor = parseInt(part.slice(2)); + if (!divisor || divisor <= 0) return false; + return value % divisor === 0; + } if (part.includes(',')) return part.split(',').map(Number).includes(value); if (part.includes('-')) { const [start, end] = part.split('-').map(Number); @@ -66,6 +70,17 @@ export function schedule(taskId, cronExpr, callback, persist = true) { if (schedules.has(taskId)) unschedule(taskId, false); if (!cron.validate(cronExpr)) throw new Error(`Expressão cron inválida: ${cronExpr}`); + const MIN_INTERVAL_PARTS = cronExpr.split(' '); + if (MIN_INTERVAL_PARTS[0] === '*' && MIN_INTERVAL_PARTS[1] === '*') { + throw new Error('Intervalo mínimo de agendamento é 5 minutos. Use */5 ou maior.'); + } + if (MIN_INTERVAL_PARTS[0].startsWith('*/')) { + const interval = parseInt(MIN_INTERVAL_PARTS[0].slice(2)); + if (interval < 5 && MIN_INTERVAL_PARTS[1] === '*') { + throw new Error(`Intervalo mínimo de agendamento é 5 minutos. Recebido: ${cronExpr}`); + } + } + const task = cron.schedule( cronExpr, () => { diff --git a/src/routes/api.js b/src/routes/api.js index 7a39975..e4bbc93 100644 --- a/src/routes/api.js +++ b/src/routes/api.js @@ -1,17 +1,17 @@ import { Router } from 'express'; -import { execSync } from 'child_process'; +import { execFile } 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, webhooksStore, notificationsStore } from '../store/db.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 } from '../agents/executor.js'; import { invalidateAgentMapCache } from '../agents/pipeline.js'; import { cached } from '../cache/index.js'; import { readdirSync, readFileSync, unlinkSync, existsSync } from 'fs'; -import { join, dirname } from 'path'; +import { join, dirname, resolve as pathResolve } from 'path'; import { fileURLToPath } from 'url'; const __apiDirname = dirname(fileURLToPath(import.meta.url)); @@ -169,6 +169,92 @@ router.get('/agents/:id/export', (req, res) => { } }); +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); @@ -329,17 +415,18 @@ router.delete('/pipelines/:id', (req, res) => { } }); -router.post('/pipelines/:id/execute', (req, res) => { +router.post('/pipelines/:id/execute', async (req, res) => { try { const { input, workingDirectory } = 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; - pipeline.executePipeline(req.params.id, input, (msg) => wsCallback(msg, clientId), options).catch(() => {}); + const result = pipeline.executePipeline(req.params.id, input, (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') ? 404 : 400; + const status = err.message.includes('não encontrado') || err.message.includes('desativado') ? 400 : 500; res.status(status).json({ error: err.message }); } }); @@ -409,18 +496,17 @@ router.post('/webhooks', (req, res) => { } }); -router.put('/webhooks/:id', async (req, res) => { +router.put('/webhooks/:id', (req, res) => { try { - const webhooks = webhooksStore.getAll(); - const idx = webhooks.findIndex(w => w.id === req.params.id); - if (idx === -1) return res.status(404).json({ error: 'Webhook não encontrado' }); + 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) webhooks[idx][key] = req.body[key]; + if (req.body[key] !== undefined) updateData[key] = req.body[key]; } - webhooks[idx].updated_at = new Date().toISOString(); - webhooksStore.save(webhooks); - res.json(webhooks[idx]); + const updated = webhooksStore.update(req.params.id, updateData); + res.json(updated); } catch (err) { res.status(400).json({ error: err.message }); } @@ -428,10 +514,22 @@ router.put('/webhooks/:id', async (req, res) => { router.post('/webhooks/:id/test', async (req, res) => { try { - const webhooks = webhooksStore.getAll(); - const wh = webhooks.find(w => w.id === req.params.id); + const wh = webhooksStore.getById(req.params.id); if (!wh) return res.status(404).json({ error: 'Webhook não encontrado' }); - res.json({ success: true, message: 'Webhook testado com sucesso', webhook: { id: wh.id, name: wh.name, targetType: wh.targetType } }); + + 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 }); } @@ -471,12 +569,12 @@ hookRouter.post('/:token', (req, res) => { 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(() => {}); + }).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; @@ -590,11 +688,16 @@ router.get('/system/status', (req, res) => { let claudeVersionCache = null; -router.get('/system/info', (req, res) => { +router.get('/system/info', async (req, res) => { try { if (claudeVersionCache === null) { try { - claudeVersionCache = execSync(`${getBinPath()} --version`, { timeout: 5000 }).toString().trim(); + 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'; } @@ -783,24 +886,22 @@ router.get('/notifications', async (req, res) => { } }); -router.post('/notifications/:id/read', async (req, res) => { +router.post('/notifications/:id/read', (req, res) => { try { - const notifications = notificationsStore.getAll(); - const n = notifications.find(n => n.id === req.params.id); - if (!n) return res.status(404).json({ error: 'Notificação não encontrada' }); - n.read = true; - notificationsStore.save(notifications); + 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', async (req, res) => { +router.post('/notifications/read-all', (req, res) => { try { const notifications = notificationsStore.getAll(); - notifications.forEach(n => n.read = true); - notificationsStore.save(notifications); + 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 }); @@ -834,6 +935,10 @@ router.get('/reports/:filename', (req, res) => { 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 }); @@ -846,6 +951,10 @@ 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 }); diff --git a/src/store/db.js b/src/store/db.js index 6069979..3f5bf35 100644 --- a/src/store/db.js +++ b/src/store/db.js @@ -1,4 +1,5 @@ import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync } from 'fs'; +import { writeFile, rename } from 'fs/promises'; import { dirname } from 'path'; import { fileURLToPath } from 'url'; import { v4 as uuidv4 } from 'uuid'; @@ -35,6 +36,13 @@ function writeJson(path, data) { renameSync(tmpPath, path); } +async function writeJsonAsync(path, data) { + ensureDir(); + const tmpPath = path + '.tmp'; + await writeFile(tmpPath, JSON.stringify(data, null, 2), 'utf8'); + await rename(tmpPath, path); +} + function clone(v) { return structuredClone(v); } @@ -57,7 +65,7 @@ function createStore(filePath) { timer = setTimeout(() => { timer = null; if (dirty) { - writeJson(filePath, mem); + writeJsonAsync(filePath, mem).catch((e) => console.error(`[db] Erro ao salvar ${filePath}:`, e.message)); dirty = false; } }, DEBOUNCE_MS); @@ -75,6 +83,20 @@ function createStore(filePath) { return item ? clone(item) : null; }, + findById(id) { + return store.getById(id); + }, + + count() { + boot(); + return mem.length; + }, + + filter(predicate) { + boot(); + return mem.filter(predicate).map((item) => clone(item)); + }, + create(data) { boot(); const item = { @@ -110,7 +132,8 @@ function createStore(filePath) { }, save(items) { - mem = Array.isArray(items) ? items : mem; + if (!Array.isArray(items)) return; + mem = items; touch(); }, @@ -186,6 +209,21 @@ function createSettingsStore(filePath) { return store; } +const locks = new Map(); + +export async function withLock(key, fn) { + while (locks.has(key)) await locks.get(key); + let resolve; + const promise = new Promise((r) => { resolve = r; }); + locks.set(key, promise); + try { + return await fn(); + } finally { + locks.delete(key); + resolve(); + } +} + export function flushAllStores() { for (const s of allStores) s.flush(); }