Compare commits

...

43 Commits

Author SHA1 Message Date
Frederico Castro
1606efa09f Reescrever README com layout profissional e documentação completa 2026-02-28 04:42:00 -03:00
Frederico Castro
633b19f80d Integrar repositórios Git na execução de agentes e pipelines
- Módulo git-integration: clone/pull, commit/push automático, listagem de repos
- Seletor de repositório nos modais de execução (agente e pipeline)
- Seletor de branch carregado dinamicamente ao escolher repo
- Campo de diretório escondido quando repositório selecionado
- Auto-commit e push ao final da execução com mensagem descritiva
- Instrução injetada para agentes não fazerem operações git
- Rotas API: GET /repos, GET /repos/:name/branches
- Pipeline: commit automático ao final de todos os steps
2026-02-28 04:24:47 -03:00
Frederico Castro
2fae816162 Corrigir truncamento no terminal usando CSS grid 2026-02-28 04:05:07 -03:00
Frederico Castro
2201ac8699 Usar force-recreate ao reiniciar Caddy na publicação 2026-02-28 03:30:21 -03:00
Frederico Castro
a6bbe33e4b Adicionar grupo docker ao user node no Dockerfile 2026-02-28 03:18:52 -03:00
Frederico Castro
4c197eef91 Adicionar publicação automática de projetos
- Botão publicar (rocket) nas pastas raiz do explorador
- Cria repositório no Gitea, faz git init + push
- Atualiza Caddyfile com subdomínio e file_server
- Adiciona volume ao docker-compose e reinicia Caddy
- Botões lado a lado (download, publicar, excluir) no file explorer
- Dockerfile: adiciona git e docker-cli
2026-02-28 03:17:56 -03:00
Frederico Castro
e9f65c2845 Corrigir ícones e adicionar exclusão no explorador de arquivos
- Trocar ícone archive (lixeira) por download em todos os botões
- Adicionar botão de excluir com ícone trash-2 em cada entrada
- Rota DELETE /api/files com proteção contra exclusão da raiz
- Confirmação via modal antes de excluir
2026-02-28 03:00:45 -03:00
Frederico Castro
2fccaaac40 Adicionar botão de download da pasta raiz no explorador de arquivos 2026-02-28 02:42:57 -03:00
Frederico Castro
3178366e0e Corrigir truncamento de linhas longas no terminal
- Adicionar overflow-wrap: anywhere e min-width: 0 no .content
- Adicionar min-width: 0 no .terminal-line para flex shrink correto
- Definir overflow-x: hidden no .terminal-body
2026-02-28 02:27:27 -03:00
Frederico Castro
fa47538a8f Adicionar campo diretório de trabalho no modal de execução
- Campo execute-workdir no modal com valor pré-preenchido do agente
- Frontend envia workingDirectory na API de execução
- Backend aceita e aplica override de workingDirectory via metadata
- Pré-preenche com config do agente selecionado ao abrir modal
- Adiciona stopAll ao scheduler e limpa README
2026-02-28 02:20:09 -03:00
Frederico Castro
7a4ab2279d Corrigir classes CSS dos modais para convenção BEM (modal--lg) 2026-02-28 02:13:27 -03:00
Frederico Castro
7cbfcb2d0d Desabilitar cache de arquivos estáticos para evitar JS desatualizado 2026-02-28 02:10:41 -03:00
Frederico Castro
46a6ebc9dd Pré-preencher diretório de trabalho dos agentes com /home/projetos/ 2026-02-28 02:06:40 -03:00
Frederico Castro
f6bf7ce0ed Adicionar delegação automática entre agentes coordenadores 2026-02-28 01:59:38 -03:00
Frederico Castro
96733b55cd Adicionar file explorer para projetos criados pelos agentes 2026-02-28 01:38:26 -03:00
Frederico Castro
3ed285c9d1 Atualizar README com deploy automático, catálogo de tarefas e novos endpoints 2026-02-28 01:27:33 -03:00
Frederico Castro
6a21a4d711 Aumentar truncate dos cards de tarefas e adicionar margem no footer 2026-02-28 01:22:45 -03:00
Frederico Castro
bbfb9864bd Truncar textos dos cards de tarefas para layout padronizado
- Truncar descrição em 120 caracteres no JS com tooltip do texto completo
- Truncar nome com ellipsis via CSS
- Limitar descrição a 2 linhas com max-height
2026-02-28 00:59:25 -03:00
Frederico Castro
c29aa695d4 Merge branch 'main' of https://git.nitro-cloud.duckdns.org/fred/agents-orchestrator 2026-02-28 00:51:18 -03:00
Frederico Castro
4db351cb45 Truncar descrição dos cards de tarefas em 2 linhas 2026-02-28 00:47:22 -03:00
Frederico Castro
af1c59b75c Desabilitar cache HTTP para arquivos HTML 2026-02-28 00:40:48 -03:00
Frederico Castro
738ab12631 Desabilitar cache HTTP para arquivos HTML 2026-02-28 00:40:39 -03:00
Frederico Castro
46f999c676 Adicionar landing page e redirecionar rota raiz
- Landing page profissional com hero, features, pricing (Starter/Pro/Enterprise), FAQ
- Animações no scroll, parallax, contadores animados, glassmorphism
- Dashboard movido para /app.html, landing page agora é a página inicial
- CTAs direcionam para /app.html
2026-02-28 00:32:14 -03:00
Frederico Castro
39f0902a0f Rodar container como usuário node e corrigir resume do executor
- Dockerfile: usar USER node (UID 1000) para bypassPermissions funcionar
- Volumes mapeados para /home/node/ em vez de /root/
- Corrigir resume: voltar a usar -p para mensagens curtas de chat
- Manter stdin piping apenas em execute e summarize (prompts grandes)
2026-02-28 00:32:14 -03:00
Frederico Castro
fd3c2dc69a Adicionar botão Interromper no terminal e corrigir botão Retomar
- Botão Interromper na toolbar do terminal para matar execuções ativas
- Endpoint POST /executions/cancel-all para cancelar agentes e pipelines
- Botão aparece/esconde automaticamente conforme execuções ativas
- Corrigir condição do botão Retomar para pipelines antigas sem failedAtStep
2026-02-28 00:32:14 -03:00
Frederico Castro
1411c750e4 Adicionar landing page e redirecionar rota raiz
- Landing page profissional com hero, features, pricing (Starter/Pro/Enterprise), FAQ
- Animações no scroll, parallax, contadores animados, glassmorphism
- Dashboard movido para /app.html, landing page agora é a página inicial
- CTAs direcionam para /app.html
2026-02-28 00:32:05 -03:00
Frederico Castro
1ef5903da1 Rodar container como usuário node e corrigir resume do executor
- Dockerfile: usar USER node (UID 1000) para bypassPermissions funcionar
- Volumes mapeados para /home/node/ em vez de /root/
- Corrigir resume: voltar a usar -p para mensagens curtas de chat
- Manter stdin piping apenas em execute e summarize (prompts grandes)
2026-02-28 00:19:08 -03:00
Frederico Castro
d662860c61 Adicionar botão Interromper no terminal e corrigir botão Retomar
- Botão Interromper na toolbar do terminal para matar execuções ativas
- Endpoint POST /executions/cancel-all para cancelar agentes e pipelines
- Botão aparece/esconde automaticamente conforme execuções ativas
- Corrigir condição do botão Retomar para pipelines antigas sem failedAtStep
2026-02-28 00:03:44 -03:00
Fred
a1d3ce707c Corrigir E2BIG em pipelines, adicionar diretório de projeto e retomada 2026-02-27 23:46:05 -03:00
Frederico Castro
275d74b18c Corrigir E2BIG em pipelines, adicionar diretório de projeto e retomada
- Instalar Claude CLI no container Docker (npm install -g)
- Pipar prompt via stdin ao invés de argumento -p (resolve E2BIG)
- Adicionar campo workingDirectory na criação/edição de pipeline
- Pre-preencher com /home/projetos/ como base path
- Auto-criar diretório se não existir ao executar agente
- Salvar failedAtStep e lastStepInput quando pipeline falha
- Implementar retomada de pipeline a partir do passo que falhou
- Adicionar botão Retomar no histórico para pipelines com erro
- Configurar trust proxy para Express atrás de reverse proxy
2026-02-27 23:45:36 -03:00
Frederico Castro
38556f9bf5 Corrigir detecção de Tech Lead e PO nos cards de agentes
Detecção por nome do agente além das tags para garantir
destaque e ordenação corretos independente das tags usadas.
2026-02-27 22:44:36 -03:00
Frederico Castro
972ae92291 Melhorias no frontend, pipeline e executor
- Estilos CSS expandidos com novos componentes visuais
- Editor de fluxo visual para pipelines (flow-editor.js)
- Melhorias na UI de agentes e pipelines
- Sumarização automática entre steps de pipeline
- Retry com backoff no executor
- Utilitários adicionais no frontend
2026-02-27 22:39:23 -03:00
Frederico Castro
0b5a81c3e6 Atualizar README com informações de deploy e acesso remoto
- URLs de acesso público via Nitro Cloud
- Instruções de deploy, atualização e restart
- Diagrama de infraestrutura (Caddy + Docker)
- Variáveis de ambiente atualizadas para produção
2026-02-27 22:03:01 -03:00
Frederico Castro
64389b3cf9 Adicionar Dockerfile e .dockerignore para deploy 2026-02-27 21:26:45 -03:00
Frederico Castro
a2a1aa2c7a Download MD no histórico, relatórios externos e service systemd
- Botão de download .md no modal de detalhe do histórico (agente e pipeline)
- Relatórios de execução gravados também em ~/agent_reports/ (configurável via AGENT_REPORTS_DIR)
- Service systemd (user) para iniciar o orchestrator no boot com auto-restart
2026-02-27 04:19:10 -03:00
Frederico Castro
9b66a415ff Correções de bugs, layout de cards e webhook test funcional
- Pipeline cancel/approve/reject corrigido com busca bidirecional
- Secrets injetados no executor via cleanEnv
- Versionamento automático ao atualizar agentes
- writeJsonAsync com log de erro
- Removido asyncHandler.js (código morto)
- Restaurado permissionMode padrão bypassPermissions
- Ícones dos cards alinhados à direita com wrapper
- Botão Editar convertido para ícone nos cards
- Webhook test agora dispara execução real do agente/pipeline
- Corrigido App.navigateTo no teste de webhook
2026-02-26 23:28:50 -03:00
Frederico Castro
bbd2ec46dd Botões de copiar e download no modal de relatório de execução 2026-02-26 21:03:16 -03:00
Frederico Castro
3b10984233 Terminal verboso com eventos de tool, turno, sistema e stderr + cards com botões na base
- Executor envia 5 tipos de evento: chunk, tool, turn, system, stderr
- Frontend renderiza cada tipo com cor e formatação distintas no terminal
- Cards de agentes e pipelines com flex-column e botões alinhados na base
- CSS para novos tipos de linha do terminal (tool amarelo, turn accent, stderr muted)
2026-02-26 20:59:17 -03:00
Frederico Castro
9a874ad032 Imagem dashboard 2026-02-26 20:48:42 -03:00
Frederico Castro
da22154f66 Evolução da plataforma: dashboard com gráficos, notificações, relatórios automáticos, ícones Lucide local e melhorias gerais
- Dashboard com 5 gráficos Chart.js (execuções, status, custo, agentes, pipelines)
- Sistema de notificações com polling, badge e Browser Notification API
- Relatórios MD automáticos para execuções de agentes e pipelines (data/reports/)
- Lucide local (v0.475.0) com nomes de ícones atualizados e refreshIcons centralizado
- Correção de ícones icon-only (padding CSS sobrescrito por btn-sm)
- Cards de agentes e pipelines com botões alinhados na base (flex column)
- Terminal com busca, download, cópia e auto-scroll toggle
- Histórico com export CSV, retry, paginação e truncamento de texto
- Webhooks com edição e teste inline
- Duplicação de agentes e export/import JSON
- Rate limiting, CORS, correlação de requests e health check no backend
- Escrita atômica em JSON (temp + rename) e store de notificações
- Tema claro/escuro com toggle e persistência em localStorage
- Atalhos de teclado 1-9 para navegação entre seções
2026-02-26 20:41:17 -03:00
Frederico Castro
69943f91be Remover CLAUDE.md do repositório 2026-02-26 19:21:48 -03:00
Frederico Castro
68605d837d Atualizar README com documentação completa das funcionalidades 2026-02-26 19:17:36 -03:00
Frederico Castro
d7d2421fc2 Proteção XSS, assinatura de webhook, limite de execuções e data no histórico
- Utilitário centralizado Utils.escapeHtml() substituindo duplicações locais
- Escaping completo em todos os componentes (agents, tasks, schedules, pipelines, webhooks, terminal, history, tags)
- Verificação HMAC-SHA256 para webhooks usando raw body
- Limite de 5000 registros no store de execuções (maxSize)
- Data de execução visível no histórico com ícone de calendário
- Remoção de mutex desnecessário no flush síncrono do db.js
- Novos stores preparatórios (secrets, notifications, agentVersions)
2026-02-26 18:26:27 -03:00
37 changed files with 25275 additions and 1696 deletions

4
.dockerignore Normal file
View File

@@ -0,0 +1,4 @@
node_modules
.git
.env
*.log

View File

@@ -1,70 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Sobre o Projeto
Painel administrativo web para orquestração de agentes Claude Code. Permite criar, configurar e executar agentes que invocam o CLI `claude` como subprocesso, com suporte a agendamento via cron e pipelines sequenciais (saída de um agente alimenta o próximo).
## Comandos
```bash
npm start # Inicia o servidor (porta 3000)
npm run dev # Inicia com --watch (hot reload automático)
```
Não há testes, linting ou build configurados.
## Arquitetura
### Backend (Node.js + Express, ESM)
```
server.js → HTTP + WebSocket (ws) na mesma porta
src/routes/api.js → Todas as rotas REST sob /api
src/agents/manager.js → CRUD de agentes + orquestra execuções e agendamentos
src/agents/executor.js → Spawna o CLI claude como child_process com stream-json
src/agents/scheduler.js → Agendamento cron via node-cron (in-memory)
src/agents/pipeline.js → Execução sequencial de steps, cada um delegando ao executor
src/store/db.js → Persistência em arquivos JSON (data/*.json)
```
**Fluxo de execução:** API recebe POST → `manager.executeTask()``executor.execute()` spawna `/home/fred/.local/bin/claude` com `--output-format stream-json` → stdout é parseado linha a linha → chunks são enviados via WebSocket broadcast para o frontend.
**Pipelines:** Executam steps em sequência. Cada step usa um agente diferente. A saída de um step é passada como input do próximo via template `{{input}}`.
**Persistência:** `db.js` expõe stores (agents, tasks, pipelines) que leem/escrevem JSON em `data/`. Cada operação recarrega o arquivo inteiro. Agendamentos cron são apenas in-memory.
### Frontend (Vanilla JS, SPA)
```
public/index.html → SPA single-page com todas as seções
public/css/styles.css → Estilos (Inter + JetBrains Mono, Lucide icons)
public/js/app.js → Controlador principal, navegação, WebSocket client
public/js/api.js → Client HTTP para /api/*
public/js/components/*.js → UI por seção (dashboard, agents, tasks, schedules, pipelines, terminal, modal, toast)
```
O frontend usa objetos globais no `window` (App, API, DashboardUI, AgentsUI, etc.) sem bundler ou framework. WebSocket reconecta automaticamente com backoff exponencial.
### Endpoints REST
| Recurso | Rotas |
|---------|-------|
| Agentes | GET/POST `/api/agents`, GET/PUT/DELETE `/api/agents/:id`, POST `.../execute`, POST `.../cancel/:executionId`, GET `.../export` |
| Tarefas | GET/POST `/api/tasks`, PUT/DELETE `/api/tasks/:id` |
| Agendamentos | GET/POST `/api/schedules`, DELETE `/api/schedules/:taskId` |
| Pipelines | GET/POST `/api/pipelines`, GET/PUT/DELETE `/api/pipelines/:id`, POST `.../execute`, POST `.../cancel` |
| Sistema | GET `/api/system/status`, GET `/api/executions/active` |
### WebSocket Events
O servidor envia eventos tipados (`execution_output`, `execution_complete`, `execution_error`, `pipeline_step_start`, `pipeline_step_complete`, `pipeline_complete`, `pipeline_error`) que o frontend renderiza no terminal.
## Convenções
- Todo o código e mensagens em português brasileiro
- ESM (`"type": "module"` no package.json) — usar `import`/`export`, não `require`
- Sem TypeScript, sem bundler, sem framework frontend
- IDs gerados com `uuid` v4
- Modelo padrão dos agentes: `claude-sonnet-4-6`

14
Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
FROM node:22-alpine
RUN apk add --no-cache git docker-cli
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
RUN npm install -g @anthropic-ai/claude-code
COPY . .
RUN mkdir -p data && chown -R node:node /app
RUN addgroup -g 984 docker 2>/dev/null; addgroup node docker 2>/dev/null || true
USER node
ENV HOST=0.0.0.0
ENV PORT=3000
EXPOSE 3000
CMD ["node", "server.js"]

433
README.md
View File

