Histórico persistente de execuções com visualização detalhada
- Novo executionsStore em db.js com cache in-memory e escrita debounced - Camada de cache (src/cache/index.js) com TTL e suporte opcional a Redis - Persistência de execuções de agentes e pipelines com metadados completos - Pipeline grava cada etapa com prompt, resultado, timestamps e status - 4 endpoints REST: listagem paginada com filtros, detalhe, exclusão individual e limpeza total - Componente frontend (history.js) com cards, filtros, paginação e modal de detalhe - Timeline visual para pipelines com prompts colapsáveis por etapa - Correção do executor: --max-turns em vez de --max-tokens, --permission-mode bypassPermissions - Refatoração do scheduler com persistência melhorada e graceful shutdown
This commit is contained in:
@@ -3295,3 +3295,429 @@ tbody tr:hover td {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toolbar-filters {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-agent {
|
||||||
|
background-color: rgba(99, 102, 241, 0.15);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-pipeline {
|
||||||
|
background-color: rgba(139, 92, 246, 0.15);
|
||||||
|
color: #8b5cf6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-card {
|
||||||
|
background-color: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
animation: fadeInUp 0.2s ease both;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-card:hover {
|
||||||
|
border-color: var(--border-secondary);
|
||||||
|
background-color: var(--bg-card-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-card-identity {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-card-name {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-card-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-card-date {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-card-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-card-task {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-card-duration {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-card-duration i {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-card-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid var(--border-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-result {
|
||||||
|
background-color: #08080e;
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #c8c8d8;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 400px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-result--error {
|
||||||
|
border-color: rgba(239, 68, 68, 0.3);
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-result--prompt {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background-color: var(--bg-tertiary);
|
||||||
|
max-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-detail-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-detail-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-bottom: 1px solid var(--border-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-detail-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-detail-label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
min-width: 80px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-detail-value {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-detail-section {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-detail-section:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-detail-section-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-detail-task {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.6;
|
||||||
|
background-color: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pipeline-timeline {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pipeline-step-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pipeline-step-connector {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pipeline-step-node {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--bg-tertiary);
|
||||||
|
border: 2px solid var(--border-secondary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pipeline-step-node--completed {
|
||||||
|
background-color: rgba(34, 197, 94, 0.15);
|
||||||
|
border-color: var(--success);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pipeline-step-node--error {
|
||||||
|
background-color: rgba(239, 68, 68, 0.15);
|
||||||
|
border-color: var(--error);
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pipeline-step-connector-line {
|
||||||
|
width: 2px;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 24px;
|
||||||
|
background-color: var(--border-secondary);
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pipeline-step-detail {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
background-color: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pipeline-step-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pipeline-step-identity {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pipeline-step-agent {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pipeline-step-duration {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pipeline-step-duration i {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pipeline-step-prompt {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pipeline-step-prompt-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 0;
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pipeline-step-prompt-toggle:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pipeline-step-prompt-toggle[aria-expanded="true"] i {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pipeline-step-prompt-toggle i {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pipeline-step-prompt-body {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pipeline-step-result {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pipeline-step-result-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 0;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-info {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-page {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background-color: var(--bg-input);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input--sm {
|
||||||
|
padding: 5px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -59,6 +59,12 @@
|
|||||||
<span>Terminal</span>
|
<span>Terminal</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="sidebar-nav-item">
|
||||||
|
<a href="#" class="sidebar-nav-link" data-section="history">
|
||||||
|
<i data-lucide="history"></i>
|
||||||
|
<span>Histórico</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
<li class="sidebar-nav-item">
|
<li class="sidebar-nav-item">
|
||||||
<a href="#" class="sidebar-nav-link" data-section="settings">
|
<a href="#" class="sidebar-nav-link" data-section="settings">
|
||||||
<i data-lucide="settings"></i>
|
<i data-lucide="settings"></i>
|
||||||
@@ -387,6 +393,36 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section id="history" class="section" aria-label="Histórico de Execuções" hidden>
|
||||||
|
<div class="section-toolbar">
|
||||||
|
<div class="toolbar-filters">
|
||||||
|
<div class="search-field">
|
||||||
|
<i data-lucide="search"></i>
|
||||||
|
<input type="text" placeholder="Buscar..." id="history-search" aria-label="Buscar no histórico" />
|
||||||
|
</div>
|
||||||
|
<select class="select" id="history-filter-type" aria-label="Filtrar por tipo">
|
||||||
|
<option value="">Todos os tipos</option>
|
||||||
|
<option value="agent">Agentes</option>
|
||||||
|
<option value="pipeline">Pipelines</option>
|
||||||
|
</select>
|
||||||
|
<select class="select" id="history-filter-status" aria-label="Filtrar por status">
|
||||||
|
<option value="">Todos os status</option>
|
||||||
|
<option value="completed">Concluído</option>
|
||||||
|
<option value="error">Erro</option>
|
||||||
|
<option value="running">Em execução</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-actions">
|
||||||
|
<button class="btn btn-ghost btn-sm btn-danger" id="history-clear-btn" type="button">
|
||||||
|
<i data-lucide="trash-2"></i>
|
||||||
|
Limpar Histórico
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="history-list"></div>
|
||||||
|
<div id="history-pagination"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section id="settings" class="section" aria-label="Configurações" hidden>
|
<section id="settings" class="section" aria-label="Configurações" hidden>
|
||||||
<div class="settings-grid">
|
<div class="settings-grid">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -864,6 +900,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="execution-detail-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="execution-detail-title" hidden>
|
||||||
|
<div class="modal modal-lg">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 class="modal-title" id="execution-detail-title">Detalhe da Execução</h2>
|
||||||
|
<button class="modal-close" data-modal-close="execution-detail-modal-overlay" aria-label="Fechar" type="button">
|
||||||
|
<i data-lucide="x"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" id="execution-detail-content">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="modal-overlay" id="confirm-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="confirm-modal-title" hidden>
|
<div class="modal-overlay" id="confirm-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="confirm-modal-title" hidden>
|
||||||
<div class="modal modal--sm">
|
<div class="modal modal--sm">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
@@ -954,6 +1003,7 @@
|
|||||||
<script src="js/components/schedules.js"></script>
|
<script src="js/components/schedules.js"></script>
|
||||||
<script src="js/components/pipelines.js"></script>
|
<script src="js/components/pipelines.js"></script>
|
||||||
<script src="js/components/settings.js"></script>
|
<script src="js/components/settings.js"></script>
|
||||||
|
<script src="js/components/history.js"></script>
|
||||||
<script src="js/app.js"></script>
|
<script src="js/app.js"></script>
|
||||||
<script>
|
<script>
|
||||||
lucide.createIcons();
|
lucide.createIcons();
|
||||||
|
|||||||
@@ -82,6 +82,13 @@ const API = {
|
|||||||
|
|
||||||
executions: {
|
executions: {
|
||||||
recent(limit = 20) { return API.request('GET', `/executions/recent?limit=${limit}`); },
|
recent(limit = 20) { return API.request('GET', `/executions/recent?limit=${limit}`); },
|
||||||
|
history(params = {}) {
|
||||||
|
const qs = new URLSearchParams(params).toString();
|
||||||
|
return API.request('GET', `/executions/history${qs ? '?' + qs : ''}`);
|
||||||
|
},
|
||||||
|
get(id) { return API.request('GET', `/executions/history/${id}`); },
|
||||||
|
delete(id) { return API.request('DELETE', `/executions/history/${id}`); },
|
||||||
|
clearAll() { return API.request('DELETE', '/executions/history'); },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const App = {
|
|||||||
schedules: 'Agendamentos',
|
schedules: 'Agendamentos',
|
||||||
pipelines: 'Pipelines',
|
pipelines: 'Pipelines',
|
||||||
terminal: 'Terminal',
|
terminal: 'Terminal',
|
||||||
|
history: 'Histórico',
|
||||||
settings: 'Configurações',
|
settings: 'Configurações',
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -70,6 +71,7 @@ const App = {
|
|||||||
case 'tasks': await TasksUI.load(); break;
|
case 'tasks': await TasksUI.load(); break;
|
||||||
case 'schedules': await SchedulesUI.load(); break;
|
case 'schedules': await SchedulesUI.load(); break;
|
||||||
case 'pipelines': await PipelinesUI.load(); break;
|
case 'pipelines': await PipelinesUI.load(); break;
|
||||||
|
case 'history': await HistoryUI.load(); break;
|
||||||
case 'settings': await SettingsUI.load(); break;
|
case 'settings': await SettingsUI.load(); break;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -363,6 +365,32 @@ const App = {
|
|||||||
PipelinesUI.filter(document.getElementById('pipelines-search')?.value);
|
PipelinesUI.filter(document.getElementById('pipelines-search')?.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
on('history-search', 'input', () => {
|
||||||
|
HistoryUI.filter(
|
||||||
|
document.getElementById('history-search')?.value,
|
||||||
|
document.getElementById('history-filter-type')?.value,
|
||||||
|
document.getElementById('history-filter-status')?.value
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
on('history-filter-type', 'change', () => {
|
||||||
|
HistoryUI.filter(
|
||||||
|
document.getElementById('history-search')?.value,
|
||||||
|
document.getElementById('history-filter-type')?.value,
|
||||||
|
document.getElementById('history-filter-status')?.value
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
on('history-filter-status', 'change', () => {
|
||||||
|
HistoryUI.filter(
|
||||||
|
document.getElementById('history-search')?.value,
|
||||||
|
document.getElementById('history-filter-type')?.value,
|
||||||
|
document.getElementById('history-filter-status')?.value
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
on('history-clear-btn', 'click', () => HistoryUI.clearHistory());
|
||||||
|
|
||||||
document.getElementById('agents-grid')?.addEventListener('click', (e) => {
|
document.getElementById('agents-grid')?.addEventListener('click', (e) => {
|
||||||
const btn = e.target.closest('[data-action]');
|
const btn = e.target.closest('[data-action]');
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
@@ -419,6 +447,16 @@ const App = {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.getElementById('history-list')?.addEventListener('click', (e) => {
|
||||||
|
const btn = e.target.closest('[data-action]');
|
||||||
|
if (!btn) return;
|
||||||
|
const { action, id } = btn.dataset;
|
||||||
|
switch (action) {
|
||||||
|
case 'view-execution': HistoryUI.viewDetail(id); break;
|
||||||
|
case 'delete-execution': HistoryUI.deleteExecution(id); break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById('pipeline-steps-container')?.addEventListener('click', (e) => {
|
document.getElementById('pipeline-steps-container')?.addEventListener('click', (e) => {
|
||||||
const btn = e.target.closest('[data-step-action]');
|
const btn = e.target.closest('[data-step-action]');
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
|
|||||||
421
public/js/components/history.js
Normal file
421
public/js/components/history.js
Normal file
@@ -0,0 +1,421 @@
|
|||||||
|
const HistoryUI = {
|
||||||
|
executions: [],
|
||||||
|
total: 0,
|
||||||
|
page: 0,
|
||||||
|
pageSize: 20,
|
||||||
|
_currentSearch: '',
|
||||||
|
_currentType: '',
|
||||||
|
_currentStatus: '',
|
||||||
|
|
||||||
|
async load() {
|
||||||
|
const params = { limit: HistoryUI.pageSize, offset: HistoryUI.page * HistoryUI.pageSize };
|
||||||
|
if (HistoryUI._currentType) params.type = HistoryUI._currentType;
|
||||||
|
if (HistoryUI._currentStatus) params.status = HistoryUI._currentStatus;
|
||||||
|
if (HistoryUI._currentSearch) params.search = HistoryUI._currentSearch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await API.executions.history(params);
|
||||||
|
HistoryUI.executions = data.items || [];
|
||||||
|
HistoryUI.total = data.total || 0;
|
||||||
|
HistoryUI.render();
|
||||||
|
HistoryUI._renderPagination();
|
||||||
|
} catch (err) {
|
||||||
|
Toast.error(`Erro ao carregar histórico: ${err.message}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const container = document.getElementById('history-list');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
if (HistoryUI.executions.length === 0) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-state-icon">
|
||||||
|
<i data-lucide="history"></i>
|
||||||
|
</div>
|
||||||
|
<h3 class="empty-state-title">Nenhuma execução encontrada</h3>
|
||||||
|
<p class="empty-state-text">O histórico de execuções aparecerá aqui.</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
if (window.lucide) lucide.createIcons({ nodes: [container] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = HistoryUI.executions.map((exec) => HistoryUI._renderCard(exec)).join('');
|
||||||
|
if (window.lucide) lucide.createIcons({ nodes: [container] });
|
||||||
|
},
|
||||||
|
|
||||||
|
_renderCard(exec) {
|
||||||
|
const typeBadge = exec.type === 'pipeline'
|
||||||
|
? '<span class="badge badge-pipeline">Pipeline</span>'
|
||||||
|
: '<span class="badge badge-agent">Agente</span>';
|
||||||
|
|
||||||
|
const statusBadge = HistoryUI._statusBadge(exec.status);
|
||||||
|
const name = exec.type === 'pipeline'
|
||||||
|
? (exec.pipelineName || 'Pipeline')
|
||||||
|
: (exec.agentName || 'Agente');
|
||||||
|
const task = exec.type === 'pipeline'
|
||||||
|
? (exec.input || '')
|
||||||
|
: (exec.task || '');
|
||||||
|
const date = HistoryUI._formatDate(exec.startedAt);
|
||||||
|
const duration = HistoryUI._formatDuration(exec.startedAt, exec.endedAt);
|
||||||
|
|
||||||
|
return `
|
||||||
|
<article class="history-card">
|
||||||
|
<div class="history-card-header">
|
||||||
|
<div class="history-card-identity">
|
||||||
|
${typeBadge}
|
||||||
|
<span class="history-card-name">${HistoryUI._escapeHtml(name)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="history-card-status">
|
||||||
|
${statusBadge}
|
||||||
|
<span class="history-card-date">${date}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="history-card-meta">
|
||||||
|
<span class="history-card-task">${HistoryUI._escapeHtml(task)}</span>
|
||||||
|
<span class="history-card-duration">
|
||||||
|
<i data-lucide="clock" aria-hidden="true"></i>
|
||||||
|
${duration}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="history-card-actions">
|
||||||
|
<button class="btn btn-ghost btn-sm" data-action="view-execution" data-id="${exec.id}" type="button">
|
||||||
|
<i data-lucide="eye"></i>
|
||||||
|
Ver detalhes
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-ghost btn-sm btn-danger" data-action="delete-execution" data-id="${exec.id}" type="button" aria-label="Excluir execução">
|
||||||
|
<i data-lucide="trash-2"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
|
||||||
|
_renderPagination() {
|
||||||
|
const container = document.getElementById('history-pagination');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(HistoryUI.total / HistoryUI.pageSize);
|
||||||
|
if (totalPages <= 1) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasPrev = HistoryUI.page > 0;
|
||||||
|
const hasNext = HistoryUI.page < totalPages - 1;
|
||||||
|
const start = HistoryUI.page * HistoryUI.pageSize + 1;
|
||||||
|
const end = Math.min((HistoryUI.page + 1) * HistoryUI.pageSize, HistoryUI.total);
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="pagination">
|
||||||
|
<span class="pagination-info">${start}–${end} de ${HistoryUI.total}</span>
|
||||||
|
<div class="pagination-controls">
|
||||||
|
<button class="btn btn-ghost btn-sm" id="history-prev-btn" type="button" ${hasPrev ? '' : 'disabled'}>
|
||||||
|
<i data-lucide="chevron-left"></i>
|
||||||
|
Anterior
|
||||||
|
</button>
|
||||||
|
<span class="pagination-page">Página ${HistoryUI.page + 1} de ${totalPages}</span>
|
||||||
|
<button class="btn btn-ghost btn-sm" id="history-next-btn" type="button" ${hasNext ? '' : 'disabled'}>
|
||||||
|
Próxima
|
||||||
|
<i data-lucide="chevron-right"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (window.lucide) lucide.createIcons({ nodes: [container] });
|
||||||
|
|
||||||
|
document.getElementById('history-prev-btn')?.addEventListener('click', () => {
|
||||||
|
HistoryUI.page--;
|
||||||
|
HistoryUI.load();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('history-next-btn')?.addEventListener('click', () => {
|
||||||
|
HistoryUI.page++;
|
||||||
|
HistoryUI.load();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
filter(search, type, status) {
|
||||||
|
HistoryUI._currentSearch = search || '';
|
||||||
|
HistoryUI._currentType = type || '';
|
||||||
|
HistoryUI._currentStatus = status || '';
|
||||||
|
HistoryUI.page = 0;
|
||||||
|
HistoryUI.load();
|
||||||
|
},
|
||||||
|
|
||||||
|
async viewDetail(id) {
|
||||||
|
try {
|
||||||
|
const exec = await API.executions.get(id);
|
||||||
|
const modal = document.getElementById('execution-detail-modal-overlay');
|
||||||
|
const title = document.getElementById('execution-detail-title');
|
||||||
|
const content = document.getElementById('execution-detail-content');
|
||||||
|
|
||||||
|
if (!modal || !title || !content) return;
|
||||||
|
|
||||||
|
const name = exec.type === 'pipeline'
|
||||||
|
? (exec.pipelineName || 'Pipeline')
|
||||||
|
: (exec.agentName || 'Agente');
|
||||||
|
|
||||||
|
title.textContent = name;
|
||||||
|
content.innerHTML = exec.type === 'pipeline'
|
||||||
|
? HistoryUI._renderPipelineDetail(exec)
|
||||||
|
: HistoryUI._renderAgentDetail(exec);
|
||||||
|
|
||||||
|
Modal.open('execution-detail-modal-overlay');
|
||||||
|
if (window.lucide) lucide.createIcons({ nodes: [content] });
|
||||||
|
|
||||||
|
content.querySelectorAll('.pipeline-step-prompt-toggle').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const stepCard = btn.closest('.pipeline-step-detail');
|
||||||
|
const promptBody = stepCard?.querySelector('.pipeline-step-prompt-body');
|
||||||
|
if (!promptBody) return;
|
||||||
|
const isHidden = promptBody.hidden;
|
||||||
|
promptBody.hidden = !isHidden;
|
||||||
|
btn.setAttribute('aria-expanded', String(isHidden));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
Toast.error(`Erro ao carregar execução: ${err.message}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_renderAgentDetail(exec) {
|
||||||
|
const duration = HistoryUI._formatDuration(exec.startedAt, exec.endedAt);
|
||||||
|
const startDate = HistoryUI._formatDate(exec.startedAt);
|
||||||
|
const endDate = exec.endedAt ? HistoryUI._formatDate(exec.endedAt) : '—';
|
||||||
|
|
||||||
|
const resultBlock = exec.result
|
||||||
|
? `<div class="execution-result" role="region" aria-label="Resultado da execução">${HistoryUI._escapeHtml(exec.result)}</div>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const errorBlock = exec.error
|
||||||
|
? `<div class="execution-result execution-result--error" role="alert">${HistoryUI._escapeHtml(exec.error)}</div>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="execution-detail-meta">
|
||||||
|
<div class="execution-detail-row">
|
||||||
|
<span class="execution-detail-label">Agente</span>
|
||||||
|
<span class="execution-detail-value">${HistoryUI._escapeHtml(exec.agentName || exec.agentId || '—')}</span>
|
||||||
|
</div>
|
||||||
|
<div class="execution-detail-row">
|
||||||
|
<span class="execution-detail-label">Status</span>
|
||||||
|
<span class="execution-detail-value">${HistoryUI._statusBadge(exec.status)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="execution-detail-row">
|
||||||
|
<span class="execution-detail-label">Início</span>
|
||||||
|
<span class="execution-detail-value">${startDate}</span>
|
||||||
|
</div>
|
||||||
|
<div class="execution-detail-row">
|
||||||
|
<span class="execution-detail-label">Fim</span>
|
||||||
|
<span class="execution-detail-value">${endDate}</span>
|
||||||
|
</div>
|
||||||
|
<div class="execution-detail-row">
|
||||||
|
<span class="execution-detail-label">Duração</span>
|
||||||
|
<span class="execution-detail-value">${duration}</span>
|
||||||
|
</div>
|
||||||
|
${exec.exitCode !== undefined && exec.exitCode !== null ? `
|
||||||
|
<div class="execution-detail-row">
|
||||||
|
<span class="execution-detail-label">Exit Code</span>
|
||||||
|
<span class="execution-detail-value font-mono">${exec.exitCode}</span>
|
||||||
|
</div>` : ''}
|
||||||
|
</div>
|
||||||
|
${exec.task ? `
|
||||||
|
<div class="execution-detail-section">
|
||||||
|
<h3 class="execution-detail-section-title">Tarefa</h3>
|
||||||
|
<p class="execution-detail-task">${HistoryUI._escapeHtml(exec.task)}</p>
|
||||||
|
</div>` : ''}
|
||||||
|
${resultBlock ? `
|
||||||
|
<div class="execution-detail-section">
|
||||||
|
<h3 class="execution-detail-section-title">Resultado</h3>
|
||||||
|
${resultBlock}
|
||||||
|
</div>` : ''}
|
||||||
|
${errorBlock ? `
|
||||||
|
<div class="execution-detail-section">
|
||||||
|
<h3 class="execution-detail-section-title">Erro</h3>
|
||||||
|
${errorBlock}
|
||||||
|
</div>` : ''}
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
|
||||||
|
_renderPipelineDetail(exec) {
|
||||||
|
const duration = HistoryUI._formatDuration(exec.startedAt, exec.endedAt);
|
||||||
|
const startDate = HistoryUI._formatDate(exec.startedAt);
|
||||||
|
const endDate = exec.endedAt ? HistoryUI._formatDate(exec.endedAt) : '—';
|
||||||
|
const steps = Array.isArray(exec.steps) ? exec.steps : [];
|
||||||
|
|
||||||
|
const stepsHtml = steps.map((step, index) => {
|
||||||
|
const stepDuration = HistoryUI._formatDuration(step.startedAt, step.endedAt);
|
||||||
|
const isLast = index === steps.length - 1;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="pipeline-step-item">
|
||||||
|
<div class="pipeline-step-connector">
|
||||||
|
<div class="pipeline-step-node ${step.status === 'error' ? 'pipeline-step-node--error' : step.status === 'completed' ? 'pipeline-step-node--completed' : ''}">
|
||||||
|
<span>${step.stepIndex + 1}</span>
|
||||||
|
</div>
|
||||||
|
${isLast ? '' : '<div class="pipeline-step-connector-line" aria-hidden="true"></div>'}
|
||||||
|
</div>
|
||||||
|
<div class="pipeline-step-detail">
|
||||||
|
<div class="pipeline-step-header">
|
||||||
|
<div class="pipeline-step-identity">
|
||||||
|
<span class="pipeline-step-agent">${HistoryUI._escapeHtml(step.agentName || step.agentId || 'Agente')}</span>
|
||||||
|
${HistoryUI._statusBadge(step.status)}
|
||||||
|
</div>
|
||||||
|
<span class="pipeline-step-duration">
|
||||||
|
<i data-lucide="clock" aria-hidden="true"></i>
|
||||||
|
${stepDuration}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
${step.prompt ? `
|
||||||
|
<div class="pipeline-step-prompt">
|
||||||
|
<button class="pipeline-step-prompt-toggle" type="button" aria-expanded="false">
|
||||||
|
<i data-lucide="chevron-right"></i>
|
||||||
|
Prompt utilizado
|
||||||
|
</button>
|
||||||
|
<div class="pipeline-step-prompt-body" hidden>
|
||||||
|
<div class="execution-result execution-result--prompt">${HistoryUI._escapeHtml(step.prompt)}</div>
|
||||||
|
</div>
|
||||||
|
</div>` : ''}
|
||||||
|
${step.result ? `
|
||||||
|
<div class="pipeline-step-result">
|
||||||
|
<span class="pipeline-step-result-label">Resultado</span>
|
||||||
|
<div class="execution-result">${HistoryUI._escapeHtml(step.result)}</div>
|
||||||
|
</div>` : ''}
|
||||||
|
${step.status === 'error' ? `
|
||||||
|
<div class="execution-result execution-result--error">Passo falhou.</div>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="execution-detail-meta">
|
||||||
|
<div class="execution-detail-row">
|
||||||
|
<span class="execution-detail-label">Pipeline</span>
|
||||||
|
<span class="execution-detail-value">${HistoryUI._escapeHtml(exec.pipelineName || exec.pipelineId || '—')}</span>
|
||||||
|
</div>
|
||||||
|
<div class="execution-detail-row">
|
||||||
|
<span class="execution-detail-label">Status</span>
|
||||||
|
<span class="execution-detail-value">${HistoryUI._statusBadge(exec.status)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="execution-detail-row">
|
||||||
|
<span class="execution-detail-label">Início</span>
|
||||||
|
<span class="execution-detail-value">${startDate}</span>
|
||||||
|
</div>
|
||||||
|
<div class="execution-detail-row">
|
||||||
|
<span class="execution-detail-label">Fim</span>
|
||||||
|
<span class="execution-detail-value">${endDate}</span>
|
||||||
|
</div>
|
||||||
|
<div class="execution-detail-row">
|
||||||
|
<span class="execution-detail-label">Duração</span>
|
||||||
|
<span class="execution-detail-value">${duration}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${exec.input ? `
|
||||||
|
<div class="execution-detail-section">
|
||||||
|
<h3 class="execution-detail-section-title">Input Inicial</h3>
|
||||||
|
<p class="execution-detail-task">${HistoryUI._escapeHtml(exec.input)}</p>
|
||||||
|
</div>` : ''}
|
||||||
|
${steps.length > 0 ? `
|
||||||
|
<div class="execution-detail-section">
|
||||||
|
<h3 class="execution-detail-section-title">Passos do Pipeline</h3>
|
||||||
|
<div class="pipeline-timeline">
|
||||||
|
${stepsHtml}
|
||||||
|
</div>
|
||||||
|
</div>` : ''}
|
||||||
|
${exec.error ? `
|
||||||
|
<div class="execution-detail-section">
|
||||||
|
<h3 class="execution-detail-section-title">Erro</h3>
|
||||||
|
<div class="execution-result execution-result--error">${HistoryUI._escapeHtml(exec.error)}</div>
|
||||||
|
</div>` : ''}
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteExecution(id) {
|
||||||
|
const confirmed = await Modal.confirm(
|
||||||
|
'Excluir execução',
|
||||||
|
'Tem certeza que deseja excluir esta execução do histórico? Esta ação não pode ser desfeita.'
|
||||||
|
);
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await API.executions.delete(id);
|
||||||
|
Toast.success('Execução excluída do histórico');
|
||||||
|
await HistoryUI.load();
|
||||||
|
} catch (err) {
|
||||||
|
Toast.error(`Erro ao excluir execução: ${err.message}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async clearHistory() {
|
||||||
|
const confirmed = await Modal.confirm(
|
||||||
|
'Limpar histórico',
|
||||||
|
'Tem certeza que deseja excluir todo o histórico de execuções? Esta ação não pode ser desfeita.'
|
||||||
|
);
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await API.executions.clearAll();
|
||||||
|
Toast.success('Histórico limpo com sucesso');
|
||||||
|
HistoryUI.page = 0;
|
||||||
|
await HistoryUI.load();
|
||||||
|
} catch (err) {
|
||||||
|
Toast.error(`Erro ao limpar histórico: ${err.message}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_statusBadge(status) {
|
||||||
|
const map = {
|
||||||
|
running: ['badge-running', 'Em execução'],
|
||||||
|
completed: ['badge-active', 'Concluído'],
|
||||||
|
error: ['badge-error', 'Erro'],
|
||||||
|
};
|
||||||
|
const [cls, label] = map[status] || ['badge-inactive', status || 'Desconhecido'];
|
||||||
|
return `<span class="badge ${cls}">${label}</span>`;
|
||||||
|
},
|
||||||
|
|
||||||
|
_formatDuration(start, end) {
|
||||||
|
if (!start) return '—';
|
||||||
|
const startMs = new Date(start).getTime();
|
||||||
|
const endMs = end ? new Date(end).getTime() : Date.now();
|
||||||
|
const totalSeconds = Math.floor((endMs - startMs) / 1000);
|
||||||
|
|
||||||
|
if (totalSeconds < 0) return '—';
|
||||||
|
if (totalSeconds < 60) return `${totalSeconds}s`;
|
||||||
|
|
||||||
|
const hours = Math.floor(totalSeconds / 3600);
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
|
||||||
|
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||||
|
return `${minutes}m ${seconds}s`;
|
||||||
|
},
|
||||||
|
|
||||||
|
_formatDate(iso) {
|
||||||
|
if (!iso) return '—';
|
||||||
|
const date = new Date(iso);
|
||||||
|
return date.toLocaleString('pt-BR', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
_escapeHtml(str) {
|
||||||
|
if (!str) return '';
|
||||||
|
return String(str)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
window.HistoryUI = HistoryUI;
|
||||||
26
server.js
26
server.js
@@ -7,6 +7,7 @@ import { v4 as uuidv4 } from 'uuid';
|
|||||||
import apiRouter, { setWsBroadcast, setWsBroadcastTo } from './src/routes/api.js';
|
import apiRouter, { setWsBroadcast, setWsBroadcastTo } from './src/routes/api.js';
|
||||||
import * as manager from './src/agents/manager.js';
|
import * as manager from './src/agents/manager.js';
|
||||||
import { cancelAllExecutions } from './src/agents/executor.js';
|
import { cancelAllExecutions } from './src/agents/executor.js';
|
||||||
|
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;
|
||||||
@@ -57,35 +58,23 @@ wss.on('connection', (ws, req) => {
|
|||||||
ws.clientId = clientId;
|
ws.clientId = clientId;
|
||||||
connectedClients.set(clientId, ws);
|
connectedClients.set(clientId, ws);
|
||||||
|
|
||||||
ws.on('close', () => {
|
ws.on('close', () => connectedClients.delete(clientId));
|
||||||
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 }));
|
||||||
});
|
});
|
||||||
|
|
||||||
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) {
|
||||||
if (client.readyState === 1) {
|
if (client.readyState === 1) client.send(payload);
|
||||||
client.send(payload);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function broadcastTo(clientId, message) {
|
function broadcastTo(clientId, message) {
|
||||||
const payload = JSON.stringify(message);
|
const payload = JSON.stringify(message);
|
||||||
const client = connectedClients.get(clientId);
|
const client = connectedClients.get(clientId);
|
||||||
|
if (client && client.readyState === 1) client.send(payload);
|
||||||
if (client && client.readyState === 1) {
|
else broadcast(message);
|
||||||
client.send(payload);
|
|
||||||
} else {
|
|
||||||
broadcast(message);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setWsBroadcast(broadcast);
|
setWsBroadcast(broadcast);
|
||||||
@@ -97,6 +86,9 @@ function gracefulShutdown(signal) {
|
|||||||
cancelAllExecutions();
|
cancelAllExecutions();
|
||||||
console.log('Execuções ativas canceladas.');
|
console.log('Execuções ativas canceladas.');
|
||||||
|
|
||||||
|
flushAllStores();
|
||||||
|
console.log('Dados persistidos.');
|
||||||
|
|
||||||
httpServer.close(() => {
|
httpServer.close(() => {
|
||||||
console.log('Servidor HTTP encerrado.');
|
console.log('Servidor HTTP encerrado.');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
|||||||
@@ -6,20 +6,23 @@ import { settingsStore } from '../store/db.js';
|
|||||||
const CLAUDE_BIN = resolveBin();
|
const CLAUDE_BIN = resolveBin();
|
||||||
const activeExecutions = new Map();
|
const activeExecutions = new Map();
|
||||||
|
|
||||||
|
let maxConcurrent = settingsStore.get().maxConcurrent || 5;
|
||||||
|
|
||||||
|
export function updateMaxConcurrent(value) {
|
||||||
|
maxConcurrent = Math.max(1, Math.min(20, parseInt(value) || 5));
|
||||||
|
}
|
||||||
|
|
||||||
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 || '';
|
||||||
const candidates = [
|
const candidates = [
|
||||||
`${home}/.local/bin/claude`,
|
`${home}/.local/bin/claude`,
|
||||||
'/usr/local/bin/claude',
|
'/usr/local/bin/claude',
|
||||||
'/usr/bin/claude',
|
'/usr/bin/claude',
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const p of candidates) {
|
for (const p of candidates) {
|
||||||
if (existsSync(p)) return p;
|
if (existsSync(p)) return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'claude';
|
return 'claude';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,7 +61,6 @@ function buildArgs(agentConfig, prompt) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
args.push('--permission-mode', agentConfig.permissionMode || 'bypassPermissions');
|
args.push('--permission-mode', agentConfig.permissionMode || 'bypassPermissions');
|
||||||
|
|
||||||
return args;
|
return args;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,13 +91,8 @@ function extractText(event) {
|
|||||||
.join('');
|
.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === 'content_block_delta' && event.delta?.text) {
|
if (event.type === 'content_block_delta' && event.delta?.text) return event.delta.text;
|
||||||
return event.delta.text;
|
if (event.type === 'content_block_start' && event.content_block?.text) return event.content_block.text;
|
||||||
}
|
|
||||||
|
|
||||||
if (event.type === 'content_block_start' && event.content_block?.text) {
|
|
||||||
return event.content_block.text;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.type === 'result') {
|
if (event.type === 'result') {
|
||||||
if (typeof event.result === 'string') return event.result;
|
if (typeof event.result === 'string') return event.result;
|
||||||
@@ -108,17 +105,10 @@ function extractText(event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === 'text') return event.content || null;
|
if (event.type === 'text') return event.content || null;
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMaxConcurrent() {
|
|
||||||
const s = settingsStore.get();
|
|
||||||
return s.maxConcurrent || 5;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function execute(agentConfig, task, callbacks = {}) {
|
export function execute(agentConfig, task, callbacks = {}) {
|
||||||
const maxConcurrent = getMaxConcurrent();
|
|
||||||
if (activeExecutions.size >= maxConcurrent) {
|
if (activeExecutions.size >= maxConcurrent) {
|
||||||
const err = new Error(`Limite de ${maxConcurrent} execuções simultâneas atingido`);
|
const err = new Error(`Limite de ${maxConcurrent} execuções simultâneas atingido`);
|
||||||
if (callbacks.onError) callbacks.onError(err, uuidv4());
|
if (callbacks.onError) callbacks.onError(err, uuidv4());
|
||||||
@@ -145,9 +135,7 @@ export function execute(agentConfig, task, callbacks = {}) {
|
|||||||
spawnOptions.cwd = agentConfig.workingDirectory;
|
spawnOptions.cwd = agentConfig.workingDirectory;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[executor] Iniciando: ${executionId}`);
|
console.log(`[executor] Iniciando: ${executionId} | Modelo: ${agentConfig.model || 'claude-sonnet-4-6'}`);
|
||||||
console.log(`[executor] Modelo: ${agentConfig.model || 'claude-sonnet-4-6'}`);
|
|
||||||
console.log(`[executor] cwd: ${spawnOptions.cwd || process.cwd()}`);
|
|
||||||
|
|
||||||
const child = spawn(CLAUDE_BIN, args, spawnOptions);
|
const child = spawn(CLAUDE_BIN, args, spawnOptions);
|
||||||
let hadError = false;
|
let hadError = false;
|
||||||
@@ -165,14 +153,12 @@ export function execute(agentConfig, task, callbacks = {}) {
|
|||||||
let fullText = '';
|
let fullText = '';
|
||||||
|
|
||||||
child.stdout.on('data', (chunk) => {
|
child.stdout.on('data', (chunk) => {
|
||||||
const raw = chunk.toString();
|
const lines = (outputBuffer + chunk.toString()).split('\n');
|
||||||
const lines = (outputBuffer + raw).split('\n');
|
|
||||||
outputBuffer = lines.pop();
|
outputBuffer = lines.pop();
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const parsed = parseStreamLine(line);
|
const parsed = parseStreamLine(line);
|
||||||
if (!parsed) continue;
|
if (!parsed) continue;
|
||||||
|
|
||||||
const text = extractText(parsed);
|
const text = extractText(parsed);
|
||||||
if (text) {
|
if (text) {
|
||||||
fullText += text;
|
fullText += text;
|
||||||
@@ -193,7 +179,6 @@ export function execute(agentConfig, task, callbacks = {}) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
child.on('close', (code) => {
|
child.on('close', (code) => {
|
||||||
console.log(`[executor][close] code=${code} hadError=${hadError}`);
|
|
||||||
activeExecutions.delete(executionId);
|
activeExecutions.delete(executionId);
|
||||||
if (hadError) return;
|
if (hadError) return;
|
||||||
|
|
||||||
@@ -206,15 +191,7 @@ export function execute(agentConfig, task, callbacks = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (onComplete) {
|
if (onComplete) {
|
||||||
onComplete(
|
onComplete({ executionId, exitCode: code, result: fullText, stderr: errorBuffer }, executionId);
|
||||||
{
|
|
||||||
executionId,
|
|
||||||
exitCode: code,
|
|
||||||
result: fullText,
|
|
||||||
stderr: errorBuffer,
|
|
||||||
},
|
|
||||||
executionId,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -224,22 +201,19 @@ export function execute(agentConfig, task, 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.process.kill('SIGTERM');
|
execution.process.kill('SIGTERM');
|
||||||
activeExecutions.delete(executionId);
|
activeExecutions.delete(executionId);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cancelAllExecutions() {
|
export function cancelAllExecutions() {
|
||||||
for (const [id, exec] of activeExecutions) {
|
for (const [, exec] of activeExecutions) exec.process.kill('SIGTERM');
|
||||||
exec.process.kill('SIGTERM');
|
|
||||||
}
|
|
||||||
activeExecutions.clear();
|
activeExecutions.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getActiveExecutions() {
|
export function getActiveExecutions() {
|
||||||
return Array.from(activeExecutions.entries()).map(([id, exec]) => ({
|
return Array.from(activeExecutions.values()).map((exec) => ({
|
||||||
executionId: id,
|
executionId: exec.executionId,
|
||||||
startedAt: exec.startedAt,
|
startedAt: exec.startedAt,
|
||||||
agentConfig: exec.agentConfig,
|
agentConfig: exec.agentConfig,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { agentsStore, schedulesStore } from '../store/db.js';
|
import { agentsStore, schedulesStore, executionsStore } 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';
|
||||||
|
|
||||||
@@ -12,6 +12,9 @@ const DEFAULT_CONFIG = {
|
|||||||
allowedTools: '',
|
allowedTools: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const MAX_RECENT = 200;
|
||||||
|
const recentExecBuffer = [];
|
||||||
|
|
||||||
let dailyExecutionCount = 0;
|
let dailyExecutionCount = 0;
|
||||||
let dailyCountDate = new Date().toDateString();
|
let dailyCountDate = new Date().toDateString();
|
||||||
|
|
||||||
@@ -61,11 +64,8 @@ export function getAgentById(id) {
|
|||||||
|
|
||||||
export function createAgent(data) {
|
export function createAgent(data) {
|
||||||
const errors = validateAgent(data);
|
const errors = validateAgent(data);
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) throw new Error(errors.join('; '));
|
||||||
throw new Error(errors.join('; '));
|
return agentsStore.create({
|
||||||
}
|
|
||||||
|
|
||||||
const agentData = {
|
|
||||||
agent_name: data.agent_name,
|
agent_name: data.agent_name,
|
||||||
description: data.description || '',
|
description: data.description || '',
|
||||||
tags: sanitizeTags(data.tags),
|
tags: sanitizeTags(data.tags),
|
||||||
@@ -74,15 +74,12 @@ export function createAgent(data) {
|
|||||||
status: data.status || 'active',
|
status: data.status || 'active',
|
||||||
assigned_host: data.assigned_host || 'localhost',
|
assigned_host: data.assigned_host || 'localhost',
|
||||||
executions: [],
|
executions: [],
|
||||||
};
|
});
|
||||||
|
|
||||||
return agentsStore.create(agentData);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
|
|
||||||
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;
|
||||||
@@ -90,10 +87,7 @@ export function updateAgent(id, data) {
|
|||||||
if (data.tasks !== undefined) updateData.tasks = data.tasks;
|
if (data.tasks !== undefined) updateData.tasks = data.tasks;
|
||||||
if (data.status !== undefined) updateData.status = data.status;
|
if (data.status !== undefined) updateData.status = data.status;
|
||||||
if (data.assigned_host !== undefined) updateData.assigned_host = data.assigned_host;
|
if (data.assigned_host !== undefined) updateData.assigned_host = data.assigned_host;
|
||||||
if (data.config !== undefined) {
|
if (data.config !== undefined) updateData.config = { ...existing.config, ...data.config };
|
||||||
updateData.config = { ...existing.config, ...data.config };
|
|
||||||
}
|
|
||||||
|
|
||||||
return agentsStore.update(id, updateData);
|
return agentsStore.update(id, updateData);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,12 +100,25 @@ export function executeTask(agentId, task, instructions, wsCallback) {
|
|||||||
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 executionRecord = {
|
const taskText = typeof task === 'string' ? task : task.description;
|
||||||
|
const startedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
const historyRecord = executionsStore.create({
|
||||||
|
type: 'agent',
|
||||||
|
agentId,
|
||||||
|
agentName: agent.agent_name,
|
||||||
|
task: taskText,
|
||||||
|
instructions: instructions || '',
|
||||||
|
status: 'running',
|
||||||
|
startedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
const execRecord = {
|
||||||
executionId: null,
|
executionId: null,
|
||||||
agentId,
|
agentId,
|
||||||
agentName: agent.agent_name,
|
agentName: agent.agent_name,
|
||||||
task: typeof task === 'string' ? task : task.description,
|
task: taskText,
|
||||||
startedAt: new Date().toISOString(),
|
startedAt,
|
||||||
status: 'running',
|
status: 'running',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -120,74 +127,70 @@ export function executeTask(agentId, task, instructions, wsCallback) {
|
|||||||
{ description: task, instructions },
|
{ description: task, instructions },
|
||||||
{
|
{
|
||||||
onData: (parsed, execId) => {
|
onData: (parsed, execId) => {
|
||||||
if (wsCallback) {
|
if (wsCallback) wsCallback({ type: 'execution_output', executionId: execId, agentId, data: parsed });
|
||||||
wsCallback({
|
|
||||||
type: 'execution_output',
|
|
||||||
executionId: execId,
|
|
||||||
agentId,
|
|
||||||
data: parsed,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onError: (err, execId) => {
|
onError: (err, execId) => {
|
||||||
updateAgentExecution(agentId, execId, { status: 'error', error: err.message, endedAt: new Date().toISOString() });
|
const endedAt = new Date().toISOString();
|
||||||
if (wsCallback) {
|
updateExecutionRecord(agentId, execId, { status: 'error', error: err.message, endedAt });
|
||||||
wsCallback({
|
executionsStore.update(historyRecord.id, { status: 'error', error: err.message, endedAt });
|
||||||
type: 'execution_error',
|
if (wsCallback) wsCallback({ type: 'execution_error', executionId: execId, agentId, data: { error: err.message } });
|
||||||
executionId: execId,
|
|
||||||
agentId,
|
|
||||||
data: { error: err.message },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onComplete: (result, execId) => {
|
onComplete: (result, execId) => {
|
||||||
updateAgentExecution(agentId, execId, { status: 'completed', result, endedAt: new Date().toISOString() });
|
const endedAt = new Date().toISOString();
|
||||||
if (wsCallback) {
|
updateExecutionRecord(agentId, execId, { status: 'completed', result, endedAt });
|
||||||
wsCallback({
|
executionsStore.update(historyRecord.id, {
|
||||||
type: 'execution_complete',
|
status: 'completed',
|
||||||
executionId: execId,
|
result: result.result || '',
|
||||||
agentId,
|
exitCode: result.exitCode,
|
||||||
data: result,
|
endedAt,
|
||||||
});
|
});
|
||||||
}
|
if (wsCallback) wsCallback({ type: 'execution_complete', executionId: execId, agentId, data: result });
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!executionId) {
|
if (!executionId) {
|
||||||
|
executionsStore.update(historyRecord.id, { status: 'error', error: 'Limite de execuções simultâneas atingido', endedAt: new Date().toISOString() });
|
||||||
throw new Error('Limite de execuções simultâneas atingido');
|
throw new Error('Limite de execuções simultâneas atingido');
|
||||||
}
|
}
|
||||||
|
|
||||||
executionRecord.executionId = executionId;
|
execRecord.executionId = executionId;
|
||||||
|
executionsStore.update(historyRecord.id, { executionId });
|
||||||
incrementDailyCount();
|
incrementDailyCount();
|
||||||
|
|
||||||
const updatedAgent = agentsStore.getById(agentId);
|
const updatedAgent = agentsStore.getById(agentId);
|
||||||
const executions = [...(updatedAgent.executions || []), executionRecord];
|
const executions = [...(updatedAgent.executions || []), execRecord];
|
||||||
agentsStore.update(agentId, { executions: executions.slice(-100) });
|
agentsStore.update(agentId, { executions: executions.slice(-100) });
|
||||||
|
|
||||||
|
recentExecBuffer.unshift({ ...execRecord });
|
||||||
|
if (recentExecBuffer.length > MAX_RECENT) recentExecBuffer.length = MAX_RECENT;
|
||||||
|
|
||||||
return executionId;
|
return executionId;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateAgentExecution(agentId, executionId, updates) {
|
function updateRecentBuffer(executionId, updates) {
|
||||||
|
const entry = recentExecBuffer.find((e) => e.executionId === executionId);
|
||||||
|
if (entry) Object.assign(entry, updates);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateExecutionRecord(agentId, executionId, updates) {
|
||||||
const agent = agentsStore.getById(agentId);
|
const agent = agentsStore.getById(agentId);
|
||||||
if (!agent) return;
|
if (!agent) return;
|
||||||
|
const executions = (agent.executions || []).map((exec) =>
|
||||||
const executions = (agent.executions || []).map((exec) => {
|
exec.executionId === executionId ? { ...exec, ...updates } : exec
|
||||||
if (exec.executionId === executionId) {
|
);
|
||||||
return { ...exec, ...updates };
|
|
||||||
}
|
|
||||||
return exec;
|
|
||||||
});
|
|
||||||
|
|
||||||
agentsStore.update(agentId, { executions });
|
agentsStore.update(agentId, { executions });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getRecentExecutions(limit = 20) {
|
||||||
|
return recentExecBuffer.slice(0, Math.min(limit, MAX_RECENT));
|
||||||
|
}
|
||||||
|
|
||||||
export function scheduleTask(agentId, taskDescription, cronExpression, wsCallback) {
|
export function scheduleTask(agentId, taskDescription, cronExpression, wsCallback) {
|
||||||
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`);
|
||||||
|
|
||||||
const scheduleId = uuidv4();
|
const scheduleId = uuidv4();
|
||||||
|
|
||||||
const items = schedulesStore.getAll();
|
const items = schedulesStore.getAll();
|
||||||
items.push({
|
items.push({
|
||||||
id: scheduleId,
|
id: scheduleId,
|
||||||
@@ -223,13 +226,7 @@ export function updateScheduleTask(scheduleId, data, wsCallback) {
|
|||||||
executeTask(agentId, taskDescription, null, wsCallback);
|
executeTask(agentId, taskDescription, null, wsCallback);
|
||||||
});
|
});
|
||||||
|
|
||||||
schedulesStore.update(scheduleId, {
|
schedulesStore.update(scheduleId, { agentId, agentName: agent.agent_name, taskDescription, cronExpression });
|
||||||
agentId,
|
|
||||||
agentName: agent.agent_name,
|
|
||||||
taskDescription,
|
|
||||||
cronExpression,
|
|
||||||
});
|
|
||||||
|
|
||||||
return schedulesStore.getById(scheduleId);
|
return schedulesStore.getById(scheduleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,23 +238,9 @@ export function getActiveExecutions() {
|
|||||||
return executor.getActiveExecutions();
|
return executor.getActiveExecutions();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRecentExecutions(limit = 20) {
|
|
||||||
const agents = agentsStore.getAll();
|
|
||||||
const all = agents.flatMap((a) =>
|
|
||||||
(a.executions || []).map((e) => ({
|
|
||||||
...e,
|
|
||||||
agentName: a.agent_name,
|
|
||||||
agentId: a.id,
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
all.sort((a, b) => new Date(b.startedAt) - new Date(a.startedAt));
|
|
||||||
return all.slice(0, limit);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function exportAgent(agentId) {
|
export function exportAgent(agentId) {
|
||||||
const agent = agentsStore.getById(agentId);
|
const agent = agentsStore.getById(agentId);
|
||||||
if (!agent) return null;
|
if (!agent) return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
agent_name: agent.agent_name,
|
agent_name: agent.agent_name,
|
||||||
description: agent.description,
|
description: agent.description,
|
||||||
@@ -270,11 +253,8 @@ export function exportAgent(agentId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function importAgent(data) {
|
export function importAgent(data) {
|
||||||
if (!data.agent_name) {
|
if (!data.agent_name) throw new Error('agent_name é obrigatório para importação');
|
||||||
throw new Error('agent_name é obrigatório para importação');
|
return agentsStore.create({
|
||||||
}
|
|
||||||
|
|
||||||
const agentData = {
|
|
||||||
agent_name: data.agent_name,
|
agent_name: data.agent_name,
|
||||||
description: data.description || '',
|
description: data.description || '',
|
||||||
tags: sanitizeTags(data.tags),
|
tags: sanitizeTags(data.tags),
|
||||||
@@ -283,9 +263,7 @@ export function importAgent(data) {
|
|||||||
status: data.status || 'active',
|
status: data.status || 'active',
|
||||||
assigned_host: data.assigned_host || 'localhost',
|
assigned_host: data.assigned_host || 'localhost',
|
||||||
executions: [],
|
executions: [],
|
||||||
};
|
});
|
||||||
|
|
||||||
return agentsStore.create(agentData);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function restoreSchedules(wsCallback) {
|
export function restoreSchedules(wsCallback) {
|
||||||
|
|||||||
@@ -1,9 +1,23 @@
|
|||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { pipelinesStore } from '../store/db.js';
|
import { pipelinesStore, agentsStore, executionsStore } from '../store/db.js';
|
||||||
import { agentsStore } from '../store/db.js';
|
|
||||||
import * as executor from './executor.js';
|
import * as executor from './executor.js';
|
||||||
|
import { mem } from '../cache/index.js';
|
||||||
|
|
||||||
const activePipelines = new Map();
|
const activePipelines = new Map();
|
||||||
|
const AGENT_MAP_TTL = 30_000;
|
||||||
|
|
||||||
|
function getAgentMap() {
|
||||||
|
const hit = mem.get('agent:map');
|
||||||
|
if (hit !== undefined) return hit;
|
||||||
|
const agents = agentsStore.getAll();
|
||||||
|
const map = new Map(agents.map((a) => [a.id, a.agent_name]));
|
||||||
|
mem.set('agent:map', map, AGENT_MAP_TTL);
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invalidateAgentMapCache() {
|
||||||
|
mem.del('agent:map');
|
||||||
|
}
|
||||||
|
|
||||||
function validatePipeline(data) {
|
function validatePipeline(data) {
|
||||||
const errors = [];
|
const errors = [];
|
||||||
@@ -13,8 +27,8 @@ function validatePipeline(data) {
|
|||||||
if (!Array.isArray(data.steps) || data.steps.length === 0) {
|
if (!Array.isArray(data.steps) || data.steps.length === 0) {
|
||||||
errors.push('steps é obrigatório e deve ser um array não vazio');
|
errors.push('steps é obrigatório e deve ser um array não vazio');
|
||||||
} else {
|
} else {
|
||||||
data.steps.forEach((step, index) => {
|
data.steps.forEach((step, i) => {
|
||||||
if (!step.agentId) errors.push(`steps[${index}].agentId é obrigatório`);
|
if (!step.agentId) errors.push(`steps[${i}].agentId é obrigatório`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return errors;
|
return errors;
|
||||||
@@ -33,13 +47,8 @@ function buildSteps(steps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function enrichStepsWithAgentNames(steps) {
|
function enrichStepsWithAgentNames(steps) {
|
||||||
const agents = agentsStore.getAll();
|
const agentMap = getAgentMap();
|
||||||
const agentMap = new Map(agents.map((a) => [a.id, a.agent_name]));
|
return steps.map((s) => ({ ...s, agentName: agentMap.get(s.agentId) || s.agentId }));
|
||||||
|
|
||||||
return steps.map((s) => ({
|
|
||||||
...s,
|
|
||||||
agentName: agentMap.get(s.agentId) || s.agentId,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyTemplate(template, input) {
|
function applyTemplate(template, input) {
|
||||||
@@ -64,9 +73,7 @@ function executeStepAsPromise(agentConfig, prompt, pipelineState, wsCallback, pi
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => reject(err),
|
||||||
reject(err);
|
|
||||||
},
|
|
||||||
onComplete: (result) => {
|
onComplete: (result) => {
|
||||||
if (result.exitCode !== 0 && !result.result) {
|
if (result.exitCode !== 0 && !result.result) {
|
||||||
reject(new Error(result.stderr || `Processo encerrado com código ${result.exitCode}`));
|
reject(new Error(result.stderr || `Processo encerrado com código ${result.exitCode}`));
|
||||||
@@ -87,18 +94,23 @@ function executeStepAsPromise(agentConfig, prompt, pipelineState, wsCallback, pi
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function executePipeline(pipelineId, initialInput, wsCallback) {
|
export async function executePipeline(pipelineId, initialInput, wsCallback) {
|
||||||
const pipeline = pipelinesStore.getById(pipelineId);
|
const pl = pipelinesStore.getById(pipelineId);
|
||||||
if (!pipeline) throw new Error(`Pipeline ${pipelineId} não encontrado`);
|
if (!pl) throw new Error(`Pipeline ${pipelineId} não encontrado`);
|
||||||
|
|
||||||
const pipelineState = {
|
|
||||||
currentExecutionId: null,
|
|
||||||
currentStep: 0,
|
|
||||||
canceled: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
|
const pipelineState = { currentExecutionId: null, currentStep: 0, canceled: false };
|
||||||
activePipelines.set(pipelineId, pipelineState);
|
activePipelines.set(pipelineId, pipelineState);
|
||||||
|
|
||||||
const steps = buildSteps(pipeline.steps);
|
const historyRecord = executionsStore.create({
|
||||||
|
type: 'pipeline',
|
||||||
|
pipelineId,
|
||||||
|
pipelineName: pl.name,
|
||||||
|
input: initialInput,
|
||||||
|
status: 'running',
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
steps: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const steps = buildSteps(pl.steps);
|
||||||
const results = [];
|
const results = [];
|
||||||
let currentInput = initialInput;
|
let currentInput = initialInput;
|
||||||
|
|
||||||
@@ -114,6 +126,7 @@ export async function executePipeline(pipelineId, initialInput, wsCallback) {
|
|||||||
if (agent.status !== 'active') throw new Error(`Agente ${agent.agent_name} está inativo`);
|
if (agent.status !== 'active') throw new Error(`Agente ${agent.agent_name} está inativo`);
|
||||||
|
|
||||||
const prompt = applyTemplate(step.inputTemplate, currentInput);
|
const prompt = applyTemplate(step.inputTemplate, currentInput);
|
||||||
|
const stepStart = new Date().toISOString();
|
||||||
|
|
||||||
if (wsCallback) {
|
if (wsCallback) {
|
||||||
wsCallback({
|
wsCallback({
|
||||||
@@ -133,6 +146,20 @@ export async function executePipeline(pipelineId, initialInput, wsCallback) {
|
|||||||
currentInput = result;
|
currentInput = result;
|
||||||
results.push({ stepId: step.id, agentName: agent.agent_name, result });
|
results.push({ stepId: step.id, agentName: agent.agent_name, result });
|
||||||
|
|
||||||
|
const current = executionsStore.getById(historyRecord.id);
|
||||||
|
const savedSteps = current ? (current.steps || []) : [];
|
||||||
|
savedSteps.push({
|
||||||
|
stepIndex: i,
|
||||||
|
agentId: step.agentId,
|
||||||
|
agentName: agent.agent_name,
|
||||||
|
prompt: prompt.slice(0, 5000),
|
||||||
|
result,
|
||||||
|
startedAt: stepStart,
|
||||||
|
endedAt: new Date().toISOString(),
|
||||||
|
status: 'completed',
|
||||||
|
});
|
||||||
|
executionsStore.update(historyRecord.id, { steps: savedSteps });
|
||||||
|
|
||||||
if (wsCallback) {
|
if (wsCallback) {
|
||||||
wsCallback({
|
wsCallback({
|
||||||
type: 'pipeline_step_complete',
|
type: 'pipeline_step_complete',
|
||||||
@@ -145,19 +172,23 @@ export async function executePipeline(pipelineId, initialInput, wsCallback) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
activePipelines.delete(pipelineId);
|
activePipelines.delete(pipelineId);
|
||||||
|
executionsStore.update(historyRecord.id, {
|
||||||
|
status: pipelineState.canceled ? 'canceled' : 'completed',
|
||||||
|
endedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
if (!pipelineState.canceled && wsCallback) {
|
if (!pipelineState.canceled && wsCallback) {
|
||||||
wsCallback({
|
wsCallback({ type: 'pipeline_complete', pipelineId, results });
|
||||||
type: 'pipeline_complete',
|
|
||||||
pipelineId,
|
|
||||||
results,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
activePipelines.delete(pipelineId);
|
activePipelines.delete(pipelineId);
|
||||||
|
executionsStore.update(historyRecord.id, {
|
||||||
|
status: 'error',
|
||||||
|
error: err.message,
|
||||||
|
endedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
if (wsCallback) {
|
if (wsCallback) {
|
||||||
wsCallback({
|
wsCallback({
|
||||||
type: 'pipeline_error',
|
type: 'pipeline_error',
|
||||||
@@ -166,7 +197,6 @@ export async function executePipeline(pipelineId, initialInput, wsCallback) {
|
|||||||
error: err.message,
|
error: err.message,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -174,13 +204,8 @@ export async function executePipeline(pipelineId, initialInput, wsCallback) {
|
|||||||
export function cancelPipeline(pipelineId) {
|
export function cancelPipeline(pipelineId) {
|
||||||
const state = activePipelines.get(pipelineId);
|
const state = activePipelines.get(pipelineId);
|
||||||
if (!state) return false;
|
if (!state) return false;
|
||||||
|
|
||||||
state.canceled = true;
|
state.canceled = true;
|
||||||
|
if (state.currentExecutionId) executor.cancel(state.currentExecutionId);
|
||||||
if (state.currentExecutionId) {
|
|
||||||
executor.cancel(state.currentExecutionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
activePipelines.delete(pipelineId);
|
activePipelines.delete(pipelineId);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -196,27 +221,22 @@ export function getActivePipelines() {
|
|||||||
export function createPipeline(data) {
|
export function createPipeline(data) {
|
||||||
const errors = validatePipeline(data);
|
const errors = validatePipeline(data);
|
||||||
if (errors.length > 0) throw new Error(errors.join('; '));
|
if (errors.length > 0) throw new Error(errors.join('; '));
|
||||||
|
return pipelinesStore.create({
|
||||||
const pipelineData = {
|
|
||||||
name: data.name,
|
name: data.name,
|
||||||
description: data.description || '',
|
description: data.description || '',
|
||||||
steps: buildSteps(data.steps),
|
steps: buildSteps(data.steps),
|
||||||
status: data.status || 'active',
|
status: data.status || 'active',
|
||||||
};
|
});
|
||||||
|
|
||||||
return pipelinesStore.create(pipelineData);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updatePipeline(id, data) {
|
export function updatePipeline(id, data) {
|
||||||
const existing = pipelinesStore.getById(id);
|
const existing = pipelinesStore.getById(id);
|
||||||
if (!existing) return null;
|
if (!existing) return null;
|
||||||
|
|
||||||
const updateData = {};
|
const updateData = {};
|
||||||
if (data.name !== undefined) updateData.name = data.name;
|
if (data.name !== undefined) updateData.name = data.name;
|
||||||
if (data.description !== undefined) updateData.description = data.description;
|
if (data.description !== undefined) updateData.description = data.description;
|
||||||
if (data.status !== undefined) updateData.status = data.status;
|
if (data.status !== undefined) updateData.status = data.status;
|
||||||
if (data.steps !== undefined) updateData.steps = buildSteps(data.steps);
|
if (data.steps !== undefined) updateData.steps = buildSteps(data.steps);
|
||||||
|
|
||||||
return pipelinesStore.update(id, updateData);
|
return pipelinesStore.update(id, updateData);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,8 +249,7 @@ export function getPipeline(id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getAllPipelines() {
|
export function getAllPipelines() {
|
||||||
const pipelines = pipelinesStore.getAll();
|
return pipelinesStore.getAll().map((p) => ({
|
||||||
return pipelines.map((p) => ({
|
|
||||||
...p,
|
...p,
|
||||||
steps: enrichStepsWithAgentNames(p.steps || []),
|
steps: enrichStepsWithAgentNames(p.steps || []),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -7,11 +7,12 @@ const schedules = new Map();
|
|||||||
const history = [];
|
const history = [];
|
||||||
const emitter = new EventEmitter();
|
const emitter = new EventEmitter();
|
||||||
|
|
||||||
|
const cronDateCache = new Map();
|
||||||
|
const CRON_CACHE_TTL = 60_000;
|
||||||
|
|
||||||
function addToHistory(entry) {
|
function addToHistory(entry) {
|
||||||
history.unshift(entry);
|
history.unshift(entry);
|
||||||
if (history.length > HISTORY_LIMIT) {
|
if (history.length > HISTORY_LIMIT) history.splice(HISTORY_LIMIT);
|
||||||
history.splice(HISTORY_LIMIT);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function matchesCronPart(part, value) {
|
function matchesCronPart(part, value) {
|
||||||
@@ -25,7 +26,7 @@ function matchesCronPart(part, value) {
|
|||||||
return parseInt(part) === value;
|
return parseInt(part) === value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function nextCronDate(cronExpr) {
|
function computeNextCronDate(cronExpr) {
|
||||||
const parts = cronExpr.split(' ');
|
const parts = cronExpr.split(' ');
|
||||||
if (parts.length !== 5) return null;
|
if (parts.length !== 5) return null;
|
||||||
|
|
||||||
@@ -37,36 +38,33 @@ function nextCronDate(cronExpr) {
|
|||||||
candidate.setMinutes(candidate.getMinutes() + 1);
|
candidate.setMinutes(candidate.getMinutes() + 1);
|
||||||
|
|
||||||
for (let i = 0; i < 525600; i++) {
|
for (let i = 0; i < 525600; i++) {
|
||||||
const m = candidate.getMinutes();
|
|
||||||
const h = candidate.getHours();
|
|
||||||
const dom = candidate.getDate();
|
|
||||||
const mon = candidate.getMonth() + 1;
|
|
||||||
const dow = candidate.getDay();
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
matchesCronPart(minute, m) &&
|
matchesCronPart(minute, candidate.getMinutes()) &&
|
||||||
matchesCronPart(hour, h) &&
|
matchesCronPart(hour, candidate.getHours()) &&
|
||||||
matchesCronPart(dayOfMonth, dom) &&
|
matchesCronPart(dayOfMonth, candidate.getDate()) &&
|
||||||
matchesCronPart(month, mon) &&
|
matchesCronPart(month, candidate.getMonth() + 1) &&
|
||||||
matchesCronPart(dayOfWeek, dow)
|
matchesCronPart(dayOfWeek, candidate.getDay())
|
||||||
) {
|
) {
|
||||||
return candidate.toISOString();
|
return candidate.toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
candidate.setMinutes(candidate.getMinutes() + 1);
|
candidate.setMinutes(candidate.getMinutes() + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function schedule(taskId, cronExpr, callback, persist = true) {
|
function nextCronDate(cronExpr) {
|
||||||
if (schedules.has(taskId)) {
|
const now = Date.now();
|
||||||
unschedule(taskId, false);
|
const cached = cronDateCache.get(cronExpr);
|
||||||
}
|
if (cached && now - cached.at < CRON_CACHE_TTL) return cached.val;
|
||||||
|
const val = computeNextCronDate(cronExpr);
|
||||||
|
cronDateCache.set(cronExpr, { val, at: now });
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
|
||||||
if (!cron.validate(cronExpr)) {
|
export function schedule(taskId, cronExpr, callback, persist = true) {
|
||||||
throw new Error(`Expressão cron inválida: ${cronExpr}`);
|
if (schedules.has(taskId)) unschedule(taskId, false);
|
||||||
}
|
if (!cron.validate(cronExpr)) throw new Error(`Expressão cron inválida: ${cronExpr}`);
|
||||||
|
|
||||||
const task = cron.schedule(
|
const task = cron.schedule(
|
||||||
cronExpr,
|
cronExpr,
|
||||||
@@ -74,47 +72,31 @@ export function schedule(taskId, cronExpr, callback, persist = true) {
|
|||||||
const firedAt = new Date().toISOString();
|
const firedAt = new Date().toISOString();
|
||||||
addToHistory({ taskId, cronExpr, firedAt });
|
addToHistory({ taskId, cronExpr, firedAt });
|
||||||
emitter.emit('scheduled-task', { taskId, firedAt });
|
emitter.emit('scheduled-task', { taskId, firedAt });
|
||||||
|
cronDateCache.delete(cronExpr);
|
||||||
if (callback) callback({ taskId, firedAt });
|
if (callback) callback({ taskId, firedAt });
|
||||||
},
|
},
|
||||||
{ scheduled: true }
|
{ scheduled: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
schedules.set(taskId, {
|
schedules.set(taskId, { taskId, cronExpr, task, active: true, createdAt: new Date().toISOString() });
|
||||||
taskId,
|
|
||||||
cronExpr,
|
|
||||||
task,
|
|
||||||
active: true,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
return { taskId, cronExpr };
|
return { taskId, cronExpr };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function unschedule(taskId, persist = true) {
|
export function unschedule(taskId, persist = true) {
|
||||||
const entry = schedules.get(taskId);
|
const entry = schedules.get(taskId);
|
||||||
if (!entry) return false;
|
if (!entry) return false;
|
||||||
|
|
||||||
entry.task.stop();
|
entry.task.stop();
|
||||||
schedules.delete(taskId);
|
schedules.delete(taskId);
|
||||||
|
if (persist) schedulesStore.delete(taskId);
|
||||||
if (persist) {
|
|
||||||
schedulesStore.delete(taskId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateSchedule(taskId, cronExpr, callback) {
|
export function updateSchedule(taskId, cronExpr, callback) {
|
||||||
const entry = schedules.get(taskId);
|
const entry = schedules.get(taskId);
|
||||||
if (!entry) return false;
|
if (!entry) return false;
|
||||||
|
|
||||||
entry.task.stop();
|
entry.task.stop();
|
||||||
schedules.delete(taskId);
|
schedules.delete(taskId);
|
||||||
|
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}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
schedule(taskId, cronExpr, callback, false);
|
schedule(taskId, cronExpr, callback, false);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -122,32 +104,23 @@ export function updateSchedule(taskId, cronExpr, callback) {
|
|||||||
export function setActive(taskId, active) {
|
export function setActive(taskId, active) {
|
||||||
const entry = schedules.get(taskId);
|
const entry = schedules.get(taskId);
|
||||||
if (!entry) return false;
|
if (!entry) return false;
|
||||||
|
active ? entry.task.start() : entry.task.stop();
|
||||||
if (active) {
|
|
||||||
entry.task.start();
|
|
||||||
} else {
|
|
||||||
entry.task.stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
entry.active = active;
|
entry.active = active;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSchedules() {
|
export function getSchedules() {
|
||||||
const stored = schedulesStore.getAll();
|
const stored = schedulesStore.getAll();
|
||||||
const result = [];
|
return stored.map((s) => {
|
||||||
|
|
||||||
for (const s of stored) {
|
|
||||||
const inMemory = schedules.get(s.id);
|
const inMemory = schedules.get(s.id);
|
||||||
result.push({
|
const cronExpr = s.cronExpression || s.cronExpr || '';
|
||||||
|
return {
|
||||||
...s,
|
...s,
|
||||||
cronExpr: s.cronExpression || s.cronExpr,
|
cronExpr,
|
||||||
active: inMemory ? inMemory.active : false,
|
active: inMemory ? inMemory.active : false,
|
||||||
nextRun: nextCronDate(s.cronExpression || s.cronExpr || ''),
|
nextRun: nextCronDate(cronExpr),
|
||||||
});
|
};
|
||||||
}
|
});
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getHistory() {
|
export function getHistory() {
|
||||||
@@ -161,20 +134,15 @@ export function restoreSchedules(executeFn) {
|
|||||||
for (const s of stored) {
|
for (const s of stored) {
|
||||||
if (!s.active) continue;
|
if (!s.active) continue;
|
||||||
const cronExpr = s.cronExpression || s.cronExpr;
|
const cronExpr = s.cronExpression || s.cronExpr;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
schedule(s.id, cronExpr, () => {
|
schedule(s.id, cronExpr, () => executeFn(s.agentId, s.taskDescription), false);
|
||||||
executeFn(s.agentId, s.taskDescription);
|
|
||||||
}, false);
|
|
||||||
restored++;
|
restored++;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(`[scheduler] Falha ao restaurar agendamento ${s.id}: ${err.message}`);
|
console.log(`[scheduler] Falha ao restaurar ${s.id}: ${err.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (restored > 0) {
|
if (restored > 0) console.log(`[scheduler] ${restored} agendamento(s) restaurado(s)`);
|
||||||
console.log(`[scheduler] ${restored} agendamento(s) restaurado(s)`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function on(event, listener) {
|
export function on(event, listener) {
|
||||||
|
|||||||
153
src/cache/index.js
vendored
Normal file
153
src/cache/index.js
vendored
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
class MemoryCache {
|
||||||
|
#entries = new Map();
|
||||||
|
#timer;
|
||||||
|
|
||||||
|
constructor(cleanupIntervalMs = 5 * 60 * 1000) {
|
||||||
|
this.#timer = setInterval(() => this.#evict(), cleanupIntervalMs);
|
||||||
|
this.#timer.unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
#evict() {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [key, entry] of this.#entries) {
|
||||||
|
if (entry.exp > 0 && now > entry.exp) {
|
||||||
|
this.#entries.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get(key) {
|
||||||
|
const entry = this.#entries.get(key);
|
||||||
|
if (!entry) return undefined;
|
||||||
|
if (entry.exp > 0 && Date.now() > entry.exp) {
|
||||||
|
this.#entries.delete(key);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return entry.val;
|
||||||
|
}
|
||||||
|
|
||||||
|
set(key, value, ttlMs = 0) {
|
||||||
|
this.#entries.set(key, { val: value, exp: ttlMs > 0 ? Date.now() + ttlMs : 0 });
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
del(key) {
|
||||||
|
return this.#entries.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
has(key) {
|
||||||
|
return this.get(key) !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidatePrefix(prefix) {
|
||||||
|
let n = 0;
|
||||||
|
for (const key of this.#entries.keys()) {
|
||||||
|
if (key.startsWith(prefix)) {
|
||||||
|
this.#entries.delete(key);
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
flush() {
|
||||||
|
this.#entries.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
get size() {
|
||||||
|
return this.#entries.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
clearInterval(this.#timer);
|
||||||
|
this.#entries.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tryRedis(url) {
|
||||||
|
try {
|
||||||
|
const { default: Redis } = await import('ioredis');
|
||||||
|
const client = new Redis(url, {
|
||||||
|
lazyConnect: true,
|
||||||
|
connectTimeout: 3000,
|
||||||
|
maxRetriesPerRequest: 1,
|
||||||
|
enableOfflineQueue: false,
|
||||||
|
});
|
||||||
|
await client.ping();
|
||||||
|
console.log('[cache] Redis conectado');
|
||||||
|
return client;
|
||||||
|
} catch (err) {
|
||||||
|
console.log('[cache] Redis indisponível, usando memória:', err.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mem = new MemoryCache();
|
||||||
|
|
||||||
|
let redisClient = null;
|
||||||
|
|
||||||
|
if (process.env.REDIS_URL) {
|
||||||
|
tryRedis(process.env.REDIS_URL).then((c) => {
|
||||||
|
redisClient = c;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function redisGet(key) {
|
||||||
|
if (!redisClient) return Promise.resolve(undefined);
|
||||||
|
return redisClient
|
||||||
|
.get(key)
|
||||||
|
.then((raw) => (raw != null ? JSON.parse(raw) : undefined))
|
||||||
|
.catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function redisSet(key, value, ttlMs) {
|
||||||
|
if (!redisClient) return;
|
||||||
|
const s = JSON.stringify(value);
|
||||||
|
const ttlSec = Math.ceil(ttlMs / 1000);
|
||||||
|
(ttlSec > 0 ? redisClient.setex(key, ttlSec, s) : redisClient.set(key, s)).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function redisDel(key) {
|
||||||
|
if (!redisClient) return;
|
||||||
|
redisClient.del(key).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cached(key, ttlMs, computeFn) {
|
||||||
|
const hit = mem.get(key);
|
||||||
|
if (hit !== undefined) return hit;
|
||||||
|
const value = computeFn();
|
||||||
|
mem.set(key, value, ttlMs);
|
||||||
|
redisSet(key, value, ttlMs);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cachedAsync(key, ttlMs, computeFn) {
|
||||||
|
const hit = mem.get(key);
|
||||||
|
if (hit !== undefined) return hit;
|
||||||
|
const l2 = await redisGet(key);
|
||||||
|
if (l2 !== undefined) {
|
||||||
|
mem.set(key, l2, ttlMs);
|
||||||
|
return l2;
|
||||||
|
}
|
||||||
|
const value = await computeFn();
|
||||||
|
mem.set(key, value, ttlMs);
|
||||||
|
redisSet(key, value, ttlMs);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invalidate(key) {
|
||||||
|
mem.del(key);
|
||||||
|
redisDel(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invalidatePrefix(prefix) {
|
||||||
|
mem.invalidatePrefix(prefix);
|
||||||
|
if (redisClient) {
|
||||||
|
redisClient
|
||||||
|
.keys(`${prefix}*`)
|
||||||
|
.then((keys) => {
|
||||||
|
if (keys.length > 0) redisClient.del(...keys);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,10 +2,12 @@ import { Router } from 'express';
|
|||||||
import { execSync } from 'child_process';
|
import { execSync } from 'child_process';
|
||||||
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 } from '../store/db.js';
|
import { tasksStore, settingsStore, executionsStore } 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 } from '../agents/executor.js';
|
import { getBinPath, updateMaxConcurrent } from '../agents/executor.js';
|
||||||
|
import { invalidateAgentMapCache } from '../agents/pipeline.js';
|
||||||
|
import { cached } from '../cache/index.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -21,11 +23,8 @@ export function setWsBroadcastTo(fn) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function wsCallback(message, clientId) {
|
function wsCallback(message, clientId) {
|
||||||
if (clientId && wsBroadcastTo) {
|
if (clientId && wsBroadcastTo) wsBroadcastTo(clientId, message);
|
||||||
wsBroadcastTo(clientId, message);
|
else if (wsbroadcast) wsbroadcast(message);
|
||||||
} else if (wsbroadcast) {
|
|
||||||
wsbroadcast(message);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
router.get('/settings', (req, res) => {
|
router.get('/settings', (req, res) => {
|
||||||
@@ -45,6 +44,7 @@ router.put('/settings', (req, res) => {
|
|||||||
}
|
}
|
||||||
if (data.maxConcurrent !== undefined) {
|
if (data.maxConcurrent !== undefined) {
|
||||||
data.maxConcurrent = Math.max(1, Math.min(20, parseInt(data.maxConcurrent) || 5));
|
data.maxConcurrent = Math.max(1, Math.min(20, parseInt(data.maxConcurrent) || 5));
|
||||||
|
updateMaxConcurrent(data.maxConcurrent);
|
||||||
}
|
}
|
||||||
const saved = settingsStore.save(data);
|
const saved = settingsStore.save(data);
|
||||||
res.json(saved);
|
res.json(saved);
|
||||||
@@ -74,6 +74,7 @@ router.get('/agents/:id', (req, res) => {
|
|||||||
router.post('/agents', (req, res) => {
|
router.post('/agents', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const agent = manager.createAgent(req.body);
|
const agent = manager.createAgent(req.body);
|
||||||
|
invalidateAgentMapCache();
|
||||||
res.status(201).json(agent);
|
res.status(201).json(agent);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(400).json({ error: err.message });
|
res.status(400).json({ error: err.message });
|
||||||
@@ -83,6 +84,7 @@ router.post('/agents', (req, res) => {
|
|||||||
router.post('/agents/import', (req, res) => {
|
router.post('/agents/import', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const agent = manager.importAgent(req.body);
|
const agent = manager.importAgent(req.body);
|
||||||
|
invalidateAgentMapCache();
|
||||||
res.status(201).json(agent);
|
res.status(201).json(agent);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(400).json({ error: err.message });
|
res.status(400).json({ error: err.message });
|
||||||
@@ -93,6 +95,7 @@ router.put('/agents/:id', (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const agent = manager.updateAgent(req.params.id, req.body);
|
const agent = manager.updateAgent(req.params.id, req.body);
|
||||||
if (!agent) return res.status(404).json({ error: 'Agente não encontrado' });
|
if (!agent) return res.status(404).json({ error: 'Agente não encontrado' });
|
||||||
|
invalidateAgentMapCache();
|
||||||
res.json(agent);
|
res.json(agent);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(400).json({ error: err.message });
|
res.status(400).json({ error: err.message });
|
||||||
@@ -103,6 +106,7 @@ router.delete('/agents/:id', (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const deleted = manager.deleteAgent(req.params.id);
|
const deleted = manager.deleteAgent(req.params.id);
|
||||||
if (!deleted) return res.status(404).json({ error: 'Agente não encontrado' });
|
if (!deleted) return res.status(404).json({ error: 'Agente não encontrado' });
|
||||||
|
invalidateAgentMapCache();
|
||||||
res.status(204).send();
|
res.status(204).send();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
@@ -113,12 +117,8 @@ router.post('/agents/:id/execute', (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const { task, instructions } = req.body;
|
const { task, instructions } = req.body;
|
||||||
if (!task) return res.status(400).json({ error: 'task é obrigatório' });
|
if (!task) return res.status(400).json({ error: 'task é obrigatório' });
|
||||||
|
|
||||||
const clientId = req.headers['x-client-id'] || null;
|
const clientId = req.headers['x-client-id'] || null;
|
||||||
const executionId = manager.executeTask(
|
const executionId = manager.executeTask(req.params.id, task, instructions, (msg) => wsCallback(msg, clientId));
|
||||||
req.params.id, task, instructions,
|
|
||||||
(msg) => wsCallback(msg, clientId)
|
|
||||||
);
|
|
||||||
res.status(202).json({ executionId, status: 'started' });
|
res.status(202).json({ executionId, status: 'started' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const status = err.message.includes('não encontrado') ? 404 : 400;
|
const status = err.message.includes('não encontrado') ? 404 : 400;
|
||||||
@@ -157,8 +157,7 @@ router.get('/tasks', (req, res) => {
|
|||||||
router.post('/tasks', (req, res) => {
|
router.post('/tasks', (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (!req.body.name) return res.status(400).json({ error: 'name é obrigatório' });
|
if (!req.body.name) return res.status(400).json({ error: 'name é obrigatório' });
|
||||||
const task = tasksStore.create(req.body);
|
res.status(201).json(tasksStore.create(req.body));
|
||||||
res.status(201).json(task);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(400).json({ error: err.message });
|
res.status(400).json({ error: err.message });
|
||||||
}
|
}
|
||||||
@@ -257,8 +256,7 @@ router.get('/pipelines/:id', (req, res) => {
|
|||||||
|
|
||||||
router.post('/pipelines', (req, res) => {
|
router.post('/pipelines', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const created = pipeline.createPipeline(req.body);
|
res.status(201).json(pipeline.createPipeline(req.body));
|
||||||
res.status(201).json(created);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(400).json({ error: err.message });
|
res.status(400).json({ error: err.message });
|
||||||
}
|
}
|
||||||
@@ -288,7 +286,6 @@ router.post('/pipelines/:id/execute', (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const { input } = req.body;
|
const { input } = 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;
|
||||||
pipeline.executePipeline(req.params.id, input, (msg) => wsCallback(msg, clientId)).catch(() => {});
|
pipeline.executePipeline(req.params.id, input, (msg) => wsCallback(msg, clientId)).catch(() => {});
|
||||||
res.status(202).json({ pipelineId: req.params.id, status: 'started' });
|
res.status(202).json({ pipelineId: req.params.id, status: 'started' });
|
||||||
@@ -308,54 +305,121 @@ router.post('/pipelines/:id/cancel', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const SYSTEM_STATUS_TTL = 5_000;
|
||||||
|
|
||||||
router.get('/system/status', (req, res) => {
|
router.get('/system/status', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const agents = manager.getAllAgents();
|
const status = cached('system:status', SYSTEM_STATUS_TTL, () => {
|
||||||
const activeExecutions = manager.getActiveExecutions();
|
const agents = manager.getAllAgents();
|
||||||
const schedules = scheduler.getSchedules();
|
const activeExecutions = manager.getActiveExecutions();
|
||||||
const pipelines = pipeline.getAllPipelines();
|
const schedules = scheduler.getSchedules();
|
||||||
const activePipelines = pipeline.getActivePipelines();
|
const pipelines = pipeline.getAllPipelines();
|
||||||
|
const activePipelines = pipeline.getActivePipelines();
|
||||||
|
return {
|
||||||
|
agents: {
|
||||||
|
total: agents.length,
|
||||||
|
active: agents.filter((a) => a.status === 'active').length,
|
||||||
|
inactive: agents.filter((a) => a.status === 'inactive').length,
|
||||||
|
},
|
||||||
|
executions: {
|
||||||
|
active: activeExecutions.length,
|
||||||
|
today: manager.getDailyExecutionCount(),
|
||||||
|
list: activeExecutions,
|
||||||
|
},
|
||||||
|
schedules: {
|
||||||
|
total: schedules.length,
|
||||||
|
active: schedules.filter((s) => s.active).length,
|
||||||
|
},
|
||||||
|
pipelines: {
|
||||||
|
total: pipelines.length,
|
||||||
|
active: pipelines.filter((p) => p.status === 'active').length,
|
||||||
|
running: activePipelines.length,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
res.json(status);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let claudeVersionCache = null;
|
||||||
|
|
||||||
|
router.get('/system/info', (req, res) => {
|
||||||
|
try {
|
||||||
|
if (claudeVersionCache === null) {
|
||||||
|
try {
|
||||||
|
claudeVersionCache = execSync(`${getBinPath()} --version`, { timeout: 5000 }).toString().trim();
|
||||||
|
} catch {
|
||||||
|
claudeVersionCache = 'N/A';
|
||||||
|
}
|
||||||
|
}
|
||||||
res.json({
|
res.json({
|
||||||
agents: {
|
serverVersion: '1.0.0',
|
||||||
total: agents.length,
|
nodeVersion: process.version,
|
||||||
active: agents.filter((a) => a.status === 'active').length,
|
claudeVersion: claudeVersionCache,
|
||||||
inactive: agents.filter((a) => a.status === 'inactive').length,
|
platform: `${os.platform()} ${os.arch()}`,
|
||||||
},
|
uptime: Math.floor(process.uptime()),
|
||||||
executions: {
|
|
||||||
active: activeExecutions.length,
|
|
||||||
today: manager.getDailyExecutionCount(),
|
|
||||||
list: activeExecutions,
|
|
||||||
},
|
|
||||||
schedules: {
|
|
||||||
total: schedules.length,
|
|
||||||
active: schedules.filter((s) => s.active).length,
|
|
||||||
},
|
|
||||||
pipelines: {
|
|
||||||
total: pipelines.length,
|
|
||||||
active: pipelines.filter((p) => p.status === 'active').length,
|
|
||||||
running: activePipelines.length,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/system/info', (req, res) => {
|
router.get('/executions/history', (req, res) => {
|
||||||
try {
|
try {
|
||||||
let claudeVersion = 'N/A';
|
const limit = parseInt(req.query.limit) || 50;
|
||||||
try {
|
const offset = parseInt(req.query.offset) || 0;
|
||||||
claudeVersion = execSync(`${getBinPath()} --version`, { timeout: 5000 }).toString().trim();
|
const typeFilter = req.query.type || '';
|
||||||
} catch {}
|
const statusFilter = req.query.status || '';
|
||||||
|
const search = (req.query.search || '').toLowerCase();
|
||||||
|
|
||||||
res.json({
|
let items = executionsStore.getAll();
|
||||||
serverVersion: '1.0.0',
|
|
||||||
nodeVersion: process.version,
|
if (typeFilter) items = items.filter((e) => e.type === typeFilter);
|
||||||
claudeVersion,
|
if (statusFilter) items = items.filter((e) => e.status === statusFilter);
|
||||||
platform: `${os.platform()} ${os.arch()}`,
|
if (search) {
|
||||||
uptime: Math.floor(process.uptime()),
|
items = items.filter((e) => {
|
||||||
});
|
const name = (e.agentName || e.pipelineName || '').toLowerCase();
|
||||||
|
const task = (e.task || e.input || '').toLowerCase();
|
||||||
|
return name.includes(search) || task.includes(search);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
items.sort((a, b) => new Date(b.startedAt) - new Date(a.startedAt));
|
||||||
|
const total = items.length;
|
||||||
|
const paged = items.slice(offset, offset + limit);
|
||||||
|
|
||||||
|
res.json({ items: paged, total });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/executions/history/:id', (req, res) => {
|
||||||
|
try {
|
||||||
|
const exec = executionsStore.getById(req.params.id);
|
||||||
|
if (!exec) return res.status(404).json({ error: 'Execução não encontrada' });
|
||||||
|
res.json(exec);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/executions/history/:id', (req, res) => {
|
||||||
|
try {
|
||||||
|
const deleted = executionsStore.delete(req.params.id);
|
||||||
|
if (!deleted) return res.status(404).json({ error: 'Execução não encontrada' });
|
||||||
|
res.status(204).send();
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/executions/history', (req, res) => {
|
||||||
|
try {
|
||||||
|
executionsStore.save([]);
|
||||||
|
res.status(204).send();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
}
|
}
|
||||||
|
|||||||
230
src/store/db.js
230
src/store/db.js
@@ -1,15 +1,10 @@
|
|||||||
import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync } from 'fs';
|
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
||||||
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';
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const DATA_DIR = `${__dirname}/../../data`;
|
const DATA_DIR = `${__dirname}/../../data`;
|
||||||
const AGENTS_FILE = `${DATA_DIR}/agents.json`;
|
|
||||||
const TASKS_FILE = `${DATA_DIR}/tasks.json`;
|
|
||||||
const PIPELINES_FILE = `${DATA_DIR}/pipelines.json`;
|
|
||||||
const SCHEDULES_FILE = `${DATA_DIR}/schedules.json`;
|
|
||||||
const SETTINGS_FILE = `${DATA_DIR}/settings.json`;
|
|
||||||
|
|
||||||
const DEFAULT_SETTINGS = {
|
const DEFAULT_SETTINGS = {
|
||||||
defaultModel: 'claude-sonnet-4-6',
|
defaultModel: 'claude-sonnet-4-6',
|
||||||
@@ -17,124 +12,177 @@ const DEFAULT_SETTINGS = {
|
|||||||
maxConcurrent: 5,
|
maxConcurrent: 5,
|
||||||
};
|
};
|
||||||
|
|
||||||
const writeLocks = new Map();
|
const DEBOUNCE_MS = 300;
|
||||||
const fileCache = new Map();
|
const allStores = [];
|
||||||
|
|
||||||
function ensureDataDir() {
|
function ensureDir() {
|
||||||
if (!existsSync(DATA_DIR)) {
|
if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
|
||||||
mkdirSync(DATA_DIR, { recursive: true });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCacheMtime(filePath) {
|
function readJson(path, fallback) {
|
||||||
const cached = fileCache.get(filePath);
|
|
||||||
if (!cached) return null;
|
|
||||||
return cached.mtime;
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadFile(filePath, defaultValue = []) {
|
|
||||||
ensureDataDir();
|
|
||||||
if (!existsSync(filePath)) {
|
|
||||||
writeFileSync(filePath, JSON.stringify(defaultValue, null, 2), 'utf8');
|
|
||||||
fileCache.set(filePath, { data: defaultValue, mtime: Date.now() });
|
|
||||||
return JSON.parse(JSON.stringify(defaultValue));
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const stat = statSync(filePath);
|
if (!existsSync(path)) return fallback;
|
||||||
const mtime = stat.mtimeMs;
|
return JSON.parse(readFileSync(path, 'utf8'));
|
||||||
const cached = fileCache.get(filePath);
|
|
||||||
|
|
||||||
if (cached && cached.mtime === mtime) {
|
|
||||||
return JSON.parse(JSON.stringify(cached.data));
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = JSON.parse(readFileSync(filePath, 'utf8'));
|
|
||||||
fileCache.set(filePath, { data, mtime });
|
|
||||||
return JSON.parse(JSON.stringify(data));
|
|
||||||
} catch {
|
} catch {
|
||||||
return JSON.parse(JSON.stringify(defaultValue));
|
return fallback;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveFile(filePath, data) {
|
function writeJson(path, data) {
|
||||||
ensureDataDir();
|
ensureDir();
|
||||||
const prev = writeLocks.get(filePath) || Promise.resolve();
|
writeFileSync(path, JSON.stringify(data, null, 2), 'utf8');
|
||||||
const next = prev.then(() => {
|
}
|
||||||
writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8');
|
|
||||||
const stat = statSync(filePath);
|
function clone(v) {
|
||||||
fileCache.set(filePath, { data: JSON.parse(JSON.stringify(data)), mtime: stat.mtimeMs });
|
return structuredClone(v);
|
||||||
}).catch(() => {});
|
|
||||||
writeLocks.set(filePath, next);
|
|
||||||
return next;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createStore(filePath) {
|
function createStore(filePath) {
|
||||||
return {
|
let mem = null;
|
||||||
load: () => loadFile(filePath),
|
let dirty = false;
|
||||||
|
let timer = null;
|
||||||
|
|
||||||
save: (data) => saveFile(filePath, data),
|
function boot() {
|
||||||
|
if (mem !== null) return;
|
||||||
|
ensureDir();
|
||||||
|
mem = readJson(filePath, []);
|
||||||
|
}
|
||||||
|
|
||||||
getAll: () => loadFile(filePath),
|
function touch() {
|
||||||
|
dirty = true;
|
||||||
|
if (timer) return;
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
timer = null;
|
||||||
|
if (dirty) {
|
||||||
|
writeJson(filePath, mem);
|
||||||
|
dirty = false;
|
||||||
|
}
|
||||||
|
}, DEBOUNCE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
getById: (id) => {
|
const store = {
|
||||||
const items = loadFile(filePath);
|
getAll() {
|
||||||
return items.find((item) => item.id === id) || null;
|
boot();
|
||||||
|
return clone(mem);
|
||||||
},
|
},
|
||||||
|
|
||||||
create: (data) => {
|
getById(id) {
|
||||||
const items = loadFile(filePath);
|
boot();
|
||||||
const newItem = {
|
const item = mem.find((i) => i.id === id);
|
||||||
|
return item ? clone(item) : null;
|
||||||
|
},
|
||||||
|
|
||||||
|
create(data) {
|
||||||
|
boot();
|
||||||
|
const item = {
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
...data,
|
...data,
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
updated_at: new Date().toISOString(),
|
updated_at: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
items.push(newItem);
|
mem.push(item);
|
||||||
saveFile(filePath, items);
|
touch();
|
||||||
return newItem;
|
return clone(item);
|
||||||
},
|
},
|
||||||
|
|
||||||
update: (id, data) => {
|
update(id, data) {
|
||||||
const items = loadFile(filePath);
|
boot();
|
||||||
const index = items.findIndex((item) => item.id === id);
|
const i = mem.findIndex((x) => x.id === id);
|
||||||
if (index === -1) return null;
|
if (i === -1) return null;
|
||||||
items[index] = {
|
mem[i] = { ...mem[i], ...data, id, updated_at: new Date().toISOString() };
|
||||||
...items[index],
|
touch();
|
||||||
...data,
|
return clone(mem[i]);
|
||||||
id,
|
|
||||||
updated_at: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
saveFile(filePath, items);
|
|
||||||
return items[index];
|
|
||||||
},
|
},
|
||||||
|
|
||||||
delete: (id) => {
|
delete(id) {
|
||||||
const items = loadFile(filePath);
|
boot();
|
||||||
const index = items.findIndex((item) => item.id === id);
|
const i = mem.findIndex((x) => x.id === id);
|
||||||
if (index === -1) return false;
|
if (i === -1) return false;
|
||||||
items.splice(index, 1);
|
mem.splice(i, 1);
|
||||||
saveFile(filePath, items);
|
touch();
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
save(items) {
|
||||||
|
mem = Array.isArray(items) ? items : mem;
|
||||||
|
touch();
|
||||||
|
},
|
||||||
|
|
||||||
|
flush() {
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
|
if (mem !== null && dirty) {
|
||||||
|
writeJson(filePath, mem);
|
||||||
|
dirty = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
allStores.push(store);
|
||||||
|
return store;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createSettingsStore(filePath) {
|
function createSettingsStore(filePath) {
|
||||||
return {
|
let mem = null;
|
||||||
get: () => loadFile(filePath, DEFAULT_SETTINGS),
|
let dirty = false;
|
||||||
|
let timer = null;
|
||||||
|
|
||||||
save: (data) => {
|
function boot() {
|
||||||
const current = loadFile(filePath, DEFAULT_SETTINGS);
|
if (mem !== null) return;
|
||||||
const merged = { ...current, ...data };
|
ensureDir();
|
||||||
saveFile(filePath, merged);
|
mem = { ...DEFAULT_SETTINGS, ...readJson(filePath, DEFAULT_SETTINGS) };
|
||||||
return merged;
|
}
|
||||||
|
|
||||||
|
function touch() {
|
||||||
|
dirty = true;
|
||||||
|
if (timer) return;
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
timer = null;
|
||||||
|
if (dirty) {
|
||||||
|
writeJson(filePath, mem);
|
||||||
|
dirty = false;
|
||||||
|
}
|
||||||
|
}, DEBOUNCE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = {
|
||||||
|
get() {
|
||||||
|
boot();
|
||||||
|
return clone(mem);
|
||||||
|
},
|
||||||
|
|
||||||
|
save(data) {
|
||||||
|
boot();
|
||||||
|
mem = { ...mem, ...data };
|
||||||
|
touch();
|
||||||
|
return clone(mem);
|
||||||
|
},
|
||||||
|
|
||||||
|
flush() {
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
|
if (mem !== null && dirty) {
|
||||||
|
writeJson(filePath, mem);
|
||||||
|
dirty = false;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
allStores.push(store);
|
||||||
|
return store;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const agentsStore = createStore(AGENTS_FILE);
|
export function flushAllStores() {
|
||||||
export const tasksStore = createStore(TASKS_FILE);
|
for (const s of allStores) s.flush();
|
||||||
export const pipelinesStore = createStore(PIPELINES_FILE);
|
}
|
||||||
export const schedulesStore = createStore(SCHEDULES_FILE);
|
|
||||||
export const settingsStore = createSettingsStore(SETTINGS_FILE);
|
export const agentsStore = createStore(`${DATA_DIR}/agents.json`);
|
||||||
|
export const tasksStore = createStore(`${DATA_DIR}/tasks.json`);
|
||||||
|
export const pipelinesStore = createStore(`${DATA_DIR}/pipelines.json`);
|
||||||
|
export const schedulesStore = createStore(`${DATA_DIR}/schedules.json`);
|
||||||
|
export const executionsStore = createStore(`${DATA_DIR}/executions.json`);
|
||||||
|
export const settingsStore = createSettingsStore(`${DATA_DIR}/settings.json`);
|
||||||
|
|||||||
Reference in New Issue
Block a user