Correções de bugs, layout de cards e webhook test funcional
- Pipeline cancel/approve/reject corrigido com busca bidirecional - Secrets injetados no executor via cleanEnv - Versionamento automático ao atualizar agentes - writeJsonAsync com log de erro - Removido asyncHandler.js (código morto) - Restaurado permissionMode padrão bypassPermissions - Ícones dos cards alinhados à direita com wrapper - Botão Editar convertido para ícone nos cards - Webhook test agora dispara execução real do agente/pipeline - Corrigido App.navigateTo no teste de webhook
This commit is contained in:
59
package-lock.json
generated
59
package-lock.json
generated
@@ -8,8 +8,10 @@
|
|||||||
"name": "agents-orchestrator",
|
"name": "agents-orchestrator",
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"compression": "^1.8.1",
|
||||||
"express": "^4.21.0",
|
"express": "^4.21.0",
|
||||||
"express-rate-limit": "^8.2.1",
|
"express-rate-limit": "^8.2.1",
|
||||||
|
"helmet": "^8.1.0",
|
||||||
"node-cron": "^3.0.3",
|
"node-cron": "^3.0.3",
|
||||||
"uuid": "^10.0.0",
|
"uuid": "^10.0.0",
|
||||||
"ws": "^8.18.0"
|
"ws": "^8.18.0"
|
||||||
@@ -96,6 +98,45 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/content-disposition": {
|
||||||
"version": "0.5.4",
|
"version": "0.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||||
@@ -416,6 +457,15 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/http-errors": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||||
@@ -601,6 +651,15 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/parseurl": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||||
|
|||||||
@@ -9,8 +9,10 @@
|
|||||||
"dev": "node --watch server.js"
|
"dev": "node --watch server.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"compression": "^1.8.1",
|
||||||
"express": "^4.21.0",
|
"express": "^4.21.0",
|
||||||
"express-rate-limit": "^8.2.1",
|
"express-rate-limit": "^8.2.1",
|
||||||
|
"helmet": "^8.1.0",
|
||||||
"node-cron": "^3.0.3",
|
"node-cron": "^3.0.3",
|
||||||
"uuid": "^10.0.0",
|
"uuid": "^10.0.0",
|
||||||
"ws": "^8.18.0"
|
"ws": "^8.18.0"
|
||||||
|
|||||||
@@ -529,12 +529,19 @@ textarea {
|
|||||||
|
|
||||||
.agent-actions {
|
.agent-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 12px 20px;
|
padding: 12px 16px;
|
||||||
border-top: 1px solid var(--border-primary);
|
border-top: 1px solid var(--border-primary);
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.agent-actions-icons {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.badge {
|
.badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -683,7 +690,9 @@ textarea {
|
|||||||
.btn-sm.btn-icon {
|
.btn-sm.btn-icon {
|
||||||
width: 30px;
|
width: 30px;
|
||||||
height: 30px;
|
height: 30px;
|
||||||
|
min-width: 30px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-lg {
|
.btn-lg {
|
||||||
@@ -3243,6 +3252,7 @@ tbody tr:hover td {
|
|||||||
|
|
||||||
.agent-card-actions {
|
.agent-card-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
border-top: 1px solid var(--border-primary);
|
border-top: 1px solid var(--border-primary);
|
||||||
@@ -4268,3 +4278,235 @@ body, .sidebar, .header, .card, .modal-content, .input, .select, textarea, .metr
|
|||||||
.report-toast:hover {
|
.report-toast:hover {
|
||||||
text-decoration: underline;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -838,6 +838,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Retry em caso de falha</label>
|
||||||
|
<div class="toggle-wrapper">
|
||||||
|
<input type="checkbox" class="toggle-input" id="agent-retry-toggle" name="retryOnFailure" role="switch" />
|
||||||
|
<label class="toggle-label" for="agent-retry-toggle">
|
||||||
|
<span class="toggle-thumb"></span>
|
||||||
|
<span class="toggle-text-on">Sim</span>
|
||||||
|
<span class="toggle-text-off">Não</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" id="agent-retry-max-group" style="display:none;">
|
||||||
|
<label class="form-label" for="agent-retry-max">Máximo de tentativas</label>
|
||||||
|
<select class="select" id="agent-retry-max" name="maxRetries">
|
||||||
|
<option value="1">1</option>
|
||||||
|
<option value="2">2</option>
|
||||||
|
<option value="3" selected>3</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="agent-tags-input">Tags</label>
|
<label class="form-label" for="agent-tags-input">Tags</label>
|
||||||
<div class="tags-input-wrapper" id="agent-tags-wrapper">
|
<div class="tags-input-wrapper" id="agent-tags-wrapper">
|
||||||
@@ -852,6 +874,24 @@
|
|||||||
</div>
|
</div>
|
||||||
<input type="hidden" id="agent-tags" name="tags" value="[]" />
|
<input type="hidden" id="agent-tags" name="tags" value="[]" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-divider"></div>
|
||||||
|
<div class="form-section" id="agent-secrets-section" hidden>
|
||||||
|
<h3 class="form-section-title">
|
||||||
|
<i data-lucide="key-round"></i>
|
||||||
|
Variáveis de Ambiente (Secrets)
|
||||||
|
</h3>
|
||||||
|
<p class="form-hint mb-12">Secrets são injetados como variáveis de ambiente na execução. Valores nunca são exibidos após salvos.</p>
|
||||||
|
<div id="agent-secrets-list" class="secrets-list"></div>
|
||||||
|
<div class="secrets-add-form">
|
||||||
|
<input type="text" class="input" id="agent-secret-name" placeholder="NOME_DA_VARIAVEL" autocomplete="off" />
|
||||||
|
<input type="password" class="input" id="agent-secret-value" placeholder="valor secreto" autocomplete="new-password" />
|
||||||
|
<button type="button" class="btn btn--primary btn--sm" id="agent-secret-add-btn">
|
||||||
|
<i data-lucide="plus"></i>
|
||||||
|
Adicionar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
@@ -861,6 +901,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="agent-versions-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="agent-versions-title" hidden>
|
||||||
|
<div class="modal modal--lg">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 class="modal-title" id="agent-versions-title">Histórico de Versões</h2>
|
||||||
|
<button class="modal-close" type="button" aria-label="Fechar modal" data-modal-close="agent-versions-modal-overlay">
|
||||||
|
<i data-lucide="x"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" id="agent-versions-content">
|
||||||
|
<div class="empty-state">
|
||||||
|
<i data-lucide="history"></i>
|
||||||
|
<p>Carregando versões...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="modal-overlay" id="execute-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="execute-modal-title" hidden>
|
<div class="modal-overlay" id="execute-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="execute-modal-title" hidden>
|
||||||
<div class="modal modal--md">
|
<div class="modal modal--md">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
|
|||||||
@@ -46,6 +46,17 @@ const API = {
|
|||||||
duplicate(id) { return API.request('POST', `/agents/${id}/duplicate`); },
|
duplicate(id) { return API.request('POST', `/agents/${id}/duplicate`); },
|
||||||
},
|
},
|
||||||
|
|
||||||
|
secrets: {
|
||||||
|
list(agentId) { return API.request('GET', `/agents/${agentId}/secrets`); },
|
||||||
|
create(agentId, data) { return API.request('POST', `/agents/${agentId}/secrets`, data); },
|
||||||
|
delete(agentId, name) { return API.request('DELETE', `/agents/${agentId}/secrets/${encodeURIComponent(name)}`); },
|
||||||
|
},
|
||||||
|
|
||||||
|
versions: {
|
||||||
|
list(agentId) { return API.request('GET', `/agents/${agentId}/versions`); },
|
||||||
|
restore(agentId, version) { return API.request('POST', `/agents/${agentId}/versions/${version}/restore`); },
|
||||||
|
},
|
||||||
|
|
||||||
tasks: {
|
tasks: {
|
||||||
list() { return API.request('GET', '/tasks'); },
|
list() { return API.request('GET', '/tasks'); },
|
||||||
create(data) { return API.request('POST', '/tasks', data); },
|
create(data) { return API.request('POST', '/tasks', data); },
|
||||||
|
|||||||
@@ -241,6 +241,15 @@ const App = {
|
|||||||
App._updateActiveBadge();
|
App._updateActiveBadge();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'execution_retry':
|
||||||
|
Terminal.stopProcessing();
|
||||||
|
Terminal.addLine(
|
||||||
|
`Retry ${data.attempt || '?'}/${data.maxRetries || '?'} — próxima tentativa em ${data.nextRetryIn || '?'}s. Motivo: ${data.reason || 'erro na execução'}`,
|
||||||
|
'warning',
|
||||||
|
data.executionId
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
case 'pipeline_step_output': {
|
case 'pipeline_step_output': {
|
||||||
Terminal.stopProcessing();
|
Terminal.stopProcessing();
|
||||||
const stepEvtType = data.data?.type || 'chunk';
|
const stepEvtType = data.data?.type || 'chunk';
|
||||||
@@ -652,6 +661,7 @@ const App = {
|
|||||||
case 'export': AgentsUI.export(id); break;
|
case 'export': AgentsUI.export(id); break;
|
||||||
case 'delete': AgentsUI.delete(id); break;
|
case 'delete': AgentsUI.delete(id); break;
|
||||||
case 'duplicate': AgentsUI.duplicate(id); break;
|
case 'duplicate': AgentsUI.duplicate(id); break;
|
||||||
|
case 'versions': AgentsUI.openVersionsModal(id); break;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -112,19 +112,23 @@ const AgentsUI = {
|
|||||||
<i data-lucide="play"></i>
|
<i data-lucide="play"></i>
|
||||||
Executar
|
Executar
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-ghost btn-sm" data-action="edit" data-id="${agent.id}">
|
<div class="agent-actions-icons">
|
||||||
<i data-lucide="pencil"></i>
|
<button class="btn btn-ghost btn-icon btn-sm" data-action="edit" data-id="${agent.id}" title="Editar agente">
|
||||||
Editar
|
<i data-lucide="pencil"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-ghost btn-icon btn-sm" data-action="duplicate" data-id="${agent.id}" title="Duplicar agente">
|
<button class="btn btn-ghost btn-icon btn-sm" data-action="duplicate" data-id="${agent.id}" title="Duplicar agente">
|
||||||
<i data-lucide="copy"></i>
|
<i data-lucide="copy"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-ghost btn-icon btn-sm" data-action="export" data-id="${agent.id}" title="Exportar agente">
|
<button class="btn btn-ghost btn-icon btn-sm" data-action="export" data-id="${agent.id}" title="Exportar agente">
|
||||||
<i data-lucide="download"></i>
|
<i data-lucide="download"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-ghost btn-icon btn-sm btn-danger" data-action="delete" data-id="${agent.id}" title="Excluir agente">
|
<button class="btn btn-ghost btn-icon btn-sm" data-action="versions" data-id="${agent.id}" title="Histórico de versões">
|
||||||
<i data-lucide="trash-2"></i>
|
<i data-lucide="history"></i>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn btn-ghost btn-icon btn-sm btn-danger" data-action="delete" data-id="${agent.id}" title="Excluir agente">
|
||||||
|
<i data-lucide="trash-2"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -158,7 +162,23 @@ const AgentsUI = {
|
|||||||
const permissionMode = document.getElementById('agent-permission-mode');
|
const permissionMode = document.getElementById('agent-permission-mode');
|
||||||
if (permissionMode) permissionMode.value = '';
|
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');
|
Modal.open('agent-modal-overlay');
|
||||||
|
AgentsUI._setupModalListeners();
|
||||||
},
|
},
|
||||||
|
|
||||||
async openEditModal(agentId) {
|
async openEditModal(agentId) {
|
||||||
@@ -199,7 +219,23 @@ const AgentsUI = {
|
|||||||
).join('');
|
).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');
|
Modal.open('agent-modal-overlay');
|
||||||
|
AgentsUI._setupModalListeners();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
Toast.error(`Erro ao carregar agente: ${err.message}`);
|
Toast.error(`Erro ao carregar agente: ${err.message}`);
|
||||||
}
|
}
|
||||||
@@ -237,6 +273,8 @@ const AgentsUI = {
|
|||||||
allowedTools: document.getElementById('agent-allowed-tools')?.value.trim() || '',
|
allowedTools: document.getElementById('agent-allowed-tools')?.value.trim() || '',
|
||||||
maxTurns: parseInt(document.getElementById('agent-max-turns')?.value) || 0,
|
maxTurns: parseInt(document.getElementById('agent-max-turns')?.value) || 0,
|
||||||
permissionMode: document.getElementById('agent-permission-mode')?.value || '',
|
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',
|
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 = '<p class="text-muted text-sm">Nenhum secret configurado.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.innerHTML = items.map(s => `
|
||||||
|
<div class="secret-item">
|
||||||
|
<span class="secret-name font-mono">${Utils.escapeHtml(s.name || s)}</span>
|
||||||
|
<span class="secret-value-placeholder">••••••••</span>
|
||||||
|
<button type="button" class="btn btn-ghost btn-icon btn-sm btn-danger" data-secret-delete="${Utils.escapeHtml(s.name || s)}" data-agent-id="${agentId}" title="Remover secret">
|
||||||
|
<i data-lucide="trash-2"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`).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 = '<p class="text-muted text-sm">Erro ao carregar secrets.</p>';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
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 = '<div class="flex flex-center gap-8"><div class="spinner"></div><span class="text-secondary">Carregando versões...</span></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-state-icon"><i data-lucide="history"></i></div>
|
||||||
|
<h3 class="empty-state-title">Sem histórico de versões</h3>
|
||||||
|
<p class="empty-state-desc">As alterações neste agente serão registradas aqui automaticamente.</p>
|
||||||
|
</div>`;
|
||||||
|
Utils.refreshIcons(contentEl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
contentEl.innerHTML = `
|
||||||
|
<div class="versions-timeline">
|
||||||
|
${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 `
|
||||||
|
<div class="version-item ${isLatest ? 'version-item--latest' : ''}">
|
||||||
|
<div class="version-node">
|
||||||
|
<div class="version-dot ${isLatest ? 'version-dot--active' : ''}"></div>
|
||||||
|
${i < items.length - 1 ? '<div class="version-line"></div>' : ''}
|
||||||
|
</div>
|
||||||
|
<div class="version-content">
|
||||||
|
<div class="version-header">
|
||||||
|
<span class="version-number">v${v.version || items.length - i}</span>
|
||||||
|
<span class="version-date">${date}</span>
|
||||||
|
${!isLatest ? `<button class="btn btn-ghost btn-sm" data-restore-version="${v.version || items.length - i}" data-agent-id="${agentId}" type="button">
|
||||||
|
<i data-lucide="undo-2"></i> Restaurar
|
||||||
|
</button>` : '<span class="badge badge-active">Atual</span>'}
|
||||||
|
</div>
|
||||||
|
${changedFields ? `<div class="version-changes">${changedFields}</div>` : ''}
|
||||||
|
${v.changelog ? `<p class="version-changelog">${Utils.escapeHtml(v.changelog)}</p>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('')}
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-state-icon"><i data-lucide="alert-circle"></i></div>
|
||||||
|
<h3 class="empty-state-title">Erro ao carregar versões</h3>
|
||||||
|
<p class="empty-state-desc">${Utils.escapeHtml(err.message)}</p>
|
||||||
|
</div>`;
|
||||||
|
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 =>
|
||||||
|
`<span class="version-field-badge">${fieldLabels[f] || f}</span>`
|
||||||
|
).join('');
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
window.AgentsUI = AgentsUI;
|
window.AgentsUI = AgentsUI;
|
||||||
|
|||||||
@@ -369,6 +369,18 @@ const DashboardUI = {
|
|||||||
wsBadge.textContent = wsConnected ? 'Conectado' : 'Desconectado';
|
wsBadge.textContent = wsConnected ? 'Conectado' : 'Desconectado';
|
||||||
wsBadge.className = `badge ${wsConnected ? 'badge--green' : 'badge--red'}`;
|
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) {
|
_statusBadgeClass(status) {
|
||||||
|
|||||||
@@ -119,13 +119,14 @@ const PipelinesUI = {
|
|||||||
<i data-lucide="play"></i>
|
<i data-lucide="play"></i>
|
||||||
Executar
|
Executar
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-ghost btn-sm" data-action="edit-pipeline" data-id="${pipeline.id}">
|
<div class="agent-actions-icons">
|
||||||
<i data-lucide="pencil"></i>
|
<button class="btn btn-ghost btn-icon btn-sm" data-action="edit-pipeline" data-id="${pipeline.id}" title="Editar pipeline">
|
||||||
Editar
|
<i data-lucide="pencil"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-ghost btn-icon btn-sm btn-danger" data-action="delete-pipeline" data-id="${pipeline.id}" title="Excluir pipeline">
|
<button class="btn btn-ghost btn-icon btn-sm btn-danger" data-action="delete-pipeline" data-id="${pipeline.id}" title="Excluir pipeline">
|
||||||
<i data-lucide="trash-2"></i>
|
<i data-lucide="trash-2"></i>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -181,7 +181,10 @@ const WebhooksUI = {
|
|||||||
async test(webhookId) {
|
async test(webhookId) {
|
||||||
try {
|
try {
|
||||||
const result = await API.webhooks.test(webhookId);
|
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) {
|
} catch (err) {
|
||||||
Toast.error(`Erro ao testar webhook: ${err.message}`);
|
Toast.error(`Erro ao testar webhook: ${err.message}`);
|
||||||
}
|
}
|
||||||
|
|||||||
78
server.js
78
server.js
@@ -6,6 +6,8 @@ import { dirname, join } from 'path';
|
|||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import rateLimit from 'express-rate-limit';
|
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 apiRouter, { setWsBroadcast, setWsBroadcastTo, hookRouter } from './src/routes/api.js';
|
||||||
import * as manager from './src/agents/manager.js';
|
import * as manager from './src/agents/manager.js';
|
||||||
import { setGlobalBroadcast } 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 __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
|
const HOST = process.env.HOST || '127.0.0.1';
|
||||||
const AUTH_TOKEN = process.env.AUTH_TOKEN || '';
|
const AUTH_TOKEN = process.env.AUTH_TOKEN || '';
|
||||||
const ALLOWED_ORIGIN = process.env.ALLOWED_ORIGIN || 'http://localhost:3000';
|
const ALLOWED_ORIGIN = process.env.ALLOWED_ORIGIN || 'http://localhost:3000';
|
||||||
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || '';
|
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || '';
|
||||||
|
|
||||||
|
|
||||||
function timingSafeCompare(a, b) {
|
function timingSafeCompare(a, b) {
|
||||||
if (typeof a !== 'string' || typeof b !== 'string') return false;
|
if (typeof a !== 'string' || typeof b !== 'string') return false;
|
||||||
const bufA = Buffer.from(a);
|
const hashA = crypto.createHash('sha256').update(a).digest();
|
||||||
const bufB = Buffer.from(b);
|
const hashB = crypto.createHash('sha256').update(b).digest();
|
||||||
if (bufA.length !== bufB.length) return false;
|
return crypto.timingSafeEqual(hashA, hashB);
|
||||||
return crypto.timingSafeEqual(bufA, bufB);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiLimiter = rateLimit({
|
const apiLimiter = rateLimit({
|
||||||
@@ -34,6 +37,14 @@ const apiLimiter = rateLimit({
|
|||||||
message: { error: 'Limite de requisições excedido. Tente novamente em breve.' },
|
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) {
|
function verifyWebhookSignature(req, res, next) {
|
||||||
if (!WEBHOOK_SECRET) return next();
|
if (!WEBHOOK_SECRET) return next();
|
||||||
const sig = req.headers['x-hub-signature-256'];
|
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);
|
app.use('/api', apiLimiter);
|
||||||
|
|
||||||
if (AUTH_TOKEN) {
|
app.use('/api', (req, res, next) => {
|
||||||
app.use('/api', (req, res, next) => {
|
if (!AUTH_TOKEN) return next();
|
||||||
const header = req.headers.authorization || '';
|
const header = req.headers.authorization || '';
|
||||||
const token = header.startsWith('Bearer ') ? header.slice(7) : req.query.token;
|
const token = header.startsWith('Bearer ') ? header.slice(7) : req.query.token;
|
||||||
if (!timingSafeCompare(token, AUTH_TOKEN)) {
|
if (!timingSafeCompare(token, AUTH_TOKEN)) {
|
||||||
return res.status(401).json({ error: 'Token de autenticação inválido' });
|
return res.status(401).json({ error: 'Token de autenticação inválido' });
|
||||||
}
|
}
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
app.use(express.json({
|
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('/hook', hookLimiter, verifyWebhookSignature, hookRouter);
|
||||||
app.use(express.static(join(__dirname, 'public')));
|
app.use(express.static(join(__dirname, 'public'), { maxAge: '1h', etag: true }));
|
||||||
app.use('/api', apiRouter);
|
app.use('/api', apiRouter);
|
||||||
|
|
||||||
const connectedClients = new Map();
|
const connectedClients = new Map();
|
||||||
@@ -104,20 +120,30 @@ wss.on('connection', (ws, req) => {
|
|||||||
|
|
||||||
if (AUTH_TOKEN) {
|
if (AUTH_TOKEN) {
|
||||||
const token = new URL(req.url, 'http://localhost').searchParams.get('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');
|
ws.close(4001, 'Token inválido');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.clientId = clientId;
|
ws.clientId = clientId;
|
||||||
|
ws.isAlive = true;
|
||||||
connectedClients.set(clientId, ws);
|
connectedClients.set(clientId, ws);
|
||||||
|
|
||||||
|
ws.on('pong', () => { ws.isAlive = true; });
|
||||||
ws.on('close', () => connectedClients.delete(clientId));
|
ws.on('close', () => connectedClients.delete(clientId));
|
||||||
ws.on('error', () => connectedClients.delete(clientId));
|
ws.on('error', () => connectedClients.delete(clientId));
|
||||||
ws.send(JSON.stringify({ type: 'connected', 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) {
|
function broadcast(message) {
|
||||||
const payload = JSON.stringify(message);
|
const payload = JSON.stringify(message);
|
||||||
for (const [, client] of connectedClients) {
|
for (const [, client] of connectedClients) {
|
||||||
@@ -145,6 +171,8 @@ function gracefulShutdown(signal) {
|
|||||||
flushAllStores();
|
flushAllStores();
|
||||||
console.log('Dados persistidos.');
|
console.log('Dados persistidos.');
|
||||||
|
|
||||||
|
clearInterval(wsHeartbeat);
|
||||||
|
|
||||||
httpServer.close(() => {
|
httpServer.close(() => {
|
||||||
console.log('Servidor HTTP encerrado.');
|
console.log('Servidor HTTP encerrado.');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
@@ -159,10 +187,18 @@ function gracefulShutdown(signal) {
|
|||||||
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
||||||
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
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();
|
manager.restoreSchedules();
|
||||||
|
|
||||||
httpServer.listen(PORT, () => {
|
httpServer.listen(PORT, HOST, () => {
|
||||||
console.log(`Painel administrativo disponível em http://localhost:${PORT}`);
|
console.log(`Painel administrativo disponível em http://${HOST}:${PORT}`);
|
||||||
console.log(`WebSocket server ativo na mesma porta.`);
|
console.log(`WebSocket server ativo na mesma porta.`);
|
||||||
if (AUTH_TOKEN) console.log('Autenticação por token ativada.');
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import { spawn } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
import { existsSync } from 'fs';
|
import { existsSync } from 'fs';
|
||||||
|
import path from 'path';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { settingsStore } from '../store/db.js';
|
import { settingsStore } from '../store/db.js';
|
||||||
|
|
||||||
const CLAUDE_BIN = resolveBin();
|
const CLAUDE_BIN = resolveBin();
|
||||||
const activeExecutions = new Map();
|
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;
|
let maxConcurrent = settingsStore.get().maxConcurrent || 5;
|
||||||
|
|
||||||
@@ -12,6 +16,12 @@ export function updateMaxConcurrent(value) {
|
|||||||
maxConcurrent = Math.max(1, Math.min(20, parseInt(value) || 5));
|
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() {
|
function resolveBin() {
|
||||||
if (process.env.CLAUDE_BIN) return process.env.CLAUDE_BIN;
|
if (process.env.CLAUDE_BIN) return process.env.CLAUDE_BIN;
|
||||||
const home = process.env.HOME || '';
|
const home = process.env.HOME || '';
|
||||||
@@ -34,13 +44,16 @@ function sanitizeText(str) {
|
|||||||
.slice(0, 50000);
|
.slice(0, 50000);
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanEnv() {
|
function cleanEnv(agentSecrets) {
|
||||||
const env = { ...process.env };
|
const env = { ...process.env };
|
||||||
delete env.CLAUDECODE;
|
delete env.CLAUDECODE;
|
||||||
delete env.ANTHROPIC_API_KEY;
|
delete env.ANTHROPIC_API_KEY;
|
||||||
if (!env.CLAUDE_CODE_MAX_OUTPUT_TOKENS) {
|
if (!env.CLAUDE_CODE_MAX_OUTPUT_TOKENS) {
|
||||||
env.CLAUDE_CODE_MAX_OUTPUT_TOKENS = '128000';
|
env.CLAUDE_CODE_MAX_OUTPUT_TOKENS = '128000';
|
||||||
}
|
}
|
||||||
|
if (agentSecrets && typeof agentSecrets === 'object') {
|
||||||
|
Object.assign(env, agentSecrets);
|
||||||
|
}
|
||||||
return env;
|
return env;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,52 +174,21 @@ function extractSystemInfo(event) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function execute(agentConfig, task, callbacks = {}) {
|
function processChildOutput(child, executionId, callbacks, options = {}) {
|
||||||
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;
|
const { onData, onError, onComplete } = callbacks;
|
||||||
|
const timeoutMs = options.timeout || 1800000;
|
||||||
const prompt = buildPrompt(task.description || task, task.instructions);
|
const sessionIdOverride = options.sessionIdOverride || null;
|
||||||
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,
|
|
||||||
});
|
|
||||||
|
|
||||||
let outputBuffer = '';
|
let outputBuffer = '';
|
||||||
let errorBuffer = '';
|
let errorBuffer = '';
|
||||||
let fullText = '';
|
let fullText = '';
|
||||||
let resultMeta = null;
|
let resultMeta = null;
|
||||||
|
|
||||||
let turnCount = 0;
|
let turnCount = 0;
|
||||||
|
let hadError = false;
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
child.kill('SIGTERM');
|
||||||
|
setTimeout(() => { if (!child.killed) child.kill('SIGKILL'); }, 5000);
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
function processEvent(parsed) {
|
function processEvent(parsed) {
|
||||||
if (!parsed) return;
|
if (!parsed) return;
|
||||||
@@ -221,7 +203,9 @@ export function execute(agentConfig, task, callbacks = {}) {
|
|||||||
|
|
||||||
const text = extractText(parsed);
|
const text = extractText(parsed);
|
||||||
if (text) {
|
if (text) {
|
||||||
fullText += text;
|
if (fullText.length < MAX_OUTPUT_SIZE) {
|
||||||
|
fullText += text;
|
||||||
|
}
|
||||||
if (onData) onData({ type: 'chunk', content: text }, executionId);
|
if (onData) onData({ type: 'chunk', content: text }, executionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,7 +226,7 @@ export function execute(agentConfig, task, callbacks = {}) {
|
|||||||
durationMs: parsed.duration_ms || 0,
|
durationMs: parsed.duration_ms || 0,
|
||||||
durationApiMs: parsed.duration_api_ms || 0,
|
durationApiMs: parsed.duration_api_ms || 0,
|
||||||
numTurns: parsed.num_turns || 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) => {
|
child.stderr.on('data', (chunk) => {
|
||||||
const str = chunk.toString();
|
const str = chunk.toString();
|
||||||
errorBuffer += str;
|
if (errorBuffer.length < MAX_ERROR_SIZE) {
|
||||||
|
errorBuffer += str;
|
||||||
|
}
|
||||||
const lines = str.split('\n').filter(l => l.trim());
|
const lines = str.split('\n').filter(l => l.trim());
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (onData) onData({ type: 'stderr', content: line.trim() }, executionId);
|
if (onData) onData({ type: 'stderr', content: line.trim() }, executionId);
|
||||||
@@ -263,6 +249,7 @@ export function execute(agentConfig, task, callbacks = {}) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
child.on('error', (err) => {
|
child.on('error', (err) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
console.log(`[executor][error] ${err.message}`);
|
console.log(`[executor][error] ${err.message}`);
|
||||||
hadError = true;
|
hadError = true;
|
||||||
activeExecutions.delete(executionId);
|
activeExecutions.delete(executionId);
|
||||||
@@ -270,21 +257,81 @@ export function execute(agentConfig, task, callbacks = {}) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
child.on('close', (code) => {
|
child.on('close', (code) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
const wasCanceled = activeExecutions.get(executionId)?.canceled || false;
|
||||||
activeExecutions.delete(executionId);
|
activeExecutions.delete(executionId);
|
||||||
if (hadError) return;
|
if (hadError) return;
|
||||||
|
|
||||||
if (outputBuffer.trim()) processEvent(parseStreamLine(outputBuffer));
|
if (outputBuffer.trim()) processEvent(parseStreamLine(outputBuffer));
|
||||||
|
|
||||||
if (onComplete) {
|
if (onComplete) {
|
||||||
onComplete({
|
onComplete({
|
||||||
executionId,
|
executionId,
|
||||||
exitCode: code,
|
exitCode: code,
|
||||||
result: fullText,
|
result: fullText,
|
||||||
stderr: errorBuffer,
|
stderr: errorBuffer,
|
||||||
|
canceled: wasCanceled,
|
||||||
...(resultMeta || {}),
|
...(resultMeta || {}),
|
||||||
}, executionId);
|
}, 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;
|
return executionId;
|
||||||
}
|
}
|
||||||
@@ -299,6 +346,8 @@ export function resume(agentConfig, sessionId, message, callbacks = {}) {
|
|||||||
const executionId = uuidv4();
|
const executionId = uuidv4();
|
||||||
const { onData, onError, onComplete } = callbacks;
|
const { onData, onError, onComplete } = callbacks;
|
||||||
|
|
||||||
|
if (!validateWorkingDirectory(agentConfig, executionId, onError)) return null;
|
||||||
|
|
||||||
const model = agentConfig.model || 'claude-sonnet-4-6';
|
const model = agentConfig.model || 'claude-sonnet-4-6';
|
||||||
const args = [
|
const args = [
|
||||||
'--resume', sessionId,
|
'--resume', sessionId,
|
||||||
@@ -319,18 +368,12 @@ export function resume(agentConfig, sessionId, message, callbacks = {}) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (agentConfig.workingDirectory && agentConfig.workingDirectory.trim()) {
|
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;
|
spawnOptions.cwd = agentConfig.workingDirectory;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[executor] Resumindo sessão: ${sessionId} | Execução: ${executionId}`);
|
console.log(`[executor] Resumindo sessão: ${sessionId} | Execução: ${executionId}`);
|
||||||
|
|
||||||
const child = spawn(CLAUDE_BIN, args, spawnOptions);
|
const child = spawn(CLAUDE_BIN, args, spawnOptions);
|
||||||
let hadError = false;
|
|
||||||
|
|
||||||
activeExecutions.set(executionId, {
|
activeExecutions.set(executionId, {
|
||||||
process: child,
|
process: child,
|
||||||
@@ -340,86 +383,9 @@ export function resume(agentConfig, sessionId, message, callbacks = {}) {
|
|||||||
executionId,
|
executionId,
|
||||||
});
|
});
|
||||||
|
|
||||||
let outputBuffer = '';
|
processChildOutput(child, executionId, { onData, onError, onComplete }, {
|
||||||
let errorBuffer = '';
|
timeout: agentConfig.timeout || 1800000,
|
||||||
let fullText = '';
|
sessionIdOverride: sessionId,
|
||||||
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);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return executionId;
|
return executionId;
|
||||||
@@ -428,8 +394,8 @@ export function resume(agentConfig, sessionId, message, callbacks = {}) {
|
|||||||
export function cancel(executionId) {
|
export function cancel(executionId) {
|
||||||
const execution = activeExecutions.get(executionId);
|
const execution = activeExecutions.get(executionId);
|
||||||
if (!execution) return false;
|
if (!execution) return false;
|
||||||
|
execution.canceled = true;
|
||||||
execution.process.kill('SIGTERM');
|
execution.process.kill('SIGTERM');
|
||||||
activeExecutions.delete(executionId);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { v4 as uuidv4 } from 'uuid';
|
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 executor from './executor.js';
|
||||||
import * as scheduler from './scheduler.js';
|
import * as scheduler from './scheduler.js';
|
||||||
import { generateAgentReport } from '../reports/generator.js';
|
import { generateAgentReport } from '../reports/generator.js';
|
||||||
@@ -99,6 +100,13 @@ export function createAgent(data) {
|
|||||||
export function updateAgent(id, data) {
|
export function updateAgent(id, data) {
|
||||||
const existing = agentsStore.getById(id);
|
const existing = agentsStore.getById(id);
|
||||||
if (!existing) return null;
|
if (!existing) return null;
|
||||||
|
|
||||||
|
agentVersionsStore.create({
|
||||||
|
agentId: id,
|
||||||
|
version: existing,
|
||||||
|
changedFields: Object.keys(data).filter(k => k !== 'id'),
|
||||||
|
});
|
||||||
|
|
||||||
const updateData = {};
|
const updateData = {};
|
||||||
if (data.agent_name !== undefined) updateData.agent_name = data.agent_name;
|
if (data.agent_name !== undefined) updateData.agent_name = data.agent_name;
|
||||||
if (data.description !== undefined) updateData.description = data.description;
|
if (data.description !== undefined) updateData.description = data.description;
|
||||||
@@ -114,25 +122,44 @@ export function deleteAgent(id) {
|
|||||||
return agentsStore.delete(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 = {}) {
|
export function executeTask(agentId, task, instructions, wsCallback, metadata = {}) {
|
||||||
const agent = agentsStore.getById(agentId);
|
const agent = agentsStore.getById(agentId);
|
||||||
if (!agent) throw new Error(`Agente ${agentId} não encontrado`);
|
if (!agent) throw new Error(`Agente ${agentId} não encontrado`);
|
||||||
if (agent.status !== 'active') throw new Error(`Agente ${agentId} está inativo`);
|
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 cb = getWsCallback(wsCallback);
|
||||||
const taskText = typeof task === 'string' ? task : task.description;
|
const taskText = typeof task === 'string' ? task : task.description;
|
||||||
const startedAt = new Date().toISOString();
|
const startedAt = new Date().toISOString();
|
||||||
|
|
||||||
const historyRecord = executionsStore.create({
|
const historyRecord = metadata._historyRecordId
|
||||||
type: 'agent',
|
? { id: metadata._historyRecordId }
|
||||||
...metadata,
|
: executionsStore.create({
|
||||||
agentId,
|
type: 'agent',
|
||||||
agentName: agent.agent_name,
|
...metadata,
|
||||||
task: taskText,
|
agentId,
|
||||||
instructions: instructions || '',
|
agentName: agent.agent_name,
|
||||||
status: 'running',
|
task: taskText,
|
||||||
startedAt,
|
instructions: instructions || '',
|
||||||
});
|
status: 'running',
|
||||||
|
startedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (metadata._retryAttempt) {
|
||||||
|
executionsStore.update(historyRecord.id, { status: 'running', error: null });
|
||||||
|
}
|
||||||
|
|
||||||
const execRecord = {
|
const execRecord = {
|
||||||
executionId: null,
|
executionId: null,
|
||||||
@@ -143,6 +170,8 @@ export function executeTask(agentId, task, instructions, wsCallback, metadata =
|
|||||||
status: 'running',
|
status: 'running',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const agentSecrets = loadAgentSecrets(agentId);
|
||||||
|
|
||||||
const executionId = executor.execute(
|
const executionId = executor.execute(
|
||||||
agent.config,
|
agent.config,
|
||||||
{ description: task, instructions },
|
{ description: task, instructions },
|
||||||
@@ -153,6 +182,31 @@ export function executeTask(agentId, task, instructions, wsCallback, metadata =
|
|||||||
onError: (err, execId) => {
|
onError: (err, execId) => {
|
||||||
const endedAt = new Date().toISOString();
|
const endedAt = new Date().toISOString();
|
||||||
updateExecutionRecord(agentId, execId, { status: 'error', error: err.message, endedAt });
|
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 });
|
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 });
|
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 } });
|
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);
|
const report = generateAgentReport(updated);
|
||||||
if (cb) cb({ type: 'report_generated', executionId: execId, agentId, reportFile: report.filename });
|
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 });
|
if (cb) cb({ type: 'execution_complete', executionId: execId, agentId, data: result });
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
|
agentSecrets
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!executionId) {
|
if (!executionId) {
|
||||||
@@ -203,18 +258,15 @@ export function executeTask(agentId, task, instructions, wsCallback, metadata =
|
|||||||
return executionId;
|
return executionId;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateRecentBuffer(executionId, updates) {
|
async function updateExecutionRecord(agentId, executionId, updates) {
|
||||||
const entry = recentExecBuffer.find((e) => e.executionId === executionId);
|
await withLock(`agent:${agentId}`, () => {
|
||||||
if (entry) Object.assign(entry, updates);
|
const agent = agentsStore.getById(agentId);
|
||||||
}
|
if (!agent) return;
|
||||||
|
const executions = (agent.executions || []).map((exec) =>
|
||||||
function updateExecutionRecord(agentId, executionId, updates) {
|
exec.executionId === executionId ? { ...exec, ...updates } : exec
|
||||||
const agent = agentsStore.getById(agentId);
|
);
|
||||||
if (!agent) return;
|
agentsStore.update(agentId, { executions });
|
||||||
const executions = (agent.executions || []).map((exec) =>
|
});
|
||||||
exec.executionId === executionId ? { ...exec, ...updates } : exec
|
|
||||||
);
|
|
||||||
agentsStore.update(agentId, { executions });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRecentExecutions(limit = 20) {
|
export function getRecentExecutions(limit = 20) {
|
||||||
@@ -225,6 +277,10 @@ export function scheduleTask(agentId, taskDescription, cronExpression, wsCallbac
|
|||||||
const agent = agentsStore.getById(agentId);
|
const agent = agentsStore.getById(agentId);
|
||||||
if (!agent) throw new Error(`Agente ${agentId} não encontrado`);
|
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 scheduleId = uuidv4();
|
||||||
const items = schedulesStore.getAll();
|
const items = schedulesStore.getAll();
|
||||||
items.push({
|
items.push({
|
||||||
@@ -314,7 +370,7 @@ export function continueConversation(agentId, sessionId, message, wsCallback) {
|
|||||||
const report = generateAgentReport(updated);
|
const report = generateAgentReport(updated);
|
||||||
if (cb) cb({ type: 'report_generated', executionId: execId, agentId, reportFile: report.filename });
|
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 });
|
if (cb) cb({ type: 'execution_complete', executionId: execId, agentId, data: result });
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) => {
|
return new Promise((resolve) => {
|
||||||
const state = activePipelines.get(pipelineId);
|
const state = activePipelines.get(executionId);
|
||||||
if (!state) { resolve(false); return; }
|
if (!state) { resolve(false); return; }
|
||||||
|
|
||||||
state.pendingApproval = {
|
state.pendingApproval = {
|
||||||
@@ -116,6 +116,7 @@ function waitForApproval(pipelineId, stepIndex, previousOutput, agentName, wsCal
|
|||||||
wsCallback({
|
wsCallback({
|
||||||
type: 'pipeline_approval_required',
|
type: 'pipeline_approval_required',
|
||||||
pipelineId,
|
pipelineId,
|
||||||
|
executionId,
|
||||||
stepIndex,
|
stepIndex,
|
||||||
agentName,
|
agentName,
|
||||||
previousOutput: previousOutput.slice(0, 3000),
|
previousOutput: previousOutput.slice(0, 3000),
|
||||||
@@ -124,8 +125,16 @@ function waitForApproval(pipelineId, stepIndex, previousOutput, agentName, wsCal
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function approvePipelineStep(pipelineId) {
|
function findPipelineState(idOrExecId) {
|
||||||
const state = activePipelines.get(pipelineId);
|
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;
|
if (!state?.pendingApproval) return false;
|
||||||
const { resolve } = state.pendingApproval;
|
const { resolve } = state.pendingApproval;
|
||||||
state.pendingApproval = null;
|
state.pendingApproval = null;
|
||||||
@@ -133,8 +142,8 @@ export function approvePipelineStep(pipelineId) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function rejectPipelineStep(pipelineId) {
|
export function rejectPipelineStep(id) {
|
||||||
const state = activePipelines.get(pipelineId);
|
const state = findPipelineState(id);
|
||||||
if (!state?.pendingApproval) return false;
|
if (!state?.pendingApproval) return false;
|
||||||
const { resolve } = state.pendingApproval;
|
const { resolve } = state.pendingApproval;
|
||||||
state.pendingApproval = null;
|
state.pendingApproval = null;
|
||||||
@@ -145,9 +154,11 @@ export function rejectPipelineStep(pipelineId) {
|
|||||||
export async function executePipeline(pipelineId, initialInput, wsCallback, options = {}) {
|
export async function executePipeline(pipelineId, initialInput, wsCallback, options = {}) {
|
||||||
const pl = pipelinesStore.getById(pipelineId);
|
const pl = pipelinesStore.getById(pipelineId);
|
||||||
if (!pl) throw new Error(`Pipeline ${pipelineId} não encontrado`);
|
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 };
|
const executionId = uuidv4();
|
||||||
activePipelines.set(pipelineId, pipelineState);
|
const pipelineState = { pipelineId, currentExecutionId: null, currentStep: 0, canceled: false, pendingApproval: null };
|
||||||
|
activePipelines.set(executionId, pipelineState);
|
||||||
|
|
||||||
const historyRecord = executionsStore.create({
|
const historyRecord = executionsStore.create({
|
||||||
type: 'pipeline',
|
type: 'pipeline',
|
||||||
@@ -181,7 +192,7 @@ export async function executePipeline(pipelineId, initialInput, wsCallback, opti
|
|||||||
wsCallback({ type: 'pipeline_status', pipelineId, status: 'awaiting_approval', stepIndex: i });
|
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) {
|
if (!approved) {
|
||||||
pipelineState.canceled = true;
|
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';
|
const finalStatus = pipelineState.canceled ? 'canceled' : 'completed';
|
||||||
executionsStore.update(historyRecord.id, {
|
executionsStore.update(historyRecord.id, {
|
||||||
@@ -273,13 +284,13 @@ export async function executePipeline(pipelineId, initialInput, wsCallback, opti
|
|||||||
const report = generatePipelineReport(updated);
|
const report = generatePipelineReport(updated);
|
||||||
if (wsCallback) wsCallback({ type: 'report_generated', pipelineId, reportFile: report.filename });
|
if (wsCallback) wsCallback({ type: 'report_generated', pipelineId, reportFile: report.filename });
|
||||||
}
|
}
|
||||||
} catch (e) {}
|
} catch (e) { console.error('[pipeline] Erro ao gerar relatório:', e.message); }
|
||||||
if (wsCallback) wsCallback({ type: 'pipeline_complete', pipelineId, results, totalCostUsd: totalCost });
|
if (wsCallback) wsCallback({ type: 'pipeline_complete', pipelineId, executionId, results, totalCostUsd: totalCost });
|
||||||
}
|
}
|
||||||
|
|
||||||
return results;
|
return { executionId, results };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
activePipelines.delete(pipelineId);
|
activePipelines.delete(executionId);
|
||||||
executionsStore.update(historyRecord.id, {
|
executionsStore.update(historyRecord.id, {
|
||||||
status: 'error',
|
status: 'error',
|
||||||
error: err.message,
|
error: err.message,
|
||||||
@@ -298,8 +309,14 @@ export async function executePipeline(pipelineId, initialInput, wsCallback, opti
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cancelPipeline(pipelineId) {
|
export function cancelPipeline(id) {
|
||||||
const state = activePipelines.get(pipelineId);
|
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;
|
if (!state) return false;
|
||||||
state.canceled = true;
|
state.canceled = true;
|
||||||
if (state.pendingApproval) {
|
if (state.pendingApproval) {
|
||||||
@@ -307,14 +324,12 @@ export function cancelPipeline(pipelineId) {
|
|||||||
state.pendingApproval = null;
|
state.pendingApproval = null;
|
||||||
}
|
}
|
||||||
if (state.currentExecutionId) executor.cancel(state.currentExecutionId);
|
if (state.currentExecutionId) executor.cancel(state.currentExecutionId);
|
||||||
activePipelines.delete(pipelineId);
|
activePipelines.delete(executionId);
|
||||||
|
|
||||||
const allExecs = executionsStore.getAll();
|
const allExecs = executionsStore.getAll();
|
||||||
const idx = allExecs.findIndex(e => e.pipelineId === pipelineId && (e.status === 'running' || e.status === 'awaiting_approval'));
|
const exec = allExecs.find(e => e.pipelineId === state.pipelineId && (e.status === 'running' || e.status === 'awaiting_approval'));
|
||||||
if (idx !== -1) {
|
if (exec) {
|
||||||
allExecs[idx].status = 'canceled';
|
executionsStore.update(exec.id, { status: 'canceled', endedAt: new Date().toISOString() });
|
||||||
allExecs[idx].endedAt = new Date().toISOString();
|
|
||||||
executionsStore.save(allExecs);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -322,7 +337,8 @@ export function cancelPipeline(pipelineId) {
|
|||||||
|
|
||||||
export function getActivePipelines() {
|
export function getActivePipelines() {
|
||||||
return Array.from(activePipelines.entries()).map(([id, state]) => ({
|
return Array.from(activePipelines.entries()).map(([id, state]) => ({
|
||||||
pipelineId: id,
|
executionId: id,
|
||||||
|
pipelineId: state.pipelineId,
|
||||||
currentStep: state.currentStep,
|
currentStep: state.currentStep,
|
||||||
currentExecutionId: state.currentExecutionId,
|
currentExecutionId: state.currentExecutionId,
|
||||||
pendingApproval: !!state.pendingApproval,
|
pendingApproval: !!state.pendingApproval,
|
||||||
|
|||||||
@@ -17,7 +17,11 @@ function addToHistory(entry) {
|
|||||||
|
|
||||||
function matchesCronPart(part, value) {
|
function matchesCronPart(part, value) {
|
||||||
if (part === '*') return true;
|
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(',')) return part.split(',').map(Number).includes(value);
|
||||||
if (part.includes('-')) {
|
if (part.includes('-')) {
|
||||||
const [start, end] = part.split('-').map(Number);
|
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 (schedules.has(taskId)) unschedule(taskId, false);
|
||||||
if (!cron.validate(cronExpr)) throw new Error(`Expressão cron inválida: ${cronExpr}`);
|
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(
|
const task = cron.schedule(
|
||||||
cronExpr,
|
cronExpr,
|
||||||
() => {
|
() => {
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { execSync } from 'child_process';
|
import { execFile } from 'child_process';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
import * as manager from '../agents/manager.js';
|
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 scheduler from '../agents/scheduler.js';
|
||||||
import * as pipeline from '../agents/pipeline.js';
|
import * as pipeline from '../agents/pipeline.js';
|
||||||
import { getBinPath, updateMaxConcurrent } from '../agents/executor.js';
|
import { getBinPath, updateMaxConcurrent } from '../agents/executor.js';
|
||||||
import { invalidateAgentMapCache } from '../agents/pipeline.js';
|
import { invalidateAgentMapCache } from '../agents/pipeline.js';
|
||||||
import { cached } from '../cache/index.js';
|
import { cached } from '../cache/index.js';
|
||||||
import { readdirSync, readFileSync, unlinkSync, existsSync } from 'fs';
|
import { readdirSync, readFileSync, unlinkSync, existsSync } from 'fs';
|
||||||
import { join, dirname } from 'path';
|
import { join, dirname, resolve as pathResolve } from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
const __apiDirname = dirname(fileURLToPath(import.meta.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) => {
|
router.post('/agents/:id/duplicate', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const agent = manager.getAgentById(req.params.id);
|
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 {
|
try {
|
||||||
const { input, workingDirectory } = req.body;
|
const { input, workingDirectory } = req.body;
|
||||||
if (!input) return res.status(400).json({ error: 'input é obrigatório' });
|
if (!input) return res.status(400).json({ error: 'input é obrigatório' });
|
||||||
const clientId = req.headers['x-client-id'] || null;
|
const clientId = req.headers['x-client-id'] || null;
|
||||||
const options = {};
|
const options = {};
|
||||||
if (workingDirectory) options.workingDirectory = workingDirectory;
|
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' });
|
res.status(202).json({ pipelineId: req.params.id, status: 'started' });
|
||||||
} catch (err) {
|
} 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 });
|
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 {
|
try {
|
||||||
const webhooks = webhooksStore.getAll();
|
const existing = webhooksStore.getById(req.params.id);
|
||||||
const idx = webhooks.findIndex(w => w.id === req.params.id);
|
if (!existing) return res.status(404).json({ error: 'Webhook não encontrado' });
|
||||||
if (idx === -1) return res.status(404).json({ error: 'Webhook não encontrado' });
|
|
||||||
const allowed = ['name', 'targetType', 'targetId', 'active'];
|
const allowed = ['name', 'targetType', 'targetId', 'active'];
|
||||||
|
const updateData = {};
|
||||||
for (const key of allowed) {
|
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();
|
const updated = webhooksStore.update(req.params.id, updateData);
|
||||||
webhooksStore.save(webhooks);
|
res.json(updated);
|
||||||
res.json(webhooks[idx]);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(400).json({ error: err.message });
|
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) => {
|
router.post('/webhooks/:id/test', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const webhooks = webhooksStore.getAll();
|
const wh = webhooksStore.getById(req.params.id);
|
||||||
const wh = webhooks.find(w => w.id === req.params.id);
|
|
||||||
if (!wh) return res.status(404).json({ error: 'Webhook não encontrado' });
|
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) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: err.message });
|
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 });
|
res.status(202).json({ executionId, status: 'started', webhook: webhook.name });
|
||||||
} else if (webhook.targetType === 'pipeline') {
|
} else if (webhook.targetType === 'pipeline') {
|
||||||
const input = payload.input || payload.task || payload.message || 'Webhook trigger';
|
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) => {
|
pipeline.executePipeline(webhook.targetId, input, (msg) => {
|
||||||
if (wsbroadcast) wsbroadcast(msg);
|
if (wsbroadcast) wsbroadcast(msg);
|
||||||
}, options).catch(() => {});
|
}).catch(() => {});
|
||||||
res.status(202).json({ pipelineId: webhook.targetId, status: 'started', webhook: webhook.name });
|
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) {
|
} catch (err) {
|
||||||
const status = err.message.includes('não encontrado') ? 404 : 500;
|
const status = err.message.includes('não encontrado') ? 404 : 500;
|
||||||
@@ -590,11 +688,16 @@ router.get('/system/status', (req, res) => {
|
|||||||
|
|
||||||
let claudeVersionCache = null;
|
let claudeVersionCache = null;
|
||||||
|
|
||||||
router.get('/system/info', (req, res) => {
|
router.get('/system/info', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (claudeVersionCache === null) {
|
if (claudeVersionCache === null) {
|
||||||
try {
|
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 {
|
} catch {
|
||||||
claudeVersionCache = 'N/A';
|
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 {
|
try {
|
||||||
const notifications = notificationsStore.getAll();
|
const updated = notificationsStore.update(req.params.id, { read: true });
|
||||||
const n = notifications.find(n => n.id === req.params.id);
|
if (!updated) return res.status(404).json({ error: 'Notificação não encontrada' });
|
||||||
if (!n) return res.status(404).json({ error: 'Notificação não encontrada' });
|
|
||||||
n.read = true;
|
|
||||||
notificationsStore.save(notifications);
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/notifications/read-all', async (req, res) => {
|
router.post('/notifications/read-all', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const notifications = notificationsStore.getAll();
|
const notifications = notificationsStore.getAll();
|
||||||
notifications.forEach(n => n.read = true);
|
for (const n of notifications) {
|
||||||
notificationsStore.save(notifications);
|
if (!n.read) notificationsStore.update(n.id, { read: true });
|
||||||
|
}
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: error.message });
|
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, '');
|
const filename = req.params.filename.replace(/[^a-zA-Z0-9À-ÿ_.\-]/g, '');
|
||||||
if (!filename.endsWith('.md')) return res.status(400).json({ error: 'Formato inválido' });
|
if (!filename.endsWith('.md')) return res.status(400).json({ error: 'Formato inválido' });
|
||||||
const filepath = join(REPORTS_DIR, filename);
|
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' });
|
if (!existsSync(filepath)) return res.status(404).json({ error: 'Relatório não encontrado' });
|
||||||
const content = readFileSync(filepath, 'utf-8');
|
const content = readFileSync(filepath, 'utf-8');
|
||||||
res.json({ filename, content });
|
res.json({ filename, content });
|
||||||
@@ -846,6 +951,10 @@ router.delete('/reports/:filename', (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const filename = req.params.filename.replace(/[^a-zA-Z0-9À-ÿ_.\-]/g, '');
|
const filename = req.params.filename.replace(/[^a-zA-Z0-9À-ÿ_.\-]/g, '');
|
||||||
const filepath = join(REPORTS_DIR, filename);
|
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' });
|
if (!existsSync(filepath)) return res.status(404).json({ error: 'Relatório não encontrado' });
|
||||||
unlinkSync(filepath);
|
unlinkSync(filepath);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync } from 'fs';
|
import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync } from 'fs';
|
||||||
|
import { writeFile, rename } from 'fs/promises';
|
||||||
import { dirname } from 'path';
|
import { dirname } from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
@@ -35,6 +36,13 @@ function writeJson(path, data) {
|
|||||||
renameSync(tmpPath, path);
|
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) {
|
function clone(v) {
|
||||||
return structuredClone(v);
|
return structuredClone(v);
|
||||||
}
|
}
|
||||||
@@ -57,7 +65,7 @@ function createStore(filePath) {
|
|||||||
timer = setTimeout(() => {
|
timer = setTimeout(() => {
|
||||||
timer = null;
|
timer = null;
|
||||||
if (dirty) {
|
if (dirty) {
|
||||||
writeJson(filePath, mem);
|
writeJsonAsync(filePath, mem).catch((e) => console.error(`[db] Erro ao salvar ${filePath}:`, e.message));
|
||||||
dirty = false;
|
dirty = false;
|
||||||
}
|
}
|
||||||
}, DEBOUNCE_MS);
|
}, DEBOUNCE_MS);
|
||||||
@@ -75,6 +83,20 @@ function createStore(filePath) {
|
|||||||
return item ? clone(item) : null;
|
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) {
|
create(data) {
|
||||||
boot();
|
boot();
|
||||||
const item = {
|
const item = {
|
||||||
@@ -110,7 +132,8 @@ function createStore(filePath) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
save(items) {
|
save(items) {
|
||||||
mem = Array.isArray(items) ? items : mem;
|
if (!Array.isArray(items)) return;
|
||||||
|
mem = items;
|
||||||
touch();
|
touch();
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -186,6 +209,21 @@ function createSettingsStore(filePath) {
|
|||||||
return store;
|
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() {
|
export function flushAllStores() {
|
||||||
for (const s of allStores) s.flush();
|
for (const s of allStores) s.flush();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user