@@ -1,148 +1,373 @@
# Agents Orchestrator <p align="center">
<img src="docs/logo.svg" alt="Agents Orchestrator" width="80" />
</p>
Painel administrativo web para orquestração de agentes [Claude Code](https://docs.anthropic.com/en/docs/claude-code). Crie, configure e execute múltiplos agentes com diferentes personalidades, modelos e diretórios de trabalho — tudo a partir de uma interface visual. <h1 align="center">Agents Orchestrator</h1>
<p align="center">
<strong>Plataforma de orquestração de agentes IA com interface visual, pipelines automatizados e integração Git nativa.</strong>
</p>
<p align="center">
<a href="https://agents.nitro-cloud.duckdns.org"><img src="https://img.shields.io/badge/demo-live-00d4aa?style=flat-square" alt="Live Demo" /></a>
<a href="https://git.nitro-cloud.duckdns.org/fred/agents-orchestrator"><img src="https://img.shields.io/badge/gitea-repo-6c40cc?style=flat-square" alt="Gitea" /></a>
<img src="https://img.shields.io/badge/node-%3E%3D22-339933?style=flat-square&logo=node.js&logoColor=white" alt="Node.js" />
<img src="https://img.shields.io/badge/license-MIT-blue?style=flat-square" alt="License" />
</p>
<p align="center">
<a href="#visao-geral">Visao Geral</a> &bull;
<a href="#funcionalidades">Funcionalidades</a> &bull;
<a href="#quick-start">Quick Start</a> &bull;
<a href="#arquitetura">Arquitetura</a> &bull;
<a href="#api">API</a> &bull;
<a href="#deploy">Deploy</a>
</p>
---
## Visao Geral
Agents Orchestrator e uma plataforma web para criar, configurar e executar agentes [Claude Code](https://docs.anthropic.com/en/docs/claude-code) de forma visual. Projetada para equipes de desenvolvimento e profissionais que precisam orquestrar multiplos agentes IA com diferentes especialidades, executar pipelines de trabalho automatizados e integrar com repositorios Git — tudo a partir de um painel administrativo elegante.
### Por que usar?
| Problema | Solucao |
|----------|---------|
| Gerenciar multiplos agentes via CLI e tedioso | Interface visual com cards, filtros e execucao com 1 clique |
| Saida do agente nao e visivel em tempo real | Terminal com streaming WebSocket chunk-a-chunk |
| Automatizar fluxos sequenciais e complexo | Pipelines visuais com aprovacao humana entre passos |
| Agentes nao tem acesso a repositorios remotos | Integracao Git nativa com clone, commit e push automatico |
| Deploy manual e propenso a erros | `git deploy` — um comando faz tudo |
---
## Funcionalidades ## Funcionalidades
- **Gerenciamento de agentes** — Crie agentes com nome, system prompt, modelo (Sonnet/Opus/Haiku), diretório de trabalho e tags. Ative, desative, edite ou exclua a qualquer momento. ### Agentes
- **Execução de tarefas** — Execute tarefas sob demanda em qualquer agente ativo. Templates rápidos incluídos (detecção de bugs, revisão OWASP, refatoração, testes, documentação, performance).
- **Terminal em tempo real** — Acompanhe a saída dos agentes via WebSocket com streaming chunk-a-chunk. Indicador de status de conexão e filtro por execução.
- **Agendamento cron** — Agende tarefas recorrentes com expressões cron. Presets incluídos (horário, diário, semanal, mensal).
- **Pipelines** — Encadeie múltiplos agentes em fluxos sequenciais. A saída de cada passo alimenta o próximo via template `{{input}}`. Ideal para fluxos como "analisar → corrigir → testar".
- **Dashboard** — Visão geral com métricas (agentes, execuções ativas, agendamentos), atividade recente e status do sistema.
- **Exportação** — Exporte a configuração completa de qualquer agente em JSON.
## Pré-requisitos - Criacao com system prompt, modelo (Sonnet/Opus/Haiku), diretorio de trabalho, ferramentas permitidas e modo de permissao
- Tags para organizacao e filtragem
- Duplicacao, importacao/exportacao JSON
- Delegacao automatica entre agentes (Tech Lead → PO)
- Agentes coordenadores recebem lista de agentes disponiveis injetada no prompt
- **Node.js** 18+ ### Execucao
- **Claude Code CLI** instalado e autenticado (`claude` disponível no PATH)
## Instalação - Modal de execucao com seletor de agente, tarefa, instrucoes adicionais e arquivos de contexto
- **Seletor de repositorio Git** — escolha um repo do Gitea e o branch; o sistema clona/atualiza, executa e faz commit/push automatico
- Templates rapidos: deteccao de bugs, revisao OWASP, refatoracao, testes, documentacao, performance
- Retry automatico configuravel por agente
- Continuacao de conversa (resume session)
- Cancelamento individual ou em massa
### Pipelines
- Encadeamento de multiplos agentes em fluxos sequenciais
- Saida de cada passo alimenta o proximo via `{{input}}`
- **Seletor de repositorio** — todos os passos trabalham no mesmo repo com commit automatico ao final
- Portoes de aprovacao humana (human-in-the-loop)
- Retomada de pipelines falhos a partir do passo onde pararam
- Editor de fluxo visual com drag para reordenar passos
### Terminal
- Streaming em tempo real via WebSocket
- Botao Interromper para cancelar execucoes ativas
- Busca no output com navegacao entre ocorrencias
- Download como `.txt` e copia para clipboard
- Auto-scroll toggleavel
### Integração Git
- Listagem automatica de repositorios do Gitea
- Seletor de branch dinamico
- Clone/pull automatico antes da execucao
- **Commit e push automatico** ao final com mensagem descritiva
- Instrucao injetada para agentes nao fazerem operacoes git
- Publicacao de projetos: cria repo, configura subdominio, deploy com 1 clique
### Explorador de Arquivos
- Navegacao em `/home/projetos/` com breadcrumb
- Download de arquivos individuais ou pastas completas (.tar.gz)
- Exclusao com confirmacao
- Botao publicar em projetos — cria repo no Gitea, configura Caddy e faz deploy automatico em `projeto.nitro-cloud.duckdns.org`
### Dashboard
- Metricas em tempo real: agentes, execucoes, agendamentos, custo, webhooks
- Graficos: execucoes por dia, custo diario, distribuicao de status, top 5 agentes, taxa de sucesso
- Seletor de periodo: 7, 14 ou 30 dias
### Catalogo de Tarefas
- Tarefas reutilizaveis com nome, categoria e descricao
- Categorias: Code Review, Seguranca, Refatoracao, Testes, Documentacao, Performance
- Filtro por texto e categoria
- Execucao direta a partir do catalogo
### Agendamento Cron
- Expressoes cron com presets (horario, diario, semanal, mensal)
- Historico de execucoes por agendamento
- Retry automatico em caso de limite de slots
### Webhooks
- Disparo de execucoes via HTTP externo
- Edicao, teste com 1 clique e snippet cURL
- Assinatura HMAC-SHA256
### Notificacoes
- Centro de notificacoes com badge de contagem
- Notificacoes nativas do navegador
- Polling automatico a cada 15 segundos
### Tema e UX
- Tema claro/escuro com transicao suave
- Atalhos de teclado (`1`-`9` navegacao, `N` novo agente, `Esc` fechar modal)
- Exportacao de historico como CSV
---
## Quick Start
### Requisitos
- Node.js >= 22
- [Claude CLI](https://docs.anthropic.com/en/docs/claude-code) instalado e autenticado
### Execucao local
```bash ```bash
git clone <repo-url> git clone https://github.com/fredac100/agents-orchestrator.git
cd agents-orchestrator cd agents-orchestrator
npm install npm install
npm start
``` ```
## Uso Acesse `http://localhost:3000`.
### Com Docker
```bash ```bash
# Produção docker build -t agents-orchestrator .
npm start docker run -p 3000:3000 \
-v $(pwd)/data:/app/data \
# Desenvolvimento (hot reload) -v ~/.claude:/home/node/.claude \
npm run dev agents-orchestrator
``` ```
Acesse **http://localhost:3000** no navegador. A porta pode ser alterada via variável de ambiente `PORT`. ---
## Como funciona
### Criando um agente
1. Clique em **Novo Agente** no header ou na seção Agentes
2. Configure nome, system prompt, modelo e diretório de trabalho
3. Salve — o agente aparecerá como card na listagem
### Executando uma tarefa
1. No card do agente, clique em **Executar**
2. Descreva a tarefa ou use um template rápido
3. Opcionalmente adicione instruções extras
4. A execução inicia e o terminal abre automaticamente com streaming da saída
### Criando um pipeline
1. Vá em **Pipelines****Novo Pipeline**
2. Adicione pelo menos 2 passos, selecionando um agente para cada
3. Opcionalmente defina um template de input usando `{{input}}` para referenciar a saída do passo anterior
4. Execute o pipeline fornecendo o input inicial
### Agendando uma tarefa
1. Vá em **Agendamentos****Novo Agendamento**
2. Selecione o agente, descreva a tarefa e defina a expressão cron
3. A tarefa será executada automaticamente nos horários configurados
## Arquitetura ## Arquitetura
``` ```
server.js Express + WebSocket na mesma porta HTTPS (443)
src/ |
routes/api.js API REST (/api/*) [Caddy] ─── SSL automatico via DuckDNS
agents/ |
manager.js CRUD + orquestração de agentes *.nitro-cloud.duckdns.org
executor.js Spawna o CLI claude como child_process |
scheduler.js Agendamento cron (in-memory) ┌──────────────┼──────────────┐
pipeline.js Execução sequencial de steps | | |
store/db.js Persistência em JSON (data/*.json) [agents.*] [git.*] [projeto.*]
public/ | | |
index.html SPA single-page ┌──────┴──────┐ [Gitea] [Caddy file_server]
css/styles.css Estilos (Inter, JetBrains Mono, Lucide) | |
js/ [Express] [WebSocket]
app.js Controlador principal + WebSocket client | |
api.js Client HTTP para a API ├── API REST (40+ endpoints)
components/ UI por seção (dashboard, agents, tasks, etc.) ├── Manager (CRUD + orquestracao)
data/ ├── Executor (spawn claude CLI)
agents.json Agentes cadastrados ├── Pipeline (sequencial + aprovacao)
tasks.json Templates de tarefas ├── Scheduler (cron jobs)
pipelines.json Pipelines configurados ├── Git Integration (clone/pull/commit/push)
└── Store (JSON com escrita atomica)
``` ```
O executor invoca o binário `claude` com `--output-format stream-json`, parseia o stdout linha a linha e transmite os chunks via WebSocket para o frontend em tempo real. ### Estrutura do Projeto
## API REST ```
server.js HTTP + WebSocket + rate limiting + auth
src/
routes/api.js API REST — 40+ endpoints
agents/
manager.js CRUD + orquestracao + delegacao
executor.js Spawna o CLI claude como child_process
scheduler.js Agendamento cron
pipeline.js Execucao sequencial + aprovacao humana
git-integration.js Clone, pull, commit, push automatico
store/db.js Persistencia JSON com escrita atomica
cache/index.js Cache L1 (memoria) + L2 (Redis opcional)
reports/generator.js Geracao de relatorios de execucao
public/
app.html SPA com hash routing
css/styles.css Design system (dark/light)
js/
app.js Controlador principal + WebSocket
api.js Client HTTP para a API
components/ 16 modulos UI independentes
scripts/
deploy.sh Deploy automatizado via rsync + Docker
data/ Persistencia em JSON (8 stores)
```
| Método | Endpoint | Descrição | ---
## API
### Agentes
| Metodo | Endpoint | Descricao |
|--------|----------|-----------| |--------|----------|-----------|
| `GET` | `/api/agents` | Listar agentes | | `GET` | `/api/agents` | Listar agentes |
| `POST` | `/api/agents` | Criar agente | | `POST` | `/api/agents` | Criar agente |
| `GET` | `/api/agents/:id` | Obter agente | | `GET` | `/api/agents/:id` | Obter agente |
| `PUT` | `/api/agents/:id` | Atualizar agente | | `PUT` | `/api/agents/:id` | Atualizar agente |
| `DELETE` | `/api/agents/:id` | Excluir agente | | `DELETE` | `/api/agents/:id` | Excluir agente |
| `POST` | `/api/agents/:id/execute` | Executar tarefa no agente | | `POST` | `/api/agents/:id/execute` | Executar tarefa (aceita `repoName` e `repoBranch`) |
| `POST` | `/api/agents/:id/cancel/:executionId` | Cancelar execução | | `POST` | `/api/agents/:id/continue` | Continuar conversa |
| `GET` | `/api/agents/:id/export` | Exportar agente (JSON) | | `POST` | `/api/agents/:id/cancel/:execId` | Cancelar execucao |
| `GET` | `/api/tasks` | Listar tarefas | | `GET` | `/api/agents/:id/export` | Exportar agente |
| `POST` | `/api/tasks` | Criar tarefa | | `POST` | `/api/agents/:id/duplicate` | Duplicar agente |
| `PUT` | `/api/tasks/:id` | Atualizar tarefa |
| `DELETE` | `/api/tasks/:id` | Excluir tarefa | ### Pipelines
| `GET` | `/api/schedules` | Listar agendamentos |
| `POST` | `/api/schedules` | Criar agendamento | | Metodo | Endpoint | Descricao |
| `DELETE` | `/api/schedules/:taskId` | Remover agendamento | |--------|----------|-----------|
| `GET` | `/api/pipelines` | Listar pipelines | | `GET` | `/api/pipelines` | Listar pipelines |
| `POST` | `/api/pipelines` | Criar pipeline | | `POST` | `/api/pipelines` | Criar pipeline |
| `GET` | `/api/pipelines/:id` | Obter pipeline | | `POST` | `/api/pipelines/:id/execute` | Executar (aceita `repoName` e `repoBranch`) |
| `PUT` | `/api/pipelines/:id` | Atualizar pipeline | | `POST` | `/api/pipelines/:id/approve` | Aprovar passo pendente |
| `DELETE` | `/api/pipelines/:id` | Excluir pipeline | | `POST` | `/api/pipelines/:id/reject` | Rejeitar passo |
| `POST` | `/api/pipelines/:id/execute` | Executar pipeline | | `POST` | `/api/pipelines/resume/:execId` | Retomar pipeline falho |
| `POST` | `/api/pipelines/:id/cancel` | Cancelar pipeline |
| `GET` | `/api/system/status` | Status geral do sistema | ### Repositorios
| `GET` | `/api/executions/active` | Execuções em andamento |
| Metodo | Endpoint | Descricao |
|--------|----------|-----------|
| `GET` | `/api/repos` | Listar repositorios do Gitea |
| `GET` | `/api/repos/:name/branches` | Listar branches de um repo |
### Arquivos e Publicacao
| Metodo | Endpoint | Descricao |
|--------|----------|-----------|
| `GET` | `/api/files` | Listar diretorio |
| `GET` | `/api/files/download` | Download de arquivo |
| `GET` | `/api/files/download-folder` | Download de pasta (.tar.gz) |
| `DELETE` | `/api/files` | Excluir arquivo ou pasta |
| `POST` | `/api/files/publish` | Publicar projeto (repo + deploy + subdominio) |
### Sistema
| Metodo | Endpoint | Descricao |
|--------|----------|-----------|
| `GET` | `/api/health` | Health check |
| `GET` | `/api/system/status` | Status geral |
| `GET` | `/api/stats/costs` | Estatisticas de custo |
| `GET` | `/api/stats/charts` | Dados para graficos |
---
## Deploy
### Deploy automatico
```bash
git deploy
```
O alias executa `scripts/deploy.sh` que automaticamente:
1. Push para GitHub e Gitea
2. Backup dos dados no VPS
3. Sincronizacao via rsync
4. Correcao de permissoes
5. Rebuild do container Docker
6. Verificacao de integridade
7. Limpeza de backups antigos (mantem 3)
```bash
# Apenas deploy sem push
bash scripts/deploy.sh --skip-push
```
### Variaveis de Ambiente
| Variavel | Descricao | Padrao |
|----------|-----------|--------|
| `PORT` | Porta do servidor | `3000` |
| `HOST` | Endereco de bind | `0.0.0.0` |
| `AUTH_TOKEN` | Bearer token para auth da API | _(desabilitado)_ |
| `ALLOWED_ORIGIN` | Origin para CORS | `http://localhost:3000` |
| `WEBHOOK_SECRET` | Segredo HMAC para webhooks | _(desabilitado)_ |
| `GITEA_URL` | URL interna do Gitea | `http://gitea:3000` |
| `GITEA_USER` | Usuario do Gitea | `fred` |
| `GITEA_PASS` | Senha do Gitea | _(obrigatorio para Git)_ |
| `DOMAIN` | Dominio base para subdominios | `nitro-cloud.duckdns.org` |
| `CLAUDE_BIN` | Caminho do CLI Claude | _(auto-detectado)_ |
| `REDIS_URL` | Redis para cache L2 | _(somente memoria)_ |
---
## Seguranca
- HTTPS via Caddy com certificado wildcard Let's Encrypt
- Autenticacao Bearer token com timing-safe comparison
- Rate limiting: 100 req/min (API), 30 req/min (webhooks)
- CORS restrito a origin configurada
- Correlation IDs em todas as requisicoes
- Escrita atomica em disco (temp + rename)
- Sanitizacao de prompts (NUL, controle, limite 50K chars)
- HMAC-SHA256 para webhooks recebidos
- Protecao contra path traversal no file explorer
---
## Eventos WebSocket ## Eventos WebSocket
O servidor envia eventos tipados via WebSocket que o frontend renderiza no terminal: | Evento | Descricao |
| Evento | Descrição |
|--------|-----------| |--------|-----------|
| `execution_output` | Chunk de texto da saída do agente | | `execution_output` | Chunk de saida do agente |
| `execution_complete` | Execução finalizada com resultado | | `execution_complete` | Execucao finalizada |
| `execution_error` | Erro durante execução | | `execution_error` | Erro durante execucao |
| `pipeline_step_start` | Início de um passo do pipeline | | `execution_retry` | Tentativa de retry |
| `pipeline_step_complete` | Passo do pipeline concluído | | `pipeline_step_start` | Inicio de passo |
| `pipeline_step_complete` | Passo concluido |
| `pipeline_complete` | Pipeline finalizado | | `pipeline_complete` | Pipeline finalizado |
| `pipeline_error` | Erro em um passo do pipeline | | `pipeline_error` | Erro no pipeline |
| `pipeline_approval_required` | Aguardando aprovacao humana |
| `report_generated` | Relatorio gerado |
---
## Stack ## Stack
- **Backend**: Node.js, Express, WebSocket (ws), node-cron, uuid | Camada | Tecnologias |
- **Frontend**: HTML, CSS, JavaScript vanilla (sem framework, sem bundler) |--------|-------------|
- **Ícones**: Lucide | **Backend** | Node.js 22, Express, WebSocket (ws), node-cron, uuid |
- **Fontes**: Inter (UI), JetBrains Mono (código/terminal) | **Frontend** | HTML, CSS, JavaScript vanilla — sem framework, sem bundler |
- **Persistência**: Arquivos JSON em disco | **Graficos** | Chart.js 4.x |
| **Icones** | Lucide |
| **Fontes** | Inter (UI), JetBrains Mono (terminal) |
| **Persistencia** | JSON em disco com escrita atomica |
| **Cache** | In-memory + Redis opcional (ioredis) |
| **Infra** | Docker, Caddy, DuckDNS, Let's Encrypt |
| **Git** | Gitea (self-hosted) |
## Licença ---
## Licenca
MIT MIT
---
<p align="center">
<sub>Desenvolvido por <a href="https://nitro-cloud.duckdns.org">Nitro Cloud</a></sub>
</p>

BIN
docs/dashboard.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

230
package-lock.json generated
View File

@@ -1,14 +1,18 @@
{ {
"name": "agents-orchestrator", "name": "agents-orchestrator",
"version": "1.0.0", "version": "1.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "agents-orchestrator", "name": "agents-orchestrator",
"version": "1.0.0", "version": "1.1.0",
"dependencies": { "dependencies": {
"compression": "^1.8.1",
"express": "^4.21.0", "express": "^4.21.0",
"express-rate-limit": "^8.2.1",
"helmet": "^8.1.0",
"multer": "^2.0.2",
"node-cron": "^3.0.3", "node-cron": "^3.0.3",
"uuid": "^10.0.0", "uuid": "^10.0.0",
"ws": "^8.18.0" "ws": "^8.18.0"
@@ -27,6 +31,12 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/append-field": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
"license": "MIT"
},
"node_modules/array-flatten": { "node_modules/array-flatten": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@@ -57,6 +67,23 @@
"npm": "1.2.8000 || >= 1.4.16" "npm": "1.2.8000 || >= 1.4.16"
} }
}, },
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"license": "MIT"
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/bytes": { "node_modules/bytes": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -95,6 +122,60 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/compressible": {
"version": "2.0.18",
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
"integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==",
"license": "MIT",
"dependencies": {
"mime-db": ">= 1.43.0 < 2"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/compression": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz",
"integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==",
"license": "MIT",
"dependencies": {
"bytes": "3.1.2",
"compressible": "~2.0.18",
"debug": "2.6.9",
"negotiator": "~0.6.4",
"on-headers": "~1.1.0",
"safe-buffer": "5.2.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/compression/node_modules/negotiator": {
"version": "0.6.4",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz",
"integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
"node >= 6.0"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
"node_modules/content-disposition": { "node_modules/content-disposition": {
"version": "0.5.4", "version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -279,6 +360,24 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/express-rate-limit": {
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz",
"integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==",
"license": "MIT",
"dependencies": {
"ip-address": "10.0.1"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": ">= 4.11"
}
},
"node_modules/finalhandler": { "node_modules/finalhandler": {
"version": "1.3.2", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
@@ -397,6 +496,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/helmet": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
"integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/http-errors": { "node_modules/http-errors": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -435,6 +543,15 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/ip-address": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
"integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/ipaddr.js": { "node_modules/ipaddr.js": {
"version": "1.9.1", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -513,12 +630,51 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mkdirp": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"license": "MIT",
"dependencies": {
"minimist": "^1.2.6"
},
"bin": {
"mkdirp": "bin/cmd.js"
}
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/multer": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz",
"integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.6.0",
"concat-stream": "^2.0.0",
"mkdirp": "^0.5.6",
"object-assign": "^4.1.1",
"type-is": "^1.6.18",
"xtend": "^4.0.2"
},
"engines": {
"node": ">= 10.16.0"
}
},
"node_modules/negotiator": { "node_modules/negotiator": {
"version": "0.6.3", "version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@@ -549,6 +705,15 @@
"uuid": "dist/bin/uuid" "uuid": "dist/bin/uuid"
} }
}, },
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object-inspect": { "node_modules/object-inspect": {
"version": "1.13.4", "version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -573,6 +738,15 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/on-headers": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/parseurl": { "node_modules/parseurl": {
"version": "1.3.3", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -640,6 +814,20 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/safe-buffer": { "node_modules/safe-buffer": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -798,6 +986,23 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/toidentifier": { "node_modules/toidentifier": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -820,6 +1025,12 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT"
},
"node_modules/unpipe": { "node_modules/unpipe": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@@ -829,6 +1040,12 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/utils-merge": { "node_modules/utils-merge": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
@@ -880,6 +1097,15 @@
"optional": true "optional": true
} }
} }
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
} }
} }
} }

View File

@@ -9,9 +9,13 @@
"dev": "node --watch server.js" "dev": "node --watch server.js"
}, },
"dependencies": { "dependencies": {
"compression": "^1.8.1",
"express": "^4.21.0", "express": "^4.21.0",
"ws": "^8.18.0", "express-rate-limit": "^8.2.1",
"helmet": "^8.1.0",
"multer": "^2.0.2",
"node-cron": "^3.0.3", "node-cron": "^3.0.3",
"uuid": "^10.0.0" "uuid": "^10.0.0",
"ws": "^8.18.0"
} }
} }

1444
public/app.html Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -38,11 +38,29 @@ const API = {
create(data) { return API.request('POST', '/agents', data); }, create(data) { return API.request('POST', '/agents', data); },
update(id, data) { return API.request('PUT', `/agents/${id}`, data); }, update(id, data) { return API.request('PUT', `/agents/${id}`, data); },
delete(id) { return API.request('DELETE', `/agents/${id}`); }, delete(id) { return API.request('DELETE', `/agents/${id}`); },
execute(id, task, instructions) { return API.request('POST', `/agents/${id}/execute`, { task, instructions }); }, execute(id, task, instructions, contextFiles, workingDirectory, repoName, repoBranch) {
const body = { task, instructions };
if (repoName) { body.repoName = repoName; if (repoBranch) body.repoBranch = repoBranch; }
else if (workingDirectory) body.workingDirectory = workingDirectory;
if (contextFiles && contextFiles.length > 0) body.contextFiles = contextFiles;
return API.request('POST', `/agents/${id}/execute`, body);
},
cancel(id, executionId) { return API.request('POST', `/agents/${id}/cancel/${executionId}`); }, cancel(id, executionId) { return API.request('POST', `/agents/${id}/cancel/${executionId}`); },
continue(id, sessionId, message) { return API.request('POST', `/agents/${id}/continue`, { sessionId, message }); }, continue(id, sessionId, message) { return API.request('POST', `/agents/${id}/continue`, { sessionId, message }); },
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); }, import(data) { return API.request('POST', '/agents/import', data); },
duplicate(id) { return API.request('POST', `/agents/${id}/duplicate`); },
},
secrets: {
list(agentId) { return API.request('GET', `/agents/${agentId}/secrets`); },
create(agentId, data) { return API.request('POST', `/agents/${agentId}/secrets`, data); },
delete(agentId, name) { return API.request('DELETE', `/agents/${agentId}/secrets/${encodeURIComponent(name)}`); },
},
versions: {
list(agentId) { return API.request('GET', `/agents/${agentId}/versions`); },
restore(agentId, version) { return API.request('POST', `/agents/${agentId}/versions/${version}/restore`); },
}, },
tasks: { tasks: {
@@ -66,14 +84,17 @@ const API = {
create(data) { return API.request('POST', '/pipelines', data); }, create(data) { return API.request('POST', '/pipelines', data); },
update(id, data) { return API.request('PUT', `/pipelines/${id}`, data); }, update(id, data) { return API.request('PUT', `/pipelines/${id}`, data); },
delete(id) { return API.request('DELETE', `/pipelines/${id}`); }, delete(id) { return API.request('DELETE', `/pipelines/${id}`); },
execute(id, input, workingDirectory) { execute(id, input, workingDirectory, contextFiles, repoName, repoBranch) {
const body = { input }; const body = { input };
if (workingDirectory) body.workingDirectory = workingDirectory; if (repoName) { body.repoName = repoName; if (repoBranch) body.repoBranch = repoBranch; }
else if (workingDirectory) body.workingDirectory = workingDirectory;
if (contextFiles && contextFiles.length > 0) body.contextFiles = contextFiles;
return API.request('POST', `/pipelines/${id}/execute`, body); return API.request('POST', `/pipelines/${id}/execute`, body);
}, },
cancel(id) { return API.request('POST', `/pipelines/${id}/cancel`); }, cancel(id) { return API.request('POST', `/pipelines/${id}/cancel`); },
approve(id) { return API.request('POST', `/pipelines/${id}/approve`); }, approve(id) { return API.request('POST', `/pipelines/${id}/approve`); },
reject(id) { return API.request('POST', `/pipelines/${id}/reject`); }, reject(id) { return API.request('POST', `/pipelines/${id}/reject`); },
resume(executionId) { return API.request('POST', `/pipelines/resume/${executionId}`); },
}, },
webhooks: { webhooks: {
@@ -81,16 +102,26 @@ const API = {
create(data) { return API.request('POST', '/webhooks', data); }, create(data) { return API.request('POST', '/webhooks', data); },
update(id, data) { return API.request('PUT', `/webhooks/${id}`, data); }, update(id, data) { return API.request('PUT', `/webhooks/${id}`, data); },
delete(id) { return API.request('DELETE', `/webhooks/${id}`); }, delete(id) { return API.request('DELETE', `/webhooks/${id}`); },
test(id) { return API.request('POST', `/webhooks/${id}/test`); },
}, },
stats: { stats: {
costs(days) { return API.request('GET', `/stats/costs${days ? '?days=' + days : ''}`); }, costs(days) { return API.request('GET', `/stats/costs${days ? '?days=' + days : ''}`); },
charts(days) { return API.request('GET', `/stats/charts${days ? '?days=' + days : ''}`); },
},
notifications: {
list() { return API.request('GET', '/notifications'); },
markRead(id) { return API.request('POST', `/notifications/${id}/read`); },
markAllRead() { return API.request('POST', '/notifications/read-all'); },
clear() { return API.request('DELETE', '/notifications'); },
}, },
system: { system: {
status() { return API.request('GET', '/system/status'); }, status() { return API.request('GET', '/system/status'); },
info() { return API.request('GET', '/system/info'); }, info() { return API.request('GET', '/system/info'); },
activeExecutions() { return API.request('GET', '/executions/active'); }, activeExecutions() { return API.request('GET', '/executions/active'); },
cancelAll() { return API.request('POST', '/executions/cancel-all'); },
}, },
settings: { settings: {
@@ -98,6 +129,38 @@ const API = {
save(data) { return API.request('PUT', '/settings', data); }, save(data) { return API.request('PUT', '/settings', data); },
}, },
uploads: {
async send(files) {
const form = new FormData();
for (const f of files) form.append('files', f);
const response = await fetch('/api/uploads', {
method: 'POST',
headers: { 'X-Client-Id': API.clientId },
body: form,
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Erro no upload');
return data;
},
},
repos: {
list() { return API.request('GET', '/repos'); },
branches(name) { return API.request('GET', `/repos/${encodeURIComponent(name)}/branches`); },
},
files: {
list(path) { return API.request('GET', `/files${path ? '?path=' + encodeURIComponent(path) : ''}`); },
delete(path) { return API.request('DELETE', `/files?path=${encodeURIComponent(path)}`); },
publish(path) { return API.request('POST', '/files/publish', { path }); },
},
reports: {
list() { return API.request('GET', '/reports'); },
get(filename) { return API.request('GET', `/reports/${encodeURIComponent(filename)}`); },
delete(filename) { return API.request('DELETE', `/reports/${encodeURIComponent(filename)}`); },
},
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 = {}) { history(params = {}) {
@@ -107,6 +170,19 @@ const API = {
get(id) { return API.request('GET', `/executions/history/${id}`); }, get(id) { return API.request('GET', `/executions/history/${id}`); },
delete(id) { return API.request('DELETE', `/executions/history/${id}`); }, delete(id) { return API.request('DELETE', `/executions/history/${id}`); },
clearAll() { return API.request('DELETE', '/executions/history'); }, clearAll() { return API.request('DELETE', '/executions/history'); },
retry(id) { return API.request('POST', `/executions/${id}/retry`); },
async exportCsv() {
const response = await fetch('/api/executions/export', {
headers: { 'X-Client-Id': API.clientId },
});
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `execucoes_${new Date().toISOString().split('T')[0]}.csv`;
a.click();
URL.revokeObjectURL(url);
},
}, },
}; };

View File

@@ -5,6 +5,8 @@ const App = {
wsReconnectTimer: null, wsReconnectTimer: null,
_initialized: false, _initialized: false,
_lastAgentName: '', _lastAgentName: '',
_executeDropzone: null,
_pipelineDropzone: null,
sectionTitles: { sectionTitles: {
dashboard: 'Dashboard', dashboard: 'Dashboard',
@@ -15,21 +17,55 @@ const App = {
webhooks: 'Webhooks', webhooks: 'Webhooks',
terminal: 'Terminal', terminal: 'Terminal',
history: 'Histórico', history: 'Histórico',
files: 'Projetos',
settings: 'Configurações', settings: 'Configurações',
}, },
sections: ['dashboard', 'agents', 'tasks', 'schedules', 'pipelines', 'webhooks', 'terminal', 'history', 'files', 'settings'],
init() { init() {
if (App._initialized) return; if (App._initialized) return;
App._initialized = true; App._initialized = true;
const savedTheme = localStorage.getItem('theme') || 'dark';
document.documentElement.setAttribute('data-theme', savedTheme);
App.setupNavigation(); App.setupNavigation();
App.setupWebSocket(); App.setupWebSocket();
App.setupEventListeners(); App.setupEventListeners();
App.setupKeyboardShortcuts(); App.setupKeyboardShortcuts();
App.navigateTo('dashboard');
App._executeDropzone = Utils.initDropzone('execute-dropzone', 'execute-files', 'execute-file-list');
App._pipelineDropzone = Utils.initDropzone('pipeline-execute-dropzone', 'pipeline-execute-files', 'pipeline-execute-file-list');
App._initRepoSelectors();
const initialSection = location.hash.replace('#', '') || 'dashboard';
App.navigateTo(App.sections.includes(initialSection) ? initialSection : 'dashboard');
App.startPeriodicRefresh(); App.startPeriodicRefresh();
if (window.lucide) lucide.createIcons(); window.addEventListener('hashchange', () => {
const section = location.hash.replace('#', '') || 'dashboard';
if (App.sections.includes(section)) App.navigateTo(section);
});
const themeToggle = document.getElementById('theme-toggle');
if (themeToggle) {
themeToggle.addEventListener('click', () => {
const current = document.documentElement.getAttribute('data-theme') || 'dark';
const next = current === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', next);
localStorage.setItem('theme', next);
Utils.refreshIcons();
});
}
if (typeof NotificationsUI !== 'undefined') NotificationsUI.init();
if ('Notification' in window && Notification.permission === 'default') {
Notification.requestPermission();
}
Utils.refreshIcons();
}, },
setupNavigation() { setupNavigation() {
@@ -47,6 +83,10 @@ const App = {
}, },
navigateTo(section) { navigateTo(section) {
if (location.hash !== `#${section}`) {
history.pushState(null, '', `#${section}`);
}
document.querySelectorAll('.section').forEach((el) => { document.querySelectorAll('.section').forEach((el) => {
const isActive = el.id === section; const isActive = el.id === section;
el.classList.toggle('active', isActive); el.classList.toggle('active', isActive);
@@ -75,6 +115,7 @@ const App = {
case 'pipelines': await PipelinesUI.load(); break; case 'pipelines': await PipelinesUI.load(); break;
case 'webhooks': await WebhooksUI.load(); break; case 'webhooks': await WebhooksUI.load(); break;
case 'history': await HistoryUI.load(); break; case 'history': await HistoryUI.load(); break;
case 'files': await FilesUI.load(); break;
case 'settings': await SettingsUI.load(); break; case 'settings': await SettingsUI.load(); break;
} }
} catch (err) { } catch (err) {
@@ -138,8 +179,18 @@ const App = {
case 'execution_output': { case 'execution_output': {
Terminal.stopProcessing(); Terminal.stopProcessing();
const evtType = data.data?.type || 'chunk';
const content = data.data?.content || ''; const content = data.data?.content || '';
if (content) { if (!content) break;
if (evtType === 'tool') {
Terminal.addLine(`${content}`, 'tool', data.executionId);
} else if (evtType === 'turn') {
Terminal.addLine(`── ${content} ──`, 'turn', data.executionId);
} else if (evtType === 'system') {
Terminal.addLine(content, 'system', data.executionId);
} else if (evtType === 'stderr') {
Terminal.addLine(content, 'stderr', data.executionId);
} else {
Terminal.addLine(content, 'default', data.executionId); Terminal.addLine(content, 'default', data.executionId);
} }
App._updateActiveBadge(); App._updateActiveBadge();
@@ -174,6 +225,11 @@ const App = {
} }
} }
if (typeof NotificationsUI !== 'undefined') {
NotificationsUI.loadCount();
NotificationsUI.showBrowserNotification('Execução concluída', data.agentName || 'Agente');
}
Toast.success('Execução concluída'); Toast.success('Execução concluída');
App.refreshCurrentSection(); App.refreshCurrentSection();
App._updateActiveBadge(); App._updateActiveBadge();
@@ -183,14 +239,39 @@ const App = {
case 'execution_error': case 'execution_error':
Terminal.stopProcessing(); Terminal.stopProcessing();
Terminal.addLine(data.data?.error || 'Erro na execução', 'error', data.executionId); Terminal.addLine(data.data?.error || 'Erro na execução', 'error', data.executionId);
if (typeof NotificationsUI !== 'undefined') {
NotificationsUI.loadCount();
NotificationsUI.showBrowserNotification('Execução falhou', data.agentName || 'Agente');
}
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 'execution_retry':
Terminal.stopProcessing();
Terminal.addLine(
`Retry ${data.attempt || '?'}/${data.maxRetries || '?'} — próxima tentativa em ${data.nextRetryIn || '?'}s. Motivo: ${data.reason || 'erro na execução'}`,
'warning',
data.executionId
);
break;
case 'pipeline_step_output': { case 'pipeline_step_output': {
Terminal.stopProcessing(); Terminal.stopProcessing();
const stepEvtType = data.data?.type || 'chunk';
const stepContent = data.data?.content || ''; const stepContent = data.data?.content || '';
if (stepContent) { if (!stepContent) break;
if (stepEvtType === 'tool') {
Terminal.addLine(`${stepContent}`, 'tool', data.executionId);
} else if (stepEvtType === 'turn') {
Terminal.addLine(`── ${stepContent} ──`, 'turn', data.executionId);
} else if (stepEvtType === 'system') {
Terminal.addLine(stepContent, 'system', data.executionId);
} else if (stepEvtType === 'stderr') {
Terminal.addLine(stepContent, 'stderr', data.executionId);
} else {
Terminal.addLine(stepContent, 'default', data.executionId); Terminal.addLine(stepContent, 'default', data.executionId);
} }
break; break;
@@ -198,6 +279,7 @@ const App = {
case 'pipeline_step_start': case 'pipeline_step_start':
Terminal.stopProcessing(); Terminal.stopProcessing();
if (data.resumed) Terminal.addLine('(retomando execução anterior)', 'system');
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');
Terminal.startProcessing(data.agentName); Terminal.startProcessing(data.agentName);
break; break;
@@ -211,6 +293,9 @@ const App = {
case 'pipeline_complete': case 'pipeline_complete':
Terminal.stopProcessing(); Terminal.stopProcessing();
Terminal.addLine('Pipeline concluído com sucesso.', 'success'); Terminal.addLine('Pipeline concluído com sucesso.', 'success');
if (data.lastSessionId && data.lastAgentId) {
Terminal.enableChat(data.lastAgentId, data.lastAgentName || 'Agente', data.lastSessionId);
}
Toast.success('Pipeline concluído'); Toast.success('Pipeline concluído');
App.refreshCurrentSection(); App.refreshCurrentSection();
break; break;
@@ -241,9 +326,58 @@ const App = {
case 'pipeline_status': case 'pipeline_status':
break; break;
case 'report_generated':
if (data.reportFile) {
Terminal.addLine(`📄 Relatório gerado: ${data.reportFile}`, 'info');
App._openReport(data.reportFile);
}
break;
} }
}, },
async _openReport(filename) {
try {
const data = await API.request('GET', `/reports/${encodeURIComponent(filename)}`);
if (!data || !data.content) return;
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;
title.textContent = 'Relatório de Execução';
content.innerHTML = `
<div class="report-actions">
<button class="btn btn-ghost btn-sm" id="report-copy-btn">
<i data-lucide="copy"></i> Copiar
</button>
<button class="btn btn-ghost btn-sm" id="report-download-btn">
<i data-lucide="download"></i> Download .md
</button>
</div>
<div class="report-content"><pre class="report-markdown">${Utils.escapeHtml(data.content)}</pre></div>`;
Utils.refreshIcons(content);
document.getElementById('report-copy-btn')?.addEventListener('click', () => {
navigator.clipboard.writeText(data.content).then(() => Toast.success('Relatório copiado'));
});
document.getElementById('report-download-btn')?.addEventListener('click', () => {
const blob = new Blob([data.content], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
Toast.success('Download iniciado');
});
Modal.open('execution-detail-modal-overlay');
} catch (e) {}
},
_showApprovalNotification(pipelineId, stepIndex, agentName) { _showApprovalNotification(pipelineId, stepIndex, agentName) {
const container = document.getElementById('approval-notification'); const container = document.getElementById('approval-notification');
if (!container) return; if (!container) return;
@@ -253,7 +387,7 @@ const App = {
<div class="approval-icon"><i data-lucide="shield-alert"></i></div> <div class="approval-icon"><i data-lucide="shield-alert"></i></div>
<div class="approval-text"> <div class="approval-text">
<strong>Aprovação necessária</strong> <strong>Aprovação necessária</strong>
<span>Passo ${stepIndex + 1} (${agentName || 'agente'}) aguardando autorização</span> <span>Passo ${stepIndex + 1} (${Utils.escapeHtml(agentName) || 'agente'}) aguardando autorização</span>
</div> </div>
<div class="approval-actions"> <div class="approval-actions">
<button class="btn btn--primary btn--sm" id="approval-approve-btn" type="button">Aprovar</button> <button class="btn btn--primary btn--sm" id="approval-approve-btn" type="button">Aprovar</button>
@@ -264,7 +398,7 @@ const App = {
container.hidden = false; container.hidden = false;
container.dataset.pipelineId = pipelineId; container.dataset.pipelineId = pipelineId;
if (window.lucide) lucide.createIcons({ nodes: [container] }); Utils.refreshIcons(container);
document.getElementById('approval-approve-btn')?.addEventListener('click', () => { document.getElementById('approval-approve-btn')?.addEventListener('click', () => {
App._handleApproval(pipelineId, true); App._handleApproval(pipelineId, true);
@@ -424,6 +558,18 @@ const App = {
on('pipeline-execute-submit', 'click', () => PipelinesUI._executeFromModal()); on('pipeline-execute-submit', 'click', () => PipelinesUI._executeFromModal());
on('terminal-stop-btn', 'click', async () => {
try {
await API.system.cancelAll();
Terminal.stopProcessing();
Terminal.addLine('Todas as execuções foram interrompidas.', 'error');
Toast.warning('Execuções interrompidas');
App._updateActiveBadge();
} catch (err) {
Toast.error(`Erro ao interromper: ${err.message}`);
}
});
on('terminal-clear-btn', 'click', () => { on('terminal-clear-btn', 'click', () => {
Terminal.clear(); Terminal.clear();
Terminal.disableChat(); Terminal.disableChat();
@@ -538,6 +684,8 @@ const App = {
case 'edit': AgentsUI.openEditModal(id); break; case 'edit': AgentsUI.openEditModal(id); break;
case 'export': AgentsUI.export(id); break; case 'export': AgentsUI.export(id); break;
case 'delete': AgentsUI.delete(id); break; case 'delete': AgentsUI.delete(id); break;
case 'duplicate': AgentsUI.duplicate(id); break;
case 'versions': AgentsUI.openVersionsModal(id); break;
} }
}); });
@@ -587,6 +735,7 @@ const App = {
switch (action) { switch (action) {
case 'execute-pipeline': PipelinesUI.execute(id); break; case 'execute-pipeline': PipelinesUI.execute(id); break;
case 'edit-pipeline': PipelinesUI.openEditModal(id); break; case 'edit-pipeline': PipelinesUI.openEditModal(id); break;
case 'flow-pipeline': FlowEditor.open(id); break;
case 'delete-pipeline': PipelinesUI.delete(id); break; case 'delete-pipeline': PipelinesUI.delete(id); break;
} }
}); });
@@ -598,6 +747,8 @@ const App = {
switch (action) { switch (action) {
case 'view-execution': HistoryUI.viewDetail(id); break; case 'view-execution': HistoryUI.viewDetail(id); break;
case 'delete-execution': HistoryUI.deleteExecution(id); break; case 'delete-execution': HistoryUI.deleteExecution(id); break;
case 'retry': HistoryUI.retryExecution(id); break;
case 'resume-pipeline': HistoryUI.resumePipeline(id); break;
} }
}); });
@@ -610,6 +761,22 @@ const App = {
case 'delete-webhook': WebhooksUI.delete(id); break; case 'delete-webhook': WebhooksUI.delete(id); break;
case 'copy-webhook-url': WebhooksUI.copyUrl(url); break; case 'copy-webhook-url': WebhooksUI.copyUrl(url); break;
case 'copy-webhook-curl': WebhooksUI.copyCurl(id); break; case 'copy-webhook-curl': WebhooksUI.copyCurl(id); break;
case 'edit-webhook': WebhooksUI.openEditModal(id); break;
case 'test-webhook': WebhooksUI.test(id); break;
}
});
document.getElementById('files-container')?.addEventListener('click', (e) => {
const el = e.target.closest('[data-action]');
if (!el) return;
e.preventDefault();
const { action, path } = el.dataset;
switch (action) {
case 'navigate-files': FilesUI.navigate(path || ''); break;
case 'download-file': FilesUI.downloadFile(path); break;
case 'download-folder': FilesUI.downloadFolder(path); break;
case 'publish-project': FilesUI.publishProject(path); break;
case 'delete-entry': FilesUI.deleteEntry(path, el.dataset.entryType); break;
} }
}); });
@@ -624,6 +791,7 @@ const App = {
case 'move-up': PipelinesUI.moveStep(stepIndex, -1); break; case 'move-up': PipelinesUI.moveStep(stepIndex, -1); break;
case 'move-down': PipelinesUI.moveStep(stepIndex, 1); break; case 'move-down': PipelinesUI.moveStep(stepIndex, 1); break;
case 'remove': PipelinesUI.removeStep(stepIndex); break; case 'remove': PipelinesUI.removeStep(stepIndex); break;
case 'toggle-mode': PipelinesUI.toggleMode(stepIndex); break;
} }
}); });
@@ -661,8 +829,8 @@ const App = {
hidden.value = JSON.stringify(tags); hidden.value = JSON.stringify(tags);
chips.innerHTML = tags.map((t) => ` chips.innerHTML = tags.map((t) => `
<span class="tag-chip"> <span class="tag-chip">
${t} ${Utils.escapeHtml(t)}
<button type="button" class="tag-remove" data-tag="${t}" aria-label="Remover tag ${t}">×</button> <button type="button" class="tag-remove" data-tag="${Utils.escapeHtml(t)}" aria-label="Remover tag ${Utils.escapeHtml(t)}">×</button>
</span> </span>
`).join(''); `).join('');
}; };
@@ -693,6 +861,61 @@ const App = {
}); });
}, },
_reposCache: null,
async _loadRepos(selectId) {
const select = document.getElementById(selectId);
if (!select) return;
try {
if (!App._reposCache) App._reposCache = await API.repos.list();
const current = select.value;
select.innerHTML = '<option value="">Nenhum (usar diretório manual)</option>';
App._reposCache.forEach(r => {
select.insertAdjacentHTML('beforeend',
`<option value="${Utils.escapeHtml(r.name)}">${Utils.escapeHtml(r.name)}${r.description ? ' — ' + Utils.escapeHtml(r.description.slice(0, 40)) : ''}</option>`
);
});
if (current) select.value = current;
} catch { }
},
_initRepoSelectors() {
const pairs = [
['execute-repo', 'execute-repo-branch', 'execute-workdir-group'],
['pipeline-execute-repo', 'pipeline-execute-repo-branch', 'pipeline-execute-workdir-group'],
];
pairs.forEach(([repoId, branchId, workdirGroupId]) => {
const repoSelect = document.getElementById(repoId);
const branchSelect = document.getElementById(branchId);
const workdirGroup = document.getElementById(workdirGroupId);
if (!repoSelect) return;
repoSelect.addEventListener('change', async () => {
const repoName = repoSelect.value;
if (repoName) {
if (workdirGroup) workdirGroup.style.display = 'none';
if (branchSelect) {
branchSelect.style.display = '';
branchSelect.innerHTML = '<option value="">Branch padrão</option>';
try {
const branches = await API.repos.branches(repoName);
branches.forEach(b => {
branchSelect.insertAdjacentHTML('beforeend', `<option value="${Utils.escapeHtml(b)}">${Utils.escapeHtml(b)}</option>`);
});
} catch { }
}
} else {
if (workdirGroup) workdirGroup.style.display = '';
if (branchSelect) branchSelect.style.display = 'none';
}
});
repoSelect.addEventListener('focus', () => {
if (repoSelect.options.length <= 1) App._loadRepos(repoId);
});
});
},
async _handleExecute() { async _handleExecute() {
const agentId = document.getElementById('execute-agent-select')?.value const agentId = document.getElementById('execute-agent-select')?.value
|| document.getElementById('execute-agent-id')?.value; || document.getElementById('execute-agent-id')?.value;
@@ -709,16 +932,28 @@ const App = {
} }
const instructions = document.getElementById('execute-instructions')?.value.trim() || ''; const instructions = document.getElementById('execute-instructions')?.value.trim() || '';
const workingDirectory = document.getElementById('execute-workdir')?.value.trim() || '';
const repoName = document.getElementById('execute-repo')?.value || '';
const repoBranch = document.getElementById('execute-repo-branch')?.value || '';
try { try {
const selectEl = document.getElementById('execute-agent-select'); const selectEl = document.getElementById('execute-agent-select');
const agentName = selectEl?.selectedOptions[0]?.text || 'Agente'; const agentName = selectEl?.selectedOptions[0]?.text || 'Agente';
let contextFiles = null;
const dropzone = App._executeDropzone;
if (dropzone && dropzone.getFiles().length > 0) {
Toast.info('Fazendo upload dos arquivos...');
const uploadResult = await API.uploads.send(dropzone.getFiles());
contextFiles = uploadResult.files;
}
Terminal.disableChat(); Terminal.disableChat();
App._lastAgentName = agentName; App._lastAgentName = agentName;
await API.agents.execute(agentId, task, instructions); await API.agents.execute(agentId, task, instructions, contextFiles, workingDirectory, repoName, repoBranch);
if (dropzone) dropzone.reset();
Modal.close('execute-modal-overlay'); Modal.close('execute-modal-overlay');
App.navigateTo('terminal'); App.navigateTo('terminal');
Toast.info('Execução iniciada'); Toast.info('Execução iniciada');
@@ -768,14 +1003,32 @@ const App = {
return; return;
} }
const isTyping = ['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName); const isInInput = ['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName);
if (isTyping) return; if (isInInput) return;
if (e.key === 'n' || e.key === 'N') { if (e.key === 'n' || e.key === 'N') {
if (App.currentSection === 'agents') { if (App.currentSection === 'agents') {
AgentsUI.openCreateModal(); AgentsUI.openCreateModal();
} }
} }
if (!e.ctrlKey && !e.metaKey && !e.altKey) {
const sectionKeys = {
'1': 'dashboard',
'2': 'agents',
'3': 'tasks',
'4': 'schedules',
'5': 'pipelines',
'6': 'terminal',
'7': 'history',
'8': 'webhooks',
'9': 'settings',
};
if (sectionKeys[e.key]) {
e.preventDefault();
App.navigateTo(sectionKeys[e.key]);
}
}
}); });
}, },
@@ -794,6 +1047,9 @@ const App = {
if (countEl) countEl.textContent = count; if (countEl) countEl.textContent = count;
if (badge) badge.style.display = count > 0 ? 'flex' : 'none'; if (badge) badge.style.display = count > 0 ? 'flex' : 'none';
const stopBtn = document.getElementById('terminal-stop-btn');
if (stopBtn) stopBtn.hidden = count === 0;
const terminalSelect = document.getElementById('terminal-execution-select'); const terminalSelect = document.getElementById('terminal-execution-select');
if (terminalSelect && Array.isArray(active)) { if (terminalSelect && Array.isArray(active)) {
const existing = new Set( const existing = new Set(

View File

@@ -39,8 +39,19 @@ const AgentsUI = {
if (empty) empty.style.display = 'none'; if (empty) empty.style.display = 'none';
const sorted = [...agents].sort((a, b) => {
const rank = (agent) => {
const name = (agent.agent_name || agent.name || '').toLowerCase();
const tags = (agent.tags || []).map((t) => t.toLowerCase());
if (name === 'tech lead' || tags.includes('lider')) return 0;
if (name === 'product owner' || tags.includes('po') || tags.includes('product-owner')) return 1;
return 2;
};
return rank(a) - rank(b);
});
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
agents.forEach((agent) => { sorted.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);
@@ -48,7 +59,7 @@ const AgentsUI = {
grid.appendChild(fragment); grid.appendChild(fragment);
if (window.lucide) lucide.createIcons({ nodes: [grid] }); Utils.refreshIcons(grid);
}, },
filter(searchText, statusFilter) { filter(searchText, statusFilter) {
@@ -76,23 +87,33 @@ const AgentsUI = {
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 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>` ? `<div class="agent-tags">${agent.tags.map((t) => `<span class="tag-chip tag-chip--sm">${Utils.escapeHtml(t)}</span>`).join('')}</div>`
: ''; : '';
const agentNameLower = (agent.agent_name || agent.name || '').toLowerCase();
const tagsLower = Array.isArray(agent.tags) ? agent.tags.map((t) => t.toLowerCase()) : [];
const isLeader = agentNameLower === 'tech lead' || tagsLower.includes('lider');
const isPO = !isLeader && (agentNameLower === 'product owner' || tagsLower.includes('po') || tagsLower.includes('product-owner'));
const roleClass = isLeader ? ' agent-card--leader' : isPO ? ' agent-card--po' : '';
const roleBadge = isLeader
? '<i data-lucide="crown" class="agent-leader-icon"></i>'
: isPO
? '<i data-lucide="shield-check" class="agent-po-icon"></i>'
: '';
return ` return `
<div class="agent-card" data-agent-id="${agent.id}"> <div class="agent-card${roleClass}" data-agent-id="${agent.id}">
<div class="agent-card-body"> <div class="agent-card-body">
<div class="agent-card-top"> <div class="agent-card-top">
<div class="agent-avatar" style="background-color: ${color}" aria-hidden="true"> <div class="agent-avatar" style="background-color: ${color}" aria-hidden="true">
<span>${initials}</span> <span>${initials}</span>
</div> </div>
<div class="agent-info"> <div class="agent-info">
<h3 class="agent-name">${name}</h3> <h3 class="agent-name">${roleBadge}${Utils.escapeHtml(name)}</h3>
<span class="badge ${statusClass}">${statusLabel}</span> <span class="badge ${statusClass}">${statusLabel}</span>
</div> </div>
</div> </div>
${agent.description ? `<p class="agent-description">${agent.description}</p>` : ''} ${agent.description ? `<p class="agent-description">${Utils.escapeHtml(agent.description)}</p>` : ''}
${tags} ${tags}
<div class="agent-meta"> <div class="agent-meta">
@@ -112,16 +133,23 @@ const AgentsUI = {
<i data-lucide="play"></i> <i data-lucide="play"></i>
Executar Executar
</button> </button>
<button class="btn btn-ghost btn-sm" data-action="edit" data-id="${agent.id}"> <div class="agent-actions-icons">
<i data-lucide="pencil"></i> <button class="btn btn-ghost btn-icon btn-sm" data-action="edit" data-id="${agent.id}" title="Editar agente">
Editar <i data-lucide="pencil"></i>
</button> </button>
<button class="btn btn-ghost btn-icon btn-sm" data-action="export" data-id="${agent.id}" title="Exportar agente"> <button class="btn btn-ghost btn-icon btn-sm" data-action="duplicate" data-id="${agent.id}" title="Duplicar agente">
<i data-lucide="download"></i> <i data-lucide="copy"></i>
</button> </button>
<button class="btn btn-ghost btn-icon btn-sm btn-danger" data-action="delete" data-id="${agent.id}" title="Excluir agente"> <button class="btn btn-ghost btn-icon btn-sm" data-action="export" data-id="${agent.id}" title="Exportar agente">
<i data-lucide="trash-2"></i> <i data-lucide="download"></i>
</button> </button>
<button class="btn btn-ghost btn-icon btn-sm" data-action="versions" data-id="${agent.id}" title="Histórico de versões">
<i data-lucide="history"></i>
</button>
<button class="btn btn-ghost btn-icon btn-sm btn-danger" data-action="delete" data-id="${agent.id}" title="Excluir agente">
<i data-lucide="trash-2"></i>
</button>
</div>
</div> </div>
</div> </div>
`; `;
@@ -152,10 +180,31 @@ const AgentsUI = {
const maxTurns = document.getElementById('agent-max-turns'); const maxTurns = document.getElementById('agent-max-turns');
if (maxTurns) maxTurns.value = '0'; if (maxTurns) maxTurns.value = '0';
const workdir = document.getElementById('agent-workdir');
if (workdir) workdir.value = '/home/projetos/';
const permissionMode = document.getElementById('agent-permission-mode'); const permissionMode = document.getElementById('agent-permission-mode');
if (permissionMode) permissionMode.value = ''; if (permissionMode) permissionMode.value = '';
const retryToggle = document.getElementById('agent-retry-toggle');
if (retryToggle) retryToggle.checked = false;
const retryMaxGroup = document.getElementById('agent-retry-max-group');
if (retryMaxGroup) retryMaxGroup.style.display = 'none';
const retryMax = document.getElementById('agent-retry-max');
if (retryMax) retryMax.value = '3';
AgentsUI._populateDelegateSelect('');
const secretsSection = document.getElementById('agent-secrets-section');
if (secretsSection) secretsSection.hidden = true;
const secretsList = document.getElementById('agent-secrets-list');
if (secretsList) secretsList.innerHTML = '';
Modal.open('agent-modal-overlay'); Modal.open('agent-modal-overlay');
AgentsUI._setupModalListeners();
}, },
async openEditModal(agentId) { async openEditModal(agentId) {
@@ -196,7 +245,25 @@ const AgentsUI = {
).join(''); ).join('');
} }
const retryToggle = document.getElementById('agent-retry-toggle');
const retryOnFailure = agent.config && agent.config.retryOnFailure;
if (retryToggle) retryToggle.checked = !!retryOnFailure;
const retryMaxGroup = document.getElementById('agent-retry-max-group');
if (retryMaxGroup) retryMaxGroup.style.display = retryOnFailure ? '' : 'none';
const retryMax = document.getElementById('agent-retry-max');
if (retryMax) retryMax.value = (agent.config && agent.config.maxRetries) || '3';
AgentsUI._populateDelegateSelect(agent.config?.delegateTo || '', agent.id);
const secretsSection = document.getElementById('agent-secrets-section');
if (secretsSection) secretsSection.hidden = false;
AgentsUI._loadSecrets(agent.id);
Modal.open('agent-modal-overlay'); Modal.open('agent-modal-overlay');
AgentsUI._setupModalListeners();
} catch (err) { } catch (err) {
Toast.error(`Erro ao carregar agente: ${err.message}`); Toast.error(`Erro ao carregar agente: ${err.message}`);
} }
@@ -234,6 +301,9 @@ const AgentsUI = {
allowedTools: document.getElementById('agent-allowed-tools')?.value.trim() || '', allowedTools: document.getElementById('agent-allowed-tools')?.value.trim() || '',
maxTurns: parseInt(document.getElementById('agent-max-turns')?.value) || 0, maxTurns: parseInt(document.getElementById('agent-max-turns')?.value) || 0,
permissionMode: document.getElementById('agent-permission-mode')?.value || '', permissionMode: document.getElementById('agent-permission-mode')?.value || '',
retryOnFailure: !!document.getElementById('agent-retry-toggle')?.checked,
maxRetries: parseInt(document.getElementById('agent-retry-max')?.value) || 3,
delegateTo: document.getElementById('agent-delegate-to')?.value || '',
}, },
}; };
@@ -279,7 +349,7 @@ const AgentsUI = {
selectEl.innerHTML = '<option value="">Selecionar agente...</option>' + selectEl.innerHTML = '<option value="">Selecionar agente...</option>' +
allAgents allAgents
.filter((a) => a.status === 'active') .filter((a) => a.status === 'active')
.map((a) => `<option value="${a.id}">${a.agent_name || a.name}</option>`) .map((a) => `<option value="${a.id}">${Utils.escapeHtml(a.agent_name || a.name)}</option>`)
.join(''); .join('');
selectEl.value = agentId; selectEl.value = agentId;
@@ -294,8 +364,21 @@ const AgentsUI = {
const instructionsEl = document.getElementById('execute-instructions'); const instructionsEl = document.getElementById('execute-instructions');
if (instructionsEl) instructionsEl.value = ''; if (instructionsEl) instructionsEl.value = '';
if (App._executeDropzone) App._executeDropzone.reset();
const selectedAgent = allAgents.find(a => a.id === agentId);
const workdirEl = document.getElementById('execute-workdir');
if (workdirEl) {
workdirEl.value = (selectedAgent?.config?.workingDirectory) || '/home/projetos/';
}
AgentsUI._loadSavedTasks(); AgentsUI._loadSavedTasks();
const repoSelect = document.getElementById('execute-repo');
if (repoSelect) { repoSelect.value = ''; repoSelect.dispatchEvent(new Event('change')); }
App._reposCache = null;
App._loadRepos('execute-repo');
Modal.open('execute-modal-overlay'); Modal.open('execute-modal-overlay');
} catch (err) { } catch (err) {
Toast.error(`Erro ao abrir modal de execução: ${err.message}`); Toast.error(`Erro ao abrir modal de execução: ${err.message}`);
@@ -311,7 +394,7 @@ const AgentsUI = {
savedTaskSelect.innerHTML = '<option value="">Digitar manualmente...</option>' + savedTaskSelect.innerHTML = '<option value="">Digitar manualmente...</option>' +
tasks.map((t) => { tasks.map((t) => {
const label = t.category ? `[${t.category.toUpperCase()}] ${t.name}` : t.name; const label = t.category ? `[${t.category.toUpperCase()}] ${t.name}` : t.name;
return `<option value="${t.id}">${label}</option>`; return `<option value="${t.id}">${Utils.escapeHtml(label)}</option>`;
}).join(''); }).join('');
AgentsUI._savedTasksCache = tasks; AgentsUI._savedTasksCache = tasks;
} catch { } catch {
@@ -322,6 +405,16 @@ const AgentsUI = {
_savedTasksCache: [], _savedTasksCache: [],
async duplicate(agentId) {
try {
await API.agents.duplicate(agentId);
Toast.success('Agente duplicado com sucesso');
await AgentsUI.load();
} catch (err) {
Toast.error(`Erro ao duplicar agente: ${err.message}`);
}
},
async export(agentId) { async export(agentId) {
try { try {
const data = await API.agents.export(agentId); const data = await API.agents.export(agentId);
@@ -393,6 +486,231 @@ const AgentsUI = {
minute: '2-digit', minute: '2-digit',
}); });
}, },
_populateDelegateSelect(currentValue, excludeId) {
const select = document.getElementById('agent-delegate-to');
if (!select) return;
const activeAgents = AgentsUI.agents.filter(a => a.status === 'active' && a.id !== excludeId);
select.innerHTML = '<option value="">Nenhum</option>' +
activeAgents.map(a => `<option value="${a.id}" ${a.id === currentValue ? 'selected' : ''}>${Utils.escapeHtml(a.agent_name || a.name)}</option>`).join('');
},
_setupModalListeners() {
const retryToggle = document.getElementById('agent-retry-toggle');
const retryMaxGroup = document.getElementById('agent-retry-max-group');
if (retryToggle && !retryToggle._listenerAdded) {
retryToggle._listenerAdded = true;
retryToggle.addEventListener('change', () => {
if (retryMaxGroup) retryMaxGroup.style.display = retryToggle.checked ? '' : 'none';
});
}
const addSecretBtn = document.getElementById('agent-secret-add-btn');
if (addSecretBtn && !addSecretBtn._listenerAdded) {
addSecretBtn._listenerAdded = true;
addSecretBtn.addEventListener('click', () => {
const agentId = document.getElementById('agent-form-id')?.value;
if (agentId) {
AgentsUI._addSecret(agentId);
} else {
Toast.warning('Salve o agente primeiro para adicionar secrets');
}
});
}
},
async _loadSecrets(agentId) {
const list = document.getElementById('agent-secrets-list');
if (!list) return;
try {
const secrets = await API.secrets.list(agentId);
const items = Array.isArray(secrets) ? secrets : (secrets?.secrets || []);
if (items.length === 0) {
list.innerHTML = '<p class="text-muted text-sm">Nenhum secret configurado.</p>';
return;
}
list.innerHTML = items.map(s => `
<div class="secret-item">
<span class="secret-name font-mono">${Utils.escapeHtml(s.name || s)}</span>
<span class="secret-value-placeholder">••••••••</span>
<button type="button" class="btn btn-ghost btn-icon btn-sm btn-danger" data-secret-delete="${Utils.escapeHtml(s.name || s)}" data-agent-id="${agentId}" title="Remover secret">
<i data-lucide="trash-2"></i>
</button>
</div>
`).join('');
Utils.refreshIcons(list);
list.querySelectorAll('[data-secret-delete]').forEach(btn => {
btn.addEventListener('click', () => {
AgentsUI._deleteSecret(btn.dataset.agentId, btn.dataset.secretDelete);
});
});
} catch {
list.innerHTML = '<p class="text-muted text-sm">Erro ao carregar secrets.</p>';
}
},
async _addSecret(agentId) {
const nameEl = document.getElementById('agent-secret-name');
const valueEl = document.getElementById('agent-secret-value');
const name = nameEl?.value.trim();
const value = valueEl?.value;
if (!name) {
Toast.warning('Nome do secret é obrigatório');
return;
}
if (!value) {
Toast.warning('Valor do secret é obrigatório');
return;
}
try {
await API.secrets.create(agentId, { name, value });
Toast.success(`Secret "${name}" salvo`);
if (nameEl) nameEl.value = '';
if (valueEl) valueEl.value = '';
AgentsUI._loadSecrets(agentId);
} catch (err) {
Toast.error(`Erro ao salvar secret: ${err.message}`);
}
},
async _deleteSecret(agentId, secretName) {
const confirmed = await Modal.confirm(
'Remover secret',
`Tem certeza que deseja remover o secret "${secretName}"?`
);
if (!confirmed) return;
try {
await API.secrets.delete(agentId, secretName);
Toast.success(`Secret "${secretName}" removido`);
AgentsUI._loadSecrets(agentId);
} catch (err) {
Toast.error(`Erro ao remover secret: ${err.message}`);
}
},
async openVersionsModal(agentId) {
const agent = AgentsUI.agents.find(a => a.id === agentId);
const titleEl = document.getElementById('agent-versions-title');
const contentEl = document.getElementById('agent-versions-content');
if (titleEl) titleEl.textContent = `Versões — ${agent?.agent_name || agent?.name || 'Agente'}`;
if (contentEl) {
contentEl.innerHTML = '<div class="flex flex-center gap-8"><div class="spinner"></div><span class="text-secondary">Carregando versões...</span></div>';
}
Modal.open('agent-versions-modal-overlay');
try {
const versions = await API.versions.list(agentId);
const items = Array.isArray(versions) ? versions : (versions?.versions || []);
if (!contentEl) return;
if (items.length === 0) {
contentEl.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon"><i data-lucide="history"></i></div>
<h3 class="empty-state-title">Sem histórico de versões</h3>
<p class="empty-state-desc">As alterações neste agente serão registradas aqui automaticamente.</p>
</div>`;
Utils.refreshIcons(contentEl);
return;
}
contentEl.innerHTML = `
<div class="versions-timeline">
${items.map((v, i) => {
const date = v.changedAt ? new Date(v.changedAt).toLocaleString('pt-BR') : '—';
const changedFields = AgentsUI._getChangedFields(v);
const isLatest = i === 0;
return `
<div class="version-item ${isLatest ? 'version-item--latest' : ''}">
<div class="version-node">
<div class="version-dot ${isLatest ? 'version-dot--active' : ''}"></div>
${i < items.length - 1 ? '<div class="version-line"></div>' : ''}
</div>
<div class="version-content">
<div class="version-header">
<span class="version-number">v${v.version || items.length - i}</span>
<span class="version-date">${date}</span>
${!isLatest ? `<button class="btn btn-ghost btn-sm" data-restore-version="${v.version || items.length - i}" data-agent-id="${agentId}" type="button">
<i data-lucide="undo-2"></i> Restaurar
</button>` : '<span class="badge badge-active">Atual</span>'}
</div>
${changedFields ? `<div class="version-changes">${changedFields}</div>` : ''}
${v.changelog ? `<p class="version-changelog">${Utils.escapeHtml(v.changelog)}</p>` : ''}
</div>
</div>
`;
}).join('')}
</div>`;
Utils.refreshIcons(contentEl);
contentEl.querySelectorAll('[data-restore-version]').forEach(btn => {
btn.addEventListener('click', async () => {
const version = btn.dataset.restoreVersion;
const aid = btn.dataset.agentId;
const confirmed = await Modal.confirm(
'Restaurar versão',
`Deseja restaurar a versão v${version} deste agente? A configuração atual será substituída.`
);
if (!confirmed) return;
try {
await API.versions.restore(aid, version);
Toast.success(`Versão v${version} restaurada`);
Modal.close('agent-versions-modal-overlay');
await AgentsUI.load();
} catch (err) {
Toast.error(`Erro ao restaurar versão: ${err.message}`);
}
});
});
} catch (err) {
if (contentEl) {
contentEl.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon"><i data-lucide="alert-circle"></i></div>
<h3 class="empty-state-title">Erro ao carregar versões</h3>
<p class="empty-state-desc">${Utils.escapeHtml(err.message)}</p>
</div>`;
Utils.refreshIcons(contentEl);
}
}
},
_getChangedFields(version) {
if (!version.config) return '';
const fieldLabels = {
systemPrompt: 'System Prompt',
model: 'Modelo',
workingDirectory: 'Diretório',
allowedTools: 'Ferramentas',
maxTurns: 'Max Turns',
permissionMode: 'Permission Mode',
retryOnFailure: 'Retry',
};
const fields = Object.keys(version.config || {}).filter(k => fieldLabels[k]);
if (fields.length === 0) return '';
return fields.map(f =>
`<span class="version-field-badge">${fieldLabels[f] || f}</span>`
).join('');
},
}; };
window.AgentsUI = AgentsUI; window.AgentsUI = AgentsUI;

View File

@@ -1,4 +1,6 @@
const DashboardUI = { const DashboardUI = {
charts: {},
async load() { async load() {
try { try {
const [status, recentExecs] = await Promise.all([ const [status, recentExecs] = await Promise.all([
@@ -9,11 +11,253 @@ const DashboardUI = {
DashboardUI.updateMetrics(status); DashboardUI.updateMetrics(status);
DashboardUI.updateRecentActivity(recentExecs || []); DashboardUI.updateRecentActivity(recentExecs || []);
DashboardUI.updateSystemStatus(status); DashboardUI.updateSystemStatus(status);
DashboardUI.setupChartPeriod();
DashboardUI.loadCharts();
} catch (err) { } catch (err) {
Toast.error(`Erro ao carregar dashboard: ${err.message}`); Toast.error(`Erro ao carregar dashboard: ${err.message}`);
} }
}, },
async loadCharts() {
try {
const period = document.getElementById('chart-period');
const days = period ? parseInt(period.value) : 7;
const data = await API.stats.charts(days);
DashboardUI.renderExecutionsChart(data);
DashboardUI.renderCostChart(data);
DashboardUI.renderStatusChart(data);
DashboardUI.renderTopAgentsChart(data);
DashboardUI.renderSuccessRateChart(data);
} catch (e) {
console.error('Erro ao carregar gráficos:', e);
}
},
_cssVar(name) {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
},
renderExecutionsChart(data) {
const ctx = document.getElementById('executions-chart');
if (!ctx) return;
if (DashboardUI.charts.executions) DashboardUI.charts.executions.destroy();
const labels = (data.labels || []).map(l => {
const d = new Date(l + 'T12:00:00');
return d.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit' });
});
DashboardUI.charts.executions = new Chart(ctx, {
type: 'bar',
data: {
labels,
datasets: [
{ label: 'Sucesso', data: data.successCounts || [], backgroundColor: 'rgba(34, 197, 94, 0.8)', borderRadius: 4 },
{ label: 'Erro', data: data.errorCounts || [], backgroundColor: 'rgba(239, 68, 68, 0.8)', borderRadius: 4 },
],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom',
labels: { color: DashboardUI._cssVar('--text-secondary'), font: { size: 11 } },
},
},
scales: {
x: {
stacked: true,
grid: { display: false },
ticks: { color: DashboardUI._cssVar('--text-tertiary'), font: { size: 10 } },
},
y: {
stacked: true,
beginAtZero: true,
grid: { color: 'rgba(128,128,128,0.1)' },
ticks: { color: DashboardUI._cssVar('--text-tertiary'), font: { size: 10 } },
},
},
},
});
},
renderCostChart(data) {
const ctx = document.getElementById('cost-chart');
if (!ctx) return;
if (DashboardUI.charts.cost) DashboardUI.charts.cost.destroy();
const labels = (data.labels || []).map(l => {
const d = new Date(l + 'T12:00:00');
return d.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit' });
});
DashboardUI.charts.cost = new Chart(ctx, {
type: 'line',
data: {
labels,
datasets: [{
label: 'Custo (USD)',
data: data.costData || [],
borderColor: '#6366f1',
backgroundColor: 'rgba(99, 102, 241, 0.1)',
fill: true,
tension: 0.4,
pointRadius: 4,
pointBackgroundColor: '#6366f1',
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: {
grid: { display: false },
ticks: { color: DashboardUI._cssVar('--text-tertiary'), font: { size: 10 } },
},
y: {
beginAtZero: true,
grid: { color: 'rgba(128,128,128,0.1)' },
ticks: {
color: DashboardUI._cssVar('--text-tertiary'),
font: { size: 10 },
callback: (v) => '$' + v.toFixed(2),
},
},
},
},
});
},
renderStatusChart(data) {
const ctx = document.getElementById('status-chart');
if (!ctx) return;
if (DashboardUI.charts.status) DashboardUI.charts.status.destroy();
const dist = data.statusDistribution || {};
const statuses = Object.keys(dist);
const values = Object.values(dist);
const colors = {
completed: '#22c55e',
error: '#ef4444',
running: '#6366f1',
canceled: '#f59e0b',
rejected: '#ef4444',
};
DashboardUI.charts.status = new Chart(ctx, {
type: 'doughnut',
data: {
labels: statuses.map(s => s.charAt(0).toUpperCase() + s.slice(1)),
datasets: [{
data: values,
backgroundColor: statuses.map(s => colors[s] || '#94a3b8'),
borderWidth: 0,
}],
},
options: {
responsive: true,
maintainAspectRatio: true,
aspectRatio: 1,
cutout: '65%',
plugins: {
legend: {
position: 'bottom',
labels: {
color: DashboardUI._cssVar('--text-secondary'),
font: { size: 11 },
padding: 12,
},
},
},
},
});
},
renderTopAgentsChart(data) {
const ctx = document.getElementById('agents-chart');
if (!ctx) return;
if (DashboardUI.charts.agents) DashboardUI.charts.agents.destroy();
const top = data.topAgents || [];
DashboardUI.charts.agents = new Chart(ctx, {
type: 'bar',
data: {
labels: top.map(a => a.name.length > 15 ? a.name.substring(0, 15) + '\u2026' : a.name),
datasets: [{
data: top.map(a => a.count),
backgroundColor: ['#6366f1', '#8b5cf6', '#a78bfa', '#c4b5fd', '#ddd6fe'],
borderRadius: 4,
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
indexAxis: 'y',
plugins: { legend: { display: false } },
scales: {
x: {
beginAtZero: true,
grid: { color: 'rgba(128,128,128,0.1)' },
ticks: { color: DashboardUI._cssVar('--text-tertiary'), font: { size: 10 } },
},
y: {
grid: { display: false },
ticks: { color: DashboardUI._cssVar('--text-secondary'), font: { size: 10 } },
},
},
},
});
},
renderSuccessRateChart(data) {
const ctx = document.getElementById('success-rate-chart');
if (!ctx) return;
if (DashboardUI.charts.successRate) DashboardUI.charts.successRate.destroy();
const dist = data.statusDistribution || {};
const total = Object.values(dist).reduce((a, b) => a + b, 0);
const success = dist.completed || 0;
const rate = total > 0 ? Math.round((success / total) * 100) : 0;
DashboardUI.charts.successRate = new Chart(ctx, {
type: 'doughnut',
data: {
labels: ['Sucesso', 'Outros'],
datasets: [{
data: [rate, 100 - rate],
backgroundColor: ['#22c55e', 'rgba(128,128,128,0.15)'],
borderWidth: 0,
}],
},
options: {
responsive: true,
maintainAspectRatio: true,
aspectRatio: 1,
cutout: '75%',
plugins: {
legend: { display: false },
tooltip: { enabled: false },
},
},
plugins: [{
id: 'centerText',
afterDraw(chart) {
const { ctx: c, width, height } = chart;
c.save();
c.font = 'bold 24px Inter';
c.fillStyle = getComputedStyle(document.documentElement).getPropertyValue('--text-primary').trim();
c.textAlign = 'center';
c.textBaseline = 'middle';
c.fillText(rate + '%', width / 2, height / 2);
c.restore();
},
}],
});
},
updateMetrics(status) { updateMetrics(status) {
const metrics = { const metrics = {
'metric-total-agents': status.agents?.total ?? 0, 'metric-total-agents': status.agents?.total ?? 0,
@@ -71,15 +315,15 @@ const DashboardUI = {
<span>Nenhuma execução recente</span> <span>Nenhuma execução recente</span>
</li> </li>
`; `;
if (window.lucide) lucide.createIcons({ nodes: [list] }); Utils.refreshIcons(list);
return; return;
} }
list.innerHTML = executions.map((exec) => { list.innerHTML = executions.map((exec) => {
const statusClass = DashboardUI._statusBadgeClass(exec.status); const statusClass = DashboardUI._statusBadgeClass(exec.status);
const statusLabel = DashboardUI._statusLabel(exec.status); const statusLabel = DashboardUI._statusLabel(exec.status);
const name = exec.agentName || exec.pipelineName || exec.agentId || 'Execução'; const name = Utils.escapeHtml(exec.agentName || exec.pipelineName || exec.agentId || 'Execução');
const taskText = exec.task || exec.input || ''; const taskText = Utils.escapeHtml(exec.task || exec.input || '');
const typeBadge = exec.type === 'pipeline' const typeBadge = exec.type === 'pipeline'
? '<span class="badge badge--purple" style="font-size:0.6rem;padding:1px 5px;">Pipeline</span> ' ? '<span class="badge badge--purple" style="font-size:0.6rem;padding:1px 5px;">Pipeline</span> '
: ''; : '';
@@ -110,6 +354,14 @@ const DashboardUI = {
}).join(''); }).join('');
}, },
setupChartPeriod() {
const chartPeriod = document.getElementById('chart-period');
if (chartPeriod && !chartPeriod._listenerAdded) {
chartPeriod._listenerAdded = true;
chartPeriod.addEventListener('change', () => DashboardUI.loadCharts());
}
},
updateSystemStatus(status) { updateSystemStatus(status) {
const wsBadge = document.getElementById('system-ws-status-badge'); const wsBadge = document.getElementById('system-ws-status-badge');
if (wsBadge) { if (wsBadge) {
@@ -117,6 +369,18 @@ const DashboardUI = {
wsBadge.textContent = wsConnected ? 'Conectado' : 'Desconectado'; wsBadge.textContent = wsConnected ? 'Conectado' : 'Desconectado';
wsBadge.className = `badge ${wsConnected ? 'badge--green' : 'badge--red'}`; wsBadge.className = `badge ${wsConnected ? 'badge--green' : 'badge--red'}`;
} }
const claudeBadge = document.getElementById('system-claude-status-badge');
if (claudeBadge) {
API.system.info().then((info) => {
const available = info.claudeVersion && info.claudeVersion !== 'N/A';
claudeBadge.textContent = available ? info.claudeVersion : 'Indisponível';
claudeBadge.className = `badge ${available ? 'badge--green' : 'badge--red'}`;
}).catch(() => {
claudeBadge.textContent = 'Indisponível';
claudeBadge.className = 'badge badge--red';
});
}
}, },
_statusBadgeClass(status) { _statusBadgeClass(status) {

View File

@@ -0,0 +1,210 @@
const FilesUI = {
currentPath: '',
async load() {
await FilesUI.navigate('');
},
async navigate(path) {
try {
const data = await API.files.list(path);
FilesUI.currentPath = data.path || '';
FilesUI.render(data);
} catch (err) {
Toast.error(`Erro ao carregar arquivos: ${err.message}`);
}
},
render(data) {
const container = document.getElementById('files-container');
if (!container) return;
const breadcrumb = FilesUI._renderBreadcrumb(data.path);
const entries = data.entries || [];
if (entries.length === 0) {
container.innerHTML = `
${breadcrumb}
<div class="files-empty">
<i data-lucide="folder-open" style="width:48px;height:48px;color:var(--text-muted)"></i>
<p>Nenhum arquivo encontrado neste diretório</p>
</div>
`;
Utils.refreshIcons(container);
return;
}
const rows = entries.map(entry => FilesUI._renderRow(entry, data.path)).join('');
container.innerHTML = `
${breadcrumb}
<div class="files-toolbar">
<span class="files-count">${entries.length} ${entries.length === 1 ? 'item' : 'itens'}</span>
<button class="btn btn--ghost btn--sm" data-action="download-folder" data-path="${Utils.escapeHtml(data.path || '')}" title="Baixar pasta como .tar.gz"><i data-lucide="download" style="width:14px;height:14px"></i> Baixar tudo</button>
</div>
<div class="files-table-wrapper">
<table class="files-table">
<thead>
<tr>
<th class="files-th-name">Nome</th>
<th class="files-th-size">Tamanho</th>
<th class="files-th-date">Modificado</th>
<th class="files-th-actions"></th>
</tr>
</thead>
<tbody>
${rows}
</tbody>
</table>
</div>
`;
Utils.refreshIcons(container);
},
_renderBreadcrumb(currentPath) {
const parts = currentPath ? currentPath.split('/').filter(Boolean) : [];
let html = `<nav class="files-breadcrumb"><a href="#" data-action="navigate-files" data-path="" class="files-breadcrumb-link"><i data-lucide="home" style="width:14px;height:14px"></i> projetos</a>`;
let accumulated = '';
for (const part of parts) {
accumulated += (accumulated ? '/' : '') + part;
html += ` <span class="files-breadcrumb-sep">/</span> <a href="#" data-action="navigate-files" data-path="${Utils.escapeHtml(accumulated)}" class="files-breadcrumb-link">${Utils.escapeHtml(part)}</a>`;
}
html += '</nav>';
return html;
},
_renderRow(entry, currentPath) {
const fullPath = currentPath ? `${currentPath}/${entry.name}` : entry.name;
const icon = entry.type === 'directory' ? 'folder' : FilesUI._fileIcon(entry.extension);
const iconColor = entry.type === 'directory' ? 'var(--warning)' : 'var(--text-muted)';
const size = entry.type === 'directory' ? '—' : FilesUI._formatSize(entry.size);
const date = FilesUI._formatDate(entry.modified);
const nameCell = entry.type === 'directory'
? `<a href="#" class="files-entry-link files-entry-dir" data-action="navigate-files" data-path="${Utils.escapeHtml(fullPath)}"><i data-lucide="${icon}" style="width:16px;height:16px;color:${iconColor};flex-shrink:0"></i> ${Utils.escapeHtml(entry.name)}</a>`
: `<span class="files-entry-link files-entry-file"><i data-lucide="${icon}" style="width:16px;height:16px;color:${iconColor};flex-shrink:0"></i> ${Utils.escapeHtml(entry.name)}</span>`;
const downloadBtn = entry.type === 'directory'
? `<button class="btn btn--ghost btn--sm" data-action="download-folder" data-path="${Utils.escapeHtml(fullPath)}" title="Baixar pasta"><i data-lucide="download" style="width:14px;height:14px"></i></button>`
: `<button class="btn btn--ghost btn--sm" data-action="download-file" data-path="${Utils.escapeHtml(fullPath)}" title="Baixar arquivo"><i data-lucide="download" style="width:14px;height:14px"></i></button>`;
const isRootDir = entry.type === 'directory' && !currentPath;
const publishBtn = isRootDir
? `<button class="btn btn--ghost btn--sm btn-publish" data-action="publish-project" data-path="${Utils.escapeHtml(fullPath)}" title="Publicar projeto"><i data-lucide="rocket" style="width:14px;height:14px"></i></button>`
: '';
const deleteBtn = `<button class="btn btn--ghost btn--sm btn-danger" data-action="delete-entry" data-path="${Utils.escapeHtml(fullPath)}" data-entry-type="${entry.type}" title="Excluir"><i data-lucide="trash-2" style="width:14px;height:14px"></i></button>`;
const actions = `${downloadBtn}${publishBtn}${deleteBtn}`;
return `
<tr class="files-row">
<td class="files-td-name">${nameCell}</td>
<td class="files-td-size">${size}</td>
<td class="files-td-date">${date}</td>
<td class="files-td-actions">${actions}</td>
</tr>
`;
},
_fileIcon(ext) {
const map = {
js: 'file-code-2', ts: 'file-code-2', jsx: 'file-code-2', tsx: 'file-code-2',
py: 'file-code-2', rb: 'file-code-2', go: 'file-code-2', rs: 'file-code-2',
java: 'file-code-2', c: 'file-code-2', cpp: 'file-code-2', h: 'file-code-2',
html: 'file-code-2', css: 'file-code-2', scss: 'file-code-2', vue: 'file-code-2',
json: 'file-json', xml: 'file-json', yaml: 'file-json', yml: 'file-json',
md: 'file-text', txt: 'file-text', log: 'file-text', csv: 'file-text',
pdf: 'file-text',
png: 'file-image', jpg: 'file-image', jpeg: 'file-image', gif: 'file-image',
svg: 'file-image', webp: 'file-image', ico: 'file-image',
zip: 'file-archive', tar: 'file-archive', gz: 'file-archive', rar: 'file-archive',
sh: 'file-terminal', bash: 'file-terminal',
sql: 'database',
env: 'file-lock',
};
return map[ext] || 'file';
},
_formatSize(bytes) {
if (bytes == null) return '—';
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0) + ' ' + units[i];
},
_formatDate(isoString) {
if (!isoString) return '—';
const d = new Date(isoString);
return d.toLocaleDateString('pt-BR') + ' ' + d.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
},
downloadFile(path) {
const a = document.createElement('a');
a.href = `/api/files/download?path=${encodeURIComponent(path)}`;
a.download = '';
a.click();
},
downloadFolder(path) {
const a = document.createElement('a');
a.href = `/api/files/download-folder?path=${encodeURIComponent(path)}`;
a.download = '';
a.click();
},
async publishProject(path) {
const name = path.split('/').pop();
const confirmed = await Modal.confirm(
'Publicar projeto',
`Isso irá criar o repositório "${name}" no Gitea, fazer push dos arquivos e publicar em <strong>${name}.nitro-cloud.duckdns.org</strong>. Continuar?`
);
if (!confirmed) return;
try {
Toast.info('Publicando projeto... isso pode levar alguns segundos');
const result = await API.files.publish(path);
Toast.success(`Projeto publicado com sucesso!`);
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) {
title.textContent = 'Projeto Publicado';
content.innerHTML = `
<div class="publish-result">
<div class="publish-result-item"><strong>Repositório:</strong> <a href="${Utils.escapeHtml(result.repoUrl)}" target="_blank">${Utils.escapeHtml(result.repoUrl)}</a></div>
<div class="publish-result-item"><strong>Site:</strong> <a href="${Utils.escapeHtml(result.siteUrl)}" target="_blank">${Utils.escapeHtml(result.siteUrl)}</a></div>
<div class="publish-result-item"><strong>Status:</strong> <span class="badge badge-active">${Utils.escapeHtml(result.status)}</span></div>
${result.message ? `<div class="publish-result-item"><em>${Utils.escapeHtml(result.message)}</em></div>` : ''}
</div>`;
Modal.open('execution-detail-modal-overlay');
}
await FilesUI.navigate(FilesUI.currentPath);
} catch (err) {
Toast.error(`Erro ao publicar: ${err.message}`);
}
},
async deleteEntry(path, entryType) {
const label = entryType === 'directory' ? 'pasta' : 'arquivo';
const name = path.split('/').pop();
const confirmed = await Modal.confirm(
`Excluir ${label}`,
`Tem certeza que deseja excluir "${name}"? Esta ação não pode ser desfeita.`
);
if (!confirmed) return;
try {
await API.files.delete(path);
Toast.success(`${label.charAt(0).toUpperCase() + label.slice(1)} excluído`);
await FilesUI.navigate(FilesUI.currentPath);
} catch (err) {
Toast.error(`Erro ao excluir: ${err.message}`);
}
},
};
window.FilesUI = FilesUI;

View File

@@ -0,0 +1,761 @@
const FlowEditor = {
_overlay: null,
_canvas: null,
_ctx: null,
_nodesContainer: null,
_pipelineId: null,
_pipeline: null,
_agents: [],
_nodes: [],
_dragState: null,
_panOffset: { x: 0, y: 0 },
_panStart: null,
_scale: 1,
_selectedNode: null,
_editingNode: null,
_resizeObserver: null,
_animFrame: null,
_dirty: false,
NODE_WIDTH: 240,
NODE_HEIGHT: 72,
NODE_GAP_Y: 100,
START_X: 0,
START_Y: 60,
async open(pipelineId) {
try {
const [pipeline, agents] = await Promise.all([
API.pipelines.get(pipelineId),
API.agents.list(),
]);
FlowEditor._pipelineId = pipelineId;
FlowEditor._pipeline = pipeline;
FlowEditor._agents = Array.isArray(agents) ? agents : [];
FlowEditor._selectedNode = null;
FlowEditor._editingNode = null;
FlowEditor._panOffset = { x: 0, y: 0 };
FlowEditor._scale = 1;
FlowEditor._dirty = false;
FlowEditor._buildNodes();
FlowEditor._show();
FlowEditor._centerView();
FlowEditor._render();
} catch (err) {
Toast.error('Erro ao abrir editor de fluxo: ' + err.message);
}
},
_buildNodes() {
const steps = Array.isArray(FlowEditor._pipeline.steps) ? FlowEditor._pipeline.steps : [];
FlowEditor._nodes = steps.map((step, i) => {
const agent = FlowEditor._agents.find((a) => a.id === step.agentId);
return {
id: step.id || 'step-' + i,
index: i,
x: 0,
y: i * (FlowEditor.NODE_HEIGHT + FlowEditor.NODE_GAP_Y),
agentId: step.agentId || '',
agentName: agent ? (agent.agent_name || agent.name) : (step.agentName || 'Agente'),
inputTemplate: step.inputTemplate || '',
requiresApproval: !!step.requiresApproval,
description: step.description || '',
};
});
},
_show() {
let overlay = document.getElementById('flow-editor-overlay');
if (!overlay) {
FlowEditor._createDOM();
overlay = document.getElementById('flow-editor-overlay');
}
FlowEditor._overlay = overlay;
FlowEditor._canvas = document.getElementById('flow-editor-canvas');
FlowEditor._ctx = FlowEditor._canvas.getContext('2d');
FlowEditor._nodesContainer = document.getElementById('flow-editor-nodes');
const titleEl = document.getElementById('flow-editor-title');
if (titleEl) titleEl.textContent = FlowEditor._pipeline.name || 'Pipeline';
const saveBtn = document.getElementById('flow-editor-save-btn');
if (saveBtn) saveBtn.classList.toggle('flow-btn--disabled', true);
overlay.hidden = false;
requestAnimationFrame(() => overlay.classList.add('active'));
FlowEditor._setupEvents();
FlowEditor._resizeCanvas();
if (!FlowEditor._resizeObserver) {
FlowEditor._resizeObserver = new ResizeObserver(() => {
FlowEditor._resizeCanvas();
FlowEditor._render();
});
}
FlowEditor._resizeObserver.observe(FlowEditor._canvas.parentElement);
},
_createDOM() {
const div = document.createElement('div');
div.innerHTML = `
<div class="flow-editor-overlay" id="flow-editor-overlay" hidden>
<div class="flow-editor">
<div class="flow-editor-header">
<div class="flow-editor-header-left">
<button class="flow-btn flow-btn--ghost" id="flow-editor-close-btn" title="Voltar">
<i data-lucide="arrow-left" style="width:18px;height:18px"></i>
</button>
<div class="flow-editor-title-group">
<h2 class="flow-editor-title" id="flow-editor-title">Pipeline</h2>
<span class="flow-editor-subtitle">Editor de Fluxo</span>
</div>
</div>
<div class="flow-editor-header-actions">
<div class="flow-editor-zoom">
<button class="flow-btn flow-btn--ghost flow-btn--sm" id="flow-zoom-out" title="Diminuir zoom">
<i data-lucide="minus" style="width:14px;height:14px"></i>
</button>
<span class="flow-zoom-label" id="flow-zoom-label">100%</span>
<button class="flow-btn flow-btn--ghost flow-btn--sm" id="flow-zoom-in" title="Aumentar zoom">
<i data-lucide="plus" style="width:14px;height:14px"></i>
</button>
<button class="flow-btn flow-btn--ghost flow-btn--sm" id="flow-zoom-fit" title="Centralizar">
<i data-lucide="maximize-2" style="width:14px;height:14px"></i>
</button>
</div>
<button class="flow-btn flow-btn--ghost flow-btn--sm" id="flow-add-node-btn" title="Adicionar passo">
<i data-lucide="plus-circle" style="width:16px;height:16px"></i>
<span>Passo</span>
</button>
<button class="flow-btn flow-btn--primary flow-btn--disabled" id="flow-editor-save-btn">
<i data-lucide="save" style="width:14px;height:14px"></i>
<span>Salvar</span>
</button>
</div>
</div>
<div class="flow-editor-body">
<div class="flow-editor-canvas-wrap" id="flow-editor-canvas-wrap">
<canvas id="flow-editor-canvas"></canvas>
<div class="flow-editor-nodes" id="flow-editor-nodes"></div>
</div>
<div class="flow-editor-panel" id="flow-editor-panel" hidden>
<div class="flow-panel-header">
<h3 class="flow-panel-title" id="flow-panel-title">Configuração</h3>
<button class="flow-btn flow-btn--ghost flow-btn--sm" id="flow-panel-close" title="Fechar painel">
<i data-lucide="x" style="width:14px;height:14px"></i>
</button>
</div>
<div class="flow-panel-body" id="flow-panel-body"></div>
</div>
</div>
</div>
</div>
`;
document.body.appendChild(div.firstElementChild);
},
_setupEvents() {
const wrap = document.getElementById('flow-editor-canvas-wrap');
if (!wrap || wrap._flowBound) return;
wrap._flowBound = true;
wrap.addEventListener('pointerdown', FlowEditor._onPointerDown);
wrap.addEventListener('pointermove', FlowEditor._onPointerMove);
wrap.addEventListener('pointerup', FlowEditor._onPointerUp);
wrap.addEventListener('wheel', FlowEditor._onWheel, { passive: false });
document.getElementById('flow-editor-close-btn')?.addEventListener('click', FlowEditor._close);
document.getElementById('flow-editor-save-btn')?.addEventListener('click', FlowEditor._save);
document.getElementById('flow-add-node-btn')?.addEventListener('click', FlowEditor._addNode);
document.getElementById('flow-zoom-in')?.addEventListener('click', () => FlowEditor._zoom(0.1));
document.getElementById('flow-zoom-out')?.addEventListener('click', () => FlowEditor._zoom(-0.1));
document.getElementById('flow-zoom-fit')?.addEventListener('click', () => FlowEditor._centerView());
document.getElementById('flow-panel-close')?.addEventListener('click', FlowEditor._closePanel);
document.addEventListener('keydown', FlowEditor._onKeyDown);
},
_resizeCanvas() {
const wrap = document.getElementById('flow-editor-canvas-wrap');
const canvas = FlowEditor._canvas;
if (!wrap || !canvas) return;
const dpr = window.devicePixelRatio || 1;
const rect = wrap.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
canvas.style.width = rect.width + 'px';
canvas.style.height = rect.height + 'px';
FlowEditor._ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
},
_render() {
if (FlowEditor._animFrame) cancelAnimationFrame(FlowEditor._animFrame);
FlowEditor._animFrame = requestAnimationFrame(FlowEditor._draw);
},
_draw() {
const ctx = FlowEditor._ctx;
const canvas = FlowEditor._canvas;
if (!ctx || !canvas) return;
const dpr = window.devicePixelRatio || 1;
const w = canvas.width / dpr;
const h = canvas.height / dpr;
ctx.clearRect(0, 0, w, h);
ctx.save();
ctx.translate(FlowEditor._panOffset.x, FlowEditor._panOffset.y);
ctx.scale(FlowEditor._scale, FlowEditor._scale);
FlowEditor._drawGrid(ctx, w, h);
FlowEditor._drawConnections(ctx);
ctx.restore();
FlowEditor._renderNodes();
},
_drawGrid(ctx, w, h) {
const scale = FlowEditor._scale;
const ox = FlowEditor._panOffset.x;
const oy = FlowEditor._panOffset.y;
const gridSize = 24;
ctx.strokeStyle = 'rgba(255,255,255,0.03)';
ctx.lineWidth = 1 / scale;
const startX = Math.floor(-ox / scale / gridSize) * gridSize;
const startY = Math.floor(-oy / scale / gridSize) * gridSize;
const endX = startX + w / scale + gridSize * 2;
const endY = startY + h / scale + gridSize * 2;
ctx.beginPath();
for (let x = startX; x < endX; x += gridSize) {
ctx.moveTo(x, startY);
ctx.lineTo(x, endY);
}
for (let y = startY; y < endY; y += gridSize) {
ctx.moveTo(startX, y);
ctx.lineTo(endX, y);
}
ctx.stroke();
},
_drawConnections(ctx) {
const nodes = FlowEditor._nodes;
const nw = FlowEditor.NODE_WIDTH;
const nh = FlowEditor.NODE_HEIGHT;
for (let i = 0; i < nodes.length - 1; i++) {
const a = nodes[i];
const b = nodes[i + 1];
const ax = a.x + nw / 2;
const ay = a.y + nh;
const bx = b.x + nw / 2;
const by = b.y;
const midY = (ay + by) / 2;
const grad = ctx.createLinearGradient(ax, ay, bx, by);
grad.addColorStop(0, 'rgba(99,102,241,0.6)');
grad.addColorStop(1, 'rgba(139,92,246,0.6)');
ctx.strokeStyle = grad;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(ax, ay);
ctx.bezierCurveTo(ax, midY, bx, midY, bx, by);
ctx.stroke();
const arrowSize = 6;
const angle = Math.atan2(by - midY, bx - bx) || Math.PI / 2;
ctx.fillStyle = 'rgba(139,92,246,0.8)';
ctx.beginPath();
ctx.moveTo(bx, by);
ctx.lineTo(bx - arrowSize * Math.cos(angle - 0.4), by - arrowSize * Math.sin(angle - 0.4));
ctx.lineTo(bx - arrowSize * Math.cos(angle + 0.4), by - arrowSize * Math.sin(angle + 0.4));
ctx.closePath();
ctx.fill();
if (b.requiresApproval) {
const iconX = (ax + bx) / 2;
const iconY = midY;
ctx.fillStyle = '#0a0a0f';
ctx.beginPath();
ctx.arc(iconX, iconY, 10, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = 'rgba(245,158,11,0.8)';
ctx.lineWidth = 1.5;
ctx.stroke();
ctx.fillStyle = '#f59e0b';
ctx.font = 'bold 10px Inter, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('!', iconX, iconY);
}
}
},
_renderNodes() {
const container = FlowEditor._nodesContainer;
if (!container) return;
const ox = FlowEditor._panOffset.x;
const oy = FlowEditor._panOffset.y;
const scale = FlowEditor._scale;
let existingEls = container.querySelectorAll('.flow-node');
const existingMap = {};
existingEls.forEach((el) => { existingMap[el.dataset.nodeId] = el; });
FlowEditor._nodes.forEach((node, i) => {
const screenX = node.x * scale + ox;
const screenY = node.y * scale + oy;
const isSelected = FlowEditor._selectedNode === i;
let el = existingMap[node.id];
if (!el) {
el = document.createElement('div');
el.className = 'flow-node';
el.dataset.nodeId = node.id;
el.dataset.nodeIndex = i;
container.appendChild(el);
}
el.dataset.nodeIndex = i;
el.style.transform = `translate(${screenX}px, ${screenY}px) scale(${scale})`;
el.style.width = FlowEditor.NODE_WIDTH + 'px';
el.style.height = FlowEditor.NODE_HEIGHT + 'px';
el.classList.toggle('flow-node--selected', isSelected);
const stepNum = i + 1;
const name = Utils.escapeHtml(node.agentName || 'Selecionar agente...');
const approvalBadge = node.requiresApproval && i > 0
? '<span class="flow-node-approval">Aprovação</span>'
: '';
el.innerHTML = `
<div class="flow-node-header">
<span class="flow-node-number">${stepNum}</span>
<span class="flow-node-name" title="${name}">${name}</span>
${approvalBadge}
</div>
<div class="flow-node-sub">
${node.inputTemplate ? Utils.escapeHtml(Utils.truncate(node.inputTemplate, 40)) : '<span class="flow-node-placeholder">Sem template de input</span>'}
</div>
`;
delete existingMap[node.id];
});
Object.values(existingMap).forEach((el) => el.remove());
},
_centerView() {
const canvas = FlowEditor._canvas;
if (!canvas || FlowEditor._nodes.length === 0) return;
const dpr = window.devicePixelRatio || 1;
const w = canvas.width / dpr;
const h = canvas.height / dpr;
const nw = FlowEditor.NODE_WIDTH;
const nh = FlowEditor.NODE_HEIGHT;
const nodes = FlowEditor._nodes;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
nodes.forEach((n) => {
minX = Math.min(minX, n.x);
minY = Math.min(minY, n.y);
maxX = Math.max(maxX, n.x + nw);
maxY = Math.max(maxY, n.y + nh);
});
const contentW = maxX - minX;
const contentH = maxY - minY;
const padding = 80;
const scaleX = (w - padding * 2) / contentW;
const scaleY = (h - padding * 2) / contentH;
const scale = Math.min(Math.max(Math.min(scaleX, scaleY), 0.3), 1.5);
FlowEditor._scale = scale;
FlowEditor._panOffset = {
x: (w - contentW * scale) / 2 - minX * scale,
y: (h - contentH * scale) / 2 - minY * scale,
};
FlowEditor._updateZoomLabel();
FlowEditor._render();
},
_zoom(delta) {
const oldScale = FlowEditor._scale;
FlowEditor._scale = Math.min(Math.max(oldScale + delta, 0.2), 2.5);
FlowEditor._updateZoomLabel();
FlowEditor._render();
},
_updateZoomLabel() {
const el = document.getElementById('flow-zoom-label');
if (el) el.textContent = Math.round(FlowEditor._scale * 100) + '%';
},
_onPointerDown(e) {
const nodeEl = e.target.closest('.flow-node');
if (nodeEl) {
const idx = parseInt(nodeEl.dataset.nodeIndex, 10);
FlowEditor._selectedNode = idx;
if (e.detail === 2) {
FlowEditor._openNodePanel(idx);
FlowEditor._render();
return;
}
const node = FlowEditor._nodes[idx];
FlowEditor._dragState = {
type: 'node',
index: idx,
startX: e.clientX,
startY: e.clientY,
origX: node.x,
origY: node.y,
moved: false,
};
nodeEl.setPointerCapture(e.pointerId);
FlowEditor._render();
return;
}
if (e.target.closest('.flow-editor-panel') || e.target.closest('.flow-editor-header')) return;
FlowEditor._selectedNode = null;
FlowEditor._panStart = {
x: e.clientX - FlowEditor._panOffset.x,
y: e.clientY - FlowEditor._panOffset.y,
};
FlowEditor._render();
},
_onPointerMove(e) {
if (FlowEditor._dragState) {
const ds = FlowEditor._dragState;
const dx = (e.clientX - ds.startX) / FlowEditor._scale;
const dy = (e.clientY - ds.startY) / FlowEditor._scale;
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) ds.moved = true;
FlowEditor._nodes[ds.index].x = ds.origX + dx;
FlowEditor._nodes[ds.index].y = ds.origY + dy;
FlowEditor._render();
return;
}
if (FlowEditor._panStart) {
FlowEditor._panOffset.x = e.clientX - FlowEditor._panStart.x;
FlowEditor._panOffset.y = e.clientY - FlowEditor._panStart.y;
FlowEditor._render();
}
},
_onPointerUp(e) {
if (FlowEditor._dragState) {
const ds = FlowEditor._dragState;
if (!ds.moved) {
FlowEditor._openNodePanel(ds.index);
} else {
FlowEditor._markDirty();
}
FlowEditor._dragState = null;
FlowEditor._render();
return;
}
FlowEditor._panStart = null;
},
_onWheel(e) {
e.preventDefault();
const delta = e.deltaY > 0 ? -0.08 : 0.08;
const oldScale = FlowEditor._scale;
const newScale = Math.min(Math.max(oldScale + delta, 0.2), 2.5);
const rect = FlowEditor._canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
FlowEditor._panOffset.x = mx - (mx - FlowEditor._panOffset.x) * (newScale / oldScale);
FlowEditor._panOffset.y = my - (my - FlowEditor._panOffset.y) * (newScale / oldScale);
FlowEditor._scale = newScale;
FlowEditor._updateZoomLabel();
FlowEditor._render();
},
_onKeyDown(e) {
if (!FlowEditor._overlay || FlowEditor._overlay.hidden) return;
if (e.key === 'Escape') {
if (FlowEditor._editingNode !== null) {
FlowEditor._closePanel();
} else {
FlowEditor._close();
}
e.stopPropagation();
return;
}
if (e.key === 'Delete' && FlowEditor._selectedNode !== null && FlowEditor._editingNode === null) {
FlowEditor._removeNode(FlowEditor._selectedNode);
}
},
_openNodePanel(index) {
const node = FlowEditor._nodes[index];
if (!node) return;
FlowEditor._editingNode = index;
FlowEditor._selectedNode = index;
const panel = document.getElementById('flow-editor-panel');
const title = document.getElementById('flow-panel-title');
const body = document.getElementById('flow-panel-body');
if (!panel || !body) return;
if (title) title.textContent = `Passo ${index + 1}`;
panel.hidden = false;
const agentOptions = FlowEditor._agents
.map((a) => {
const aName = Utils.escapeHtml(a.agent_name || a.name);
const selected = a.id === node.agentId ? 'selected' : '';
return `<option value="${a.id}" ${selected}>${aName}</option>`;
})
.join('');
const approvalChecked = node.requiresApproval ? 'checked' : '';
const showApproval = index > 0;
body.innerHTML = `
<div class="flow-panel-field">
<label class="flow-panel-label">Agente</label>
<select class="flow-panel-select" id="flow-panel-agent">
<option value="">Selecionar agente...</option>
${agentOptions}
</select>
</div>
<div class="flow-panel-field">
<label class="flow-panel-label">Template de Input</label>
<textarea class="flow-panel-textarea" id="flow-panel-template" rows="4" placeholder="{{input}} será substituído pelo output anterior">${Utils.escapeHtml(node.inputTemplate || '')}</textarea>
<span class="flow-panel-hint">Use <code>{{input}}</code> para referenciar o output do passo anterior</span>
</div>
${showApproval ? `
<div class="flow-panel-field">
<label class="flow-panel-checkbox">
<input type="checkbox" id="flow-panel-approval" ${approvalChecked} />
<span>Requer aprovação antes de executar</span>
</label>
</div>` : ''}
<div class="flow-panel-field flow-panel-actions-group">
<button class="flow-btn flow-btn--ghost flow-btn--sm flow-btn--full" id="flow-panel-move-up" ${index === 0 ? 'disabled' : ''}>
<i data-lucide="chevron-up" style="width:14px;height:14px"></i> Mover acima
</button>
<button class="flow-btn flow-btn--ghost flow-btn--sm flow-btn--full" id="flow-panel-move-down" ${index === FlowEditor._nodes.length - 1 ? 'disabled' : ''}>
<i data-lucide="chevron-down" style="width:14px;height:14px"></i> Mover abaixo
</button>
<button class="flow-btn flow-btn--danger flow-btn--sm flow-btn--full" id="flow-panel-delete">
<i data-lucide="trash-2" style="width:14px;height:14px"></i> Remover passo
</button>
</div>
`;
Utils.refreshIcons(body);
document.getElementById('flow-panel-agent')?.addEventListener('change', (ev) => {
const val = ev.target.value;
node.agentId = val;
const agent = FlowEditor._agents.find((a) => a.id === val);
node.agentName = agent ? (agent.agent_name || agent.name) : 'Selecionar agente...';
FlowEditor._markDirty();
FlowEditor._render();
});
document.getElementById('flow-panel-template')?.addEventListener('input', (ev) => {
node.inputTemplate = ev.target.value;
FlowEditor._markDirty();
FlowEditor._render();
});
document.getElementById('flow-panel-approval')?.addEventListener('change', (ev) => {
node.requiresApproval = ev.target.checked;
FlowEditor._markDirty();
FlowEditor._render();
});
document.getElementById('flow-panel-move-up')?.addEventListener('click', () => {
FlowEditor._swapNodes(index, index - 1);
});
document.getElementById('flow-panel-move-down')?.addEventListener('click', () => {
FlowEditor._swapNodes(index, index + 1);
});
document.getElementById('flow-panel-delete')?.addEventListener('click', () => {
FlowEditor._removeNode(index);
});
},
_closePanel() {
const panel = document.getElementById('flow-editor-panel');
if (panel) panel.hidden = true;
FlowEditor._editingNode = null;
},
_addNode() {
const lastNode = FlowEditor._nodes[FlowEditor._nodes.length - 1];
const newY = lastNode
? lastNode.y + FlowEditor.NODE_HEIGHT + FlowEditor.NODE_GAP_Y
: FlowEditor.START_Y;
const newX = lastNode ? lastNode.x : FlowEditor.START_X;
FlowEditor._nodes.push({
id: 'step-new-' + Date.now(),
index: FlowEditor._nodes.length,
x: newX,
y: newY,
agentId: '',
agentName: 'Selecionar agente...',
inputTemplate: '',
requiresApproval: false,
description: '',
});
FlowEditor._markDirty();
FlowEditor._render();
const newIdx = FlowEditor._nodes.length - 1;
FlowEditor._selectedNode = newIdx;
FlowEditor._openNodePanel(newIdx);
},
_removeNode(index) {
if (FlowEditor._nodes.length <= 2) {
Toast.warning('O pipeline precisa de pelo menos 2 passos');
return;
}
FlowEditor._nodes.splice(index, 1);
FlowEditor._nodes.forEach((n, i) => { n.index = i; });
if (FlowEditor._editingNode === index) FlowEditor._closePanel();
if (FlowEditor._selectedNode === index) FlowEditor._selectedNode = null;
FlowEditor._markDirty();
FlowEditor._render();
},
_swapNodes(a, b) {
if (b < 0 || b >= FlowEditor._nodes.length) return;
const tempX = FlowEditor._nodes[a].x;
const tempY = FlowEditor._nodes[a].y;
FlowEditor._nodes[a].x = FlowEditor._nodes[b].x;
FlowEditor._nodes[a].y = FlowEditor._nodes[b].y;
FlowEditor._nodes[b].x = tempX;
FlowEditor._nodes[b].y = tempY;
const temp = FlowEditor._nodes[a];
FlowEditor._nodes[a] = FlowEditor._nodes[b];
FlowEditor._nodes[b] = temp;
FlowEditor._nodes.forEach((n, i) => { n.index = i; });
FlowEditor._selectedNode = b;
FlowEditor._editingNode = b;
FlowEditor._markDirty();
FlowEditor._openNodePanel(b);
FlowEditor._render();
},
_markDirty() {
FlowEditor._dirty = true;
const btn = document.getElementById('flow-editor-save-btn');
if (btn) btn.classList.remove('flow-btn--disabled');
},
async _save() {
if (!FlowEditor._dirty) return;
const invalidNode = FlowEditor._nodes.find((n) => !n.agentId);
if (invalidNode) {
Toast.warning('Todos os passos devem ter um agente selecionado');
return;
}
if (FlowEditor._nodes.length < 2) {
Toast.warning('O pipeline precisa de pelo menos 2 passos');
return;
}
const steps = FlowEditor._nodes.map((n) => ({
agentId: n.agentId,
inputTemplate: n.inputTemplate || '',
requiresApproval: !!n.requiresApproval,
}));
try {
await API.pipelines.update(FlowEditor._pipelineId, {
name: FlowEditor._pipeline.name,
description: FlowEditor._pipeline.description,
steps,
});
FlowEditor._dirty = false;
const btn = document.getElementById('flow-editor-save-btn');
if (btn) btn.classList.add('flow-btn--disabled');
Toast.success('Pipeline atualizado com sucesso');
if (typeof PipelinesUI !== 'undefined') PipelinesUI.load();
} catch (err) {
Toast.error('Erro ao salvar: ' + err.message);
}
},
_close() {
if (FlowEditor._dirty) {
const leave = confirm('Existem alterações não salvas. Deseja sair mesmo assim?');
if (!leave) return;
}
const overlay = FlowEditor._overlay;
if (!overlay) return;
overlay.classList.remove('active');
setTimeout(() => { overlay.hidden = true; }, 200);
FlowEditor._closePanel();
if (FlowEditor._resizeObserver) {
FlowEditor._resizeObserver.disconnect();
}
document.removeEventListener('keydown', FlowEditor._onKeyDown);
FlowEditor._editingNode = null;
FlowEditor._selectedNode = null;
FlowEditor._dragState = null;
FlowEditor._panStart = null;
},
};
window.FlowEditor = FlowEditor;

View File

@@ -7,7 +7,17 @@ const HistoryUI = {
_currentType: '', _currentType: '',
_currentStatus: '', _currentStatus: '',
_exportListenerAdded: false,
async load() { async load() {
if (!HistoryUI._exportListenerAdded) {
HistoryUI._exportListenerAdded = true;
const exportBtn = document.getElementById('history-export-csv');
if (exportBtn) {
exportBtn.addEventListener('click', () => API.executions.exportCsv());
}
}
const params = { limit: HistoryUI.pageSize, offset: HistoryUI.page * HistoryUI.pageSize }; const params = { limit: HistoryUI.pageSize, offset: HistoryUI.page * HistoryUI.pageSize };
if (HistoryUI._currentType) params.type = HistoryUI._currentType; if (HistoryUI._currentType) params.type = HistoryUI._currentType;
if (HistoryUI._currentStatus) params.status = HistoryUI._currentStatus; if (HistoryUI._currentStatus) params.status = HistoryUI._currentStatus;
@@ -38,12 +48,12 @@ const HistoryUI = {
<p class="empty-state-text">O histórico de execuções aparecerá aqui.</p> <p class="empty-state-text">O histórico de execuções aparecerá aqui.</p>
</div> </div>
`; `;
if (window.lucide) lucide.createIcons({ nodes: [container] }); Utils.refreshIcons(container);
return; return;
} }
container.innerHTML = HistoryUI.executions.map((exec) => HistoryUI._renderCard(exec)).join(''); container.innerHTML = HistoryUI.executions.map((exec) => HistoryUI._renderCard(exec)).join('');
if (window.lucide) lucide.createIcons({ nodes: [container] }); Utils.refreshIcons(container);
}, },
_renderCard(exec) { _renderCard(exec) {
@@ -55,9 +65,10 @@ const HistoryUI = {
const name = exec.type === 'pipeline' const name = exec.type === 'pipeline'
? (exec.pipelineName || 'Pipeline') ? (exec.pipelineName || 'Pipeline')
: (exec.agentName || 'Agente'); : (exec.agentName || 'Agente');
const task = exec.type === 'pipeline' const taskRaw = exec.type === 'pipeline'
? (exec.input || '') ? (exec.input || '')
: (exec.task || ''); : (exec.task || '');
const task = taskRaw.length > 150 ? taskRaw.slice(0, 150) + '…' : taskRaw;
const date = HistoryUI._formatDate(exec.startedAt); const date = HistoryUI._formatDate(exec.startedAt);
const duration = HistoryUI._formatDuration(exec.startedAt, exec.endedAt); const duration = HistoryUI._formatDuration(exec.startedAt, exec.endedAt);
const cost = exec.costUsd || exec.totalCostUsd || 0; const cost = exec.costUsd || exec.totalCostUsd || 0;
@@ -70,28 +81,36 @@ const HistoryUI = {
<div class="history-card-header"> <div class="history-card-header">
<div class="history-card-identity"> <div class="history-card-identity">
${typeBadge} ${typeBadge}
<span class="history-card-name">${HistoryUI._escapeHtml(name)}</span> <span class="history-card-name">${Utils.escapeHtml(name)}</span>
</div>
<div class="history-card-status">
${statusBadge} ${statusBadge}
<span class="history-card-date">${date}</span>
</div> </div>
</div> </div>
<div class="history-card-meta"> <div class="history-card-task" title="${Utils.escapeHtml(taskRaw)}">${Utils.escapeHtml(task)}</div>
<span class="history-card-task">${HistoryUI._escapeHtml(task)}</span> <div class="history-card-info">
<span class="history-card-duration-group"> <span class="history-card-date">
<span class="history-card-duration"> <i data-lucide="calendar" aria-hidden="true"></i>
<i data-lucide="clock" aria-hidden="true"></i> ${date}
${duration}
</span>
${costHtml}
</span> </span>
<span class="history-card-duration">
<i data-lucide="clock" aria-hidden="true"></i>
${duration}
</span>
${costHtml}
</div> </div>
<div class="history-card-actions"> <div class="history-card-actions">
<button class="btn btn-ghost btn-sm" data-action="view-execution" data-id="${exec.id}" type="button"> <button class="btn btn-ghost btn-sm" data-action="view-execution" data-id="${exec.id}" type="button">
<i data-lucide="eye"></i> <i data-lucide="eye"></i>
Ver detalhes Ver detalhes
</button> </button>
${(exec.status === 'error' && exec.type === 'pipeline') ? `
<button class="btn btn-ghost btn-sm" data-action="resume-pipeline" data-id="${exec.id}" type="button" title="Retomar do passo ${(exec.failedAtStep || 0) + 1}">
<i data-lucide="play"></i>
Retomar
</button>` : ''}
${(exec.status === 'error' || exec.status === 'canceled') ? `
<button class="btn btn-ghost btn-sm" data-action="retry" data-id="${exec.id}" type="button" title="Reexecutar">
<i data-lucide="refresh-cw"></i>
</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"> <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> <i data-lucide="trash-2"></i>
</button> </button>
@@ -132,7 +151,7 @@ const HistoryUI = {
</div> </div>
`; `;
if (window.lucide) lucide.createIcons({ nodes: [container] }); Utils.refreshIcons(container);
document.getElementById('history-prev-btn')?.addEventListener('click', () => { document.getElementById('history-prev-btn')?.addEventListener('click', () => {
HistoryUI.page--; HistoryUI.page--;
@@ -172,7 +191,11 @@ const HistoryUI = {
: HistoryUI._renderAgentDetail(exec); : HistoryUI._renderAgentDetail(exec);
Modal.open('execution-detail-modal-overlay'); Modal.open('execution-detail-modal-overlay');
if (window.lucide) lucide.createIcons({ nodes: [content] }); Utils.refreshIcons(content);
content.querySelector('[data-action="download-result-md"]')?.addEventListener('click', () => {
HistoryUI._downloadResultMd(exec);
});
content.querySelectorAll('.pipeline-step-prompt-toggle').forEach((btn) => { content.querySelectorAll('.pipeline-step-prompt-toggle').forEach((btn) => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
@@ -195,18 +218,24 @@ const HistoryUI = {
const endDate = exec.endedAt ? HistoryUI._formatDate(exec.endedAt) : '—'; const endDate = exec.endedAt ? HistoryUI._formatDate(exec.endedAt) : '—';
const resultBlock = exec.result const resultBlock = exec.result
? `<div class="execution-result" role="region" aria-label="Resultado da execução">${HistoryUI._escapeHtml(exec.result)}</div>` ? `<div class="execution-result" role="region" aria-label="Resultado da execução">${Utils.escapeHtml(exec.result)}</div>`
: ''; : '';
const errorBlock = exec.error const errorBlock = exec.error
? `<div class="execution-result execution-result--error" role="alert">${HistoryUI._escapeHtml(exec.error)}</div>` ? `<div class="execution-result execution-result--error" role="alert">${Utils.escapeHtml(exec.error)}</div>`
: ''; : '';
return ` return `
${exec.result ? `
<div class="report-actions">
<button class="btn btn-ghost btn-sm" data-action="download-result-md" type="button">
<i data-lucide="download"></i> Download .md
</button>
</div>` : ''}
<div class="execution-detail-meta"> <div class="execution-detail-meta">
<div class="execution-detail-row"> <div class="execution-detail-row">
<span class="execution-detail-label">Agente</span> <span class="execution-detail-label">Agente</span>
<span class="execution-detail-value">${HistoryUI._escapeHtml(exec.agentName || exec.agentId || '—')}</span> <span class="execution-detail-value">${Utils.escapeHtml(exec.agentName || exec.agentId || '—')}</span>
</div> </div>
<div class="execution-detail-row"> <div class="execution-detail-row">
<span class="execution-detail-label">Status</span> <span class="execution-detail-label">Status</span>
@@ -243,7 +272,7 @@ const HistoryUI = {
${exec.task ? ` ${exec.task ? `
<div class="execution-detail-section"> <div class="execution-detail-section">
<h3 class="execution-detail-section-title">Tarefa</h3> <h3 class="execution-detail-section-title">Tarefa</h3>
<p class="execution-detail-task">${HistoryUI._escapeHtml(exec.task)}</p> <p class="execution-detail-task">${Utils.escapeHtml(exec.task)}</p>
</div>` : ''} </div>` : ''}
${resultBlock ? ` ${resultBlock ? `
<div class="execution-detail-section"> <div class="execution-detail-section">
@@ -279,7 +308,7 @@ const HistoryUI = {
<div class="pipeline-step-detail"> <div class="pipeline-step-detail">
<div class="pipeline-step-header"> <div class="pipeline-step-header">
<div class="pipeline-step-identity"> <div class="pipeline-step-identity">
<span class="pipeline-step-agent">${HistoryUI._escapeHtml(step.agentName || step.agentId || 'Agente')}</span> <span class="pipeline-step-agent">${Utils.escapeHtml(step.agentName || step.agentId || 'Agente')}</span>
${HistoryUI._statusBadge(step.status)} ${HistoryUI._statusBadge(step.status)}
</div> </div>
<span class="pipeline-step-meta-group"> <span class="pipeline-step-meta-group">
@@ -297,13 +326,13 @@ const HistoryUI = {
Prompt utilizado Prompt utilizado
</button> </button>
<div class="pipeline-step-prompt-body" hidden> <div class="pipeline-step-prompt-body" hidden>
<div class="execution-result execution-result--prompt">${HistoryUI._escapeHtml(step.prompt)}</div> <div class="execution-result execution-result--prompt">${Utils.escapeHtml(step.prompt)}</div>
</div> </div>
</div>` : ''} </div>` : ''}
${step.result ? ` ${step.result ? `
<div class="pipeline-step-result"> <div class="pipeline-step-result">
<span class="pipeline-step-result-label">Resultado</span> <span class="pipeline-step-result-label">Resultado</span>
<div class="execution-result">${HistoryUI._escapeHtml(step.result)}</div> <div class="execution-result">${Utils.escapeHtml(step.result)}</div>
</div>` : ''} </div>` : ''}
${step.status === 'error' ? ` ${step.status === 'error' ? `
<div class="execution-result execution-result--error">Passo falhou.</div>` : ''} <div class="execution-result execution-result--error">Passo falhou.</div>` : ''}
@@ -312,11 +341,18 @@ const HistoryUI = {
`; `;
}).join(''); }).join('');
const hasResults = steps.some(s => s.result);
return ` return `
${hasResults ? `
<div class="report-actions">
<button class="btn btn-ghost btn-sm" data-action="download-result-md" type="button">
<i data-lucide="download"></i> Download .md
</button>
</div>` : ''}
<div class="execution-detail-meta"> <div class="execution-detail-meta">
<div class="execution-detail-row"> <div class="execution-detail-row">
<span class="execution-detail-label">Pipeline</span> <span class="execution-detail-label">Pipeline</span>
<span class="execution-detail-value">${HistoryUI._escapeHtml(exec.pipelineName || exec.pipelineId || '—')}</span> <span class="execution-detail-value">${Utils.escapeHtml(exec.pipelineName || exec.pipelineId || '—')}</span>
</div> </div>
<div class="execution-detail-row"> <div class="execution-detail-row">
<span class="execution-detail-label">Status</span> <span class="execution-detail-label">Status</span>
@@ -343,7 +379,7 @@ const HistoryUI = {
${exec.input ? ` ${exec.input ? `
<div class="execution-detail-section"> <div class="execution-detail-section">
<h3 class="execution-detail-section-title">Input Inicial</h3> <h3 class="execution-detail-section-title">Input Inicial</h3>
<p class="execution-detail-task">${HistoryUI._escapeHtml(exec.input)}</p> <p class="execution-detail-task">${Utils.escapeHtml(exec.input)}</p>
</div>` : ''} </div>` : ''}
${steps.length > 0 ? ` ${steps.length > 0 ? `
<div class="execution-detail-section"> <div class="execution-detail-section">
@@ -355,11 +391,61 @@ const HistoryUI = {
${exec.error ? ` ${exec.error ? `
<div class="execution-detail-section"> <div class="execution-detail-section">
<h3 class="execution-detail-section-title">Erro</h3> <h3 class="execution-detail-section-title">Erro</h3>
<div class="execution-result execution-result--error">${HistoryUI._escapeHtml(exec.error)}</div> <div class="execution-result execution-result--error">${Utils.escapeHtml(exec.error)}</div>
</div>` : ''} </div>` : ''}
`; `;
}, },
_downloadResultMd(exec) {
let md = '';
const name = exec.type === 'pipeline'
? (exec.pipelineName || 'Pipeline')
: (exec.agentName || 'Agente');
if (exec.type === 'pipeline') {
md += `# ${name}\n\n`;
const steps = Array.isArray(exec.steps) ? exec.steps : [];
steps.forEach((step, i) => {
md += `## Passo ${i + 1}${step.agentName || 'Agente'}\n\n`;
if (step.result) md += `${step.result}\n\n`;
});
} else {
md += exec.result || '';
}
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
const filename = `${slug}-${new Date(exec.startedAt || Date.now()).toISOString().slice(0, 10)}.md`;
const blob = new Blob([md], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
Toast.success('Download iniciado');
},
async resumePipeline(executionId) {
try {
await API.pipelines.resume(executionId);
Toast.info('Pipeline retomado');
App.navigateTo('terminal');
} catch (err) {
Toast.error(`Erro ao retomar pipeline: ${err.message}`);
}
},
async retryExecution(id) {
try {
await API.executions.retry(id);
Toast.success('Execução reiniciada');
App.navigateTo('terminal');
} catch (err) {
Toast.error(`Erro ao reexecutar: ${err.message}`);
}
},
async deleteExecution(id) { async deleteExecution(id) {
const confirmed = await Modal.confirm( const confirmed = await Modal.confirm(
'Excluir execução', 'Excluir execução',
@@ -435,15 +521,6 @@ const HistoryUI = {
}); });
}, },
_escapeHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
},
}; };
window.HistoryUI = HistoryUI; window.HistoryUI = HistoryUI;

View File

@@ -0,0 +1,153 @@
const NotificationsUI = {
notifications: [],
unreadCount: 0,
pollInterval: null,
init() {
this.setupEventListeners();
this.startPolling();
},
setupEventListeners() {
const bell = document.getElementById('notification-bell');
const panel = document.getElementById('notification-panel');
if (bell) {
bell.addEventListener('click', (e) => {
e.stopPropagation();
panel.classList.toggle('hidden');
if (!panel.classList.contains('hidden')) this.load();
});
}
document.addEventListener('click', (e) => {
if (panel && !panel.contains(e.target) && e.target !== bell) {
panel.classList.add('hidden');
}
});
const markAllBtn = document.getElementById('mark-all-read');
if (markAllBtn) {
markAllBtn.addEventListener('click', () => this.markAllRead());
}
const clearBtn = document.getElementById('clear-notifications');
if (clearBtn) {
clearBtn.addEventListener('click', () => this.clearAll());
}
},
startPolling() {
this.pollInterval = setInterval(() => this.loadCount(), 15000);
this.loadCount();
},
async loadCount() {
try {
const data = await API.request('GET', '/notifications');
this.unreadCount = data.unreadCount || 0;
this.updateBadge();
} catch (e) {}
},
async load() {
try {
const data = await API.request('GET', '/notifications');
this.notifications = data.notifications || [];
this.unreadCount = data.unreadCount || 0;
this.updateBadge();
this.render();
} catch (e) {
console.error('Erro ao carregar notificações:', e);
}
},
updateBadge() {
const badge = document.getElementById('notification-badge');
if (!badge) return;
if (this.unreadCount > 0) {
badge.textContent = this.unreadCount > 99 ? '99+' : this.unreadCount;
badge.classList.remove('hidden');
} else {
badge.classList.add('hidden');
}
},
render() {
const list = document.getElementById('notification-list');
if (!list) return;
if (this.notifications.length === 0) {
list.innerHTML = '<div class="notification-empty">Nenhuma notificação</div>';
return;
}
list.innerHTML = this.notifications.map(n => {
const iconClass = n.type === 'success' ? 'success' : n.type === 'error' ? 'error' : 'info';
const icon = n.type === 'success' ? '✓' : n.type === 'error' ? '✕' : '';
const time = this.timeAgo(n.createdAt);
const unread = n.read ? '' : ' unread';
return `<div class="notification-item${unread}" data-id="${n.id}">
<div class="notification-item-icon ${iconClass}">${icon}</div>
<div class="notification-item-content">
<div class="notification-item-title">${Utils.escapeHtml(n.title)}</div>
<div class="notification-item-message">${Utils.escapeHtml(n.message)}</div>
<div class="notification-item-time">${time}</div>
</div>
</div>`;
}).join('');
list.querySelectorAll('.notification-item').forEach(item => {
item.addEventListener('click', () => this.markAsRead(item.dataset.id));
});
},
async markAsRead(id) {
try {
await API.request('POST', `/notifications/${id}/read`);
const n = this.notifications.find(n => n.id === id);
if (n) n.read = true;
this.unreadCount = Math.max(0, this.unreadCount - 1);
this.updateBadge();
this.render();
} catch (e) {}
},
async markAllRead() {
try {
await API.request('POST', '/notifications/read-all');
this.notifications.forEach(n => n.read = true);
this.unreadCount = 0;
this.updateBadge();
this.render();
} catch (e) {}
},
async clearAll() {
try {
await API.request('DELETE', '/notifications');
this.notifications = [];
this.unreadCount = 0;
this.updateBadge();
this.render();
} catch (e) {}
},
timeAgo(dateStr) {
const now = new Date();
const date = new Date(dateStr);
const diff = Math.floor((now - date) / 1000);
if (diff < 60) return 'agora';
if (diff < 3600) return `${Math.floor(diff / 60)}min atrás`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h atrás`;
return `${Math.floor(diff / 86400)}d atrás`;
},
showBrowserNotification(title, body) {
if (Notification.permission === 'granted') {
new Notification(title, { body, icon: '/favicon.ico' });
}
}
};
window.NotificationsUI = NotificationsUI;

View File

@@ -44,7 +44,7 @@ const PipelinesUI = {
if (!emptyState) { if (!emptyState) {
grid.insertAdjacentHTML('beforeend', PipelinesUI.renderEmpty()); grid.insertAdjacentHTML('beforeend', PipelinesUI.renderEmpty());
} }
if (window.lucide) lucide.createIcons({ nodes: [grid] }); Utils.refreshIcons(grid);
return; return;
} }
@@ -59,7 +59,7 @@ const PipelinesUI = {
grid.appendChild(fragment); grid.appendChild(fragment);
if (window.lucide) lucide.createIcons({ nodes: [grid] }); Utils.refreshIcons(grid);
}, },
renderEmpty() { renderEmpty() {
@@ -83,8 +83,7 @@ const PipelinesUI = {
const stepCount = steps.length; const stepCount = steps.length;
const flowHtml = steps.map((step, index) => { const flowHtml = steps.map((step, index) => {
const agentName = step.agentName || step.agentId || 'Agente'; const agentName = Utils.escapeHtml(step.agentName || step.agentId || 'Agente');
const isLast = index === steps.length - 1;
const approvalIcon = step.requiresApproval && index > 0 const approvalIcon = step.requiresApproval && index > 0
? '<i data-lucide="shield-check" style="width:10px;height:10px;color:var(--warning)"></i> ' ? '<i data-lucide="shield-check" style="width:10px;height:10px;color:var(--warning)"></i> '
: ''; : '';
@@ -93,7 +92,6 @@ const PipelinesUI = {
<span class="pipeline-step-number">${index + 1}</span> <span class="pipeline-step-number">${index + 1}</span>
${approvalIcon}${agentName} ${approvalIcon}${agentName}
</span> </span>
${!isLast ? '<span class="pipeline-flow-arrow">→</span>' : ''}
`; `;
}).join(''); }).join('');
@@ -102,12 +100,14 @@ const PipelinesUI = {
<div class="agent-card-body"> <div class="agent-card-body">
<div class="agent-card-top"> <div class="agent-card-top">
<div class="agent-info"> <div class="agent-info">
<h3 class="agent-name">${pipeline.name || 'Sem nome'}</h3> <h3 class="agent-name">${Utils.escapeHtml(pipeline.name || 'Sem nome')}</h3>
<span class="badge badge-active">${stepCount} ${stepCount === 1 ? 'passo' : 'passos'}</span> <span class="badge badge-active">${stepCount} ${stepCount === 1 ? 'passo' : 'passos'}</span>
</div> </div>
</div> </div>
${pipeline.description ? `<p class="agent-description">${pipeline.description}</p>` : ''} ${pipeline.description ? `<p class="agent-description">${Utils.escapeHtml(pipeline.description)}</p>` : ''}
${pipeline.workingDirectory ? `<div class="pipeline-workdir-badge"><i data-lucide="folder" style="width:12px;height:12px"></i> <code>${Utils.escapeHtml(pipeline.workingDirectory)}</code></div>` : ''}
<div class="pipeline-flow"> <div class="pipeline-flow">
${flowHtml || '<span class="agent-description">Nenhum passo configurado</span>'} ${flowHtml || '<span class="agent-description">Nenhum passo configurado</span>'}
@@ -119,23 +119,29 @@ const PipelinesUI = {
<i data-lucide="play"></i> <i data-lucide="play"></i>
Executar Executar
</button> </button>
<button class="btn btn-ghost btn-sm" data-action="edit-pipeline" data-id="${pipeline.id}"> <div class="agent-actions-icons">
<i data-lucide="pencil"></i> <button class="btn btn-ghost btn-icon btn-sm" data-action="flow-pipeline" data-id="${pipeline.id}" title="Editor de fluxo">
Editar <i data-lucide="workflow"></i>
</button> </button>
<button class="btn btn-ghost btn-icon btn-sm btn-danger" data-action="delete-pipeline" data-id="${pipeline.id}" title="Excluir pipeline"> <button class="btn btn-ghost btn-icon btn-sm" data-action="edit-pipeline" data-id="${pipeline.id}" title="Editar pipeline">
<i data-lucide="trash-2"></i> <i data-lucide="pencil"></i>
</button> </button>
<button class="btn btn-ghost btn-icon btn-sm btn-danger" data-action="delete-pipeline" data-id="${pipeline.id}" title="Excluir pipeline">
<i data-lucide="trash-2"></i>
</button>
</div>
</div> </div>
</div> </div>
`; `;
}, },
_basePath: '/home/projetos/',
openCreateModal() { openCreateModal() {
PipelinesUI._editingId = null; PipelinesUI._editingId = null;
PipelinesUI._steps = [ PipelinesUI._steps = [
{ agentId: '', inputTemplate: '', requiresApproval: false }, { agentId: '', inputTemplate: '', description: '', promptMode: 'simple', requiresApproval: false },
{ agentId: '', inputTemplate: '', requiresApproval: false }, { agentId: '', inputTemplate: '', description: '', promptMode: 'simple', requiresApproval: false },
]; ];
const titleEl = document.getElementById('pipeline-modal-title'); const titleEl = document.getElementById('pipeline-modal-title');
@@ -150,6 +156,9 @@ const PipelinesUI = {
const descEl = document.getElementById('pipeline-description'); const descEl = document.getElementById('pipeline-description');
if (descEl) descEl.value = ''; if (descEl) descEl.value = '';
const workdirEl = document.getElementById('pipeline-workdir');
if (workdirEl) workdirEl.value = PipelinesUI._basePath;
PipelinesUI.renderSteps(); PipelinesUI.renderSteps();
Modal.open('pipeline-modal-overlay'); Modal.open('pipeline-modal-overlay');
}, },
@@ -160,7 +169,13 @@ const PipelinesUI = {
PipelinesUI._editingId = pipelineId; PipelinesUI._editingId = pipelineId;
PipelinesUI._steps = Array.isArray(pipeline.steps) PipelinesUI._steps = Array.isArray(pipeline.steps)
? pipeline.steps.map((s) => ({ agentId: s.agentId || '', inputTemplate: s.inputTemplate || '', requiresApproval: !!s.requiresApproval })) ? pipeline.steps.map((s) => ({
agentId: s.agentId || '',
inputTemplate: s.inputTemplate || '',
description: s.description || '',
promptMode: s.description ? 'simple' : 'advanced',
requiresApproval: !!s.requiresApproval,
}))
: []; : [];
const titleEl = document.getElementById('pipeline-modal-title'); const titleEl = document.getElementById('pipeline-modal-title');
@@ -175,6 +190,9 @@ const PipelinesUI = {
const descEl = document.getElementById('pipeline-description'); const descEl = document.getElementById('pipeline-description');
if (descEl) descEl.value = pipeline.description || ''; if (descEl) descEl.value = pipeline.description || '';
const workdirEl = document.getElementById('pipeline-workdir');
if (workdirEl) workdirEl.value = pipeline.workingDirectory || PipelinesUI._basePath;
PipelinesUI.renderSteps(); PipelinesUI.renderSteps();
Modal.open('pipeline-modal-overlay'); Modal.open('pipeline-modal-overlay');
} catch (err) { } catch (err) {
@@ -192,7 +210,7 @@ const PipelinesUI = {
} }
const agentOptions = PipelinesUI.agents const agentOptions = PipelinesUI.agents
.map((a) => `<option value="${a.id}">${a.agent_name || a.name}</option>`) .map((a) => `<option value="${a.id}">${Utils.escapeHtml(a.agent_name || a.name)}</option>`)
.join(''); .join('');
container.innerHTML = PipelinesUI._steps.map((step, index) => { container.innerHTML = PipelinesUI._steps.map((step, index) => {
@@ -211,6 +229,46 @@ const PipelinesUI = {
</label>` </label>`
: ''; : '';
const isSimple = step.promptMode !== 'advanced';
const inputContext = isFirst
? 'O input inicial do pipeline'
: 'O resultado (sumarizado) do passo anterior';
const promptHtml = isSimple
? `<textarea
class="textarea"
rows="2"
placeholder="Ex: Analise os requisitos e crie um plano técnico detalhado"
data-step-field="description"
data-step-index="${index}"
>${Utils.escapeHtml(step.description || '')}</textarea>
<div class="pipeline-step-hints">
<span class="pipeline-step-hint">
<i data-lucide="info" style="width:11px;height:11px"></i>
${inputContext} será injetado via <code>{{input}}</code> automaticamente no final.
</span>
<span class="pipeline-step-hint">
<i data-lucide="lightbulb" style="width:11px;height:11px"></i>
Dica: use <code>&lt;tags&gt;</code> XML para organizar melhor. Ex: <code>&lt;contexto&gt;</code> <code>&lt;regras&gt;</code> <code>&lt;formato_saida&gt;</code>
</span>
</div>`
: `<textarea
class="textarea"
rows="3"
placeholder="Use {{input}} para posicionar o output do passo anterior. Estruture com <tags> XML."
data-step-field="inputTemplate"
data-step-index="${index}"
>${Utils.escapeHtml(step.inputTemplate || '')}</textarea>
<div class="pipeline-step-hints">
<span class="pipeline-step-hint">
<i data-lucide="lightbulb" style="width:11px;height:11px"></i>
Dica: use <code>&lt;tags&gt;</code> XML para organizar. Ex: <code>&lt;contexto&gt;{{input}}&lt;/contexto&gt;</code> <code>&lt;regras&gt;</code> <code>&lt;formato_saida&gt;</code>
</span>
</div>`;
const modeIcon = isSimple ? 'code' : 'text';
const modeLabel = isSimple ? 'Avançado' : 'Simples';
return ` return `
<div class="pipeline-step-row" data-step-index="${index}"> <div class="pipeline-step-row" data-step-index="${index}">
<span class="pipeline-step-number-lg">${index + 1}</span> <span class="pipeline-step-number-lg">${index + 1}</span>
@@ -219,14 +277,14 @@ const PipelinesUI = {
<option value="">Selecionar agente...</option> <option value="">Selecionar agente...</option>
${agentOptions} ${agentOptions}
</select> </select>
<textarea ${promptHtml}
class="textarea" <div class="pipeline-step-footer">
rows="2" ${approvalHtml}
placeholder="{{input}} será substituído pelo output anterior" <button type="button" class="pipeline-mode-toggle" data-step-action="toggle-mode" data-step-index="${index}" title="Alternar entre modo simples e avançado">
data-step-field="inputTemplate" <i data-lucide="${modeIcon}" style="width:12px;height:12px"></i>
data-step-index="${index}" ${modeLabel}
>${step.inputTemplate || ''}</textarea> </button>
${approvalHtml} </div>
</div> </div>
<div class="pipeline-step-actions"> <div class="pipeline-step-actions">
<button class="btn btn-ghost btn-icon btn-sm" type="button" data-step-action="move-up" data-step-index="${index}" title="Mover para cima" ${isFirst ? 'disabled' : ''}> <button class="btn btn-ghost btn-icon btn-sm" type="button" data-step-action="move-up" data-step-index="${index}" title="Mover para cima" ${isFirst ? 'disabled' : ''}>
@@ -249,7 +307,7 @@ const PipelinesUI = {
select.value = PipelinesUI._steps[index].agentId || ''; select.value = PipelinesUI._steps[index].agentId || '';
}); });
if (window.lucide) lucide.createIcons({ nodes: [container] }); Utils.refreshIcons(container);
}, },
_syncStepsFromDOM() { _syncStepsFromDOM() {
@@ -269,9 +327,41 @@ const PipelinesUI = {
}); });
}, },
_generateTemplate(description, stepIndex) {
if (!description) return '';
if (stepIndex === 0) {
return `${description}\n\n{{input}}`;
}
return `${description}\n\nResultado do passo anterior:\n{{input}}`;
},
toggleMode(index) {
PipelinesUI._syncStepsFromDOM();
const step = PipelinesUI._steps[index];
if (!step) return;
if (step.promptMode === 'advanced') {
step.promptMode = 'simple';
if (step.inputTemplate && !step.description) {
step.description = step.inputTemplate
.replace(/\{\{input\}\}/g, '')
.replace(/Resultado do passo anterior:\s*/g, '')
.replace(/Input:\s*/g, '')
.trim();
}
} else {
step.promptMode = 'advanced';
if (step.description && !step.inputTemplate) {
step.inputTemplate = PipelinesUI._generateTemplate(step.description, index);
}
}
PipelinesUI.renderSteps();
},
addStep() { addStep() {
PipelinesUI._syncStepsFromDOM(); PipelinesUI._syncStepsFromDOM();
PipelinesUI._steps.push({ agentId: '', inputTemplate: '', requiresApproval: false }); PipelinesUI._steps.push({ agentId: '', inputTemplate: '', description: '', promptMode: 'simple', requiresApproval: false });
PipelinesUI.renderSteps(); PipelinesUI.renderSteps();
}, },
@@ -311,14 +401,29 @@ const PipelinesUI = {
return; return;
} }
const workingDirectory = document.getElementById('pipeline-workdir')?.value.trim() || '';
if (workingDirectory && !workingDirectory.startsWith('/')) {
Toast.warning('O diretório do projeto deve ser um caminho absoluto (começar com /)');
return;
}
const data = { const data = {
name, name,
description: document.getElementById('pipeline-description')?.value.trim() || '', description: document.getElementById('pipeline-description')?.value.trim() || '',
steps: PipelinesUI._steps.map((s) => ({ workingDirectory,
agentId: s.agentId, steps: PipelinesUI._steps.map((s, index) => {
inputTemplate: s.inputTemplate || '', const isSimple = s.promptMode !== 'advanced';
requiresApproval: !!s.requiresApproval, const inputTemplate = isSimple
})), ? PipelinesUI._generateTemplate(s.description, index)
: (s.inputTemplate || '');
return {
agentId: s.agentId,
inputTemplate,
description: isSimple ? (s.description || '') : '',
requiresApproval: !!s.requiresApproval,
};
}),
}; };
try { try {
@@ -367,7 +472,14 @@ const PipelinesUI = {
if (inputEl) inputEl.value = ''; if (inputEl) inputEl.value = '';
const workdirEl = document.getElementById('pipeline-execute-workdir'); const workdirEl = document.getElementById('pipeline-execute-workdir');
if (workdirEl) workdirEl.value = ''; if (workdirEl) workdirEl.value = (pipeline && pipeline.workingDirectory) || PipelinesUI._basePath;
if (App._pipelineDropzone) App._pipelineDropzone.reset();
const repoSelect = document.getElementById('pipeline-execute-repo');
if (repoSelect) { repoSelect.value = ''; repoSelect.dispatchEvent(new Event('change')); }
App._reposCache = null;
App._loadRepos('pipeline-execute-repo');
Modal.open('pipeline-execute-modal-overlay'); Modal.open('pipeline-execute-modal-overlay');
}, },
@@ -382,8 +494,24 @@ const PipelinesUI = {
return; return;
} }
if (workingDirectory && !workingDirectory.startsWith('/')) {
Toast.warning('O diretório de trabalho deve ser um caminho absoluto (começar com /)');
return;
}
try { try {
await API.pipelines.execute(pipelineId, input, workingDirectory); let contextFiles = null;
const dropzone = App._pipelineDropzone;
if (dropzone && dropzone.getFiles().length > 0) {
Toast.info('Fazendo upload dos arquivos...');
const uploadResult = await API.uploads.send(dropzone.getFiles());
contextFiles = uploadResult.files;
}
const repoName = document.getElementById('pipeline-execute-repo')?.value || '';
const repoBranch = document.getElementById('pipeline-execute-repo-branch')?.value || '';
await API.pipelines.execute(pipelineId, input, workingDirectory, contextFiles, repoName, repoBranch);
if (dropzone) dropzone.reset();
Modal.close('pipeline-execute-modal-overlay'); Modal.close('pipeline-execute-modal-overlay');
App.navigateTo('terminal'); App.navigateTo('terminal');
Toast.info('Pipeline iniciado'); Toast.info('Pipeline iniciado');

View File

@@ -28,7 +28,7 @@ const SchedulesUI = {
</td> </td>
</tr> </tr>
`; `;
if (window.lucide) lucide.createIcons({ nodes: [tbody] }); Utils.refreshIcons(tbody);
return; return;
} }
@@ -44,8 +44,8 @@ const SchedulesUI = {
return ` return `
<tr> <tr>
<td>${schedule.agentName || '—'}</td> <td>${Utils.escapeHtml(schedule.agentName || '—')}</td>
<td class="schedule-task-cell" title="${schedule.taskDescription || ''}">${schedule.taskDescription || '—'}</td> <td class="schedule-task-cell" title="${Utils.escapeHtml(schedule.taskDescription || '')}">${Utils.escapeHtml(schedule.taskDescription || '—')}</td>
<td> <td>
<code class="font-mono">${cronExpr}</code> <code class="font-mono">${cronExpr}</code>
</td> </td>
@@ -77,7 +77,7 @@ const SchedulesUI = {
`; `;
}).join(''); }).join('');
if (window.lucide) lucide.createIcons({ nodes: [tbody] }); Utils.refreshIcons(tbody);
}, },
filter(searchText, statusFilter) { filter(searchText, statusFilter) {
@@ -106,7 +106,7 @@ const SchedulesUI = {
select.innerHTML = '<option value="">Selecionar agente...</option>' + select.innerHTML = '<option value="">Selecionar agente...</option>' +
agents agents
.filter((a) => a.status === 'active') .filter((a) => a.status === 'active')
.map((a) => `<option value="${a.id}">${a.agent_name || a.name}</option>`) .map((a) => `<option value="${a.id}">${Utils.escapeHtml(a.agent_name || a.name)}</option>`)
.join(''); .join('');
} }
@@ -208,7 +208,11 @@ const SchedulesUI = {
if (!container) return; if (!container) return;
if (history.length === 0) { if (history.length === 0) {
container.innerHTML = '<p class="empty-state-desc">Nenhum disparo registrado</p>'; const hasSchedules = SchedulesUI.schedules.length > 0;
const msg = hasSchedules
? 'Nenhum disparo registrado ainda. As tarefas agendadas aparecerão aqui após a próxima execução.'
: 'Nenhum disparo registrado. Crie um agendamento para começar.';
container.innerHTML = `<p class="empty-state-desc">${msg}</p>`;
return; return;
} }
@@ -233,12 +237,12 @@ const SchedulesUI = {
const duration = SchedulesUI._formatDuration(exec.startedAt, exec.endedAt); const duration = SchedulesUI._formatDuration(exec.startedAt, exec.endedAt);
const cost = exec.costUsd || exec.totalCostUsd || 0; const cost = exec.costUsd || exec.totalCostUsd || 0;
const costStr = cost > 0 ? `$${cost.toFixed(4)}` : '—'; const costStr = cost > 0 ? `$${cost.toFixed(4)}` : '—';
const taskStr = SchedulesUI._escapeHtml(SchedulesUI._truncate(exec.task || '', 60)); const taskStr = Utils.escapeHtml(Utils.truncate(exec.task || '', 60));
return ` return `
<tr> <tr>
<td>${SchedulesUI._escapeHtml(exec.agentName || '—')}</td> <td>${Utils.escapeHtml(exec.agentName || '—')}</td>
<td title="${SchedulesUI._escapeHtml(exec.task || '')}">${taskStr}</td> <td title="${Utils.escapeHtml(exec.task || '')}">${taskStr}</td>
<td>${status}</td> <td>${status}</td>
<td>${date}</td> <td>${date}</td>
<td>${duration}</td> <td>${duration}</td>
@@ -256,7 +260,7 @@ const SchedulesUI = {
</div> </div>
`; `;
if (window.lucide) lucide.createIcons({ nodes: [container] }); Utils.refreshIcons(container);
}, },
_statusBadge(status) { _statusBadge(status) {
@@ -283,16 +287,6 @@ const SchedulesUI = {
return `${minutes}m ${seconds}s`; return `${minutes}m ${seconds}s`;
}, },
_escapeHtml(str) {
if (!str) return '';
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
},
_truncate(str, max) {
if (!str || str.length <= max) return str;
return str.slice(0, max) + '…';
},
cronToHuman(expression) { cronToHuman(expression) {
if (!expression) return '—'; if (!expression) return '—';

View File

@@ -8,11 +8,20 @@ const SettingsUI = {
SettingsUI.populateForm(settings); SettingsUI.populateForm(settings);
SettingsUI.populateSystemInfo(info); SettingsUI.populateSystemInfo(info);
SettingsUI.updateThemeInfo();
} catch (err) { } catch (err) {
Toast.error(`Erro ao carregar configurações: ${err.message}`); Toast.error(`Erro ao carregar configurações: ${err.message}`);
} }
}, },
updateThemeInfo() {
const themeEl = document.getElementById('info-current-theme');
if (themeEl) {
const theme = document.documentElement.getAttribute('data-theme') || 'dark';
themeEl.textContent = theme === 'dark' ? 'Escuro' : 'Claro';
}
},
populateForm(settings) { populateForm(settings) {
const fields = { const fields = {
'settings-default-model': settings.defaultModel || 'claude-sonnet-4-6', 'settings-default-model': settings.defaultModel || 'claude-sonnet-4-6',

View File

@@ -39,7 +39,7 @@ const TasksUI = {
container.appendChild(fragment); container.appendChild(fragment);
if (window.lucide) lucide.createIcons({ nodes: [container] }); Utils.refreshIcons(container);
}, },
filter(searchText, categoryFilter) { filter(searchText, categoryFilter) {
@@ -65,10 +65,10 @@ const TasksUI = {
return ` return `
<div class="task-card" data-task-id="${task.id}"> <div class="task-card" data-task-id="${task.id}">
<div class="task-card-header"> <div class="task-card-header">
<h4 class="task-card-name">${task.name}</h4> <h4 class="task-card-name">${Utils.escapeHtml(task.name)}</h4>
<span class="badge ${categoryClass}">${categoryLabel}</span> <span class="badge ${categoryClass}">${Utils.escapeHtml(categoryLabel)}</span>
</div> </div>
${task.description ? `<p class="task-card-description">${task.description}</p>` : ''} ${task.description ? `<p class="task-card-description" title="${Utils.escapeHtml(task.description)}">${Utils.escapeHtml(task.description.length > 240 ? task.description.slice(0, 240) + '…' : task.description)}</p>` : ''}
<div class="task-card-footer"> <div class="task-card-footer">
<span class="task-card-date"> <span class="task-card-date">
<i data-lucide="calendar"></i> <i data-lucide="calendar"></i>
@@ -117,7 +117,7 @@ const TasksUI = {
<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">${title}</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" value="${task.name || ''}"> <input type="text" id="task-inline-name" class="input" placeholder="Ex: Code Review de PR" required autocomplete="off" value="${Utils.escapeHtml(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>
@@ -133,7 +133,7 @@ const TasksUI = {
</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...">${task.description || ''}</textarea> <textarea id="task-inline-description" class="textarea" rows="2" placeholder="Descreva o objetivo desta tarefa...">${Utils.escapeHtml(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">${btnLabel}</button> <button class="btn btn--primary" id="btn-save-inline-task" type="button">${btnLabel}</button>
@@ -228,7 +228,7 @@ const TasksUI = {
const selectEl = document.getElementById('execute-agent-select'); const selectEl = document.getElementById('execute-agent-select');
if (selectEl) { if (selectEl) {
selectEl.innerHTML = '<option value="">Selecionar agente...</option>' + selectEl.innerHTML = '<option value="">Selecionar agente...</option>' +
activeAgents.map((a) => `<option value="${a.id}">${a.agent_name || a.name}</option>`).join(''); activeAgents.map((a) => `<option value="${a.id}">${Utils.escapeHtml(a.agent_name || a.name)}</option>`).join('');
selectEl.value = ''; selectEl.value = '';
} }

View File

@@ -5,6 +5,9 @@ const Terminal = {
executionFilter: null, executionFilter: null,
_processingInterval: null, _processingInterval: null,
_chatSession: null, _chatSession: null,
searchMatches: [],
searchIndex: -1,
_toolbarInitialized: false,
enableChat(agentId, agentName, sessionId) { enableChat(agentId, agentName, sessionId) {
Terminal._chatSession = { agentId, agentName, sessionId }; Terminal._chatSession = { agentId, agentName, sessionId };
@@ -83,7 +86,121 @@ const Terminal = {
if (output) output.scrollTop = output.scrollHeight; if (output) output.scrollTop = output.scrollHeight;
}, },
initToolbar() {
if (Terminal._toolbarInitialized) return;
Terminal._toolbarInitialized = true;
const searchToggle = document.getElementById('terminal-search-toggle');
const searchBar = document.getElementById('terminal-search-bar');
const searchInput = document.getElementById('terminal-search-input');
const searchClose = document.getElementById('terminal-search-close');
const searchPrev = document.getElementById('terminal-search-prev');
const searchNext = document.getElementById('terminal-search-next');
const downloadBtn = document.getElementById('terminal-download');
const copyBtn = document.getElementById('terminal-copy');
const autoScrollCheck = document.getElementById('terminal-autoscroll');
if (searchToggle && searchBar) {
searchToggle.addEventListener('click', () => {
searchBar.classList.toggle('hidden');
if (!searchBar.classList.contains('hidden') && searchInput) searchInput.focus();
});
}
if (searchInput) {
searchInput.addEventListener('input', () => Terminal.search(searchInput.value));
}
if (searchClose && searchBar) {
searchClose.addEventListener('click', () => {
searchBar.classList.add('hidden');
Terminal.clearSearch();
});
}
if (searchPrev) searchPrev.addEventListener('click', () => Terminal.searchPrev());
if (searchNext) searchNext.addEventListener('click', () => Terminal.searchNext());
if (downloadBtn) {
downloadBtn.addEventListener('click', () => Terminal.downloadOutput());
}
if (copyBtn) {
copyBtn.addEventListener('click', () => Terminal.copyOutput());
}
if (autoScrollCheck) {
autoScrollCheck.addEventListener('change', (e) => {
Terminal.autoScroll = e.target.checked;
});
}
},
search(query) {
const output = document.getElementById('terminal-output');
if (!output || !query) { Terminal.clearSearch(); return; }
const text = output.textContent;
Terminal.searchMatches = [];
Terminal.searchIndex = -1;
const regex = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
let match;
while ((match = regex.exec(text)) !== null) {
Terminal.searchMatches.push(match.index);
}
const countEl = document.getElementById('terminal-search-count');
if (countEl) countEl.textContent = Terminal.searchMatches.length > 0 ? `0/${Terminal.searchMatches.length}` : '0/0';
if (Terminal.searchMatches.length > 0) Terminal.searchNext();
},
searchNext() {
if (Terminal.searchMatches.length === 0) return;
Terminal.searchIndex = (Terminal.searchIndex + 1) % Terminal.searchMatches.length;
const countEl = document.getElementById('terminal-search-count');
if (countEl) countEl.textContent = `${Terminal.searchIndex + 1}/${Terminal.searchMatches.length}`;
},
searchPrev() {
if (Terminal.searchMatches.length === 0) return;
Terminal.searchIndex = Terminal.searchIndex <= 0 ? Terminal.searchMatches.length - 1 : Terminal.searchIndex - 1;
const countEl = document.getElementById('terminal-search-count');
if (countEl) countEl.textContent = `${Terminal.searchIndex + 1}/${Terminal.searchMatches.length}`;
},
clearSearch() {
Terminal.searchMatches = [];
Terminal.searchIndex = -1;
const countEl = document.getElementById('terminal-search-count');
if (countEl) countEl.textContent = '0/0';
},
downloadOutput() {
const output = document.getElementById('terminal-output');
if (!output) return;
const text = output.textContent;
const blob = new Blob([text], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `terminal_${new Date().toISOString().replace(/[:.]/g, '-')}.txt`;
a.click();
URL.revokeObjectURL(url);
if (typeof Toast !== 'undefined') Toast.success('Saída baixada');
},
copyOutput() {
const output = document.getElementById('terminal-output');
if (!output) return;
navigator.clipboard.writeText(output.textContent).then(() => {
if (typeof Toast !== 'undefined') Toast.success('Saída copiada');
});
},
render() { render() {
Terminal.initToolbar();
const output = document.getElementById('terminal-output'); const output = document.getElementById('terminal-output');
if (!output) return; if (!output) return;
@@ -102,7 +219,7 @@ const Terminal = {
const html = lines.map((line) => { const html = lines.map((line) => {
const typeClass = line.type && line.type !== 'default' ? ' ' + line.type : ''; const typeClass = line.type && line.type !== 'default' ? ' ' + line.type : '';
const escaped = Terminal._escapeHtml(line.content); const escaped = Utils.escapeHtml(line.content);
const formatted = escaped.replace(/\n/g, '<br>'); const formatted = escaped.replace(/\n/g, '<br>');
return `<div class="terminal-line${typeClass}"> return `<div class="terminal-line${typeClass}">
@@ -120,14 +237,6 @@ const Terminal = {
if (Terminal.autoScroll) Terminal.scrollToBottom(); if (Terminal.autoScroll) Terminal.scrollToBottom();
}, },
_escapeHtml(text) {
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
},
}; };
window.Terminal = Terminal; window.Terminal = Terminal;

View File

@@ -1,9 +1,9 @@
const Toast = { const Toast = {
iconMap: { iconMap: {
success: 'check-circle', success: 'circle-check',
error: 'x-circle', error: 'circle-x',
info: 'info', info: 'info',
warning: 'alert-triangle', warning: 'triangle-alert',
}, },
colorMap: { colorMap: {
@@ -35,9 +35,7 @@ const Toast = {
container.appendChild(toast); container.appendChild(toast);
if (window.lucide) { Utils.refreshIcons(toast);
lucide.createIcons({ nodes: [toast] });
}
requestAnimationFrame(() => { requestAnimationFrame(() => {
toast.classList.add('toast-show'); toast.classList.add('toast-show');

View File

@@ -44,12 +44,12 @@ const WebhooksUI = {
<p class="empty-state-desc">Crie webhooks para disparar agentes ou pipelines via HTTP.</p> <p class="empty-state-desc">Crie webhooks para disparar agentes ou pipelines via HTTP.</p>
</div> </div>
`; `;
if (window.lucide) lucide.createIcons({ nodes: [container] }); Utils.refreshIcons(container);
return; return;
} }
container.innerHTML = webhooks.map((w) => WebhooksUI._renderCard(w)).join(''); container.innerHTML = webhooks.map((w) => WebhooksUI._renderCard(w)).join('');
if (window.lucide) lucide.createIcons({ nodes: [container] }); Utils.refreshIcons(container);
}, },
_renderCard(webhook) { _renderCard(webhook) {
@@ -71,7 +71,7 @@ const WebhooksUI = {
<article class="webhook-card"> <article class="webhook-card">
<div class="webhook-card-header"> <div class="webhook-card-header">
<div class="webhook-card-identity"> <div class="webhook-card-identity">
<span class="webhook-card-name">${WebhooksUI._escapeHtml(webhook.name)}</span> <span class="webhook-card-name">${Utils.escapeHtml(webhook.name)}</span>
${typeBadge} ${typeBadge}
${statusBadge} ${statusBadge}
</div> </div>
@@ -79,6 +79,12 @@ const WebhooksUI = {
<button class="btn btn-ghost btn-sm btn-icon" data-action="toggle-webhook" data-id="${webhook.id}" title="${webhook.active ? 'Desativar' : 'Ativar'}"> <button class="btn btn-ghost btn-sm btn-icon" data-action="toggle-webhook" data-id="${webhook.id}" title="${webhook.active ? 'Desativar' : 'Ativar'}">
<i data-lucide="${webhook.active ? 'pause' : 'play'}"></i> <i data-lucide="${webhook.active ? 'pause' : 'play'}"></i>
</button> </button>
<button class="btn btn-ghost btn-sm btn-icon" data-action="edit-webhook" data-id="${webhook.id}" title="Editar">
<i data-lucide="pencil"></i>
</button>
<button class="btn btn-ghost btn-sm btn-icon" data-action="test-webhook" data-id="${webhook.id}" title="Testar">
<i data-lucide="zap"></i>
</button>
<button class="btn btn-ghost btn-sm btn-icon btn-danger" data-action="delete-webhook" data-id="${webhook.id}" title="Excluir"> <button class="btn btn-ghost btn-sm btn-icon btn-danger" data-action="delete-webhook" data-id="${webhook.id}" title="Excluir">
<i data-lucide="trash-2"></i> <i data-lucide="trash-2"></i>
</button> </button>
@@ -87,7 +93,7 @@ const WebhooksUI = {
<div class="webhook-card-body"> <div class="webhook-card-body">
<div class="webhook-card-target"> <div class="webhook-card-target">
<span class="webhook-card-label">Destino</span> <span class="webhook-card-label">Destino</span>
<span class="webhook-card-value">${WebhooksUI._escapeHtml(targetName)}</span> <span class="webhook-card-value">${Utils.escapeHtml(targetName)}</span>
</div> </div>
<div class="webhook-card-url"> <div class="webhook-card-url">
<span class="webhook-card-label">URL</span> <span class="webhook-card-label">URL</span>
@@ -141,19 +147,59 @@ const WebhooksUI = {
WebhooksUI._updateTargetSelect('agent'); WebhooksUI._updateTargetSelect('agent');
} }
const submitBtn = document.getElementById('webhook-form-submit');
if (submitBtn) submitBtn.dataset.editId = '';
Modal.open('webhook-modal-overlay'); Modal.open('webhook-modal-overlay');
}, },
openEditModal(webhookId) {
const webhook = WebhooksUI.webhooks.find(w => w.id === webhookId);
if (!webhook) return;
const titleEl = document.getElementById('webhook-modal-title');
if (titleEl) titleEl.textContent = 'Editar Webhook';
const nameEl = document.getElementById('webhook-name');
if (nameEl) nameEl.value = webhook.name || '';
const typeEl = document.getElementById('webhook-target-type');
if (typeEl) {
typeEl.value = webhook.targetType || 'agent';
WebhooksUI._updateTargetSelect(webhook.targetType || 'agent');
}
const targetEl = document.getElementById('webhook-target-id');
if (targetEl) targetEl.value = webhook.targetId || '';
const submitBtn = document.getElementById('webhook-form-submit');
if (submitBtn) submitBtn.dataset.editId = webhookId;
Modal.open('webhook-modal-overlay');
},
async test(webhookId) {
try {
const result = await API.webhooks.test(webhookId);
Toast.success(result.message || 'Webhook disparado com sucesso');
if (result.executionId || result.pipelineId) {
App.navigateTo('terminal');
}
} catch (err) {
Toast.error(`Erro ao testar webhook: ${err.message}`);
}
},
_updateTargetSelect(targetType) { _updateTargetSelect(targetType) {
const selectEl = document.getElementById('webhook-target-id'); const selectEl = document.getElementById('webhook-target-id');
if (!selectEl) return; if (!selectEl) return;
if (targetType === 'agent') { if (targetType === 'agent') {
selectEl.innerHTML = '<option value="">Selecionar agente...</option>' + selectEl.innerHTML = '<option value="">Selecionar agente...</option>' +
WebhooksUI.agents.map((a) => `<option value="${a.id}">${a.agent_name || a.name}</option>`).join(''); WebhooksUI.agents.map((a) => `<option value="${a.id}">${Utils.escapeHtml(a.agent_name || a.name)}</option>`).join('');
} else { } else {
selectEl.innerHTML = '<option value="">Selecionar pipeline...</option>' + selectEl.innerHTML = '<option value="">Selecionar pipeline...</option>' +
WebhooksUI.pipelines.map((p) => `<option value="${p.id}">${p.name}</option>`).join(''); WebhooksUI.pipelines.map((p) => `<option value="${p.id}">${Utils.escapeHtml(p.name)}</option>`).join('');
} }
}, },
@@ -161,17 +207,25 @@ const WebhooksUI = {
const name = document.getElementById('webhook-name')?.value.trim(); const name = document.getElementById('webhook-name')?.value.trim();
const targetType = document.getElementById('webhook-target-type')?.value; const targetType = document.getElementById('webhook-target-type')?.value;
const targetId = document.getElementById('webhook-target-id')?.value; const targetId = document.getElementById('webhook-target-id')?.value;
const submitBtn = document.getElementById('webhook-form-submit');
const editId = submitBtn?.dataset.editId || '';
if (!name) { Toast.warning('Nome do webhook é obrigatório'); return; } if (!name) { Toast.warning('Nome do webhook é obrigatório'); return; }
if (!targetId) { Toast.warning('Selecione um destino'); return; } if (!targetId) { Toast.warning('Selecione um destino'); return; }
try { try {
await API.webhooks.create({ name, targetType, targetId }); if (editId) {
Modal.close('webhook-modal-overlay'); await API.webhooks.update(editId, { name, targetType, targetId });
Toast.success('Webhook criado com sucesso'); Modal.close('webhook-modal-overlay');
Toast.success('Webhook atualizado com sucesso');
} else {
await API.webhooks.create({ name, targetType, targetId });
Modal.close('webhook-modal-overlay');
Toast.success('Webhook criado com sucesso');
}
await WebhooksUI.load(); await WebhooksUI.load();
} catch (err) { } catch (err) {
Toast.error(`Erro ao criar webhook: ${err.message}`); Toast.error(`Erro ao salvar webhook: ${err.message}`);
} }
}, },
@@ -243,15 +297,6 @@ const WebhooksUI = {
} }
}, },
_escapeHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
},
}; };
window.WebhooksUI = WebhooksUI; window.WebhooksUI = WebhooksUI;

16688
public/js/lucide.js Normal file

File diff suppressed because it is too large Load Diff

114
public/js/utils.js Normal file
View File

@@ -0,0 +1,114 @@
const Utils = {
escapeHtml(str) {
if (str === null || str === undefined) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
},
formatDuration(ms) {
if (!ms || ms < 0) return '—';
if (ms < 1000) return `${ms}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
const m = Math.floor(ms / 60000);
const s = Math.floor((ms % 60000) / 1000);
return `${m}m ${s}s`;
},
formatCost(usd) {
if (!usd || usd === 0) return '$0.0000';
return `$${Number(usd).toFixed(4)}`;
},
truncate(str, max = 80) {
if (!str) return '';
return str.length > max ? str.slice(0, max) + '…' : str;
},
refreshIcons(container) {
if (!window.lucide) return;
const target = container || document;
const pending = target.querySelectorAll('i[data-lucide]');
if (pending.length === 0) return;
lucide.createIcons();
},
formatFileSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
},
initDropzone(dropzoneId, fileInputId, fileListId) {
const zone = document.getElementById(dropzoneId);
const input = document.getElementById(fileInputId);
const list = document.getElementById(fileListId);
if (!zone || !input || !list) return null;
const state = { files: [] };
function render() {
list.innerHTML = state.files.map((f, i) => `
<li class="dropzone-file">
<span class="dropzone-file-name">${Utils.escapeHtml(f.name)}</span>
<span class="dropzone-file-size">${Utils.formatFileSize(f.size)}</span>
<button type="button" class="dropzone-file-remove" data-index="${i}" title="Remover">&times;</button>
</li>
`).join('');
const content = zone.querySelector('.dropzone-content');
if (content) content.style.display = state.files.length > 0 ? 'none' : '';
}
function addFiles(fileList) {
for (const f of fileList) {
if (state.files.length >= 20) break;
if (f.size > 10 * 1024 * 1024) continue;
const dupe = state.files.some(x => x.name === f.name && x.size === f.size);
if (!dupe) state.files.push(f);
}
render();
}
const browseBtn = zone.querySelector('.dropzone-browse');
if (browseBtn) {
browseBtn.addEventListener('click', (e) => {
e.stopPropagation();
input.click();
});
}
zone.addEventListener('click', (e) => {
if (e.target.closest('.dropzone-file-remove')) {
const idx = parseInt(e.target.closest('.dropzone-file-remove').dataset.index);
state.files.splice(idx, 1);
render();
return;
}
if (e.target.closest('.dropzone-browse')) return;
if (!e.target.closest('.dropzone-file')) input.click();
});
input.addEventListener('change', () => {
if (input.files.length > 0) addFiles(input.files);
input.value = '';
});
zone.addEventListener('dragover', (e) => { e.preventDefault(); zone.classList.add('dragover'); });
zone.addEventListener('dragleave', () => zone.classList.remove('dragover'));
zone.addEventListener('drop', (e) => {
e.preventDefault();
zone.classList.remove('dragover');
if (e.dataTransfer.files.length > 0) addFiles(e.dataTransfer.files);
});
state.reset = () => { state.files = []; render(); };
state.getFiles = () => state.files;
return state;
},
};
window.Utils = Utils;

79
scripts/deploy.sh Executable file
View File

@@ -0,0 +1,79 @@
#!/bin/bash
set -e
VPS_HOST="fred@192.168.1.151"
VPS_PORT=2222
VPS_APP_DIR="/home/fred/vps/apps/agents-orchestrator"
VPS_COMPOSE_DIR="/home/fred/vps"
SSH="ssh -p $VPS_PORT $VPS_HOST"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
info() { echo -e "${GREEN}[deploy]${NC} $1"; }
warn() { echo -e "${YELLOW}[deploy]${NC} $1"; }
error() { echo -e "${RED}[deploy]${NC} $1"; }
SKIP_PUSH=false
for arg in "$@"; do
case "$arg" in
--skip-push) SKIP_PUSH=true ;;
esac
done
if [ "$SKIP_PUSH" = false ]; then
info "Fazendo push para origin..."
git push origin main
info "Fazendo push para nitro..."
git push nitro main 2>/dev/null || warn "Push para nitro falhou (não crítico)"
fi
info "Verificando dados no VPS antes do deploy..."
DATA_FILES=$($SSH "ls -1 $VPS_APP_DIR/data/*.json 2>/dev/null | wc -l")
info "Arquivos de dados encontrados: $DATA_FILES"
if [ "$DATA_FILES" -gt 0 ]; then
info "Criando backup dos dados..."
$SSH "cp -r $VPS_APP_DIR/data $VPS_APP_DIR/data-backup-\$(date +%Y%m%d-%H%M%S)"
fi
info "Sincronizando código com o VPS..."
rsync -avz --delete \
--exclude='node_modules' \
--exclude='data' \
--exclude='data-backup-*' \
--exclude='.git' \
--exclude='.env' \
--exclude='*.log' \
-e "ssh -p $VPS_PORT" \
./ "$VPS_HOST:$VPS_APP_DIR/"
info "Corrigindo permissões do diretório data..."
$SSH "sudo chown -R 1000:1000 $VPS_APP_DIR/data"
info "Rebuilding container..."
$SSH "cd $VPS_COMPOSE_DIR && docker compose up -d --build agents-orchestrator 2>&1 | tail -5"
info "Verificando container..."
sleep 2
STATUS=$($SSH "docker ps --filter name=agents-orchestrator --format '{{.Status}}'")
if echo "$STATUS" | grep -q "Up"; then
info "Container rodando: $STATUS"
else
error "Container não está rodando! Status: $STATUS"
exit 1
fi
DATA_AFTER=$($SSH "ls -1 $VPS_APP_DIR/data/*.json 2>/dev/null | wc -l")
info "Arquivos de dados após deploy: $DATA_AFTER"
if [ "$DATA_AFTER" -lt "$DATA_FILES" ]; then
error "ALERTA: Menos arquivos de dados após deploy! ($DATA_FILES -> $DATA_AFTER)"
error "Backup disponível em data-backup-*"
exit 1
fi
$SSH "ls -dt $VPS_APP_DIR/data-backup-* 2>/dev/null | tail -n +4 | xargs rm -rf 2>/dev/null" || true
info "Deploy concluído com sucesso!"

159
server.js
View File

@@ -4,44 +4,122 @@ import { WebSocketServer } from 'ws';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { dirname, join } from 'path'; import { dirname, join } from 'path';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import crypto from 'crypto';
import rateLimit from 'express-rate-limit';
import helmet from 'helmet';
import compression from 'compression';
import apiRouter, { setWsBroadcast, setWsBroadcastTo, hookRouter } from './src/routes/api.js'; import apiRouter, { setWsBroadcast, setWsBroadcastTo, hookRouter } from './src/routes/api.js';
import * as manager from './src/agents/manager.js'; import * as manager from './src/agents/manager.js';
import { setGlobalBroadcast } from './src/agents/manager.js'; import { setGlobalBroadcast } from './src/agents/manager.js';
import { cancelAllExecutions } from './src/agents/executor.js'; import { cancelAllExecutions } from './src/agents/executor.js';
import { stopAll as stopAllSchedules } from './src/agents/scheduler.js';
import { flushAllStores } from './src/store/db.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;
const HOST = process.env.HOST || '127.0.0.1';
const AUTH_TOKEN = process.env.AUTH_TOKEN || ''; const AUTH_TOKEN = process.env.AUTH_TOKEN || '';
const ALLOWED_ORIGIN = process.env.ALLOWED_ORIGIN || ''; const ALLOWED_ORIGIN = process.env.ALLOWED_ORIGIN || 'http://localhost:3000';
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || '';
function timingSafeCompare(a, b) {
if (typeof a !== 'string' || typeof b !== 'string') return false;
const hashA = crypto.createHash('sha256').update(a).digest();
const hashB = crypto.createHash('sha256').update(b).digest();
return crypto.timingSafeEqual(hashA, hashB);
}
const apiLimiter = rateLimit({
windowMs: 60 * 1000,
max: 100,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Limite de requisições excedido. Tente novamente em breve.' },
});
const hookLimiter = rateLimit({
windowMs: 60 * 1000,
max: 30,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Limite de requisições de webhook excedido.' },
});
function verifyWebhookSignature(req, res, next) {
if (!WEBHOOK_SECRET) return next();
const sig = req.headers['x-hub-signature-256'];
if (!sig) return res.status(401).json({ error: 'Assinatura ausente' });
const hmac = crypto.createHmac('sha256', WEBHOOK_SECRET);
hmac.update(req.rawBody || '');
const expected = 'sha256=' + hmac.digest('hex');
try {
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
return res.status(401).json({ error: 'Assinatura inválida' });
}
} catch {
return res.status(401).json({ error: 'Assinatura inválida' });
}
next();
}
const app = express(); const app = express();
app.set('trust proxy', 1);
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) => {
const origin = ALLOWED_ORIGIN || req.headers.origin || '*'; res.setHeader('Access-Control-Allow-Origin', ALLOWED_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, X-Client-Id'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Client-Id, X-Correlation-ID');
if (req.method === 'OPTIONS') return res.sendStatus(204); if (req.method === 'OPTIONS') return res.sendStatus(204);
next(); next();
}); });
if (AUTH_TOKEN) { app.use((req, res, next) => {
app.use('/api', (req, res, next) => { req.correlationId = req.headers['x-correlation-id'] || crypto.randomUUID();
const header = req.headers.authorization || ''; res.setHeader('X-Correlation-ID', req.correlationId);
const token = header.startsWith('Bearer ') ? header.slice(7) : req.query.token; next();
if (token !== AUTH_TOKEN) { });
return res.status(401).json({ error: 'Token de autenticação inválido' });
}
next();
});
}
app.use(express.json()); app.get('/api/health', (req, res) => {
app.use('/hook', hookRouter); res.json({
app.use(express.static(join(__dirname, 'public'))); status: 'ok',
timestamp: new Date().toISOString(),
uptime: Math.floor(process.uptime()),
});
});
app.use(helmet({
contentSecurityPolicy: false,
}));
app.use(compression());
app.use('/api', apiLimiter);
app.use('/api', (req, res, next) => {
if (!AUTH_TOKEN) return next();
const header = req.headers.authorization || '';
const token = header.startsWith('Bearer ') ? header.slice(7) : req.query.token;
if (!timingSafeCompare(token, AUTH_TOKEN)) {
return res.status(401).json({ error: 'Token de autenticação inválido' });
}
next();
});
app.use(express.json({
verify: (req, res, buf) => { req.rawBody = buf || Buffer.alloc(0); },
}));
app.use('/hook', hookLimiter, verifyWebhookSignature, hookRouter);
app.use(express.static(join(__dirname, 'public'), {
etag: true,
setHeaders(res, filePath) {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
},
}));
app.use('/api', apiRouter); app.use('/api', apiRouter);
const connectedClients = new Map(); const connectedClients = new Map();
@@ -51,20 +129,30 @@ wss.on('connection', (ws, req) => {
if (AUTH_TOKEN) { if (AUTH_TOKEN) {
const token = new URL(req.url, 'http://localhost').searchParams.get('token'); const token = new URL(req.url, 'http://localhost').searchParams.get('token');
if (token !== AUTH_TOKEN) { if (!timingSafeCompare(token, AUTH_TOKEN)) {
ws.close(4001, 'Token inválido'); ws.close(4001, 'Token inválido');
return; return;
} }
} }
ws.clientId = clientId; ws.clientId = clientId;
ws.isAlive = true;
connectedClients.set(clientId, ws); connectedClients.set(clientId, ws);
ws.on('pong', () => { ws.isAlive = true; });
ws.on('close', () => connectedClients.delete(clientId)); ws.on('close', () => connectedClients.delete(clientId));
ws.on('error', () => connectedClients.delete(clientId)); ws.on('error', () => connectedClients.delete(clientId));
ws.send(JSON.stringify({ type: 'connected', clientId })); ws.send(JSON.stringify({ type: 'connected', clientId }));
}); });
const wsHeartbeat = setInterval(() => {
wss.clients.forEach(ws => {
if (!ws.isAlive) return ws.terminate();
ws.isAlive = false;
ws.ping();
});
}, 30000);
function broadcast(message) { function broadcast(message) {
const payload = JSON.stringify(message); const payload = JSON.stringify(message);
for (const [, client] of connectedClients) { for (const [, client] of connectedClients) {
@@ -86,30 +174,51 @@ setGlobalBroadcast(broadcast);
function gracefulShutdown(signal) { function gracefulShutdown(signal) {
console.log(`\nSinal ${signal} recebido. Encerrando servidor...`); console.log(`\nSinal ${signal} recebido. Encerrando servidor...`);
stopAllSchedules();
console.log('Agendamentos parados.');
cancelAllExecutions(); cancelAllExecutions();
console.log('Execuções ativas canceladas.'); console.log('Execuções ativas canceladas.');
flushAllStores(); flushAllStores();
console.log('Dados persistidos.'); console.log('Dados persistidos.');
httpServer.close(() => { clearInterval(wsHeartbeat);
console.log('Servidor HTTP encerrado.');
process.exit(0); for (const client of wss.clients) {
client.close(1001, 'Servidor encerrando');
}
connectedClients.clear();
wss.close(() => {
console.log('WebSocket server encerrado.');
httpServer.close(() => {
console.log('Servidor HTTP encerrado.');
process.exit(0);
});
}); });
setTimeout(() => { setTimeout(() => {
console.error('Forçando encerramento após timeout.'); console.error('Forçando encerramento após timeout.');
process.exit(1); process.exit(1);
}, 10000); }, 10000).unref();
} }
process.on('SIGTERM', () => gracefulShutdown('SIGTERM')); process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT')); process.on('SIGINT', () => gracefulShutdown('SIGINT'));
process.on('uncaughtException', (err) => {
console.error('[FATAL] Exceção não capturada:', err.message);
console.error(err.stack);
});
process.on('unhandledRejection', (reason) => {
console.error('[WARN] Promise rejeitada não tratada:', reason);
});
manager.restoreSchedules(); manager.restoreSchedules();
httpServer.listen(PORT, () => { httpServer.listen(PORT, HOST, () => {
console.log(`Painel administrativo disponível em http://localhost:${PORT}`); console.log(`Painel administrativo disponível em http://${HOST}:${PORT}`);
console.log(`WebSocket server ativo na mesma porta.`); console.log(`WebSocket server ativo na mesma porta.`);
if (AUTH_TOKEN) console.log('Autenticação por token ativada.');
}); });

View File

@@ -1,10 +1,17 @@
import { spawn } from 'child_process'; import { spawn } from 'child_process';
import { existsSync } from 'fs'; import { existsSync, mkdirSync } from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { settingsStore } from '../store/db.js'; import { settingsStore } from '../store/db.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const AGENT_SETTINGS = path.resolve(__dirname, '..', '..', 'data', 'agent-settings.json');
const CLAUDE_BIN = resolveBin(); const CLAUDE_BIN = resolveBin();
const activeExecutions = new Map(); const activeExecutions = new Map();
const MAX_OUTPUT_SIZE = 512 * 1024;
const MAX_ERROR_SIZE = 100 * 1024;
const ALLOWED_DIRECTORIES = (process.env.ALLOWED_DIRECTORIES || '').split(',').map(d => d.trim()).filter(Boolean);
let maxConcurrent = settingsStore.get().maxConcurrent || 5; let maxConcurrent = settingsStore.get().maxConcurrent || 5;
@@ -12,6 +19,12 @@ export function updateMaxConcurrent(value) {
maxConcurrent = Math.max(1, Math.min(20, parseInt(value) || 5)); maxConcurrent = Math.max(1, Math.min(20, parseInt(value) || 5));
} }
function isDirectoryAllowed(dir) {
if (ALLOWED_DIRECTORIES.length === 0) return true;
const resolved = path.resolve(dir);
return ALLOWED_DIRECTORIES.some(allowed => resolved.startsWith(path.resolve(allowed)));
}
function resolveBin() { function resolveBin() {
if (process.env.CLAUDE_BIN) return process.env.CLAUDE_BIN; if (process.env.CLAUDE_BIN) return process.env.CLAUDE_BIN;
const home = process.env.HOME || ''; const home = process.env.HOME || '';
@@ -34,19 +47,24 @@ function sanitizeText(str) {
.slice(0, 50000); .slice(0, 50000);
} }
function cleanEnv() { function cleanEnv(agentSecrets) {
const env = { ...process.env }; const env = { ...process.env };
delete env.CLAUDECODE; delete env.CLAUDECODE;
delete env.ANTHROPIC_API_KEY; delete env.ANTHROPIC_API_KEY;
if (!env.CLAUDE_CODE_MAX_OUTPUT_TOKENS) { env.CLAUDE_CODE_MAX_OUTPUT_TOKENS = '128000';
env.CLAUDE_CODE_MAX_OUTPUT_TOKENS = '128000'; if (agentSecrets && typeof agentSecrets === 'object') {
Object.assign(env, agentSecrets);
} }
return env; return env;
} }
function buildArgs(agentConfig, prompt) { function buildArgs(agentConfig) {
const model = agentConfig.model || 'claude-sonnet-4-6'; const model = agentConfig.model || 'claude-sonnet-4-6';
const args = ['-p', prompt, '--output-format', 'stream-json', '--verbose', '--model', model]; const args = ['--output-format', 'stream-json', '--verbose', '--model', model];
if (existsSync(AGENT_SETTINGS)) {
args.push('--settings', AGENT_SETTINGS);
}
if (agentConfig.systemPrompt) { if (agentConfig.systemPrompt) {
args.push('--system-prompt', agentConfig.systemPrompt); args.push('--system-prompt', agentConfig.systemPrompt);
@@ -111,58 +129,101 @@ function extractText(event) {
return null; return null;
} }
export function execute(agentConfig, task, callbacks = {}) { function extractToolInfo(event) {
if (activeExecutions.size >= maxConcurrent) { if (!event) return null;
const err = new Error(`Limite de ${maxConcurrent} execuções simultâneas atingido`);
if (callbacks.onError) callbacks.onError(err, uuidv4());
return null;
}
const executionId = uuidv4(); if (event.type === 'assistant' && event.message?.content) {
const { onData, onError, onComplete } = callbacks; const toolBlocks = event.message.content.filter((b) => b.type === 'tool_use');
if (toolBlocks.length > 0) {
const prompt = buildPrompt(task.description || task, task.instructions); return toolBlocks.map((b) => {
const args = buildArgs(agentConfig, prompt); const name = b.name || 'unknown';
const input = b.input || {};
const spawnOptions = { let detail = '';
env: cleanEnv(), if (input.command) detail = input.command.slice(0, 120);
stdio: ['ignore', 'pipe', 'pipe'], else if (input.file_path) detail = input.file_path;
}; else if (input.pattern) detail = input.pattern;
else if (input.query) detail = input.query;
if (agentConfig.workingDirectory && agentConfig.workingDirectory.trim()) { else if (input.path) detail = input.path;
if (!existsSync(agentConfig.workingDirectory)) { else if (input.prompt) detail = input.prompt.slice(0, 80);
const err = new Error(`Diretório de trabalho não encontrado: ${agentConfig.workingDirectory}`); else if (input.description) detail = input.description.slice(0, 80);
if (onError) onError(err, executionId); return { name, detail };
return executionId; });
} }
spawnOptions.cwd = agentConfig.workingDirectory;
} }
console.log(`[executor] Iniciando: ${executionId} | Modelo: ${agentConfig.model || 'claude-sonnet-4-6'}`); if (event.type === 'content_block_start' && event.content_block?.type === 'tool_use') {
return [{ name: event.content_block.name || 'tool', detail: '' }];
}
const child = spawn(CLAUDE_BIN, args, spawnOptions); return null;
let hadError = false; }
activeExecutions.set(executionId, { function extractSystemInfo(event) {
process: child, if (!event) return null;
agentConfig,
task,
startedAt: new Date().toISOString(),
executionId,
});
if (event.type === 'system' && event.message) return event.message;
if (event.type === 'error') return event.error?.message || event.message || 'Erro desconhecido';
if (event.type === 'result') {
const parts = [];
if (event.num_turns) parts.push(`${event.num_turns} turnos`);
if (event.cost_usd) parts.push(`custo: $${event.cost_usd.toFixed(4)}`);
if (event.duration_ms) {
const s = (event.duration_ms / 1000).toFixed(1);
parts.push(`duração: ${s}s`);
}
if (event.session_id) parts.push(`sessão: ${event.session_id.slice(0, 8)}...`);
return parts.length > 0 ? `Resultado: ${parts.join(' | ')}` : null;
}
return null;
}
function processChildOutput(child, executionId, callbacks, options = {}) {
const { onData, onError, onComplete } = callbacks;
const timeoutMs = options.timeout || 1800000;
const sessionIdOverride = options.sessionIdOverride || null;
let outputBuffer = ''; let outputBuffer = '';
let errorBuffer = ''; let errorBuffer = '';
let fullText = ''; let fullText = '';
let resultMeta = null; let resultMeta = null;
let turnCount = 0;
let hadError = false;
const timeout = setTimeout(() => {
child.kill('SIGTERM');
setTimeout(() => { if (!child.killed) child.kill('SIGKILL'); }, 5000);
}, timeoutMs);
function processEvent(parsed) { function processEvent(parsed) {
if (!parsed) return; if (!parsed) return;
const tools = extractToolInfo(parsed);
if (tools) {
for (const t of tools) {
const msg = t.detail ? `${t.name}: ${t.detail}` : t.name;
if (onData) onData({ type: 'tool', content: msg, toolName: t.name }, executionId);
}
}
const text = extractText(parsed); const text = extractText(parsed);
if (text) { if (text) {
fullText += text; if (fullText.length < MAX_OUTPUT_SIZE) {
fullText += text;
}
if (onData) onData({ type: 'chunk', content: text }, executionId); if (onData) onData({ type: 'chunk', content: text }, executionId);
} }
const sysInfo = extractSystemInfo(parsed);
if (sysInfo) {
if (onData) onData({ type: 'system', content: sysInfo }, executionId);
}
if (parsed.type === 'assistant') {
turnCount++;
if (onData) onData({ type: 'turn', content: `Turno ${turnCount}`, turn: turnCount }, executionId);
}
if (parsed.type === 'result') { if (parsed.type === 'result') {
resultMeta = { resultMeta = {
costUsd: parsed.cost_usd || 0, costUsd: parsed.cost_usd || 0,
@@ -170,7 +231,7 @@ export function execute(agentConfig, task, callbacks = {}) {
durationMs: parsed.duration_ms || 0, durationMs: parsed.duration_ms || 0,
durationApiMs: parsed.duration_api_ms || 0, durationApiMs: parsed.duration_api_ms || 0,
numTurns: parsed.num_turns || 0, numTurns: parsed.num_turns || 0,
sessionId: parsed.session_id || '', sessionId: parsed.session_id || sessionIdOverride || '',
}; };
} }
} }
@@ -182,10 +243,18 @@ export function execute(agentConfig, task, callbacks = {}) {
}); });
child.stderr.on('data', (chunk) => { child.stderr.on('data', (chunk) => {
errorBuffer += chunk.toString(); const str = chunk.toString();
if (errorBuffer.length < MAX_ERROR_SIZE) {
errorBuffer += str;
}
const lines = str.split('\n').filter(l => l.trim());
for (const line of lines) {
if (onData) onData({ type: 'stderr', content: line.trim() }, executionId);
}
}); });
child.on('error', (err) => { child.on('error', (err) => {
clearTimeout(timeout);
console.log(`[executor][error] ${err.message}`); console.log(`[executor][error] ${err.message}`);
hadError = true; hadError = true;
activeExecutions.delete(executionId); activeExecutions.delete(executionId);
@@ -193,21 +262,87 @@ export function execute(agentConfig, task, callbacks = {}) {
}); });
child.on('close', (code) => { child.on('close', (code) => {
clearTimeout(timeout);
const wasCanceled = activeExecutions.get(executionId)?.canceled || false;
activeExecutions.delete(executionId); activeExecutions.delete(executionId);
if (hadError) return; if (hadError) return;
if (outputBuffer.trim()) processEvent(parseStreamLine(outputBuffer)); if (outputBuffer.trim()) processEvent(parseStreamLine(outputBuffer));
if (onComplete) { if (onComplete) {
onComplete({ onComplete({
executionId, executionId,
exitCode: code, exitCode: code,
result: fullText, result: fullText,
stderr: errorBuffer, stderr: errorBuffer,
canceled: wasCanceled,
...(resultMeta || {}), ...(resultMeta || {}),
}, executionId); }, executionId);
} }
}); });
}
function validateWorkingDirectory(agentConfig, executionId, onError) {
if (!agentConfig.workingDirectory || !agentConfig.workingDirectory.trim()) return true;
if (!isDirectoryAllowed(agentConfig.workingDirectory)) {
const err = new Error(`Diretório de trabalho não permitido: ${agentConfig.workingDirectory}`);
if (onError) onError(err, executionId);
return false;
}
if (!existsSync(agentConfig.workingDirectory)) {
try {
mkdirSync(agentConfig.workingDirectory, { recursive: true });
} catch (e) {
const err = new Error(`Não foi possível criar o diretório: ${agentConfig.workingDirectory} (${e.message})`);
if (onError) onError(err, executionId);
return false;
}
}
return true;
}
export function execute(agentConfig, task, callbacks = {}, secrets = null) {
if (activeExecutions.size >= maxConcurrent) {
const err = new Error(`Limite de ${maxConcurrent} execuções simultâneas atingido`);
if (callbacks.onError) callbacks.onError(err, uuidv4());
return null;
}
const executionId = uuidv4();
const { onData, onError, onComplete } = callbacks;
if (!validateWorkingDirectory(agentConfig, executionId, onError)) return null;
const prompt = buildPrompt(task.description || task, task.instructions);
const args = buildArgs(agentConfig);
const spawnOptions = {
env: cleanEnv(secrets),
stdio: ['pipe', 'pipe', 'pipe'],
};
if (agentConfig.workingDirectory && agentConfig.workingDirectory.trim()) {
spawnOptions.cwd = agentConfig.workingDirectory;
}
console.log(`[executor] Iniciando: ${executionId} | Modelo: ${agentConfig.model || 'claude-sonnet-4-6'}`);
const child = spawn(CLAUDE_BIN, args, spawnOptions);
child.stdin.write(prompt);
child.stdin.end();
activeExecutions.set(executionId, {
process: child,
agentConfig,
task,
startedAt: new Date().toISOString(),
executionId,
});
processChildOutput(child, executionId, { onData, onError, onComplete }, {
timeout: agentConfig.timeout || 1800000,
});
return executionId; return executionId;
} }
@@ -222,6 +357,8 @@ export function resume(agentConfig, sessionId, message, callbacks = {}) {
const executionId = uuidv4(); const executionId = uuidv4();
const { onData, onError, onComplete } = callbacks; const { onData, onError, onComplete } = callbacks;
if (!validateWorkingDirectory(agentConfig, executionId, onError)) return null;
const model = agentConfig.model || 'claude-sonnet-4-6'; const model = agentConfig.model || 'claude-sonnet-4-6';
const args = [ const args = [
'--resume', sessionId, '--resume', sessionId,
@@ -232,6 +369,10 @@ export function resume(agentConfig, sessionId, message, callbacks = {}) {
'--permission-mode', agentConfig.permissionMode || 'bypassPermissions', '--permission-mode', agentConfig.permissionMode || 'bypassPermissions',
]; ];
if (existsSync(AGENT_SETTINGS)) {
args.push('--settings', AGENT_SETTINGS);
}
if (agentConfig.maxTurns && agentConfig.maxTurns > 0) { if (agentConfig.maxTurns && agentConfig.maxTurns > 0) {
args.push('--max-turns', String(agentConfig.maxTurns)); args.push('--max-turns', String(agentConfig.maxTurns));
} }
@@ -242,18 +383,12 @@ export function resume(agentConfig, sessionId, message, callbacks = {}) {
}; };
if (agentConfig.workingDirectory && agentConfig.workingDirectory.trim()) { if (agentConfig.workingDirectory && agentConfig.workingDirectory.trim()) {
if (!existsSync(agentConfig.workingDirectory)) {
const err = new Error(`Diretório de trabalho não encontrado: ${agentConfig.workingDirectory}`);
if (onError) onError(err, executionId);
return executionId;
}
spawnOptions.cwd = agentConfig.workingDirectory; spawnOptions.cwd = agentConfig.workingDirectory;
} }
console.log(`[executor] Resumindo sessão: ${sessionId} | Execução: ${executionId}`); console.log(`[executor] Resumindo sessão: ${sessionId} | Execução: ${executionId}`);
const child = spawn(CLAUDE_BIN, args, spawnOptions); const child = spawn(CLAUDE_BIN, args, spawnOptions);
let hadError = false;
activeExecutions.set(executionId, { activeExecutions.set(executionId, {
process: child, process: child,
@@ -263,60 +398,9 @@ export function resume(agentConfig, sessionId, message, callbacks = {}) {
executionId, executionId,
}); });
let outputBuffer = ''; processChildOutput(child, executionId, { onData, onError, onComplete }, {
let errorBuffer = ''; timeout: agentConfig.timeout || 1800000,
let fullText = ''; sessionIdOverride: sessionId,
let resultMeta = null;
function processEvent(parsed) {
if (!parsed) return;
const text = extractText(parsed);
if (text) {
fullText += text;
if (onData) onData({ type: 'chunk', content: text }, executionId);
}
if (parsed.type === 'result') {
resultMeta = {
costUsd: parsed.cost_usd || 0,
totalCostUsd: parsed.total_cost_usd || 0,
durationMs: parsed.duration_ms || 0,
durationApiMs: parsed.duration_api_ms || 0,
numTurns: parsed.num_turns || 0,
sessionId: parsed.session_id || sessionId,
};
}
}
child.stdout.on('data', (chunk) => {
const lines = (outputBuffer + chunk.toString()).split('\n');
outputBuffer = lines.pop();
for (const line of lines) processEvent(parseStreamLine(line));
});
child.stderr.on('data', (chunk) => {
errorBuffer += chunk.toString();
});
child.on('error', (err) => {
console.log(`[executor][error] ${err.message}`);
hadError = true;
activeExecutions.delete(executionId);
if (onError) onError(err, executionId);
});
child.on('close', (code) => {
activeExecutions.delete(executionId);
if (hadError) return;
if (outputBuffer.trim()) processEvent(parseStreamLine(outputBuffer));
if (onComplete) {
onComplete({
executionId,
exitCode: code,
result: fullText,
stderr: errorBuffer,
...(resultMeta || {}),
}, executionId);
}
}); });
return executionId; return executionId;
@@ -325,8 +409,8 @@ export function resume(agentConfig, sessionId, message, callbacks = {}) {
export function cancel(executionId) { export function cancel(executionId) {
const execution = activeExecutions.get(executionId); const execution = activeExecutions.get(executionId);
if (!execution) return false; if (!execution) return false;
execution.canceled = true;
execution.process.kill('SIGTERM'); execution.process.kill('SIGTERM');
activeExecutions.delete(executionId);
return true; return true;
} }
@@ -343,6 +427,66 @@ export function getActiveExecutions() {
})); }));
} }
export function summarize(text, threshold = 1500) {
return new Promise((resolve) => {
if (!text || text.length <= threshold) {
resolve(text);
return;
}
const prompt = `Resuma o conteúdo abaixo de forma estruturada e concisa. Preserve TODAS as informações críticas:
- Decisões técnicas e justificativas
- Trechos de código essenciais
- Dados, números e métricas
- Problemas encontrados e soluções
- Recomendações e próximos passos
Organize o resumo usando <tags> XML (ex: <decisoes>, <codigo>, <problemas>, <recomendacoes>).
NÃO omita informações que seriam necessárias para outro profissional continuar o trabalho.
<conteudo_para_resumir>
${text}
</conteudo_para_resumir>`;
const args = [
'--output-format', 'text',
'--model', 'claude-haiku-4-5-20251001',
'--max-turns', '1',
'--permission-mode', 'bypassPermissions',
];
if (existsSync(AGENT_SETTINGS)) {
args.push('--settings', AGENT_SETTINGS);
}
const child = spawn(CLAUDE_BIN, args, {
env: cleanEnv(),
stdio: ['pipe', 'pipe', 'pipe'],
});
child.stdin.write(prompt);
child.stdin.end();
let output = '';
const timer = setTimeout(() => {
child.kill('SIGTERM');
}, 120000);
child.stdout.on('data', (chunk) => { output += chunk.toString(); });
child.on('close', () => {
clearTimeout(timer);
const result = output.trim();
console.log(`[executor] Sumarização: ${text.length}${result.length} chars`);
resolve(result || text);
});
child.on('error', () => {
clearTimeout(timer);
resolve(text);
});
});
}
export function getBinPath() { export function getBinPath() {
return CLAUDE_BIN; return CLAUDE_BIN;
} }

View File

@@ -0,0 +1,117 @@
import { spawn } from 'child_process';
import { existsSync } from 'fs';
import { join, basename } from 'path';
const PROJECTS_DIR = '/home/projetos';
const GITEA_URL = () => process.env.GITEA_URL || 'http://gitea:3000';
const GITEA_USER = () => process.env.GITEA_USER || 'fred';
const GITEA_PASS = () => process.env.GITEA_PASS || '';
const DOMAIN = () => process.env.DOMAIN || 'nitro-cloud.duckdns.org';
function exec(cmd, cwd) {
return new Promise((resolve, reject) => {
const proc = spawn('sh', ['-c', cmd], {
cwd,
env: { ...process.env, HOME: '/tmp', GIT_TERMINAL_PROMPT: '0' },
});
let stdout = '', stderr = '';
proc.stdout.on('data', d => stdout += d);
proc.stderr.on('data', d => stderr += d);
proc.on('close', code =>
code === 0 ? resolve(stdout.trim()) : reject(new Error(stderr.trim() || `exit ${code}`))
);
});
}
function authHeader() {
return 'Basic ' + Buffer.from(`${GITEA_USER()}:${GITEA_PASS()}`).toString('base64');
}
function repoCloneUrl(repoName) {
return `${GITEA_URL().replace('://', `://${GITEA_USER()}:${GITEA_PASS()}@`)}/${GITEA_USER()}/${repoName}.git`;
}
export async function listRepos() {
const url = `${GITEA_URL()}/api/v1/user/repos?limit=50&sort=updated`;
const res = await fetch(url, { headers: { Authorization: authHeader() } });
if (!res.ok) throw new Error('Erro ao listar repositórios');
const repos = await res.json();
return repos.map(r => ({
name: r.name,
fullName: r.full_name,
description: r.description || '',
defaultBranch: r.default_branch || 'main',
updatedAt: r.updated_at,
htmlUrl: r.html_url,
cloneUrl: r.clone_url,
empty: r.empty,
}));
}
export async function listBranches(repoName) {
const url = `${GITEA_URL()}/api/v1/repos/${GITEA_USER()}/${repoName}/branches?limit=50`;
const res = await fetch(url, { headers: { Authorization: authHeader() } });
if (!res.ok) return [];
const branches = await res.json();
return branches.map(b => b.name);
}
export async function cloneOrPull(repoName, branch) {
const targetDir = join(PROJECTS_DIR, repoName);
const cloneUrl = repoCloneUrl(repoName);
if (existsSync(join(targetDir, '.git'))) {
await exec(`git remote set-url origin "${cloneUrl}"`, targetDir);
await exec('git fetch origin', targetDir);
if (branch) {
try {
await exec(`git checkout ${branch}`, targetDir);
} catch {
await exec(`git checkout -b ${branch} origin/${branch}`, targetDir);
}
await exec(`git reset --hard origin/${branch}`, targetDir);
} else {
const currentBranch = await exec('git rev-parse --abbrev-ref HEAD', targetDir);
await exec(`git reset --hard origin/${currentBranch}`, targetDir);
}
return { dir: targetDir, action: 'pull' };
}
const branchArg = branch ? `-b ${branch}` : '';
await exec(`git clone ${branchArg} "${cloneUrl}" "${targetDir}"`);
return { dir: targetDir, action: 'clone' };
}
export async function commitAndPush(repoDir, agentName, taskSummary) {
try {
const status = await exec('git status --porcelain', repoDir);
if (!status) return { changed: false };
await exec('git add -A', repoDir);
const summary = taskSummary
? taskSummary.slice(0, 100).replace(/"/g, '\\"')
: 'Alterações automáticas';
const message = `${summary}\n\nExecutado por: ${agentName}`;
await exec(
`git -c user.name="Agents Orchestrator" -c user.email="agents@${DOMAIN()}" commit -m "${message}"`,
repoDir
);
await exec('git push origin HEAD', repoDir);
const commitHash = await exec('git rev-parse --short HEAD', repoDir);
const branch = await exec('git rev-parse --abbrev-ref HEAD', repoDir);
const repoName = basename(repoDir);
const commitUrl = `https://git.${DOMAIN()}/${GITEA_USER()}/${repoName}/commit/${commitHash}`;
return { changed: true, commitHash, branch, commitUrl, filesChanged: status.split('\n').length };
} catch (err) {
return { changed: false, error: err.message };
}
}
export function getProjectDir(repoName) {
return join(PROJECTS_DIR, repoName);
}

View File

@@ -1,7 +1,10 @@
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { agentsStore, schedulesStore, executionsStore } from '../store/db.js'; import cron from 'node-cron';
import { agentsStore, schedulesStore, executionsStore, notificationsStore, secretsStore, agentVersionsStore, withLock } from '../store/db.js';
import * as executor from './executor.js'; import * as executor from './executor.js';
import * as scheduler from './scheduler.js'; import * as scheduler from './scheduler.js';
import { generateAgentReport } from '../reports/generator.js';
import * as gitIntegration from './git-integration.js';
const DEFAULT_CONFIG = { const DEFAULT_CONFIG = {
model: 'claude-sonnet-4-6', model: 'claude-sonnet-4-6',
@@ -25,6 +28,14 @@ function getWsCallback(wsCallback) {
return wsCallback || globalBroadcast || null; return wsCallback || globalBroadcast || null;
} }
function createNotification(type, title, message, metadata = {}) {
notificationsStore.create({
type, title, message, metadata,
read: false,
createdAt: new Date().toISOString(),
});
}
let dailyExecutionCount = 0; let dailyExecutionCount = 0;
let dailyCountDate = new Date().toDateString(); let dailyCountDate = new Date().toDateString();
@@ -90,6 +101,13 @@ export function createAgent(data) {
export function updateAgent(id, data) { export function updateAgent(id, data) {
const existing = agentsStore.getById(id); const existing = agentsStore.getById(id);
if (!existing) return null; if (!existing) return null;
agentVersionsStore.create({
agentId: id,
version: existing,
changedFields: Object.keys(data).filter(k => k !== 'id'),
});
const updateData = {}; const updateData = {};
if (data.agent_name !== undefined) updateData.agent_name = data.agent_name; if (data.agent_name !== undefined) updateData.agent_name = data.agent_name;
if (data.description !== undefined) updateData.description = data.description; if (data.description !== undefined) updateData.description = data.description;
@@ -105,25 +123,44 @@ export function deleteAgent(id) {
return agentsStore.delete(id); return agentsStore.delete(id);
} }
function loadAgentSecrets(agentId) {
const all = secretsStore.getAll();
const agentSecrets = all.filter(s => s.agentId === agentId);
if (agentSecrets.length === 0) return null;
const env = {};
for (const s of agentSecrets) env[s.name] = s.value;
return env;
}
export function executeTask(agentId, task, instructions, wsCallback, metadata = {}) { export function executeTask(agentId, task, instructions, wsCallback, metadata = {}) {
const agent = agentsStore.getById(agentId); const agent = agentsStore.getById(agentId);
if (!agent) throw new Error(`Agente ${agentId} não encontrado`); if (!agent) throw new Error(`Agente ${agentId} não encontrado`);
if (agent.status !== 'active') throw new Error(`Agente ${agentId} está inativo`); if (agent.status !== 'active') throw new Error(`Agente ${agentId} está inativo`);
const retryEnabled = agent.config?.retryOnFailure === true;
const maxRetries = Math.min(Math.max(parseInt(agent.config?.maxRetries) || 1, 1), 3);
const attempt = metadata._retryAttempt || 1;
const cb = getWsCallback(wsCallback); const cb = getWsCallback(wsCallback);
const taskText = typeof task === 'string' ? task : task.description; const taskText = typeof task === 'string' ? task : task.description;
const startedAt = new Date().toISOString(); const startedAt = new Date().toISOString();
const historyRecord = executionsStore.create({ const historyRecord = metadata._historyRecordId
type: 'agent', ? { id: metadata._historyRecordId }
...metadata, : executionsStore.create({
agentId, type: 'agent',
agentName: agent.agent_name, ...metadata,
task: taskText, agentId,
instructions: instructions || '', agentName: agent.agent_name,
status: 'running', task: taskText,
startedAt, instructions: instructions || '',
}); status: 'running',
startedAt,
});
if (metadata._retryAttempt) {
executionsStore.update(historyRecord.id, { status: 'running', error: null });
}
const execRecord = { const execRecord = {
executionId: null, executionId: null,
@@ -134,9 +171,28 @@ export function executeTask(agentId, task, instructions, wsCallback, metadata =
status: 'running', status: 'running',
}; };
const agentSecrets = loadAgentSecrets(agentId);
let effectiveInstructions = instructions || '';
const tags = agent.tags || [];
if (tags.includes('coordinator')) {
const allAgents = agentsStore.getAll().filter(a => a.id !== agentId && a.status === 'active');
const agentList = allAgents.map(a => `- **${a.agent_name}**: ${a.description || 'Sem descrição'}`).join('\n');
effectiveInstructions += `\n\n<agentes_disponiveis>\n${agentList}\n</agentes_disponiveis>`;
}
if (metadata.repoName) {
effectiveInstructions += `\n\n<git_repository>\nVocê está trabalhando no repositório "${metadata.repoName}". NÃO faça git init, git commit, git push ou qualquer operação git. O sistema fará commit e push automaticamente ao final da execução. Foque apenas no código.\n</git_repository>`;
}
const effectiveConfig = { ...agent.config };
if (metadata.workingDirectoryOverride) {
effectiveConfig.workingDirectory = metadata.workingDirectoryOverride;
}
const executionId = executor.execute( const executionId = executor.execute(
agent.config, effectiveConfig,
{ description: task, instructions }, { description: task, instructions: effectiveInstructions },
{ {
onData: (parsed, execId) => { onData: (parsed, execId) => {
if (cb) cb({ type: 'execution_output', executionId: execId, agentId, data: parsed }); if (cb) cb({ type: 'execution_output', executionId: execId, agentId, data: parsed });
@@ -144,7 +200,33 @@ export function executeTask(agentId, task, instructions, wsCallback, metadata =
onError: (err, execId) => { onError: (err, execId) => {
const endedAt = new Date().toISOString(); const endedAt = new Date().toISOString();
updateExecutionRecord(agentId, execId, { status: 'error', error: err.message, endedAt }); updateExecutionRecord(agentId, execId, { status: 'error', error: err.message, endedAt });
if (retryEnabled && attempt < maxRetries) {
const delayMs = attempt * 5000;
executionsStore.update(historyRecord.id, { status: 'retrying', error: err.message, attempt, endedAt });
if (cb) cb({
type: 'execution_retry',
executionId: execId,
agentId,
data: { attempt, maxRetries, nextRetryIn: delayMs / 1000, reason: err.message },
});
setTimeout(() => {
try {
executeTask(agentId, task, instructions, wsCallback, {
...metadata,
_retryAttempt: attempt + 1,
_historyRecordId: historyRecord.id,
});
} catch (retryErr) {
executionsStore.update(historyRecord.id, { status: 'error', error: retryErr.message, endedAt: new Date().toISOString() });
if (cb) cb({ type: 'execution_error', executionId: execId, agentId, data: { error: retryErr.message } });
}
}, delayMs);
return;
}
executionsStore.update(historyRecord.id, { status: 'error', error: err.message, endedAt }); executionsStore.update(historyRecord.id, { status: 'error', error: err.message, endedAt });
createNotification('error', 'Execução falhou', `Agente "${agent.agent_name}" encontrou um erro`, { agentId, executionId: execId });
if (cb) cb({ type: 'execution_error', executionId: execId, agentId, data: { error: err.message } }); if (cb) cb({ type: 'execution_error', executionId: execId, agentId, data: { error: err.message } });
}, },
onComplete: (result, execId) => { onComplete: (result, execId) => {
@@ -161,9 +243,61 @@ export function executeTask(agentId, task, instructions, wsCallback, metadata =
numTurns: result.numTurns || 0, numTurns: result.numTurns || 0,
sessionId: result.sessionId || '', sessionId: result.sessionId || '',
}); });
createNotification('success', 'Execução concluída', `Agente "${agent.agent_name}" finalizou a tarefa`, { agentId, executionId: execId });
try {
const updated = executionsStore.getById(historyRecord.id);
if (updated) {
const report = generateAgentReport(updated);
if (cb) cb({ type: 'report_generated', executionId: execId, agentId, reportFile: report.filename });
}
} catch (e) { console.error('[manager] Erro ao gerar relatório:', e.message); }
if (metadata.repoName && result.result) {
const repoDir = gitIntegration.getProjectDir(metadata.repoName);
gitIntegration.commitAndPush(repoDir, agent.agent_name, taskText.slice(0, 100))
.then(gitResult => {
if (gitResult.changed) {
console.log(`[manager] Auto-commit: ${gitResult.commitHash} em ${metadata.repoName}`);
if (cb) cb({
type: 'execution_output', executionId: execId, agentId,
data: { type: 'success', content: `Git: commit ${gitResult.commitHash} pushed para ${metadata.repoName} (${gitResult.filesChanged} arquivos) → ${gitResult.commitUrl}` },
});
} else if (gitResult.error) {
console.error(`[manager] Erro no auto-commit:`, gitResult.error);
}
})
.catch(err => console.error(`[manager] Erro no auto-commit:`, err.message));
}
if (cb) cb({ type: 'execution_complete', executionId: execId, agentId, data: result }); if (cb) cb({ type: 'execution_complete', executionId: execId, agentId, data: result });
const isPipelineStep = !!metadata.pipelineExecutionId;
const delegateTo = agent.config?.delegateTo;
if (!isPipelineStep && delegateTo && result.result) {
const delegateAgent = agentsStore.getById(delegateTo);
if (delegateAgent && delegateAgent.status === 'active') {
console.log(`[manager] Auto-delegando de "${agent.agent_name}" para "${delegateAgent.agent_name}"`);
if (cb) cb({
type: 'execution_output',
executionId: execId,
agentId,
data: { type: 'system', content: `Delegando para ${delegateAgent.agent_name}...` },
});
setTimeout(() => {
try {
executeTask(delegateTo, result.result, null, wsCallback, {
delegatedFrom: agent.agent_name,
originalTask: taskText,
});
} catch (delegateErr) {
console.error(`[manager] Erro ao delegar para "${delegateAgent.agent_name}":`, delegateErr.message);
if (cb) cb({ type: 'execution_error', executionId: execId, agentId: delegateTo, data: { error: delegateErr.message } });
}
}, 3000);
}
}
}, },
} },
agentSecrets
); );
if (!executionId) { if (!executionId) {
@@ -185,28 +319,46 @@ export function executeTask(agentId, task, instructions, wsCallback, metadata =
return executionId; return executionId;
} }
function updateRecentBuffer(executionId, updates) { async function updateExecutionRecord(agentId, executionId, updates) {
const entry = recentExecBuffer.find((e) => e.executionId === executionId); await withLock(`agent:${agentId}`, () => {
if (entry) Object.assign(entry, updates); const agent = agentsStore.getById(agentId);
} if (!agent) return;
const executions = (agent.executions || []).map((exec) =>
function updateExecutionRecord(agentId, executionId, updates) { exec.executionId === executionId ? { ...exec, ...updates } : exec
const agent = agentsStore.getById(agentId); );
if (!agent) return; agentsStore.update(agentId, { executions });
const executions = (agent.executions || []).map((exec) => });
exec.executionId === executionId ? { ...exec, ...updates } : exec
);
agentsStore.update(agentId, { executions });
} }
export function getRecentExecutions(limit = 20) { export function getRecentExecutions(limit = 20) {
return recentExecBuffer.slice(0, Math.min(limit, MAX_RECENT)); return recentExecBuffer.slice(0, Math.min(limit, MAX_RECENT));
} }
async function executeWithRetry(agentId, taskDescription, metadata, maxRetries = 10, baseDelay = 30000) {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
executeTask(agentId, taskDescription, null, null, metadata);
return;
} catch (err) {
if (err.message.includes('Limite de execuções simultâneas') && attempt < maxRetries) {
const delay = baseDelay + Math.random() * 10000;
console.log(`[manager] Agendamento aguardando slot (tentativa ${attempt}/${maxRetries}), retry em ${(delay / 1000).toFixed(0)}s`);
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}
throw err;
}
}
}
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`);
if (!cron.validate(cronExpression)) {
throw new Error(`Expressão cron inválida: ${cronExpression}`);
}
const scheduleId = uuidv4(); const scheduleId = uuidv4();
const items = schedulesStore.getAll(); const items = schedulesStore.getAll();
items.push({ items.push({
@@ -222,7 +374,9 @@ export function scheduleTask(agentId, taskDescription, cronExpression, wsCallbac
schedulesStore.save(items); schedulesStore.save(items);
scheduler.schedule(scheduleId, cronExpression, () => { scheduler.schedule(scheduleId, cronExpression, () => {
executeTask(agentId, taskDescription, null, null, { source: 'schedule', scheduleId }); executeWithRetry(agentId, taskDescription, { source: 'schedule', scheduleId }).catch(err => {
console.log(`[manager] Agendamento ${scheduleId} falhou após retries: ${err.message}`);
});
}, false); }, false);
return { scheduleId, agentId, agentName: agent.agent_name, taskDescription, cronExpression }; return { scheduleId, agentId, agentName: agent.agent_name, taskDescription, cronExpression };
@@ -240,7 +394,9 @@ export function updateScheduleTask(scheduleId, data, wsCallback) {
const cronExpression = data.cronExpression || stored.cronExpression; const cronExpression = data.cronExpression || stored.cronExpression;
scheduler.updateSchedule(scheduleId, cronExpression, () => { scheduler.updateSchedule(scheduleId, cronExpression, () => {
executeTask(agentId, taskDescription, null, null, { source: 'schedule', scheduleId }); executeWithRetry(agentId, taskDescription, { source: 'schedule', scheduleId }).catch(err => {
console.log(`[manager] Agendamento ${scheduleId} falhou após retries: ${err.message}`);
});
}); });
schedulesStore.update(scheduleId, { agentId, agentName: agent.agent_name, taskDescription, cronExpression }); schedulesStore.update(scheduleId, { agentId, agentName: agent.agent_name, taskDescription, cronExpression });
@@ -290,6 +446,13 @@ export function continueConversation(agentId, sessionId, message, wsCallback) {
numTurns: result.numTurns || 0, numTurns: result.numTurns || 0,
sessionId: result.sessionId || sessionId, sessionId: result.sessionId || sessionId,
}); });
try {
const updated = executionsStore.getById(historyRecord.id);
if (updated) {
const report = generateAgentReport(updated);
if (cb) cb({ type: 'report_generated', executionId: execId, agentId, reportFile: report.filename });
}
} catch (e) { console.error('[manager] Erro ao gerar relatório:', e.message); }
if (cb) cb({ type: 'execution_complete', executionId: execId, agentId, data: result }); if (cb) cb({ type: 'execution_complete', executionId: execId, agentId, data: result });
}, },
} }
@@ -343,10 +506,8 @@ export function importAgent(data) {
export function restoreSchedules() { export function restoreSchedules() {
scheduler.restoreSchedules((agentId, taskDescription, scheduleId) => { scheduler.restoreSchedules((agentId, taskDescription, scheduleId) => {
try { executeWithRetry(agentId, taskDescription, { source: 'schedule', scheduleId }).catch(err => {
executeTask(agentId, taskDescription, null, null, { source: 'schedule', scheduleId });
} catch (err) {
console.log(`[manager] Erro ao executar tarefa agendada: ${err.message}`); console.log(`[manager] Erro ao executar tarefa agendada: ${err.message}`);
} });
}); });
} }

View File

@@ -1,7 +1,9 @@
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { pipelinesStore, agentsStore, executionsStore } from '../store/db.js'; import { pipelinesStore, agentsStore, executionsStore } from '../store/db.js';
import * as executor from './executor.js'; import * as executor from './executor.js';
import * as gitIntegration from './git-integration.js';
import { mem } from '../cache/index.js'; import { mem } from '../cache/index.js';
import { generatePipelineReport } from '../reports/generator.js';
const activePipelines = new Map(); const activePipelines = new Map();
const AGENT_MAP_TTL = 30_000; const AGENT_MAP_TTL = 30_000;
@@ -85,6 +87,7 @@ function executeStepAsPromise(agentConfig, prompt, pipelineState, wsCallback, pi
costUsd: result.costUsd || 0, costUsd: result.costUsd || 0,
durationMs: result.durationMs || 0, durationMs: result.durationMs || 0,
numTurns: result.numTurns || 0, numTurns: result.numTurns || 0,
sessionId: result.sessionId || '',
}); });
}, },
} }
@@ -99,9 +102,9 @@ function executeStepAsPromise(agentConfig, prompt, pipelineState, wsCallback, pi
}); });
} }
function waitForApproval(pipelineId, stepIndex, previousOutput, agentName, wsCallback) { function waitForApproval(executionId, pipelineId, stepIndex, previousOutput, agentName, wsCallback) {
return new Promise((resolve) => { return new Promise((resolve) => {
const state = activePipelines.get(pipelineId); const state = activePipelines.get(executionId);
if (!state) { resolve(false); return; } if (!state) { resolve(false); return; }
state.pendingApproval = { state.pendingApproval = {
@@ -115,6 +118,7 @@ function waitForApproval(pipelineId, stepIndex, previousOutput, agentName, wsCal
wsCallback({ wsCallback({
type: 'pipeline_approval_required', type: 'pipeline_approval_required',
pipelineId, pipelineId,
executionId,
stepIndex, stepIndex,
agentName, agentName,
previousOutput: previousOutput.slice(0, 3000), previousOutput: previousOutput.slice(0, 3000),
@@ -123,8 +127,16 @@ function waitForApproval(pipelineId, stepIndex, previousOutput, agentName, wsCal
}); });
} }
export function approvePipelineStep(pipelineId) { function findPipelineState(idOrExecId) {
const state = activePipelines.get(pipelineId); if (activePipelines.has(idOrExecId)) return activePipelines.get(idOrExecId);
for (const [, state] of activePipelines) {
if (state.pipelineId === idOrExecId) return state;
}
return null;
}
export function approvePipelineStep(id) {
const state = findPipelineState(id);
if (!state?.pendingApproval) return false; if (!state?.pendingApproval) return false;
const { resolve } = state.pendingApproval; const { resolve } = state.pendingApproval;
state.pendingApproval = null; state.pendingApproval = null;
@@ -132,8 +144,8 @@ export function approvePipelineStep(pipelineId) {
return true; return true;
} }
export function rejectPipelineStep(pipelineId) { export function rejectPipelineStep(id) {
const state = activePipelines.get(pipelineId); const state = findPipelineState(id);
if (!state?.pendingApproval) return false; if (!state?.pendingApproval) return false;
const { resolve } = state.pendingApproval; const { resolve } = state.pendingApproval;
state.pendingApproval = null; state.pendingApproval = null;
@@ -144,9 +156,11 @@ export function rejectPipelineStep(pipelineId) {
export async function executePipeline(pipelineId, initialInput, wsCallback, options = {}) { export async function executePipeline(pipelineId, initialInput, wsCallback, options = {}) {
const pl = pipelinesStore.getById(pipelineId); const pl = pipelinesStore.getById(pipelineId);
if (!pl) throw new Error(`Pipeline ${pipelineId} não encontrado`); if (!pl) throw new Error(`Pipeline ${pipelineId} não encontrado`);
if (pl.status !== 'active') throw new Error(`Pipeline "${pl.name}" está desativado`);
const pipelineState = { currentExecutionId: null, currentStep: 0, canceled: false, pendingApproval: null }; const executionId = uuidv4();
activePipelines.set(pipelineId, pipelineState); const pipelineState = { pipelineId, currentExecutionId: null, currentStep: 0, canceled: false, pendingApproval: null };
activePipelines.set(executionId, pipelineState);
const historyRecord = executionsStore.create({ const historyRecord = executionsStore.create({
type: 'pipeline', type: 'pipeline',
@@ -180,7 +194,7 @@ export async function executePipeline(pipelineId, initialInput, wsCallback, opti
wsCallback({ type: 'pipeline_status', pipelineId, status: 'awaiting_approval', stepIndex: i }); wsCallback({ type: 'pipeline_status', pipelineId, status: 'awaiting_approval', stepIndex: i });
} }
const approved = await waitForApproval(pipelineId, i, currentInput, prevAgentName, wsCallback); const approved = await waitForApproval(executionId, pipelineId, i, currentInput, prevAgentName, wsCallback);
if (!approved) { if (!approved) {
pipelineState.canceled = true; pipelineState.canceled = true;
@@ -201,8 +215,9 @@ export async function executePipeline(pipelineId, initialInput, wsCallback, opti
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 stepConfig = { ...agent.config }; const stepConfig = { ...agent.config };
if (options.workingDirectory) { const effectiveWorkdir = options.workingDirectory || pl.workingDirectory;
stepConfig.workingDirectory = options.workingDirectory; if (effectiveWorkdir) {
stepConfig.workingDirectory = effectiveWorkdir;
} }
const prompt = applyTemplate(step.inputTemplate, currentInput); const prompt = applyTemplate(step.inputTemplate, currentInput);
@@ -225,7 +240,7 @@ export async function executePipeline(pipelineId, initialInput, wsCallback, opti
totalCost += stepResult.costUsd; totalCost += stepResult.costUsd;
currentInput = stepResult.text; currentInput = stepResult.text;
results.push({ stepId: step.id, agentName: agent.agent_name, result: stepResult.text }); results.push({ stepId: step.id, agentId: step.agentId, agentName: agent.agent_name, result: stepResult.text, sessionId: stepResult.sessionId });
const current = executionsStore.getById(historyRecord.id); const current = executionsStore.getById(historyRecord.id);
const savedSteps = current ? (current.steps || []) : []; const savedSteps = current ? (current.steps || []) : [];
@@ -254,9 +269,22 @@ export async function executePipeline(pipelineId, initialInput, wsCallback, opti
costUsd: stepResult.costUsd, costUsd: stepResult.costUsd,
}); });
} }
if (i < steps.length - 1 && !pipelineState.canceled) {
if (wsCallback) {
wsCallback({ type: 'pipeline_summarizing', pipelineId, stepIndex: i, originalLength: currentInput.length });
}
const summarized = await executor.summarize(currentInput);
if (summarized !== currentInput) {
if (wsCallback) {
wsCallback({ type: 'pipeline_summarized', pipelineId, stepIndex: i, originalLength: currentInput.length, summarizedLength: summarized.length });
}
currentInput = summarized;
}
}
} }
activePipelines.delete(pipelineId); activePipelines.delete(executionId);
const finalStatus = pipelineState.canceled ? 'canceled' : 'completed'; const finalStatus = pipelineState.canceled ? 'canceled' : 'completed';
executionsStore.update(historyRecord.id, { executionsStore.update(historyRecord.id, {
@@ -265,16 +293,48 @@ export async function executePipeline(pipelineId, initialInput, wsCallback, opti
totalCostUsd: totalCost, totalCostUsd: totalCost,
}); });
if (!pipelineState.canceled && wsCallback) { if (!pipelineState.canceled) {
wsCallback({ type: 'pipeline_complete', pipelineId, results, totalCostUsd: totalCost }); if (options.repoName) {
try {
const repoDir = gitIntegration.getProjectDir(options.repoName);
const gitResult = await gitIntegration.commitAndPush(repoDir, pl.name, `Pipeline: ${pl.name}`);
if (gitResult.changed && wsCallback) {
wsCallback({
type: 'pipeline_step_output', pipelineId, stepIndex: steps.length - 1,
data: { type: 'success', content: `Git: commit ${gitResult.commitHash} pushed para ${options.repoName} (${gitResult.filesChanged} arquivos) → ${gitResult.commitUrl}` },
});
}
} catch (e) { console.error('[pipeline] Erro no auto-commit:', e.message); }
}
try {
const updated = executionsStore.getById(historyRecord.id);
if (updated) {
const report = generatePipelineReport(updated);
if (wsCallback) wsCallback({ type: 'report_generated', pipelineId, reportFile: report.filename });
}
} catch (e) { console.error('[pipeline] Erro ao gerar relatório:', e.message); }
const lastResult = results.length > 0 ? results[results.length - 1] : null;
if (wsCallback) wsCallback({
type: 'pipeline_complete',
pipelineId,
executionId,
results,
totalCostUsd: totalCost,
lastAgentId: lastResult?.agentId || '',
lastAgentName: lastResult?.agentName || '',
lastSessionId: lastResult?.sessionId || '',
});
} }
return results; return { executionId, results };
} catch (err) { } catch (err) {
activePipelines.delete(pipelineId); activePipelines.delete(executionId);
executionsStore.update(historyRecord.id, { executionsStore.update(historyRecord.id, {
status: 'error', status: 'error',
error: err.message, error: err.message,
failedAtStep: pipelineState.currentStep,
lastStepInput: currentInput.slice(0, 50000),
endedAt: new Date().toISOString(), endedAt: new Date().toISOString(),
totalCostUsd: totalCost, totalCostUsd: totalCost,
}); });
@@ -290,8 +350,14 @@ export async function executePipeline(pipelineId, initialInput, wsCallback, opti
} }
} }
export function cancelPipeline(pipelineId) { export function cancelPipeline(id) {
const state = activePipelines.get(pipelineId); let executionId = id;
let state = activePipelines.get(id);
if (!state) {
for (const [execId, s] of activePipelines) {
if (s.pipelineId === id) { state = s; executionId = execId; break; }
}
}
if (!state) return false; if (!state) return false;
state.canceled = true; state.canceled = true;
if (state.pendingApproval) { if (state.pendingApproval) {
@@ -299,13 +365,190 @@ export function cancelPipeline(pipelineId) {
state.pendingApproval = null; state.pendingApproval = null;
} }
if (state.currentExecutionId) executor.cancel(state.currentExecutionId); if (state.currentExecutionId) executor.cancel(state.currentExecutionId);
activePipelines.delete(pipelineId); activePipelines.delete(executionId);
const allExecs = executionsStore.getAll();
const exec = allExecs.find(e => e.pipelineId === state.pipelineId && (e.status === 'running' || e.status === 'awaiting_approval'));
if (exec) {
executionsStore.update(exec.id, { status: 'canceled', endedAt: new Date().toISOString() });
}
return true; return true;
} }
export async function resumePipeline(executionId, wsCallback, options = {}) {
const prevExec = executionsStore.getById(executionId);
if (!prevExec) throw new Error('Execução não encontrada');
if (prevExec.status !== 'error') throw new Error('Só é possível retomar execuções com erro');
if (prevExec.type !== 'pipeline') throw new Error('Execução não é de pipeline');
const pl = pipelinesStore.getById(prevExec.pipelineId);
if (!pl) throw new Error(`Pipeline ${prevExec.pipelineId} não encontrado`);
const startStep = prevExec.failedAtStep || 0;
const initialInput = prevExec.lastStepInput || prevExec.input;
const newExecId = uuidv4();
const pipelineState = { pipelineId: prevExec.pipelineId, currentExecutionId: null, currentStep: startStep, canceled: false, pendingApproval: null };
activePipelines.set(newExecId, pipelineState);
const prevSteps = Array.isArray(prevExec.steps) ? [...prevExec.steps] : [];
const prevCost = prevExec.totalCostUsd || 0;
const historyRecord = executionsStore.create({
type: 'pipeline',
pipelineId: prevExec.pipelineId,
pipelineName: pl.name,
input: prevExec.input,
resumedFrom: executionId,
resumedAtStep: startStep,
status: 'running',
startedAt: new Date().toISOString(),
steps: prevSteps,
totalCostUsd: prevCost,
});
const steps = buildSteps(pl.steps);
const results = prevSteps.map(s => ({ stepId: s.stepId, agentId: s.agentId, agentName: s.agentName, result: s.result, sessionId: '' }));
let currentInput = initialInput;
let totalCost = prevCost;
try {
for (let i = startStep; i < steps.length; i++) {
if (pipelineState.canceled) break;
const step = steps[i];
pipelineState.currentStep = i;
if (step.requiresApproval && i > 0) {
const prevAgentName = results.length > 0 ? results[results.length - 1].agentName : '';
executionsStore.update(historyRecord.id, { status: 'awaiting_approval' });
if (wsCallback) wsCallback({ type: 'pipeline_status', pipelineId: prevExec.pipelineId, status: 'awaiting_approval', stepIndex: i });
const approved = await waitForApproval(newExecId, prevExec.pipelineId, i, currentInput, prevAgentName, wsCallback);
if (!approved) {
pipelineState.canceled = true;
executionsStore.update(historyRecord.id, { status: 'rejected', endedAt: new Date().toISOString(), totalCostUsd: totalCost });
if (wsCallback) wsCallback({ type: 'pipeline_rejected', pipelineId: prevExec.pipelineId, stepIndex: i });
break;
}
executionsStore.update(historyRecord.id, { status: 'running' });
}
if (pipelineState.canceled) break;
const agent = agentsStore.getById(step.agentId);
if (!agent) throw new Error(`Agente ${step.agentId} não encontrado no passo ${i}`);
if (agent.status !== 'active') throw new Error(`Agente ${agent.agent_name} está inativo`);
const stepConfig = { ...agent.config };
const effectiveWorkdir = options.workingDirectory || pl.workingDirectory;
if (effectiveWorkdir) stepConfig.workingDirectory = effectiveWorkdir;
const prompt = applyTemplate(step.inputTemplate, currentInput);
const stepStart = new Date().toISOString();
if (wsCallback) {
wsCallback({
type: 'pipeline_step_start',
pipelineId: prevExec.pipelineId,
stepIndex: i,
stepId: step.id,
agentName: agent.agent_name,
totalSteps: steps.length,
resumed: true,
});
}
const stepResult = await executeStepAsPromise(stepConfig, prompt, pipelineState, wsCallback, prevExec.pipelineId, i);
if (pipelineState.canceled) break;
totalCost += stepResult.costUsd;
currentInput = stepResult.text;
results.push({ stepId: step.id, agentId: step.agentId, agentName: agent.agent_name, result: stepResult.text, sessionId: stepResult.sessionId });
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: stepResult.text,
startedAt: stepStart,
endedAt: new Date().toISOString(),
status: 'completed',
costUsd: stepResult.costUsd,
durationMs: stepResult.durationMs,
numTurns: stepResult.numTurns,
});
executionsStore.update(historyRecord.id, { steps: savedSteps, totalCostUsd: totalCost });
if (wsCallback) {
wsCallback({
type: 'pipeline_step_complete',
pipelineId: prevExec.pipelineId,
stepIndex: i,
stepId: step.id,
result: stepResult.text.slice(0, 500),
costUsd: stepResult.costUsd,
});
}
if (i < steps.length - 1 && !pipelineState.canceled) {
if (wsCallback) wsCallback({ type: 'pipeline_summarizing', pipelineId: prevExec.pipelineId, stepIndex: i, originalLength: currentInput.length });
const summarized = await executor.summarize(currentInput);
if (summarized !== currentInput) {
if (wsCallback) wsCallback({ type: 'pipeline_summarized', pipelineId: prevExec.pipelineId, stepIndex: i, originalLength: currentInput.length, summarizedLength: summarized.length });
currentInput = summarized;
}
}
}
activePipelines.delete(newExecId);
const finalStatus = pipelineState.canceled ? 'canceled' : 'completed';
executionsStore.update(historyRecord.id, { status: finalStatus, endedAt: new Date().toISOString(), totalCostUsd: totalCost });
if (!pipelineState.canceled) {
try {
const updated = executionsStore.getById(historyRecord.id);
if (updated) {
const report = generatePipelineReport(updated);
if (wsCallback) wsCallback({ type: 'report_generated', pipelineId: prevExec.pipelineId, reportFile: report.filename });
}
} catch (e) { console.error('[pipeline] Erro ao gerar relatório:', e.message); }
const lastResult = results.length > 0 ? results[results.length - 1] : null;
if (wsCallback) wsCallback({
type: 'pipeline_complete',
pipelineId: prevExec.pipelineId,
executionId: newExecId,
results,
totalCostUsd: totalCost,
lastAgentId: lastResult?.agentId || '',
lastAgentName: lastResult?.agentName || '',
lastSessionId: lastResult?.sessionId || '',
});
}
return { executionId: newExecId, results };
} catch (err) {
activePipelines.delete(newExecId);
executionsStore.update(historyRecord.id, {
status: 'error',
error: err.message,
failedAtStep: pipelineState.currentStep,
lastStepInput: currentInput.slice(0, 50000),
endedAt: new Date().toISOString(),
totalCostUsd: totalCost,
});
if (wsCallback) wsCallback({ type: 'pipeline_error', pipelineId: prevExec.pipelineId, stepIndex: pipelineState.currentStep, error: err.message });
throw err;
}
}
export function getActivePipelines() { export function getActivePipelines() {
return Array.from(activePipelines.entries()).map(([id, state]) => ({ return Array.from(activePipelines.entries()).map(([id, state]) => ({
pipelineId: id, executionId: id,
pipelineId: state.pipelineId,
currentStep: state.currentStep, currentStep: state.currentStep,
currentExecutionId: state.currentExecutionId, currentExecutionId: state.currentExecutionId,
pendingApproval: !!state.pendingApproval, pendingApproval: !!state.pendingApproval,
@@ -318,6 +561,7 @@ export function createPipeline(data) {
return pipelinesStore.create({ return pipelinesStore.create({
name: data.name, name: data.name,
description: data.description || '', description: data.description || '',
workingDirectory: data.workingDirectory || '',
steps: buildSteps(data.steps), steps: buildSteps(data.steps),
status: data.status || 'active', status: data.status || 'active',
}); });
@@ -329,6 +573,7 @@ export function updatePipeline(id, data) {
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.workingDirectory !== undefined) updateData.workingDirectory = data.workingDirectory;
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);

View File

@@ -17,7 +17,11 @@ function addToHistory(entry) {
function matchesCronPart(part, value) { function matchesCronPart(part, value) {
if (part === '*') return true; if (part === '*') return true;
if (part.startsWith('*/')) return value % parseInt(part.slice(2)) === 0; if (part.startsWith('*/')) {
const divisor = parseInt(part.slice(2));
if (!divisor || divisor <= 0) return false;
return value % divisor === 0;
}
if (part.includes(',')) return part.split(',').map(Number).includes(value); if (part.includes(',')) return part.split(',').map(Number).includes(value);
if (part.includes('-')) { if (part.includes('-')) {
const [start, end] = part.split('-').map(Number); const [start, end] = part.split('-').map(Number);
@@ -66,6 +70,17 @@ export function schedule(taskId, cronExpr, callback, persist = true) {
if (schedules.has(taskId)) unschedule(taskId, false); if (schedules.has(taskId)) unschedule(taskId, false);
if (!cron.validate(cronExpr)) throw new Error(`Expressão cron inválida: ${cronExpr}`); if (!cron.validate(cronExpr)) throw new Error(`Expressão cron inválida: ${cronExpr}`);
const MIN_INTERVAL_PARTS = cronExpr.split(' ');
if (MIN_INTERVAL_PARTS[0] === '*' && MIN_INTERVAL_PARTS[1] === '*') {
throw new Error('Intervalo mínimo de agendamento é 5 minutos. Use */5 ou maior.');
}
if (MIN_INTERVAL_PARTS[0].startsWith('*/')) {
const interval = parseInt(MIN_INTERVAL_PARTS[0].slice(2));
if (interval < 5 && MIN_INTERVAL_PARTS[1] === '*') {
throw new Error(`Intervalo mínimo de agendamento é 5 minutos. Recebido: ${cronExpr}`);
}
}
const task = cron.schedule( const task = cron.schedule(
cronExpr, cronExpr,
() => { () => {
@@ -145,6 +160,13 @@ export function restoreSchedules(executeFn) {
if (restored > 0) console.log(`[scheduler] ${restored} agendamento(s) restaurado(s)`); if (restored > 0) console.log(`[scheduler] ${restored} agendamento(s) restaurado(s)`);
} }
export function stopAll() {
for (const [, entry] of schedules) {
entry.task.stop();
}
schedules.clear();
}
export function on(event, listener) { export function on(event, listener) {
emitter.on(event, listener); emitter.on(event, listener);
} }

194
src/reports/generator.js Normal file
View File

@@ -0,0 +1,194 @@
import { writeFileSync, mkdirSync, existsSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const REPORTS_DIR = join(__dirname, '..', '..', 'data', 'reports');
const EXTERNAL_REPORTS_DIR = process.env.AGENT_REPORTS_DIR || join(process.env.HOME || '/home/fred', 'agent_reports');
function ensureDir() {
if (!existsSync(REPORTS_DIR)) mkdirSync(REPORTS_DIR, { recursive: true });
if (!existsSync(EXTERNAL_REPORTS_DIR)) mkdirSync(EXTERNAL_REPORTS_DIR, { recursive: true });
}
function sanitizeFilename(name) {
return name.replace(/[^a-zA-Z0-9À-ÿ_-]/g, '_').slice(0, 60);
}
function timestamp() {
return new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
}
function formatDate(iso) {
if (!iso) return '—';
return new Date(iso).toLocaleString('pt-BR', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit', second: '2-digit',
});
}
function formatDuration(startIso, endIso) {
if (!startIso || !endIso) return '—';
const ms = new Date(endIso) - new Date(startIso);
if (ms < 0) return '—';
const s = Math.floor(ms / 1000);
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
const h = Math.floor(m / 60);
if (h > 0) return `${h}h ${m % 60}m ${s % 60}s`;
return `${m}m ${s % 60}s`;
}
export function generateAgentReport(execution) {
ensureDir();
const name = execution.agentName || 'Agente';
const filename = `agente_${sanitizeFilename(name)}_${timestamp()}.md`;
const filepath = join(REPORTS_DIR, filename);
const status = execution.status === 'completed' ? '✅ Concluído' : '❌ Erro';
const cost = (execution.costUsd || execution.totalCostUsd || 0).toFixed(4);
const lines = [
`# Relatório de Execução — ${name}`,
'',
`**Data:** ${formatDate(execution.startedAt)}`,
`**Status:** ${status}`,
`**Duração:** ${formatDuration(execution.startedAt, execution.endedAt)}`,
`**Custo:** $${cost}`,
`**Turnos:** ${execution.numTurns || '—'}`,
`**Session ID:** \`${execution.sessionId || '—'}\``,
'',
'---',
'',
'## Tarefa',
'',
execution.task || '_(sem tarefa definida)_',
'',
];
if (execution.instructions) {
lines.push('## Instruções Adicionais', '', execution.instructions, '');
}
lines.push('---', '', '## Resultado', '');
if (execution.status === 'error' && execution.error) {
lines.push('### Erro', '', '```', execution.error, '```', '');
}
if (execution.result) {
lines.push(execution.result);
} else {
lines.push('_(sem resultado textual)_');
}
lines.push('', '---', '', `_Relatório gerado automaticamente em ${formatDate(new Date().toISOString())}_`);
const content = lines.join('\n');
writeFileSync(filepath, content, 'utf-8');
try { writeFileSync(join(EXTERNAL_REPORTS_DIR, filename), content, 'utf-8'); } catch {}
return { filename, filepath };
}
export function generatePipelineReport(execution) {
ensureDir();
const name = execution.pipelineName || 'Pipeline';
const filename = `pipeline_${sanitizeFilename(name)}_${timestamp()}.md`;
const filepath = join(REPORTS_DIR, filename);
const status = execution.status === 'completed' ? '✅ Concluído'
: execution.status === 'error' ? '❌ Erro'
: execution.status === 'canceled' ? '⚠️ Cancelado'
: execution.status;
const totalCost = (execution.totalCostUsd || 0).toFixed(4);
const steps = Array.isArray(execution.steps) ? execution.steps : [];
const lines = [
`# Relatório de Pipeline — ${name}`,
'',
`**Data:** ${formatDate(execution.startedAt)}`,
`**Status:** ${status}`,
`**Duração:** ${formatDuration(execution.startedAt, execution.endedAt)}`,
`**Custo Total:** $${totalCost}`,
`**Passos:** ${steps.length}`,
'',
'---',
'',
'## Input Inicial',
'',
execution.input || '_(sem input)_',
'',
'---',
'',
'## Execução dos Passos',
'',
];
steps.forEach((step, i) => {
const stepStatus = step.status === 'completed' ? '✅' : step.status === 'error' ? '❌' : '⏳';
const stepCost = (step.costUsd || 0).toFixed(4);
const stepDuration = formatDuration(step.startedAt, step.endedAt);
lines.push(
`### Passo ${i + 1}${step.agentName || 'Agente'} ${stepStatus}`,
'',
`| Propriedade | Valor |`,
`|-------------|-------|`,
`| Status | ${step.status || '—'} |`,
`| Duração | ${stepDuration} |`,
`| Custo | $${stepCost} |`,
`| Turnos | ${step.numTurns || '—'} |`,
'',
);
if (step.prompt) {
lines.push(
'<details>',
'<summary>Prompt utilizado</summary>',
'',
'```',
step.prompt,
'```',
'',
'</details>',
'',
);
}
if (step.result) {
lines.push('**Resultado:**', '', step.result, '');
} else if (step.status === 'error') {
lines.push('**Erro:** Passo falhou durante a execução.', '');
}
if (i < steps.length - 1) {
lines.push('---', '');
}
});
if (execution.error) {
lines.push('---', '', '## Erro da Pipeline', '', '```', execution.error, '```', '');
}
const lastStep = steps[steps.length - 1];
if (execution.status === 'completed' && lastStep?.result) {
lines.push(
'---',
'',
'## Resultado Final',
'',
lastStep.result,
'',
);
}
lines.push('---', '', `_Relatório gerado automaticamente em ${formatDate(new Date().toISOString())}_`);
const content = lines.join('\n');
writeFileSync(filepath, content, 'utf-8');
try { writeFileSync(join(EXTERNAL_REPORTS_DIR, filename), content, 'utf-8'); } catch {}
return { filename, filepath };
}

View File

@@ -1,15 +1,43 @@
import { Router } from 'express'; import { Router } from 'express';
import { execSync } from 'child_process'; import { execFile, spawn as spawnProcess } from 'child_process';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import crypto from 'crypto'; import crypto from 'crypto';
import os from 'os'; import os from 'os';
import multer from 'multer';
import * as manager from '../agents/manager.js'; import * as manager from '../agents/manager.js';
import { tasksStore, settingsStore, executionsStore, webhooksStore } from '../store/db.js'; import { tasksStore, settingsStore, executionsStore, webhooksStore, notificationsStore, secretsStore, agentVersionsStore } from '../store/db.js';
import * as scheduler from '../agents/scheduler.js'; import * as scheduler from '../agents/scheduler.js';
import * as pipeline from '../agents/pipeline.js'; import * as pipeline from '../agents/pipeline.js';
import { getBinPath, updateMaxConcurrent } from '../agents/executor.js'; import * as gitIntegration from '../agents/git-integration.js';
import { getBinPath, updateMaxConcurrent, cancelAllExecutions, getActiveExecutions } from '../agents/executor.js';
import { invalidateAgentMapCache } from '../agents/pipeline.js'; import { invalidateAgentMapCache } from '../agents/pipeline.js';
import { cached } from '../cache/index.js'; import { cached } from '../cache/index.js';
import { readdirSync, readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync, statSync, createReadStream, rmSync } from 'fs';
import { join, dirname, resolve as pathResolve, extname, basename, relative } from 'path';
import { createGzip } from 'zlib';
import { Readable } from 'stream';
import { fileURLToPath } from 'url';
const __apiDirname = dirname(fileURLToPath(import.meta.url));
const REPORTS_DIR = join(__apiDirname, '..', '..', 'data', 'reports');
const UPLOADS_DIR = join(__apiDirname, '..', '..', 'data', 'uploads');
if (!existsSync(UPLOADS_DIR)) mkdirSync(UPLOADS_DIR, { recursive: true });
const upload = multer({
storage: multer.diskStorage({
destination: (req, file, cb) => {
const sessionDir = join(UPLOADS_DIR, req.uploadSessionId || 'tmp');
if (!existsSync(sessionDir)) mkdirSync(sessionDir, { recursive: true });
cb(null, sessionDir);
},
filename: (req, file, cb) => {
const safe = file.originalname.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 200);
cb(null, `${Date.now()}-${safe}`);
},
}),
limits: { fileSize: 10 * 1024 * 1024, files: 20 },
});
const router = Router(); const router = Router();
export const hookRouter = Router(); export const hookRouter = Router();
@@ -116,12 +144,47 @@ router.delete('/agents/:id', (req, res) => {
} }
}); });
router.post('/agents/:id/execute', (req, res) => { router.post('/uploads', (req, res, next) => {
req.uploadSessionId = uuidv4();
next();
}, upload.array('files', 20), (req, res) => {
try { try {
const { task, instructions } = req.body; const files = (req.files || []).map(f => ({
originalName: f.originalname,
path: f.path,
size: f.size,
}));
res.json({ sessionId: req.uploadSessionId, files });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
function buildContextFilesPrompt(contextFiles) {
if (!Array.isArray(contextFiles) || contextFiles.length === 0) return '';
const lines = contextFiles.map(f => `- ${f.path} (${f.originalName})`);
return `\n\nArquivos de contexto anexados (leia cada um deles antes de iniciar):\n${lines.join('\n')}`;
}
router.post('/agents/:id/execute', async (req, res) => {
try {
const { task, instructions, contextFiles, workingDirectory, repoName, repoBranch } = 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(req.params.id, task, instructions, (msg) => wsCallback(msg, clientId)); const filesPrompt = buildContextFilesPrompt(contextFiles);
const fullTask = task + filesPrompt;
const metadata = {};
if (repoName) {
const syncResult = await gitIntegration.cloneOrPull(repoName, repoBranch || null);
metadata.workingDirectoryOverride = syncResult.dir;
metadata.repoName = repoName;
metadata.repoBranch = repoBranch || null;
} else if (workingDirectory) {
metadata.workingDirectoryOverride = workingDirectory;
}
const executionId = manager.executeTask(req.params.id, fullTask, instructions, (msg) => wsCallback(msg, clientId), metadata);
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;
@@ -163,6 +226,111 @@ router.get('/agents/:id/export', (req, res) => {
} }
}); });
router.get('/agents/:id/secrets', (req, res) => {
try {
const agent = manager.getAgentById(req.params.id);
if (!agent) return res.status(404).json({ error: 'Agente não encontrado' });
const all = secretsStore.getAll();
const agentSecrets = all
.filter((s) => s.agentId === req.params.id)
.map((s) => ({ name: s.name, created_at: s.created_at }));
res.json(agentSecrets);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.post('/agents/:id/secrets', (req, res) => {
try {
const agent = manager.getAgentById(req.params.id);
if (!agent) return res.status(404).json({ error: 'Agente não encontrado' });
const { name, value } = req.body;
if (!name || !value) return res.status(400).json({ error: 'name e value são obrigatórios' });
const all = secretsStore.getAll();
const existing = all.find((s) => s.agentId === req.params.id && s.name === name);
if (existing) {
secretsStore.update(existing.id, { value });
return res.json({ name, updated: true });
}
secretsStore.create({ agentId: req.params.id, name, value });
res.status(201).json({ name, created: true });
} catch (err) {
res.status(400).json({ error: err.message });
}
});
router.delete('/agents/:id/secrets/:name', (req, res) => {
try {
const secretName = decodeURIComponent(req.params.name);
const all = secretsStore.getAll();
const secret = all.find((s) => s.agentId === req.params.id && s.name === secretName);
if (!secret) return res.status(404).json({ error: 'Secret não encontrado' });
secretsStore.delete(secret.id);
res.status(204).send();
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.get('/agents/:id/versions', (req, res) => {
try {
const agent = manager.getAgentById(req.params.id);
if (!agent) return res.status(404).json({ error: 'Agente não encontrado' });
const all = agentVersionsStore.getAll();
const versions = all
.filter((v) => v.agentId === req.params.id)
.sort((a, b) => b.version - a.version);
res.json(versions);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.post('/agents/:id/versions/:version/restore', (req, res) => {
try {
const agent = manager.getAgentById(req.params.id);
if (!agent) return res.status(404).json({ error: 'Agente não encontrado' });
const versionNum = parseInt(req.params.version);
const all = agentVersionsStore.getAll();
const target = all.find((v) => v.agentId === req.params.id && v.version === versionNum);
if (!target) return res.status(404).json({ error: 'Versão não encontrada' });
if (!target.snapshot) return res.status(400).json({ error: 'Snapshot da versão não disponível' });
const { id, created_at, updated_at, ...snapshotData } = target.snapshot;
const restored = manager.updateAgent(req.params.id, snapshotData);
if (!restored) return res.status(500).json({ error: 'Falha ao restaurar versão' });
invalidateAgentMapCache();
agentVersionsStore.create({
agentId: req.params.id,
version: Math.max(...all.filter((v) => v.agentId === req.params.id).map((v) => v.version), 0) + 1,
changes: ['restore'],
changelog: `Restaurado para versão ${versionNum}`,
snapshot: structuredClone(restored),
});
res.json(restored);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.post('/agents/:id/duplicate', async (req, res) => {
try {
const agent = manager.getAgentById(req.params.id);
if (!agent) return res.status(404).json({ error: 'Agente não encontrado' });
const { id, created_at, updated_at, executions, ...rest } = agent;
const duplicate = {
...rest,
agent_name: `${agent.agent_name} (cópia)`,
executions: [],
status: 'active',
};
const created = manager.createAgent(duplicate);
invalidateAgentMapCache();
res.status(201).json(created);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
router.get('/tasks', (req, res) => { router.get('/tasks', (req, res) => {
try { try {
res.json(tasksStore.getAll()); res.json(tasksStore.getAll());
@@ -304,17 +472,29 @@ router.delete('/pipelines/:id', (req, res) => {
} }
}); });
router.post('/pipelines/:id/execute', (req, res) => { router.post('/pipelines/:id/execute', async (req, res) => {
try { try {
const { input, workingDirectory } = req.body; const { input, workingDirectory, contextFiles, repoName, repoBranch } = req.body;
if (!input) return res.status(400).json({ error: 'input é obrigatório' }); if (!input) return res.status(400).json({ error: 'input é obrigatório' });
const clientId = req.headers['x-client-id'] || null; const clientId = req.headers['x-client-id'] || null;
const options = {}; const options = {};
if (workingDirectory) options.workingDirectory = workingDirectory;
pipeline.executePipeline(req.params.id, input, (msg) => wsCallback(msg, clientId), options).catch(() => {}); if (repoName) {
const syncResult = await gitIntegration.cloneOrPull(repoName, repoBranch || null);
options.workingDirectory = syncResult.dir;
options.repoName = repoName;
options.repoBranch = repoBranch || null;
} else if (workingDirectory) {
options.workingDirectory = workingDirectory;
}
const filesPrompt = buildContextFilesPrompt(contextFiles);
const fullInput = input + filesPrompt;
const result = pipeline.executePipeline(req.params.id, fullInput, (msg) => wsCallback(msg, clientId), options);
result.catch(() => {});
res.status(202).json({ pipelineId: req.params.id, status: 'started' }); res.status(202).json({ pipelineId: req.params.id, status: 'started' });
} catch (err) { } catch (err) {
const status = err.message.includes('não encontrado') ? 404 : 400; const status = err.message.includes('não encontrado') || err.message.includes('desativado') ? 400 : 500;
res.status(status).json({ error: err.message }); res.status(status).json({ error: err.message });
} }
}); });
@@ -349,6 +529,18 @@ router.post('/pipelines/:id/reject', (req, res) => {
} }
}); });
router.post('/pipelines/resume/:executionId', async (req, res) => {
try {
const clientId = req.headers['x-client-id'] || null;
const result = pipeline.resumePipeline(req.params.executionId, (msg) => wsCallback(msg, clientId));
result.catch(() => {});
res.status(202).json({ status: 'resumed' });
} catch (err) {
const status = err.message.includes('não encontrad') ? 404 : 400;
res.status(status).json({ error: err.message });
}
});
router.get('/webhooks', (req, res) => { router.get('/webhooks', (req, res) => {
try { try {
res.json(webhooksStore.getAll()); res.json(webhooksStore.getAll());
@@ -388,11 +580,11 @@ router.put('/webhooks/:id', (req, res) => {
try { try {
const existing = webhooksStore.getById(req.params.id); const existing = webhooksStore.getById(req.params.id);
if (!existing) return res.status(404).json({ error: 'Webhook não encontrado' }); if (!existing) return res.status(404).json({ error: 'Webhook não encontrado' });
const allowed = ['name', 'targetType', 'targetId', 'active'];
const updateData = {}; const updateData = {};
if (req.body.name !== undefined) updateData.name = req.body.name; for (const key of allowed) {
if (req.body.active !== undefined) updateData.active = !!req.body.active; if (req.body[key] !== undefined) updateData[key] = req.body[key];
}
const updated = webhooksStore.update(req.params.id, updateData); const updated = webhooksStore.update(req.params.id, updateData);
res.json(updated); res.json(updated);
} catch (err) { } catch (err) {
@@ -400,6 +592,29 @@ router.put('/webhooks/:id', (req, res) => {
} }
}); });
router.post('/webhooks/:id/test', async (req, res) => {
try {
const wh = webhooksStore.getById(req.params.id);
if (!wh) return res.status(404).json({ error: 'Webhook não encontrado' });
if (wh.targetType === 'agent') {
const executionId = manager.executeTask(wh.targetId, 'Teste de webhook', '', (msg) => {
if (wsbroadcast) wsbroadcast(msg);
}, { source: 'webhook-test', webhookId: wh.id });
res.status(202).json({ success: true, message: 'Webhook disparado com sucesso', executionId });
} else if (wh.targetType === 'pipeline') {
pipeline.executePipeline(wh.targetId, 'Teste de webhook', (msg) => {
if (wsbroadcast) wsbroadcast(msg);
}).catch(() => {});
res.status(202).json({ success: true, message: 'Pipeline disparada com sucesso', pipelineId: wh.targetId });
} else {
return res.status(400).json({ error: `targetType inválido: ${wh.targetType}` });
}
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.delete('/webhooks/:id', (req, res) => { router.delete('/webhooks/:id', (req, res) => {
try { try {
const deleted = webhooksStore.delete(req.params.id); const deleted = webhooksStore.delete(req.params.id);
@@ -434,12 +649,12 @@ hookRouter.post('/:token', (req, res) => {
res.status(202).json({ executionId, status: 'started', webhook: webhook.name }); res.status(202).json({ executionId, status: 'started', webhook: webhook.name });
} else if (webhook.targetType === 'pipeline') { } else if (webhook.targetType === 'pipeline') {
const input = payload.input || payload.task || payload.message || 'Webhook trigger'; const input = payload.input || payload.task || payload.message || 'Webhook trigger';
const options = {};
if (payload.workingDirectory) options.workingDirectory = payload.workingDirectory;
pipeline.executePipeline(webhook.targetId, input, (msg) => { pipeline.executePipeline(webhook.targetId, input, (msg) => {
if (wsbroadcast) wsbroadcast(msg); if (wsbroadcast) wsbroadcast(msg);
}, options).catch(() => {}); }).catch(() => {});
res.status(202).json({ pipelineId: webhook.targetId, status: 'started', webhook: webhook.name }); res.status(202).json({ pipelineId: webhook.targetId, status: 'started', webhook: webhook.name });
} else {
return res.status(400).json({ error: `targetType inválido: ${webhook.targetType}` });
} }
} catch (err) { } catch (err) {
const status = err.message.includes('não encontrado') ? 404 : 500; const status = err.message.includes('não encontrado') ? 404 : 500;
@@ -553,11 +768,16 @@ router.get('/system/status', (req, res) => {
let claudeVersionCache = null; let claudeVersionCache = null;
router.get('/system/info', (req, res) => { router.get('/system/info', async (req, res) => {
try { try {
if (claudeVersionCache === null) { if (claudeVersionCache === null) {
try { try {
claudeVersionCache = execSync(`${getBinPath()} --version`, { timeout: 5000 }).toString().trim(); claudeVersionCache = await new Promise((resolve, reject) => {
execFile(getBinPath(), ['--version'], { timeout: 5000 }, (err, stdout) => {
if (err) reject(err);
else resolve(stdout.toString().trim());
});
});
} catch { } catch {
claudeVersionCache = 'N/A'; claudeVersionCache = 'N/A';
} }
@@ -641,6 +861,23 @@ router.get('/executions/active', (req, res) => {
} }
}); });
router.post('/executions/cancel-all', (req, res) => {
try {
const activePipelines = pipeline.getActivePipelines();
for (const p of activePipelines) {
pipeline.cancelPipeline(p.pipelineId);
}
cancelAllExecutions();
const running = executionsStore.getAll().filter(e => e.status === 'running' || e.status === 'awaiting_approval');
for (const e of running) {
executionsStore.update(e.id, { status: 'canceled', endedAt: new Date().toISOString() });
}
res.json({ cancelled: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.get('/executions/recent', (req, res) => { router.get('/executions/recent', (req, res) => {
try { try {
const limit = parseInt(req.query.limit) || 20; const limit = parseInt(req.query.limit) || 20;
@@ -652,4 +889,453 @@ router.get('/executions/recent', (req, res) => {
} }
}); });
router.post('/executions/:id/retry', async (req, res) => {
try {
const execution = executionsStore.getById(req.params.id);
if (!execution) return res.status(404).json({ error: 'Execução não encontrada' });
if (!['error', 'canceled'].includes(execution.status)) {
return res.status(400).json({ error: 'Apenas execuções com erro ou canceladas podem ser reexecutadas' });
}
const clientId = req.headers['x-client-id'] || null;
if (execution.type === 'pipeline') {
pipeline.executePipeline(execution.pipelineId, execution.input, (msg) => wsCallback(msg, clientId)).catch(() => {});
return res.json({ success: true, message: 'Pipeline reexecutado' });
}
manager.executeTask(execution.agentId, execution.task, null, (msg) => wsCallback(msg, clientId));
res.json({ success: true, message: 'Execução reiniciada' });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
router.get('/executions/export', async (req, res) => {
try {
const executions = executionsStore.getAll();
const headers = ['ID', 'Tipo', 'Nome', 'Status', 'Início', 'Fim', 'Duração (ms)', 'Custo (USD)', 'Turnos'];
const rows = executions.map(e => [
e.id,
e.type || 'agent',
e.agentName || e.pipelineName || '',
e.status,
e.startedAt || '',
e.endedAt || '',
e.durationMs || '',
e.costUsd || e.totalCostUsd || '',
e.numTurns || '',
]);
const csv = [headers.join(','), ...rows.map(r => r.map(v => `"${String(v).replace(/"/g, '""')}"`).join(','))].join('\n');
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename=executions_${new Date().toISOString().split('T')[0]}.csv`);
res.send('\uFEFF' + csv);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
router.get('/stats/charts', async (req, res) => {
try {
const days = parseInt(req.query.days) || 7;
const executions = executionsStore.getAll();
const now = new Date();
const labels = [];
const executionCounts = [];
const costData = [];
const successCounts = [];
const errorCounts = [];
for (let i = days - 1; i >= 0; i--) {
const date = new Date(now);
date.setDate(date.getDate() - i);
const dateStr = date.toISOString().split('T')[0];
labels.push(dateStr);
const dayExecs = executions.filter(e => e.startedAt && e.startedAt.startsWith(dateStr));
executionCounts.push(dayExecs.length);
costData.push(+(dayExecs.reduce((sum, e) => sum + (e.costUsd || e.totalCostUsd || 0), 0)).toFixed(4));
successCounts.push(dayExecs.filter(e => e.status === 'completed').length);
errorCounts.push(dayExecs.filter(e => e.status === 'error').length);
}
const agentCounts = {};
executions.forEach(e => {
if (e.agentName) agentCounts[e.agentName] = (agentCounts[e.agentName] || 0) + 1;
});
const topAgents = Object.entries(agentCounts)
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(([name, count]) => ({ name, count }));
const statusDist = {};
executions.forEach(e => { statusDist[e.status] = (statusDist[e.status] || 0) + 1; });
res.json({ labels, executionCounts, costData, successCounts, errorCounts, topAgents, statusDistribution: statusDist });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
router.get('/notifications', async (req, res) => {
try {
const notifications = notificationsStore.getAll();
const unreadCount = notifications.filter(n => !n.read).length;
res.json({ notifications: notifications.slice(-50).reverse(), unreadCount });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
router.post('/notifications/:id/read', (req, res) => {
try {
const updated = notificationsStore.update(req.params.id, { read: true });
if (!updated) return res.status(404).json({ error: 'Notificação não encontrada' });
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
router.post('/notifications/read-all', (req, res) => {
try {
const notifications = notificationsStore.getAll();
for (const n of notifications) {
if (!n.read) notificationsStore.update(n.id, { read: true });
}
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
router.delete('/notifications', async (req, res) => {
try {
notificationsStore.save([]);
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
router.get('/reports', (req, res) => {
try {
if (!existsSync(REPORTS_DIR)) return res.json([]);
const files = readdirSync(REPORTS_DIR)
.filter(f => f.endsWith('.md'))
.sort((a, b) => b.localeCompare(a))
.slice(0, 100);
res.json(files);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.get('/reports/:filename', (req, res) => {
try {
const filename = req.params.filename.replace(/[^a-zA-Z0-9À-ÿ_.\-]/g, '');
if (!filename.endsWith('.md')) return res.status(400).json({ error: 'Formato inválido' });
const filepath = join(REPORTS_DIR, filename);
const resolved = pathResolve(filepath);
if (!resolved.startsWith(pathResolve(REPORTS_DIR))) {
return res.status(400).json({ error: 'Caminho inválido' });
}
if (!existsSync(filepath)) return res.status(404).json({ error: 'Relatório não encontrado' });
const content = readFileSync(filepath, 'utf-8');
res.json({ filename, content });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.delete('/reports/:filename', (req, res) => {
try {
const filename = req.params.filename.replace(/[^a-zA-Z0-9À-ÿ_.\-]/g, '');
const filepath = join(REPORTS_DIR, filename);
const resolved = pathResolve(filepath);
if (!resolved.startsWith(pathResolve(REPORTS_DIR))) {
return res.status(400).json({ error: 'Caminho inválido' });
}
if (!existsSync(filepath)) return res.status(404).json({ error: 'Relatório não encontrado' });
unlinkSync(filepath);
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
const PROJECTS_DIR = '/home/projetos';
function resolveProjectPath(requestedPath) {
const decoded = decodeURIComponent(requestedPath || '');
const resolved = pathResolve(PROJECTS_DIR, decoded);
if (!resolved.startsWith(PROJECTS_DIR)) return null;
return resolved;
}
router.get('/files', (req, res) => {
try {
const targetPath = resolveProjectPath(req.query.path || '');
if (!targetPath) return res.status(400).json({ error: 'Caminho inválido' });
if (!existsSync(targetPath)) return res.status(404).json({ error: 'Diretório não encontrado' });
const stat = statSync(targetPath);
if (!stat.isDirectory()) return res.status(400).json({ error: 'Caminho não é um diretório' });
const entries = readdirSync(targetPath, { withFileTypes: true })
.filter(e => !e.name.startsWith('.'))
.map(entry => {
const fullPath = join(targetPath, entry.name);
try {
const s = statSync(fullPath);
return {
name: entry.name,
type: entry.isDirectory() ? 'directory' : 'file',
size: entry.isDirectory() ? null : s.size,
modified: s.mtime.toISOString(),
extension: entry.isDirectory() ? null : extname(entry.name).slice(1).toLowerCase(),
};
} catch {
return null;
}
})
.filter(Boolean)
.sort((a, b) => {
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
return a.name.localeCompare(b.name);
});
const relativePath = relative(PROJECTS_DIR, targetPath) || '';
res.json({
path: relativePath,
parent: relativePath ? dirname(relativePath) : null,
entries,
});
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.get('/files/download', (req, res) => {
try {
const targetPath = resolveProjectPath(req.query.path || '');
if (!targetPath) return res.status(400).json({ error: 'Caminho inválido' });
if (!existsSync(targetPath)) return res.status(404).json({ error: 'Arquivo não encontrado' });
const stat = statSync(targetPath);
if (!stat.isFile()) return res.status(400).json({ error: 'Caminho não é um arquivo' });
const filename = basename(targetPath);
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(filename)}"`);
res.setHeader('Content-Length', stat.size);
createReadStream(targetPath).pipe(res);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.get('/files/download-folder', (req, res) => {
try {
const targetPath = resolveProjectPath(req.query.path || '');
if (!targetPath) return res.status(400).json({ error: 'Caminho inválido' });
if (!existsSync(targetPath)) return res.status(404).json({ error: 'Pasta não encontrada' });
const stat = statSync(targetPath);
if (!stat.isDirectory()) return res.status(400).json({ error: 'Caminho não é uma pasta' });
const folderName = basename(targetPath) || 'projetos';
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(folderName)}.tar.gz"`);
res.setHeader('Content-Type', 'application/gzip');
const parentDir = dirname(targetPath);
const dirName = basename(targetPath);
const tar = spawnProcess('tar', ['-czf', '-', '-C', parentDir, dirName]);
tar.stdout.pipe(res);
tar.stderr.on('data', () => {});
tar.on('error', (err) => {
if (!res.headersSent) res.status(500).json({ error: err.message });
});
req.on('close', () => { try { tar.kill(); } catch {} });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.delete('/files', (req, res) => {
try {
const targetPath = resolveProjectPath(req.query.path || '');
if (!targetPath) return res.status(400).json({ error: 'Caminho inválido' });
if (targetPath === PROJECTS_DIR) return res.status(400).json({ error: 'Não é permitido excluir o diretório raiz' });
if (!existsSync(targetPath)) return res.status(404).json({ error: 'Arquivo ou pasta não encontrado' });
const stat = statSync(targetPath);
if (stat.isDirectory()) {
rmSync(targetPath, { recursive: true, force: true });
} else {
unlinkSync(targetPath);
}
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.get('/repos', async (req, res) => {
try {
const repos = await gitIntegration.listRepos();
res.json(repos);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.get('/repos/:name/branches', async (req, res) => {
try {
const branches = await gitIntegration.listBranches(req.params.name);
res.json(branches);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.post('/files/publish', async (req, res) => {
const { path: projectPath } = req.body;
if (!projectPath) return res.status(400).json({ error: 'path é obrigatório' });
const targetPath = resolveProjectPath(projectPath);
if (!targetPath) return res.status(400).json({ error: 'Caminho inválido' });
if (!existsSync(targetPath)) return res.status(404).json({ error: 'Projeto não encontrado' });
if (!statSync(targetPath).isDirectory()) return res.status(400).json({ error: 'Caminho não é uma pasta' });
const projectName = basename(targetPath).toLowerCase().replace(/[^a-z0-9-]/g, '-');
const GITEA_URL = process.env.GITEA_URL || 'http://gitea:3000';
const GITEA_USER = process.env.GITEA_USER || 'fred';
const GITEA_PASS = process.env.GITEA_PASS || '';
const DOMAIN = process.env.DOMAIN || 'nitro-cloud.duckdns.org';
const VPS_COMPOSE_DIR = process.env.VPS_COMPOSE_DIR || '/vps';
if (!GITEA_PASS) return res.status(500).json({ error: 'GITEA_PASS não configurado no servidor' });
const exec = (cmd, opts = {}) => new Promise((resolve, reject) => {
const proc = spawnProcess('sh', ['-c', cmd], { cwd: opts.cwd || targetPath, env: { ...process.env, HOME: '/tmp', GIT_TERMINAL_PROMPT: '0' } });
let stdout = '', stderr = '';
proc.stdout.on('data', d => stdout += d);
proc.stderr.on('data', d => stderr += d);
proc.on('close', code => code === 0 ? resolve(stdout.trim()) : reject(new Error(stderr.trim() || `exit ${code}`)));
});
const steps = [];
try {
const authUrl = `${GITEA_URL.replace('://', `://${GITEA_USER}:${GITEA_PASS}@`)}`;
const repoApiUrl = `${GITEA_URL}/api/v1/repos/${GITEA_USER}/${projectName}`;
const createUrl = `${GITEA_URL}/api/v1/user/repos`;
const authHeader = 'Basic ' + Buffer.from(`${GITEA_USER}:${GITEA_PASS}`).toString('base64');
let repoExists = false;
try {
const check = await fetch(repoApiUrl, { headers: { Authorization: authHeader } });
repoExists = check.ok;
} catch {}
if (!repoExists) {
const createRes = await fetch(createUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: authHeader },
body: JSON.stringify({ name: projectName, auto_init: false, private: false }),
});
if (!createRes.ok) {
const err = await createRes.json().catch(() => ({}));
throw new Error(`Erro ao criar repositório: ${err.message || createRes.statusText}`);
}
steps.push('Repositório criado no Gitea');
} else {
steps.push('Repositório já existe no Gitea');
}
const repoUrl = `${authUrl}/${GITEA_USER}/${projectName}.git`;
const gitDir = `${targetPath}/.git`;
if (!existsSync(gitDir)) {
await exec('git init');
await exec(`git remote add origin "${repoUrl}"`);
steps.push('Git inicializado');
} else {
try {
await exec('git remote get-url origin');
await exec(`git remote set-url origin "${repoUrl}"`);
} catch {
await exec(`git remote add origin "${repoUrl}"`);
}
steps.push('Remote atualizado');
}
await exec('git add -A');
try {
await exec('git -c user.name="Agents Orchestrator" -c user.email="agents@nitro-cloud" commit -m "Publicação automática"');
steps.push('Commit criado');
} catch {
steps.push('Sem alterações para commit');
}
await exec('git push -u origin HEAD:main --force');
steps.push('Push realizado');
const caddyFile = `${VPS_COMPOSE_DIR}/caddy/Caddyfile`;
if (existsSync(caddyFile)) {
const caddyContent = readFileSync(caddyFile, 'utf-8');
const marker = `@${projectName} host ${projectName}.${DOMAIN}`;
if (!caddyContent.includes(marker)) {
const block = `\n @${projectName} host ${projectName}.${DOMAIN}\n handle @${projectName} {\n root * /srv/${projectName}\n file_server\n try_files {path} /index.html\n }\n`;
const updated = caddyContent.replace(
/(\n? {4}handle \{[\s\S]*?respond.*?200[\s\S]*?\})/,
block + '$1'
);
writeFileSync(caddyFile, updated);
steps.push('Caddyfile atualizado');
} else {
steps.push('Caddyfile já configurado');
}
}
const composePath = `${VPS_COMPOSE_DIR}/docker-compose.yml`;
if (existsSync(composePath)) {
const composeContent = readFileSync(composePath, 'utf-8');
const volumeLine = `/home/projetos/${basename(targetPath)}:/srv/${projectName}:ro`;
if (!composeContent.includes(volumeLine)) {
const updated = composeContent.replace(
/(- .\/caddy\/config:\/config)/,
`$1\n - ${volumeLine}`
);
writeFileSync(composePath, updated);
steps.push('Volume adicionado ao docker-compose');
} else {
steps.push('Volume já configurado');
}
}
try {
await exec(`docker compose -f ${VPS_COMPOSE_DIR}/docker-compose.yml up -d --force-recreate --no-deps caddy`, { cwd: VPS_COMPOSE_DIR });
steps.push('Caddy reiniciado');
} catch (e) {
steps.push(`Caddy: reinício manual necessário (${e.message})`);
}
const siteUrl = `https://${projectName}.${DOMAIN}`;
const repoWebUrl = `https://git.${DOMAIN}/${GITEA_USER}/${projectName}`;
res.json({
status: 'Publicado',
siteUrl,
repoUrl: repoWebUrl,
projectName,
steps,
message: `Acesse ${siteUrl} em alguns segundos`,
});
} catch (err) {
res.status(500).json({ error: err.message, steps });
}
});
export default router; export default router;

View File

@@ -1,4 +1,5 @@
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'; import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync } from 'fs';
import { writeFile, rename } from 'fs/promises';
import { dirname } from 'path'; import { dirname } from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
@@ -30,7 +31,16 @@ function readJson(path, fallback) {
function writeJson(path, data) { function writeJson(path, data) {
ensureDir(); ensureDir();
writeFileSync(path, JSON.stringify(data, null, 2), 'utf8'); const tmpPath = path + '.tmp';
writeFileSync(tmpPath, JSON.stringify(data, null, 2), 'utf8');
renameSync(tmpPath, path);
}
async function writeJsonAsync(path, data) {
ensureDir();
const tmpPath = path + '.tmp';
await writeFile(tmpPath, JSON.stringify(data, null, 2), 'utf8');
await rename(tmpPath, path);
} }
function clone(v) { function clone(v) {
@@ -41,6 +51,7 @@ function createStore(filePath) {
let mem = null; let mem = null;
let dirty = false; let dirty = false;
let timer = null; let timer = null;
let maxSize = Infinity;
function boot() { function boot() {
if (mem !== null) return; if (mem !== null) return;
@@ -54,7 +65,7 @@ function createStore(filePath) {
timer = setTimeout(() => { timer = setTimeout(() => {
timer = null; timer = null;
if (dirty) { if (dirty) {
writeJson(filePath, mem); writeJsonAsync(filePath, mem).catch((e) => console.error(`[db] Erro ao salvar ${filePath}:`, e.message));
dirty = false; dirty = false;
} }
}, DEBOUNCE_MS); }, DEBOUNCE_MS);
@@ -72,6 +83,20 @@ function createStore(filePath) {
return item ? clone(item) : null; return item ? clone(item) : null;
}, },
findById(id) {
return store.getById(id);
},
count() {
boot();
return mem.length;
},
filter(predicate) {
boot();
return mem.filter(predicate).map((item) => clone(item));
},
create(data) { create(data) {
boot(); boot();
const item = { const item = {
@@ -81,6 +106,9 @@ function createStore(filePath) {
updated_at: new Date().toISOString(), updated_at: new Date().toISOString(),
}; };
mem.push(item); mem.push(item);
if (maxSize !== Infinity && mem.length > maxSize) {
mem.splice(0, mem.length - maxSize);
}
touch(); touch();
return clone(item); return clone(item);
}, },
@@ -104,7 +132,8 @@ function createStore(filePath) {
}, },
save(items) { save(items) {
mem = Array.isArray(items) ? items : mem; if (!Array.isArray(items)) return;
mem = items;
touch(); touch();
}, },
@@ -118,6 +147,10 @@ function createStore(filePath) {
dirty = false; dirty = false;
} }
}, },
setMaxSize(n) {
maxSize = n;
},
}; };
allStores.push(store); allStores.push(store);
@@ -176,6 +209,21 @@ function createSettingsStore(filePath) {
return store; return store;
} }
const locks = new Map();
export async function withLock(key, fn) {
while (locks.has(key)) await locks.get(key);
let resolve;
const promise = new Promise((r) => { resolve = r; });
locks.set(key, promise);
try {
return await fn();
} finally {
locks.delete(key);
resolve();
}
}
export function flushAllStores() { export function flushAllStores() {
for (const s of allStores) s.flush(); for (const s of allStores) s.flush();
} }
@@ -185,5 +233,10 @@ export const tasksStore = createStore(`${DATA_DIR}/tasks.json`);
export const pipelinesStore = createStore(`${DATA_DIR}/pipelines.json`); export const pipelinesStore = createStore(`${DATA_DIR}/pipelines.json`);
export const schedulesStore = createStore(`${DATA_DIR}/schedules.json`); export const schedulesStore = createStore(`${DATA_DIR}/schedules.json`);
export const executionsStore = createStore(`${DATA_DIR}/executions.json`); export const executionsStore = createStore(`${DATA_DIR}/executions.json`);
executionsStore.setMaxSize(5000);
export const webhooksStore = createStore(`${DATA_DIR}/webhooks.json`); export const webhooksStore = createStore(`${DATA_DIR}/webhooks.json`);
export const settingsStore = createSettingsStore(`${DATA_DIR}/settings.json`); export const settingsStore = createSettingsStore(`${DATA_DIR}/settings.json`);
export const secretsStore = createStore(`${DATA_DIR}/secrets.json`);
export const notificationsStore = createStore(`${DATA_DIR}/notifications.json`);
notificationsStore.setMaxSize(200);
export const agentVersionsStore = createStore(`${DATA_DIR}/agent_versions.json`);