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 @@
+
+
+
+
+
+
+
+
+
+
+
+
Carregando versões...
+
+
+
+
+
`;
@@ -158,7 +162,23 @@ const AgentsUI = {
const permissionMode = document.getElementById('agent-permission-mode');
if (permissionMode) permissionMode.value = '';
+ const retryToggle = document.getElementById('agent-retry-toggle');
+ if (retryToggle) retryToggle.checked = false;
+
+ const retryMaxGroup = document.getElementById('agent-retry-max-group');
+ if (retryMaxGroup) retryMaxGroup.style.display = 'none';
+
+ const retryMax = document.getElementById('agent-retry-max');
+ if (retryMax) retryMax.value = '3';
+
+ const secretsSection = document.getElementById('agent-secrets-section');
+ if (secretsSection) secretsSection.hidden = true;
+
+ const secretsList = document.getElementById('agent-secrets-list');
+ if (secretsList) secretsList.innerHTML = '';
+
Modal.open('agent-modal-overlay');
+ AgentsUI._setupModalListeners();
},
async openEditModal(agentId) {
@@ -199,7 +219,23 @@ const AgentsUI = {
).join('');
}
+ const retryToggle = document.getElementById('agent-retry-toggle');
+ const retryOnFailure = agent.config && agent.config.retryOnFailure;
+ if (retryToggle) retryToggle.checked = !!retryOnFailure;
+
+ const retryMaxGroup = document.getElementById('agent-retry-max-group');
+ if (retryMaxGroup) retryMaxGroup.style.display = retryOnFailure ? '' : 'none';
+
+ const retryMax = document.getElementById('agent-retry-max');
+ if (retryMax) retryMax.value = (agent.config && agent.config.maxRetries) || '3';
+
+ const secretsSection = document.getElementById('agent-secrets-section');
+ if (secretsSection) secretsSection.hidden = false;
+
+ AgentsUI._loadSecrets(agent.id);
+
Modal.open('agent-modal-overlay');
+ AgentsUI._setupModalListeners();
} catch (err) {
Toast.error(`Erro ao carregar agente: ${err.message}`);
}
@@ -237,6 +273,8 @@ const AgentsUI = {
allowedTools: document.getElementById('agent-allowed-tools')?.value.trim() || '',
maxTurns: parseInt(document.getElementById('agent-max-turns')?.value) || 0,
permissionMode: document.getElementById('agent-permission-mode')?.value || '',
+ retryOnFailure: !!document.getElementById('agent-retry-toggle')?.checked,
+ maxRetries: parseInt(document.getElementById('agent-retry-max')?.value) || 3,
},
};
@@ -406,6 +444,223 @@ const AgentsUI = {
minute: '2-digit',
});
},
+
+ _setupModalListeners() {
+ const retryToggle = document.getElementById('agent-retry-toggle');
+ const retryMaxGroup = document.getElementById('agent-retry-max-group');
+
+ if (retryToggle && !retryToggle._listenerAdded) {
+ retryToggle._listenerAdded = true;
+ retryToggle.addEventListener('change', () => {
+ if (retryMaxGroup) retryMaxGroup.style.display = retryToggle.checked ? '' : 'none';
+ });
+ }
+
+ const addSecretBtn = document.getElementById('agent-secret-add-btn');
+ if (addSecretBtn && !addSecretBtn._listenerAdded) {
+ addSecretBtn._listenerAdded = true;
+ addSecretBtn.addEventListener('click', () => {
+ const agentId = document.getElementById('agent-form-id')?.value;
+ if (agentId) {
+ AgentsUI._addSecret(agentId);
+ } else {
+ Toast.warning('Salve o agente primeiro para adicionar secrets');
+ }
+ });
+ }
+ },
+
+ async _loadSecrets(agentId) {
+ const list = document.getElementById('agent-secrets-list');
+ if (!list) return;
+
+ try {
+ const secrets = await API.secrets.list(agentId);
+ const items = Array.isArray(secrets) ? secrets : (secrets?.secrets || []);
+
+ if (items.length === 0) {
+ list.innerHTML = '
Nenhum secret configurado.
';
+ return;
+ }
+
+ list.innerHTML = items.map(s => `
+
+ ${Utils.escapeHtml(s.name || s)}
+ ••••••••
+
+
+ `).join('');
+
+ Utils.refreshIcons(list);
+
+ list.querySelectorAll('[data-secret-delete]').forEach(btn => {
+ btn.addEventListener('click', () => {
+ AgentsUI._deleteSecret(btn.dataset.agentId, btn.dataset.secretDelete);
+ });
+ });
+ } catch {
+ list.innerHTML = '
Erro ao carregar secrets.
';
+ }
+ },
+
+ async _addSecret(agentId) {
+ const nameEl = document.getElementById('agent-secret-name');
+ const valueEl = document.getElementById('agent-secret-value');
+ const name = nameEl?.value.trim();
+ const value = valueEl?.value;
+
+ if (!name) {
+ Toast.warning('Nome do secret é obrigatório');
+ return;
+ }
+
+ if (!value) {
+ Toast.warning('Valor do secret é obrigatório');
+ return;
+ }
+
+ try {
+ await API.secrets.create(agentId, { name, value });
+ Toast.success(`Secret "${name}" salvo`);
+ if (nameEl) nameEl.value = '';
+ if (valueEl) valueEl.value = '';
+ AgentsUI._loadSecrets(agentId);
+ } catch (err) {
+ Toast.error(`Erro ao salvar secret: ${err.message}`);
+ }
+ },
+
+ async _deleteSecret(agentId, secretName) {
+ const confirmed = await Modal.confirm(
+ 'Remover secret',
+ `Tem certeza que deseja remover o secret "${secretName}"?`
+ );
+ if (!confirmed) return;
+
+ try {
+ await API.secrets.delete(agentId, secretName);
+ Toast.success(`Secret "${secretName}" removido`);
+ AgentsUI._loadSecrets(agentId);
+ } catch (err) {
+ Toast.error(`Erro ao remover secret: ${err.message}`);
+ }
+ },
+
+ async openVersionsModal(agentId) {
+ const agent = AgentsUI.agents.find(a => a.id === agentId);
+ const titleEl = document.getElementById('agent-versions-title');
+ const contentEl = document.getElementById('agent-versions-content');
+
+ if (titleEl) titleEl.textContent = `Versões — ${agent?.agent_name || agent?.name || 'Agente'}`;
+
+ if (contentEl) {
+ contentEl.innerHTML = '
';
+ }
+
+ Modal.open('agent-versions-modal-overlay');
+
+ try {
+ const versions = await API.versions.list(agentId);
+ const items = Array.isArray(versions) ? versions : (versions?.versions || []);
+
+ if (!contentEl) return;
+
+ if (items.length === 0) {
+ contentEl.innerHTML = `
+
+
+
Sem histórico de versões
+
As alterações neste agente serão registradas aqui automaticamente.
+
`;
+ Utils.refreshIcons(contentEl);
+ return;
+ }
+
+ contentEl.innerHTML = `
+
+ ${items.map((v, i) => {
+ const date = v.changedAt ? new Date(v.changedAt).toLocaleString('pt-BR') : '—';
+ const changedFields = AgentsUI._getChangedFields(v);
+ const isLatest = i === 0;
+
+ return `
+
+
+
+ ${i < items.length - 1 ? '
' : ''}
+
+
+
+ ${changedFields ? `
${changedFields}
` : ''}
+ ${v.changelog ? `
${Utils.escapeHtml(v.changelog)}
` : ''}
+
+
+ `;
+ }).join('')}
+
`;
+
+ Utils.refreshIcons(contentEl);
+
+ contentEl.querySelectorAll('[data-restore-version]').forEach(btn => {
+ btn.addEventListener('click', async () => {
+ const version = btn.dataset.restoreVersion;
+ const aid = btn.dataset.agentId;
+ const confirmed = await Modal.confirm(
+ 'Restaurar versão',
+ `Deseja restaurar a versão v${version} deste agente? A configuração atual será substituída.`
+ );
+ if (!confirmed) return;
+
+ try {
+ await API.versions.restore(aid, version);
+ Toast.success(`Versão v${version} restaurada`);
+ Modal.close('agent-versions-modal-overlay');
+ await AgentsUI.load();
+ } catch (err) {
+ Toast.error(`Erro ao restaurar versão: ${err.message}`);
+ }
+ });
+ });
+ } catch (err) {
+ if (contentEl) {
+ contentEl.innerHTML = `
+
+
+
Erro ao carregar versões
+
${Utils.escapeHtml(err.message)}
+
`;
+ Utils.refreshIcons(contentEl);
+ }
+ }
+ },
+
+ _getChangedFields(version) {
+ if (!version.config) return '';
+ const fieldLabels = {
+ systemPrompt: 'System Prompt',
+ model: 'Modelo',
+ workingDirectory: 'Diretório',
+ allowedTools: 'Ferramentas',
+ maxTurns: 'Max Turns',
+ permissionMode: 'Permission Mode',
+ retryOnFailure: 'Retry',
+ };
+
+ const fields = Object.keys(version.config || {}).filter(k => fieldLabels[k]);
+ if (fields.length === 0) return '';
+
+ return fields.map(f =>
+ `
${fieldLabels[f] || f}`
+ ).join('');
+ },
};
window.AgentsUI = AgentsUI;
diff --git a/public/js/components/dashboard.js b/public/js/components/dashboard.js
index 8ea3b02..3381280 100644
--- a/public/js/components/dashboard.js
+++ b/public/js/components/dashboard.js
@@ -369,6 +369,18 @@ const DashboardUI = {
wsBadge.textContent = wsConnected ? 'Conectado' : 'Desconectado';
wsBadge.className = `badge ${wsConnected ? 'badge--green' : 'badge--red'}`;
}
+
+ const claudeBadge = document.getElementById('system-claude-status-badge');
+ if (claudeBadge) {
+ API.system.info().then((info) => {
+ const available = info.claudeVersion && info.claudeVersion !== 'N/A';
+ claudeBadge.textContent = available ? info.claudeVersion : 'Indisponível';
+ claudeBadge.className = `badge ${available ? 'badge--green' : 'badge--red'}`;
+ }).catch(() => {
+ claudeBadge.textContent = 'Indisponível';
+ claudeBadge.className = 'badge badge--red';
+ });
+ }
},
_statusBadgeClass(status) {
diff --git a/public/js/components/pipelines.js b/public/js/components/pipelines.js
index 3a2fbd3..e3b9f27 100644
--- a/public/js/components/pipelines.js
+++ b/public/js/components/pipelines.js
@@ -119,13 +119,14 @@ const PipelinesUI = {
Executar
-
-
+
+
+
+
`;
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();
}