Implementação completa de funcionalidades pendentes

- Settings persistentes (modelo padrão, workdir, max concurrent)
- Import/export de agentes via JSON
- Agendamentos persistentes com restore no startup
- Edição de agendamentos e tarefas existentes
- Filtros e busca em todas as seções
- Isolamento de WebSocket por clientId
- Autenticação via AUTH_TOKEN e CORS configurável
- Graceful shutdown com cancelamento de execuções
- Correção: --max-tokens removido (flag inválida do CLI)
- Correção: pipeline agora verifica exit code e propaga erros
- Correção: streaming de output em pipelines via WebSocket
- Permission mode bypassPermissions como padrão
- Página de configurações do sistema
- Contagem diária de execuções no dashboard
- Histórico de execuções recentes
This commit is contained in:
Frederico Castro
2026-02-26 01:24:51 -03:00
parent 723a08d2e1
commit 2f7a9d4c56
18 changed files with 1104 additions and 115 deletions

View File

@@ -2504,6 +2504,24 @@ tbody tr:hover td {
text-align: center; text-align: center;
} }
#schedules-table {
table-layout: fixed;
}
.schedule-task-cell {
max-width: 250px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.schedule-actions-cell {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: nowrap;
}
.terminal-wrapper { .terminal-wrapper {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -91,6 +91,10 @@
<i data-lucide="server"></i> <i data-lucide="server"></i>
<span>Sistema</span> <span>Sistema</span>
</button> </button>
<button class="btn btn--ghost btn--icon-text" id="import-agent-btn" type="button">
<i data-lucide="upload"></i>
<span>Importar</span>
</button>
<button class="btn btn--primary btn--icon-text" id="new-agent-btn" type="button"> <button class="btn btn--primary btn--icon-text" id="new-agent-btn" type="button">
<i data-lucide="plus"></i> <i data-lucide="plus"></i>
<span>Novo Agente</span> <span>Novo Agente</span>
@@ -288,6 +292,14 @@
<div class="card"> <div class="card">
<div class="table-wrapper"> <div class="table-wrapper">
<table class="table" id="schedules-table"> <table class="table" id="schedules-table">
<colgroup>
<col style="width: 15%">
<col style="width: 30%">
<col style="width: 18%">
<col style="width: 17%">
<col style="width: 8%">
<col style="width: 12%">
</colgroup>
<thead> <thead>
<tr> <tr>
<th scope="col">Agente</th> <th scope="col">Agente</th>
@@ -311,6 +323,15 @@
</table> </table>
</div> </div>
</div> </div>
<div class="card" style="margin-top: 1rem;">
<div class="card-header">
<h2 class="card-title">Histórico de Disparos</h2>
</div>
<div class="card-body" id="schedules-history">
<p class="empty-state-desc">Nenhum disparo registrado</p>
</div>
</div>
</section> </section>
<section id="pipelines" class="section" aria-label="Pipelines" hidden> <section id="pipelines" class="section" aria-label="Pipelines" hidden>
@@ -534,6 +555,44 @@
</div> </div>
</div> </div>
<div class="form-row">
<div class="form-group form-group--grow">
<label class="form-label" for="agent-allowed-tools">Ferramentas Permitidas</label>
<input
type="text"
class="input"
id="agent-allowed-tools"
name="allowedTools"
placeholder="Bash,Read,Write,Glob,Grep"
autocomplete="off"
/>
<p class="form-hint">Lista separada por vírgula. Vazio = todas permitidas.</p>
</div>
<div class="form-group">
<label class="form-label" for="agent-max-turns">Max Turns</label>
<input
type="number"
class="input"
id="agent-max-turns"
name="maxTurns"
min="0"
max="100"
value="0"
/>
<p class="form-hint">Limite de turnos agênticos. 0 = sem limite.</p>
</div>
<div class="form-group">
<label class="form-label" for="agent-permission-mode">Permission Mode</label>
<select class="select" id="agent-permission-mode" name="permissionMode">
<option value="">Padrão</option>
<option value="default">default</option>
<option value="plan">plan</option>
<option value="acceptEdits">acceptEdits</option>
<option value="bypassPermissions">bypassPermissions</option>
</select>
</div>
</div>
<div class="form-group"> <div class="form-group">
<label class="form-label" for="agent-tags-input">Tags</label> <label class="form-label" for="agent-tags-input">Tags</label>
<div class="tags-input-wrapper" id="agent-tags-wrapper"> <div class="tags-input-wrapper" id="agent-tags-wrapper">
@@ -852,6 +911,36 @@
</div> </div>
</div> </div>
<div class="modal-overlay" id="import-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="import-modal-title" hidden>
<div class="modal modal--md">
<div class="modal-header">
<h2 class="modal-title" id="import-modal-title">Importar Agente</h2>
<button class="modal-close" type="button" aria-label="Fechar modal" data-modal-close="import-modal-overlay">
<i data-lucide="x"></i>
</button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label" for="import-json-content">JSON do Agente</label>
<textarea
class="textarea textarea--code"
id="import-json-content"
rows="10"
placeholder="Cole aqui o JSON exportado do agente..."
></textarea>
<p class="form-hint">Cole o JSON gerado pela função de exportar agente.</p>
</div>
</div>
<div class="modal-footer">
<button class="btn btn--ghost" type="button" data-modal-close="import-modal-overlay">Cancelar</button>
<button class="btn btn--primary btn--icon-text" type="button" id="import-confirm-btn">
<i data-lucide="upload"></i>
<span>Importar</span>
</button>
</div>
</div>
</div>
<div class="toast-container" id="toast-container" aria-live="assertive" aria-atomic="false" role="region" aria-label="Notificações"></div> <div class="toast-container" id="toast-container" aria-live="assertive" aria-atomic="false" role="region" aria-label="Notificações"></div>
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.js"></script> <script src="https://unpkg.com/lucide@latest/dist/umd/lucide.js"></script>
@@ -864,6 +953,7 @@
<script src="js/components/tasks.js"></script> <script src="js/components/tasks.js"></script>
<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/app.js"></script> <script src="js/app.js"></script>
<script> <script>
lucide.createIcons(); lucide.createIcons();

View File

@@ -1,10 +1,18 @@
const API = { const API = {
baseUrl: '/api', baseUrl: '/api',
clientId: sessionStorage.getItem('clientId') || (() => {
const id = crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2);
sessionStorage.setItem('clientId', id);
return id;
})(),
async request(method, path, body = null) { async request(method, path, body = null) {
const options = { const options = {
method, method,
headers: { 'Content-Type': 'application/json' }, headers: {
'Content-Type': 'application/json',
'X-Client-Id': API.clientId,
},
}; };
if (body !== null) { if (body !== null) {
@@ -33,6 +41,7 @@ const API = {
execute(id, task, instructions) { return API.request('POST', `/agents/${id}/execute`, { task, instructions }); }, execute(id, task, instructions) { return API.request('POST', `/agents/${id}/execute`, { task, instructions }); },
cancel(id, executionId) { return API.request('POST', `/agents/${id}/cancel/${executionId}`); }, cancel(id, executionId) { return API.request('POST', `/agents/${id}/cancel/${executionId}`); },
export(id) { return API.request('GET', `/agents/${id}/export`); }, export(id) { return API.request('GET', `/agents/${id}/export`); },
import(data) { return API.request('POST', '/agents/import', data); },
}, },
tasks: { tasks: {
@@ -45,7 +54,9 @@ const API = {
schedules: { schedules: {
list() { return API.request('GET', '/schedules'); }, list() { return API.request('GET', '/schedules'); },
create(data) { return API.request('POST', '/schedules', data); }, create(data) { return API.request('POST', '/schedules', data); },
update(id, data) { return API.request('PUT', `/schedules/${id}`, data); },
delete(taskId) { return API.request('DELETE', `/schedules/${taskId}`); }, delete(taskId) { return API.request('DELETE', `/schedules/${taskId}`); },
history() { return API.request('GET', '/schedules/history'); },
}, },
pipelines: { pipelines: {
@@ -60,8 +71,18 @@ const API = {
system: { system: {
status() { return API.request('GET', '/system/status'); }, status() { return API.request('GET', '/system/status'); },
info() { return API.request('GET', '/system/info'); },
activeExecutions() { return API.request('GET', '/executions/active'); }, activeExecutions() { return API.request('GET', '/executions/active'); },
}, },
settings: {
get() { return API.request('GET', '/settings'); },
save(data) { return API.request('PUT', '/settings', data); },
},
executions: {
recent(limit = 20) { return API.request('GET', `/executions/recent?limit=${limit}`); },
},
}; };
window.API = API; window.API = API;

View File

@@ -70,6 +70,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 'settings': await SettingsUI.load(); break;
} }
} catch (err) { } catch (err) {
Toast.error(`Erro ao carregar seção: ${err.message}`); Toast.error(`Erro ao carregar seção: ${err.message}`);
@@ -78,7 +79,8 @@ const App = {
setupWebSocket() { setupWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = `${protocol}//${window.location.host}`; const clientId = API.clientId;
const url = `${protocol}//${window.location.host}?clientId=${clientId}`;
try { try {
App.ws = new WebSocket(url); App.ws = new WebSocket(url);
@@ -126,11 +128,14 @@ const App = {
handleWsMessage(data) { handleWsMessage(data) {
switch (data.type) { switch (data.type) {
case 'connected':
break;
case 'execution_output': { case 'execution_output': {
Terminal.stopProcessing(); Terminal.stopProcessing();
const content = data.data?.content || ''; const content = data.data?.content || '';
if (content) { if (content) {
Terminal.addLine(content, 'default'); Terminal.addLine(content, 'default', data.executionId);
} }
App._updateActiveBadge(); App._updateActiveBadge();
break; break;
@@ -140,12 +145,12 @@ const App = {
Terminal.stopProcessing(); Terminal.stopProcessing();
const result = data.data?.result || ''; const result = data.data?.result || '';
if (result) { if (result) {
Terminal.addLine(result, 'success'); Terminal.addLine(result, 'success', data.executionId);
} else { } else {
Terminal.addLine('Execução concluída (sem resultado textual).', 'info'); Terminal.addLine('Execução concluída (sem resultado textual).', 'info', data.executionId);
} }
if (data.data?.stderr) { if (data.data?.stderr) {
Terminal.addLine(data.data.stderr, 'error'); Terminal.addLine(data.data.stderr, 'error', data.executionId);
} }
Toast.success('Execução concluída'); Toast.success('Execução concluída');
App.refreshCurrentSection(); App.refreshCurrentSection();
@@ -155,11 +160,20 @@ const App = {
case 'execution_error': case 'execution_error':
Terminal.stopProcessing(); Terminal.stopProcessing();
Terminal.addLine(data.data?.error || 'Erro na execução', 'error'); Terminal.addLine(data.data?.error || 'Erro na execução', 'error', data.executionId);
Toast.error(`Erro na execução: ${data.data?.error || 'desconhecido'}`); Toast.error(`Erro na execução: ${data.data?.error || 'desconhecido'}`);
App._updateActiveBadge(); App._updateActiveBadge();
break; break;
case 'pipeline_step_output': {
Terminal.stopProcessing();
const stepContent = data.data?.content || '';
if (stepContent) {
Terminal.addLine(stepContent, 'default', data.executionId);
}
break;
}
case 'pipeline_step_start': case 'pipeline_step_start':
Terminal.stopProcessing(); Terminal.stopProcessing();
Terminal.addLine(`Pipeline passo ${data.stepIndex + 1}/${data.totalSteps}: Executando agente "${data.agentName}"...`, 'system'); Terminal.addLine(`Pipeline passo ${data.stepIndex + 1}/${data.totalSteps}: Executando agente "${data.agentName}"...`, 'system');
@@ -238,6 +252,7 @@ const App = {
on('new-agent-btn', 'click', () => AgentsUI.openCreateModal()); on('new-agent-btn', 'click', () => AgentsUI.openCreateModal());
on('agents-empty-new-btn', 'click', () => AgentsUI.openCreateModal()); on('agents-empty-new-btn', 'click', () => AgentsUI.openCreateModal());
on('import-agent-btn', 'click', () => AgentsUI.openImportModal());
on('agent-form-submit', 'click', (e) => { on('agent-form-submit', 'click', (e) => {
e.preventDefault(); e.preventDefault();
@@ -249,6 +264,8 @@ const App = {
AgentsUI.save(); AgentsUI.save();
}); });
on('import-confirm-btn', 'click', () => AgentsUI.importAgent());
on('execute-form-submit', 'click', (e) => { on('execute-form-submit', 'click', (e) => {
e.preventDefault(); e.preventDefault();
App._handleExecute(); App._handleExecute();
@@ -297,7 +314,53 @@ const App = {
on('settings-form', 'submit', (e) => { on('settings-form', 'submit', (e) => {
e.preventDefault(); e.preventDefault();
Toast.info('Configurações salvas'); SettingsUI.save();
});
on('agents-search', 'input', () => {
AgentsUI.filter(
document.getElementById('agents-search')?.value,
document.getElementById('agents-filter-status')?.value
);
});
on('agents-filter-status', 'change', () => {
AgentsUI.filter(
document.getElementById('agents-search')?.value,
document.getElementById('agents-filter-status')?.value
);
});
on('tasks-search', 'input', () => {
TasksUI.filter(
document.getElementById('tasks-search')?.value,
document.getElementById('tasks-filter-category')?.value
);
});
on('tasks-filter-category', 'change', () => {
TasksUI.filter(
document.getElementById('tasks-search')?.value,
document.getElementById('tasks-filter-category')?.value
);
});
on('schedules-search', 'input', () => {
SchedulesUI.filter(
document.getElementById('schedules-search')?.value,
document.getElementById('schedules-filter-status')?.value
);
});
on('schedules-filter-status', 'change', () => {
SchedulesUI.filter(
document.getElementById('schedules-search')?.value,
document.getElementById('schedules-filter-status')?.value
);
});
on('pipelines-search', 'input', () => {
PipelinesUI.filter(document.getElementById('pipelines-search')?.value);
}); });
document.getElementById('agents-grid')?.addEventListener('click', (e) => { document.getElementById('agents-grid')?.addEventListener('click', (e) => {
@@ -320,7 +383,10 @@ const App = {
const { action, id } = btn.dataset; const { action, id } = btn.dataset;
if (action === 'delete-task') TasksUI.delete(id); switch (action) {
case 'edit-task': TasksUI.openEditModal(id); break;
case 'delete-task': TasksUI.delete(id); break;
}
}); });
document.getElementById('schedules-tbody')?.addEventListener('click', (e) => { document.getElementById('schedules-tbody')?.addEventListener('click', (e) => {
@@ -329,7 +395,10 @@ const App = {
const { action, id } = btn.dataset; const { action, id } = btn.dataset;
if (action === 'delete-schedule') SchedulesUI.delete(id); switch (action) {
case 'edit-schedule': SchedulesUI.openEditModal(id); break;
case 'delete-schedule': SchedulesUI.delete(id); break;
}
}); });
document.getElementById('pipelines-grid')?.addEventListener('click', (e) => { document.getElementById('pipelines-grid')?.addEventListener('click', (e) => {

View File

@@ -21,24 +21,26 @@ const AgentsUI = {
} }
}, },
render() { render(filteredAgents) {
const grid = document.getElementById('agents-grid'); const grid = document.getElementById('agents-grid');
const empty = document.getElementById('agents-empty-state'); const empty = document.getElementById('agents-empty-state');
if (!grid) return; if (!grid) return;
if (AgentsUI.agents.length === 0) { const agents = filteredAgents || AgentsUI.agents;
const existingCards = grid.querySelectorAll('.agent-card');
existingCards.forEach((c) => c.remove());
if (agents.length === 0) {
if (empty) empty.style.display = 'flex'; if (empty) empty.style.display = 'flex';
return; return;
} }
if (empty) empty.style.display = 'none'; if (empty) empty.style.display = 'none';
const existingCards = grid.querySelectorAll('.agent-card');
existingCards.forEach((c) => c.remove());
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
AgentsUI.agents.forEach((agent) => { agents.forEach((agent) => {
const wrapper = document.createElement('div'); const wrapper = document.createElement('div');
wrapper.innerHTML = AgentsUI.renderCard(agent); wrapper.innerHTML = AgentsUI.renderCard(agent);
fragment.appendChild(wrapper.firstElementChild); fragment.appendChild(wrapper.firstElementChild);
@@ -49,6 +51,22 @@ const AgentsUI = {
if (window.lucide) lucide.createIcons({ nodes: [grid] }); if (window.lucide) lucide.createIcons({ nodes: [grid] });
}, },
filter(searchText, statusFilter) {
const search = (searchText || '').toLowerCase();
const status = statusFilter || '';
const filtered = AgentsUI.agents.filter((a) => {
const name = (a.agent_name || '').toLowerCase();
const desc = (a.description || '').toLowerCase();
const tags = (a.tags || []).join(' ').toLowerCase();
const matchesSearch = !search || name.includes(search) || desc.includes(search) || tags.includes(search);
const matchesStatus = !status || a.status === status;
return matchesSearch && matchesStatus;
});
AgentsUI.render(filtered);
},
renderCard(agent) { renderCard(agent) {
const name = agent.agent_name || agent.name || 'Sem nome'; const name = agent.agent_name || agent.name || 'Sem nome';
const color = AgentsUI.getAvatarColor(name); const color = AgentsUI.getAvatarColor(name);
@@ -57,6 +75,9 @@ const AgentsUI = {
const statusClass = agent.status === 'active' ? 'badge-active' : 'badge-inactive'; const statusClass = agent.status === 'active' ? 'badge-active' : 'badge-inactive';
const model = (agent.config && agent.config.model) || agent.model || 'claude-sonnet-4-6'; const model = (agent.config && agent.config.model) || agent.model || 'claude-sonnet-4-6';
const updatedAt = AgentsUI.formatDate(agent.updated_at || agent.updatedAt || agent.created_at || agent.createdAt); const updatedAt = AgentsUI.formatDate(agent.updated_at || agent.updatedAt || agent.created_at || agent.createdAt);
const tags = Array.isArray(agent.tags) && agent.tags.length > 0
? `<div class="agent-tags">${agent.tags.map((t) => `<span class="tag-chip tag-chip--sm">${t}</span>`).join('')}</div>`
: '';
return ` return `
<div class="agent-card" data-agent-id="${agent.id}"> <div class="agent-card" data-agent-id="${agent.id}">
@@ -72,6 +93,7 @@ const AgentsUI = {
</div> </div>
${agent.description ? `<p class="agent-description">${agent.description}</p>` : ''} ${agent.description ? `<p class="agent-description">${agent.description}</p>` : ''}
${tags}
<div class="agent-meta"> <div class="agent-meta">
<span class="agent-meta-item"> <span class="agent-meta-item">
@@ -124,6 +146,15 @@ const AgentsUI = {
const tagsChips = document.getElementById('agent-tags-chips'); const tagsChips = document.getElementById('agent-tags-chips');
if (tagsChips) tagsChips.innerHTML = ''; if (tagsChips) tagsChips.innerHTML = '';
const allowedTools = document.getElementById('agent-allowed-tools');
if (allowedTools) allowedTools.value = '';
const maxTurns = document.getElementById('agent-max-turns');
if (maxTurns) maxTurns.value = '0';
const permissionMode = document.getElementById('agent-permission-mode');
if (permissionMode) permissionMode.value = '';
Modal.open('agent-modal-overlay'); Modal.open('agent-modal-overlay');
}, },
@@ -141,6 +172,9 @@ const AgentsUI = {
'agent-system-prompt': (agent.config && agent.config.systemPrompt) || '', 'agent-system-prompt': (agent.config && agent.config.systemPrompt) || '',
'agent-model': (agent.config && agent.config.model) || 'claude-sonnet-4-6', 'agent-model': (agent.config && agent.config.model) || 'claude-sonnet-4-6',
'agent-workdir': (agent.config && agent.config.workingDirectory) || '', 'agent-workdir': (agent.config && agent.config.workingDirectory) || '',
'agent-allowed-tools': (agent.config && agent.config.allowedTools) || '',
'agent-max-turns': (agent.config && agent.config.maxTurns) || 0,
'agent-permission-mode': (agent.config && agent.config.permissionMode) || '',
}; };
for (const [fieldId, value] of Object.entries(fields)) { for (const [fieldId, value] of Object.entries(fields)) {
@@ -191,11 +225,15 @@ const AgentsUI = {
const data = { const data = {
agent_name: nameEl.value.trim(), agent_name: nameEl.value.trim(),
description: document.getElementById('agent-description')?.value.trim() || '', description: document.getElementById('agent-description')?.value.trim() || '',
tags,
status: toggle && toggle.checked ? 'active' : 'inactive', status: toggle && toggle.checked ? 'active' : 'inactive',
config: { config: {
systemPrompt: document.getElementById('agent-system-prompt')?.value.trim() || '', systemPrompt: document.getElementById('agent-system-prompt')?.value.trim() || '',
model: document.getElementById('agent-model')?.value || 'claude-sonnet-4-6', model: document.getElementById('agent-model')?.value || 'claude-sonnet-4-6',
workingDirectory: document.getElementById('agent-workdir')?.value.trim() || '', workingDirectory: document.getElementById('agent-workdir')?.value.trim() || '',
allowedTools: document.getElementById('agent-allowed-tools')?.value.trim() || '',
maxTurns: parseInt(document.getElementById('agent-max-turns')?.value) || 0,
permissionMode: document.getElementById('agent-permission-mode')?.value || '',
}, },
}; };
@@ -233,8 +271,6 @@ const AgentsUI = {
}, },
async execute(agentId) { async execute(agentId) {
const agent = AgentsUI.agents.find((a) => a.id === agentId);
try { try {
const allAgents = AgentsUI.agents.length > 0 ? AgentsUI.agents : await API.agents.list(); const allAgents = AgentsUI.agents.length > 0 ? AgentsUI.agents : await API.agents.list();
const selectEl = document.getElementById('execute-agent-select'); const selectEl = document.getElementById('execute-agent-select');
@@ -275,6 +311,37 @@ const AgentsUI = {
} }
}, },
openImportModal() {
const textarea = document.getElementById('import-json-content');
if (textarea) textarea.value = '';
Modal.open('import-modal-overlay');
},
async importAgent() {
const textarea = document.getElementById('import-json-content');
if (!textarea || !textarea.value.trim()) {
Toast.warning('Cole o JSON do agente para importar');
return;
}
let data;
try {
data = JSON.parse(textarea.value.trim());
} catch {
Toast.error('JSON inválido');
return;
}
try {
await API.agents.import(data);
Toast.success('Agente importado com sucesso');
Modal.close('import-modal-overlay');
await AgentsUI.load();
} catch (err) {
Toast.error(`Erro ao importar agente: ${err.message}`);
}
},
getAvatarColor(name) { getAvatarColor(name) {
let hash = 0; let hash = 0;
for (let i = 0; i < name.length; i++) { for (let i = 0; i < name.length; i++) {

View File

@@ -1,24 +1,24 @@
const DashboardUI = { const DashboardUI = {
async load() { async load() {
try { try {
const [status, agents] = await Promise.all([ const [status, recentExecs] = await Promise.all([
API.system.status(), API.system.status(),
API.agents.list(), API.executions.recent(10),
]); ]);
DashboardUI.updateMetrics(status, agents); DashboardUI.updateMetrics(status);
DashboardUI.updateRecentActivity(status.executions?.list || []); DashboardUI.updateRecentActivity(recentExecs || []);
DashboardUI.updateSystemStatus(status); DashboardUI.updateSystemStatus(status);
} catch (err) { } catch (err) {
Toast.error(`Erro ao carregar dashboard: ${err.message}`); Toast.error(`Erro ao carregar dashboard: ${err.message}`);
} }
}, },
updateMetrics(status, agents) { updateMetrics(status) {
const metrics = { const metrics = {
'metric-total-agents': status.agents?.total ?? (agents?.length ?? 0), 'metric-total-agents': status.agents?.total ?? 0,
'metric-active-agents': status.agents?.active ?? 0, 'metric-active-agents': status.agents?.active ?? 0,
'metric-executions-today': status.executions?.active ?? 0, 'metric-executions-today': status.executions?.today ?? 0,
'metric-schedules': status.schedules?.total ?? 0, 'metric-schedules': status.schedules?.total ?? 0,
}; };

View File

@@ -18,16 +18,28 @@ const PipelinesUI = {
} }
}, },
render() { filter(searchText) {
const search = (searchText || '').toLowerCase();
const filtered = PipelinesUI.pipelines.filter((p) => {
const name = (p.name || '').toLowerCase();
const desc = (p.description || '').toLowerCase();
return !search || name.includes(search) || desc.includes(search);
});
PipelinesUI.render(filtered);
},
render(filteredPipelines) {
const grid = document.getElementById('pipelines-grid'); const grid = document.getElementById('pipelines-grid');
if (!grid) return; if (!grid) return;
const pipelines = filteredPipelines || PipelinesUI.pipelines;
const existingCards = grid.querySelectorAll('.pipeline-card'); const existingCards = grid.querySelectorAll('.pipeline-card');
existingCards.forEach((c) => c.remove()); existingCards.forEach((c) => c.remove());
const emptyState = grid.querySelector('.empty-state'); const emptyState = grid.querySelector('.empty-state');
if (PipelinesUI.pipelines.length === 0) { if (pipelines.length === 0) {
if (!emptyState) { if (!emptyState) {
grid.insertAdjacentHTML('beforeend', PipelinesUI.renderEmpty()); grid.insertAdjacentHTML('beforeend', PipelinesUI.renderEmpty());
} }
@@ -38,7 +50,7 @@ const PipelinesUI = {
if (emptyState) emptyState.remove(); if (emptyState) emptyState.remove();
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
PipelinesUI.pipelines.forEach((pipeline) => { pipelines.forEach((pipeline) => {
const wrapper = document.createElement('div'); const wrapper = document.createElement('div');
wrapper.innerHTML = PipelinesUI.renderCard(pipeline); wrapper.innerHTML = PipelinesUI.renderCard(pipeline);
fragment.appendChild(wrapper.firstElementChild); fragment.appendChild(wrapper.firstElementChild);

View File

@@ -5,16 +5,19 @@ const SchedulesUI = {
try { try {
SchedulesUI.schedules = await API.schedules.list(); SchedulesUI.schedules = await API.schedules.list();
SchedulesUI.render(); SchedulesUI.render();
SchedulesUI.loadHistory();
} catch (err) { } catch (err) {
Toast.error(`Erro ao carregar agendamentos: ${err.message}`); Toast.error(`Erro ao carregar agendamentos: ${err.message}`);
} }
}, },
render() { render(filteredSchedules) {
const tbody = document.getElementById('schedules-tbody'); const tbody = document.getElementById('schedules-tbody');
if (!tbody) return; if (!tbody) return;
if (SchedulesUI.schedules.length === 0) { const schedules = filteredSchedules || SchedulesUI.schedules;
if (schedules.length === 0) {
tbody.innerHTML = ` tbody.innerHTML = `
<tr class="table-empty-row"> <tr class="table-empty-row">
<td colspan="6"> <td colspan="6">
@@ -29,7 +32,7 @@ const SchedulesUI = {
return; return;
} }
tbody.innerHTML = SchedulesUI.schedules.map((schedule) => { tbody.innerHTML = schedules.map((schedule) => {
const cronExpr = schedule.cronExpression || schedule.cronExpr || ''; const cronExpr = schedule.cronExpression || schedule.cronExpr || '';
const statusClass = schedule.active ? 'badge-active' : 'badge-inactive'; const statusClass = schedule.active ? 'badge-active' : 'badge-inactive';
const statusLabel = schedule.active ? 'Ativo' : 'Inativo'; const statusLabel = schedule.active ? 'Ativo' : 'Inativo';
@@ -37,10 +40,11 @@ const SchedulesUI = {
const nextRun = schedule.nextRun const nextRun = schedule.nextRun
? new Date(schedule.nextRun).toLocaleString('pt-BR') ? new Date(schedule.nextRun).toLocaleString('pt-BR')
: '—'; : '—';
const scheduleId = schedule.id || schedule.taskId;
return ` return `
<tr> <tr>
<td>${schedule.agentName || schedule.agentId || '—'}</td> <td>${schedule.agentName || '—'}</td>
<td class="schedule-task-cell" title="${schedule.taskDescription || ''}">${schedule.taskDescription || '—'}</td> <td class="schedule-task-cell" title="${schedule.taskDescription || ''}">${schedule.taskDescription || '—'}</td>
<td> <td>
<span title="${cronExpr}">${humanCron}</span> <span title="${cronExpr}">${humanCron}</span>
@@ -49,15 +53,26 @@ const SchedulesUI = {
<td>${nextRun}</td> <td>${nextRun}</td>
<td><span class="badge ${statusClass}">${statusLabel}</span></td> <td><span class="badge ${statusClass}">${statusLabel}</span></td>
<td> <td>
<div class="schedule-actions-cell">
<button
class="btn btn-ghost btn-sm"
data-action="edit-schedule"
data-id="${scheduleId}"
title="Editar agendamento"
aria-label="Editar agendamento"
>
<i data-lucide="pencil"></i>
</button>
<button <button
class="btn btn-ghost btn-sm btn-danger" class="btn btn-ghost btn-sm btn-danger"
data-action="delete-schedule" data-action="delete-schedule"
data-id="${schedule.taskId}" data-id="${scheduleId}"
title="Remover agendamento" title="Remover agendamento"
aria-label="Remover agendamento" aria-label="Remover agendamento"
> >
<i data-lucide="trash-2"></i> <i data-lucide="trash-2"></i>
</button> </button>
</div>
</td> </td>
</tr> </tr>
`; `;
@@ -66,7 +81,24 @@ const SchedulesUI = {
if (window.lucide) lucide.createIcons({ nodes: [tbody] }); if (window.lucide) lucide.createIcons({ nodes: [tbody] });
}, },
async openCreateModal() { filter(searchText, statusFilter) {
const search = (searchText || '').toLowerCase();
const status = statusFilter || '';
const filtered = SchedulesUI.schedules.filter((s) => {
const agent = (s.agentName || '').toLowerCase();
const task = (s.taskDescription || '').toLowerCase();
const matchesSearch = !search || agent.includes(search) || task.includes(search);
const matchesStatus = !status ||
(status === 'active' && s.active) ||
(status === 'paused' && !s.active);
return matchesSearch && matchesStatus;
});
SchedulesUI.render(filtered);
},
async openCreateModal(editSchedule) {
try { try {
const agents = await API.agents.list(); const agents = await API.agents.list();
const select = document.getElementById('schedule-agent'); const select = document.getElementById('schedule-agent');
@@ -79,11 +111,23 @@ const SchedulesUI = {
.join(''); .join('');
} }
const titleEl = document.getElementById('schedule-modal-title');
const idEl = document.getElementById('schedule-form-id');
const taskEl = document.getElementById('schedule-task'); const taskEl = document.getElementById('schedule-task');
if (taskEl) taskEl.value = '';
const cronEl = document.getElementById('schedule-cron'); const cronEl = document.getElementById('schedule-cron');
if (editSchedule) {
if (titleEl) titleEl.textContent = 'Editar Agendamento';
if (idEl) idEl.value = editSchedule.id || editSchedule.taskId || '';
if (select) select.value = editSchedule.agentId || '';
if (taskEl) taskEl.value = editSchedule.taskDescription || '';
if (cronEl) cronEl.value = editSchedule.cronExpression || editSchedule.cronExpr || '';
} else {
if (titleEl) titleEl.textContent = 'Novo Agendamento';
if (idEl) idEl.value = '';
if (taskEl) taskEl.value = '';
if (cronEl) cronEl.value = ''; if (cronEl) cronEl.value = '';
}
Modal.open('schedule-modal-overlay'); Modal.open('schedule-modal-overlay');
} catch (err) { } catch (err) {
@@ -91,7 +135,16 @@ const SchedulesUI = {
} }
}, },
async openEditModal(scheduleId) {
const schedule = SchedulesUI.schedules.find(
(s) => (s.id || s.taskId) === scheduleId
);
if (!schedule) return;
await SchedulesUI.openCreateModal(schedule);
},
async save() { async save() {
const scheduleId = document.getElementById('schedule-form-id')?.value.trim();
const agentId = document.getElementById('schedule-agent')?.value; const agentId = document.getElementById('schedule-agent')?.value;
const taskDescription = document.getElementById('schedule-task')?.value.trim(); const taskDescription = document.getElementById('schedule-task')?.value.trim();
const cronExpression = document.getElementById('schedule-cron')?.value.trim(); const cronExpression = document.getElementById('schedule-cron')?.value.trim();
@@ -112,12 +165,17 @@ const SchedulesUI = {
} }
try { try {
if (scheduleId) {
await API.schedules.update(scheduleId, { agentId, taskDescription, cronExpression });
Toast.success('Agendamento atualizado com sucesso');
} else {
await API.schedules.create({ agentId, taskDescription, cronExpression }); await API.schedules.create({ agentId, taskDescription, cronExpression });
Toast.success('Agendamento criado com sucesso'); Toast.success('Agendamento criado com sucesso');
}
Modal.close('schedule-modal-overlay'); Modal.close('schedule-modal-overlay');
await SchedulesUI.load(); await SchedulesUI.load();
} catch (err) { } catch (err) {
Toast.error(`Erro ao criar agendamento: ${err.message}`); Toast.error(`Erro ao salvar agendamento: ${err.message}`);
} }
}, },
@@ -138,6 +196,39 @@ const SchedulesUI = {
} }
}, },
async loadHistory() {
try {
const history = await API.schedules.history();
SchedulesUI.renderHistory(history || []);
} catch {
}
},
renderHistory(history) {
const container = document.getElementById('schedules-history');
if (!container) return;
if (history.length === 0) {
container.innerHTML = '<p class="empty-state-desc">Nenhum disparo registrado</p>';
return;
}
container.innerHTML = `
<ul class="activity-list">
${history.slice(0, 20).map((h) => `
<li class="activity-item">
<div class="activity-item-info">
<span class="activity-item-agent">${h.cronExpr}</span>
</div>
<div class="activity-item-meta">
<span class="activity-item-time">${new Date(h.firedAt).toLocaleString('pt-BR')}</span>
</div>
</li>
`).join('')}
</ul>
`;
},
cronToHuman(expression) { cronToHuman(expression) {
if (!expression) return '—'; if (!expression) return '—';

View File

@@ -0,0 +1,72 @@
const SettingsUI = {
async load() {
try {
const [settings, info] = await Promise.all([
API.settings.get(),
API.system.info(),
]);
SettingsUI.populateForm(settings);
SettingsUI.populateSystemInfo(info);
} catch (err) {
Toast.error(`Erro ao carregar configurações: ${err.message}`);
}
},
populateForm(settings) {
const fields = {
'settings-default-model': settings.defaultModel || 'claude-sonnet-4-6',
'settings-default-workdir': settings.defaultWorkdir || '',
'settings-max-concurrent': settings.maxConcurrent || 5,
};
for (const [id, value] of Object.entries(fields)) {
const el = document.getElementById(id);
if (el) el.value = value;
}
},
populateSystemInfo(info) {
const fields = {
'info-server-version': info.serverVersion || '1.0.0',
'info-node-version': info.nodeVersion || 'N/A',
'info-claude-version': info.claudeVersion || 'N/A',
'info-platform': info.platform || 'N/A',
'info-uptime': SettingsUI.formatUptime(info.uptime),
};
for (const [id, value] of Object.entries(fields)) {
const el = document.getElementById(id);
if (el) el.textContent = value;
}
},
formatUptime(seconds) {
if (!seconds && seconds !== 0) return 'N/A';
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
const parts = [];
if (h > 0) parts.push(`${h}h`);
if (m > 0) parts.push(`${m}m`);
parts.push(`${s}s`);
return parts.join(' ');
},
async save() {
const data = {
defaultModel: document.getElementById('settings-default-model')?.value || 'claude-sonnet-4-6',
defaultWorkdir: document.getElementById('settings-default-workdir')?.value.trim() || '',
maxConcurrent: parseInt(document.getElementById('settings-max-concurrent')?.value) || 5,
};
try {
await API.settings.save(data);
Toast.success('Configurações salvas com sucesso');
} catch (err) {
Toast.error(`Erro ao salvar configurações: ${err.message}`);
}
},
};
window.SettingsUI = SettingsUI;

View File

@@ -1,5 +1,6 @@
const TasksUI = { const TasksUI = {
tasks: [], tasks: [],
_editingId: null,
async load() { async load() {
try { try {
@@ -10,16 +11,18 @@ const TasksUI = {
} }
}, },
render() { render(filteredTasks) {
const container = document.getElementById('tasks-grid'); const container = document.getElementById('tasks-grid');
const empty = document.getElementById('tasks-empty-state'); const empty = document.getElementById('tasks-empty-state');
if (!container) return; if (!container) return;
const tasks = filteredTasks || TasksUI.tasks;
const existingCards = container.querySelectorAll('.task-card'); const existingCards = container.querySelectorAll('.task-card');
existingCards.forEach((c) => c.remove()); existingCards.forEach((c) => c.remove());
if (TasksUI.tasks.length === 0) { if (tasks.length === 0) {
if (empty) empty.style.display = 'flex'; if (empty) empty.style.display = 'flex';
return; return;
} }
@@ -28,7 +31,7 @@ const TasksUI = {
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
TasksUI.tasks.forEach((task) => { tasks.forEach((task) => {
const wrapper = document.createElement('div'); const wrapper = document.createElement('div');
wrapper.innerHTML = TasksUI._renderCard(task); wrapper.innerHTML = TasksUI._renderCard(task);
fragment.appendChild(wrapper.firstElementChild); fragment.appendChild(wrapper.firstElementChild);
@@ -39,10 +42,25 @@ const TasksUI = {
if (window.lucide) lucide.createIcons({ nodes: [container] }); if (window.lucide) lucide.createIcons({ nodes: [container] });
}, },
filter(searchText, categoryFilter) {
const search = (searchText || '').toLowerCase();
const category = categoryFilter || '';
const filtered = TasksUI.tasks.filter((t) => {
const name = (t.name || '').toLowerCase();
const desc = (t.description || '').toLowerCase();
const matchesSearch = !search || name.includes(search) || desc.includes(search);
const matchesCategory = !category || t.category === category;
return matchesSearch && matchesCategory;
});
TasksUI.render(filtered);
},
_renderCard(task) { _renderCard(task) {
const categoryClass = TasksUI._categoryClass(task.category); const categoryClass = TasksUI._categoryClass(task.category);
const categoryLabel = task.category || 'Geral'; const categoryLabel = task.category || 'Geral';
const createdAt = TasksUI._formatDate(task.createdAt); const createdAt = TasksUI._formatDate(task.createdAt || task.created_at);
return ` return `
<div class="task-card" data-task-id="${task.id}"> <div class="task-card" data-task-id="${task.id}">
@@ -70,39 +88,52 @@ const TasksUI = {
}, },
openCreateModal() { openCreateModal() {
TasksUI._editingId = null;
TasksUI._openInlineForm({});
},
openEditModal(taskId) {
const task = TasksUI.tasks.find((t) => t.id === taskId);
if (!task) return;
TasksUI._editingId = taskId;
TasksUI._openInlineForm(task);
},
_openInlineForm(task) {
const container = document.getElementById('tasks-grid'); const container = document.getElementById('tasks-grid');
if (!container) return; if (!container) return;
const existing = document.getElementById('task-inline-form'); const existing = document.getElementById('task-inline-form');
if (existing) { if (existing) existing.remove();
existing.remove();
return; const isEdit = !!TasksUI._editingId;
} const title = isEdit ? 'Editar tarefa' : 'Nome da tarefa *';
const btnLabel = isEdit ? 'Atualizar' : 'Salvar';
const formHtml = ` const formHtml = `
<div class="task-card task-card--form" id="task-inline-form"> <div class="task-card task-card--form" id="task-inline-form">
<div class="form-group"> <div class="form-group">
<label class="form-label" for="task-inline-name">Nome da tarefa *</label> <label class="form-label" for="task-inline-name">${title}</label>
<input type="text" id="task-inline-name" class="input" placeholder="Ex: Code Review de PR" required autocomplete="off"> <input type="text" id="task-inline-name" class="input" placeholder="Ex: Code Review de PR" required autocomplete="off" value="${task.name || ''}">
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label" for="task-inline-category">Categoria</label> <label class="form-label" for="task-inline-category">Categoria</label>
<select id="task-inline-category" class="select"> <select id="task-inline-category" class="select">
<option value="">Selecionar...</option> <option value="">Selecionar...</option>
<option value="code-review">Code Review</option> <option value="code-review" ${task.category === 'code-review' ? 'selected' : ''}>Code Review</option>
<option value="security">Segurança</option> <option value="security" ${task.category === 'security' ? 'selected' : ''}>Segurança</option>
<option value="refactor">Refatoração</option> <option value="refactor" ${task.category === 'refactor' ? 'selected' : ''}>Refatoração</option>
<option value="tests">Testes</option> <option value="tests" ${task.category === 'tests' ? 'selected' : ''}>Testes</option>
<option value="docs">Documentação</option> <option value="docs" ${task.category === 'docs' ? 'selected' : ''}>Documentação</option>
<option value="performance">Performance</option> <option value="performance" ${task.category === 'performance' ? 'selected' : ''}>Performance</option>
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label" for="task-inline-description">Descrição</label> <label class="form-label" for="task-inline-description">Descrição</label>
<textarea id="task-inline-description" class="textarea" rows="2" placeholder="Descreva o objetivo desta tarefa..."></textarea> <textarea id="task-inline-description" class="textarea" rows="2" placeholder="Descreva o objetivo desta tarefa...">${task.description || ''}</textarea>
</div> </div>
<div class="form-actions"> <div class="form-actions">
<button class="btn btn--primary" id="btn-save-inline-task" type="button">Salvar</button> <button class="btn btn--primary" id="btn-save-inline-task" type="button">${btnLabel}</button>
<button class="btn btn--ghost" id="btn-cancel-inline-task" type="button">Cancelar</button> <button class="btn btn--ghost" id="btn-cancel-inline-task" type="button">Cancelar</button>
</div> </div>
</div> </div>
@@ -128,6 +159,7 @@ const TasksUI = {
document.getElementById('btn-cancel-inline-task')?.addEventListener('click', () => { document.getElementById('btn-cancel-inline-task')?.addEventListener('click', () => {
document.getElementById('task-inline-form')?.remove(); document.getElementById('task-inline-form')?.remove();
TasksUI._editingId = null;
if (TasksUI.tasks.length === 0) { if (TasksUI.tasks.length === 0) {
const emptyEl = document.getElementById('tasks-empty-state'); const emptyEl = document.getElementById('tasks-empty-state');
if (emptyEl) emptyEl.style.display = 'flex'; if (emptyEl) emptyEl.style.display = 'flex';
@@ -144,8 +176,15 @@ const TasksUI = {
} }
try { try {
if (TasksUI._editingId) {
await API.tasks.update(TasksUI._editingId, data);
Toast.success('Tarefa atualizada com sucesso');
} else {
await API.tasks.create(data); await API.tasks.create(data);
Toast.success('Tarefa criada com sucesso'); Toast.success('Tarefa criada com sucesso');
}
TasksUI._editingId = null;
document.getElementById('task-inline-form')?.remove(); document.getElementById('task-inline-form')?.remove();
await TasksUI.load(); await TasksUI.load();
} catch (err) { } catch (err) {

View File

@@ -5,11 +5,11 @@ const Terminal = {
executionFilter: null, executionFilter: null,
_processingInterval: null, _processingInterval: null,
addLine(content, type = 'default') { addLine(content, type = 'default', executionId = null) {
const time = new Date(); const time = new Date();
const formatted = time.toTimeString().slice(0, 8); const formatted = time.toTimeString().slice(0, 8);
Terminal.lines.push({ content, type, timestamp: formatted }); Terminal.lines.push({ content, type, timestamp: formatted, executionId });
if (Terminal.lines.length > Terminal.maxLines) { if (Terminal.lines.length > Terminal.maxLines) {
Terminal.lines.shift(); Terminal.lines.shift();
@@ -63,7 +63,7 @@ const Terminal = {
if (!output) return; if (!output) return;
const lines = Terminal.executionFilter const lines = Terminal.executionFilter
? Terminal.lines.filter((l) => l.executionId === Terminal.executionFilter) ? Terminal.lines.filter((l) => !l.executionId || l.executionId === Terminal.executionFilter)
: Terminal.lines; : Terminal.lines;
if (lines.length === 0 && !Terminal._processingInterval) { if (lines.length === 0 && !Terminal._processingInterval) {

View File

@@ -3,55 +3,100 @@ import { createServer } from 'http';
import { WebSocketServer } from 'ws'; import { WebSocketServer } from 'ws';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { dirname, join } from 'path'; import { dirname, join } from 'path';
import apiRouter, { setWsBroadcast } from './src/routes/api.js'; import { v4 as uuidv4 } from 'uuid';
import apiRouter, { setWsBroadcast, setWsBroadcastTo } from './src/routes/api.js';
import * as manager from './src/agents/manager.js';
import { cancelAllExecutions } from './src/agents/executor.js';
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
const AUTH_TOKEN = process.env.AUTH_TOKEN || '';
const ALLOWED_ORIGIN = process.env.ALLOWED_ORIGIN || '';
const app = express(); const app = express();
const httpServer = createServer(app); const httpServer = createServer(app);
const wss = new WebSocketServer({ server: httpServer }); const wss = new WebSocketServer({ server: httpServer });
app.use((req, res, next) => { app.use((req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', '*'); const origin = ALLOWED_ORIGIN || req.headers.origin || '*';
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Client-Id');
if (req.method === 'OPTIONS') return res.sendStatus(204); if (req.method === 'OPTIONS') return res.sendStatus(204);
next(); next();
}); });
if (AUTH_TOKEN) {
app.use('/api', (req, res, next) => {
const header = req.headers.authorization || '';
const token = header.startsWith('Bearer ') ? header.slice(7) : req.query.token;
if (token !== AUTH_TOKEN) {
return res.status(401).json({ error: 'Token de autenticação inválido' });
}
next();
});
}
app.use(express.json()); app.use(express.json());
app.use(express.static(join(__dirname, 'public'))); app.use(express.static(join(__dirname, 'public')));
app.use('/api', apiRouter); app.use('/api', apiRouter);
const connectedClients = new Set(); const connectedClients = new Map();
wss.on('connection', (ws) => { wss.on('connection', (ws, req) => {
connectedClients.add(ws); const clientId = new URL(req.url, 'http://localhost').searchParams.get('clientId') || uuidv4();
if (AUTH_TOKEN) {
const token = new URL(req.url, 'http://localhost').searchParams.get('token');
if (token !== AUTH_TOKEN) {
ws.close(4001, 'Token inválido');
return;
}
}
ws.clientId = clientId;
connectedClients.set(clientId, ws);
ws.on('close', () => { ws.on('close', () => {
connectedClients.delete(ws); connectedClients.delete(clientId);
}); });
ws.on('error', () => { ws.on('error', () => {
connectedClients.delete(ws); connectedClients.delete(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) {
const payload = JSON.stringify(message);
const client = connectedClients.get(clientId);
if (client && client.readyState === 1) {
client.send(payload);
} else {
broadcast(message);
}
}
setWsBroadcast(broadcast); setWsBroadcast(broadcast);
setWsBroadcastTo(broadcastTo);
function gracefulShutdown(signal) { function gracefulShutdown(signal) {
console.log(`\nSinal ${signal} recebido. Encerrando servidor...`); console.log(`\nSinal ${signal} recebido. Encerrando servidor...`);
cancelAllExecutions();
console.log('Execuções ativas canceladas.');
httpServer.close(() => { httpServer.close(() => {
console.log('Servidor HTTP encerrado.'); console.log('Servidor HTTP encerrado.');
process.exit(0); process.exit(0);
@@ -66,7 +111,10 @@ function gracefulShutdown(signal) {
process.on('SIGTERM', () => gracefulShutdown('SIGTERM')); process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT')); process.on('SIGINT', () => gracefulShutdown('SIGINT'));
manager.restoreSchedules(broadcast);
httpServer.listen(PORT, () => { httpServer.listen(PORT, () => {
console.log(`Painel administrativo disponível em http://localhost:${PORT}`); console.log(`Painel administrativo disponível em http://localhost:${PORT}`);
console.log(`WebSocket server ativo na mesma porta.`); console.log(`WebSocket server ativo na mesma porta.`);
if (AUTH_TOKEN) console.log('Autenticação por token ativada.');
}); });

View File

@@ -1,11 +1,36 @@
import { spawn } from 'child_process'; import { spawn } from 'child_process';
import { existsSync } from 'fs'; import { existsSync } from 'fs';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { settingsStore } from '../store/db.js';
const CLAUDE_BIN = '/home/fred/.local/bin/claude'; const CLAUDE_BIN = resolveBin();
const DEFAULT_MODEL = 'claude-sonnet-4-6';
const activeExecutions = new Map(); const activeExecutions = new Map();
function resolveBin() {
if (process.env.CLAUDE_BIN) return process.env.CLAUDE_BIN;
const home = process.env.HOME || '';
const candidates = [
`${home}/.local/bin/claude`,
'/usr/local/bin/claude',
'/usr/bin/claude',
];
for (const p of candidates) {
if (existsSync(p)) return p;
}
return 'claude';
}
function sanitizeText(str) {
if (typeof str !== 'string') return '';
return str
.replace(/\x00/g, '')
.replace(/[\x01-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
.slice(0, 50000);
}
function cleanEnv() { function cleanEnv() {
const env = { ...process.env }; const env = { ...process.env };
delete env.CLAUDECODE; delete env.CLAUDECODE;
@@ -14,20 +39,33 @@ function cleanEnv() {
} }
function buildArgs(agentConfig, prompt) { function buildArgs(agentConfig, prompt) {
const model = agentConfig.model || DEFAULT_MODEL; const model = agentConfig.model || 'claude-sonnet-4-6';
const args = ['-p', prompt, '--output-format', 'stream-json', '--verbose', '--model', model]; const args = ['-p', prompt, '--output-format', 'stream-json', '--verbose', '--model', model];
if (agentConfig.systemPrompt) { if (agentConfig.systemPrompt) {
args.push('--system-prompt', agentConfig.systemPrompt); args.push('--system-prompt', agentConfig.systemPrompt);
} }
if (agentConfig.maxTurns && agentConfig.maxTurns > 0) {
args.push('--max-turns', String(agentConfig.maxTurns));
}
if (agentConfig.allowedTools && agentConfig.allowedTools.length > 0) {
const tools = Array.isArray(agentConfig.allowedTools)
? agentConfig.allowedTools.join(',')
: agentConfig.allowedTools;
args.push('--allowedTools', tools);
}
args.push('--permission-mode', agentConfig.permissionMode || 'bypassPermissions');
return args; return args;
} }
function buildPrompt(task, instructions) { function buildPrompt(task, instructions) {
const parts = []; const parts = [];
if (task) parts.push(task); if (task) parts.push(sanitizeText(task));
if (instructions) parts.push(`\nInstruções adicionais:\n${instructions}`); if (instructions) parts.push(`\nInstruções adicionais:\n${sanitizeText(instructions)}`);
return parts.join('\n'); return parts.join('\n');
} }
@@ -74,7 +112,19 @@ function extractText(event) {
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) {
const err = new Error(`Limite de ${maxConcurrent} execuções simultâneas atingido`);
if (callbacks.onError) callbacks.onError(err, uuidv4());
return null;
}
const executionId = uuidv4(); const executionId = uuidv4();
const { onData, onError, onComplete } = callbacks; const { onData, onError, onComplete } = callbacks;
@@ -96,7 +146,7 @@ export function execute(agentConfig, task, callbacks = {}) {
} }
console.log(`[executor] Iniciando: ${executionId}`); console.log(`[executor] Iniciando: ${executionId}`);
console.log(`[executor] Modelo: ${agentConfig.model || DEFAULT_MODEL}`); console.log(`[executor] Modelo: ${agentConfig.model || 'claude-sonnet-4-6'}`);
console.log(`[executor] cwd: ${spawnOptions.cwd || process.cwd()}`); console.log(`[executor] cwd: ${spawnOptions.cwd || process.cwd()}`);
const child = spawn(CLAUDE_BIN, args, spawnOptions); const child = spawn(CLAUDE_BIN, args, spawnOptions);
@@ -180,6 +230,13 @@ export function cancel(executionId) {
return true; return true;
} }
export function cancelAllExecutions() {
for (const [id, exec] of activeExecutions) {
exec.process.kill('SIGTERM');
}
activeExecutions.clear();
}
export function getActiveExecutions() { export function getActiveExecutions() {
return Array.from(activeExecutions.entries()).map(([id, exec]) => ({ return Array.from(activeExecutions.entries()).map(([id, exec]) => ({
executionId: id, executionId: id,
@@ -187,3 +244,7 @@ export function getActiveExecutions() {
agentConfig: exec.agentConfig, agentConfig: exec.agentConfig,
})); }));
} }
export function getBinPath() {
return CLAUDE_BIN;
}

View File

@@ -1,5 +1,5 @@
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { agentsStore } from '../store/db.js'; import { agentsStore, schedulesStore } 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';
@@ -7,10 +7,32 @@ const DEFAULT_CONFIG = {
model: 'claude-sonnet-4-6', model: 'claude-sonnet-4-6',
systemPrompt: '', systemPrompt: '',
workingDirectory: '', workingDirectory: '',
maxTokens: 16000, maxTurns: 0,
temperature: 1, permissionMode: 'bypassPermissions',
allowedTools: '',
}; };
let dailyExecutionCount = 0;
let dailyCountDate = new Date().toDateString();
function incrementDailyCount() {
const today = new Date().toDateString();
if (today !== dailyCountDate) {
dailyExecutionCount = 0;
dailyCountDate = today;
}
dailyExecutionCount++;
}
export function getDailyExecutionCount() {
const today = new Date().toDateString();
if (today !== dailyCountDate) {
dailyExecutionCount = 0;
dailyCountDate = today;
}
return dailyExecutionCount;
}
function validateAgent(data) { function validateAgent(data) {
const errors = []; const errors = [];
if (!data.agent_name || typeof data.agent_name !== 'string') { if (!data.agent_name || typeof data.agent_name !== 'string') {
@@ -22,6 +44,13 @@ function validateAgent(data) {
return errors; return errors;
} }
function sanitizeTags(tags) {
if (!Array.isArray(tags)) return [];
return tags
.filter((t) => typeof t === 'string' && t.length > 0 && t.length <= 50)
.slice(0, 20);
}
export function getAllAgents() { export function getAllAgents() {
return agentsStore.getAll(); return agentsStore.getAll();
} }
@@ -39,6 +68,7 @@ export function createAgent(data) {
const agentData = { const agentData = {
agent_name: data.agent_name, agent_name: data.agent_name,
description: data.description || '', description: data.description || '',
tags: sanitizeTags(data.tags),
tasks: data.tasks || [], tasks: data.tasks || [],
config: { ...DEFAULT_CONFIG, ...(data.config || {}) }, config: { ...DEFAULT_CONFIG, ...(data.config || {}) },
status: data.status || 'active', status: data.status || 'active',
@@ -56,6 +86,7 @@ export function updateAgent(id, data) {
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;
if (data.tags !== undefined) updateData.tags = sanitizeTags(data.tags);
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;
@@ -78,6 +109,7 @@ export function executeTask(agentId, task, instructions, wsCallback) {
const executionRecord = { const executionRecord = {
executionId: null, executionId: null,
agentId, agentId,
agentName: agent.agent_name,
task: typeof task === 'string' ? task : task.description, task: typeof task === 'string' ? task : task.description,
startedAt: new Date().toISOString(), startedAt: new Date().toISOString(),
status: 'running', status: 'running',
@@ -122,7 +154,12 @@ export function executeTask(agentId, task, instructions, wsCallback) {
} }
); );
if (!executionId) {
throw new Error('Limite de execuções simultâneas atingido');
}
executionRecord.executionId = executionId; executionRecord.executionId = executionId;
incrementDailyCount();
const updatedAgent = agentsStore.getById(agentId); const updatedAgent = agentsStore.getById(agentId);
const executions = [...(updatedAgent.executions || []), executionRecord]; const executions = [...(updatedAgent.executions || []), executionRecord];
@@ -151,11 +188,49 @@ export function scheduleTask(agentId, taskDescription, cronExpression, wsCallbac
const scheduleId = uuidv4(); const scheduleId = uuidv4();
const items = schedulesStore.getAll();
items.push({
id: scheduleId,
agentId,
agentName: agent.agent_name,
taskDescription,
cronExpression,
active: true,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
});
schedulesStore.save(items);
scheduler.schedule(scheduleId, cronExpression, () => { scheduler.schedule(scheduleId, cronExpression, () => {
executeTask(agentId, taskDescription, null, wsCallback); executeTask(agentId, taskDescription, null, wsCallback);
}, false);
return { scheduleId, agentId, agentName: agent.agent_name, taskDescription, cronExpression };
}
export function updateScheduleTask(scheduleId, data, wsCallback) {
const stored = schedulesStore.getById(scheduleId);
if (!stored) return null;
const agentId = data.agentId || stored.agentId;
const agent = agentsStore.getById(agentId);
if (!agent) throw new Error(`Agente ${agentId} não encontrado`);
const taskDescription = data.taskDescription || stored.taskDescription;
const cronExpression = data.cronExpression || stored.cronExpression;
scheduler.updateSchedule(scheduleId, cronExpression, () => {
executeTask(agentId, taskDescription, null, wsCallback);
}); });
return { scheduleId, agentId, taskDescription, cronExpression }; schedulesStore.update(scheduleId, {
agentId,
agentName: agent.agent_name,
taskDescription,
cronExpression,
});
return schedulesStore.getById(scheduleId);
} }
export function cancelExecution(executionId) { export function cancelExecution(executionId) {
@@ -166,20 +241,59 @@ 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 {
id: agent.id,
agent_name: agent.agent_name, agent_name: agent.agent_name,
description: agent.description, description: agent.description,
tags: agent.tags || [],
tasks: agent.tasks, tasks: agent.tasks,
config: agent.config, config: agent.config,
status: agent.status, status: agent.status,
assigned_host: agent.assigned_host, assigned_host: agent.assigned_host,
created_at: agent.created_at,
updated_at: agent.updated_at,
executions: agent.executions || [],
}; };
} }
export function importAgent(data) {
if (!data.agent_name) {
throw new Error('agent_name é obrigatório para importação');
}
const agentData = {
agent_name: data.agent_name,
description: data.description || '',
tags: sanitizeTags(data.tags),
tasks: data.tasks || [],
config: { ...DEFAULT_CONFIG, ...(data.config || {}) },
status: data.status || 'active',
assigned_host: data.assigned_host || 'localhost',
executions: [],
};
return agentsStore.create(agentData);
}
export function restoreSchedules(wsCallback) {
scheduler.restoreSchedules((agentId, taskDescription) => {
try {
executeTask(agentId, taskDescription, null, wsCallback);
} catch (err) {
console.log(`[manager] Erro ao executar tarefa agendada: ${err.message}`);
}
});
}

View File

@@ -32,27 +32,56 @@ function buildSteps(steps) {
.sort((a, b) => a.order - b.order); .sort((a, b) => a.order - b.order);
} }
function enrichStepsWithAgentNames(steps) {
const agents = agentsStore.getAll();
const agentMap = new Map(agents.map((a) => [a.id, a.agent_name]));
return steps.map((s) => ({
...s,
agentName: agentMap.get(s.agentId) || s.agentId,
}));
}
function applyTemplate(template, input) { function applyTemplate(template, input) {
if (!template) return input; if (!template) return input;
return template.replace(/\{\{input\}\}/g, input); return template.replace(/\{\{input\}\}/g, input);
} }
function executeStepAsPromise(agentConfig, prompt, pipelineState) { function executeStepAsPromise(agentConfig, prompt, pipelineState, wsCallback, pipelineId, stepIndex) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const executionId = executor.execute( const executionId = executor.execute(
agentConfig, agentConfig,
{ description: prompt }, { description: prompt },
{ {
onData: () => {}, onData: (parsed, execId) => {
if (wsCallback) {
wsCallback({
type: 'pipeline_step_output',
pipelineId,
stepIndex,
executionId: execId,
data: parsed,
});
}
},
onError: (err) => { onError: (err) => {
reject(err); reject(err);
}, },
onComplete: (result) => { onComplete: (result) => {
if (result.exitCode !== 0 && !result.result) {
reject(new Error(result.stderr || `Processo encerrado com código ${result.exitCode}`));
return;
}
resolve(result.result || ''); resolve(result.result || '');
}, },
} }
); );
if (!executionId) {
reject(new Error('Limite de execuções simultâneas atingido'));
return;
}
pipelineState.currentExecutionId = executionId; pipelineState.currentExecutionId = executionId;
}); });
} }
@@ -97,7 +126,7 @@ export async function executePipeline(pipelineId, initialInput, wsCallback) {
}); });
} }
const result = await executeStepAsPromise(agent.config, prompt, pipelineState); const result = await executeStepAsPromise(agent.config, prompt, pipelineState, wsCallback, pipelineId, i);
if (pipelineState.canceled) break; if (pipelineState.canceled) break;
@@ -200,5 +229,9 @@ export function getPipeline(id) {
} }
export function getAllPipelines() { export function getAllPipelines() {
return pipelinesStore.getAll(); const pipelines = pipelinesStore.getAll();
return pipelines.map((p) => ({
...p,
steps: enrichStepsWithAgentNames(p.steps || []),
}));
} }

View File

@@ -1,5 +1,6 @@
import cron from 'node-cron'; import cron from 'node-cron';
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
import { schedulesStore } from '../store/db.js';
const HISTORY_LIMIT = 50; const HISTORY_LIMIT = 50;
const schedules = new Map(); const schedules = new Map();
@@ -13,9 +14,54 @@ function addToHistory(entry) {
} }
} }
export function schedule(taskId, cronExpr, callback) { function matchesCronPart(part, value) {
if (part === '*') return true;
if (part.startsWith('*/')) return value % parseInt(part.slice(2)) === 0;
if (part.includes(',')) return part.split(',').map(Number).includes(value);
if (part.includes('-')) {
const [start, end] = part.split('-').map(Number);
return value >= start && value <= end;
}
return parseInt(part) === value;
}
function nextCronDate(cronExpr) {
const parts = cronExpr.split(' ');
if (parts.length !== 5) return null;
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
const candidate = new Date();
candidate.setSeconds(0);
candidate.setMilliseconds(0);
candidate.setMinutes(candidate.getMinutes() + 1);
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 (
matchesCronPart(minute, m) &&
matchesCronPart(hour, h) &&
matchesCronPart(dayOfMonth, dom) &&
matchesCronPart(month, mon) &&
matchesCronPart(dayOfWeek, dow)
) {
return candidate.toISOString();
}
candidate.setMinutes(candidate.getMinutes() + 1);
}
return null;
}
export function schedule(taskId, cronExpr, callback, persist = true) {
if (schedules.has(taskId)) { if (schedules.has(taskId)) {
unschedule(taskId); unschedule(taskId, false);
} }
if (!cron.validate(cronExpr)) { if (!cron.validate(cronExpr)) {
@@ -44,12 +90,32 @@ export function schedule(taskId, cronExpr, callback) {
return { taskId, cronExpr }; return { taskId, cronExpr };
} }
export function unschedule(taskId) { 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);
}
return true;
}
export function updateSchedule(taskId, cronExpr, callback) {
const entry = schedules.get(taskId);
if (!entry) return false;
entry.task.stop();
schedules.delete(taskId);
if (!cron.validate(cronExpr)) {
throw new Error(`Expressão cron inválida: ${cronExpr}`);
}
schedule(taskId, cronExpr, callback, false);
return true; return true;
} }
@@ -68,13 +134,49 @@ export function setActive(taskId, active) {
} }
export function getSchedules() { export function getSchedules() {
return Array.from(schedules.values()).map(({ task: _, ...rest }) => rest); const stored = schedulesStore.getAll();
const result = [];
for (const s of stored) {
const inMemory = schedules.get(s.id);
result.push({
...s,
cronExpr: s.cronExpression || s.cronExpr,
active: inMemory ? inMemory.active : false,
nextRun: nextCronDate(s.cronExpression || s.cronExpr || ''),
});
}
return result;
} }
export function getHistory() { export function getHistory() {
return [...history]; return [...history];
} }
export function restoreSchedules(executeFn) {
const stored = schedulesStore.getAll();
let restored = 0;
for (const s of stored) {
if (!s.active) continue;
const cronExpr = s.cronExpression || s.cronExpr;
try {
schedule(s.id, cronExpr, () => {
executeFn(s.agentId, s.taskDescription);
}, false);
restored++;
} catch (err) {
console.log(`[scheduler] Falha ao restaurar agendamento ${s.id}: ${err.message}`);
}
}
if (restored > 0) {
console.log(`[scheduler] ${restored} agendamento(s) restaurado(s)`);
}
}
export function on(event, listener) { export function on(event, listener) {
emitter.on(event, listener); emitter.on(event, listener);
} }

View File

@@ -1,21 +1,58 @@
import { Router } from 'express'; import { Router } from 'express';
import { execSync } from 'child_process';
import os from 'os';
import * as manager from '../agents/manager.js'; import * as manager from '../agents/manager.js';
import { tasksStore } from '../store/db.js'; import { tasksStore, settingsStore } 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';
const router = Router(); const router = Router();
let wsbroadcast = null; let wsbroadcast = null;
let wsBroadcastTo = null;
export function setWsBroadcast(fn) { export function setWsBroadcast(fn) {
wsbroadcast = fn; wsbroadcast = fn;
} }
function wsCallback(message) { export function setWsBroadcastTo(fn) {
if (wsbroadcast) wsbroadcast(message); wsBroadcastTo = fn;
} }
function wsCallback(message, clientId) {
if (clientId && wsBroadcastTo) {
wsBroadcastTo(clientId, message);
} else if (wsbroadcast) {
wsbroadcast(message);
}
}
router.get('/settings', (req, res) => {
try {
res.json(settingsStore.get());
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.put('/settings', (req, res) => {
try {
const allowed = ['defaultModel', 'defaultWorkdir', 'maxConcurrent'];
const data = {};
for (const key of allowed) {
if (req.body[key] !== undefined) data[key] = req.body[key];
}
if (data.maxConcurrent !== undefined) {
data.maxConcurrent = Math.max(1, Math.min(20, parseInt(data.maxConcurrent) || 5));
}
const saved = settingsStore.save(data);
res.json(saved);
} catch (err) {
res.status(400).json({ error: err.message });
}
});
router.get('/agents', (req, res) => { router.get('/agents', (req, res) => {
try { try {
res.json(manager.getAllAgents()); res.json(manager.getAllAgents());
@@ -43,6 +80,15 @@ router.post('/agents', (req, res) => {
} }
}); });
router.post('/agents/import', (req, res) => {
try {
const agent = manager.importAgent(req.body);
res.status(201).json(agent);
} catch (err) {
res.status(400).json({ error: err.message });
}
});
router.put('/agents/:id', (req, res) => { 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);
@@ -68,7 +114,11 @@ router.post('/agents/:id/execute', (req, res) => {
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 executionId = manager.executeTask(req.params.id, task, instructions, wsCallback); const clientId = req.headers['x-client-id'] || null;
const executionId = manager.executeTask(
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;
@@ -140,7 +190,8 @@ router.post('/schedules', (req, res) => {
if (!agentId || !taskDescription || !cronExpression) { if (!agentId || !taskDescription || !cronExpression) {
return res.status(400).json({ error: 'agentId, taskDescription e cronExpression são obrigatórios' }); return res.status(400).json({ error: 'agentId, taskDescription e cronExpression são obrigatórios' });
} }
const result = manager.scheduleTask(agentId, taskDescription, cronExpression, wsCallback); const clientId = req.headers['x-client-id'] || null;
const result = manager.scheduleTask(agentId, taskDescription, cronExpression, (msg) => wsCallback(msg, clientId));
res.status(201).json(result); res.status(201).json(result);
} catch (err) { } catch (err) {
const status = err.message.includes('não encontrado') ? 404 : 400; const status = err.message.includes('não encontrado') ? 404 : 400;
@@ -148,6 +199,14 @@ router.post('/schedules', (req, res) => {
} }
}); });
router.get('/schedules/history', (req, res) => {
try {
res.json(scheduler.getHistory());
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.get('/schedules', (req, res) => { router.get('/schedules', (req, res) => {
try { try {
res.json(scheduler.getSchedules()); res.json(scheduler.getSchedules());
@@ -156,6 +215,18 @@ router.get('/schedules', (req, res) => {
} }
}); });
router.put('/schedules/:id', (req, res) => {
try {
const clientId = req.headers['x-client-id'] || null;
const updated = manager.updateScheduleTask(req.params.id, req.body, (msg) => wsCallback(msg, clientId));
if (!updated) return res.status(404).json({ error: 'Agendamento não encontrado' });
res.json(updated);
} catch (err) {
const status = err.message.includes('não encontrado') ? 404 : 400;
res.status(status).json({ error: err.message });
}
});
router.delete('/schedules/:taskId', (req, res) => { router.delete('/schedules/:taskId', (req, res) => {
try { try {
const removed = scheduler.unschedule(req.params.taskId); const removed = scheduler.unschedule(req.params.taskId);
@@ -218,7 +289,8 @@ router.post('/pipelines/:id/execute', (req, res) => {
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' });
pipeline.executePipeline(req.params.id, input, wsCallback).catch(() => {}); const clientId = req.headers['x-client-id'] || null;
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' });
} catch (err) { } catch (err) {
const status = err.message.includes('não encontrado') ? 404 : 400; const status = err.message.includes('não encontrado') ? 404 : 400;
@@ -252,6 +324,7 @@ router.get('/system/status', (req, res) => {
}, },
executions: { executions: {
active: activeExecutions.length, active: activeExecutions.length,
today: manager.getDailyExecutionCount(),
list: activeExecutions, list: activeExecutions,
}, },
schedules: { schedules: {
@@ -269,6 +342,25 @@ router.get('/system/status', (req, res) => {
} }
}); });
router.get('/system/info', (req, res) => {
try {
let claudeVersion = 'N/A';
try {
claudeVersion = execSync(`${getBinPath()} --version`, { timeout: 5000 }).toString().trim();
} catch {}
res.json({
serverVersion: '1.0.0',
nodeVersion: process.version,
claudeVersion,
platform: `${os.platform()} ${os.arch()}`,
uptime: Math.floor(process.uptime()),
});
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.get('/executions/active', (req, res) => { router.get('/executions/active', (req, res) => {
try { try {
res.json(manager.getActiveExecutions()); res.json(manager.getActiveExecutions());
@@ -277,4 +369,13 @@ router.get('/executions/active', (req, res) => {
} }
}); });
router.get('/executions/recent', (req, res) => {
try {
const limit = parseInt(req.query.limit) || 20;
res.json(manager.getRecentExecutions(limit));
} catch (err) {
res.status(500).json({ error: err.message });
}
});
export default router; export default router;

View File

@@ -1,4 +1,4 @@
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'; import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync } 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';
@@ -8,6 +8,17 @@ const DATA_DIR = `${__dirname}/../../data`;
const AGENTS_FILE = `${DATA_DIR}/agents.json`; const AGENTS_FILE = `${DATA_DIR}/agents.json`;
const TASKS_FILE = `${DATA_DIR}/tasks.json`; const TASKS_FILE = `${DATA_DIR}/tasks.json`;
const PIPELINES_FILE = `${DATA_DIR}/pipelines.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 = {
defaultModel: 'claude-sonnet-4-6',
defaultWorkdir: '',
maxConcurrent: 5,
};
const writeLocks = new Map();
const fileCache = new Map();
function ensureDataDir() { function ensureDataDir() {
if (!existsSync(DATA_DIR)) { if (!existsSync(DATA_DIR)) {
@@ -15,22 +26,47 @@ function ensureDataDir() {
} }
} }
function loadFile(filePath) { function getCacheMtime(filePath) {
const cached = fileCache.get(filePath);
if (!cached) return null;
return cached.mtime;
}
function loadFile(filePath, defaultValue = []) {
ensureDataDir(); ensureDataDir();
if (!existsSync(filePath)) { if (!existsSync(filePath)) {
writeFileSync(filePath, JSON.stringify([]), 'utf8'); writeFileSync(filePath, JSON.stringify(defaultValue, null, 2), 'utf8');
return []; fileCache.set(filePath, { data: defaultValue, mtime: Date.now() });
return JSON.parse(JSON.stringify(defaultValue));
} }
try { try {
return JSON.parse(readFileSync(filePath, 'utf8')); const stat = statSync(filePath);
const mtime = stat.mtimeMs;
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 []; return JSON.parse(JSON.stringify(defaultValue));
} }
} }
function saveFile(filePath, data) { function saveFile(filePath, data) {
ensureDataDir(); ensureDataDir();
const prev = writeLocks.get(filePath) || Promise.resolve();
const next = prev.then(() => {
writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8'); writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8');
const stat = statSync(filePath);
fileCache.set(filePath, { data: JSON.parse(JSON.stringify(data)), mtime: stat.mtimeMs });
}).catch(() => {});
writeLocks.set(filePath, next);
return next;
} }
function createStore(filePath) { function createStore(filePath) {
@@ -84,6 +120,21 @@ function createStore(filePath) {
}; };
} }
function createSettingsStore(filePath) {
return {
get: () => loadFile(filePath, DEFAULT_SETTINGS),
save: (data) => {
const current = loadFile(filePath, DEFAULT_SETTINGS);
const merged = { ...current, ...data };
saveFile(filePath, merged);
return merged;
},
};
}
export const agentsStore = createStore(AGENTS_FILE); export const agentsStore = createStore(AGENTS_FILE);
export const tasksStore = createStore(TASKS_FILE); export const tasksStore = createStore(TASKS_FILE);
export const pipelinesStore = createStore(PIPELINES_FILE); export const pipelinesStore = createStore(PIPELINES_FILE);
export const schedulesStore = createStore(SCHEDULES_FILE);
export const settingsStore = createSettingsStore(SETTINGS_FILE);